diff --git a/README.md b/README.md index 0062e43..dac52f0 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,10 @@ const styles = await css('./src/routes/page.tsx', { This keeps `@knighted/css` resolution in sync with your bundler’s alias/extension rules. +### Sass alias specifiers + +If your Sass files rely on virtual specifiers such as `pkg:#styles/modules/typography.scss`, forward the same resolver you use for JavaScript imports. `@knighted/css` normalizes any resolver-backed custom scheme to a real `file://` URL before Dart Sass evaluates it, so a file loaded via `pkg:#…` still has a stable canonical URL. That keeps Sass’s internal `new URL('./tokens.scss', context.containingUrl)` calls working, which means relative `@use`/`@import` statements inside those alias-backed files continue to resolve just like they do in your bundler. + ### Specificity boost Use `specificityBoost` to tweak selector behavior: diff --git a/package-lock.json b/package-lock.json index 9df1652..064938f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11041,7 +11041,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.4", + "version": "1.0.0-rc.5", "license": "MIT", "dependencies": { "dependency-tree": "^11.2.0", @@ -11068,7 +11068,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.4", + "@knighted/css": "1.0.0-rc.5", "@knighted/jsx": "^1.2.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index b33def0..8c3bc9f 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.4", + "version": "1.0.0-rc.5", "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 9c8122d..766f8e9 100644 --- a/packages/css/src/css.ts +++ b/packages/css/src/css.ts @@ -1,5 +1,6 @@ import path from 'node:path' import { existsSync, promises as fs } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' import dependencyTree from 'dependency-tree' import type { Options as DependencyTreeOpts } from 'dependency-tree' @@ -92,6 +93,7 @@ export async function cssWithMeta( const chunk = await compileStyleModule(file, { cwd, peerResolver: options.peerResolver, + resolver: options.resolver, }) if (chunk) { chunks.push(chunk) @@ -198,14 +200,22 @@ function matchExtension(filePath: string, extensions: string[]): string | undefi async function compileStyleModule( file: StyleModule, - { cwd, peerResolver }: { cwd: string; peerResolver?: PeerLoader }, + { + cwd, + peerResolver, + resolver, + }: { cwd: string; peerResolver?: PeerLoader; resolver?: CssResolver }, ): Promise { switch (file.ext) { case '.css': return fs.readFile(file.path, 'utf8') case '.scss': case '.sass': - return compileSass(file.path, file.ext === '.sass', peerResolver) + return compileSass(file.path, file.ext === '.sass', { + cwd, + peerResolver, + resolver, + }) case '.less': return compileLess(file.path, peerResolver) case '.css.ts': @@ -218,7 +228,11 @@ async function compileStyleModule( async function compileSass( filePath: string, indented: boolean, - peerResolver?: PeerLoader, + { + cwd, + peerResolver, + resolver, + }: { cwd: string; peerResolver?: PeerLoader; resolver?: CssResolver }, ): Promise { const sassModule = await optionalPeer( 'sass', @@ -226,9 +240,11 @@ async function compileSass( peerResolver, ) const sass = sassModule - const result = sass.compile(filePath, { + const importer = createSassImporter({ cwd, resolver }) + const result = await sass.compileAsync(filePath, { style: 'expanded', loadPaths: buildSassLoadPaths(filePath), + importers: importer ? [importer] : undefined, }) return result.css } @@ -253,6 +269,132 @@ function buildSassLoadPaths(filePath: string): string[] { return Array.from(loadPaths).filter(dir => dir && existsSync(dir)) } +function createSassImporter({ cwd, resolver }: { cwd: string; resolver?: CssResolver }) { + if (!resolver) return undefined + const debug = process.env.KNIGHTED_CSS_DEBUG_SASS === '1' + + return { + async canonicalize(url: string, context?: { containingUrl?: URL | null }) { + if (debug) { + console.error('[knighted-css:sass] canonicalize request:', url) + if (context?.containingUrl) { + console.error('[knighted-css:sass] containing url:', context.containingUrl.href) + } + } + if (shouldNormalizeSpecifier(url)) { + const resolvedPath = await resolveAliasSpecifier(url, resolver, cwd) + if (!resolvedPath) { + if (debug) { + console.error('[knighted-css:sass] resolver returned no result for', url) + } + return null + } + const fileUrl = pathToFileURL(resolvedPath) + if (debug) { + console.error('[knighted-css:sass] canonical url:', fileUrl.href) + } + return fileUrl + } + const relativePath = resolveRelativeSpecifier(url, context?.containingUrl) + if (relativePath) { + const fileUrl = pathToFileURL(relativePath) + if (debug) { + console.error('[knighted-css:sass] canonical url:', fileUrl.href) + } + return fileUrl + } + return null + }, + async load(canonicalUrl: URL) { + if (debug) { + console.error('[knighted-css:sass] load request:', canonicalUrl.href) + } + const filePath = fileURLToPath(canonicalUrl) + const contents = await fs.readFile(filePath, 'utf8') + return { + contents, + syntax: inferSassSyntax(filePath), + } + }, + } +} + +async function resolveAliasSpecifier( + specifier: string, + resolver: CssResolver, + cwd: string, +): Promise { + const resolved = await resolver(specifier, { cwd }) + if (!resolved) { + return undefined + } + if (resolved.startsWith('file://')) { + return ensureSassPath(fileURLToPath(new URL(resolved))) + } + const normalized = path.isAbsolute(resolved) ? resolved : path.resolve(cwd, resolved) + return ensureSassPath(normalized) +} + +function shouldNormalizeSpecifier(specifier: string): boolean { + const schemeMatch = specifier.match(/^([a-z][\w+.-]*):/i) + if (!schemeMatch) { + return false + } + const scheme = schemeMatch[1].toLowerCase() + if ( + scheme === 'file' || + scheme === 'http' || + scheme === 'https' || + scheme === 'data' || + scheme === 'sass' + ) { + return false + } + return true +} + +function inferSassSyntax(filePath: string): 'scss' | 'indented' { + return filePath.endsWith('.sass') ? 'indented' : 'scss' +} + +function ensureSassPath(filePath: string): string | undefined { + if (existsSync(filePath)) { + return filePath + } + const ext = path.extname(filePath) + const dir = path.dirname(filePath) + const base = path.basename(filePath, ext) + const partialCandidate = path.join(dir, `_${base}${ext}`) + if (ext && existsSync(partialCandidate)) { + return partialCandidate + } + const indexCandidate = path.join(dir, base, `index${ext}`) + if (ext && existsSync(indexCandidate)) { + return indexCandidate + } + const partialIndexCandidate = path.join(dir, base, `_index${ext}`) + if (ext && existsSync(partialIndexCandidate)) { + return partialIndexCandidate + } + return undefined +} + +function resolveRelativeSpecifier( + specifier: string, + containingUrl?: URL | null, +): string | undefined { + if (!containingUrl || containingUrl.protocol !== 'file:') { + return undefined + } + if (/^[a-z][\w+.-]*:/i.test(specifier)) { + return undefined + } + const containingPath = fileURLToPath(containingUrl) + const baseDir = path.dirname(containingPath) + const candidate = path.resolve(baseDir, specifier) + return ensureSassPath(candidate) +} + async function compileLess(filePath: string, peerResolver?: PeerLoader): Promise { const mod = await optionalPeer('less', 'Less', peerResolver) const less = unwrapModuleNamespace(mod) diff --git a/packages/css/test/css.test.ts b/packages/css/test/css.test.ts index f9b6f77..0e61d1f 100644 --- a/packages/css/test/css.test.ts +++ b/packages/css/test/css.test.ts @@ -18,6 +18,8 @@ const vanillaEntry = path.join(fixturesDir, 'vanilla/styles.css.ts') const miscFixturesDir = path.resolve(__dirname, './fixtures/misc') const selectorsCss = path.join(miscFixturesDir, 'selectors.css') const unsupportedStyle = path.join(miscFixturesDir, 'unsupported.noop') +const pkgAliasDir = path.resolve(__dirname, './fixtures/pkg-alias') +const pkgAliasEntry = path.join(pkgAliasDir, 'entry.scss') test('extracts CSS from JS dependency graph', async () => { const result = await css(basicEntry) @@ -41,6 +43,19 @@ 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) + }, + }) + + assert.match(result, /\.alias-demo/) + assert.match(result, /font-family:\s*["']Space Grotesk["'], sans-serif/) +}) + test('supports less compilation', async () => { const result = await css(lessEntry) assert.match(result, /\.less-styles/) diff --git a/packages/css/test/fixtures/pkg-alias/entry.scss b/packages/css/test/fixtures/pkg-alias/entry.scss new file mode 100644 index 0000000..1ae8c39 --- /dev/null +++ b/packages/css/test/fixtures/pkg-alias/entry.scss @@ -0,0 +1,6 @@ +@use 'pkg:#styles/modules/typography.scss' as typography; + +.alias-demo { + @include typography.apply-stack; + font-size: 1rem; +} diff --git a/packages/css/test/fixtures/pkg-alias/styles/modules/_tokens.scss b/packages/css/test/fixtures/pkg-alias/styles/modules/_tokens.scss new file mode 100644 index 0000000..885b926 --- /dev/null +++ b/packages/css/test/fixtures/pkg-alias/styles/modules/_tokens.scss @@ -0,0 +1 @@ +$base-stack: 'Space Grotesk', sans-serif; diff --git a/packages/css/test/fixtures/pkg-alias/styles/modules/_typography.scss b/packages/css/test/fixtures/pkg-alias/styles/modules/_typography.scss new file mode 100644 index 0000000..b3fcabd --- /dev/null +++ b/packages/css/test/fixtures/pkg-alias/styles/modules/_typography.scss @@ -0,0 +1,7 @@ +@use './tokens.scss'; + +$stack: tokens.$base-stack; + +@mixin apply-stack { + font-family: $stack; +} diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 20df54f..5392d88 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -14,7 +14,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.4", + "@knighted/css": "1.0.0-rc.5", "@knighted/jsx": "^1.2.1", "lit": "^3.2.1", "react": "^19.0.0",