Skip to content

feat(bundler): generate token output inside the build with a unplugin plugin #63

Description

@Hebilicious

Goal

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.

Target Entry Watch Dev reload
Vite, Nuxt, Astro, Vitest /vite watchChange handleHotUpdate
Rollup, Rolldown /rollup, /rolldown watchChange rebuild
webpack, Rspack, Rsbuild /webpack, /rspack, /rsbuild watchChange recompile
Farm /farm watchChange rebuild
esbuild, Bun /esbuild, /bun not available next build

Initial API direction

// vite.config.ts
import cssforge from "@hebilicious/cssforge-unplugin/vite";

export default defineConfig({
  plugins: [cssforge({ config: "./cssforge.config.ts" })],
});
// src/main.ts
import "virtual:cssforge.css";
// optional disk output, byte-identical to the CLI
cssforge({
  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

  1. 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.
  2. 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.
  3. 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.
  • example/vanilla-vue-css moves to the plugin, drops its cssforge-generate dependency, 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 through moon run cssforge:readme-update.

Deferred

  • Turbopack. Next.js has no unplugin adapter; mounting the webpack plugin through next.config's webpack hook can be a follow-up.
  • PostCSS and Tailwind configuration-level integration, which are different seams.
  • A JSR distribution channel for the plugin (chore(distribution): make npm the primary release channel and keep JSR as fallback #22 keeps npm primary).
  • Any change to generation behavior. This issue adds an integration path only.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions