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
5 changes: 5 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 9 additions & 7 deletions docs/sass-import-aliases.md
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<root>/.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

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: 2 additions & 0 deletions packages/css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.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",
Expand Down
8 changes: 6 additions & 2 deletions packages/css/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -328,7 +328,8 @@ async function compileSass(
peerResolver,
)
const sass = resolveSassNamespace(sassModule)
const importer = createSassImporter({ 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') {
Expand All @@ -348,6 +349,7 @@ async function compileSass(
filePath,
indented,
loadPaths,
legacyImporter,
)
}

Expand All @@ -361,6 +363,7 @@ function renderLegacySass(
filePath: string,
indented: boolean,
loadPaths: string[],
importer?: ReturnType<typeof createLegacySassImporter>,
): Promise<string> {
return new Promise((resolve, reject) => {
sass.render(
Expand All @@ -369,6 +372,7 @@ function renderLegacySass(
indentedSyntax: indented,
outputStyle: 'expanded',
includePaths: loadPaths,
importer: importer ? [importer] : undefined,
},
(error, result) => {
if (error) {
Expand Down
89 changes: 88 additions & 1 deletion packages/css/src/generateTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,7 @@ interface GenerateTypesInternalOptions {
stableNamespace?: string
autoStable?: boolean
tsconfig?: TsconfigResolutionContext
resolver?: CssResolver
}

export interface GenerateTypesResult {
Expand All @@ -63,6 +65,7 @@ export interface GenerateTypesOptions {
outDir?: string
stableNamespace?: string
autoStable?: boolean
resolver?: CssResolver
}

const DEFAULT_SKIP_DIRS = new Set([
Expand Down Expand Up @@ -146,6 +149,7 @@ export async function generateTypes(
stableNamespace: options.stableNamespace,
autoStable: options.autoStable,
tsconfig,
resolver: options.resolver,
}

return generateDeclarations(internalOptions)
Expand Down Expand Up @@ -194,6 +198,7 @@ async function generateDeclarations(
match.importer,
options.rootDir,
options.tsconfig,
options.resolver,
resolverFactory,
RESOLUTION_EXTENSIONS,
)
Expand All @@ -217,6 +222,7 @@ async function generateDeclarations(
options.autoStable && shouldUseCssModules
? { cssModules: true }
: undefined,
resolver: options.resolver,
})
selectorMap = buildStableSelectorsLiteral({
css,
Expand Down Expand Up @@ -412,6 +418,7 @@ async function resolveImportPath(
importerPath: string,
rootDir: string,
tsconfig?: TsconfigResolutionContext,
resolver?: CssResolver,
resolverFactory?: ReturnType<typeof createResolverFactory>,
resolutionExtensions: string[] = RESOLUTION_EXTENSIONS,
): Promise<string | undefined> {
Expand All @@ -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)
Expand All @@ -447,6 +465,26 @@ async function resolveImportPath(
}
}

async function resolveWithResolver(
specifier: string,
resolver: CssResolver,
rootDir: string,
importerPath?: string,
): Promise<string | undefined> {
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('/')
}
Expand Down Expand Up @@ -798,6 +836,37 @@ function getProjectRequire(rootDir: string): ReturnType<typeof createRequire> {
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<CssResolver> {
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<void> {
let parsed: ParsedCliArgs
try {
Expand All @@ -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) {
Expand All @@ -833,6 +906,7 @@ export interface ParsedCliArgs {
outDir?: string
stableNamespace?: string
autoStable?: boolean
resolver?: string
help?: boolean
}

Expand All @@ -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]
Expand Down Expand Up @@ -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 {
Expand All @@ -902,6 +985,7 @@ Options:
--out-dir <path> Directory to store selector module manifest cache
--stable-namespace <name> Stable namespace prefix for generated selector maps
--auto-stable Enable autoStable when extracting CSS for selectors
--resolver <path> Path or package name exporting a CssResolver
-h, --help Show this help message
`)
}
Expand Down Expand Up @@ -948,11 +1032,14 @@ export const __generateTypesInternals = {
setImportMetaUrlProvider,
isNonRelativeSpecifier,
isStyleResource,
resolveProxyInfo,
resolveWithExtensionFallback,
resolveIndexFallback,
createProjectPeerResolver,
getProjectRequire,
loadTsconfigResolutionContext,
resolveWithTsconfigPaths,
loadResolverModule,
parseCliArgs,
printHelp,
reportCliResult,
Expand Down
Loading