Skip to content

Commit 20c224e

Browse files
feat: better sass node package import support. (#62)
1 parent 2423c9e commit 20c224e

17 files changed

Lines changed: 709 additions & 25 deletions

File tree

docs/roadmap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@
1515

1616
- Evaluate promoting `lightningcss` to a peer dependency so consumers can align with their own upgrade cadence.
1717
- Document fallbacks for specificity workflows if teams opt to satisfy the peer via compatible forks or alternative transformers.
18+
19+
## Sass Resolver Options
20+
21+
- Allow configuring conditionNames for `pkg:` resolution (e.g., opt into `sass` or custom priority ordering).
22+
- Allow opting into explicit `tsconfig` selection instead of `tsconfig: auto` when resolving `pkg:` specifiers.

docs/sass-import-aliases.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
# Sass import aliases
22

3-
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.
3+
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`.
44

5-
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.
5+
`@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.
6+
7+
> [!NOTE]
8+
> Sass support is provided via Dart Sass (`sass` npm package). Ruby Sass and node-sass are not supported.
69
710
## When you need a resolver
811

912
Add a resolver whenever you see either of the following:
1013

11-
- An `@use`/`@import` statement that starts with a nonstandard scheme such as `pkg:` or `sass:`.
14+
- An `@use`/`@import` statement that starts with a nonstandard scheme such as `alias:` or `sass:` (other than `pkg:`).
1215
- 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).
1316

1417
Without a resolver, those imports throw “Cannot resolve specifier” errors as soon as `@knighted/css` tries to crawl the module graph.
1518

16-
## Example: strip `pkg:#` aliases
19+
## Example: strip custom aliases
1720

1821
```ts
1922
import path from 'node:path'
@@ -22,10 +25,9 @@ import { css } from '@knighted/css'
2225
const pkgAppSrcDir = path.resolve(process.cwd(), 'packages/app/src')
2326

2427
function resolvePkgAlias(specifier: string): string | undefined {
25-
if (!specifier.startsWith('pkg:')) return undefined
28+
if (!specifier.startsWith('alias:')) return undefined
2629
const remainder = specifier
27-
.slice('pkg:'.length)
28-
.replace(/^#/, '')
30+
.slice('alias:'.length)
2931
.replace(/^@scope\/app\/?/, '')
3032
.replace(/^\/+/, '')
3133
return path.resolve(pkgAppSrcDir, remainder)

docs/type-generation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Wire it into `postinstall` or your build so new selectors land automatically.
2727
- `--out-dir` – directory for the selector module manifest cache (defaults to `<root>/.knighted-css`).
2828
- `--stable-namespace` – namespace prefix shared by the generated selector maps and loader runtime.
2929
- `--auto-stable` – enable auto-stable selector generation during extraction (mirrors the loader’s auto-stable behavior).
30+
- `--resolver` – path or package name exporting a `CssResolver` (default export or named `resolver`).
3031

3132
### Relationship to the loader
3233

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/css/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ Run `knighted-css-generate-types` so every specifier that ends with `.knighted-c
113113
import stableSelectors from './button.module.scss.knighted-css.js'
114114
```
115115

116+
Need bespoke resolution? Pass `--resolver` to load a module exporting a `CssResolver` and apply it during type generation.
117+
116118
When the `.knighted-css` import targets a JavaScript/TypeScript module, the generated proxy also re-exports the modules exports and `knightedCss`, so a single import can provide component exports, typed selectors, and the compiled stylesheet string:
117119

118120
```ts

packages/css/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/css",
3-
"version": "1.1.0-rc.3",
3+
"version": "1.1.0-rc.4",
44
"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.",
55
"type": "module",
66
"main": "./dist/css.js",

packages/css/src/css.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { stableClass } from './stableSelectors.js'
2323

2424
import { collectStyleImports } from './moduleGraph.js'
2525
import type { ModuleGraphOptions } from './moduleGraph.js'
26-
import { createSassImporter } from './sassInternals.js'
26+
import { createLegacySassImporter, createSassImporter } from './sassInternals.js'
2727
import type { CssResolver } from './types.js'
2828
export type { AutoStableOption } from './autoStableSelectors.js'
2929

@@ -328,7 +328,8 @@ async function compileSass(
328328
peerResolver,
329329
)
330330
const sass = resolveSassNamespace(sassModule)
331-
const importer = createSassImporter({ cwd, resolver })
331+
const importer = createSassImporter({ cwd, resolver, entryPath: filePath })
332+
const legacyImporter = createLegacySassImporter({ cwd, resolver, entryPath: filePath })
332333
const loadPaths = buildSassLoadPaths(filePath)
333334

