diff --git a/package-lock.json b/package-lock.json index ff4994e..cbe3868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2496,7 +2495,6 @@ "integrity": "sha512-FolcIAH5FW4J2FET+qwjd1kNeFbCkd0VLuIHO0thyolEjaPSxw5qxG67DA7BZGm6PVcoiSgPLks1DL6eZ8c+fA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.21.6", "@rspack/binding": "1.6.8", @@ -2829,7 +2827,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3076,7 +3073,6 @@ "integrity": "sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", @@ -3098,7 +3094,6 @@ "integrity": "sha512-ILob4F9cEHXpbWAVt3Y2iaQJpqYq/c/5TJC8Fz58C2XmX3QW2Y589krvViiyJhQfydCGK3EbwPQhVFjQaBeKfg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", @@ -3560,7 +3555,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3600,7 +3594,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3955,7 +3948,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6821,7 +6813,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -8618,7 +8609,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8860,7 +8850,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9150,7 +9139,6 @@ "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10250,7 +10238,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10409,8 +10396,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -10468,7 +10454,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10677,7 +10662,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10786,7 +10770,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/packages/css/src/css.ts b/packages/css/src/css.ts index e8536b9..a4cd247 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 { createSassImporter, createPkgImporter } from './sassInternals.js' import type { CssResolver } from './types.js' export type { AutoStableOption } from './autoStableSelectors.js' @@ -332,12 +332,30 @@ async function compileSass( const loadPaths = buildSassLoadPaths(filePath) if (typeof (sass as { compileAsync?: Function }).compileAsync === 'function') { + const importers: unknown[] = [] + /* + * Add custom importer first to handle user-provided resolver. + * Then add built-in pkg:# importer for native bundler-style resolution. + * Note: NodePackageImporter is not added because it conflicts with pkg:# + * imports (it throws an error for pkg:# URLs instead of returning null). + * Our pkgImporter handles pkg:# natively using oxc-resolver. + */ + if (importer) { + importers.push(importer) + } + /* Add built-in pkg:# importer using Node.js resolution */ + const pkgImporter = createPkgImporter({ + cwd, + extensions: ['.scss', '.sass', '.css'], + }) + importers.push(pkgImporter) + const result = await ( sass as { compileAsync: typeof import('sass').compileAsync } ).compileAsync(filePath, { style: 'expanded', loadPaths, - importers: importer ? [importer] : undefined, + importers: importers.length > 0 ? (importers as never) : undefined, }) return result.css } diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index a4f1039..f06aff1 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -416,22 +416,29 @@ async function resolveImportPath( resolutionExtensions: string[] = RESOLUTION_EXTENSIONS, ): Promise { if (!resourceSpecifier) return undefined - if (resourceSpecifier.startsWith('.')) { + /* Strip pkg: prefix for Sass node package imports */ + let normalizedSpecifier = resourceSpecifier + if (resourceSpecifier.startsWith('pkg:')) { + normalizedSpecifier = resourceSpecifier.slice('pkg:'.length) + } + if (normalizedSpecifier.startsWith('.')) { return resolveWithExtensionFallback( - path.resolve(path.dirname(importerPath), resourceSpecifier), + path.resolve(path.dirname(importerPath), normalizedSpecifier), ) } - if (resourceSpecifier.startsWith('/')) { - return resolveWithExtensionFallback(path.resolve(rootDir, resourceSpecifier.slice(1))) + if (normalizedSpecifier.startsWith('/')) { + return resolveWithExtensionFallback( + path.resolve(rootDir, normalizedSpecifier.slice(1)), + ) } - const tsconfigResolved = await resolveWithTsconfigPaths(resourceSpecifier, tsconfig) + const tsconfigResolved = await resolveWithTsconfigPaths(normalizedSpecifier, tsconfig) if (tsconfigResolved) { return resolveWithExtensionFallback(tsconfigResolved) } if (resolverFactory) { const resolved = resolveWithFactory( resolverFactory, - resourceSpecifier, + normalizedSpecifier, importerPath, resolutionExtensions, ) @@ -441,7 +448,7 @@ async function resolveImportPath( } const requireFromRoot = getProjectRequire(rootDir) try { - return requireFromRoot.resolve(resourceSpecifier) + return requireFromRoot.resolve(normalizedSpecifier) } catch { return undefined } diff --git a/packages/css/src/lexer.ts b/packages/css/src/lexer.ts index 9e2be7f..f418080 100644 --- a/packages/css/src/lexer.ts +++ b/packages/css/src/lexer.ts @@ -181,6 +181,17 @@ function normalizeSpecifier(raw: string): string { if (!trimmed || trimmed.startsWith('\0')) { return '' } + /* + * For pkg: scheme, only strip actual query strings (?key=value), + * but preserve # as it's part of Sass package importer syntax + */ + if (trimmed.startsWith('pkg:')) { + const queryIndex = trimmed.indexOf('?') + if (queryIndex >= 0) { + return trimmed.slice(0, queryIndex) + } + return trimmed + } const querySearchOffset = trimmed.startsWith('#') ? 1 : 0 const remainder = trimmed.slice(querySearchOffset) const queryMatchIndex = remainder.search(/[?#]/) @@ -189,7 +200,11 @@ function normalizeSpecifier(raw: string): string { if (!withoutQuery) { return '' } - if (/^[a-z][\w+.-]*:/i.test(withoutQuery) && !withoutQuery.startsWith('file:')) { + if ( + /^[a-z][\w+.-]*:/i.test(withoutQuery) && + !withoutQuery.startsWith('file:') && + !withoutQuery.startsWith('pkg:') + ) { return '' } return withoutQuery diff --git a/packages/css/src/moduleGraph.ts b/packages/css/src/moduleGraph.ts index 2c435e8..ade27d0 100644 --- a/packages/css/src/moduleGraph.ts +++ b/packages/css/src/moduleGraph.ts @@ -284,6 +284,17 @@ function normalizeSpecifier(raw: string): string { if (!trimmed || trimmed.startsWith('\0')) { return '' } + /* + * For pkg: scheme, only strip actual query strings (?key=value), + * but preserve # as it's part of Sass package importer syntax + */ + if (trimmed.startsWith('pkg:')) { + const queryIndex = trimmed.indexOf('?') + if (queryIndex >= 0) { + return trimmed.slice(0, queryIndex) + } + return trimmed + } const querySearchOffset = trimmed.startsWith('#') ? 1 : 0 const remainder = trimmed.slice(querySearchOffset) const queryMatchIndex = remainder.search(/[?#]/) @@ -292,7 +303,11 @@ function normalizeSpecifier(raw: string): string { if (!withoutQuery) { return '' } - if (/^[a-z][\w+.-]*:/i.test(withoutQuery) && !withoutQuery.startsWith('file:')) { + if ( + /^[a-z][\w+.-]*:/i.test(withoutQuery) && + !withoutQuery.startsWith('file:') && + !withoutQuery.startsWith('pkg:') + ) { return '' } return withoutQuery diff --git a/packages/css/src/moduleResolution.ts b/packages/css/src/moduleResolution.ts index e1969d8..f10d571 100644 --- a/packages/css/src/moduleResolution.ts +++ b/packages/css/src/moduleResolution.ts @@ -42,11 +42,16 @@ export function resolveWithFactory( return undefined } } - if (/^[a-z][\w+.-]*:/i.test(specifier)) { + /* Strip pkg: prefix for Sass node package imports before resolution */ + let normalizedSpecifier = specifier + if (specifier.startsWith('pkg:')) { + normalizedSpecifier = specifier.slice('pkg:'.length) + } + if (/^[a-z][\w+.-]*:/i.test(normalizedSpecifier)) { return undefined } try { - const result = factory.resolveFileSync(importer, specifier) + const result = factory.resolveFileSync(importer, normalizedSpecifier) return result?.path } catch { return undefined diff --git a/packages/css/src/sassInternals.ts b/packages/css/src/sassInternals.ts index 706b681..cb08e36 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' @@ -147,8 +148,153 @@ export function resolveRelativeSpecifier( return ensureSassPath(candidate) } +/** + * Creates a built-in Sass importer that handles all pkg: imports. + * - pkg:#subpath imports are resolved using package.json imports field + * - Other pkg: imports return null (not handled by this importer) + */ +export function createPkgImporter({ + cwd, + extensions, +}: { + cwd: string + extensions: string[] +}) { + const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' + + return { + async canonicalize(url: string, context?: { containingUrl?: URL | null }) { + if (!url.startsWith('pkg:')) { + return null + } + + if (debug) { + console.error('[knighted-css:sass-pkg] canonicalize request:', url) + if (context?.containingUrl) { + console.error( + '[knighted-css:sass-pkg] containing url:', + context.containingUrl.href, + ) + } + } + + /* Only handle pkg:# imports; others are not supported by this importer */ + const afterPkg = url.slice('pkg:'.length) + if (!afterPkg.startsWith('#')) { + if (debug) { + console.error('[knighted-css:sass-pkg] not a pkg:# import, returning null') + } + return null + } + + const containingPath = context?.containingUrl + ? fileURLToPath(context.containingUrl) + : path.join(cwd, 'index.js') + + /* Strip pkg: prefix to get the Node.js subpath import */ + const subpathImport = afterPkg + + try { + /* + * First try require.resolve which works if the exact file exists. + * If that fails, manually resolve using package.json imports field. + */ + let resolvedPath: string | undefined + + try { + const requireFrom = createRequire(containingPath) + resolvedPath = requireFrom.resolve(subpathImport) + } catch { + /* require.resolve failed, try manual resolution */ + resolvedPath = await resolveSubpathImport(subpathImport, containingPath) + } + + if (resolvedPath) { + /* Apply Sass-specific path resolution (partials, index files) */ + const sassPath = ensureSassPath(resolvedPath) + if (sassPath) { + const fileUrl = pathToFileURL(sassPath) + if (debug) { + console.error('[knighted-css:sass-pkg] canonical url:', fileUrl.href) + } + return fileUrl + } + } + } catch (err) { + if (debug) { + console.error('[knighted-css:sass-pkg] resolution failed:', err) + } + } + + return null + }, + async load(canonicalUrl: URL) { + if (debug) { + console.error('[knighted-css:sass-pkg] load request:', canonicalUrl.href) + } + const filePath = fileURLToPath(canonicalUrl) + const contents = await fs.readFile(filePath, 'utf8') + return { + contents, + syntax: inferSassSyntax(filePath), + } + }, + } +} + +/** + * Manually resolve a Node.js subpath import by reading package.json imports field. + * This is needed because require.resolve is strict and fails for Sass partials. + */ +async function resolveSubpathImport( + subpathImport: string, + fromPath: string, +): Promise { + /* Find the nearest package.json with imports field */ + let dir = path.dirname(fromPath) + const root = path.parse(dir).root + + while (dir !== root) { + const pkgPath = path.join(dir, 'package.json') + if (existsSync(pkgPath)) { + try { + const pkgContent = await fs.readFile(pkgPath, 'utf8') + const pkg = JSON.parse(pkgContent) + + if (pkg.imports && typeof pkg.imports === 'object') { + /* Try to match the subpath import against imports patterns */ + for (const [pattern, target] of Object.entries(pkg.imports)) { + if (typeof target !== 'string') continue + + /* Handle exact match */ + if (pattern === subpathImport) { + return path.resolve(dir, target) + } + + /* Handle wildcard pattern (#styles/* -> ./styles/*) */ + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, -2) // Remove /* + if (subpathImport.startsWith(prefix + '/')) { + const remaining = subpathImport.slice(prefix.length + 1) // Skip prefix and / + const resolved = target.replace('*', remaining) + return path.resolve(dir, resolved) + } + } + } + } + } catch { + /* Ignore package.json read/parse errors */ + } + } + dir = path.dirname(dir) + } + + return undefined +} + export const __sassInternals = { createSassImporter, + createPkgImporter, resolveAliasSpecifier, shouldNormalizeSpecifier, ensureSassPath, diff --git a/packages/css/test/css.test.ts b/packages/css/test/css.test.ts index 16aeb1c..00e60c9 100644 --- a/packages/css/test/css.test.ts +++ b/packages/css/test/css.test.ts @@ -43,14 +43,8 @@ test('supports indented sass compilation', async () => { assert.match(result, /padding-inline:\s*1\.25rem/) }) -test('normalizes custom scheme specifiers before Sass resolves relatives', async () => { - const result = await css(pkgAliasEntry, { - resolver: async specifier => { - if (!specifier.startsWith('pkg:#')) return undefined - const relativePath = specifier.replace(/^pkg:#/, '') - return path.join(pkgAliasDir, relativePath) - }, - }) +test('resolves pkg:# imports natively using oxc-resolver', async () => { + const result = await css(pkgAliasEntry) assert.match(result, /\.alias-demo/) assert.match(result, /font-family:\s*["']Space Grotesk["'], sans-serif/) diff --git a/packages/css/test/fixtures/pkg-alias/package.json b/packages/css/test/fixtures/pkg-alias/package.json new file mode 100644 index 0000000..82d74fd --- /dev/null +++ b/packages/css/test/fixtures/pkg-alias/package.json @@ -0,0 +1,6 @@ +{ + "name": "pkg-alias-fixture", + "imports": { + "#styles/*": "./styles/*" + } +} diff --git a/packages/css/test/lexer.test.ts b/packages/css/test/lexer.test.ts index dd167d3..a4e30ef 100644 --- a/packages/css/test/lexer.test.ts +++ b/packages/css/test/lexer.test.ts @@ -62,3 +62,24 @@ void spread './styles/resolved.css', ]) }) + +test('analyzeModule allows pkg: scheme for Sass node package imports', async () => { + const source = `import 'pkg:#styles/colors.scss' +import 'pkg:@scope/design-tokens/colors.scss?inline' +import './local/styles.css' +import 'http://example.com/remote.css' +` + + const result = await analyzeModule(source, 'entry.ts', { + esParse: () => { + throw new Error('force-oxc') + }, + }) + + assert.equal(result.defaultSignal, 'unknown') + assert.deepEqual(result.imports.sort(), [ + './local/styles.css', + 'pkg:#styles/colors.scss', + 'pkg:@scope/design-tokens/colors.scss', + ]) +}) diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index 3f58ab9..98a517f 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -661,3 +661,37 @@ test('collectStyleImports normalizes resolver results, file URLs, and template l await project.cleanup() } }) + +test('collectStyleImports resolves pkg: scheme with custom resolver', async () => { + const project = await createProject('knighted-module-graph-pkg-') + try { + await project.writeFile('styles/colors.scss', '.colors { color: red; }') + await project.writeFile( + 'entry.ts', + `import 'pkg:#styles/colors.scss' +`, + ) + + const resolver: CssResolver = async specifier => { + if (specifier.startsWith('pkg:#')) { + const relativePath = specifier.replace(/^pkg:#/, '') + return path.join(project.root, relativePath) + } + return undefined + } + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.scss'], + filter: () => true, + resolver, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/colors.scss')]), + ) + } finally { + await project.cleanup() + } +})