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
212 changes: 120 additions & 92 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@
"*.{js,jsx,ts,tsx,mjs,cjs,cts,mts,json,md,css,scss,html}": "prettier --check"
},
"optionalDependencies": {
"@oxc-parser/binding-wasm32-wasi": "^0.99.0"
"@oxc-parser/binding-wasm32-wasi": "^0.105.0"
}
}
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.3",
"version": "1.0.4",
"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
75 changes: 69 additions & 6 deletions packages/css/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'node:path'

import type {
LoaderContext,
LoaderDefinitionFunction,
Expand Down Expand Up @@ -203,15 +205,25 @@ function buildProxyRequest(ctx: LoaderContext<KnightedCssLoaderOptions>): string
const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery)
const rawRequest = getRawRequest(ctx)
if (rawRequest) {
const stripped = stripResourceQuery(rawRequest)
return `${stripped}${sanitizedQuery}`
return rebuildProxyRequestFromRaw(ctx, rawRequest, sanitizedQuery)
}
const request = `${ctx.resourcePath}${sanitizedQuery}`
const context = ctx.context ?? ctx.rootContext ?? process.cwd()
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
return ctx.utils.contextify(context, request)
return contextifyRequest(ctx, request)
}

function rebuildProxyRequestFromRaw(
ctx: LoaderContext<KnightedCssLoaderOptions>,
rawRequest: string,
sanitizedQuery: string,
): string {
const stripped = stripResourceQuery(rawRequest)
const loaderDelimiter = stripped.lastIndexOf('!')
const loaderPrefix = loaderDelimiter >= 0 ? stripped.slice(0, loaderDelimiter + 1) : ''
let resource = loaderDelimiter >= 0 ? stripped.slice(loaderDelimiter + 1) : stripped
if (isRelativeSpecifier(resource)) {
resource = makeResourceRelativeToContext(ctx, ctx.resourcePath)
}
return request
return `${loaderPrefix}${resource}${sanitizedQuery}`
}

function getRawRequest(ctx: LoaderContext<KnightedCssLoaderOptions>): string | undefined {
Expand All @@ -232,6 +244,57 @@ function stripResourceQuery(request: string): string {
return idx >= 0 ? request.slice(0, idx) : request
}

function contextifyRequest(
ctx: LoaderContext<KnightedCssLoaderOptions>,
request: string,
): string {
const context = ctx.context ?? ctx.rootContext ?? process.cwd()
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
return ctx.utils.contextify(context, request)
}
return rebuildRelativeRequest(context, request)
}

function rebuildRelativeRequest(context: string, request: string): string {
const queryIndex = request.indexOf('?')
const resourcePath = queryIndex >= 0 ? request.slice(0, queryIndex) : request
const query = queryIndex >= 0 ? request.slice(queryIndex) : ''
const relative = ensureDotPrefixedRelative(
path.relative(context, resourcePath),
resourcePath,
)
return `${relative}${query}`
}

function makeResourceRelativeToContext(
ctx: LoaderContext<KnightedCssLoaderOptions>,
resourcePath: string,
): string {
const context = ctx.context ?? path.dirname(resourcePath)
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
const result = ctx.utils.contextify(context, resourcePath)
return stripResourceQuery(result)
}
return ensureDotPrefixedRelative(path.relative(context, resourcePath), resourcePath)
}

function ensureDotPrefixedRelative(relativePath: string, resourcePath: string): string {
const fallback = relativePath.length > 0 ? relativePath : path.basename(resourcePath)
const normalized = normalizeToPosix(fallback)
if (normalized.startsWith('./') || normalized.startsWith('../')) {
return normalized
}
return `./${normalized}`
}

function normalizeToPosix(filePath: string): string {
return filePath.split(path.sep).join('/')
}

function isRelativeSpecifier(specifier: string): boolean {
return specifier.startsWith('./') || specifier.startsWith('../')
}

interface CombinedModuleOptions {
emitDefault?: boolean
stableSelectorsLiteral?: string
Expand Down
66 changes: 63 additions & 3 deletions packages/css/test/loaderUnit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ test('pitch preserves undecodable query fragments when sanitizing requests', asy
)
})

