From 6080bf5b9b99595ffe34d6cd28ccbc846197a444 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 18 Jan 2026 09:34:29 -0600 Subject: [PATCH 1/4] feat: better sass node package import support. --- docs/sass-import-aliases.md | 16 ++-- docs/type-generation.md | 1 + package-lock.json | 4 +- packages/css/README.md | 2 + packages/css/package.json | 2 +- packages/css/src/css.ts | 6 +- packages/css/src/generateTypes.ts | 86 ++++++++++++++++++- packages/css/src/sassInternals.ts | 75 +++++++++++++++- .../__snapshots__/generateTypes.snap.json | 2 +- packages/css/test/generateTypes.test.ts | 49 +++++++++++ packages/css/test/sassImporter.test.ts | 37 ++++++++ packages/playwright/package.json | 2 +- 12 files changed, 266 insertions(+), 16 deletions(-) diff --git a/docs/sass-import-aliases.md b/docs/sass-import-aliases.md index 7fc43d4..1c287f8 100644 --- a/docs/sass-import-aliases.md +++ b/docs/sass-import-aliases.md @@ -1,19 +1,22 @@ # Sass import aliases -Some Sass codebases rely on custom load-path prefixes such as `pkg:#ui/button` or `pkg:@scope/app/components/button`. Those specifiers are resolved by the Sass compiler itself—they never travel through Node.js resolution, `package.json#imports`, or tsconfig `paths`. Because `@knighted/css` reuses Node-style resolution rules underneath (`oxc-resolver`), it cannot interpret Sass-only prefixes automatically. +Some Sass codebases rely on custom load-path prefixes such as `pkg:#ui/button` or `alias:@scope/app/components/button`. Those specifiers are resolved by the Sass compiler itself—they never travel through Node.js resolution, `package.json#imports`, or tsconfig `paths`. -The fix is to provide a custom resolver that rewrites those specifiers into absolute paths before the loader (or the standalone `css()` function) tries to walk the dependency graph. +`@knighted/css` ships a built-in Sass importer for `pkg:` so `pkg:#...` specifiers resolve without any custom resolver. For any other bespoke scheme or alias, provide a custom resolver that rewrites the specifier into an absolute path before the loader (or the standalone `css()` function) walks the dependency graph. + +> [!NOTE] +> Sass support is provided via Dart Sass (`sass` npm package). Ruby Sass and node-sass are not supported. ## When you need a resolver Add a resolver whenever you see either of the following: -- An `@use`/`@import` statement that starts with a nonstandard scheme such as `pkg:` or `sass:`. +- An `@use`/`@import` statement that starts with a nonstandard scheme such as `alias:` or `sass:` (other than `pkg:`). - A project-level shorthand that never appears in `package.json#imports` or `tsconfig.json` (for example, `@scope/app` pointing at a workspace directory only Sass knows about). Without a resolver, those imports throw “Cannot resolve specifier” errors as soon as `@knighted/css` tries to crawl the module graph. -## Example: strip `pkg:#` aliases +## Example: strip custom aliases ```ts import path from 'node:path' @@ -22,10 +25,9 @@ import { css } from '@knighted/css' const pkgAppSrcDir = path.resolve(process.cwd(), 'packages/app/src') function resolvePkgAlias(specifier: string): string | undefined { - if (!specifier.startsWith('pkg:')) return undefined + if (!specifier.startsWith('alias:')) return undefined const remainder = specifier - .slice('pkg:'.length) - .replace(/^#/, '') + .slice('alias:'.length) .replace(/^@scope\/app\/?/, '') .replace(/^\/+/, '') return path.resolve(pkgAppSrcDir, remainder) diff --git a/docs/type-generation.md b/docs/type-generation.md index e87a48c..0da1402 100644 --- a/docs/type-generation.md +++ b/docs/type-generation.md @@ -27,6 +27,7 @@ Wire it into `postinstall` or your build so new selectors land automatically. - `--out-dir` – directory for the selector module manifest cache (defaults to `/.knighted-css`). - `--stable-namespace` – namespace prefix shared by the generated selector maps and loader runtime. - `--auto-stable` – enable auto-stable selector generation during extraction (mirrors the loader’s auto-stable behavior). +- `--resolver` – path or package name exporting a `CssResolver` (default export or named `resolver`). ### Relationship to the loader diff --git a/package-lock.json b/package-lock.json index ff4994e..9082e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11331,7 +11331,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.1.0-rc.3", + "version": "1.1.0-rc.4", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11366,7 +11366,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.1.0-rc.3", + "@knighted/css": "1.1.0-rc.4", "@knighted/jsx": "^1.7.3", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/README.md b/packages/css/README.md index 986c911..6a726a9 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -113,6 +113,8 @@ Run `knighted-css-generate-types` so every specifier that ends with `.knighted-c import stableSelectors from './button.module.scss.knighted-css.js' ``` +Need bespoke resolution? Pass `--resolver` to load a module exporting a `CssResolver` and apply it during type generation. + When the `.knighted-css` import targets a JavaScript/TypeScript module, the generated proxy also re-exports the module’s exports and `knightedCss`, so a single import can provide component exports, typed selectors, and the compiled stylesheet string: ```ts diff --git a/packages/css/package.json b/packages/css/package.json index 7a373f7..631b705 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.1.0-rc.3", + "version": "1.1.0-rc.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", diff --git a/packages/css/src/css.ts b/packages/css/src/css.ts index e8536b9..d3b92ad 100644 --- a/packages/css/src/css.ts +++ b/packages/css/src/css.ts @@ -23,7 +23,7 @@ import { stableClass } from './stableSelectors.js' import { collectStyleImports } from './moduleGraph.js' import type { ModuleGraphOptions } from './moduleGraph.js' -import { createSassImporter } from './sassInternals.js' +import { createLegacySassImporter, createSassImporter } from './sassInternals.js' import type { CssResolver } from './types.js' export type { AutoStableOption } from './autoStableSelectors.js' @@ -329,6 +329,7 @@ async function compileSass( ) const sass = resolveSassNamespace(sassModule) const importer = createSassImporter({ cwd, resolver }) + const legacyImporter = createLegacySassImporter({ cwd, resolver }) const loadPaths = buildSassLoadPaths(filePath) if (typeof (sass as { compileAsync?: Function }).compileAsync === 'function') { @@ -348,6 +349,7 @@ async function compileSass( filePath, indented, loadPaths, + legacyImporter, ) } @@ -361,6 +363,7 @@ function renderLegacySass( filePath: string, indented: boolean, loadPaths: string[], + importer?: ReturnType, ): Promise { return new Promise((resolve, reject) => { sass.render( @@ -369,6 +372,7 @@ function renderLegacySass( indentedSyntax: indented, outputStyle: 'expanded', includePaths: loadPaths, + importer: importer ? [importer] : undefined, }, (error, result) => { if (error) { diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index a4f1039..37eb733 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -14,6 +14,7 @@ import { analyzeModule, type DefaultExportSignal } from './lexer.js' import { createResolverFactory, resolveWithFactory } from './moduleResolution.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' import { resolveStableNamespace } from './stableNamespace.js' +import type { CssResolver } from './types.js' interface ImportMatch { specifier: string @@ -48,6 +49,7 @@ interface GenerateTypesInternalOptions { stableNamespace?: string autoStable?: boolean tsconfig?: TsconfigResolutionContext + resolver?: CssResolver } export interface GenerateTypesResult { @@ -63,6 +65,7 @@ export interface GenerateTypesOptions { outDir?: string stableNamespace?: string autoStable?: boolean + resolver?: CssResolver } const DEFAULT_SKIP_DIRS = new Set([ @@ -146,6 +149,7 @@ export async function generateTypes( stableNamespace: options.stableNamespace, autoStable: options.autoStable, tsconfig, + resolver: options.resolver, } return generateDeclarations(internalOptions) @@ -194,6 +198,7 @@ async function generateDeclarations( match.importer, options.rootDir, options.tsconfig, + options.resolver, resolverFactory, RESOLUTION_EXTENSIONS, ) @@ -217,6 +222,7 @@ async function generateDeclarations( options.autoStable && shouldUseCssModules ? { cssModules: true } : undefined, + resolver: options.resolver, }) selectorMap = buildStableSelectorsLiteral({ css, @@ -412,6 +418,7 @@ async function resolveImportPath( importerPath: string, rootDir: string, tsconfig?: TsconfigResolutionContext, + resolver?: CssResolver, resolverFactory?: ReturnType, resolutionExtensions: string[] = RESOLUTION_EXTENSIONS, ): Promise { @@ -424,6 +431,17 @@ async function resolveImportPath( if (resourceSpecifier.startsWith('/')) { return resolveWithExtensionFallback(path.resolve(rootDir, resourceSpecifier.slice(1))) } + if (resolver) { + const resolved = await resolveWithResolver( + resourceSpecifier, + resolver, + rootDir, + importerPath, + ) + if (resolved) { + return resolveWithExtensionFallback(resolved) + } + } const tsconfigResolved = await resolveWithTsconfigPaths(resourceSpecifier, tsconfig) if (tsconfigResolved) { return resolveWithExtensionFallback(tsconfigResolved) @@ -447,6 +465,26 @@ async function resolveImportPath( } } +async function resolveWithResolver( + specifier: string, + resolver: CssResolver, + rootDir: string, + importerPath?: string, +): Promise { + const resolved = await resolver(specifier, { cwd: rootDir, from: importerPath }) + if (!resolved) { + return undefined + } + if (resolved.startsWith('file://')) { + try { + return fileURLToPath(new URL(resolved)) + } catch { + return undefined + } + } + return path.isAbsolute(resolved) ? resolved : path.resolve(rootDir, resolved) +} + function buildSelectorModuleManifestKey(resolvedPath: string): string { return resolvedPath.split(path.sep).join('/') } @@ -798,6 +836,37 @@ function getProjectRequire(rootDir: string): ReturnType { return loader } +function resolveResolverModulePath(specifier: string, rootDir: string): string { + if (specifier.startsWith('file://')) { + return fileURLToPath(new URL(specifier)) + } + if (specifier.startsWith('.') || specifier.startsWith('/')) { + return path.resolve(rootDir, specifier) + } + const requireFromRoot = getProjectRequire(rootDir) + return requireFromRoot.resolve(specifier) +} + +async function loadResolverModule( + specifier: string, + rootDir: string, +): Promise { + const resolvedPath = resolveResolverModulePath(specifier, rootDir) + const mod = await import(pathToFileURL(resolvedPath).href) + const candidate = + typeof mod.default === 'function' + ? (mod.default as CssResolver) + : typeof (mod as { resolver?: unknown }).resolver === 'function' + ? ((mod as { resolver: CssResolver }).resolver as CssResolver) + : undefined + if (!candidate) { + throw new Error( + 'Resolver module must export a function as the default export or a named export named "resolver".', + ) + } + return candidate +} + export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise { let parsed: ParsedCliArgs try { @@ -812,12 +881,16 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise return } try { + const resolver = parsed.resolver + ? await loadResolverModule(parsed.resolver, parsed.rootDir) + : undefined const result = await generateTypes({ rootDir: parsed.rootDir, include: parsed.include, outDir: parsed.outDir, stableNamespace: parsed.stableNamespace, autoStable: parsed.autoStable, + resolver, }) reportCliResult(result) } catch (error) { @@ -833,6 +906,7 @@ export interface ParsedCliArgs { outDir?: string stableNamespace?: string autoStable?: boolean + resolver?: string help?: boolean } @@ -842,6 +916,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { let outDir: string | undefined let stableNamespace: string | undefined let autoStable = false + let resolver: string | undefined for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] @@ -884,13 +959,21 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { stableNamespace = value continue } + if (arg === '--resolver') { + const value = argv[++i] + if (!value) { + throw new Error('Missing value for --resolver') + } + resolver = value + continue + } if (arg.startsWith('-')) { throw new Error(`Unknown flag: ${arg}`) } include.push(arg) } - return { rootDir, include, outDir, stableNamespace, autoStable } + return { rootDir, include, outDir, stableNamespace, autoStable, resolver } } function printHelp(): void { @@ -902,6 +985,7 @@ Options: --out-dir Directory to store selector module manifest cache --stable-namespace Stable namespace prefix for generated selector maps --auto-stable Enable autoStable when extracting CSS for selectors + --resolver Path or package name exporting a CssResolver -h, --help Show this help message `) } diff --git a/packages/css/src/sassInternals.ts b/packages/css/src/sassInternals.ts index 706b681..64b95be 100644 --- a/packages/css/src/sassInternals.ts +++ b/packages/css/src/sassInternals.ts @@ -3,6 +3,7 @@ import { existsSync, promises as fs } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import type { CssResolver } from './types.js' +import { createResolverFactory, resolveWithFactory } from './moduleResolution.js' export type { CssResolver } from './types.js' @@ -13,8 +14,8 @@ export function createSassImporter({ cwd: string resolver?: CssResolver }) { - if (!resolver) return undefined const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' + const pkgResolver = createPkgResolver(cwd) return { async canonicalize(url: string, context?: { containingUrl?: URL | null }) { @@ -27,7 +28,7 @@ export function createSassImporter({ const containingPath = context?.containingUrl ? fileURLToPath(context.containingUrl) : undefined - if (shouldNormalizeSpecifier(url)) { + if (resolver && shouldNormalizeSpecifier(url)) { const resolvedPath = await resolveAliasSpecifier( url, resolver, @@ -38,6 +39,20 @@ export function createSassImporter({ if (debug) { console.error('[knighted-css:sass] resolver returned no result for', url) } + } else { + const fileUrl = pathToFileURL(resolvedPath) + if (debug) { + console.error('[knighted-css:sass] canonical url:', fileUrl.href) + } + return fileUrl + } + } + if (url.startsWith('pkg:')) { + const resolvedPath = await pkgResolver(url.slice(4), containingPath) + if (!resolvedPath) { + if (debug) { + console.error('[knighted-css:sass] pkg resolver returned no result for', url) + } return null } const fileUrl = pathToFileURL(resolvedPath) @@ -70,6 +85,46 @@ export function createSassImporter({ } } +export function createLegacySassImporter({ + cwd, + resolver, +}: { + cwd: string + resolver?: CssResolver +}) { + const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' + const pkgResolver = createPkgResolver(cwd) + + return async ( + url: string, + prev: string, + done?: (result: { file: string } | null) => void, + ) => { + const containingPath = prev && prev !== 'stdin' ? prev : undefined + let resolvedPath: string | undefined + + if (resolver && shouldNormalizeSpecifier(url)) { + resolvedPath = await resolveAliasSpecifier(url, resolver, cwd, containingPath) + if (!resolvedPath && debug) { + console.error('[knighted-css:sass] resolver returned no result for', url) + } + } + if (!resolvedPath && url.startsWith('pkg:')) { + resolvedPath = await pkgResolver(url.slice(4), containingPath) + if (!resolvedPath && debug) { + console.error('[knighted-css:sass] pkg resolver returned no result for', url) + } + } + + const result = resolvedPath ? { file: resolvedPath } : null + if (done) { + done(result) + return undefined + } + return result + } +} + export async function resolveAliasSpecifier( specifier: string, resolver: CssResolver, @@ -147,10 +202,26 @@ export function resolveRelativeSpecifier( return ensureSassPath(candidate) } +const SASS_EXTENSIONS = ['.scss', '.sass', '.css'] + +export function createPkgResolver(cwd: string) { + const factory = createResolverFactory(cwd, SASS_EXTENSIONS, SASS_EXTENSIONS) + return async (specifier: string, containingPath?: string) => { + const importer = containingPath ?? path.join(cwd, 'index.scss') + const resolved = resolveWithFactory(factory, specifier, importer, SASS_EXTENSIONS) + if (!resolved) { + return undefined + } + return ensureSassPath(resolved) ?? resolved + } +} + export const __sassInternals = { createSassImporter, + createLegacySassImporter, resolveAliasSpecifier, shouldNormalizeSpecifier, ensureSassPath, resolveRelativeSpecifier, + createPkgResolver, } diff --git a/packages/css/test/__snapshots__/generateTypes.snap.json b/packages/css/test/__snapshots__/generateTypes.snap.json index 42f380e..9473456 100644 --- a/packages/css/test/__snapshots__/generateTypes.snap.json +++ b/packages/css/test/__snapshots__/generateTypes.snap.json @@ -1,4 +1,4 @@ { "cli-generation-summary": "[log]\n[knighted-css] Selector modules updated: wrote 1, removed 0.\n[knighted-css] Manifest: /selector-modules.json\n[knighted-css] Selector modules are up to date.\n[knighted-css] Manifest: /selector-modules.json\n[warn]", - "cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root Project root directory (default: cwd)\n -i, --include Additional directories/files to scan (repeatable)\n --out-dir Directory to store selector module manifest cache\n --stable-namespace Stable namespace prefix for generated selector maps\n --auto-stable Enable autoStable when extracting CSS for selectors\n -h, --help Show this help message" + "cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root Project root directory (default: cwd)\n -i, --include Additional directories/files to scan (repeatable)\n --out-dir Directory to store selector module manifest cache\n --stable-namespace Stable namespace prefix for generated selector maps\n --auto-stable Enable autoStable when extracting CSS for selectors\n --resolver Path or package name exporting a CssResolver\n -h, --help Show this help message" } diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index bf31abb..2c89c45 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -715,6 +715,51 @@ test('runGenerateTypesCli executes generation and reports summaries', async () = } }) +test('runGenerateTypesCli loads a custom resolver module', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-cli-resolver-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + await fs.writeFile( + path.join(srcDir, 'styles.css'), + '.knighted-card { color: teal; }\n', + ) + await fs.writeFile( + path.join(srcDir, 'entry.ts'), + "import selectors from '@alias/styles.css.knighted-css'\n" + + 'console.log(selectors.card)\n', + ) + const resolverPath = path.join(root, 'resolver.mjs') + await fs.writeFile( + resolverPath, + "import path from 'node:path'\n" + + 'export default function resolver(specifier, { cwd }) {\n' + + " if (specifier === '@alias/styles.css') {\n" + + " return path.join(cwd, 'src', 'styles.css')\n" + + ' }\n' + + ' return undefined\n' + + '}\n', + ) + + const outDir = path.join(root, '.knighted-css-cli') + await runGenerateTypesCli([ + '--root', + root, + '--include', + 'src', + '--out-dir', + outDir, + '--resolver', + './resolver.mjs', + ]) + + const selectorModulePath = path.join(srcDir, 'styles.css.knighted-css.ts') + assert.equal(await pathExists(selectorModulePath), true) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test('runGenerateTypesCli prints help output when requested', async () => { const printed: string[] = [] const originalLog = console.log @@ -879,16 +924,20 @@ test('generateTypes internals support selector module helpers', async () => { '--out-dir', '.knighted-css', '--auto-stable', + '--resolver', + './resolver.mjs', ]) as ParsedCliArgs assert.equal(parsed.rootDir, path.resolve('/tmp/project')) assert.deepEqual(parsed.include, ['src']) assert.equal(parsed.stableNamespace, 'storybook') assert.equal(parsed.autoStable, true) + assert.equal(parsed.resolver, './resolver.mjs') assert.throws(() => parseCliArgs(['--root']), /Missing value/) assert.throws(() => parseCliArgs(['--include']), /Missing value/) assert.throws(() => parseCliArgs(['--out-dir']), /Missing value/) assert.throws(() => parseCliArgs(['--stable-namespace']), /Missing value/) + assert.throws(() => parseCliArgs(['--resolver']), /Missing value/) assert.throws(() => parseCliArgs(['--wat']), /Unknown flag/) const helpParsed = parseCliArgs(['--help']) assert.equal(helpParsed.help, true) diff --git a/packages/css/test/sassImporter.test.ts b/packages/css/test/sassImporter.test.ts index 508063b..b1b5113 100644 --- a/packages/css/test/sassImporter.test.ts +++ b/packages/css/test/sassImporter.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import test from 'node:test' +import fs from 'node:fs/promises' +import os from 'node:os' import { __sassInternals, type CssResolver } from '../src/sassInternals.ts' @@ -114,3 +116,38 @@ test('resolveAliasSpecifier normalizes returned file urls', async () => { ) assert.equal(result, target) }) + +test('sass importer resolves pkg: specifiers via oxc-resolver', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-sass-pkg-')) + try { + const srcDir = path.join(root, 'src') + const stylesDir = path.join(srcDir, 'styles') + await fs.mkdir(stylesDir, { recursive: true }) + await fs.writeFile(path.join(stylesDir, 'color.scss'), '.color { color: red; }') + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify( + { + name: 'knighted-sass-pkg-fixture', + type: 'module', + imports: { + '#styles/*': './src/styles/*', + }, + }, + null, + 2, + ), + ) + const importer = __sassInternals.createSassImporter({ cwd: root }) + assert.ok(importer, 'expected importer to be created') + + const containing = pathToFileURL(path.join(srcDir, 'entry.scss')) + const resolved = await importer.canonicalize('pkg:#styles/color.scss', { + containingUrl: containing, + }) + assert.ok(resolved, 'expected pkg: specifier to resolve') + assert.ok(resolved?.href.endsWith('/color.scss')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 4e662b7..782e949 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -21,7 +21,7 @@ "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.1.0-rc.3", + "@knighted/css": "1.1.0-rc.4", "@knighted/jsx": "^1.7.3", "lit": "^3.2.1", "react": "^19.0.0", From 5a8afef9dfc81c08aa2ebf91b22efb86cfdd1079 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 18 Jan 2026 11:10:44 -0600 Subject: [PATCH 2/4] test: sass node package imports. --- packages/css/src/css.ts | 4 +- packages/css/src/sassInternals.ts | 25 +++++++-- packages/css/test/generateTypes.test.ts | 54 ++++++++++++++++--- .../.knighted-css-debug/selector-modules.json | 6 +++ .../src/render-hash-imports-demo.ts | 2 +- .../src/workspace-bridge/hash-imports.scss | 5 ++ .../src/workspace-bridge/tokens.scss | 1 + 7 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 packages/playwright/.knighted-css-debug/selector-modules.json create mode 100644 packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.scss create mode 100644 packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/tokens.scss diff --git a/packages/css/src/css.ts b/packages/css/src/css.ts index d3b92ad..0661009 100644 --- a/packages/css/src/css.ts +++ b/packages/css/src/css.ts @@ -328,8 +328,8 @@ async function compileSass( peerResolver, ) const sass = resolveSassNamespace(sassModule) - const importer = createSassImporter({ cwd, resolver }) - const legacyImporter = createLegacySassImporter({ cwd, resolver }) + const importer = createSassImporter({ cwd, resolver, entryPath: filePath }) + const legacyImporter = createLegacySassImporter({ cwd, resolver, entryPath: filePath }) const loadPaths = buildSassLoadPaths(filePath) if (typeof (sass as { compileAsync?: Function }).compileAsync === 'function') { diff --git a/packages/css/src/sassInternals.ts b/packages/css/src/sassInternals.ts index 64b95be..ebca2fa 100644 --- a/packages/css/src/sassInternals.ts +++ b/packages/css/src/sassInternals.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { existsSync, promises as fs } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' import type { CssResolver } from './types.js' import { createResolverFactory, resolveWithFactory } from './moduleResolution.js' @@ -10,9 +11,11 @@ export type { CssResolver } from './types.js' export function createSassImporter({ cwd, resolver, + entryPath, }: { cwd: string resolver?: CssResolver + entryPath?: string }) { const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' const pkgResolver = createPkgResolver(cwd) @@ -27,7 +30,7 @@ export function createSassImporter({ } const containingPath = context?.containingUrl ? fileURLToPath(context.containingUrl) - : undefined + : entryPath if (resolver && shouldNormalizeSpecifier(url)) { const resolvedPath = await resolveAliasSpecifier( url, @@ -88,9 +91,11 @@ export function createSassImporter({ export function createLegacySassImporter({ cwd, resolver, + entryPath, }: { cwd: string resolver?: CssResolver + entryPath?: string }) { const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' const pkgResolver = createPkgResolver(cwd) @@ -100,7 +105,7 @@ export function createLegacySassImporter({ prev: string, done?: (result: { file: string } | null) => void, ) => { - const containingPath = prev && prev !== 'stdin' ? prev : undefined + const containingPath = prev && prev !== 'stdin' ? prev : entryPath let resolvedPath: string | undefined if (resolver && shouldNormalizeSpecifier(url)) { @@ -209,10 +214,22 @@ export function createPkgResolver(cwd: string) { return async (specifier: string, containingPath?: string) => { const importer = containingPath ?? path.join(cwd, 'index.scss') const resolved = resolveWithFactory(factory, specifier, importer, SASS_EXTENSIONS) - if (!resolved) { + if (resolved) { + return ensureSassPath(resolved) ?? resolved + } + const resolvedViaNode = resolveWithNode(specifier, importer) + if (!resolvedViaNode) { return undefined } - return ensureSassPath(resolved) ?? resolved + return ensureSassPath(resolvedViaNode) ?? resolvedViaNode + } +} + +function resolveWithNode(specifier: string, importerPath: string): string | undefined { + try { + return createRequire(importerPath).resolve(specifier) + } catch { + return undefined } } diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index 2c89c45..038e7c3 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import fs from 'node:fs/promises' +import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -362,13 +363,28 @@ test('generateTypes resolves hash-imports workspace package.json imports', async try { const appRoot = path.join(workspace.root, 'apps', 'hash-import-demo') const bridgeDir = path.join(appRoot, 'src', 'workspace-bridge') + const requireFromRepo = createRequire(import.meta.url) + const sassEntry = requireFromRepo.resolve('sass') + const sassPackageDir = await findPackageRoot(sassEntry) + const sassModuleDir = path.join(appRoot, 'node_modules', 'sass') + await fs.mkdir(path.dirname(sassModuleDir), { recursive: true }) + try { + await fs.symlink(sassPackageDir, sassModuleDir) + } catch { + await fs.cp(sassPackageDir, sassModuleDir, { recursive: true }) + } + await fs.writeFile( + path.join(bridgeDir, 'tokens.scss'), + '$accent-color: dodgerblue;\n', + ) await fs.writeFile( - path.join(bridgeDir, 'workspace-card.css'), - '.knighted-demo { color: dodgerblue; }\n', + path.join(bridgeDir, 'workspace-card.scss'), + "@use 'pkg:#workspace/ui/tokens.scss' as tokens;\n\n" + + '.knighted-demo { color: tokens.$accent-color; }\n', ) await fs.writeFile( path.join(appRoot, 'src', 'types-entry.ts'), - "import selectors from '#workspace/ui/workspace-card.css.knighted-css'\n" + + "import selectors from '#workspace/ui/workspace-card.scss.knighted-css'\n" + 'console.log(selectors.demo)\n', ) @@ -378,16 +394,42 @@ test('generateTypes resolves hash-imports workspace package.json imports', async include: ['src'], outDir, }) - assert.ok(result.selectorModulesWritten >= 1) - assert.equal(result.warnings.length, 0) + const unexpectedWarnings = result.warnings.filter( + warning => + warning.includes('Unable to resolve') || + warning.includes('Failed to extract CSS'), + ) + assert.equal( + unexpectedWarnings.length, + 0, + `Unexpected warnings:\n${unexpectedWarnings.join('\n')}`, + ) - const selectorModulePath = path.join(bridgeDir, 'workspace-card.css.knighted-css.ts') + const selectorModulePath = path.join(bridgeDir, 'workspace-card.scss.knighted-css.ts') assert.equal(await pathExists(selectorModulePath), true) } finally { await workspace.cleanup() } }) +async function findPackageRoot(entryPath: string): Promise { + let current = path.dirname(entryPath) + const { root } = path.parse(current) + while (true) { + const candidate = path.join(current, 'package.json') + try { + await fs.access(candidate) + return current + } catch { + // continue + } + if (current === root) { + throw new Error(`Unable to locate package.json for ${entryPath}`) + } + current = path.dirname(current) + } +} + test('generateTypes removes stale selector manifest entries when modules vanish', async () => { const project = await setupFixtureProject() try { diff --git a/packages/playwright/.knighted-css-debug/selector-modules.json b/packages/playwright/.knighted-css-debug/selector-modules.json new file mode 100644 index 0000000..7570624 --- /dev/null +++ b/packages/playwright/.knighted-css-debug/selector-modules.json @@ -0,0 +1,6 @@ +{ + "/Users/morgan/knighted/css/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.scss": { + "file": "/Users/morgan/knighted/css/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.scss.knighted-css.ts", + "hash": "2cb05b82a4776b703d53194253d1659decddd9c7" + } +} diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts index ad82546..7711d15 100644 --- a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts @@ -1,7 +1,7 @@ import { HASH_IMPORTS_SECTION_ID } from '../../../constants.js' import { createWorkspaceCard } from '#workspace/ui/workspace-card.js' import { knightedCss as workspaceCardCss } from '#workspace/ui/workspace-card.js?knighted-css' -import stableSelectors from '#workspace/ui/hash-imports.css.knighted-css.js' +import stableSelectors from '#workspace/ui/hash-imports.scss.knighted-css.js' export function renderHashImportsWorkspaceDemo(root: HTMLElement): void { const mount = root ?? document.body diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.scss b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.scss new file mode 100644 index 0000000..888c850 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.scss @@ -0,0 +1,5 @@ +@use 'pkg:#workspace/ui/tokens.scss' as tokens; + +.knighted-demo { + color: tokens.$accent-color; +} diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/tokens.scss b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/tokens.scss new file mode 100644 index 0000000..e9c5739 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/tokens.scss @@ -0,0 +1 @@ +$accent-color: #1e40af; From f11dd09db817da0c975ec452c29db4474b4ae380 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 18 Jan 2026 11:23:05 -0600 Subject: [PATCH 3/4] refactor: support sass condition name. --- docs/roadmap.md | 5 ++++ packages/css/src/sassInternals.ts | 4 ++- packages/css/test/sassImporter.test.ts | 38 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 46487d5..64fad49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,3 +15,8 @@ - Evaluate promoting `lightningcss` to a peer dependency so consumers can align with their own upgrade cadence. - Document fallbacks for specificity workflows if teams opt to satisfy the peer via compatible forks or alternative transformers. + +## Sass Resolver Options + +- Allow configuring conditionNames for `pkg:` resolution (e.g., opt into `sass` or custom priority ordering). +- Allow opting into explicit `tsconfig` selection instead of `tsconfig: auto` when resolving `pkg:` specifiers. diff --git a/packages/css/src/sassInternals.ts b/packages/css/src/sassInternals.ts index ebca2fa..57a383a 100644 --- a/packages/css/src/sassInternals.ts +++ b/packages/css/src/sassInternals.ts @@ -210,7 +210,9 @@ export function resolveRelativeSpecifier( const SASS_EXTENSIONS = ['.scss', '.sass', '.css'] export function createPkgResolver(cwd: string) { - const factory = createResolverFactory(cwd, SASS_EXTENSIONS, SASS_EXTENSIONS) + const factory = createResolverFactory(cwd, SASS_EXTENSIONS, SASS_EXTENSIONS, { + conditions: ['sass', 'import', 'require', 'node', 'default'], + }) return async (specifier: string, containingPath?: string) => { const importer = containingPath ?? path.join(cwd, 'index.scss') const resolved = resolveWithFactory(factory, specifier, importer, SASS_EXTENSIONS) diff --git a/packages/css/test/sassImporter.test.ts b/packages/css/test/sassImporter.test.ts index b1b5113..f5b124c 100644 --- a/packages/css/test/sassImporter.test.ts +++ b/packages/css/test/sassImporter.test.ts @@ -151,3 +151,41 @@ test('sass importer resolves pkg: specifiers via oxc-resolver', async () => { await fs.rm(root, { recursive: true, force: true }) } }) + +test('sass importer honors sass condition name', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-sass-conditions-')) + try { + const srcDir = path.join(root, 'src') + const stylesDir = path.join(srcDir, 'styles') + await fs.mkdir(stylesDir, { recursive: true }) + await fs.writeFile(path.join(stylesDir, 'sass.scss'), '.sass { color: blue; }') + await fs.writeFile(path.join(stylesDir, 'default.scss'), '.default { color: red; }') + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify( + { + name: 'knighted-sass-conditions-fixture', + type: 'module', + imports: { + '#styles/entry.scss': { + sass: './src/styles/sass.scss', + default: './src/styles/default.scss', + }, + }, + }, + null, + 2, + ), + ) + + const importer = __sassInternals.createSassImporter({ cwd: root }) + const containing = pathToFileURL(path.join(srcDir, 'entry.scss')) + const resolved = await importer.canonicalize('pkg:#styles/entry.scss', { + containingUrl: containing, + }) + assert.ok(resolved, 'expected pkg: specifier to resolve') + assert.ok(resolved?.href.endsWith('/sass.scss')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) From ddbda55ea961f48d00e7a37d1093feca848b430e Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 18 Jan 2026 11:49:29 -0600 Subject: [PATCH 4/4] test: increase patch. --- packages/css/src/generateTypes.ts | 3 + packages/css/test/generateTypes.test.ts | 195 ++++++++++++++++++++++++ packages/css/test/sassImporter.test.ts | 120 +++++++++++++++ 3 files changed, 318 insertions(+) diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index 37eb733..ce6287c 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -1032,11 +1032,14 @@ export const __generateTypesInternals = { setImportMetaUrlProvider, isNonRelativeSpecifier, isStyleResource, + resolveProxyInfo, resolveWithExtensionFallback, + resolveIndexFallback, createProjectPeerResolver, getProjectRequire, loadTsconfigResolutionContext, resolveWithTsconfigPaths, + loadResolverModule, parseCliArgs, printHelp, reportCliResult, diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index 038e7c3..b40f8f1 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -813,6 +813,201 @@ test('runGenerateTypesCli prints help output when requested', async () => { } await expectCliSnapshot('cli-help-output', printed.join('\n')) }) + +test('generateTypes internals cover edge cases', async () => { + const { + resolvePackageRoot, + setModuleTypeDetector, + collectCandidateFiles, + findSpecifierImports, + resolveImportPath, + formatSelectorModuleSource, + removeStaleSelectorModules, + resolveIndexFallback, + loadTsconfigResolutionContext, + isNonRelativeSpecifier, + resolveProxyInfo, + loadResolverModule, + parseCliArgs, + } = __generateTypesInternals + + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-internals-')) + try { + await fs.writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify({ name: 'knighted-temp-root', type: 'module' }), + ) + const originalDirname = (globalThis as { __dirname?: string }).__dirname + try { + ;(globalThis as { __dirname?: string }).__dirname = path.join(tempRoot, 'src') + setModuleTypeDetector(() => 'commonjs') + assert.equal(resolvePackageRoot(), path.resolve(tempRoot, 'src', '..')) + } finally { + setModuleTypeDetector(undefined) + if (typeof originalDirname === 'undefined') { + delete (globalThis as { __dirname?: string }).__dirname + } else { + ;(globalThis as { __dirname?: string }).__dirname = originalDirname + } + } + + assert.deepEqual(await collectCandidateFiles([path.join(tempRoot, 'missing')]), []) + assert.deepEqual(await findSpecifierImports(path.join(tempRoot, 'missing.ts')), []) + + const brokenFile = path.join(tempRoot, 'broken.ts') + await fs.writeFile( + brokenFile, + "import { broken } from ;\nrequire('./styles.css.knighted-css')\n", + ) + const brokenMatches = await findSpecifierImports(brokenFile) + assert.ok(brokenMatches.length >= 1) + + const resolverUndefined = await resolveImportPath( + '@missing', + brokenFile, + tempRoot, + undefined, + async () => undefined, + undefined, + ) + assert.equal(resolverUndefined, undefined) + + const resolverBadUrl = await resolveImportPath( + '@bad', + brokenFile, + tempRoot, + undefined, + async () => 'file://%invalid', + undefined, + ) + assert.equal(resolverBadUrl, undefined) + + const source = formatSelectorModuleSource(new Map(), undefined) + assert.ok(source.includes('{} as const')) + const populated = formatSelectorModuleSource(new Map([['demo', '.knighted-demo']]), { + moduleSpecifier: './entry.js', + includeDefault: true, + }) + assert.ok(populated.includes('"demo": ".knighted-demo"')) + + const removed = await removeStaleSelectorModules( + { demo: { file: path.join(tempRoot, 'missing.ts'), hash: 'missing' } }, + {}, + ) + assert.equal(removed, 0) + + const candidate = path.join(tempRoot, 'not-a-dir') + await fs.writeFile(candidate, 'noop') + assert.equal(await resolveIndexFallback(candidate), undefined) + + const tsconfig = loadTsconfigResolutionContext(tempRoot, () => ({ + path: path.join(tempRoot, 'tsconfig.json'), + config: { + compilerOptions: { + baseUrl: './src', + paths: { '@app/*': ['./app/*'] }, + }, + }, + })) + assert.ok(tsconfig?.absoluteBaseUrl) + assert.ok(tsconfig?.matchPath) + + assert.equal(isNonRelativeSpecifier('http://example.com/style.css'), false) + + const proxyCache = new Map>>() + const proxyInfo = await resolveProxyInfo( + 'demo', + './entry.ts', + path.join(tempRoot, 'missing-entry.ts'), + proxyCache, + ) + assert.ok(proxyInfo?.moduleSpecifier) + const proxyCached = await resolveProxyInfo( + 'demo', + './entry.ts', + path.join(tempRoot, 'missing-entry.ts'), + proxyCache, + ) + assert.equal(proxyCached, proxyInfo) + + const resolverFile = path.join(tempRoot, 'resolver-file.mjs') + await fs.writeFile(resolverFile, 'export default function resolver() { return null }') + const fileResolver = await loadResolverModule( + pathToFileURL(resolverFile).href, + tempRoot, + ) + assert.equal(typeof fileResolver, 'function') + + const nodeModulesDir = path.join(tempRoot, 'node_modules', 'fixture-resolver') + await fs.mkdir(nodeModulesDir, { recursive: true }) + await fs.writeFile( + path.join(nodeModulesDir, 'package.json'), + JSON.stringify({ name: 'fixture-resolver', type: 'module', exports: './index.js' }), + ) + await fs.writeFile( + path.join(nodeModulesDir, 'index.js'), + 'export default function resolver() { return null }', + ) + const packageResolver = await loadResolverModule('fixture-resolver', tempRoot) + assert.equal(typeof packageResolver, 'function') + + const badResolver = path.join(tempRoot, 'resolver-bad.mjs') + await fs.writeFile(badResolver, 'export const resolver = 123') + await assert.rejects( + () => loadResolverModule(pathToFileURL(badResolver).href, tempRoot), + /Resolver module must export a function/, + ) + + const namedResolverFile = path.join(tempRoot, 'resolver-named.mjs') + await fs.writeFile(namedResolverFile, 'export function resolver() { return null }') + const namedResolver = await loadResolverModule( + pathToFileURL(namedResolverFile).href, + tempRoot, + ) + assert.equal(typeof namedResolver, 'function') + + const parsed = parseCliArgs(['--root', tempRoot, 'src']) + assert.deepEqual(parsed.include, ['src']) + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } +}) + +test('generateTypes falls back when root realpath fails', async () => { + const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-root-fallback-')) + const missingRoot = path.join(sandbox, 'missing-root') + const outDir = path.join(sandbox, 'out') + await fs.mkdir(outDir, { recursive: true }) + + try { + const result = await generateTypes({ + rootDir: missingRoot, + include: ['src'], + outDir, + }) + assert.equal(result.selectorModulesWritten, 0) + assert.equal(result.warnings.length, 0) + } finally { + await fs.rm(sandbox, { recursive: true, force: true }) + } +}) + +test('generateTypes skips invalid selector sources', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-invalid-selector-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + await fs.writeFile( + path.join(srcDir, 'entry.ts'), + "import selectors from '.knighted-css'\nconsole.log(selectors)\n", + ) + const result = await generateTypes({ rootDir: root, include: ['src'] }) + assert.equal(result.selectorModulesWritten, 0) + assert.equal(result.warnings.length, 0) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) test('generateTypes internals support selector module helpers', async () => { const { stripInlineLoader, diff --git a/packages/css/test/sassImporter.test.ts b/packages/css/test/sassImporter.test.ts index f5b124c..768afe7 100644 --- a/packages/css/test/sassImporter.test.ts +++ b/packages/css/test/sassImporter.test.ts @@ -152,6 +152,126 @@ test('sass importer resolves pkg: specifiers via oxc-resolver', async () => { } }) +test('sass importer debug logs pkg resolution outcomes', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-sass-debug-')) + const previous = process.env.KNIGHTED_CSS_DEBUG_SASS + process.env.KNIGHTED_CSS_DEBUG_SASS = '1' + const originalError = console.error + const captured: string[] = [] + ;(console as Console).error = (...args: unknown[]) => { + captured.push(args.map(arg => String(arg)).join(' ')) + } + + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + await fs.writeFile(path.join(srcDir, 'tokens.scss'), '.token { color: red; }') + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify( + { + name: 'knighted-sass-debug-fixture', + type: 'module', + imports: { + '#tokens': './src/tokens.scss', + }, + }, + null, + 2, + ), + ) + + const importer = __sassInternals.createSassImporter({ cwd: root }) + const containing = pathToFileURL(path.join(srcDir, 'entry.scss')) + + const missing = await importer.canonicalize('pkg:#missing', { + containingUrl: containing, + }) + assert.equal(missing, null) + + const resolved = await importer.canonicalize('pkg:#tokens', { + containingUrl: containing, + }) + assert.ok(resolved?.href.endsWith('/tokens.scss')) + } finally { + ;(console as Console).error = originalError + resetEnv('KNIGHTED_CSS_DEBUG_SASS', previous) + await fs.rm(root, { recursive: true, force: true }) + } + + assert.ok( + captured.some(line => line.includes('pkg resolver returned no result')), + 'expected debug log for missing pkg resolution', + ) + assert.ok( + captured.some(line => line.includes('canonical url:')), + 'expected debug log for resolved pkg url', + ) +}) + +test('legacy sass importer resolves alias and handles pkg fallback', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-sass-legacy-')) + const previous = process.env.KNIGHTED_CSS_DEBUG_SASS + process.env.KNIGHTED_CSS_DEBUG_SASS = '1' + const originalError = console.error + const captured: string[] = [] + ;(console as Console).error = (...args: unknown[]) => { + captured.push(args.map(arg => String(arg)).join(' ')) + } + + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const entry = path.join(srcDir, 'entry.scss') + await fs.writeFile(entry, '.legacy { color: red; }') + + const resolver: CssResolver = async specifier => { + if (specifier === 'alias:entry') { + return entry + } + return undefined + } + + const importer = __sassInternals.createLegacySassImporter({ cwd: root, resolver }) + const doneResults: Array<{ file: string } | null> = [] + const withCallback = await importer('alias:entry', entry, result => + doneResults.push(result), + ) + assert.equal(withCallback, undefined) + assert.equal(doneResults[0]?.file, entry) + + const directResult = await importer('alias:entry', entry) + assert.equal(directResult?.file, entry) + + const missing = await importer('pkg:#missing', entry) + assert.equal(missing, null) + } finally { + ;(console as Console).error = originalError + resetEnv('KNIGHTED_CSS_DEBUG_SASS', previous) + await fs.rm(root, { recursive: true, force: true }) + } + + assert.ok( + captured.some(line => line.includes('pkg resolver returned no result')), + 'expected legacy pkg debug log', + ) +}) + +test('pkg resolver falls back to node resolution when oxc-resolver fails', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-sass-node-fallback-')) + try { + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ name: 'knighted-sass-node-fallback', type: 'module' }), + ) + const pkgResolver = __sassInternals.createPkgResolver(root) + const unresolved = await pkgResolver('#missing', path.join(root, 'entry.scss')) + assert.equal(unresolved, undefined) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test('sass importer honors sass condition name', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-sass-conditions-')) try {