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
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,25 @@ Need the module exports, `knightedCss`, and a runtime `stableSelectors` map from

```ts
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import combined, { stableSelectors } from './button.js?knighted-css&combined&types'

const { default: Button, knightedCss } = combined as KnightedCssCombinedModule<
typeof import('./button.js')
>
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'
import type { ButtonStableSelectors } from './button.css.knighted-css.js'
import * as buttonModule from './button.js?knighted-css&combined&types'

const {
default: Button,
knightedCss,
stableSelectors,
} = asKnightedCssCombinedModule<
typeof import('./button.js'),
{ stableSelectors: Readonly<Record<keyof ButtonStableSelectors, string>> }
>(buttonModule)

stableSelectors.shell
```

> [!NOTE]
> `stableSelectors` here is for runtime use; TypeScript still reads literal tokens from the generated `.knighted-css.*` modules. For a full decision matrix, see [docs/combined-queries.md](./docs/combined-queries.md).
> Prefer importing `asKnightedCssCombinedModule` from `@knighted/css/loader-helpers` instead of grabbing it from `@knighted/css/loader`—the helper lives in a Node-free chunk so both browser and server bundles stay happy.

## Examples

Expand Down
14 changes: 12 additions & 2 deletions docs/combined-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ This document summarizes how `?knighted-css&combined` behaves for different modu
> [!NOTE]
> TypeScript now reads literal selector tokens from the generated `.knighted-css.ts` modules (emitted by `knighted-css-generate-types`). Append `&types` to combined imports only when you also need `stableSelectors` at runtime—the loader still exports the map, while the double-extension modules keep your editors in sync.

> [!TIP]
> `KnightedCssCombinedModule` accepts an optional second generic parameter so you can describe loader-injected exports (for example, `{ stableSelectors: Record<string, string> }` when you append `&types`). That keeps the helper self-contained without intersecting additional types in your own code.

> [!TIP]
> Prefer importing `asKnightedCssCombinedModule` from `@knighted/css/loader-helpers` when you want a runtime helper—the file has zero Node dependencies, so both browser and Node builds stay green.

## Decision Matrix

| Source module exports | Recommended query | TypeScript import pattern | Notes |
Expand Down Expand Up @@ -37,7 +43,8 @@ import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import combined from './module.js?knighted-css&combined'

const { default: Component, knightedCss } = combined as KnightedCssCombinedModule<
typeof import('./module.js')
typeof import('./module.js'),
{ stableSelectors: typeof stableSelectors }
>
```

Expand All @@ -51,7 +58,10 @@ const {
default: Component,
helper,
knightedCss,
} = combined as KnightedCssCombinedModule<typeof import('./module.js')>
} = combined as KnightedCssCombinedModule<
typeof import('./module.js'),
{ stableSelectors: typeof stableSelectors }
>
```

