npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

domflax

v0.8.0

Published

Compile-time DOM flattener and semantic CSS compressor — fewer DOM nodes, smaller class sets, identical rendered UI.

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:

  1. 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-2p-4 my-2, h-10 w-10size-10, h-[40px] w-[40px]size-[40px], hover:px-4 hover:py-4hover:p-4). One algorithm across Tailwind v3/v4, custom CSS, and Bootstrap — no per-utility patterns — and it reaches the static parts of cn()/clsx()/template classNames and style="…" too.
  2. 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, tiny domflax/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, or dangerouslySetInnerHTML never 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/grid centering wrapper is removed only when its parent is statically display:grid (so place-self:center is 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 .svelte SFCs — 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) — reaching cn()/clsx()/template classNames, arbitrary values, variant groups, and inline style↔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 (a w-full a block already fills, a relative that anchors nothing, a one-column gridflex) 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 big audit/optimize no 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 in preserveClasses alongside 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-4p-4, flex md:flexflex, p-4 hover:p-4p-4, while correctly keeping md:p-4 in p-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-in autoHideNonVisibleMaps (injects content-visibility: auto + contain-intrinsic-size into .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-weighted opts 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 (.svelte SFCs through the CLI and all adapters; any <style> block means whole-file passthrough — the same rule as Vue/Astro; svelte is 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, and domflax/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 folder

Nothing 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 domflax

One 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/.tsx files via the bundler — it never touches a framework's shipped index.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.jsturbopack.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.html

The 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-3my-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 .astro file 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, .vue files 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, .svelte files 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 report

It 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-memory caps 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 class values 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-4p-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 @keyframes are 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 --content globs or preserveClasses.
  • 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/src

It 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-4p-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 checker

The 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-4p-4, flex md:flexflex, p-4 hover:p-4p-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