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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,32 @@ const { Button, knightedCss } = combined as KnightedCssCombinedModule<

You can mix and match: regular `?knighted-css` imports keep strong module typings and just add the CSS string, while `?knighted-css&combined` dedupes your CSS loader pipeline when you need everything at once.

### vanilla-extract loader guidance

vanilla-extract files (`*.css.ts`) compile down to CommonJS by default. That works out of the box for the loader—both `?knighted-css` and `?knighted-css&combined` queries emit `module.exports` artifacts plus the injected `knightedCss` string. Most bundlers happily consume that shape. When you _also_ need the compiled module to behave like a native ESM module (e.g., your bundler expects `export` statements so it can treeshake or when you import via extension aliases), enable the loader’s opt-in transform:

```js
// rspack.config.js (excerpt)
{
test: /\.css\.ts$/,
use: [
{
loader: '@knighted/css/loader',
options: {
lightningcss: { minify: true },
vanilla: { transformToEsm: true },
},
},
// swc/esbuild/etc.
],
}
```

The `vanilla.transformToEsm` flag runs a small post-pass that strips the CJS boilerplate emitted by `@vanilla-extract/integration` and re-exports the discovered bindings via native `export { name }` statements. That makes combined imports behave exactly like the source module, which is useful for frameworks that rely on strict ESM semantics (our Lit + React Playwright app is the canonical example in this repo).

> [!IMPORTANT]
> Only enable `vanilla.transformToEsm` when your bundler really requires ESM output. Leaving the transform off keeps the vanilla-extract module identical to what the upstream compiler produced, which is often preferable if the rest of your toolchain expects CommonJS. The loader no longer toggles this transform automatically—combined imports stay fast, but you remain in full control of when the conversion occurs.

### Custom resolver (enhanced-resolve example)

If your project uses aliases or nonstandard resolution, plug in a custom resolver. Here’s how to use [`enhanced-resolve`](https://github.com/webpack/enhanced-resolve):
Expand Down
21 changes: 16 additions & 5 deletions package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@
"setup:jsx-wasm": "node scripts/setup-jsx-wasm.mjs"
},
"devDependencies": {
"@knighted/duel": "^2.1.6",
"@emnapi/core": "^1.2.0",
"@emnapi/runtime": "^1.2.0",
"@knighted/duel": "^2.1.6",
"@napi-rs/wasm-runtime": "^1.0.7",
"@playwright/test": "^1.48.2",
"@rspack/cli": "^1.0.0",
"@rspack/core": "^1.0.0",
Expand All @@ -40,12 +41,12 @@
"@vanilla-extract/css": "^1.15.2",
"@vanilla-extract/integration": "^8.0.6",
"@vanilla-extract/recipes": "^0.5.7",
"@vanilla-extract/sprinkles": "^1.6.5",
"c8": "^10.1.2",
"http-server": "^14.1.1",
"husky": "^9.1.7",
"less": "^4.2.0",
"lint-staged": "^16.2.7",
"@napi-rs/wasm-runtime": "^1.0.7",
"oxlint": "^0.4.1",
"prettier": "^3.7.4",
"sass": "^1.80.7",
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.0.0-rc.0",
"version": "1.0.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: 12 additions & 4 deletions packages/css/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ interface StyleModule {
ext: string
}

export interface VanillaCompileResult {
source: string
css: string
}

/**
* Extract and compile all CSS-like dependencies for a given module.
*/
Expand Down Expand Up @@ -204,7 +209,7 @@ async function compileStyleModule(
case '.less':
return compileLess(file.path, peerResolver)
case '.css.ts':
return compileVanillaExtract(file.path, cwd, peerResolver)
return (await compileVanillaModule(file.path, cwd, peerResolver)).css
default:
return ''
}
Expand Down Expand Up @@ -235,11 +240,11 @@ async function compileLess(filePath: string, peerResolver?: PeerLoader): Promise
return result.css
}