test('pitch reuses rawRequest when building proxy module', async () => {
test('pitch rewrites rawRequest relative to the resource when building proxy module', async () => {
const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js')
const ctx = createMockContext({
resourcePath,
Expand All @@ -380,9 +380,69 @@ test('pitch reuses rawRequest when building proxy module', async () => {
const combinedOutput = String(result ?? '')
assert.match(
combinedOutput,
/import \* as __knightedModule from "\.\/aliased\/entry\.js\?chunk=demo";/,
/import \* as __knightedModule from "\.\/entry\.js\?chunk=demo";/,
)
assert.match(combinedOutput, /export \* from "\.\/aliased\/entry\.js\?chunk=demo";/)
assert.match(combinedOutput, /export \* from "\.\/entry\.js\?chunk=demo";/)
})

test('pitch rewrites relative rawRequest specifiers to resource-local paths', async () => {
const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js')
const ctx = createMockContext({
resourcePath,
context: path.dirname(resourcePath),
resourceQuery: '?knighted-css&combined',
_module: {
rawRequest: './components/entry.js?knighted-css&combined',
} as LoaderContext<KnightedCssLoaderOptions>['_module'],
loadModule: (_request: string, callback: LoaderCallback) => {
callback(null, 'export const stub = 1;')
},
})

const result = await pitch.call(
ctx as LoaderContext<KnightedCssLoaderOptions>,
`${resourcePath}?knighted-css&combined`,
'',
{},
)

const combinedOutput = String(result ?? '')
assert.match(
combinedOutput,
/import \* as __knightedModule from "\.\/entry\.js";/,
'should rebase proxy specifier next to the resource',
)
assert.match(combinedOutput, /export \* from "\.\/entry\.js";/)
})

test('pitch preserves inline loader prefixes while rebasing relative specifiers', async () => {
const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js')
const ctx = createMockContext({
resourcePath,
context: path.dirname(resourcePath),
resourceQuery: '?knighted-css&combined&chunk=demo',
_module: {
rawRequest: 'style-loader!./components/entry.js?knighted-css&combined&chunk=demo',
} as LoaderContext<KnightedCssLoaderOptions>['_module'],
loadModule: (_request: string, callback: LoaderCallback) => {
callback(null, 'export const stub = 1;')
},
})

const result = await pitch.call(
ctx as LoaderContext<KnightedCssLoaderOptions>,
`${resourcePath}?knighted-css&combined&chunk=demo`,
'',
{},
)

const combinedOutput = String(result ?? '')
assert.match(
combinedOutput,
/import \* as __knightedModule from "style-loader!\.\/entry\.js\?chunk=demo";/,
'should retain loader prefixes but drop the duplicated folder segment',
)
assert.match(combinedOutput, /export \* from "style-loader!\.\/entry\.js\?chunk=demo";/)
})

test('combined modules skip default export for vanilla style entries', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"pretest": "npm run build"
},
"dependencies": {
"@knighted/css": "1.0.3",
"@knighted/jsx": "^1.4.1",
"@knighted/css": "1.0.4",
"@knighted/jsx": "^1.6.1",
"lit": "^3.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright/src/lit-react/app.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
@import '@knighted/css/stable/stable.css';
@layer knighted.stable;

@layer knighted.stable {
:root {
--knighted-stable-namespace: 'knighted';
}

.knighted-layer-glow {
box-shadow: 0 25px 55px rgba(14, 165, 233, 0.35);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
.nested-combined-card {
background: radial-gradient(circle at top left, #eef2ff 0%, #c7d2fe 45%, #a5b4fc 100%);
border-radius: 22px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
color: #0f172a;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.5rem;
}

.nested-entry {
display: flex;
flex-direction: column;
gap: 0.4rem;
}

.nested-entry__subtitle {
color: rgba(15, 23, 42, 0.6);
font-size: 0.85rem;
letter-spacing: 0.05em;
}

.nested-entry__badge {
align-self: flex-start;
background: #312e81;
border-radius: 999px;
color: #ede9fe;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.2rem 0.8rem;
text-transform: uppercase;
}

.nested-details {
border-top: 1px solid rgba(49, 46, 129, 0.35);
font-size: 0.92rem;
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 './nested-combined-entry.css'

export const NESTED_COMBINED_TEST_ID = 'dialect-nested-combined'

export function NestedCombinedBadge() {
return <span className="nested-entry__badge">Nested combined loader</span>
}

export function NestedCombinedDetails() {
return (
<p className="nested-details" data-testid="nested-combined-details">
The Lit host lives one directory above this entry module, mirroring the css-jsx-app
structure that once broke `?knighted-css&combined` relative imports.
</p>
)
}

export default function NestedCombinedEntry() {
return (
<header className="nested-entry" data-testid="nested-combined-entry">
<p className="nested-entry__subtitle">Parent + Child dirs</p>
<strong>Nested combined example</strong>
<NestedCombinedBadge />
</header>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'

import * as nestedModule from './components/nested-combined-entry.js?knighted-css&combined'
import { NESTED_COMBINED_TEST_ID } from './components/nested-combined-entry.js'

const {
default: NestedCombinedEntry,
NestedCombinedBadge,
NestedCombinedDetails,
knightedCss,
} = asKnightedCssCombinedModule<typeof import('./components/nested-combined-entry.js')>(
nestedModule,
)

export const nestedCombinedCardCss = knightedCss
export { NESTED_COMBINED_TEST_ID } from './components/nested-combined-entry.js'

export function NestedCombinedCard() {
return (
<section className="nested-combined-card" data-testid={NESTED_COMBINED_TEST_ID}>
<NestedCombinedEntry />
<NestedCombinedDetails />
<NestedCombinedBadge />
</section>
)
}
10 changes: 10 additions & 0 deletions packages/playwright/src/lit-react/showcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
COMBINED_CARD_TEST_ID,
combinedCardCss,
} from './cards/combined-card/combined-card.js'
import {
NestedCombinedCard,
NESTED_COMBINED_TEST_ID,
nestedCombinedCardCss,
} from './cards/nested-combined-card/nested-combined-card.js'
import {
CombinedTypesCard,
COMBINED_TYPES_TEST_ID,
Expand Down Expand Up @@ -70,6 +75,11 @@ const cards: DialectCard[] = [
css: combinedCardCss,
Component: CombinedCard,
},
{
id: NESTED_COMBINED_TEST_ID,
css: nestedCombinedCardCss,
Component: NestedCombinedCard,
},
{
id: COMBINED_TYPES_TEST_ID,
css: combinedTypesCardCss,
Expand Down
27 changes: 27 additions & 0 deletions packages/playwright/test/lit-react.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const dialectCases = [
{ id: 'dialect-css-modules', property: 'color' },
{ id: 'dialect-vanilla', property: 'color' },
{ id: 'dialect-combined', property: 'background-image' },
{ id: 'dialect-nested-combined', property: 'background-image' },
{ id: 'dialect-combined-types', property: 'border-color' },
{ id: 'dialect-named-only', property: 'background-image' },
]
Expand Down Expand Up @@ -146,6 +147,32 @@ test.describe('Lit + React wrapper demo', () => {
expect(metrics.background).toContain('linear-gradient')
})

test('nested combined import works when the host lives one directory up', async ({
page,
}) => {
const card = page.getByTestId('dialect-nested-combined')
await expect(card).toBeVisible()
const metrics = await card.evaluate(node => {
const el = node as HTMLElement
const entry = el.querySelector(
'[data-testid="nested-combined-entry"]',
) as HTMLElement | null
const details = el.querySelector(
'[data-testid="nested-combined-details"]',
) as HTMLElement | null
const style = getComputedStyle(el)
return {
entryText: entry?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
detailsText: details?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
background: style.getPropertyValue('background-image').trim(),
}
})

expect(metrics.entryText).toContain('Nested combined example')
expect(metrics.detailsText).toContain('css-jsx-app')
expect(metrics.background).toContain('radial-gradient')
})

test('combined & types import keeps runtime selectors synced', async ({ page }) => {
const card = page.getByTestId('dialect-combined-types')
await expect(card).toBeVisible()
Expand Down