tailwind-exit
Paste a Tailwind-soup component, get back semantic CSS with extracted repeated patterns.
For frontend devs who read Julia Evans' "Moving away from Tailwind" and decided today is the day. Paste HTML or JSX with Tailwind classes, pick a target style (BEM / CSS Modules / vanilla scoped), get back two panes: refactored markup with semantic class names + an accompanying stylesheet. Repeated full class-strings (≥2 elements) become clusters that Claude names semantically (card-hero, button-primary, nav-item) — values are resolved from Tailwind's real source, never LLM-guessed.
How it works
- Parse. HTML →
parse5. JSX →@babel/parser. AST is walked and every element'sclass/classNameis tokenized. - Cluster. Full class-strings that appear on ≥2 elements are grouped into clusters. Singletons pass through unchanged.
- Resolve. Each Tailwind token is looked up in a SQLite table mirrored from
tailwindlabs/tailwindcsssource (theme.css + utilities.ts). Arbitrary values (p-[12px],bg-[#fff]) are parsed inline. - Name. Clusters are sent to Claude with their structural context (tag, parent, children, text sample) — Claude returns kebab-case names. JSON-mode + one retry if parse fails. No fallback to hash names.
- Emit. CSS is rendered in the chosen style flavor; markup is rewritten with the new class names. Modules emits a
styles.module.cssimport. - Persist + permalink. Each conversion gets a 12-char slug, stored verbatim.
/share/:slugrenders a server-side OG-tagged page so Twitter / Bluesky unfurls show the line-saved badge.
Real data sources
| Source | URL | Refresh |
|---|---|---|
| Tailwind theme | https://raw.githubusercontent.com/tailwindlabs/tailwindcss/main/packages/tailwindcss/theme.css | Weekly cron (Mon 07:00 UTC) + cold-start sync if table is empty |
| Tailwind utilities source | https://raw.githubusercontent.com/tailwindlabs/tailwindcss/main/packages/tailwindcss/src/utilities.ts | Same cron |
| Tailwind preflight | https://raw.githubusercontent.com/tailwindlabs/tailwindcss/main/packages/tailwindcss/preflight.css | Same cron |
| Anthropic Claude (via OpenRouter) | https://openrouter.ai/api/v1/chat/completions | Per POST /api/convert |
On Tailwind sync failure: last-good rows in tailwind_classes are kept and lastSyncLog.error surfaces on /api/stats. Conversions still work against the previous snapshot.
On Claude failure: HTTP 502 {error: "naming_service_unavailable", retry_after: 30}. There is no hash-name fallback — semantic naming is the differentiator.
API
All endpoints mounted under /tailwind-exit.
| Method | Path | Description |
|---|---|---|
| GET | /health | {ok:true,service,version,at}. Used by uptime checks. |
| POST | /api/convert | Body: {input, style, inputType?, model?}. Returns refactored markup + CSS + cluster names + lines-saved math + share slug. style ∈ bem, modules, vanilla. inputType ∈ auto, html, jsx. model ∈ default (sonnet), fast (haiku). 429 on rate-limit; 400 on parse failure; 502 if Claude is unreachable. |
| GET | /api/share/:slug | Returns the stored conversion JSON. |
| GET | /share/:slug | Server-rendered HTML shell with OG meta tags filled from the row; client JS hydrates. |
| GET | /api/stats | {totalConversions, totalLinesSaved, topClusterNames, lastTailwindSync, classCount, lastSyncLog}. |
| GET | /api/classes/:name | Resolves a single Tailwind class to its CSS declarations (handles variants like hover:bg-blue-500 and arbitrary values like p-[12px]). 404 if unknown. |
| GET | /api/classes?q=&limit= | Search synced class names (debug). |
Rate limit
5 conversions per IP per UTC day. IP is SHA-256 hashed with a per-day salt before storage (no plaintext IPs persisted). No accounts. Limit resets at midnight UTC. 6th request returns 429 with count, limit, resetEpoch.
Local development
cp .env.example .env
# Add a real OPENROUTER_API_KEY (sk-or-v1-…) to .env or export it.
npm install
npm start # → http://localhost:4800/tailwind-exit/
```
On first start, if tailwind_classes is empty the server runs a cold-start sync from the Tailwind GitHub repo (~5 seconds, ~4300 classes). After that the weekly cron job keeps it fresh.
Smoke test:
curl localhost:4800/tailwind-exit/health
curl localhost:4800/tailwind-exit/api/classes/p-4
curl -s -X POST localhost:4800/tailwind-exit/api/convert \
-H 'content-type: application/json' \
-d '{"style":"bem","input":"<div class=\"rounded-lg border p-4 shadow\"><h2 class=\"text-lg font-semibold mb-2\">A</h2></div><div class=\"rounded-lg border p-4 shadow\"><h2 class=\"text-lg font-semibold mb-2\">B</h2></div>"}' | jq
Stack
- Node.js ≥22, Express 4, helmet, compression
better-sqlite3(WAL) for storageparse5(HTML) +@babel/parser(JSX) for ASTnode-cronfor the weekly Tailwind sync- Anthropic Claude via OpenRouter (
claude-sonnet-4.6default,claude-haiku-4.5for fast mode)
Out of scope
- Accounts, signup, OAuth — there is no auth layer of any kind, by design.
- Payment / subscriptions — although the original brief floated a paid tier, with no auth there's no premium flow.
- Repo upload, multi-file conversion, build-tool plugins.
- @apply / SCSS / styled-components / vanilla-extract output.
- Plugin class resolution (typography, forms) — unresolved classes pass through verbatim and are surfaced in
unresolvedClasses. - Mobile-optimized editing beyond the layout collapsing to one column.
License
MIT