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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.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",
Expand Down
150 changes: 146 additions & 4 deletions packages/css/src/css.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string> {
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':
Expand All @@ -218,17 +228,23 @@ async function compileStyleModule(
async function compileSass(
filePath: string,
indented: boolean,
peerResolver?: PeerLoader,
{
cwd,
peerResolver,
resolver,
}: { cwd: string; peerResolver?: PeerLoader; resolver?: CssResolver },
): Promise<string> {
const sassModule = await optionalPeer<typeof import('sass')>(
'sass',
'Sass',
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
}
Expand All @@ -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<string | undefined> {
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<string> {
const mod = await optionalPeer<typeof import('less')>('less', 'Less', peerResolver)
const less = unwrapModuleNamespace(mod)
Expand Down
15 changes: 15 additions & 0 deletions packages/css/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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/)
Expand Down
6 changes: 6 additions & 0 deletions packages/css/test/fixtures/pkg-alias/entry.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@use 'pkg:#styles/modules/typography.scss' as typography;

.alias-demo {
@include typography.apply-stack;
font-size: 1rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$base-stack: 'Space Grotesk', sans-serif;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@use './tokens.scss';

$stack: tokens.$base-stack;

@mixin apply-stack {
font-family: $stack;
}
2 changes: 1 addition & 1 deletion packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down