Skip to content

Latest commit

 

History

History
281 lines (224 loc) · 12.3 KB

File metadata and controls

281 lines (224 loc) · 12.3 KB

Loader hook

@knighted/css/loader lets bundlers attach compiled CSS strings to any module by appending the ?knighted-css query when importing. The loader mirrors the module graph, compiles every CSS dialect it discovers (CSS, Sass, Less, vanilla-extract, etc.), and exposes the concatenated result as knightedCss.

Loader vs bridge (quick comparison)

Use this table to decide which loader you need before wiring up rules:

Capability @knighted/css/loader @knighted/css/loader-bridge
Input Original JS/TS module source + its style imports Compiled CSS Modules output (post-hash)
CSS extraction Yes (walks the import graph) No (wraps upstream output)
Export behavior Appends knightedCss (and optional selector exports) onto the original module Exposes knightedCss/knightedCssModules only; does not re-export JS/TS module exports
When to use Default choice for ?knighted-css in JS/TS modules When you need hashed CSS Modules output for runtime knightedCss
Combined wrapper needed? Only for explicit ?knighted-css&combined usage Yes if you still need original JS/TS exports (use &combined via the resolver plugin)

Loader example

import { knightedCss } from './button.js?knighted-css'

export const styles = knightedCss

Add a bundler rule that pipes ?knighted-css imports through @knighted/css/loader plus your transpiler of choice. See the main README for a complete rule configuration.

// rspack.config.js
export default {
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        resourceQuery: /knighted-css/,
        use: [
          {
            loader: '@knighted/css/loader',
            options: {
              lightningcss: { minify: true }, // all css() options supported
            },
          },
        ],
      },
    ],
  },
}

Choosing a type generation mode

knighted-css-generate-types supports two modes. Both are fully supported and tested; the right choice depends on how explicit you want the imports to be versus how much resolver automation you want to lean on:

  • --mode module (double-extension imports): use .knighted-css sidecar modules such as import stableSelectors from './button.css.knighted-css.js'. This keeps resolution explicit and tends to be the most stable under large, complex builds.
  • --mode declaration (idiomatic imports): emit .d.ts sidecars next to the original JS/TS module and keep clean imports like import { knightedCss } from './button.js'. This is cleaner at call sites, but it adds resolver work at build time and depends on the resolver plugin to stay in sync.

If you want the simplest, most transparent build behavior, start with --mode module. If you want cleaner imports and are comfortable with resolver automation, choose --mode declaration and enable strict sidecars + a manifest for safety.

Resolver plugin (declaration mode)

When you use knighted-css-generate-types --mode declaration, TypeScript expects the augmented exports to be present on the original JS/TS module. Use the resolver plugin to automatically append ?knighted-css for any module import that has a generated sidecar .d.ts file.

See docs/plugin.md for full resolver plugin documentation.

// rspack.config.js
import { knightedCssResolverPlugin } from '@knighted/css/plugin'

export default {
  resolve: {
    plugins: [knightedCssResolverPlugin()],
  },
}
// webpack.config.js
const { knightedCssResolverPlugin } = require('@knighted/css/plugin')

module.exports = {
  resolve: {
    plugins: [knightedCssResolverPlugin()],
  },
}

If you use declaration mode, consider enabling strict sidecar detection with a manifest. This ensures only .d.ts files generated by knighted-css-generate-types trigger rewrites:

import path from 'node:path'
import { knightedCssResolverPlugin } from '@knighted/css/plugin'

export default {
  resolve: {
    plugins: [
      knightedCssResolverPlugin({
        strictSidecar: true,
        manifestPath: path.resolve('.knighted-css/knighted-manifest.json'),
      }),
    ],
  },
}

Note

The loader shares the same auto-configured oxc-resolver as the standalone css() API, so hash-prefixed specifiers declared under package.json#imports (for example, #ui/button) resolve without additional options.

Tip

Sass-only aliases such as pkg:#button never hit Node resolution. Add a small shim resolver (see docs/sass-import-aliases.md) when you need to rewrite those specifiers before the loader runs.

Deterministic selectors (autoStable)

Pass autoStable to duplicate every matching class selector with a deterministic namespace (default knighted-). This runs without PostCSS and works for both plain CSS and CSS Modules:

{
  loader: '@knighted/css/loader',
  options: {
    autoStable: true, // or { namespace: 'myapp', include: /button|card/, exclude: /legacy/ }
  },
}
  • Plain CSS: .foo {} becomes .foo, .knighted-foo {}.
  • CSS Modules: exports and generated class strings include both the hashed class and the stable class so you can reference either at runtime.
  • autoStable forces a LightningCSS pass; use include/exclude to scope which class tokens are duplicated.

Bridge loader (CSS Modules)

When you want knightedCss to reflect the hashed class names produced by your existing CSS Modules pipeline, use the companion loader @knighted/css/loader-bridge. It runs after your Sass/CSS modules loaders and simply wraps their output (no reprocessing).

