Skip to content

Commit 7ee77cd

Browse files
feat: stable selector utils. (#18)
1 parent d67ec29 commit 7ee77cd

19 files changed

Lines changed: 574 additions & 7 deletions

README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,52 @@ The loader appends `export const knightedCss = "/* compiled css */"` to the modu
192192
193193
#### CSS Modules and stable selectors
194194
195-
CSS Modules hash class names after the loader extracts selectors, so the stylesheet captured by `?knighted-css` never sees those hashed tokens. Provide a second, stable selector (class or data attribute) alongside the module-generated one so both the DOM and the Lit host share a common hook. A minimal example:
195+
CSS Modules hash class names after the loader extracts selectors, so the stylesheet captured by `?knighted-css` never sees those hashed tokens. Provide a second, stable selector (class or data attribute) alongside the module-generated one so both the DOM and the loader share a common hook. A minimal example:
196196
197197
```tsx
198198
<div className={`${styles['css-modules-badge']} css-modules-badge`}>
199199
```
200200
201+
Sass/Less projects can import the shared mixins directly:
202+
203+
```scss
204+
@use '@knighted/css/stable' as knighted;
205+
206+
.button {
207+
@include knighted.stable('button') {
208+
// declarations duplicated for .button and .knighted-button
209+
}
210+
}
211+
```
212+
213+
Set `$knighted-stable-namespace: 'acme'` before the `@use` statement to change the default prefix, or override per call with `$namespace: 'storybook'`. Additional helpers let you emit only the fallback selector (`@include knighted.stable-only('token')`) or supply explicit `@at-root` selectors when nesting is inconvenient (`@include knighted.stable-at-root('.card', 'card')`).
214+
215+
For runtime usage (vanilla-extract, CSS Modules, JSX utilities), pull in the TypeScript helpers:
216+
217+
```ts
218+
import { stableClassName } from '@knighted/css/stableSelectors'
219+
220+
function Badge() {
221+
return <span className={stableClassName(styles, 'badge')} />
222+
}
223+
```
224+
225+
`stableClass('token')` returns a class name you can drop straight into `className`, and `createStableClassFactory({ namespace: 'docs' })` gives you a scoped generator to reuse across components. Need the literal CSS selector? Call `stableSelector('token')`. All helpers sanitize tokens automatically so the emitted hooks stay deterministic.
226+
227+
Need a zero-JS approach? Import the optional layer helper and co-locate your fallback selectors:
228+
229+
```css
230+
@import '@knighted/css/stable/stable.css';
231+
232+
@layer knighted.stable {
233+
.knighted-alert {
234+
/* declarations */
235+
}
236+
}
237+
```
238+
239+
Override the namespace via `:root { --knighted-stable-namespace: 'acme'; }` if you want a different prefix in pure CSS.
240+
201241
#### TypeScript support for loader queries
202242
203243
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:

package-lock.json

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

packages/css/package.json

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/css",
3-
"version": "1.0.0-rc.3",
3+
"version": "1.0.0-rc.4",
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",
@@ -32,6 +32,18 @@
3232
"./loader-queries": {
3333
"types": "./loader-queries.d.ts",
3434
"default": "./loader-queries.d.ts"
35+
},
36+
"./stableSelectors": {
37+
"types": "./dist/stableSelectors.d.ts",
38+
"import": "./dist/stableSelectors.js",
39+
"require": "./dist/cjs/stableSelectors.cjs"
40+
},
41+
"./stable": {
42+
"sass": "./stable/_index.scss",
43+
"default": "./stable/_index.scss"
44+
},
45+
"./stable/stable.css": {
46+
"default": "./stable/stable.css"
3547
}
3648
},
3749
"keywords": [
@@ -80,7 +92,8 @@
8092
"files": [
8193
"dist",
8294
"loader-queries.d.ts",
83-
"types.d.ts"
95+
"types.d.ts",
96+
"stable"
8497
],
8598
"author": "KCM <knightedcodemonkey@gmail.com>",
8699
"license": "MIT",

packages/css/src/css.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from 'node:path'
2-
import { promises as fs } from 'node:fs'
2+
import { existsSync, promises as fs } from 'node:fs'
33

44
import dependencyTree from 'dependency-tree'
55
import type { Options as DependencyTreeOpts } from 'dependency-tree'
@@ -228,10 +228,31 @@ async function compileSass(
228228
const sass = sassModule
229229
const result = sass.compile(filePath, {
230230
style: 'expanded',
231+
loadPaths: buildSassLoadPaths(filePath),
231232
})
232233
return result.css
233234
}
234235

236+
// Ensure Sass can resolve bare module specifiers by walking node_modules folders.
237+
function buildSassLoadPaths(filePath: string): string[] {
238+
const loadPaths = new Set<string>()
239+
let cursor = path.dirname(filePath)
240+
const root = path.parse(cursor).root
241+
242+
while (true) {
243+
loadPaths.add(cursor)
244+
loadPaths.add(path.join(cursor, 'node_modules'))
245+
if (cursor === root) break
246+
cursor = path.dirname(cursor)
247+
}
248+
249+
const cwd = process.cwd()
250+
loadPaths.add(cwd)
251+
loadPaths.add(path.join(cwd, 'node_modules'))
252+
253+
return Array.from(loadPaths).filter(dir => dir && existsSync(dir))
254+
}
255+
235256
async function compileLess(filePath: string, peerResolver?: PeerLoader): Promise<string> {
236257
const mod = await optionalPeer<typeof import('less')>('less', 'Less', peerResolver)
237258
const less = unwrapModuleNamespace(mod)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const DEFAULT_NAMESPACE = 'knighted'
2+
3+
export interface StableSelectorOptions {
4+
namespace?: string
5+
}
6+
7+
export interface StableClassNameOptions extends StableSelectorOptions {
8+
token?: string
9+
join?: (values: string[]) => string
10+
}
11+
12+
const defaultJoin = (values: string[]) => values.filter(Boolean).join(' ')
13+
14+
const normalizeToken = (token: string): string => {
15+
const sanitized = token
16+
.trim()
17+
.replace(/\s+/g, '-')
18+
.replace(/[^A-Za-z0-9_-]/g, '-')
19+
.replace(/-+/g, '-')
20+
.replace(/^-|-$/g, '')
21+
return sanitized.length ? sanitized : 'stable'
22+
}
23+
24+
export function stableToken(token: string, options?: StableSelectorOptions): string {
25+
const normalized = normalizeToken(token)
26+
const namespace = options?.namespace?.trim() ?? DEFAULT_NAMESPACE
27+
if (!namespace) {
28+
return normalized
29+
}
30+
return `${namespace}-${normalized}`
31+
}
32+
33+
export function stableClass(token: string, options?: StableSelectorOptions): string {
34+
return stableToken(token, options)
35+
}
36+
37+
export function stableSelector(token: string, options?: StableSelectorOptions): string {
38+
return `.${stableToken(token, options)}`
39+
}
40+
41+
export function createStableClassFactory(options?: StableSelectorOptions) {
42+
return (token: string) => stableClass(token, options)
43+
}
44+
45+
export function stableClassName<T extends Record<string, string>>(
46+
styles: T,
47+
key: keyof T | string,
48+
options?: StableClassNameOptions,
49+
): string {
50+
const hashed = styles[key as keyof T] ?? ''
51+
const token = options?.token ?? String(key)
52+
const stable = stableClass(token, options)
53+
const join = options?.join ?? defaultJoin
54+
return join([hashed, stable])
55+
}
56+
57+
export const stableClassFromModule = stableClassName

packages/css/stable/_index.scss

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Knighted stable selector mixins
2+
// Usage: @use '@knighted/css/stable' as knighted;
3+
// .button { @include knighted.stable('button') { ... } }
4+
5+
@use 'sass:meta';
6+
7+
$knighted-stable-namespace: 'knighted' !default;
8+
9+
@function _knighted-normalize-token($token) {
10+
@if meta.type-of($token) == 'number' {
11+
@return $token;
12+
}
13+
@return $token;
14+
}
15+
16+
@function stable-token($token, $namespace: $knighted-stable-namespace) {
17+
$normalized-token: _knighted-normalize-token($token);
18+
@if $namespace == '' {
19+
@return $normalized-token;
20+
}
21+
@return '#{$namespace}-#{$normalized-token}';
22+
}
23+
24+
@function stable-class($token, $namespace: $knighted-stable-namespace) {
25+
$token-value: stable-token($token, $namespace);
26+
@return '.#{$token-value}';
27+
}
28+
29+
@mixin stable($token, $namespace: $knighted-stable-namespace) {
30+
@if not & {
31+
@error 'The knighted.stable mixin must be used within a selector context so that "&" is defined.';
32+
}
33+
34+
$stable-selector: stable-class($token, $namespace);
35+
@at-root #{&},
36+
#{$stable-selector} {
37+
@content;
38+
}
39+
}
40+
41+
@mixin stable-at-root($selector, $token, $namespace: $knighted-stable-namespace) {
42+
$stable-selector: stable-class($token, $namespace);
43+
@at-root #{$selector},
44+
#{$stable-selector} {
45+
@content;
46+
}
47+
}
48+
49+
@function stable-class-name($token, $namespace: $knighted-stable-namespace) {
50+
@return stable-token($token, $namespace);
51+
}
52+
53+
@mixin stable-only($token, $namespace: $knighted-stable-namespace) {
54+
@at-root #{stable-class($token, $namespace)} {
55+
@content;
56+
}
57+
}