334335
if (typeof (sass as { compileAsync?: Function }).compileAsync === 'function') {
@@ -348,6 +349,7 @@ async function compileSass(
348349
filePath,
349350
indented,
350351
loadPaths,
352+
legacyImporter,
351353
)
352354
}
353355

@@ -361,6 +363,7 @@ function renderLegacySass(
361363
filePath: string,
362364
indented: boolean,
363365
loadPaths: string[],
366+
importer?: ReturnType<typeof createLegacySassImporter>,
364367
): Promise<string> {
365368
return new Promise((resolve, reject) => {
366369
sass.render(
@@ -369,6 +372,7 @@ function renderLegacySass(
369372
indentedSyntax: indented,
370373
outputStyle: 'expanded',
371374
includePaths: loadPaths,
375+
importer: importer ? [importer] : undefined,
372376
},
373377
(error, result) => {
374378
if (error) {

packages/css/src/generateTypes.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { analyzeModule, type DefaultExportSignal } from './lexer.js'
1414
import { createResolverFactory, resolveWithFactory } from './moduleResolution.js'
1515
import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js'
1616
import { resolveStableNamespace } from './stableNamespace.js'
17+
import type { CssResolver } from './types.js'
1718

1819
interface ImportMatch {
1920
specifier: string
@@ -48,6 +49,7 @@ interface GenerateTypesInternalOptions {
4849
stableNamespace?: string
4950
autoStable?: boolean
5051
tsconfig?: TsconfigResolutionContext
52+
resolver?: CssResolver
5153
}
5254

5355
export interface GenerateTypesResult {
@@ -63,6 +65,7 @@ export interface GenerateTypesOptions {
6365
outDir?: string
6466
stableNamespace?: string
6567
autoStable?: boolean
68+
resolver?: CssResolver
6669
}
6770

6871
const DEFAULT_SKIP_DIRS = new Set([
@@ -146,6 +149,7 @@ export async function generateTypes(
146149
stableNamespace: options.stableNamespace,
147150
autoStable: options.autoStable,
148151
tsconfig,
152+
resolver: options.resolver,
149153
}
150154

151155
return generateDeclarations(internalOptions)
@@ -194,6 +198,7 @@ async function generateDeclarations(
194198
match.importer,
195199
options.rootDir,
196200
options.tsconfig,
201+
options.resolver,
197202
resolverFactory,
198203
RESOLUTION_EXTENSIONS,
199204
)
@@ -217,6 +222,7 @@ async function generateDeclarations(
217222
options.autoStable && shouldUseCssModules
218223
? { cssModules: true }
219224
: undefined,
225+
resolver: options.resolver,
220226
})
221227
selectorMap = buildStableSelectorsLiteral({
222228
css,
@@ -412,6 +418,7 @@ async function resolveImportPath(
412418
importerPath: string,
413419
rootDir: string,
414420
tsconfig?: TsconfigResolutionContext,
421+
resolver?: CssResolver,
415422
resolverFactory?: ReturnType<typeof createResolverFactory>,
416423
resolutionExtensions: string[] = RESOLUTION_EXTENSIONS,
417424
): Promise<string | undefined> {
@@ -424,6 +431,17 @@ async function resolveImportPath(
424431
if (resourceSpecifier.startsWith('/')) {
425432
return resolveWithExtensionFallback(path.resolve(rootDir, resourceSpecifier.slice(1)))
426433
}
434+
if (resolver) {
435+
const resolved = await resolveWithResolver(
436+
resourceSpecifier,
437+
resolver,
438+
rootDir,
439+
importerPath,
440+
)
441+
if (resolved) {
442+
return resolveWithExtensionFallback(resolved)
443+
}
444+
}
427445
const tsconfigResolved = await resolveWithTsconfigPaths(resourceSpecifier, tsconfig)
428446
if (tsconfigResolved) {
429447
return resolveWithExtensionFallback(tsconfigResolved)
@@ -447,6 +465,26 @@ async function resolveImportPath(
447465
}
448466
}
449467

468+
async function resolveWithResolver(
469+
specifier: string,
470+
resolver: CssResolver,
471+
rootDir: string,
472+
importerPath?: string,
473+
): Promise<string | undefined> {
474+
const resolved = await resolver(specifier, { cwd: rootDir, from: importerPath })
475+
if (!resolved) {
476+
return undefined
477+
}
478+
if (resolved.startsWith('file://')) {
479+
try {
480+
return fileURLToPath(new URL(resolved))
481+
} catch {
482+
return undefined
483+
}
484+
}
485+
return path.isAbsolute(resolved) ? resolved : path.resolve(rootDir, resolved)
486+
}
487+
450488
function buildSelectorModuleManifestKey(resolvedPath: string): string {
451489
return resolvedPath.split(path.sep).join('/')
452490
}
@@ -798,6 +836,37 @@ function getProjectRequire(rootDir: string): ReturnType<typeof createRequire> {
798836
return loader
799837
}
800838

839+
function resolveResolverModulePath(specifier: string, rootDir: string): string {
840+
if (specifier.startsWith('file://')) {
841+
return fileURLToPath(new URL(specifier))
842+
}
843+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
844+
return path.resolve(rootDir, specifier)
845+
}
846+
const requireFromRoot = getProjectRequire(rootDir)
847+
return requireFromRoot.resolve(specifier)
848+
}
849+
850+
async function loadResolverModule(
851+
specifier: string,
852+
rootDir: string,
853+
): Promise<CssResolver> {
854+
const resolvedPath = resolveResolverModulePath(specifier, rootDir)
855+
const mod = await import(pathToFileURL(resolvedPath).href)
856+
const candidate =
857+
typeof mod.default === 'function'
858+
? (mod.default as CssResolver)
859+
: typeof (mod as { resolver?: unknown }).resolver === 'function'
860+
? ((mod as { resolver: CssResolver }).resolver as CssResolver)
861+
: undefined
862+
if (!candidate) {
863+
throw new Error(
864+
'Resolver module must export a function as the default export or a named export named "resolver".',
865+
)
866+
}
867+
return candidate
868+
}
869+
801870
export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise<void> {
802871
let parsed: ParsedCliArgs
803872
try {
@@ -812,12 +881,16 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise
812881
return
813882
}
814883
try {
884+
const resolver = parsed.resolver
885+
? await loadResolverModule(parsed.resolver, parsed.rootDir)
886+
: undefined
815887
const result = await generateTypes({
816888
rootDir: parsed.rootDir,
817889
include: parsed.include,
818890
outDir: parsed.outDir,
819891
stableNamespace: parsed.stableNamespace,
820892
autoStable: parsed.autoStable,
893+
resolver,
821894
})
822895
reportCliResult(result)
823896
} catch (error) {
@@ -833,6 +906,7 @@ export interface ParsedCliArgs {
833906
outDir?: string
834907
stableNamespace?: string
835908
autoStable?: boolean
909+
resolver?: string
836910
help?: boolean
837911
}
838912

@@ -842,6 +916,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
842916
let outDir: string | undefined
843917
let stableNamespace: string | undefined
844918
let autoStable = false
919+
let resolver: string | undefined
845920

846921
for (let i = 0; i < argv.length; i += 1) {
847922
const arg = argv[i]
@@ -884,13 +959,21 @@ function parseCliArgs(argv: string[]): ParsedCliArgs {
884959
stableNamespace = value
885960
continue
886961
}
962+
if (arg === '--resolver') {
963+
const value = argv[++i]
964+
if (!value) {
965+
throw new Error('Missing value for --resolver')
966+
}
967+
resolver = value
968+
continue
969+
}
887970
if (arg.startsWith('-')) {
888971
throw new Error(`Unknown flag: ${arg}`)
889972
}
890973
include.push(arg)
891974
}
892975

893-
return { rootDir, include, outDir, stableNamespace, autoStable }
976+
return { rootDir, include, outDir, stableNamespace, autoStable, resolver }
894977
}
895978

896979
function printHelp(): void {
@@ -902,6 +985,7 @@ Options:
902985
--out-dir <path> Directory to store selector module manifest cache
903986
--stable-namespace <name> Stable namespace prefix for generated selector maps
904987
--auto-stable Enable autoStable when extracting CSS for selectors
988+
--resolver <path> Path or package name exporting a CssResolver
905989
-h, --help Show this help message
906990
`)
907991
}
@@ -948,11 +1032,14 @@ export const __generateTypesInternals = {
9481032
setImportMetaUrlProvider,
9491033
isNonRelativeSpecifier,
9501034
isStyleResource,
1035+
resolveProxyInfo,
9511036
resolveWithExtensionFallback,
1037+
resolveIndexFallback,
9521038
createProjectPeerResolver,
9531039
getProjectRequire,
9541040
loadTsconfigResolutionContext,
9551041
resolveWithTsconfigPaths,
1042+
loadResolverModule,
9561043
parseCliArgs,
9571044
printHelp,
9581045
reportCliResult,

0 commit comments

Comments
 (0)