Skip to content

Commit dff254d

Browse files
fix: loader context. (#45)
1 parent c497600 commit dff254d

12 files changed

Lines changed: 392 additions & 106 deletions

File tree

package-lock.json

Lines changed: 120 additions & 92 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,6 @@
7171
"*.{js,jsx,ts,tsx,mjs,cjs,cts,mts,json,md,css,scss,html}": "prettier --check"
7272
},
7373
"optionalDependencies": {
74-
"@oxc-parser/binding-wasm32-wasi": "^0.99.0"
74+
"@oxc-parser/binding-wasm32-wasi": "^0.105.0"
7575
}
7676
}

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.0.3",
3+
"version": "1.0.4",
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/loader.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import path from 'node:path'
2+
13
import type {
24
LoaderContext,
35
LoaderDefinitionFunction,
@@ -203,15 +205,25 @@ function buildProxyRequest(ctx: LoaderContext<KnightedCssLoaderOptions>): string
203205
const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery)
204206
const rawRequest = getRawRequest(ctx)
205207
if (rawRequest) {
206-
const stripped = stripResourceQuery(rawRequest)
207-
return `${stripped}${sanitizedQuery}`
208+
return rebuildProxyRequestFromRaw(ctx, rawRequest, sanitizedQuery)
208209
}
209210
const request = `${ctx.resourcePath}${sanitizedQuery}`
210-
const context = ctx.context ?? ctx.rootContext ?? process.cwd()
211-
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
212-
return ctx.utils.contextify(context, request)
211+
return contextifyRequest(ctx, request)
212+
}
213+
214+
function rebuildProxyRequestFromRaw(
215+
ctx: LoaderContext<KnightedCssLoaderOptions>,
216+
rawRequest: string,
217+
sanitizedQuery: string,
218+
): string {
219+
const stripped = stripResourceQuery(rawRequest)
220+
const loaderDelimiter = stripped.lastIndexOf('!')
221+
const loaderPrefix = loaderDelimiter >= 0 ? stripped.slice(0, loaderDelimiter + 1) : ''
222+
let resource = loaderDelimiter >= 0 ? stripped.slice(loaderDelimiter + 1) : stripped
223+
if (isRelativeSpecifier(resource)) {
224+
resource = makeResourceRelativeToContext(ctx, ctx.resourcePath)
213225
}
214-
return request
226+
return `${loaderPrefix}${resource}${sanitizedQuery}`
215227
}
216228

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

247+
function contextifyRequest(
248+
ctx: LoaderContext<KnightedCssLoaderOptions>,
249+
request: string,
250+
): string {
251+
const context = ctx.context ?? ctx.rootContext ?? process.cwd()
252+
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
253+
return ctx.utils.contextify(context, request)
254+
}
255+
return rebuildRelativeRequest(context, request)
256+
}
257+
258+
function rebuildRelativeRequest(context: string, request: string): string {
259+
const queryIndex = request.indexOf('?')
260+
const resourcePath = queryIndex >= 0 ? request.slice(0, queryIndex) : request
261+
const query = queryIndex >= 0 ? request.slice(queryIndex) : ''
262+
const relative = ensureDotPrefixedRelative(
263+
path.relative(context, resourcePath),
264+
resourcePath,
265+
)
266+
return `${relative}${query}`
267+
}
268+
269+
function makeResourceRelativeToContext(
270+
ctx: LoaderContext<KnightedCssLoaderOptions>,
271+
resourcePath: string,
272+
): string {
273+
const context = ctx.context ?? path.dirname(resourcePath)
274+
if (ctx.utils && typeof ctx.utils.contextify === 'function') {
275+
const result = ctx.utils.contextify(context, resourcePath)
276+
return stripResourceQuery(result)
277+
}
278+
return ensureDotPrefixedRelative(path.relative(context, resourcePath), resourcePath)
279+
}
280+
281+
function ensureDotPrefixedRelative(relativePath: string, resourcePath: string): string {
282+
const fallback = relativePath.length > 0 ? relativePath : path.basename(resourcePath)
283+
const normalized = normalizeToPosix(fallback)
284+
if (normalized.startsWith('./') || normalized.startsWith('../')) {
285+
return normalized
286+
}
287+
return `./${normalized}`
288+
}
289+
290+
function normalizeToPosix(filePath: string): string {
291+
return filePath.split(path.sep).join('/')
292+
}
293+
294+
function isRelativeSpecifier(specifier: string): boolean {
295+
return specifier.startsWith('./') || specifier.startsWith('../')
296+
}
297+
235298
interface CombinedModuleOptions {
236299
emitDefault?: boolean
237300
stableSelectorsLiteral?: string

packages/css/test/loaderUnit.test.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ test('pitch preserves undecodable query fragments when sanitizing requests', asy
357357
)
358358
})
359359

