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: 0 additions & 2 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ jobs:
node-version: '24.11.1'
- name: Install Dependencies
run: npm ci
- name: Setup JSX WASM binding
run: npm run setup:jsx-wasm
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium webkit
- name: Run Playwright
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
node_modules/
dist/
dist-webpack/
dist-auto-stable/
coverage/
.c8/
.duel-cache/
Expand All @@ -11,3 +12,4 @@ playwright-report/
test-results/
blob-report/
.knighted-css/
.knighted-css-auto/
17 changes: 17 additions & 0 deletions docs/loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ export default {
> [!TIP]
> Sass-only aliases such as `pkg:#button` never hit Node resolution. Add a small shim resolver (see [docs/sass-import-aliases.md](./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:

```js
{
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.

### 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:
Expand Down
21 changes: 8 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test": "npm run test -w @knighted/css",
"pretest": "npm run build",
"test:e2e": "npm run test -w @knighted/css-playwright-fixture",
"pretest:e2e": "npm run setup:jsx-wasm && npm run build",
"pretest:e2e": "npm run build",
"lint": "oxlint packages",
"prettier": "prettier --write .",
"prettier:check": "prettier --check .",
Expand All @@ -25,8 +25,7 @@
"check-types": "npm run check-types -w @knighted/css && npm run check-types -w @knighted/css-playwright-fixture",
"clean:deps": "find . -name node_modules -type d -prune -exec rm -rf {} +",
"clean:dist": "find . -name dist -type d -prune -exec rm -rf {} +",
"clean": "npm run clean:deps && npm run clean:dist",
"setup:jsx-wasm": "npx @knighted/jsx init"
"clean": "npm run clean:deps && npm run clean:dist"
},
"devDependencies": {
"@emnapi/core": "^1.2.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ I needed a single source of truth for UI components that could drop into both li
- Resolution parity via [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver): tsconfig `paths`, package `exports` + `imports`, and extension aliasing (e.g., `.css.js` → `.css.ts`) are honored without wiring up a bundler.
- Compiles `*.css`, `*.scss`, `*.sass`, `*.less`, and `*.css.ts` (vanilla-extract) files out of the box.
- Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, media query optimizations, or specificity boosts.
- Deterministic selector duplication via `autoStable`: duplicate matching class selectors with a stable namespace (default `knighted-`) in both plain CSS and CSS Modules exports.
- Pluggable resolver/filter hooks for custom module resolution (e.g., Rspack/Vite/webpack aliases) or selective inclusion.
- First-class loader (`@knighted/css/loader`) so bundlers can import compiled CSS alongside their modules via `?knighted-css`.
- Built-in type generation CLI (`knighted-css-generate-types`) that emits `.knighted-css.*` selector manifests so TypeScript gets literal tokens in lockstep with the loader exports.
Expand Down Expand Up @@ -68,6 +69,13 @@ type CssOptions = {
extensions?: string[] // customize file extensions to scan
cwd?: string // working directory (defaults to process.cwd())
filter?: (filePath: string) => boolean
autoStable?:
| boolean
| {
namespace?: string
include?: RegExp
exclude?: RegExp
}
lightningcss?: boolean | LightningTransformOptions
specificityBoost?: {
visitor?: LightningTransformOptions<never>['visitor']
Expand Down
2 changes: 1 addition & 1 deletion packages/css/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/css",
"version": "1.0.10",
"version": "1.1.0-rc.0",
"description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.",
"type": "module",
"main": "./dist/css.js",
Expand Down
172 changes: 172 additions & 0 deletions packages/css/src/autoStableSelectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { LightningVisitor } from './helpers.js'
import { serializeSelector } from './helpers.js'
import { stableClass } from './stableSelectors.js'

export interface AutoStableConfig {
namespace?: string
include?: RegExp
exclude?: RegExp
}

export type AutoStableOption = boolean | AutoStableConfig
export type AutoStableVisitor = LightningVisitor

type SelectorNode = {
type: string
value?: string
name?: string
kind?: string
selectors?: Selector | Selector[] | null
[key: string]: unknown
}

type Selector = SelectorNode[]
type TransformResult = { selector: Selector; changed: boolean }

type RuleWithSelectors = {
selectors?: unknown
value?: {
selectors?: unknown
}
}

function isSelectorList(value: unknown): value is Selector[] {
return Array.isArray(value)
}

function isRuleWithSelectors(rule: unknown): rule is RuleWithSelectors {
return typeof rule === 'object' && rule !== null
}

function hasSelectorList(
rule: RuleWithSelectors,
): rule is RuleWithSelectors & { selectors: Selector[] } {
return isSelectorList(rule.selectors)
}

function hasValueSelectorList(
rule: RuleWithSelectors,
): rule is RuleWithSelectors & { value: { selectors: Selector[] } } {
return isSelectorList(rule.value?.selectors)
}

function getSelectors(rule: RuleWithSelectors | undefined): Selector[] | undefined {
if (rule && hasSelectorList(rule)) return rule.selectors
if (rule && hasValueSelectorList(rule)) return rule.value.selectors
return undefined
}

function setSelectors<T extends RuleWithSelectors>(rule: T, selectors: Selector[]): T {
if (hasSelectorList(rule)) {
rule.selectors = selectors
return rule
}
if (hasValueSelectorList(rule)) {
rule.value.selectors = selectors
return rule
}
return rule
}

export function normalizeAutoStableOption(option?: AutoStableOption) {
if (!option) return undefined
if (option === true) return {}
return option
}

export function buildAutoStableVisitor(option?: AutoStableOption) {
const config = normalizeAutoStableOption(option)
if (!config) return undefined

const visitor: LightningVisitor = {
Rule: {
style(rule) {
if (!isRuleWithSelectors(rule)) return rule
const baseSelectors = getSelectors(rule)
if (!baseSelectors) return rule
const seen = new Set(baseSelectors.map(sel => serializeSelector(sel)))
const augmented: typeof baseSelectors = [...baseSelectors]

for (const selector of baseSelectors) {
const { selector: stableSelector, changed } = transformSelector(
selector,
config,
)
if (!changed) continue
const key = serializeSelector(stableSelector)
if (seen.has(key)) continue
seen.add(key)
augmented.push(stableSelector)
}

return setSelectors(rule, augmented)
},
},
}

return visitor
}

function transformSelector(
selector: Selector,
config: AutoStableConfig,
): TransformResult {
let changed = false
const next = selector.map(node => transformNode(node, config, () => (changed = true)))
return { selector: next, changed }
}

function transformNode(
node: SelectorNode,
config: AutoStableConfig,
markChanged: () => void,
) {
if (!node || typeof node !== 'object') return node

// Respect :global(...) scopes by leaving them untouched.
if (node.type === 'pseudo-class' && node.kind === 'global') {
return node
}

if (node.type === 'class') {
const value = node.value ?? node.name ?? ''
if (!shouldTransform(value, config)) {
return node
}
const stable = stableClass(value, { namespace: config.namespace })
if (!stable || stable === value) {
return node
}
markChanged()
return { ...node, value: stable, name: stable }
}

if (hasSelectors(node)) {
const nestedSelectors = node.selectors.map(sel => {
const nested = transformSelector(sel, config)
if (nested.changed) {
markChanged()
}
return nested.selector
})
return { ...node, selectors: nestedSelectors }
}

return node
}

function hasSelectors(
node: SelectorNode,
): node is SelectorNode & { selectors: Selector[] } {
return Array.isArray(node.selectors)
}

function shouldTransform(token: string, config: AutoStableConfig) {
if (config.exclude && config.exclude.test(token)) {
return false
}
if (config.include && !config.include.test(token)) {
return false
}
return true
}
Loading