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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
dist/
dist-webpack/
dist-auto-stable/
dist-hashed/
dist-bridge/
dist-bridge-webpack/
coverage/
Expand All @@ -15,4 +16,5 @@ test-results/
blob-report/
.knighted-css/
.knighted-css-auto/
.knighted-css-hashed/
packages/playwright/src/**/*.knighted-css.ts
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- Remove the triple-slash references from `types.d.ts` for v2.0, replacing them with standard ESM import/export wiring.
- Ensure the new pipeline preserves the current downstream behavior for 1.x users via a documented migration path.
- Add a CLI `--exclude` option to skip directories/files during type generation scans.

## Lightning CSS Dependency Strategy

Expand Down
27 changes: 27 additions & 0 deletions docs/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Wire it into `postinstall` or your build so new selectors land automatically.
- `--out-dir` – directory for the selector module manifest cache (defaults to `<root>/.knighted-css`).
- `--stable-namespace` – namespace prefix shared by the generated selector maps and loader runtime.
- `--auto-stable` – enable auto-stable selector generation during extraction (mirrors the loader’s auto-stable behavior).
- `--hashed` – emit proxy modules that export `selectors` backed by loader-bridge hashed class names (mutually exclusive with `--auto-stable`).
- `--resolver` – path or package name exporting a `CssResolver` (default export or named `resolver`).

### Relationship to the loader
Expand All @@ -53,6 +54,32 @@ stableSelectors.card // "knighted-card"
knightedCss // compiled CSS string
```

## Hashed selector proxies

Use `--hashed` when you want `.knighted-css` proxy modules to export `selectors` backed by
CSS Modules hashing instead of stable selector strings. This keeps the module and selector
types while preserving hashed class names at runtime.

> [!IMPORTANT]
> `--hashed` requires the bundler to route `?knighted-css` imports through
> `@knighted/css/loader-bridge`, so the proxy can read `knightedCss` and
> `knightedCssModules` from the bridge output.

Example CLI:

```sh
knighted-css-generate-types --root . --include src --hashed
```

Example usage:

```ts
import Button, { knightedCss, selectors } from './button.knighted-css.js'

selectors.card // hashed class name from CSS Modules
knightedCss // compiled CSS string
```

Because the generated module lives next to the source stylesheet, TypeScript’s normal resolution logic applies—no custom `paths` entries required. Use the manifest in conjunction with runtime helpers such as `mergeStableClass` or `stableClassName` to keep hashed class names in sync.

## Rspack watch hook
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.

16 changes: 16 additions & 0 deletions packages/css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ When the `.knighted-css` import targets a JavaScript/TypeScript module, the gene
import Button, { knightedCss, stableSelectors } from './button.knighted-css.js'
```

Need hashed class names instead of stable selectors? Run the CLI with `--hashed` to emit proxy modules that export `selectors` backed by `knightedCssModules` from the loader-bridge:

```sh
knighted-css-generate-types --root . --include src --hashed
```

```ts
import Button, { knightedCss, selectors } from './button.knighted-css.js'

selectors.card // hashed CSS Modules class name
```

> [!IMPORTANT]
> `--hashed` requires wiring `@knighted/css/loader-bridge` to handle `?knighted-css` queries so
> the generated proxies can read `knightedCss` and `knightedCssModules` at build time.

Refer to [docs/type-generation.md](../../docs/type-generation.md) for CLI options and workflow tips.

### Combined + runtime selectors
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.1.0-rc.7",
"version": "1.1.0-rc.8",
"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
123 changes: 108 additions & 15 deletions packages/css/src/generateTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface GenerateTypesInternalOptions {
cacheDir: string
stableNamespace?: string
autoStable?: boolean
hashed?: boolean
tsconfig?: TsconfigResolutionContext
resolver?: CssResolver
}
Expand All @@ -65,6 +66,7 @@ export interface GenerateTypesOptions {
outDir?: string
stableNamespace?: string
autoStable?: boolean
hashed?: boolean
resolver?: CssResolver
}