async function compileVanillaExtract(
export async function compileVanillaModule(
filePath: string,
cwd: string,
peerResolver?: PeerLoader,
): Promise<string> {
): Promise<VanillaCompileResult> {
const mod = await optionalPeer<typeof import('@vanilla-extract/integration')>(
'@vanilla-extract/integration',
'Vanilla Extract',
Expand Down Expand Up @@ -290,7 +295,10 @@ async function compileVanillaExtract(
}
}

return imports.join('\n')
return {
source,
css: imports.join('\n'),
}
}

const defaultPeerLoader: PeerLoader = name => import(name)
Expand Down
132 changes: 114 additions & 18 deletions packages/css/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,75 @@ import type {
PitchLoaderDefinitionFunction,
} from 'webpack'

import { cssWithMeta, type CssOptions } from './css.js'
import { cssWithMeta, compileVanillaModule, type CssOptions } from './css.js'

export type KnightedCssCombinedModule<TModule> = TModule & {
knightedCss: string
}

export interface KnightedCssLoaderOptions extends CssOptions {}
export interface KnightedCssVanillaOptions {
transformToEsm?: boolean
}

export interface KnightedCssLoaderOptions extends CssOptions {
vanilla?: KnightedCssVanillaOptions
}

const DEFAULT_EXPORT_NAME = 'knightedCss'
const COMBINED_QUERY_FLAG = 'combined'

const loader: LoaderDefinitionFunction<KnightedCssLoaderOptions> = async function loader(
source: string | Buffer,
) {
const cssOptions = resolveCssOptions(this)
const { cssOptions, vanillaOptions } = resolveLoaderOptions(this)
const css = await extractCss(this, cssOptions)
const injection = buildInjection(css)
const input = toSourceString(source)
const isStyleModule = this.resourcePath.endsWith('.css.ts')
return isStyleModule ? `${injection}export default {};\n` : `${input}${injection}`
if (isStyleModule) {
const { source: compiledSource } = await compileVanillaModule(
this.resourcePath,
cssOptions.cwd ?? this.rootContext ?? process.cwd(),
cssOptions.peerResolver,
)
const vanillaSource = maybeTransformVanillaModule(compiledSource, vanillaOptions)
return `${vanillaSource}${injection}`
}

const input = toSourceString(source)
return `${input}${injection}`
}

function transformVanillaModuleToEsm(source: string): string {
const exportBlock = /__export\([^,]+,\s*{([\s\S]*?)}\);/m.exec(source)
if (!exportBlock) {
return source
}

const names = exportBlock[1]
.split(',')
.map(part => part.trim())
.filter(Boolean)
.map(entry => entry.split(':')[0]?.trim())
.filter(Boolean)

let transformed = source.replace(/module\.exports\s*=\s*__toCommonJS\([^;]+;\n?/m, '')
transformed = transformed.replace(/0 && \(module\.exports = {[^}]+}\);?\n?/m, '')

if (names.length > 0) {
transformed = `${transformed}\nexport { ${names.join(', ')} };\n`
}

return transformed
}

function maybeTransformVanillaModule(
source: string,
options?: KnightedCssVanillaOptions,
): string {
if (!options?.transformToEsm) {
return source
}
return transformVanillaModuleToEsm(source)
}

export const pitch: PitchLoaderDefinitionFunction<KnightedCssLoaderOptions> =
Expand All @@ -33,21 +82,29 @@ export const pitch: PitchLoaderDefinitionFunction<KnightedCssLoaderOptions> =
}

const request = buildProxyRequest(this)
const cssOptions = resolveCssOptions(this)
const { cssOptions } = resolveLoaderOptions(this)

return extractCss(this, cssOptions).then(css => createCombinedModule(request, css))
}
;(loader as LoaderDefinitionFunction & { pitch?: typeof pitch }).pitch = pitch

export default loader

function resolveCssOptions(ctx: LoaderContext<KnightedCssLoaderOptions>): CssOptions {
function resolveLoaderOptions(ctx: LoaderContext<KnightedCssLoaderOptions>): {
cssOptions: CssOptions
vanillaOptions?: KnightedCssVanillaOptions
} {
const rawOptions = (
typeof ctx.getOptions === 'function' ? ctx.getOptions() : {}
) as KnightedCssLoaderOptions
const { vanilla, ...rest } = rawOptions
const cssOptions: CssOptions = {
...rest,
cwd: rest.cwd ?? ctx.rootContext ?? process.cwd(),
}
return {
...rawOptions,
cwd: rawOptions.cwd ?? ctx.rootContext ?? process.cwd(),
cssOptions,
vanillaOptions: vanilla,
}
}

Expand Down Expand Up @@ -83,6 +140,11 @@ function hasCombinedQuery(query?: string | null): boolean {

function buildProxyRequest(ctx: LoaderContext<KnightedCssLoaderOptions>): string {
const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery)
const rawRequest = getRawRequest(ctx)
if (rawRequest) {
const stripped = stripResourceQuery(rawRequest)
return `${stripped}${sanitizedQuery}`
}
const request = `${ctx.resourcePath}${sanitizedQuery}`
const context = ctx.context ?? ctx.rootContext ?? process.cwd()
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
Expand All @@ -91,6 +153,24 @@ function buildProxyRequest(ctx: LoaderContext<KnightedCssLoaderOptions>): string
return request
}

function getRawRequest(ctx: LoaderContext<KnightedCssLoaderOptions>): string | undefined {
const mod = (
ctx as LoaderContext<KnightedCssLoaderOptions> & {
_module?: { rawRequest?: string }
}
)._module
const request = mod?.rawRequest
if (typeof request === 'string' && request.length > 0) {
return request
}
return undefined
}

function stripResourceQuery(request: string): string {
const idx = request.indexOf('?')
return idx >= 0 ? request.slice(0, idx) : request
}

function buildSanitizedQuery(query?: string | null): string {
if (!query) return ''
const entries = splitQuery(query).filter(part => {
Expand All @@ -116,15 +196,31 @@ function isQueryFlag(entry: string, flag: string): boolean {

function createCombinedModule(request: string, css: string): string {
const requestLiteral = JSON.stringify(request)
const defaultExport = `const __knightedDefault =
typeof __knightedModule.default !== 'undefined'
? __knightedModule.default
: __knightedModule;`
return [
const lines = [
`import * as __knightedModule from ${requestLiteral};`,
`export * from ${requestLiteral};`,
defaultExport,
'export default __knightedDefault;',
buildInjection(css),
].join('\n')
]

if (shouldForwardDefaultExport(request)) {
lines.push(
`const __knightedDefault =
typeof __knightedModule.default !== 'undefined'
? __knightedModule.default
: __knightedModule;`,
'export default __knightedDefault;',
)
}

lines.push(buildInjection(css))
return lines.join('\n')
}

function shouldForwardDefaultExport(request: string): boolean {
const [pathPart] = request.split('?')
if (!pathPart) return true
const lower = pathPart.toLowerCase()
if (lower.endsWith('.css.ts') || lower.endsWith('.css.js')) {
return false
}
return true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createGlobalTheme } from '@vanilla-extract/css'

createGlobalTheme(':root', {
color: {
primary: '#ff004d',
},
})
Loading