360-
test('pitch reuses rawRequest when building proxy module', async () => {
360+
test('pitch rewrites rawRequest relative to the resource when building proxy module', async () => {
361361
const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js')
362362
const ctx = createMockContext({
363363
resourcePath,
@@ -380,9 +380,69 @@ test('pitch reuses rawRequest when building proxy module', async () => {
380380
const combinedOutput = String(result ?? '')
381381
assert.match(
382382
combinedOutput,
383-
/import \* as __knightedModule from "\.\/aliased\/entry\.js\?chunk=demo";/,
383+
/import \* as __knightedModule from "\.\/entry\.js\?chunk=demo";/,
384384
)
385-
assert.match(combinedOutput, /export \* from "\.\/aliased\/entry\.js\?chunk=demo";/)
385+
assert.match(combinedOutput, /export \* from "\.\/entry\.js\?chunk=demo";/)
386+
})
387+
388+
test('pitch rewrites relative rawRequest specifiers to resource-local paths', async () => {
389+
const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js')
390+
const ctx = createMockContext({
391+
resourcePath,
392+
context: path.dirname(resourcePath),
393+
resourceQuery: '?knighted-css&combined',
394+
_module: {
395+
rawRequest: './components/entry.js?knighted-css&combined',
396+
} as LoaderContext<KnightedCssLoaderOptions>['_module'],
397+
loadModule: (_request: string, callback: LoaderCallback) => {
398+
callback(null, 'export const stub = 1;')
399+
},
400+
})
401+
402+
const result = await pitch.call(
403+
ctx as LoaderContext<KnightedCssLoaderOptions>,
404+
`${resourcePath}?knighted-css&combined`,
405+
'',
406+
{},
407+
)
408+
409+
const combinedOutput = String(result ?? '')
410+
assert.match(
411+
combinedOutput,
412+
/import \* as __knightedModule from "\.\/entry\.js";/,
413+
'should rebase proxy specifier next to the resource',
414+
)
415+
assert.match(combinedOutput, /export \* from "\.\/entry\.js";/)
416+
})
417+
418+
test('pitch preserves inline loader prefixes while rebasing relative specifiers', async () => {
419+
const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js')
420+
const ctx = createMockContext({
421+
resourcePath,
422+
context: path.dirname(resourcePath),
423+
resourceQuery: '?knighted-css&combined&chunk=demo',
424+
_module: {
425+
rawRequest: 'style-loader!./components/entry.js?knighted-css&combined&chunk=demo',
426+
} as LoaderContext<KnightedCssLoaderOptions>['_module'],
427+
loadModule: (_request: string, callback: LoaderCallback) => {
428+
callback(null, 'export const stub = 1;')
429+
},
430+
})
431+
432+
const result = await pitch.call(
433+
ctx as LoaderContext<KnightedCssLoaderOptions>,
434+
`${resourcePath}?knighted-css&combined&chunk=demo`,
435+
'',
436+
{},
437+
)
438+
439+
const combinedOutput = String(result ?? '')
440+
assert.match(
441+
combinedOutput,
442+
/import \* as __knightedModule from "style-loader!\.\/entry\.js\?chunk=demo";/,
443+
'should retain loader prefixes but drop the duplicated folder segment',
444+
)
445+
assert.match(combinedOutput, /export \* from "style-loader!\.\/entry\.js\?chunk=demo";/)
386446
})
387447

388448
test('combined modules skip default export for vanilla style entries', async () => {

packages/playwright/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
"pretest": "npm run build"
1616
},
1717
"dependencies": {
18-
"@knighted/css": "1.0.3",
19-
"@knighted/jsx": "^1.4.1",
18+
"@knighted/css": "1.0.4",
19+
"@knighted/jsx": "^1.6.1",
2020
"lit": "^3.2.1",
2121
"react": "^19.0.0",
2222
"react-dom": "^19.0.0"

packages/playwright/src/lit-react/app.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
@import '@knighted/css/stable/stable.css';
1+
@layer knighted.stable;
22

33
@layer knighted.stable {
4+
:root {
5+
--knighted-stable-namespace: 'knighted';
6+
}
7+
48
.knighted-layer-glow {
59
box-shadow: 0 25px 55px rgba(14, 165, 233, 0.35);
610
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
.nested-combined-card {
2+
background: radial-gradient(circle at top left, #eef2ff 0%, #c7d2fe 45%, #a5b4fc 100%);
3+
border-radius: 22px;
4+
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
5+
color: #0f172a;
6+
display: flex;
7+
flex-direction: column;
8+
gap: 0.85rem;
9+
padding: 1.5rem;
10+
}
11+
12+
.nested-entry {
13+
display: flex;
14+
flex-direction: column;
15+
gap: 0.4rem;
16+
}
17+
18+
.nested-entry__subtitle {
19+
color: rgba(15, 23, 42, 0.6);
20+
font-size: 0.85rem;
21+
letter-spacing: 0.05em;
22+
}
23+
24+
.nested-entry__badge {
25+
align-self: flex-start;
26+
background: #312e81;
27+
border-radius: 999px;
28+
color: #ede9fe;
29+
font-size: 0.7rem;
30+
font-weight: 700;
31+
letter-spacing: 0.08em;
32+
padding: 0.2rem 0.8rem;
33+
text-transform: uppercase;
34+
}
35+
36+
.nested-details {
37+
border-top: 1px solid rgba(49, 46, 129, 0.35);
38+
font-size: 0.92rem;
39+
line-height: 1.4;
40+
margin: 0;
41+
padding-top: 0.9rem;
42+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import './nested-combined-entry.css'
2+
3+
export const NESTED_COMBINED_TEST_ID = 'dialect-nested-combined'
4+
5+
export function NestedCombinedBadge() {
6+
return <span className="nested-entry__badge">Nested combined loader</span>
7+
}
8+
9+
export function NestedCombinedDetails() {
10+
return (
11+
<p className="nested-details" data-testid="nested-combined-details">
12+
The Lit host lives one directory above this entry module, mirroring the css-jsx-app
13+
structure that once broke `?knighted-css&combined` relative imports.
14+
</p>
15+
)
16+
}
17+
18+
export default function NestedCombinedEntry() {
19+
return (
20+
<header className="nested-entry" data-testid="nested-combined-entry">
21+
<p className="nested-entry__subtitle">Parent + Child dirs</p>
22+
<strong>Nested combined example</strong>
23+
<NestedCombinedBadge />
24+
</header>
25+
)
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'
2+
3+
import * as nestedModule from './components/nested-combined-entry.js?knighted-css&combined'
4+
import { NESTED_COMBINED_TEST_ID } from './components/nested-combined-entry.js'
5+
6+
const {
7+
default: NestedCombinedEntry,
8+
NestedCombinedBadge,
9+
NestedCombinedDetails,
10+
knightedCss,
11+
} = asKnightedCssCombinedModule<typeof import('./components/nested-combined-entry.js')>(
12+
nestedModule,
13+
)
14+
15+
export const nestedCombinedCardCss = knightedCss
16+
export { NESTED_COMBINED_TEST_ID } from './components/nested-combined-entry.js'
17+
18+
export function NestedCombinedCard() {
19+
return (
20+
<section className="nested-combined-card" data-testid={NESTED_COMBINED_TEST_ID}>
21+
<NestedCombinedEntry />
22+
<NestedCombinedDetails />
23+
<NestedCombinedBadge />
24+
</section>
25+
)
26+
}

0 commit comments

Comments
 (0)