packages/css/stable/stable.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@layer knighted.stable;
2+
3+
:root {
4+
--knighted-stable-namespace: 'knighted';
5+
}
6+
7+
/*
8+
Usage:
9+
@import '@knighted/css/stable/stable.css';
10+
@layer knighted.stable {
11+
.knighted-button {
12+
declarations go here;
13+
}
14+
}
15+
*/
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
import * as sass from 'sass'
7+
8+
const testDir = fileURLToPath(new URL('.', import.meta.url))
9+
const packageRoot = path.resolve(testDir, '..')
10+
const loadPaths = [packageRoot]
11+
12+
test('stable mixin duplicates the current selector', () => {
13+
const source = `@use 'stable' as knighted;
14+
.button { @include knighted.stable('button') { color: teal; } }`
15+
const { css } = sass.compileString(source, { style: 'expanded', loadPaths })
16+
assert.match(css, /.button,\s*\.knighted-button\s*{[^}]*color: teal;/)
17+
})
18+
19+
test('stable-only emits only the deterministic selector', () => {
20+
const source = `@use 'stable' as knighted;
21+
@include knighted.stable-only('card') { border: 1px solid red; }`
22+
const { css } = sass.compileString(source, { style: 'expanded', loadPaths })
23+
assert.match(css, /^\.knighted-card\s*{[^}]*border: 1px solid red;/m)
24+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
4+
import {
5+
createStableClassFactory,
6+
stableClass,
7+
stableClassFromModule,
8+
stableClassName,
9+
stableSelector,
10+
stableToken,
11+
} from '../src/stableSelectors.ts'
12+
13+
test('stableToken applies default namespace and sanitizes tokens', () => {
14+
const result = stableToken(' hero button ')
15+
assert.equal(result, 'knighted-hero-button')
16+
})
17+
18+
test('stableToken allows overriding namespace', () => {
19+
const result = stableToken('cta', { namespace: 'acme' })
20+
assert.equal(result, 'acme-cta')
21+
})
22+
23+
test('stableToken omits namespace when trimmed value is empty', () => {
24+
const result = stableToken('cta', { namespace: ' ' })
25+
assert.equal(result, 'cta')
26+
})
27+
28+
test('stableClass returns a class name without dot', () => {
29+
assert.equal(stableClass('badge'), 'knighted-badge')
30+
})
31+
32+
test('createStableClassFactory memoizes namespace preference', () => {
33+
const scoped = createStableClassFactory({ namespace: 'storybook' })
34+
assert.equal(scoped('chip'), 'storybook-chip')
35+
})
36+
37+
test('stableClassName combines hashed class with stable selector', () => {
38+
const styles = { badge: 'badge__hashed' }
39+
const combined = stableClassName(styles, 'badge')
40+
assert.equal(combined, 'badge__hashed knighted-badge')
41+
})
42+
43+
test('stableClassName falls back when hashed class is missing', () => {
44+
const styles = { badge: 'badge__hashed' }
45+
const combined = stableClassName(styles, 'missing', { token: 'pill' })
46+
assert.equal(combined, 'knighted-pill')
47+
})
48+
49+
test('stableClassFromModule is an alias', () => {
50+
const styles = { title: 'title__hash' }
51+
const combined = stableClassFromModule(styles, 'title', { namespace: 'docs' })
52+
assert.equal(combined, 'title__hash docs-title')
53+
})
54+
55+
test('stableSelector returns a CSS selector string', () => {
56+
assert.equal(stableSelector('badge'), '.knighted-badge')
57+
})

packages/playwright/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"pretest": "npm run build"
1515
},
1616
"dependencies": {
17-
"@knighted/css": "1.0.0-rc.3",
17+
"@knighted/css": "1.0.0-rc.4",
1818
"@knighted/jsx": "^1.2.1",
1919
"lit": "^3.2.1",
2020
"react": "^19.0.0",

0 commit comments

Comments
 (0)