Expand Down Expand Up @@ -148,6 +150,7 @@ export async function generateTypes(
cacheDir,
stableNamespace: options.stableNamespace,
autoStable: options.autoStable,
hashed: options.hashed,
tsconfig,
resolver: options.resolver,
}
Expand Down Expand Up @@ -224,12 +227,14 @@ async function generateDeclarations(
: undefined,
resolver: options.resolver,
})
selectorMap = buildStableSelectorsLiteral({
css,
namespace: resolvedNamespace,
resourcePath: resolvedPath,
emitWarning: message => warnings.push(message),
}).selectorMap
selectorMap = options.hashed
? collectSelectorTokensFromCss(css)
: buildStableSelectorsLiteral({
css,
namespace: resolvedNamespace,
resourcePath: resolvedPath,
emitWarning: message => warnings.push(message),
}).selectorMap
} catch (error) {
warnings.push(
`Failed to extract CSS for ${relativeToRoot(resolvedPath, options.rootDir)}: ${formatErrorMessage(error)}`,
Expand Down Expand Up @@ -261,7 +266,9 @@ async function generateDeclarations(
selectorMap,
previousSelectorManifest,
nextSelectorManifest,
selectorSource,
proxyInfo ?? undefined,
options.hashed ?? false,
)
if (moduleWrite) {
selectorModuleWrites += 1
Expand Down Expand Up @@ -501,9 +508,15 @@ function buildSelectorModulePath(resolvedPath: string): string {
function formatSelectorModuleSource(
selectors: Map<string, string>,
proxyInfo?: SelectorModuleProxyInfo,
options: {
hashed?: boolean
selectorSource?: string
resolvedPath?: string
} = {},
): string {
const header = '// Generated by @knighted/css/generate-types\n// Do not edit.'
const entries = Array.from(selectors.entries()).sort(([a], [b]) => a.localeCompare(b))
const isHashed = options.hashed === true
const lines = entries.map(
([token, selector]) => ` ${JSON.stringify(token)}: ${JSON.stringify(selector)},`,
)
Expand All @@ -513,22 +526,67 @@ function formatSelectorModuleSource(
${lines.join('\n')}
} as const`
: '{} as const'
const typeLines = entries.map(
([token]) => ` readonly ${JSON.stringify(token)}: string`,
)
const typeLiteral =
typeLines.length > 0
? `{
${typeLines.join('\n')}
}`
: 'Record<string, string>'
const proxyLines: string[] = []
const reexportLines: string[] = []
const hashedSpecifier =
options.selectorSource && options.resolvedPath
? buildProxyModuleSpecifier(options.resolvedPath, options.selectorSource)
: undefined

if (proxyInfo) {
proxyLines.push(`export * from '${proxyInfo.moduleSpecifier}'`)
reexportLines.push(`export * from '${proxyInfo.moduleSpecifier}'`)
if (proxyInfo.includeDefault) {
proxyLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`)
reexportLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`)
}
}

if (isHashed) {
const sourceSpecifier = proxyInfo?.moduleSpecifier ?? hashedSpecifier
if (sourceSpecifier) {
proxyLines.push(
`import { knightedCss as __knightedCss, knightedCssModules as __knightedCssModules } from '${sourceSpecifier}?knighted-css'`,
)
proxyLines.push('export const knightedCss = __knightedCss')
proxyLines.push('export const knightedCssModules = __knightedCssModules')
}
} else if (proxyInfo) {
proxyLines.push(
`export { knightedCss } from '${proxyInfo.moduleSpecifier}?knighted-css'`,
)
}
const defaultExport = proxyInfo ? '' : '\nexport default stableSelectors'
const stableBlock = `export const stableSelectors = ${literal}

export type KnightedCssStableSelectors = typeof stableSelectors
export type KnightedCssStableSelectorToken = keyof typeof stableSelectors${defaultExport}`
const sections = [header, proxyLines.join('\n'), stableBlock].filter(Boolean)
const exportName = isHashed ? 'selectors' : 'stableSelectors'
const typeName = isHashed ? 'KnightedCssSelectors' : 'KnightedCssStableSelectors'
const tokenTypeName = isHashed
? 'KnightedCssSelectorToken'
: 'KnightedCssStableSelectorToken'
const defaultExport = proxyInfo ? '' : `\nexport default ${exportName}`

const selectorBlock = isHashed
? `export const ${exportName} = __knightedCssModules as ${typeLiteral}

export type ${typeName} = typeof ${exportName}
export type ${tokenTypeName} = keyof typeof ${exportName}${defaultExport}`
: `export const ${exportName} = ${literal}

export type ${typeName} = typeof ${exportName}
export type ${tokenTypeName} = keyof typeof ${exportName}${defaultExport}`

const sections = [
header,
proxyLines.join('\n'),
reexportLines.join('\n'),
selectorBlock,
].filter(Boolean)
return `${sections.join('\n\n')}
`
}
Expand Down Expand Up @@ -591,11 +649,17 @@ async function ensureSelectorModule(
selectors: Map<string, string>,
previousManifest: SelectorModuleManifest,
nextManifest: SelectorModuleManifest,
selectorSource: string,
proxyInfo?: SelectorModuleProxyInfo,
hashed?: boolean,
): Promise<boolean> {
const manifestKey = buildSelectorModuleManifestKey(resolvedPath)
const targetPath = buildSelectorModulePath(resolvedPath)
const source = formatSelectorModuleSource(selectors, proxyInfo)
const source = formatSelectorModuleSource(selectors, proxyInfo, {
hashed,
selectorSource,
resolvedPath,
})
const hash = hashContent(source)
const previousEntry = previousManifest[manifestKey]
const needsWrite = previousEntry?.hash !== hash || !(await fileExists(targetPath))
Expand Down Expand Up @@ -772,6 +836,23 @@ function isStyleResource(filePath: string): boolean {
return STYLE_EXTENSIONS.some(ext => normalized.endsWith(ext))
}

function collectSelectorTokensFromCss(css: string): Map<string, string> {
const tokens = new Set<string>()
const pattern = /\.([A-Za-z_-][A-Za-z0-9_-]*)\b/g
let match: RegExpExecArray | null
while ((match = pattern.exec(css)) !== null) {
const token = match[1]
if (token) {
tokens.add(token)
}
}
const map = new Map<string, string>()
for (const token of tokens) {
map.set(token, token)
}
return map
}

async function resolveProxyInfo(
manifestKey: string,
selectorSource: string,
Expand Down Expand Up @@ -890,6 +971,7 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise
outDir: parsed.outDir,
stableNamespace: parsed.stableNamespace,
autoStable: parsed.autoStable,
hashed: parsed.hashed,
resolver,
})
reportCliResult(result)
Expand All @@ -906,6 +988,7 @@ export interface ParsedCliArgs {
outDir?: string
stableNamespace?: string
autoStable?: boolean
hashed?: boolean
resolver?: string
help?: boolean
}
Expand All @@ -916,6 +999,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
let outDir: string | undefined
let stableNamespace: string | undefined
let autoStable = false
let hashed = false
let resolver: string | undefined

for (let i = 0; i < argv.length; i += 1) {
Expand All @@ -927,6 +1011,10 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
autoStable = true
continue
}
if (arg === '--hashed') {
hashed = true
continue
}
if (arg === '--root' || arg === '-r') {
const value = argv[++i]
if (!value) {
Expand Down Expand Up @@ -973,7 +1061,11 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
include.push(arg)
}

return { rootDir, include, outDir, stableNamespace, autoStable, resolver }
if (autoStable && hashed) {
throw new Error('Cannot combine --auto-stable with --hashed')
}

return { rootDir, include, outDir, stableNamespace, autoStable, hashed, resolver }
}

function printHelp(): void {
Expand All @@ -985,6 +1077,7 @@ Options:
--out-dir <path> Directory to store selector module manifest cache
--stable-namespace <name> Stable namespace prefix for generated selector maps
--auto-stable Enable autoStable when extracting CSS for selectors
--hashed Emit selectors backed by loader-bridge hashed modules
--resolver <path> Path or package name exporting a CssResolver
-h, --help Show this help message
`)
Expand Down
Loading
Loading