domflax
v0.8.0
Published
Compile-time DOM flattener and semantic CSS compressor — fewer DOM nodes, smaller class sets, identical rendered UI.
Maintainers
Readme
domflax
Compile-time DOM flattener and semantic CSS compressor — fewer DOM nodes, smaller class sets, identical rendered UI.
domflax analyzes your JSX and HTML at build time and rewrites it to a smaller equivalent:
- Compress — a general engine rewrites each element's classes to the shortest set that produces the same computed style (
px-4 py-4 mt-2 mb-2→p-4 my-2,h-10 w-10→size-10,h-[40px] w-[40px]→size-[40px],hover:px-4 hover:py-4→hover:p-4). One algorithm across Tailwind v3/v4, custom CSS, and Bootstrap — no per-utility patterns — and it reaches the static parts ofcn()/clsx()/template classNames andstyle="…"too. - Flatten — removes wrapper elements that are provably inert (they add no layout and paint nothing).
Matching happens on computed styles, not raw class names — so the rules work across Tailwind, custom CSS, and (later) other providers, and a Tailwind class and an equivalent custom class compress the same way.
// before
<div className="contents">
<div className="px-4 py-4 mt-2 mb-2" onClick={save}>{title}</div>
</div>
// after — the inert wrapper is gone, classes are minimized, behavior is identical
<div className="p-4 my-2" onClick={save}>{title}</div>It rewrites only the static shape of your markup. Dynamic class lists (className={cn(...)}), components, and dangerouslySetInnerHTML are opaque and preserved; async/data-fetching code is untouched.
It slots in on top of the stack you already have:
- Works smoothly on top of React — a drop-in build-time transform of your
.jsx/.tsx; the rendered UI is identical, because domflax only applies provably-safe flattens and semantic class compression. - Works smoothly on top of shadcn/ui — it runs cleanly on shadcn/Tailwind-based apps, compressing Tailwind class sets and removing only provably-inert wrappers, without breaking the components.
- Non-conflicting dependency — domflax's heavy engines (
tailwindcss,postcss) are optional peer dependencies, loaded lazily from your project (never bundled or duplicated), so they never clash with your installed versions. It's a build-time-only devDependency; nothing is added to your shipped client bundle — except the optional, tinydomflax/runtime, and only if you explicitly use it.
Safety model — conservative by default, no browser involved.
- Compression is always safe. It only re-serializes an element's own class list, so a
ref, an event handler, a{dynamic}child, ordangerouslySetInnerHTMLnever blocks it — only a dynamic className (or a class a CSS selector depends on) is left alone. - Flattening is conservative. A wrapper is removed only when removal is provably render-neutral — it establishes no layout context and has no style to reproduce on its child. A
flex/gridcentering wrapper is removed only when its parent is staticallydisplay:grid(soplace-self:centeris provably equivalent — Chromium-verified); a flex/block/unknown parent leaves it preserved. It never drops a style it can't reproduce, and never touches a wrapper a CSS selector depends on (.list > .item h3). - domflax runs as a purely static source transform. It never launches a browser, so builds stay fast and deterministic.
No server, no SSR, no runtime on the page. domflax is a pure build-time transform. It needs no server, no SSR, and no runtime in the browser — it just rewrites your markup while you build. That means it works with fully client-side SPAs, static sites, and desktop shells like Tauri and Electron, exactly the same as with a server-rendered app.
Status: v0.8.0. Optimizes
.jsx/.tsx,.html,.astro, Vue.vue, and Svelte.svelteSFCs — component-return,.map()/expressions, and whole static-HTML sites — via Vite, webpack/Next, Rollup, esbuild, Rspack, and Turbopack (plus Astro-integration and Nuxt-module wrappers), plus the CLI. Compression is one general engine emitting the shortest class set that reproduces each element's computed style, across Tailwind v3/v4, custom CSS, and Bootstrap (re-resolved and verified identical before emitting) — reachingcn()/clsx()/template classNames, arbitrary values, variant groups, and inlinestyle↔class. 28 flatten + compress patterns. Flattening is a lean set of provably-safe patterns; an opt-in verified tier (--verified, static HTML) renders real pages to safely commit aggressive flattens — and it now covers context-dependent compression too (aw-fulla block already fills, arelativethat anchors nothing, a one-columngrid→flex) which commits only when a real render proves it identical. On React/Vite projects the CLI auto-detects your CSS by crawling the import graph, so you list no files. New in 0.5.0: an opt-in incremental build cache (--cache), opt-in CSS-side shrinking (--shrink-css, a Tailwind-aware safe PurgeCSS), and a benchmark suite (npm run bench). New in 0.6.0 (a partial feature release): live progress for long CLI runs (a per-file line on stderr so a bigaudit/optimizeno longer looks hung), usage-weighted audit metrics (npx domflax audit --weighted— two separate numbers: download bytes counted once + gzipped, and runtime DOM nodes weighted by each component's instantiation count, with a hard honesty rule that dynamic.map()lists are never multiplied by a guessed row count), and/regex/literals inpreserveClassesalongside globs. New in 0.7.0 (also a partial feature release): variant/breakpoint collapse (a provably-safe, default-on compression that drops a responsive/state variant when it already renders what the cascade produces —p-4 md:p-4→p-4,flex md:flex→flex,p-4 hover:p-4→p-4, while correctly keepingmd:p-4inp-4 sm:p-2 md:p-4), a shareable HTML run report (--report-html <file>— one self-contained page with totals, inline-SVG bar charts, and a per-file table that works without JS), and Astro-integration + Nuxt-module adapter wrappers (domflax.astroIntegration()/domflax.nuxtModule(), added manually to your config; the rest of the planned feature work — compress-depth, compiled-component optimization, and the 200-pattern set — is deferred, see the roadmap). New in 0.8.0 (the big-DOM performance release):domflax bench(domflax bench <dir>optimizes a built static site into a temp dir, serves both trees, and measures both in headless Chromium — median-of-N DOM nodes, transferred bytes, LCP, CLS, long-task time, and DCL/load, printed as a before | after | delta box or--json; playwright stays an optional peer with a friendly install hint), opt-inautoHideNonVisibleMaps(injectscontent-visibility: auto+contain-intrinsic-sizeinto.map()row templates so the browser skips layout/paint for offscreen rows — a render-cost win, not node removal; default off, with a conservative skip list), usage-weighted audit by default (--no-weightedopts out; the weighted pass is now multithreaded, and a new DOM-weight hotspots table ranks static components by nodes × instances and flags dynamic.map()row templates — never multiplied by a guessed row count), and the Svelte frontend (.svelteSFCs through the CLI and all adapters; any<style>block means whole-file passthrough — the same rule as Vue/Astro;svelteis an optional peer). Escape hatches when you need them:preserveClasses,no-flat. Tooling:domflax.config.js+ typed config,npx domflax init(detect + auto-configure),npx domflax audit(a 0–100 DOM-efficiency score + savings report that writes nothing),--analyze deep(searchable per-pattern report), memory-bounded parallel CLI, build-end summary, anddomflax/runtime. Default is static-only — never launches a browser during a build; a project it can't resolve is left untouched, never broken. APIs may change before 1.0.
See what domflax would do — no changes
The lowest-friction way to try it: audit your codebase. This runs the full optimization analysis but writes nothing, printing a boxed report — a 0–100 DOM-efficiency score, aggregate potential savings (nodes removable, classes compressible, bytes savable), and the worst files by savable bytes.
npx domflax audit # audit the current directory
npx domflax audit src/ # audit a specific folderNothing is installed or modified — it's a read-only scan. (The older npx domflax --audit <paths> flag still works; audit is the friendlier subcommand.)
Usage-weighted audit (the default; --no-weighted opts out)
audit runs the usage-weighted analysis by default (since 0.8.0). A file-by-file audit undercounts real impact: a shared component is instantiated many times, so removing one wrapper from its source removes that node from the live DOM once per mount. The weighted pass builds a component usage graph (parsing imports + JSX usage across the project) and reports two clearly separated numbers instead:
npx domflax audit # weighted analysis is the default
npx domflax audit --no-weighted # fast isolated-file mode (per-file savings only)download size −38 KB gzipped (counted once — bytes never multiply)
runtime DOM −11,400 nodes app-wide (usage-weighted — nodes × instances)The two numbers are never conflated: a component's markup lives once in the bundle, so download bytes are summed once and reported gzipped (the honest over-the-wire number — bytes do not multiply with reuse); but a wrapper removed from a component mounted N× is N fewer live DOM nodes, so DOM savings are weighted by each component's instantiation count.
The .map() honesty rule. A component rendered in a dynamic list (mapping over a prop, state, or fetched data of unknown length) is counted once and surfaced as a "scales with your data" note — domflax never multiplies by a guessed row count. Only a statically fixed-length array (a literal […], or a const bound to one) is multiplied by its provable length.
Weighted became the default because it stopped being slow: the pass is now multithreaded — pool workers gzip in-thread and return only byte counts, so the main process never holds file contents and the CLI's memory bound holds. --no-weighted keeps the fast isolated-file mode one flag away; --weighted is still accepted as a back-compat no-op, so existing scripts don't break.
DOM-weight hotspots
The weighted box now ends with a ranked hotspot table — where the app-wide DOM weight actually comes from, with a prescription per line:
DOM-weight hotspots
1. Button — 3 nodes × 928 = 2,784 app-wide → flatten/compress
2. Label — 2 nodes × 814 = 1,628 app-wide → flatten/compress
3. InvoiceRow — 14 nodes/row — rendered from InvoiceTable.tsx — × your data → virtualize / autoHideNonVisibleMaps
4. Card — 5 nodes × 212 = 1,060 app-wide → review
… 12 more not shown (--hotspots <n> widens)Static components are ranked by nodes × instances = app-wide nodes. Dynamic .map() row templates are listed as nodes per row with the file that renders them — they scale with your data, so they are never multiplied by a guessed row count (the same honesty rule as the totals); the prescription points at virtualization or autoHideNonVisibleMaps instead. --hotspots <n> widens the cap (default 8).
Install
npm install -D domflaxOne install, one package. pattern-kit and verify are subpaths of domflax — there are no separate packages to add.
Usage
Vite
// vite.config.ts
import { defineConfig } from 'vite'
import domflax from 'domflax'
export default defineConfig({
plugins: [domflax.vite({ provider: 'auto' })],
})Next.js (webpack)
// next.config.js
const domflax = require('domflax')
module.exports = {
webpack(config) {
domflax.webpack({ provider: 'tailwind' }).apply(config)
return config
},
}domflax runs as a source transform on your
.jsx/.tsxfiles via the bundler — it never touches a framework's shippedindex.html. This webpack adapter is the one specifically engineered and tested for Next.js.
Next.js (Turbopack)
next build --turbopack / next dev --turbopack is supported too. Turbopack accepts webpack loaders through next.config.js → turbopack.rules; the domflax.turbopackRules() helper wires domflax's loader in for you:
// next.config.js
const domflax = require('domflax')
module.exports = {
turbopack: { rules: domflax.turbopackRules({ provider: 'tailwind' }) },
}The loader is also exposed directly as domflax/webpack-loader if you prefer to wire the rule by hand. One honest caveat: under Turbopack the build-end summary box is not shown — Turbopack exposes no equivalent build-end hook — but the transform itself runs exactly as it does under webpack.
Rollup, esbuild & Rspack
Beyond Vite and webpack/Next, domflax ships adapters for Rollup, esbuild, and Rspack (one shared unplugin factory), with the same audit and build-end summary as the dedicated adapters:
// rollup.config.js
import domflax from 'domflax'
export default { plugins: [domflax.rollup({ provider: 'auto' })] }// esbuild
import * as esbuild from 'esbuild'
import domflax from 'domflax'
await esbuild.build({ plugins: [domflax.esbuild({ provider: 'auto' })], /* … */ })// rspack.config.js
const domflax = require('domflax')
module.exports = { plugins: [domflax.rspack({ provider: 'tailwind' })] }Vite and webpack/Next keep their existing dedicated adapters (the webpack one is tuned for Next.js); the unplugin factory adds the other bundlers.
At the end of the build the plugins print a one-box summary of what domflax did:
▲ domflax
────────────────────────────────
files optimized 42
DOM nodes removed 318
classes compressed 1,204
size saved 18.7 KB
────────────────────────────────Astro integration & Nuxt module
For Astro and Nuxt, domflax also ships framework-native wrappers around the same plugin, so you register it the way each framework expects rather than reaching for the raw Vite/webpack plugin:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import domflax from 'domflax'
export default defineConfig({
integrations: [domflax.astroIntegration({ provider: 'auto' })],
})// nuxt.config.ts
import domflax from 'domflax'
export default defineNuxtConfig({
// nuxtModule() returns an inline Nuxt module (options are baked in)
modules: [domflax.nuxtModule({ provider: 'auto' })],
})domflax.astroIntegration(options) injects domflax's Vite plugin through Astro's astro:config:setup hook; domflax.nuxtModule(options) adds the Vite plugin to nuxt.options (and the webpack plugin when Nuxt is on the webpack builder). Both are structurally typed and dependency-free, like the other adapters.
Two honest caveats: you add these to your config manually — a full astro add domflax registry integration is a follow-up — and, unlike the Vite/webpack adapters, they haven't yet been verified against a real Astro/Nuxt build locally.
HTML report (--report-html)
Pass --report-html <file> to write a single, self-contained HTML page summarizing the run — handy to share a before/after in a PR or with a teammate:
npx domflax ./src --out ./out --report-html report.htmlThe page shows the run totals (files, DOM nodes removed, classes compressed, bytes before/after, % saved), two inline-SVG bar charts (bytes before vs. after; the top files by bytes saved), and a per-file table. It's server-rendered — it works with JavaScript disabled (optional click-to-sort is a progressive enhancement), pulls in no external assets, and HTML-escapes every value.
Tailwind (auto-detected)
When tailwindcss is present, provider: 'auto' resolves classes through your project's real Tailwind engine — Tailwind v3 and v4 are both supported — and emits the shortest equivalent classes back. tailwindcss is an optional peer, loaded from your project only when used. A Tailwind version domflax can't resolve is left untouched (never broken).
Custom CSS files
No Tailwind? Point domflax at your stylesheets; it parses them (PostCSS) for forward (class → style) and reverse (style → class) resolution, and reads their selectors for safety.
domflax.vite({ provider: 'custom', cssFiles: ['./src/styles/main.css'] })Bootstrap
provider: 'bootstrap' loads Bootstrap's compiled stylesheet from your node_modules as the vocabulary (same engine as custom CSS). Wins are modest — Bootstrap's utilities aren't as systematic as Tailwind — so it's mostly inert-wrapper flatten, dedupe, and the real shorthand equivalences (mt-3 mb-3 → my-3); component classes stay untouched.
Astro, Vue & Svelte
domflax optimizes .astro, Vue .vue, and Svelte .svelte SFCs end-to-end — through both the CLI (npx domflax src/) and the Vite / webpack / unplugin adapters.
- Astro — only the template is optimized; the frontmatter (the
---fence) is preserved verbatim. Any.astrofile that contains a<style>block passes through unchanged (Astro styles are scoped, so touching the markup could shift a scoped selector). Nothing extra to install — parse5 is bundled. - Vue — only the
<template>block is optimized;<script>and<style>are preserved verbatim, and any SFC with a<style>block passes through unchanged. Vue parsing uses@vue/compiler-sfc, an optional peer already present in virtually every Vue project; if it's absent,.vuefiles simply pass through unchanged — never an error. - Svelte (new in 0.8.0) — only static markup is optimized;
<script>/<script module>are preserved verbatim by construction, and any component with a<style>block passes through unchanged (Svelte styles are scoped — the same rule as Vue and Astro). Every directive (bind:/on:/class:/use:/transition:/…), dynamic class, logic block ({#if}/{#each}/…), component tag,<svelte:*>element, and spread is opaque and preserved.svelte(^4 or ^5) is an optional peer loaded lazily from your project; if it's absent,.sveltefiles simply pass through unchanged — never an error.
Config & escape hatches
// domflax.config.js — shared, typed config for the CLI and the plugins
import { defineConfig } from 'domflax'
export default defineConfig({
provider: 'auto',
preserveClasses: [
'js-*', // glob — JS hooks queried by className
'sticky-header', // exact name
'/^grid-cols-\\d+$/', // regex — a class family a glob can't express
],
})preserveClasses entries are an allowlist domflax never rewrites or drops. Each entry can be an exact name, a * glob, or — new in 0.6.0 — a /pattern/flags regex literal (used as-is, user-anchored) for class families a glob can't express, e.g. /^grid-cols-\d+$/ or /^(bg|text)-brand-/. A malformed regex safely falls back to a literal (it never throws). Works everywhere preserveClasses does — CLI, plugins, and config.
Mark any subtree hands-off with a no-flat attribute (<div no-flat>…) — the element and everything inside it is left completely untouched.
domflax.vite({ provider: 'custom', cssFiles: ['./src/styles/main.css'] })autoHideNonVisibleMaps (opt-in) — big-list render cost
The big-DOM lever for data-heavy apps. When enabled, every .map() row-template root in your JSX/TSX gets contentVisibility: 'auto' + containIntrinsicSize (default 'auto 300px') surgically merged into its style — the browser then skips layout and paint for offscreen rows, so a 2,000-row table renders like a viewport's worth of rows.
// domflax.config.js (or inline plugin options)
export default defineConfig({
autoHideNonVisibleMaps: true, // default row-size estimate ('auto 300px')
// autoHideNonVisibleMaps: { intrinsicSize: 'auto 64px' }, // or your own row-height estimate
})This is render cost, not node count. The rows are not removed — the DOM node total is unchanged, because removing a data row would change what's rendered. A genuine 50,000 → 5,000 node reduction requires virtualization, which is application logic domflax must not write — the audit's hotspot table points you at the candidates instead. The injections are counted as a separate stylesInjected stat — never folded into nodesRemoved — and the added style bytes show honestly in the byte deltas.
Conservative by construction. The injection is skipped wherever it could change rendering: component or Fragment roots, table-internal tags (tr/td/th/…), rows with a dynamic or spread style/props, elements whose static style — or whose classes, resolved through your real CSS — already set content-visibility/contain or position: sticky/absolute/fixed, and anything inside no-flat/ignored subtrees. Unparseable input passes through byte-identical, and with the option absent the feature is provably inert. It works through the plugins and the CLI alike (on the CLI it's config-file only — there is no flag). Default off — like everything in domflax with a behavioral caveat, you opt in.
CLI
domflax also runs standalone — point it at a folder or files. Run it with no arguments for an interactive wizard.
npx domflax # interactive wizard (arrow keys, multiselect)
npx domflax audit ./src # read-only DOM-efficiency report, writes nothing
npx domflax ./src --dry-run # preview diffs, write nothing
npx domflax ./src --out ./domflax-out
npx domflax ./src --out ./out --cache # reuse cached results for unchanged files
npx domflax ./src --out ./out --shrink-css # also drop provably-dead CSS rules
npx domflax ./src --out ./out --report-html report.html # write a shareable HTML run reportIt optimizes .jsx/.tsx, .html/.htm, .astro, .vue, and .svelte files — pass a folder (auto-scanned) or a glob.
Source is never overwritten by default. Output goes to --out (or ./domflax-out), or in place only inside disposable build dirs (dist/, build/). Rewriting source in place requires the explicit --dangerously-overwrite-source flag and a clean git tree. The wizard never runs in CI / non-TTY.
| Flag | Description |
| --- | --- |
| <path> | Folder (auto-scanned) or glob of files. |
| --out <dir> | Write optimized output here (mirrors input structure). |
| --provider <name> | auto (default), tailwind, or custom. |
| --css <files...> | Global stylesheets (--provider custom); each .html page's own <link> imports are auto-detected on top. |
| --max-memory <MB> | Cap total RAM — and thus worker parallelism. Default ≈ 70% of free RAM; low values run slower but never OOM. |
| --concurrency <N> | Cap worker count (memory always wins). |
| --dry-run | Preview changes, write nothing. |
| --details | Print per-file optimization stats (nodes / classes / bytes). |
| --report-html <file> | Write a self-contained shareable HTML run report (totals, inline-SVG charts, per-file table; works without JS, no external assets). |
| --no-weighted | Audit only. Skip the usage-weighted pass (the default since 0.8.0) and run the fast isolated-file audit — per-file savings only, no usage graph, no hotspot table. |
| --weighted | Audit only. Back-compat no-op — the usage-weighted analysis (download bytes counted once + gzipped; runtime DOM nodes × instances; the .map() honesty rule) is now the default. |
| --hotspots <n> | Audit only. Rows shown in the weighted audit's DOM-weight hotspot table (default 8). |
| --cache | Reuse an on-disk incremental cache (node_modules/.cache/domflax); only re-optimize files whose content or resolved CSS/safety changed. Opt-in, off by default. |
| --shrink-css | After optimizing markup, remove provably-dead rules from the --css stylesheets into --out (safe Tailwind-aware PurgeCSS). Opt-in. |
| --dangerously-overwrite-source | Allow in-place source rewrite (needs clean git). |
HTML & static sites
domflax optimizes .html/.htm too (parse5), so you can run it over a built static site (dist/):
npx domflax ./dist --provider custom --out ./dist-optimized- Per-page CSS, automatically. Each HTML file resolves against the stylesheets it links (
<link rel="stylesheet">, relative + local) plus any global--css— so you usually don't select CSS at all, and selector-safety is accurate per page. - Centering actually flattens here. In HTML the parent is statically known, so a
grid-parent centering wrapper is provably removable (Chromium-verified) — real node removal, not just compression. - Big sites, safely parallel. Large batches run across CPU cores with a memory-bounded worker pool:
--max-memorycaps RAM (and parallelism); a bad or huge file fails just that file (reported), never crashing or OOM-ing the run. - Byte-for-byte outside edits — doctype, comments, whitespace, scripts, and attribute order are preserved; only changed
classvalues and unwrapped tags are touched.
Incremental cache (--cache)
Pass --cache to reuse a persistent, content-exact on-disk cache under node_modules/.cache/domflax, so a re-run only re-optimizes files that actually changed:
npx domflax ./src --out ./out --cache- Content-exact invalidation. An entry is reused only when the file's content and everything that could change its output — the resolved CSS / Tailwind vocabulary, the safety configuration, and
preserveClasses— are all identical to the cached run. Any difference recomputes. - Fail-safe. A cache miss, a corrupt entry, or an IO error simply recomputes from scratch — the cache can never serve a stale or wrong result.
- Parallel-safe & bounded. Atomic writes let the worker pool share one cache; it's size-bounded and evicts the oldest entries once past ~20k entries / 128 MB (once per run), so it can't grow unbounded.
- Opt-in, off by default. After a domflax upgrade that changes optimization logic, clear it once (
rm -rf node_modules/.cache/domflax).
CSS-side shrinking (--shrink-css)
--shrink-css adds a safe, Tailwind-aware PurgeCSS pass: after optimizing the markup it scans the emitted output — so domflax's own compression counts (px-4 py-4 → p-4 lets the now-unused px-4/py-4 rules drop) — plus any --content globs, and removes only provably-dead rules from the --css stylesheets, writing the shrunk CSS into --out.
npx domflax ./src --out ./out --shrink-css --css ./src/styles.css- Conservative by construction. Only class tokens gate removal; Tailwind escapes (
.hover\:px-4) are handled;:not()/:has()/:is(), attribute selectors, and@keyframesare always kept; it fails safe. - Standard PurgeCSS caveat. Classes built at runtime in JS (
classList.add(...),`text-${c}`) are invisible to static scanning — cover them with--contentglobs orpreserveClasses. - Best with the custom-CSS provider. Tailwind's own JIT already emits only the classes you use, so the biggest wins are on hand-written / non-JIT stylesheets.
Benchmarks (npm run bench)
npm run bench (scripts/bench.mjs) measures the built engine's throughput and savings — on generated fixtures, or on a real directory you point it at:
npm run bench # generated fixtures
node scripts/bench.mjs ./my-app/srcIt reports files/s, KB/s, % size saved, DOM nodes removed, and classes compressed.
Benchmark real pages (domflax bench)
Numbers from a real browser, not estimates. domflax bench <dir> takes a built static site, optimizes it into a temp directory via the real pipeline, serves both trees over local HTTP, and loads both in headless Chromium — so the before/after deltas are measured, not modeled:
npx domflax bench ./dist # optimize + measure: before vs. after
npx domflax bench ./dist --runs 5 # median of 5 runs per page (default 3)
npx domflax bench ./dist --page pricing.html # pick the page to measure
npx domflax bench --url https://example.com # measure one live URL (no before/after)Each metric is the median across N runs (--runs, default 3; a fresh browser context per run, so no cache or state bleeds between runs): DOM node count, transferred bytes, LCP, CLS, total long-task time, and DOMContentLoaded / load. Results print as a before | after | delta box, or as machine-readable output with --json. --page <relpath> picks the page — the default is index.html, else the first .html found (stated in the output). --url <url> measures a single live URL instead: one metrics box, no optimize step, no before/after.
Two honest notes:
- playwright stays an optional peer. Like
domflax/verify, bench lazy-loads playwright only when you run it; if it (or Chromium) is missing, bench prints an actionable hint (npm i -D playwright && npx playwright install chromium) and exits cleanly — no stack trace, and nothing about a normal install or build depends on it. - bench never writes into your project. The optimize step runs entirely inside bench's own temp directory, with the incremental cache and the experimental shared-class extraction force-disabled — so the measurement is of the default pipeline, and your files are untouched.
Writing a pattern
The shorthand compression is a general engine — there are no per-utility compress patterns (px-4 py-4→p-4 is the engine's job, not a pattern). Patterns handle flattening (removing wrapper nodes) and the structural/contextual compressions the engine can't reason about (a gap that draws no gutter on a single-child container, a w-full a block already fills). Each is a single declarative file whose definition and tests live in one definePattern call, auto-discovered, with no manual registration. A context-dependent compress that isn't statically provable sets verify: true — it then commits only under the --verified tier, exactly like a needs-verification flatten:
import { definePattern, not, hasDynamicClasses } from 'domflax/pattern-kit'
export default definePattern({
name: 'display-contents-wrapper',
category: 'flatten/wrapper/display-contents-wrapper',
safety: 2,
doc: { summary: 'A display:contents wrapper generates no box — unwrap it into its sole child.' },
match: {
tag: 'div',
style: { display: 'contents' },
onlyChild: 'element',
paintsNothing: true,
where: [not(hasDynamicClasses)],
},
rewrite: { flattenInto: 'child' },
test: {
cases: [{ before: '<div className="contents"><a className="text-blue-500">L</a></div>',
after: '<a className="text-blue-500">L</a>' }],
noMatch: ['<div className="contents" ref={r}><a>L</a></div>'],
},
})Drop the file under src/library/** as *.pattern.ts and it's auto-discovered. The generic harness runs its test cases through the real transform, plus an automatic invariant suite (purity, opacity-barrier safety, id-preservation, fixpoint termination). Every flatten/* pattern auto-receives the opacity + selector-safety guards, and the conservative safety gate only commits a removal it can prove is render-neutral — so a pattern can never produce unsafe output.
Advanced entry points
import { definePattern } from 'domflax/pattern-kit' // author custom patterns
import { verifyEquivalence } from 'domflax/verify' // optional, standalone equivalence checkerThe transform itself is static and never launches a browser. domflax/verify is a separate, opt-in tool that renders before/after in headless Chromium (via Playwright, an optional peer) and diffs pixels + box geometry + computed styles — handy for vetting patterns, not part of your build.
Examples
Runnable examples live in examples/: vite-react-tailwind, vite-custom-css (custom provider + selector-safety), next-tailwind, and static-html (CLI optimizing a plain .html page with per-page <link> CSS auto-detection).
Roadmap
Shipped through 0.8.0 (0.6.0 and 0.7.0 are both partial feature releases — see the table): the general compress engine (Tailwind v3/v4 + custom CSS + Bootstrap; cn()/template extraction; arbitrary-value + variant compression; inline-style↔class) · HTML + Astro + Vue + Svelte frontends, wired end-to-end through both the CLI and the plugins · grid-parent centering flatten and the opt-in verified tier (--verified), now covering context-dependent compression as well as flattens · 28 flatten + compress patterns · auto-CSS-detection (import-graph crawl — no manual --css) · domflax.config.js + typed config · npx domflax init onboarding · npx domflax audit score + --analyze deep searchable report + usage-weighted metrics (the default since 0.8.0; --no-weighted opts out) + the DOM-weight hotspots table · preserveClasses (globs, exact names, and /regex/ literals) / no-flat escape hatches · live progress for long CLI runs · a shareable HTML run report (--report-html) · domflax bench (real-Chromium before/after page metrics) · opt-in autoHideNonVisibleMaps (offscreen .map()-row render skipping) · domflax/runtime · memory-bounded parallel CLI + build-end summary · adapters for Vite, webpack/Next, Rollup, esbuild, Rspack, and Turbopack, plus Astro-integration + Nuxt-module wrappers · opt-in incremental cache (--cache) · opt-in CSS-side shrinking (--shrink-css) · benchmark suite (npm run bench).
Where it's going — each release adds the engine capability that unlocks its next validated pattern batch (full details in docs/ROADMAP.md):
| Version | Theme | Patterns |
| --- | --- | --- |
| 0.3.0 ✅ | The capability release (shipped) — the entire list above: extraction, arbitrary/variant compression, verified tier, Astro + Vue, Bootstrap, config, init, audit, analysis, escape hatches, runtime | 15 |
| 0.4.0 ✅ | The pattern + compress-verifier release (published) — +13 validated patterns mined from the saas-frontend + shadcn/HeroUI/aceternity/Radix corpora (structure + computed style, never library identity), plus the compress-side verification gate (context-dependent class rewrites commit only under --verified) and auto-CSS-detection (import-graph crawl). Honest yield: the engine already subsumes the shorthand/dedupe family, so the remaining compress volume now rides the verifier in ongoing 0.4.x batches | 28 → |
| 0.4.1 ✅ | The routing + relicense release (published) — the built-but-unrouted Astro / Vue frontends are now wired end-to-end through both the CLI and the Vite/webpack plugins (a .astro/.vue file was passed through untouched in 0.4.0); the friendlier npx domflax audit subcommand; relicensed to Apache-2.0; docs clarify domflax needs no server/SSR/runtime (SPA / static / Tauri) | 28 |
| 0.5.0 ✅ | The performance release (shipped) — unplugin adapters for Rollup / esbuild / Rspack (Vite + webpack/Next keep their dedicated adapters), Turbopack support via turbopack.rules, opt-in incremental build cache (--cache), opt-in safe CSS-side shrinking (--shrink-css), and a benchmark suite (npm run bench) | 28 |
| 0.6.0 🟡 | Feature release (partial — published) — shipped: live progress for long CLI runs, /regex/ literals in preserveClasses, and opt-in usage-weighted audit metrics (--weighted — download bytes once + gzipped vs. runtime DOM nodes × instances, with the hard .map() honesty rule). Deferred to 0.7: HTML report page, Astro integration + Nuxt module, compiled-component optimization, and the cascade-aware / whole-document compression family | 28 |
| 0.7.0 🟡 | Feature release (partial — published) — shipped: variant/breakpoint collapse (p-4 md:p-4→p-4, flex md:flex→flex, p-4 hover:p-4→p-4 — provably-safe, default-committing), a shareable HTML run report (--report-html), and Astro-integration + Nuxt-module wrappers (astroIntegration() / nuxtModule()). Still deferred: CSS-emitting shared-class extraction, compiled-component optimization, and the pattern-count work toward 200+ — the honest remaining road to 1.0 | 28 |
| 0.8.0 ✅ | The big-DOM performance release (shipped) — domflax bench (optimize a built site into a temp dir, serve both trees, measure both in headless Chromium: median-of-N DOM nodes, bytes, LCP, CLS, long tasks, DCL/load; --url for one live page; playwright optional), opt-in autoHideNonVisibleMaps (content-visibility: auto on .map() rows — render cost, not node count; default off), usage-weighted audit by default (now multithreaded; --no-weighted opts out) + the DOM-weight hotspots table, and the Svelte frontend (.svelte; any <style> ⇒ whole-file passthrough). Still deferred: shared-class extraction and compiled-component optimization continue as in-progress experimental opt-ins (not yet part of the documented, supported surface), plus the pattern-count work toward 200+ | 28 |
| 1.0.0 | Stable — frozen API, semver guarantees, docs site, published benchmarks | 200+ |
Every pattern that ships must uniquely fire on real code and be proven render-neutral (statically or via the verified tier) — the count grows from new capability surface, never from padding.
License
Apache-2.0 — a permissive, OSI-approved open-source license. Use it freely in commercial and open-source projects.
© Krishnesh Mishra
