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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,52 @@ The loader appends `export const knightedCss = "/* compiled css */"` to the modu

#### CSS Modules and stable selectors

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

```tsx
<div className={`${styles['css-modules-badge']} css-modules-badge`}>
```

Sass/Less projects can import the shared mixins directly:

```scss
@use '@knighted/css/stable' as knighted;

.button {
@include knighted.stable('button') {
// declarations duplicated for .button and .knighted-button
}
}
```

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')`).

For runtime usage (vanilla-extract, CSS Modules, JSX utilities), pull in the TypeScript helpers:

```ts
import { stableClassName } from '@knighted/css/stableSelectors'

function Badge() {
return <span className={stableClassName(styles, 'badge')} />
}
```

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

Need a zero-JS approach? Import the optional layer helper and co-locate your fallback selectors:

```css
@import '@knighted/css/stable/stable.css';

@layer knighted.stable {
.knighted-alert {
/* declarations */
}
}
```

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

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

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

17 changes: 15 additions & 2 deletions packages/css/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/css",
"version": "1.0.0-rc.3",
"version": "1.0.0-rc.4",
"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 Expand Up @@ -32,6 +32,18 @@
"./loader-queries": {
"types": "./loader-queries.d.ts",
"default": "./loader-queries.d.ts"
},
"./stableSelectors": {
"types": "./dist/stableSelectors.d.ts",
"import": "./dist/stableSelectors.js",
"require": "./dist/cjs/stableSelectors.cjs"
},
"./stable": {
"sass": "./stable/_index.scss",
"default": "./stable/_index.scss"
},
"./stable/stable.css": {
"default": "./stable/stable.css"
}
},
"keywords": [
Expand Down Expand Up @@ -80,7 +92,8 @@
"files": [
"dist",
"loader-queries.d.ts",
"types.d.ts"
"types.d.ts",
"stable"
],
"author": "KCM <knightedcodemonkey@gmail.com>",
"license": "MIT",
Expand Down
23 changes: 22 additions & 1 deletion packages/css/src/css.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path'
import { promises as fs } from 'node:fs'
import { existsSync, promises as fs } from 'node:fs'

import dependencyTree from 'dependency-tree'
import type { Options as DependencyTreeOpts } from 'dependency-tree'
Expand Down Expand Up @@ -228,10 +228,31 @@ async function compileSass(
const sass = sassModule
const result = sass.compile(filePath, {
style: 'expanded',
loadPaths: buildSassLoadPaths(filePath),
})
return result.css
}

// Ensure Sass can resolve bare module specifiers by walking node_modules folders.
function buildSassLoadPaths(filePath: string): string[] {
const loadPaths = new Set<string>()
let cursor = path.dirname(filePath)
const root = path.parse(cursor).root

while (true) {
loadPaths.add(cursor)
loadPaths.add(path.join(cursor, 'node_modules'))
if (cursor === root) break
cursor = path.dirname(cursor)
}

const cwd = process.cwd()
loadPaths.add(cwd)
loadPaths.add(path.join(cwd, 'node_modules'))

return Array.from(loadPaths).filter(dir => dir && existsSync(dir))
}

async function compileLess(filePath: string, peerResolver?: PeerLoader): Promise<string> {
const mod = await optionalPeer<typeof import('less')>('less', 'Less', peerResolver)
const less = unwrapModuleNamespace(mod)
Expand Down
57 changes: 57 additions & 0 deletions packages/css/src/stableSelectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const DEFAULT_NAMESPACE = 'knighted'

export interface StableSelectorOptions {
namespace?: string
}

export interface StableClassNameOptions extends StableSelectorOptions {
token?: string
join?: (values: string[]) => string
}

const defaultJoin = (values: string[]) => values.filter(Boolean).join(' ')

const normalizeToken = (token: string): string => {
const sanitized = token
.trim()
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return sanitized.length ? sanitized : 'stable'
}

export function stableToken(token: string, options?: StableSelectorOptions): string {
const normalized = normalizeToken(token)
const namespace = options?.namespace?.trim() ?? DEFAULT_NAMESPACE
if (!namespace) {
return normalized
}
return `${namespace}-${normalized}`
}

export function stableClass(token: string, options?: StableSelectorOptions): string {
return stableToken(token, options)
}

export function stableSelector(token: string, options?: StableSelectorOptions): string {
return `.${stableToken(token, options)}`
}

export function createStableClassFactory(options?: StableSelectorOptions) {
return (token: string) => stableClass(token, options)
}

export function stableClassName<T extends Record<string, string>>(
styles: T,
key: keyof T | string,
options?: StableClassNameOptions,
): string {
const hashed = styles[key as keyof T] ?? ''
const token = options?.token ?? String(key)
const stable = stableClass(token, options)
const join = options?.join ?? defaultJoin
return join([hashed, stable])
}

export const stableClassFromModule = stableClassName
57 changes: 57 additions & 0 deletions packages/css/stable/_index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Knighted stable selector mixins
// Usage: @use '@knighted/css/stable' as knighted;
// .button { @include knighted.stable('button') { ... } }

@use 'sass:meta';

$knighted-stable-namespace: 'knighted' !default;

@function _knighted-normalize-token($token) {
@if meta.type-of($token) == 'number' {
@return $token;
}
@return $token;
}

@function stable-token($token, $namespace: $knighted-stable-namespace) {
$normalized-token: _knighted-normalize-token($token);
@if $namespace == '' {
@return $normalized-token;
}
@return '#{$namespace}-#{$normalized-token}';
}

@function stable-class($token, $namespace: $knighted-stable-namespace) {
$token-value: stable-token($token, $namespace);
@return '.#{$token-value}';
}

@mixin stable($token, $namespace: $knighted-stable-namespace) {
@if not & {
@error 'The knighted.stable mixin must be used within a selector context so that "&" is defined.';
}

$stable-selector: stable-class($token, $namespace);
@at-root #{&},
#{$stable-selector} {
@content;
}
}

@mixin stable-at-root($selector, $token, $namespace: $knighted-stable-namespace) {
$stable-selector: stable-class($token, $namespace);
@at-root #{$selector},
#{$stable-selector} {
@content;
}
}