Prefer `?knighted-css&combined&named-only` plus the [named exports only](#named-exports-only) snippet when you intentionally avoid default exports but still need the named members and `knightedCss`.
Expand Down
24 changes: 19 additions & 5 deletions docs/loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,29 @@ Need the component exports **and** the compiled CSS from a single import? Use `?

```ts
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import buttonModule from './button.js?knighted-css&combined'

const { default: Button, knightedCss } = buttonModule as KnightedCssCombinedModule<
typeof import('./button.js')
>
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'
import * as buttonModule from './button.js?knighted-css&combined'

const { default: Button, knightedCss } =
asKnightedCssCombinedModule<typeof import('./button.js')>(buttonModule)

// Need to describe additional loader-injected exports (for example, `stableSelectors` when
// using `?knighted-css&combined&types`)? Pass a second generic:
const {
default: ButtonWithSelectors,
knightedCss: buttonCss,
stableSelectors,
} = asKnightedCssCombinedModule<
typeof import('./button.js'),
{ stableSelectors: Record<string, string> }
>(buttonModule)
```

Append `&named-only` (alias: `&no-default`) if you never consume the default export. Refer to [docs/combined-queries.md](./combined-queries.md) for the full matrix of query flags and destructuring patterns.

> [!TIP]
> `@knighted/css/loader-helpers` ships the `asKnightedCssCombinedModule` helper in isolation so you can safely import it from browser bundles. This keeps the heavy loader implementation (and its Node dependencies) out of client builds.

### Runtime selectors (`&types`)

When you need the runtime `stableSelectors` map alongside `knightedCss`, append `&types` to either the plain or combined import:
Expand Down
7 changes: 6 additions & 1 deletion packages/css/loader-queries.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ declare module '*?knighted-css&types' {
* TypeScript cannot infer the underlying module automatically, so consumers can
* import the default export and narrow it with `KnightedCssCombinedModule<typeof import('./file')>`.
*/
type KnightedCssCombinedModule<TModule> = TModule & { knightedCss: string }
type KnightedCssCombinedExtras = Readonly<Record<string, unknown>>

type KnightedCssCombinedModule<
TModule,
TExtras extends KnightedCssCombinedExtras = Record<never, never>,
> = TModule & TExtras & { knightedCss: string }

declare module '*?knighted-css&combined' {
const combined: KnightedCssCombinedModule<Record<string, unknown>>
Expand Down
8 changes: 8 additions & 0 deletions packages/css/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"loader": [
"./dist/loader.d.ts"
],
"loader-helpers": [
"./dist/loader-helpers.d.ts"
],
"loader-queries": [
"./loader-queries.d.ts"
],
Expand All @@ -32,6 +35,11 @@
"import": "./dist/loader.js",
"require": "./dist/cjs/loader.cjs"
},
"./loader-helpers": {
"types": "./dist/loader-helpers.d.ts",
"import": "./dist/loader-helpers.js",
"require": "./dist/cjs/loader-helpers.cjs"
},
"./loader-queries": {
"types": "./loader-queries.d.ts",
"default": "./loader-queries.d.ts"
Expand Down
11 changes: 11 additions & 0 deletions packages/css/src/loader-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { KnightedCssCombinedModule } from './loader.js'

// Keep helper side-effect free so bundlers can safely tree-shake it into web targets.
type KnightedCssCombinedExtras = Readonly<Record<string, unknown>>

export function asKnightedCssCombinedModule<
TModule,
TExtras extends KnightedCssCombinedExtras = Record<never, never>,
>(module: unknown): KnightedCssCombinedModule<TModule, TExtras> {
return module as KnightedCssCombinedModule<TModule, TExtras>
}
14 changes: 11 additions & 3 deletions packages/css/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ import {
import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js'
import { resolveStableNamespace } from './stableNamespace.js'

export type KnightedCssCombinedModule<TModule> = TModule & {
knightedCss: string
}
type KnightedCssCombinedExtras = Readonly<Record<string, unknown>>

export type KnightedCssCombinedModule<
TModule,
TExtras extends KnightedCssCombinedExtras = Record<never, never>,
> = TModule &
TExtras & {
knightedCss: string
}

export interface KnightedCssVanillaOptions {
transformToEsm?: boolean
Expand Down Expand Up @@ -50,6 +56,7 @@ const loader: LoaderDefinitionFunction<KnightedCssLoaderOptions> = async functio
namespace: resolvedNamespace,
resourcePath: this.resourcePath,
emitWarning: message => emitKnightedWarning(this, message),
target: 'js',
})
: undefined
const injection = buildInjection(css, {
Expand Down Expand Up @@ -131,6 +138,7 @@ export const pitch: PitchLoaderDefinitionFunction<KnightedCssLoaderOptions> =
namespace: resolvedNamespace,
resourcePath: this.resourcePath,
emitWarning: message => emitKnightedWarning(this, message),
target: 'js',
})
: undefined
return createCombinedModule(request, css, {
Expand Down
19 changes: 14 additions & 5 deletions packages/css/src/stableSelectorsLiteral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,22 @@ export interface StableSelectorsLiteralResult {
selectorMap: Map<string, string>
}

type StableSelectorsLiteralTarget = 'ts' | 'js'

export function buildStableSelectorsLiteral(options: {
css: string
namespace: string
resourcePath: string
emitWarning: (message: string) => void
target?: StableSelectorsLiteralTarget
}): StableSelectorsLiteralResult {
const target: StableSelectorsLiteralTarget = options.target ?? 'ts'
const trimmedNamespace = options.namespace.trim()
if (!trimmedNamespace) {
options.emitWarning(
`stableSelectors requested for ${options.resourcePath} but "stableNamespace" resolved to an empty value.`,
)
return {
literal: 'export const stableSelectors = {} as const;\n',
selectorMap: new Map<string, string>(),
}
return finalizeLiteral(new Map<string, string>(), target)
}

const selectorMap = collectStableSelectors(
Expand All @@ -34,9 +35,17 @@ export function buildStableSelectorsLiteral(options: {
`stableSelectors requested for ${options.resourcePath} but no selectors matched namespace "${trimmedNamespace}".`,
)
}
return finalizeLiteral(selectorMap, target)
}

function finalizeLiteral(
selectorMap: Map<string, string>,
target: StableSelectorsLiteralTarget,
): StableSelectorsLiteralResult {
const formatted = formatStableSelectorMap(selectorMap)
const suffix = target === 'ts' ? ' as const' : ''
return {
literal: `export const stableSelectors = ${formatStableSelectorMap(selectorMap)} as const;\n`,
literal: `export const stableSelectors = ${formatted}${suffix};\n`,
selectorMap,
}
}
Expand Down
8 changes: 4 additions & 4 deletions packages/css/test/loader_unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ test('loader emits stableSelectors export when ?types flag is present', async ()
assert.match(output, /export const stableSelectors = /)
assert.match(
output,
/export const stableSelectors = Object\.freeze\(\{\s*"demo": "knighted-demo",\s*"icon": "knighted-icon"\s*\}\) as const;/,
/export const stableSelectors = Object\.freeze\(\{\s*"demo": "knighted-demo",\s*"icon": "knighted-icon"\s*\}\);/,
'should emit map of detected selectors using default namespace',
)
})
Expand All @@ -195,7 +195,7 @@ test('loader respects stableNamespace loader option', async () => {

assert.match(
output,
/export const stableSelectors = Object\.freeze\(\{\s*"card": "acme-card"\s*\}\) as const;/,
/export const stableSelectors = Object\.freeze\(\{\s*"card": "acme-card"\s*\}\);/,
'should scope selector discovery to provided namespace',
)
})
Expand All @@ -218,7 +218,7 @@ test('loader warns when stableNamespace option resolves to empty value', async (
),
)

assert.match(output, /export const stableSelectors = \{\} as const;/)
assert.match(output, /export const stableSelectors = Object\.freeze\(\{\}\);/)
assert.equal(warnings.length, 1)
assert.match(
warnings[0] ?? '',
Expand Down Expand Up @@ -273,7 +273,7 @@ test('pitch injects stableSelectors export when combined types query is used', a
const combinedOutput = String(result ?? '')
assert.match(
combinedOutput,
/export const stableSelectors = Object\.freeze\(\{\s*"demo": "knighted-demo",\s*"icon": "knighted-icon"\s*\}\) as const;/,
/export const stableSelectors = Object\.freeze\(\{\s*"demo": "knighted-demo",\s*"icon": "knighted-icon"\s*\}\);/,
'combined proxy should forward stable selector map',
)
})
Expand Down
17 changes: 16 additions & 1 deletion packages/css/test/stableSelectorsLiteral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,26 @@ test('buildStableSelectorsLiteral warns when namespace is empty', () => {
resourcePath: 'demo.css',
emitWarning: message => warnings.push(message),
})
assert.equal(result.literal.trim(), 'export const stableSelectors = {} as const;')
assert.equal(
result.literal.trim(),
'export const stableSelectors = Object.freeze({}) as const;',
)
assert.equal(result.selectorMap.size, 0)
assert.equal(warnings.length, 1)
})

test('buildStableSelectorsLiteral emits JS-friendly literal when requested', () => {
const result = buildStableSelectorsLiteral({
css: '.knighted-card {}',
namespace: 'knighted',
resourcePath: 'demo.css',
emitWarning: () => {},
target: 'js',
})
assert.match(result.literal, /export const stableSelectors = Object\.freeze/)
assert.ok(!result.literal.includes('as const'))
})

test('collectStableSelectors captures selectors and formats map output', () => {
const { collectStableSelectors, formatStableSelectorMap } =
__stableSelectorsLiteralInternals
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ if (isCI) {

export default defineConfig({
testDir: 'test',
timeout: 30_000,
timeout: 20_000,
retries: isCI ? 1 : 0,
expect: {
timeout: 10_000,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
.combined-card {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 40%, #fcd34d 100%);
border-radius: 18px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
color: #111827;
display: flex;
flex-direction: column;
gap: 0.65rem;
padding: 1.35rem 1.5rem;
}

.combined-entry {
display: flex;
flex-direction: column;
gap: 0.35rem;
}

.combined-entry__subtitle {
color: rgba(15, 23, 42, 0.68);
font-size: 0.85rem;
letter-spacing: 0.04em;
}

.combined-entry__badge {
align-self: flex-start;
background: #0f172a;
border-radius: 999px;
color: #fefce8;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.07em;
padding: 0.2rem 0.75rem;
text-transform: uppercase;
}

.combined-details {
border-top: 1px solid rgba(15, 23, 42, 0.24);
font-size: 0.9rem;
line-height: 1.4;
margin: 0;
padding-top: 0.9rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import './combined-card-entry.css'

export const COMBINED_CARD_TEST_ID = 'dialect-combined'

export function CombinedCardBadge() {
return <span className="combined-entry__badge">Combined loader</span>
}

export function CombinedCardDetails() {
return (
<p className="combined-details" data-testid="combined-card-details">
The <code>?knighted-css&combined</code> query packages this component tree and its
CSS into a single payload so the Lit host can mount it like any other card.
</p>
)
}

export default function CombinedCardEntry() {
return (
<header className="combined-entry" data-testid="combined-card-entry">
<p className="combined-entry__subtitle">React + Lit</p>
<strong>Shared demo entry</strong>
<CombinedCardBadge />
</header>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'

import * as combinedModule from './combined-card-entry.js?knighted-css&combined'
import { COMBINED_CARD_TEST_ID } from './combined-card-entry.js'

const {
default: CombinedCardEntry,
CombinedCardBadge,
CombinedCardDetails,
knightedCss,
} = asKnightedCssCombinedModule<typeof import('./combined-card-entry.js')>(combinedModule)

export const combinedCardCss = knightedCss
export { COMBINED_CARD_TEST_ID } from './combined-card-entry.js'

export function CombinedCard() {
return (
<section className="combined-card" data-testid={COMBINED_CARD_TEST_ID}>
<CombinedCardEntry />
<CombinedCardDetails />
<CombinedCardBadge />
</section>
)
}
Loading