Skip to content

Commit 0726c2a

Browse files
feat: auto stable selectors. (#54)
1 parent f8cf325 commit 0726c2a

31 files changed

Lines changed: 1261 additions & 58 deletions

.github/workflows/playwright.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ jobs:
2121
node-version: '24.11.1'
2222
- name: Install Dependencies
2323
run: npm ci
24-
- name: Setup JSX WASM binding
25-
run: npm run setup:jsx-wasm
2624
- name: Install Playwright Browsers
2725
run: npx playwright install --with-deps chromium webkit
2826
- name: Run Playwright

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
node_modules/
33
dist/
44
dist-webpack/
5+
dist-auto-stable/
56
coverage/
67
.c8/
78
.duel-cache/
@@ -11,3 +12,4 @@ playwright-report/
1112
test-results/
1213
blob-report/
1314
.knighted-css/
15+
.knighted-css-auto/

docs/loader.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ export default {
4040
> [!TIP]
4141
> 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.
4242
43+
### Deterministic selectors (`autoStable`)
44+
45+
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:
46+
47+
```js
48+
{
49+
loader: '@knighted/css/loader',
50+
options: {
51+
autoStable: true, // or { namespace: 'myapp', include: /button|card/, exclude: /legacy/ }
52+
},
53+
}
54+
```
55+
56+
- Plain CSS: `.foo {}` becomes `.foo, .knighted-foo {}`.
57+
- CSS Modules: exports and generated class strings include both the hashed class and the stable class so you can reference either at runtime.
58+
- `autoStable` forces a LightningCSS pass; use `include`/`exclude` to scope which class tokens are duplicated.
59+
4360
### Combined imports
4461

4562
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:

package-lock.json

Lines changed: 8 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"test": "npm run test -w @knighted/css",
1717
"pretest": "npm run build",
1818
"test:e2e": "npm run test -w @knighted/css-playwright-fixture",
19-
"pretest:e2e": "npm run setup:jsx-wasm && npm run build",
19+
"pretest:e2e": "npm run build",
2020
"lint": "oxlint packages",
2121
"prettier": "prettier --write .",
2222
"prettier:check": "prettier --check .",
@@ -25,8 +25,7 @@
2525
"check-types": "npm run check-types -w @knighted/css && npm run check-types -w @knighted/css-playwright-fixture",
2626
"clean:deps": "find . -name node_modules -type d -prune -exec rm -rf {} +",
2727
"clean:dist": "find . -name dist -type d -prune -exec rm -rf {} +",
28-
"clean": "npm run clean:deps && npm run clean:dist",
29-
"setup:jsx-wasm": "npx @knighted/jsx init"
28+
"clean": "npm run clean:deps && npm run clean:dist"
3029
},
3130
"devDependencies": {
3231
"@emnapi/core": "^1.2.0",

packages/css/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ I needed a single source of truth for UI components that could drop into both li
2727
- 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.
2828
- Compiles `*.css`, `*.scss`, `*.sass`, `*.less`, and `*.css.ts` (vanilla-extract) files out of the box.
2929
- Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, media query optimizations, or specificity boosts.
30+
- Deterministic selector duplication via `autoStable`: duplicate matching class selectors with a stable namespace (default `knighted-`) in both plain CSS and CSS Modules exports.
3031
- Pluggable resolver/filter hooks for custom module resolution (e.g., Rspack/Vite/webpack aliases) or selective inclusion.
3132
- First-class loader (`@knighted/css/loader`) so bundlers can import compiled CSS alongside their modules via `?knighted-css`.
3233
- 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.
@@ -68,6 +69,13 @@ type CssOptions = {
6869
extensions?: string[] // customize file extensions to scan
6970
cwd?: string // working directory (defaults to process.cwd())
7071
filter?: (filePath: string) => boolean
72+
autoStable?:
73+
| boolean
74+
| {
75+
namespace?: string
76+
include?: RegExp
77+
exclude?: RegExp
78+
}
7179
lightningcss?: boolean | LightningTransformOptions
7280
specificityBoost?: {
7381
visitor?: LightningTransformOptions<never>['visitor']

packages/css/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/css",
3-
"version": "1.0.10",
3+
"version": "1.1.0-rc.0",
44
"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.",
55
"type": "module",
66
"main": "./dist/css.js",
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { LightningVisitor } from './helpers.js'
2+
import { serializeSelector } from './helpers.js'
3+
import { stableClass } from './stableSelectors.js'
4+
5+
export interface AutoStableConfig {
6+
namespace?: string
7+
include?: RegExp
8+
exclude?: RegExp
9+
}
10+
11+
export type AutoStableOption = boolean | AutoStableConfig
12+
export type AutoStableVisitor = LightningVisitor
13+
14+
type SelectorNode = {
15+
type: string
16+
value?: string
17+
name?: string
18+
kind?: string
19+
selectors?: Selector | Selector[] | null
20+
[key: string]: unknown
21+
}
22+
23+
type Selector = SelectorNode[]
24+
type TransformResult = { selector: Selector; changed: boolean }
25+
26+
type RuleWithSelectors = {
27+
selectors?: unknown
28+
value?: {
29+
selectors?: unknown
30+
}
31+
}
32+
33+
function isSelectorList(value: unknown): value is Selector[] {
34+
return Array.isArray(value)
35+
}
36+
37+
function isRuleWithSelectors(rule: unknown): rule is RuleWithSelectors {
38+
return typeof rule === 'object' && rule !== null
39+
}
40+
41+
function hasSelectorList(
42+
rule: RuleWithSelectors,
43+
): rule is RuleWithSelectors & { selectors: Selector[] } {
44+
return isSelectorList(rule.selectors)
45+
}
46+
47+
function hasValueSelectorList(
48+
rule: RuleWithSelectors,
49+
): rule is RuleWithSelectors & { value: { selectors: Selector[] } } {
50+
return isSelectorList(rule.value?.selectors)
51+
}
52+
53+
function getSelectors(rule: RuleWithSelectors | undefined): Selector[] | undefined {
54+
if (rule && hasSelectorList(rule)) return rule.selectors
55+
if (rule && hasValueSelectorList(rule)) return rule.value.selectors
56+
return undefined
57+
}
58+
59+
function setSelectors<T extends RuleWithSelectors>(rule: T, selectors: Selector[]): T {
60+
if (hasSelectorList(rule)) {
61+
rule.selectors = selectors
62+
return rule
63+
}
64+
if (hasValueSelectorList(rule)) {
65+
rule.value.selectors = selectors
66+
return rule
67+
}
68+
return rule
69+
}
70+
71+
export function normalizeAutoStableOption(option?: AutoStableOption) {
72+
if (!option) return undefined
73+
if (option === true) return {}
74+
return option
75+
}
76+
77+
export function buildAutoStableVisitor(option?: AutoStableOption) {
78+
const config = normalizeAutoStableOption(option)
79+
if (!config) return undefined
80+
81+
const visitor: LightningVisitor = {
82+
Rule: {
83+
style(rule) {
84+
if (!isRuleWithSelectors(rule)) return rule
85+
const baseSelectors = getSelectors(rule)
86+
if (!baseSelectors) return rule
87+
const seen = new Set(baseSelectors.map(sel => serializeSelector(sel)))
88+
const augmented: typeof baseSelectors = [...baseSelectors]
89+
90+
for (const selector of baseSelectors) {
91+
const { selector: stableSelector, changed } = transformSelector(
92+
selector,
93+
config,
94+
)
95+
if (!changed) continue
96+
const key = serializeSelector(stableSelector)
97+
if (seen.has(key)) continue
98+
seen.add(key)
99+
augmented.push(stableSelector)
100+
}
101+
102+
return setSelectors(rule, augmented)
103+
},
104+
},
105+
}
106+
107+
return visitor
108+
}
109+
110+
function transformSelector(
111+
selector: Selector,
112+
config: AutoStableConfig,
113+
): TransformResult {
114+
let changed = false
115+
const next = selector.map(node => transformNode(node, config, () => (changed = true)))
116+
return { selector: next, changed }
117+
}
118+
119+
function transformNode(
120+
node: SelectorNode,
121+
config: AutoStableConfig,
122+
markChanged: () => void,
123+
) {
124+
if (!node || typeof node !== 'object') return node
125+
126+
// Respect :global(...) scopes by leaving them untouched.
127+
if (node.type === 'pseudo-class' && node.kind === 'global') {
128+
return node
129+
}
130+
131+
if (node.type === 'class') {
132+
const value = node.value ?? node.name ?? ''
133+
if (!shouldTransform(value, config)) {
134+
return node
135+
}
136+
const stable = stableClass(value, { namespace: config.namespace })
137+
if (!stable || stable === value) {
138+
return node
139+
}
140+
markChanged()
141+
return { ...node, value: stable, name: stable }
142+
}
143+
144+
if (hasSelectors(node)) {
145+
const nestedSelectors = node.selectors.map(sel => {
146+
const nested = transformSelector(sel, config)
147+
if (nested.changed) {
148+
markChanged()
149+
}
150+
return nested.selector
151+
})
152+
return { ...node, selectors: nestedSelectors }
153+
}
154+
155+
return node
156+
}
157+
158+
function hasSelectors(
159+
node: SelectorNode,
160+
): node is SelectorNode & { selectors: Selector[] } {
161+
return Array.isArray(node.selectors)
162+
}
163+
164+
function shouldTransform(token: string, config: AutoStableConfig) {
165+
if (config.exclude && config.exclude.test(token)) {
166+
return false
167+
}
168+
if (config.include && !config.include.test(token)) {
169+
return false
170+
}
171+
return true
172+
}

0 commit comments

Comments
 (0)