@function stable-class-name($token, $namespace: $knighted-stable-namespace) {
@return stable-token($token, $namespace);
}

@mixin stable-only($token, $namespace: $knighted-stable-namespace) {
@at-root #{stable-class($token, $namespace)} {
@content;
}
}
15 changes: 15 additions & 0 deletions packages/css/stable/stable.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@layer knighted.stable;

:root {
--knighted-stable-namespace: 'knighted';
}

/*
Usage:
@import '@knighted/css/stable/stable.css';
@layer knighted.stable {
.knighted-button {
declarations go here;
}
}
*/
24 changes: 24 additions & 0 deletions packages/css/test/stable-mixins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import * as sass from 'sass'

const testDir = fileURLToPath(new URL('.', import.meta.url))
const packageRoot = path.resolve(testDir, '..')
const loadPaths = [packageRoot]

test('stable mixin duplicates the current selector', () => {
const source = `@use 'stable' as knighted;
.button { @include knighted.stable('button') { color: teal; } }`
const { css } = sass.compileString(source, { style: 'expanded', loadPaths })
assert.match(css, /.button,\s*\.knighted-button\s*{[^}]*color: teal;/)
})

test('stable-only emits only the deterministic selector', () => {
const source = `@use 'stable' as knighted;
@include knighted.stable-only('card') { border: 1px solid red; }`
const { css } = sass.compileString(source, { style: 'expanded', loadPaths })
assert.match(css, /^\.knighted-card\s*{[^}]*border: 1px solid red;/m)
})
57 changes: 57 additions & 0 deletions packages/css/test/stableSelectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import {
createStableClassFactory,
stableClass,
stableClassFromModule,
stableClassName,
stableSelector,
stableToken,
} from '../src/stableSelectors.ts'

test('stableToken applies default namespace and sanitizes tokens', () => {
const result = stableToken(' hero button ')
assert.equal(result, 'knighted-hero-button')
})

test('stableToken allows overriding namespace', () => {
const result = stableToken('cta', { namespace: 'acme' })
assert.equal(result, 'acme-cta')
})

test('stableToken omits namespace when trimmed value is empty', () => {
const result = stableToken('cta', { namespace: ' ' })
assert.equal(result, 'cta')
})

test('stableClass returns a class name without dot', () => {
assert.equal(stableClass('badge'), 'knighted-badge')
})

test('createStableClassFactory memoizes namespace preference', () => {
const scoped = createStableClassFactory({ namespace: 'storybook' })
assert.equal(scoped('chip'), 'storybook-chip')
})

test('stableClassName combines hashed class with stable selector', () => {
const styles = { badge: 'badge__hashed' }
const combined = stableClassName(styles, 'badge')
assert.equal(combined, 'badge__hashed knighted-badge')
})

test('stableClassName falls back when hashed class is missing', () => {
const styles = { badge: 'badge__hashed' }
const combined = stableClassName(styles, 'missing', { token: 'pill' })
assert.equal(combined, 'knighted-pill')
})

test('stableClassFromModule is an alias', () => {
const styles = { title: 'title__hash' }
const combined = stableClassFromModule(styles, 'title', { namespace: 'docs' })
assert.equal(combined, 'title__hash docs-title')
})

test('stableSelector returns a CSS selector string', () => {
assert.equal(stableSelector('badge'), '.knighted-badge')
})
2 changes: 1 addition & 1 deletion packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"pretest": "npm run build"
},
"dependencies": {
"@knighted/css": "1.0.0-rc.3",
"@knighted/css": "1.0.0-rc.4",
"@knighted/jsx": "^1.2.1",
"lit": "^3.2.1",
"react": "^19.0.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/playwright/src/lit-react/app.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
@import '@knighted/css/stable/stable.css';

@layer knighted.stable {
.knighted-layer-glow {
box-shadow: 0 25px 55px rgba(14, 165, 233, 0.35);
}
}

.readme-stage {
display: flex;
flex-direction: column;
Expand Down
Loading