The key distinction:

  • @knighted/css/loader works from source styles (pre-hash). It resolves imports, compiles CSS dialects, and emits knightedCss before any downstream CSS Modules hashing/compilation happens.
  • @knighted/css/loader-bridge works from compiled output (post-hash). It assumes your CSS Modules pipeline already ran and therefore must be chained after loaders like css-loader, sass-loader, or less-loader.
// rspack.config.js or webpack.config.js
export default {
  module: {
    rules: [
      {
        test: /\.module\.(css|scss|sass)$/,
        oneOf: [
          {
            resourceQuery: /knighted-css/,
            use: [
              {
                loader: '@knighted/css/loader-bridge',
              },
              {
                loader: 'css-loader',
                options: {
                  exportType: 'string',
                  modules: true,
                },
              },
              'sass-loader',
            ],
          },
          {
            use: [
              {
                loader: 'css-loader',
                options: {
                  modules: true,
                },
              },
              'sass-loader',
            ],
          },
        ],
      },
    ],
  },
}
import { knightedCss, knightedCssModules } from './card.module.scss?knighted-css'

// knightedCss uses the same hashed selectors as the DOM
shadowRoot.adoptedStyleSheets[0].replaceSync(knightedCss)

// optional convenience mapping of the CSS module locals
console.log(knightedCssModules)

Note

The bridge loader does not generate stableSelectors. It simply re-exports the upstream output and resolves knightedCss by calling the upstream module’s toString() or using its default string export when present. For CSS Modules pipelines, configure the ?knighted-css branch to emit a string (for example, css-loader with exportType: 'string') so knightedCss is populated.

Combined imports

Need the component exports and the compiled CSS from a single import? Use ?knighted-css&combined and narrow the result with KnightedCssCombinedModule to keep TypeScript happy:

import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'
import * as buttonModule from './button.js?knighted-css&combined'

const { default: Button, knightedCss } =
  asKnightedCssCombinedModule<typeof import('./button.js')>(buttonModule)

// Need to describe additional loader-injected exports (for example, `stableSelectors` when
// using `?knighted-css&combined&types`)? Pass a second generic:
const {
  default: ButtonWithSelectors,
  knightedCss: buttonCss,
  stableSelectors,
} = asKnightedCssCombinedModule<
  typeof import('./button.js'),
  { stableSelectors: Record<string, string> }
>(buttonModule)

Append &named-only (alias: &no-default) if you never consume the default export. Refer to docs/combined-queries.md for the full matrix of query flags and destructuring patterns.

Tip

@knighted/css/loader-helpers ships the asKnightedCssCombinedModule helper in isolation so you can safely import it from browser bundles. This keeps the heavy loader implementation (and its Node dependencies) out of client builds.

Runtime selectors (&types)

When you need the runtime stableSelectors map alongside knightedCss, append &types to either the plain or combined import:

import { knightedCss, stableSelectors } from './card.js?knighted-css&types'

Note

TypeScript does not infer the stable selector literal types from this import; use the generated .knighted-css.* modules described in docs/type-generation.md for compile-time safety. The runtime map is helpful for tests, telemetry, or non-TypeScript environments.

vanilla-extract loader guidance

vanilla-extract files (*.css.ts) compile down to CommonJS by default. That works out of the box for the loader—both ?knighted-css and ?knighted-css&combined queries emit module.exports artifacts plus the injected knightedCss string. Most bundlers happily consume that shape. When you also need the compiled module to behave like a native ESM module (e.g., your bundler expects export statements so it can treeshake or when you import via extension aliases), enable the loader’s opt-in transform:

{
  test: /\.css\.ts$/,
  use: [
    {
      loader: '@knighted/css/loader',
      options: {
        lightningcss: { minify: true },
        vanilla: { transformToEsm: true },
      },
    },
  ],
}

The vanilla.transformToEsm flag runs a small post-pass that strips the CJS boilerplate emitted by @vanilla-extract/integration and re-exports the discovered bindings via native export { name } statements. That makes combined imports behave exactly like the source module, which is useful for frameworks that rely on strict ESM semantics (our Lit + React Playwright app is the canonical example in this repo).

Important

Only enable vanilla.transformToEsm when your bundler really requires ESM output. Leaving the transform off keeps the vanilla-extract module identical to what the upstream compiler produced, which is often preferable if the rest of your toolchain expects CommonJS. The loader no longer toggles this transform automatically—combined imports stay fast, but you remain in full control of when the conversion occurs.

If your build pipeline can gracefully consume both module syntaxes (for example, webpack or Rspack projects that treat the vanilla-extract integration bundle as CommonJS), you may get the desired behavior simply by forcing those files through the “auto” parser instead of rewriting them:

{
  test: /@vanilla-extract\/integration/,
  type: 'javascript/auto',
}

That hint keeps the upstream CommonJS helpers intact while still letting the rest of your app compile as native ESM. It’s worth trying first if you’d rather avoid the transform and your bundler already mixes module systems without issue. Flip vanilla.transformToEsm back on whenever you hit a toolchain that insists on pure ESM output.