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
12 changes: 12 additions & 0 deletions docs/loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

`@knighted/css/loader` lets bundlers attach compiled CSS strings to any module by appending the `?knighted-css` query when importing. The loader mirrors the module graph, compiles every CSS dialect it discovers (CSS, Sass, Less, vanilla-extract, etc.), and exposes the concatenated result as `knightedCss`.

## Loader vs bridge (quick comparison)

Use this table to decide which loader you need before wiring up rules:

| Capability | @knighted/css/loader | @knighted/css/loader-bridge |
| ------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| Input | Original JS/TS module source + its style imports | Compiled CSS Modules output (post-hash) |
| CSS extraction | Yes (walks the import graph) | No (wraps upstream output) |
| Export behavior | Appends `knightedCss` (and optional selector exports) onto the original module | Exposes `knightedCss`/`knightedCssModules` only; does **not** re-export JS/TS module exports |
| When to use | Default choice for `?knighted-css` in JS/TS modules | When you need hashed CSS Modules output for runtime `knightedCss` |
| Combined wrapper needed? | Only for explicit `?knighted-css&combined` usage | **Yes** if you still need original JS/TS exports (use `&combined` via the resolver plugin) |

## Loader example

```ts
Expand Down
19 changes: 18 additions & 1 deletion docs/plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ type KnightedCssResolverPluginOptions = {
`.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs`.
- `debug` (optional): Logs rewrite decisions and a summary of cache hits/misses.
- `combinedPaths` (optional): List of strings or regexes. Any resolved path that matches will
receive `&combined` alongside `?knighted-css`.
receive `&combined` alongside `?knighted-css`. Use this when the request is handled by
`@knighted/css/loader-bridge` (hashed CSS Modules) or whenever you need a wrapper module
that re-exports the original JS/TS exports plus `knightedCss`.
- `strictSidecar` (optional): When true, only modules present in the manifest are rewritten.
Defaults to true when `manifestPath` is provided.
- `manifestPath` (optional): Path to the sidecar manifest generated by
Expand Down Expand Up @@ -118,6 +120,21 @@ export default {
If you have modules that are consumed with combined exports (`?knighted-css&combined`),
set `combinedPaths` to ensure the resolver appends `&combined` during rewrites.

`&combined` is required when the underlying loader chain does **not** preserve the module’s
original exports. The common case is declaration mode + `--hashed`, where requests flow through
`@knighted/css/loader-bridge` and would otherwise only expose `knightedCss`/`knightedCssModules`.
Appending `&combined` tells the loader to generate a small wrapper module that re-exports the
original module and then appends the `knightedCss` exports.

You typically **do not** need `combinedPaths` when requests are handled by `@knighted/css/loader`
(non-hashed declaration mode), because that loader appends exports directly onto the original
module.

Outside of loader-bridge, `&combined` is still useful when you are **not** running
`knighted-css-generate-types` but want a single runtime import that includes both the original
JS/TS exports and `knightedCss` (for example, in runtime-only builds, tests, or tooling that
cannot resolve the generated `.knighted-css` proxy modules).

Example of an import that relies on `&combined` at runtime:

```ts
Expand Down
3 changes: 2 additions & 1 deletion docs/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ The CLI only emits `.d.ts` sidecars for files that pass all of the following che
- **Within the project root**: if a candidate file resolves outside `--root`, the CLI skips it
and logs a warning.
- **Imports styles**: the file must import/require a style resource directly (e.g. `.css`,
`.scss`, `.sass`, `.less`) or resolve to one via tsconfig paths / resolver hooks.
`.scss`, `.sass`, `.less`, or vanilla-extract `.css.ts` / `.css.js`) or resolve to one via
tsconfig paths / resolver hooks.
- **Produces selectors**: the extracted CSS must be non-empty and yield at least one selector
token; otherwise the sidecar is skipped.

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.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"engineStrict": true,
"scripts": {
"build": "npm run build -w @knighted/css",
"prepare": "husky",
"test": "npm run test -w @knighted/css",
"pretest": "npm run build",
"test:e2e": "npm run test -w @knighted/css-playwright-fixture",
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.2.0-rc.0",
"version": "1.2.0-rc.1",
"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
16 changes: 14 additions & 2 deletions packages/css/src/generateTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,18 @@ function isStyleResource(filePath: string): boolean {
return STYLE_EXTENSIONS.some(ext => normalized.endsWith(ext))
}

function isVanillaExtractResource(filePath: string): boolean {
const normalized = filePath.toLowerCase()
return (
normalized.endsWith('.css.ts') ||
normalized.endsWith('.css.js') ||
normalized.endsWith('.css.mts') ||
normalized.endsWith('.css.cts') ||
normalized.endsWith('.css.mjs') ||
normalized.endsWith('.css.cjs')
)
}

function isCssModuleResource(filePath: string): boolean {
return /\.module\.(css|scss|sass|less)$/i.test(filePath)
}
Expand Down Expand Up @@ -1116,7 +1128,7 @@ async function hasStyleImports(
if (!resource) {
continue
}
if (isStyleResource(resource)) {
if (isStyleResource(resource) || isVanillaExtractResource(resource)) {
return true
}
const resolved = await resolveImportPath(
Expand All @@ -1128,7 +1140,7 @@ async function hasStyleImports(
options.resolverFactory,
RESOLUTION_EXTENSIONS,
)
if (resolved && isStyleResource(resolved)) {
if (resolved && (isStyleResource(resolved) || isVanillaExtractResource(resolved))) {
return true
}
}
Expand Down
47 changes: 46 additions & 1 deletion packages/css/test/generateTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const {
resolveIndexFallback,
readManifest,
writeSidecarManifest,
hasStyleImports,
} = __generateTypesInternals

let cachedCliSnapshots: Record<string, string> | null = null
Expand Down Expand Up @@ -390,6 +391,46 @@ test('generateTypes declaration mode skips files without style imports', async (
}
})

test('hasStyleImports treats vanilla extract modules as style imports', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-vanilla-'))
try {
const srcDir = path.join(root, 'src')
await fs.mkdir(srcDir, { recursive: true })
await fs.writeFile(
path.join(srcDir, 'styles.css.ts'),
"export const theme = { color: 'rebeccapurple' }\n",
)
const entryPath = path.join(srcDir, 'entry.ts')
await fs.writeFile(entryPath, "import './styles.css.ts'\n")

const hasStyles = await hasStyleImports(entryPath, { rootDir: root })
assert.equal(hasStyles, true)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})

test('hasStyleImports treats vanilla extract extension variants as style imports', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-vanilla-variants-'))
try {
const srcDir = path.join(root, 'src')
await fs.mkdir(srcDir, { recursive: true })
const entryPath = path.join(srcDir, 'entry.ts')
const variants = ['.css.js', '.css.mjs', '.css.cjs', '.css.mts', '.css.cts']

for (const ext of variants) {
const stylePath = path.join(srcDir, `styles${ext}`)
await fs.writeFile(stylePath, '')
await fs.writeFile(entryPath, `import './styles${ext}'\n`)

const hasStyles = await hasStyleImports(entryPath, { rootDir: root })
assert.equal(hasStyles, true)
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})

test('generateTypes hashed emits selector proxies for modules', async () => {
const project = await setupFixtureProject()
try {
Expand Down Expand Up @@ -616,7 +657,11 @@ test('generateTypes resolves hash-imports workspace package.json imports', async
const sassModuleDir = path.join(appRoot, 'node_modules', 'sass')
await fs.mkdir(path.dirname(sassModuleDir), { recursive: true })
try {
await fs.symlink(sassPackageDir, sassModuleDir)
await fs.symlink(
sassPackageDir,
sassModuleDir,
process.platform === 'win32' ? 'junction' : 'dir',
)
} catch {
await fs.cp(sassPackageDir, sassModuleDir, { recursive: true })
}
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"pretest": "npm run types && npm run build"
},
"dependencies": {
"@knighted/css": "1.2.0-rc.0",
"@knighted/css": "1.2.0-rc.1",
"@knighted/jsx": "^1.7.5",
"lit": "^3.2.1",
"react": "^19.0.0",
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright/rspack.mode.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export default async () => ({
},
module: {
rules: [
{
test: /\.css\.ts$/,
type: 'javascript/auto',
use: [
{
loader: '@knighted/css/loader',
options: {
vanilla: { transformToEsm: true },
},
},
],
},
{
test: /\.module\.css$/,
include: declarationHashedDir,
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright/src/mode/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const MODE_MODULE_HOST_TAG = 'knighted-mode-module-host'
export const MODE_DECL_HOST_TAG = 'knighted-mode-declaration-host'
export const MODE_DECL_VANILLA_HOST_TAG = 'knighted-mode-declaration-vanilla-host'
export const MODE_DECL_HASHED_HOST_TAG = 'knighted-mode-declaration-hashed-host'
export const MODE_DECL_STABLE_HOST_TAG = 'knighted-mode-declaration-stable-host'

Expand All @@ -10,11 +11,13 @@ export const MODE_DECL_STRICT_SKIP_PROBE_TEST_ID = 'mode-declaration-strict-skip

export const MODE_MODULE_HOST_TEST_ID = 'mode-module-host'
export const MODE_DECL_HOST_TEST_ID = 'mode-declaration-host'
export const MODE_DECL_VANILLA_HOST_TEST_ID = 'mode-declaration-vanilla-host'
export const MODE_DECL_HASHED_HOST_TEST_ID = 'mode-declaration-hashed-host'
export const MODE_DECL_STABLE_HOST_TEST_ID = 'mode-declaration-stable-host'

export const MODE_MODULE_LIGHT_TEST_ID = 'mode-module-light'
export const MODE_DECL_LIGHT_TEST_ID = 'mode-declaration-light'
export const MODE_DECL_VANILLA_LIGHT_TEST_ID = 'mode-declaration-vanilla-light'

export const MODE_DECL_HASHED_LIGHT_TEST_ID = 'mode-declaration-hashed-light'
export const MODE_DECL_STABLE_LIGHT_TEST_ID = 'mode-declaration-stable-light'
Expand All @@ -24,5 +27,6 @@ export const MODE_DECL_STABLE_SELECTOR_TEST_ID = 'mode-declaration-stable-select

export const MODE_MODULE_SHADOW_TEST_ID = 'mode-module-shadow'
export const MODE_DECL_SHADOW_TEST_ID = 'mode-declaration-shadow'
export const MODE_DECL_VANILLA_SHADOW_TEST_ID = 'mode-declaration-vanilla-shadow'
export const MODE_DECL_HASHED_SHADOW_TEST_ID = 'mode-declaration-hashed-shadow'
export const MODE_DECL_STABLE_SHADOW_TEST_ID = 'mode-declaration-stable-shadow'
15 changes: 15 additions & 0 deletions packages/playwright/src/mode/declaration/vanilla-card.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { globalStyle, style } from '@vanilla-extract/css'

export const vanillaCardClass = style({
background: 'rgb(14, 116, 144)',
color: 'rgb(236, 254, 255)',
borderRadius: '24px',
})

globalStyle('[data-testid="mode-declaration-vanilla-light"]', {
boxShadow: '0 18px 40px rgba(14, 116, 144, 0.35)',
})

globalStyle('[data-testid="mode-declaration-vanilla-shadow"]', {
boxShadow: '0 18px 40px rgba(14, 116, 144, 0.35)',
})
21 changes: 21 additions & 0 deletions packages/playwright/src/mode/declaration/vanilla-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import '../mode.css'
import { vanillaCardClass } from './vanilla-card.css.js'

type DeclarationVanillaCardProps = {
label: string
testId: string
}

export function DeclarationVanillaCard({ label, testId }: DeclarationVanillaCardProps) {
return (
<article
className={`knighted-mode-declaration-card ${vanillaCardClass}`}
data-testid={testId}
>
<h2 className="knighted-mode-declaration-card__title">
Declaration vanilla-extract
</h2>
<p className="knighted-mode-declaration-card__copy">{label}</p>
</article>
)
}
64 changes: 64 additions & 0 deletions packages/playwright/src/mode/declaration/vanilla-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { reactJsx } from '@knighted/jsx/react'
import { createRoot, type Root } from 'react-dom/client'
import { LitElement, css, html, unsafeCSS } from 'lit'

import {
DeclarationVanillaCard,
knightedCss as declarationVanillaCss,
} from './vanilla-card.js'
import {
MODE_DECL_VANILLA_HOST_TAG,
MODE_DECL_VANILLA_SHADOW_TEST_ID,
} from '../constants.js'

const hostShell = css`
:host {
display: block;
padding: 1.5rem;
border-radius: 1.5rem;
background: rgb(15, 23, 42);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2);
}
`

export class ModeDeclarationVanillaHost extends LitElement {
static styles = [hostShell, unsafeCSS(declarationVanillaCss)]
#reactRoot?: Root

firstUpdated(): void {
this.#mountReact()
}

disconnectedCallback(): void {
this.#reactRoot?.unmount()
super.disconnectedCallback()
}

#mountReact(): void {
if (!this.#reactRoot) {
const outlet = this.renderRoot.querySelector(
'[data-react-root]',
) as HTMLDivElement | null
if (!outlet) return
this.#reactRoot = createRoot(outlet)
}
this.#renderReactTree()
}

#renderReactTree(): void {
if (!this.#reactRoot) return
this.#reactRoot.render(
reactJsx`<${DeclarationVanillaCard} label="Shadow DOM" testId=${MODE_DECL_VANILLA_SHADOW_TEST_ID} />`,
)
}

render() {
return html`<div data-react-root></div>`
}
}

export function ensureModeDeclarationVanillaHostDefined(): void {
if (!customElements.get(MODE_DECL_VANILLA_HOST_TAG)) {
customElements.define(MODE_DECL_VANILLA_HOST_TAG, ModeDeclarationVanillaHost)
}
}
Loading
Loading