Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
fail-fast: false
matrix:
node-version:
- '22.15.0'
- '22.17.0'
- '24.11.1'
steps:
- name: Checkout
Expand Down
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@ CSS Modules hash class names after the loader extracts selectors, so the stylesh
<div className={`${styles['css-modules-badge']} css-modules-badge`}>
```

### Stable selector type generation

Run `npx knighted-css-generate-types --root .` to scan your project for `?knighted-css&types` imports. The CLI:

- extracts selectors via the loader, then writes literal module declarations into `node_modules/@knighted/css/node_modules/.knighted-css`
- updates the packaged stub at `node_modules/@knighted/css/types-stub/index.d.ts`
- exposes the declarations automatically because `types.d.ts` references the stub, so no `tsconfig` wiring is required

Re-run the command whenever imports change (add it to a `types:css` npm script or your build). If you need a different destination, pass `--out-dir` and/or `--types-root` to override the defaults.

Sass/Less projects can import the shared mixins directly:

```scss
Expand Down Expand Up @@ -238,15 +248,84 @@ Need a zero-JS approach? Import the optional layer helper and co-locate your fal

Override the namespace via `:root { --knighted-stable-namespace: 'acme'; }` if you want a different prefix in pure CSS.

#### Type-safe selector maps (`?knighted-css&types`)

Append `&types` to any loader import to receive a literal map of the discovered stable selectors alongside the raw CSS:

```ts
import { knightedCss, stableSelectors } from './styles.css?knighted-css&types'

stableSelectors.demo // "knighted-demo"
type StableSelectors = typeof stableSelectors
```

The map ships as `as const`, so every key/value pair is type-safe without additional tooling. Need the combined import? Add the flag there too and destructure everything from one place:

```ts
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import combined, { stableSelectors } from './button.js?knighted-css&combined&types'

const { knightedCss } = combined as KnightedCssCombinedModule<
typeof import('./button.js')
>

stableSelectors.demo // "knighted-demo"
```

Namespaces default to `knighted`, but you can configure a global fallback via the loader’s `stableNamespace` option:

```js
{
loader: '@knighted/css/loader',
options: {
stableNamespace: 'storybook',
},
}
```

All imports share the namespace resolved by the loader (or the `knighted-css-generate-types` CLI). Use the loader option or CLI flag to align runtime + type generation, and the loader still emits highlighted warnings when the namespace trims to an empty value or when no selectors match. For best editor support, keep `&types` at the end of the query (`?knighted-css&combined&types`, `?knighted-css&combined&named-only&types`, etc.).

#### TypeScript support for loader queries

Loader query types ship directly with `@knighted/css`. Reference them once in your project—either by adding `"types": ["@knighted/css/loader-queries"]` to `tsconfig.json` or dropping `/// <reference types="@knighted/css/loader-queries" />` into a global `.d.ts`—and the following ambient modules become available everywhere:

- `*?knighted-css` imports expose a `knightedCss: string` export.
- `*?knighted-css&combined` (and any query that includes both flags) expose `knightedCss` and return the original module exports, which you can narrow with `KnightedCssCombinedModule` before destructuring named members.
- `*?knighted-css&types` exposes both `knightedCss` and `stableSelectors`, the readonly selector map.
- `*?knighted-css&combined` (plus `&named-only` / `&no-default`) mirror the source module exports while adding `knightedCss`, which you can narrow with `KnightedCssCombinedModule` before destructuring named members.
- `*?knighted-css&combined&types` variants add the same `stableSelectors` map on top of the combined behavior so a single import can surface everything.

No vendor copies are necessary—the declarations live inside `@knighted/css`, you just need to point your TypeScript config at the shipped `loader-queries` subpath once.

#### Generate literal selector types

The runtime `stableSelectors` export is always a literal `as const` map, but TypeScript can only see those exact tokens if your project emits matching `.d.ts` files. Run the bundled CLI whenever you change a module that imports `?knighted-css&types` (or any `&combined&types` variants):

```bash
npx knighted-css-generate-types --root .
```

or wire it into `package.json` for local workflows:

```json
{
"scripts": {
"knighted:types": "knighted-css-generate-types --root . --include src"
}
}
```

The CLI scans every file you include (by default the project root, skipping `node_modules`, `dist`, etc.), finds imports containing `?knighted-css&types`, reuses the loader to extract CSS, and writes deterministic `.d.ts` files into `node_modules/.knighted-css/knt-*.d.ts`. It also maintains `node_modules/@knighted/css/types-stub/index.d.ts`, so TypeScript picks up the generated declarations automatically—no extra `typeRoots` configuration is required.

Key flags:

