You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Consumers should generate tokens from inside their build, without a pre-generation step and without a committed artifact. One unplugin plugin covers every bundler unplugin supports, exposes the generated CSS as a virtual module, and regenerates when the config or a token module it imports changes.
Problem
The CLI is the only integration path, and it works by writing files the bundler has to find before it starts:
Eight of the nine examples wire a pre-build task. example/vanilla-vue-css/moon.yml runs packages/cssforge/dist/cli.js --mode css --css ./.cssforge/output.css in cssforge-generate, and dev, build, and e2e depend on it. example/basic writes its own script instead, and packages/docs/moon.yml repeats the same CLI step for the docs site.
Consumers import the artifact by path, so it must exist before the bundler starts: @import "../.cssforge/output.css" layer(cssforge) (example/vanilla-vue-css/src/style.css:1), documented in packages/docs/guide/getting-started.md:161. A fresh clone, a plain vite build, or a deleted artifact fails until the pre-step runs.
cssforge --watch watches only the config path (packages/cssforge/src/cli.ts:183), so editing a token module that the config imports rebuilds nothing.
The cache-busting import (packages/cssforge/src/cli.ts:111) re-evaluates the config module but not its dependencies. Reproduced on Node 26.9.0: edit a token module the config imports, import the config again with a new query string, and the old value comes back.
Nothing drives the dev server, so a token edit reaches the browser only when the bundler happens to notice the regenerated file.
Approach
Add packages/unplugin, published to npm as @hebilicious/cssforge-unplugin. Keeping it out of @hebilicious/cssforge protects the JSR channel (#22) and Deno consumers from a runtime dependency on unplugin and its transitive packages (picomatch, @jridgewell/remapping, webpack-virtual-modules).
One package serves every bundler through subpath exports, following the unplugin convention (unplugin-vue-components, unplugin-auto-import, and unplugin-icons all ship ./vite, ./rollup, ./webpack, ./rspack, ./esbuild, ./rolldown, ./nuxt, and ./astro entries from a single package that depends on unplugin once). Each entry exports one adapter from the same createUnplugin instance: @hebilicious/cssforge-unplugin/vite, /rollup, /rolldown, /webpack, /rspack, /rsbuild, /esbuild, /farm, /bun, and /nuxt or /astro if those need a module wrapper. Bundler packages stay dev dependencies of this repository, never runtime or required peer dependencies, so a Vite consumer installs one package and nothing else. A target earns its own published package only when it cannot be expressed as a plugin, for example a Turbopack loader or a tool with its own runtime kit.
The plugin only adapts. Generation stays in the core: generateCSS, generateJSON, generateTS, generateStyleDictionaryJSON remain the single implementation, and no output mode is re-implemented in the plugin.
Virtual modules
virtual:cssforge.css returns the CSS from generateCSS. It resolves to a .css-suffixed virtual id so Vite's CSS pipeline compiles it.
virtual:cssforge/tokens would expose the cssForge object from generateTS as a typed module (see Decisions).
virtual:cssforge/json and virtual:cssforge/style-dictionary are optional thin wrappers for consumers piping tokens into other tools.
write defaults to false, so the CLI keeps owning files on disk and the plugin owns in-memory output. When write is set, the plugin writes the same bytes as the CLI from the same generators.
Config loading seam
Config loading moves into the core as one seam that the CLI and the plugin both call: loadConfig(path) returning { config, dependencies }. It records every local file resolved while importing the config with module.registerHooks (Node 22.15/23.5+; the package already requires Node >=24).
That replaces the ?t= cache busting and gives the CLI's watch() the dependency list it needs, which fixes the stale-dependency gap above for the existing CLI too. New core exports need explicit return types, because JSR rejects slow types (see #49).
Watch and reload
buildStart calls this.addWatchFile for the config and each dependency, and watchChange drops the cached generation. Vite gets real HMR through the vite: { handleHotUpdate } escape hatch unplugin exposes: invalidate the virtual CSS module and send the update so styles swap without a full page reload. esbuild and Bun have no watchChange hook in unplugin's hook table, so those regenerate on the next build.
// optional disk output, byte-identical to the CLIcssforge({config: "./cssforge.config.ts",write: {css: "./.cssforge/output.css",ts: "./.cssforge/output.ts"},});
Options: config (default ./cssforge.config.ts), write (default false), and prefix matching the CLI flag. A missing config or a generation failure fails the build with the same error type and token path the CLI reports (#47), never with an empty :root.
Decisions
Dependency approval (AGENTS.md requires it before adding dependencies): unplugin@3.4.0 as a runtime dependency of the new package, plus bundler packages as dev dependencies for adapter tests. The cheap path is vite and rollup for the Rollup-style adapter plus webpack for the native-loader adapter, relying on unplugin's own suite for the rest. Testing every adapter needs esbuild, @rspack/core, @rsbuild/core, @farmfe/core, and rolldown.
Package layout. Resolved: one package with per-bundler subpath exports. Nine published packages would mean nine version streams for one shared implementation, so a fix to the virtual modules or the config loader has to be released nine times, and consumers can end up with adapters on mismatched versions. The subpath layout keeps one version, one changeset, one install, and one Moon task set, and it matches what unplugin-based plugins in the ecosystem ship. Rejected alternative: @hebilicious/cssforge/bundler with unplugin as an optional peer dependency, which keeps one install too but puts an npm-only entry point inside the JSR-published package.
Typed tokens. Recommended for the first cut: ship virtual:cssforge.css only and keep the CLI .ts output as the typed entry point. Typing virtual:cssforge/tokens for an arbitrary config means writing an ambient declaration when write.ts is enabled and asking consumers to add that directory to tsconfig.include, because a static client.d.ts cannot type cssForge for an unknown config.
Acceptance criteria
@hebilicious/cssforge-unplugin resolves the virtual CSS module in a Vite build and in a Vite dev server with no .cssforge/output.css on disk and no pre-build CLI step.
For the same config, the CSS the plugin serves is byte-identical to generateCSS output, asserted through the existing generator seam rather than a second expectation.
A missing config or a name-validation failure fails the build with the CLI's error type and token path.
Adapter coverage is demonstrated by real builds for Vite, Rollup, and webpack, covering the Rollup-style hooks and the native-loader hooks; the remaining adapters stay documented as unplugin-provided.
A consumer with only vite installed resolves @hebilicious/cssforge-unplugin/vite from a packed tarball, with no bundler peer dependency required and no bundler package in the plugin's runtime dependencies.
Editing the config and editing a token module the config imports both regenerate in watch mode, including the second-change case that ?t= misses today.
In a Vite dev server, a token edit updates the styles; the test asserts the browser sees the new value, not that a specific HMR mechanism fired.
write produces files byte-identical to the CLI output for the same config.
cssforge --watch watches config dependencies through the shared loadConfig seam.
The new package has Moon targets for build, typecheck, test, format, and lint, a changeset, and an npm publish path wired into the release jobs in .github/workflows/release.yaml.
Goal
Consumers should generate tokens from inside their build, without a pre-generation step and without a committed artifact. One
unpluginplugin covers every bundler unplugin supports, exposes the generated CSS as a virtual module, and regenerates when the config or a token module it imports changes.Problem
The CLI is the only integration path, and it works by writing files the bundler has to find before it starts:
example/vanilla-vue-css/moon.ymlrunspackages/cssforge/dist/cli.js --mode css --css ./.cssforge/output.cssincssforge-generate, anddev,build, ande2edepend on it.example/basicwrites its own script instead, andpackages/docs/moon.ymlrepeats the same CLI step for the docs site.@import "../.cssforge/output.css" layer(cssforge)(example/vanilla-vue-css/src/style.css:1), documented inpackages/docs/guide/getting-started.md:161. A fresh clone, a plainvite build, or a deleted artifact fails until the pre-step runs.cssforge --watchwatches only the config path (packages/cssforge/src/cli.ts:183), so editing a token module that the config imports rebuilds nothing.packages/cssforge/src/cli.ts:111) re-evaluates the config module but not its dependencies. Reproduced on Node 26.9.0: edit a token module the config imports, import the config again with a new query string, and the old value comes back.Approach
Add
packages/unplugin, published to npm as@hebilicious/cssforge-unplugin. Keeping it out of@hebilicious/cssforgeprotects the JSR channel (#22) and Deno consumers from a runtime dependency on unplugin and its transitive packages (picomatch,@jridgewell/remapping,webpack-virtual-modules).One package serves every bundler through subpath exports, following the unplugin convention (
unplugin-vue-components,unplugin-auto-import, andunplugin-iconsall ship./vite,./rollup,./webpack,./rspack,./esbuild,./rolldown,./nuxt, and./astroentries from a single package that depends onunpluginonce). Each entry exports one adapter from the samecreateUnplugininstance:@hebilicious/cssforge-unplugin/vite,/rollup,/rolldown,/webpack,/rspack,/rsbuild,/esbuild,/farm,/bun, and/nuxtor/astroif those need a module wrapper. Bundler packages stay dev dependencies of this repository, never runtime or required peer dependencies, so a Vite consumer installs one package and nothing else. A target earns its own published package only when it cannot be expressed as a plugin, for example a Turbopack loader or a tool with its own runtime kit.The plugin only adapts. Generation stays in the core:
generateCSS,generateJSON,generateTS,generateStyleDictionaryJSONremain the single implementation, and no output mode is re-implemented in the plugin.Virtual modules
virtual:cssforge.cssreturns the CSS fromgenerateCSS. It resolves to a.css-suffixed virtual id so Vite's CSS pipeline compiles it.virtual:cssforge/tokenswould expose thecssForgeobject fromgenerateTSas a typed module (see Decisions).virtual:cssforge/jsonandvirtual:cssforge/style-dictionaryare optional thin wrappers for consumers piping tokens into other tools.writedefaults tofalse, so the CLI keeps owning files on disk and the plugin owns in-memory output. Whenwriteis set, the plugin writes the same bytes as the CLI from the same generators.Config loading seam
Config loading moves into the core as one seam that the CLI and the plugin both call:
loadConfig(path)returning{ config, dependencies }. It records every local file resolved while importing the config withmodule.registerHooks(Node 22.15/23.5+; the package already requires Node >=24).That replaces the
?t=cache busting and gives the CLI'swatch()the dependency list it needs, which fixes the stale-dependency gap above for the existing CLI too. New core exports need explicit return types, because JSR rejects slow types (see #49).Watch and reload
buildStartcallsthis.addWatchFilefor the config and each dependency, andwatchChangedrops the cached generation. Vite gets real HMR through thevite: { handleHotUpdate }escape hatch unplugin exposes: invalidate the virtual CSS module and send the update so styles swap without a full page reload. esbuild and Bun have nowatchChangehook in unplugin's hook table, so those regenerate on the next build./vitewatchChangehandleHotUpdate/rollup,/rolldownwatchChange/webpack,/rspack,/rsbuildwatchChange/farmwatchChange/esbuild,/bunInitial API direction
Options:
config(default./cssforge.config.ts),write(defaultfalse), andprefixmatching the CLI flag. A missing config or a generation failure fails the build with the same error type and token path the CLI reports (#47), never with an empty:root.Decisions
unplugin@3.4.0as a runtime dependency of the new package, plus bundler packages as dev dependencies for adapter tests. The cheap path isviteandrollupfor the Rollup-style adapter pluswebpackfor the native-loader adapter, relying on unplugin's own suite for the rest. Testing every adapter needsesbuild,@rspack/core,@rsbuild/core,@farmfe/core, androlldown.@hebilicious/cssforge/bundlerwith unplugin as an optional peer dependency, which keeps one install too but puts an npm-only entry point inside the JSR-published package.virtual:cssforge.cssonly and keep the CLI.tsoutput as the typed entry point. Typingvirtual:cssforge/tokensfor an arbitrary config means writing an ambient declaration whenwrite.tsis enabled and asking consumers to add that directory totsconfig.include, because a staticclient.d.tscannot typecssForgefor an unknown config.Acceptance criteria
@hebilicious/cssforge-unpluginresolves the virtual CSS module in a Vite build and in a Vite dev server with no.cssforge/output.csson disk and no pre-build CLI step.generateCSSoutput, asserted through the existing generator seam rather than a second expectation.viteinstalled resolves@hebilicious/cssforge-unplugin/vitefrom a packed tarball, with no bundler peer dependency required and no bundler package in the plugin's runtime dependencies.?t=misses today.writeproduces files byte-identical to the CLI output for the same config.cssforge --watchwatches config dependencies through the sharedloadConfigseam..github/workflows/release.yaml.example/vanilla-vue-cssmoves to the plugin, drops itscssforge-generatedependency, and keeps its Playwright suite green as end-to-end evidence, with CI selecting that suite (bug(ci): the JSR publish check and the browser suite do not gate CI #49).packages/docs/guide/documents the plugin, and any root README change goes throughmoon run cssforge:readme-update.Deferred
next.config's webpack hook can be a follow-up.