Skip to content

Commit 4b99cc1

Browse files
feat: --hashed classes with type generation. (#68)
1 parent dd3eab1 commit 4b99cc1

20 files changed

Lines changed: 693 additions & 24 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ node_modules/
33
dist/
44
dist-webpack/
55
dist-auto-stable/
6+
dist-hashed/
67
dist-bridge/
78
dist-bridge-webpack/
89
coverage/
@@ -15,4 +16,5 @@ test-results/
1516
blob-report/
1617
.knighted-css/
1718
.knighted-css-auto/
19+
.knighted-css-hashed/
1820
packages/playwright/src/**/*.knighted-css.ts

docs/roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

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

1415
## Lightning CSS Dependency Strategy
1516

docs/type-generation.md

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

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

57+
## Hashed selector proxies
58+
59+
Use `--hashed` when you want `.knighted-css` proxy modules to export `selectors` backed by
60+
CSS Modules hashing instead of stable selector strings. This keeps the module and selector
61+
types while preserving hashed class names at runtime.
62+
63+
> [!IMPORTANT]
64+
> `--hashed` requires the bundler to route `?knighted-css` imports through
65+
> `@knighted/css/loader-bridge`, so the proxy can read `knightedCss` and
66+
> `knightedCssModules` from the bridge output.
67+
68+
Example CLI:
69+
70+
```sh
71+
knighted-css-generate-types --root . --include src --hashed
72+
```
73+
74+
Example usage:
75+
76+
```ts
77+
import Button, { knightedCss, selectors } from './button.knighted-css.js'
78+
79+
selectors.card // hashed class name from CSS Modules
80+
knightedCss // compiled CSS string
81+
```
82+
5683
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.
5784

5885
## Rspack watch hook

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/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@ When the `.knighted-css` import targets a JavaScript/TypeScript module, the gene
121121
import Button, { knightedCss, stableSelectors } from './button.knighted-css.js'
122122
```
123123

124+
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:
125+
126+
```sh
127+
knighted-css-generate-types --root . --include src --hashed
128+
```
129+
130+
```ts
131+
import Button, { knightedCss, selectors } from './button.knighted-css.js'
132+
133+
selectors.card // hashed CSS Modules class name
134+
```
135+
136+
> [!IMPORTANT]
137+
> `--hashed` requires wiring `@knighted/css/loader-bridge` to handle `?knighted-css` queries so
138+
> the generated proxies can read `knightedCss` and `knightedCssModules` at build time.
139+
124140
Refer to [docs/type-generation.md](../../docs/type-generation.md) for CLI options and workflow tips.
125141

126142
### Combined + runtime selectors

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.1.0-rc.7",
3+
"version": "1.1.0-rc.8",
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",

packages/css/src/generateTypes.ts

Lines changed: 108 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ interface GenerateTypesInternalOptions {
4848
cacheDir: string
4949
stableNamespace?: string
5050
autoStable?: boolean
51+
hashed?: boolean
5152
tsconfig?: TsconfigResolutionContext
5253
resolver?: CssResolver
5354
}
@@ -65,6 +66,7 @@ export interface GenerateTypesOptions {
6566
outDir?: string
6667
stableNamespace?: string
6768
autoStable?: boolean
69+
hashed?: boolean
6870
resolver?: CssResolver
6971
}
7072

@@ -148,6 +150,7 @@ export async function generateTypes(
148150
cacheDir,
149151
stableNamespace: options.stableNamespace,
150152
autoStable: options.autoStable,
153+
hashed: options.hashed,
151154
tsconfig,
152155
resolver: options.resolver,
153156
}
@@ -224,12 +227,14 @@ async function generateDeclarations(
224227
: undefined,
225228
resolver: options.resolver,
226229
})
227-
selectorMap = buildStableSelectorsLiteral({
228-
css,
229-
namespace: resolvedNamespace,
230-
resourcePath: resolvedPath,
231-
emitWarning: message => warnings.push(message),
232-
}).selectorMap
230+
selectorMap = options.hashed
231+
? collectSelectorTokensFromCss(css)
232+
: buildStableSelectorsLiteral({
233+
css,
234+
namespace: resolvedNamespace,
235+
resourcePath: resolvedPath,
236+
emitWarning: message => warnings.push(message),
237+
}).selectorMap
233238
} catch (error) {
234239
warnings.push(
235240
`Failed to extract CSS for ${relativeToRoot(resolvedPath, options.rootDir)}: ${formatErrorMessage(error)}`,
@@ -261,7 +266,9 @@ async function generateDeclarations(
261266
selectorMap,
262267
previousSelectorManifest,
263268
nextSelectorManifest,
269+
selectorSource,
264270
proxyInfo ?? undefined,
271+
options.hashed ?? false,
265272
)
266273
if (moduleWrite) {
267274
selectorModuleWrites += 1
@@ -501,9 +508,15 @@ function buildSelectorModulePath(resolvedPath: string): string {
501508
function formatSelectorModuleSource(
502509
selectors: Map<string, string>,
503510
proxyInfo?: SelectorModuleProxyInfo,
511+
options: {
512+
hashed?: boolean
513+
selectorSource?: string
514+
resolvedPath?: string
515+
} = {},
504516
): string {
505517
const header = '// Generated by @knighted/css/generate-types\n// Do not edit.'
506518
const entries = Array.from(selectors.entries()).sort(([a], [b]) => a.localeCompare(b))
519+
const isHashed = options.hashed === true
507520
const lines = entries.map(
508521
([token, selector]) => ` ${JSON.stringify(token)}: ${JSON.stringify(selector)},`,
509522
)
@@ -513,22 +526,67 @@ function formatSelectorModuleSource(
513526
${lines.join('\n')}
514527
} as const`
515528
: '{} as const'
529+
const typeLines = entries.map(
530+
([token]) => ` readonly ${JSON.stringify(token)}: string`,
531+
)
532+
const typeLiteral =
533+
typeLines.length > 0
534+
? `{
535+
${typeLines.join('\n')}
536+
}`
537+
: 'Record<string, string>'
516538
const proxyLines: string[] = []
539+
const reexportLines: string[] = []
540+
const hashedSpecifier =
541+
options.selectorSource && options.resolvedPath
542+
? buildProxyModuleSpecifier(options.resolvedPath, options.selectorSource)
543+
: undefined
544+
517545
if (proxyInfo) {
518-
proxyLines.push(`export * from '${proxyInfo.moduleSpecifier}'`)
546+
reexportLines.push(`export * from '${proxyInfo.moduleSpecifier}'`)
519547
if (proxyInfo.includeDefault) {
520-
proxyLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`)
548+
reexportLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`)
549+
}
550+
}
551+
552+
if (isHashed) {
553+
const sourceSpecifier = proxyInfo?.moduleSpecifier ?? hashedSpecifier
554+
if (sourceSpecifier) {
555+
proxyLines.push(
556+
`import { knightedCss as __knightedCss, knightedCssModules as __knightedCssModules } from '${sourceSpecifier}?knighted-css'`,
557+
)
558+
proxyLines.push('export const knightedCss = __knightedCss')
559+
proxyLines.push('export const knightedCssModules = __knightedCssModules')
521560
}
561+
} else if (proxyInfo) {
522562
proxyLines.push(
523563
`export { knightedCss } from '${proxyInfo.moduleSpecifier}?knighted-css'`,
524564
)
525565
}
526-
const defaultExport = proxyInfo ? '' : '\nexport default stableSelectors'
527-
const stableBlock = `export const stableSelectors = ${literal}
528566

529-
export type KnightedCssStableSelectors = typeof stableSelectors
530-
export type KnightedCssStableSelectorToken = keyof typeof stableSelectors${defaultExport}`
531-
const sections = [header, proxyLines.join('\n'), stableBlock].filter(Boolean)
567+
const exportName = isHashed ? 'selectors' : 'stableSelectors'
568+
const typeName = isHashed ? 'KnightedCssSelectors' : 'KnightedCssStableSelectors'
569+
const tokenTypeName = isHashed
570+
? 'KnightedCssSelectorToken'
571+
: 'KnightedCssStableSelectorToken'
572+
const defaultExport = proxyInfo ? '' : `\nexport default ${exportName}`
573+
574+
const selectorBlock = isHashed
575+
? `export const ${exportName} = __knightedCssModules as ${typeLiteral}
576+
577+
export type ${typeName} = typeof ${exportName}
578+
export type ${tokenTypeName} = keyof typeof ${exportName}${defaultExport}`
579+
: `export const ${exportName} = ${literal}
580+
581+
export type ${typeName} = typeof ${exportName}
582+
export type ${tokenTypeName} = keyof typeof ${exportName}${defaultExport}`
583+
584+
const sections = [
585+
header,
586+
proxyLines.join('\n'),
587+
reexportLines.join('\n'),
588+
selectorBlock,
589+
].filter(Boolean)
532590
return `${sections.join('\n\n')}
533591
`
534592
}
@@ -591,11 +649,17 @@ async function ensureSelectorModule(
591649
selectors: Map<string, string>,
592650
previousManifest: SelectorModuleManifest,
593651
nextManifest: SelectorModuleManifest,
652+
selectorSource: string,
594653
proxyInfo?: SelectorModuleProxyInfo,
654+
hashed?: boolean,
595655
): Promise<boolean> {
596656
const manifestKey = buildSelectorModuleManifestKey(resolvedPath)
597657
const targetPath = buildSelectorModulePath(resolvedPath)
598-
const source = formatSelectorModuleSource(selectors, proxyInfo)
658+
const source = formatSelectorModuleSource(selectors, proxyInfo, {
659+
hashed,
660+
selectorSource,
661+
resolvedPath,
662+
})
599663
const hash = hashContent(source)
600664
const previousEntry = previousManifest[manifestKey]
601665
const needsWrite = previousEntry?.hash !== hash || !(await fileExists(targetPath))
@@ -772,6 +836,23 @@ function isStyleResource(filePath: string): boolean {
772836
return STYLE_EXTENSIONS.some(ext => normalized.endsWith(ext))
773837
}
774838

839+
function collectSelectorTokensFromCss(css: string): Map<string, string> {
840+
const tokens = new Set<string>()
841+
const pattern = /\.([A-Za-z_-][A-Za-z0-9_-]*)\b/g
842+
let match: RegExpExecArray | null
843+
while ((match = pattern.exec(css)) !== null) {
844+
const token = match[1]
845+
if (token) {
846+
tokens.add(token)
847+
}
848+
}
849+
const map = new Map<string, string>()
850+
for (const token of tokens) {
851+
map.set(token, token)
852+
}
853+
return map
854+
}
855+
775856
async function resolveProxyInfo(
776857
manifestKey: string,
777858
selectorSource: string,
@@ -890,6 +971,7 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise
890971
outDir: parsed.outDir,
891972
stableNamespace: parsed.stableNamespace,
892973
autoStable: parsed.autoStable,
974+
hashed: parsed.hashed,
893975
resolver,
894976
})
895977
reportCliResult(result)
@@ -906,6 +988,7 @@ export interface ParsedCliArgs {
906988
outDir?: string
907989
stableNamespace?: string
908990
autoStable?: boolean
991+
hashed?: boolean
909992
resolver?: string
910993
help?: boolean
911994
}
@@ -916,6 +999,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
916999
let outDir: string | undefined
9171000
let stableNamespace: string | undefined
9181001
let autoStable = false
1002+
let hashed = false
9191003
let resolver: string | undefined
9201004

9211005
for (let i = 0; i < argv.length; i += 1) {
@@ -927,6 +1011,10 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
9271011
autoStable = true
9281012
continue
9291013
}
1014+
if (arg === '--hashed') {
1015+
hashed = true
1016+
continue
1017+
}
9301018
if (arg === '--root' || arg === '-r') {
9311019
const value = argv[++i]
9321020
if (!value) {
@@ -973,7 +1061,11 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
9731061
include.push(arg)
9741062
}
9751063

976-
return { rootDir, include, outDir, stableNamespace, autoStable, resolver }
1064+
if (autoStable && hashed) {
1065+
throw new Error('Cannot combine --auto-stable with --hashed')
1066+
}
1067+
1068+
return { rootDir, include, outDir, stableNamespace, autoStable, hashed, resolver }
9771069
}
9781070

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

0 commit comments

Comments
 (0)