- `--root` / `-r` – project root (defaults to `process.cwd()`).
- `--include` / `-i` – additional directories or files to scan (repeatable).
- `--out-dir` – custom output folder for the generated `knt-*` declarations.
- `--types-root` – override the `@types` directory used for the aggregator.
- `--stable-namespace` – namespace prefix for the generated selector map.

Re-run the CLI (or add it to a pre-build hook) whenever selectors change so new tokens land in the literal declaration files.

#### Combined module + CSS import

If you prefer a single import that returns both your module exports and the compiled stylesheet, append `&combined` to the query. Then narrow the import once so TypeScript understands the shape:
Expand Down
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ coverage:
- packages/css/src
patch:
default:
target: 80.0
threshold: 5.0
paths:
- packages/css/src
61 changes: 56 additions & 5 deletions docs/combined-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ This document summarizes how `?knighted-css&combined` behaves for different modu

## Decision Matrix

| Source module exports | Recommended query | TypeScript import pattern | Notes |
| --------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Named exports only** | `?knighted-css&combined&named-only` | [Snippet](#named-exports-only) | `&named-only` disables the synthetic default export so you only destructure the original named members plus `knightedCss`. |
| **Default export only** | `?knighted-css&combined` | [Snippet](#default-export-only) | Loader mirrors the default export and adds `knightedCss`, so default-import code keeps working. |
| **Default + named exports** | `?knighted-css&combined` (append `&named-only` when you never consume the default) | [Snippet](#default-and-named-exports) | Without the flag you get both default + named exports; adding it drops the synthetic default for stricter codebases. |
| Source module exports | Recommended query | TypeScript import pattern | Notes |
| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Named exports only** | `?knighted-css&combined&named-only` | [Snippet](#named-exports-only) | `&named-only` disables the synthetic default export so you only destructure the original named members plus `knightedCss`. |
| **Default export only** | `?knighted-css&combined` | [Snippet](#default-export-only) | Loader mirrors the default export and adds `knightedCss`, so default-import code keeps working. |
| **Default + named exports** | `?knighted-css&combined` (append `&named-only` when you never consume the default) | [Snippet](#default-and-named-exports) | Without the flag you get both default + named exports; adding it drops the synthetic default for stricter codebases. |
| **Named exports + stable selector map** | `?knighted-css&combined&named-only&types` | [Snippet](#named-exports-with-stable-selectors) | Adds a `stableSelectors` named export; configure namespaces via the loader option or CLI flag. |
| **Default export only + stable selector map** | `?knighted-css&combined&types` | [Snippet](#default-export-with-stable-selectors) | Keep your default-import flow and add `stableSelectors`; namespaces come from loader/CLI configuration. |
| **Default + named exports + stable selectors** | `?knighted-css&combined&types` (append `&named-only` if you skip the default) | [Snippet](#default-and-named-exports-with-stable-selectors) | Best of both worlds—`stableSelectors` is exported alongside `knightedCss`; add `&named-only` if you don’t use the default. |

## Named exports only

Expand Down Expand Up @@ -50,8 +53,56 @@ const {

Prefer `?knighted-css&combined&named-only` plus the [named exports only](#named-exports-only) snippet when you intentionally avoid default exports but still need the named members and `knightedCss`.

## Named exports with stable selectors

```ts
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import combined, {
stableSelectors,
} from './module.js?knighted-css&combined&named-only&types'

const { Component, knightedCss } = combined as KnightedCssCombinedModule<
typeof import('./module.js')
>

stableSelectors.card // "knighted-card"
```

Need a different prefix? Configure the loader’s `stableNamespace` option (or pass `--stable-namespace` to the CLI) so both runtime and generated types stay in sync.

## Default export with stable selectors

```ts
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import combined, { stableSelectors } from './module.js?knighted-css&combined&types'

const { default: Component, knightedCss } = combined as KnightedCssCombinedModule<
typeof import('./module.js')
>

stableSelectors.badge // "knighted-badge"
```

## Default and named exports with stable selectors

```ts
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import combined, { stableSelectors } from './module.js?knighted-css&combined&types'

const {
default: Component,
helper,
knightedCss,
} = combined as KnightedCssCombinedModule<typeof import('./module.js')>

stableSelectors.card // "knighted-card" (or your configured namespace)
```

Append `&named-only` before `&types` when you want to drop the synthetic default export while still receiving `stableSelectors`.

## Key Takeaways

- The loader always injects `knightedCss` alongside the module’s exports.
- To avoid synthetic defaults (and TypeScript warnings) for modules that only expose named exports, add `&named-only` and use a namespace import.
- Namespace imports plus `KnightedCssCombinedModule<typeof import('./module')>` work universally; default imports are optional conveniences when the source module exposes a default you actually consume.
- Add `&types` when you also need the `stableSelectors` map. Configure the namespace globally (loader option or CLI flag) so runtime + generated types stay consistent.
Loading