From c59f0c1e2d33b217ada4363ee623f8d53b5a2fcb Mon Sep 17 00:00:00 2001 From: James Date: Thu, 6 Aug 2026 10:39:03 +0100 Subject: [PATCH] fix(css): resolve Sass tsconfig path aliases --- packages/vinext/src/index.ts | 128 +++++++++++---- packages/vinext/src/plugins/sass.ts | 117 ++++++++++++++ .../sass-tsconfig-paths/app/layout.tsx | 9 ++ .../fixtures/sass-tsconfig-paths/app/page.tsx | 3 + .../dependency-tokens/_index.scss | 1 + .../dependency-fixture/example/_entry.scss | 3 + .../sass-tsconfig-paths/styles/_tokens.scss | 1 + .../sass-tsconfig-paths/styles/global.scss | 14 ++ .../styles/palettes/colors/_tokens.scss | 1 + .../styles/special/_colors.scss | 1 + .../styles/theme/_colors.scss | 1 + .../sass-tsconfig-paths/tsconfig.json | 12 ++ tests/sass-options.test.ts | 148 ++++++++++++++++++ tests/sass-tsconfig-paths.test.ts | 80 ++++++++++ 14 files changed, 491 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/sass-tsconfig-paths/app/layout.tsx create mode 100644 tests/fixtures/sass-tsconfig-paths/app/page.tsx create mode 100644 tests/fixtures/sass-tsconfig-paths/dependency-fixture/dependency-tokens/_index.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/dependency-fixture/example/_entry.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/styles/_tokens.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/styles/global.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/styles/palettes/colors/_tokens.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/styles/special/_colors.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/styles/theme/_colors.scss create mode 100644 tests/fixtures/sass-tsconfig-paths/tsconfig.json create mode 100644 tests/sass-tsconfig-paths.test.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8574f33f8d..f8514c2a85 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -207,8 +207,10 @@ import { resolvePostcssStringPlugins } from "./plugins/postcss.js"; import { buildSassPreprocessorOptions, createSassCssUrlAssetImporter, + createSassTsconfigPathImporters, createSassTildeImporter, createSassAwareFileSystemLoader, + type SassTsconfigPathAlias, } from "./plugins/sass.js"; import { createClientFileNameConfig, @@ -782,20 +784,63 @@ function resolveTsconfigExtends(configPath: string, specifier: string): string | return null; } +type MaterializedTsconfigPathAliases = { + vite: Record; + sass: SassTsconfigPathAlias[]; +}; + +function mergeSassTsconfigPathAliases( + ...groups: readonly (readonly SassTsconfigPathAlias[])[] +): SassTsconfigPathAlias[] { + const merged = new Map(); + for (const group of groups) { + for (const alias of group) { + merged.set(alias.find, alias); + } + } + return [...merged.values()]; +} + +function hasZeroOrOneAsterisk(value: string): boolean { + const first = value.indexOf("*"); + return first < 0 || first === value.lastIndexOf("*"); +} + function materializeTsconfigPathAliases( pathsConfig: Record, baseUrl: string, projectRoot: string, -): Record { - const aliases: Record = {}; +): MaterializedTsconfigPathAliases { + const vite: Record = {}; + const sass: SassTsconfigPathAlias[] = []; for (const [find, rawTargets] of Object.entries(pathsConfig)) { - const target = Array.isArray(rawTargets) - ? rawTargets.find((value): value is string => typeof value === "string") + const targets = Array.isArray(rawTargets) + ? rawTargets.filter((value): value is string => typeof value === "string") : typeof rawTargets === "string" - ? rawTargets - : null; - if (!target) continue; + ? [rawTargets] + : []; + if (targets.length === 0) continue; + + // Sass can preserve the full TypeScript path-mapping contract: exact keys, + // one `*` anywhere in a pattern, and ordered replacement fallbacks. + if (find.length > 0 && hasZeroOrOneAsterisk(find)) { + const findHasStar = find.includes("*"); + const replacements = targets + .filter( + (target) => + target.length > 0 && + hasZeroOrOneAsterisk(target) && + (findHasStar || !target.includes("*")), + ) + .map((target) => path.resolve(baseUrl, target)); + if (replacements.length > 0) sass.push({ find, replacements }); + } + + // Vite aliases can only represent exact mappings and the common trailing + // `/*` prefix form. Keep the existing first-target materialization for JS + // transforms; Sass uses the richer representation above. + const target = targets[0]!; if (find.includes("*") || target.includes("*")) { if (!find.endsWith("/*") || !target.endsWith("/*")) continue; @@ -807,14 +852,16 @@ function materializeTsconfigPathAliases( const targetDir = target.slice(0, -2); if (!aliasKey || !targetDir) continue; - aliases[aliasKey] = toViteAliasReplacement(path.resolve(baseUrl, targetDir), projectRoot); + const replacement = path.resolve(baseUrl, targetDir); + vite[aliasKey] = toViteAliasReplacement(replacement, projectRoot); continue; } - aliases[find] = toViteAliasReplacement(path.resolve(baseUrl, target), projectRoot); + const replacement = path.resolve(baseUrl, target); + vite[find] = toViteAliasReplacement(replacement, projectRoot); } - return aliases; + return { vite, sass }; } function toViteAliasReplacement(absolutePath: string, projectRoot: string): string { @@ -870,26 +917,30 @@ function loadTsconfigPathAliases( configPath: string, projectRoot: string, seen = new Set(), -): Record { +): MaterializedTsconfigPathAliases { const normalizedPath = tryRealpathSync(configPath) ?? configPath; - if (seen.has(normalizedPath)) return {}; + if (seen.has(normalizedPath)) return { vite: {}, sass: [] }; seen.add(normalizedPath); let parsed: Record | null = null; try { parsed = parseStaticObjectLiteral(fs.readFileSync(normalizedPath, "utf-8")); } catch { - return {}; + return { vite: {}, sass: [] }; } - if (!parsed) return {}; + if (!parsed) return { vite: {}, sass: [] }; - let aliases: Record = {}; + let aliases: MaterializedTsconfigPathAliases = { vite: {}, sass: [] }; // `extends` may be a string or (TypeScript 5.0+) an array; iterate parents in // order so later entries override earlier ones (matching Next.js). for (const extendsSpecifier of normalizeTsconfigExtends(parsed.extends)) { const extendedPath = resolveTsconfigExtends(normalizedPath, extendsSpecifier); if (extendedPath) { - aliases = { ...aliases, ...loadTsconfigPathAliases(extendedPath, projectRoot, seen) }; + const parent = loadTsconfigPathAliases(extendedPath, projectRoot, seen); + aliases = { + vite: { ...aliases.vite, ...parent.vite }, + sass: mergeSassTsconfigPathAliases(aliases.sass, parent.sass), + }; } } @@ -902,9 +953,10 @@ function loadTsconfigPathAliases( compilerOptions && typeof compilerOptions.baseUrl === "string" ? compilerOptions.baseUrl : "."; const resolvedBaseUrl = path.resolve(path.dirname(normalizedPath), baseUrl); + const own = materializeTsconfigPathAliases(pathsConfig, resolvedBaseUrl, projectRoot); return { - ...aliases, - ...materializeTsconfigPathAliases(pathsConfig, resolvedBaseUrl, projectRoot), + vite: { ...aliases.vite, ...own.vite }, + sass: mergeSassTsconfigPathAliases(aliases.sass, own.sass), }; } @@ -960,7 +1012,12 @@ function suppressOptionalOptimizeDepsWarnings(logger: Logger): void { // Cache materialized tsconfig/jsconfig aliases so Vite's glob and dynamic-import // transforms can see them via resolve.alias without re-reading config files per env. -const _tsconfigAliasCache = new Map>(); +type ResolvedTsconfigPathAliases = { + vite: Record; + sass: SassTsconfigPathAlias[]; +}; + +const _tsconfigAliasCache = new Map(); /** * Order materialized tsconfig path aliases by descending prefix length. @@ -978,19 +1035,23 @@ function sortTsconfigAliasesBySpecificity(aliases: Record): Reco function resolveTsconfigAliases( projectRoot: string, configuredPath?: string, -): Record { +): ResolvedTsconfigPathAliases { const configPath = configuredPath ? path.resolve(projectRoot, configuredPath) : undefined; const cacheKey = configPath ?? projectRoot; if (_tsconfigAliasCache.has(cacheKey)) { return _tsconfigAliasCache.get(cacheKey)!; } - let aliases: Record = {}; + let aliases: ResolvedTsconfigPathAliases = { vite: {}, sass: [] }; for (const candidate of configPath ? [configPath] : TSCONFIG_FILES.map((name) => path.join(projectRoot, name))) { if (!fs.existsSync(candidate)) continue; - aliases = sortTsconfigAliasesBySpecificity(loadTsconfigPathAliases(candidate, projectRoot)); + const materialized = loadTsconfigPathAliases(candidate, projectRoot); + aliases = { + vite: sortTsconfigAliasesBySpecificity(materialized.vite), + sass: materialized.sass, + }; break; } @@ -1991,6 +2052,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { root = toSlash(config.root ?? process.cwd()); const userResolve = config.resolve as UserResolveConfigWithTsconfigPaths | undefined; let tsconfigPathAliases: Record = {}; + let sassTsconfigPathAliases: SassTsconfigPathAlias[] = []; const swcHelpersAlias = resolveSwcHelpersAlias(root); // Load .env files into process.env before anything else. @@ -2124,7 +2186,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? nextConfig.typescript.tsconfigPath : undefined : undefined; - tsconfigPathAliases = resolveTsconfigAliases(root, configuredTsconfigPath); + const resolvedTsconfigAliases = resolveTsconfigAliases(root, configuredTsconfigPath); + tsconfigPathAliases = resolvedTsconfigAliases.vite; + sassTsconfigPathAliases = resolvedTsconfigAliases.sass; // Vite's native option discovers tsconfig.json and cannot receive Next's // typescript.tsconfigPath. Only auto-enable it for the default config; // an explicit user resolve.tsconfigPaths value remains untouched. @@ -2984,9 +3048,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // which is appended at the end of `importers[]` by the vite:css // plugin (vite/src/node/plugins/css.ts makeScssWorker). const tildeImporter = createSassTildeImporter(root); + const tsconfigPathImporters = + createSassTsconfigPathImporters(sassTsconfigPathAliases); const cssUrlAssetImporter = env.command === "build" ? createSassCssUrlAssetImporter() : null; const userAdditionalData = sassPreprocessorOptions?.additionalData; + const rawUserImporters = sassPreprocessorOptions?.importers as unknown; + const userImporters = Array.isArray(rawUserImporters) + ? rawUserImporters + : rawUserImporters == null + ? [] + : [rawUserImporters]; // Base options shared by both .scss and .sass preprocessors. const baseOpts: SassPreprocessorOptions = { @@ -3004,10 +3076,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, } : {}), - // Merge user-supplied importers (from sassOptions) with the - // tilde importer. Tilde goes first so it gets first crack at - // ~ prefixed URLs; other importers follow; Vite's own internal - // importer is appended last by the vite:css plugin. + // Preserve user importer precedence over vinext's optional + // tsconfig-path extension. Vite's internal importer is appended + // last by the vite:css plugin. // // Cast: the tilde importer implements the modern Sass // `FileImporter` shape structurally and user importers are @@ -3018,7 +3089,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { importers: [ tildeImporter, ...(cssUrlAssetImporter ? [cssUrlAssetImporter] : []), - ...((sassPreprocessorOptions?.importers as unknown[]) ?? []), + ...userImporters, + ...tsconfigPathImporters, ] as SassPreprocessorOptions["importers"], }; diff --git a/packages/vinext/src/plugins/sass.ts b/packages/vinext/src/plugins/sass.ts index ddbb7819b7..2cb83e5a6d 100644 --- a/packages/vinext/src/plugins/sass.ts +++ b/packages/vinext/src/plugins/sass.ts @@ -38,6 +38,123 @@ type VitePreprocessorOptions = { [key: string]: any; }; +export type SassTsconfigPathAlias = { + find: string; + replacements: readonly string[]; +}; + +const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/; +const NODE_MODULES_PATH_RE = /(?:^|\/)node_modules(?:\/|$)/; + +type SassFileImporterContext = { + containingUrl?: URL | null; +}; + +type SassFileImporter = { + findFileUrl(url: string, context?: SassFileImporterContext): URL | null; +}; + +type MatchedSassTsconfigPathAlias = { + alias: SassTsconfigPathAlias; + matchedStar: string | null; +}; + +function matchSassTsconfigPathAlias( + aliases: readonly SassTsconfigPathAlias[], + url: string, +): MatchedSassTsconfigPathAlias | null { + for (const alias of aliases) { + if (!alias.find.includes("*") && alias.find === url) { + return { alias, matchedStar: null }; + } + } + + let bestMatch: MatchedSassTsconfigPathAlias | null = null; + let bestPrefixLength = -1; + for (const alias of aliases) { + const starIndex = alias.find.indexOf("*"); + if (starIndex < 0) continue; + const prefix = alias.find.slice(0, starIndex); + const suffix = alias.find.slice(starIndex + 1); + if ( + url.length < prefix.length + suffix.length || + !url.startsWith(prefix) || + !url.endsWith(suffix) || + prefix.length <= bestPrefixLength + ) { + continue; + } + bestPrefixLength = prefix.length; + bestMatch = { + alias, + matchedStar: url.slice(prefix.length, url.length - suffix.length), + }; + } + return bestMatch; +} + +function isSassAliasEligibleUrl(url: string, context?: SassFileImporterContext): boolean { + if ( + URL_SCHEME_RE.test(url) || + url.startsWith("//") || + url.startsWith("/") || + url.startsWith("./") || + url.startsWith("../") || + url.startsWith("~") + ) { + return false; + } + + const containingUrl = context?.containingUrl; + if (containingUrl?.protocol === "file:") { + const containingPath = toSlash(fileURLToPath(containingUrl)); + if (NODE_MODULES_PATH_RE.test(containingPath)) return false; + } + return true; +} + +/** + * Create Sass `FileImporter`s for paths materialized from tsconfig. + * + * Vite does not apply tsconfig path aliases while resolving Sass loads. Vinext + * deliberately extends that behavior for migration compatibility: application + * stylesheets may use the same exact and single-wildcard mappings as source + * modules. This is a vinext capability, not Next.js parity; current Next.js + * webpack builds do not resolve tsconfig `paths` from Sass. + * + * One importer is emitted per ordered replacement slot. When Sass cannot find + * a file for the first target it advances to the next importer, preserving the + * fallback order from `paths`. All importers select the same best pattern, so a + * failed specific mapping never falls through to a less-specific mapping. + * + * Aliases stay scoped to application stylesheets. Loads originating in + * `node_modules`, plus absolute, relative, protocol-relative, and scheme URLs, + * are left to user and Vite importers. + */ +export function createSassTsconfigPathImporters( + aliases: readonly SassTsconfigPathAlias[], +): SassFileImporter[] { + const entries = aliases.filter( + ({ find, replacements }) => find.length > 0 && replacements.length > 0, + ); + const replacementSlots = Math.max(0, ...entries.map((alias) => alias.replacements.length)); + + return Array.from({ length: replacementSlots }, (_, replacementIndex) => ({ + findFileUrl(url: string, context?: SassFileImporterContext): URL | null { + if (!isSassAliasEligibleUrl(url, context)) return null; + + const match = matchSassTsconfigPathAlias(entries, url); + if (!match) return null; + const replacement = match.alias.replacements[replacementIndex]; + if (!replacement) return null; + const matchedStar = match.matchedStar; + return pathToFileURL( + matchedStar === null ? replacement : replacement.replaceAll("*", () => matchedStar), + ); + }, + })); +} + /** * Create a Sass `FileImporter` that resolves webpack-style tilde (`~`) imports. * diff --git a/tests/fixtures/sass-tsconfig-paths/app/layout.tsx b/tests/fixtures/sass-tsconfig-paths/app/layout.tsx new file mode 100644 index 0000000000..ffae3df644 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/app/layout.tsx @@ -0,0 +1,9 @@ +import "../styles/global.scss"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/sass-tsconfig-paths/app/page.tsx b/tests/fixtures/sass-tsconfig-paths/app/page.tsx new file mode 100644 index 0000000000..daf956df32 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/app/page.tsx @@ -0,0 +1,3 @@ +export default function HomePage() { + return
Sass tsconfig aliases
; +} diff --git a/tests/fixtures/sass-tsconfig-paths/dependency-fixture/dependency-tokens/_index.scss b/tests/fixtures/sass-tsconfig-paths/dependency-fixture/dependency-tokens/_index.scss new file mode 100644 index 0000000000..8443df1619 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/dependency-fixture/dependency-tokens/_index.scss @@ -0,0 +1 @@ +$dependency: rgb(204, 17, 34); diff --git a/tests/fixtures/sass-tsconfig-paths/dependency-fixture/example/_entry.scss b/tests/fixtures/sass-tsconfig-paths/dependency-fixture/example/_entry.scss new file mode 100644 index 0000000000..939249ec72 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/dependency-fixture/example/_entry.scss @@ -0,0 +1,3 @@ +@use "dependency-tokens" as tokens; + +$dependency-color: tokens.$dependency; diff --git a/tests/fixtures/sass-tsconfig-paths/styles/_tokens.scss b/tests/fixtures/sass-tsconfig-paths/styles/_tokens.scss new file mode 100644 index 0000000000..34f75beb99 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/styles/_tokens.scss @@ -0,0 +1 @@ +$foreground: rgb(17, 34, 51); diff --git a/tests/fixtures/sass-tsconfig-paths/styles/global.scss b/tests/fixtures/sass-tsconfig-paths/styles/global.scss new file mode 100644 index 0000000000..2004cad2a1 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/styles/global.scss @@ -0,0 +1,14 @@ +@use "@tokens" as tokens; +@use "@tokens/colors" as theme; +@use "@tokens/special/colors" as special; +@use "@palette-colors-tokens" as fallback; +@use "../vendor/node_modules/example/entry" as dependency; +@import url("https://example.com/external.css"); + +.alias-probe { + color: tokens.$foreground; + background: theme.$background; + border-color: special.$border; + outline-color: fallback.$accent; + text-decoration-color: dependency.$dependency-color; +} diff --git a/tests/fixtures/sass-tsconfig-paths/styles/palettes/colors/_tokens.scss b/tests/fixtures/sass-tsconfig-paths/styles/palettes/colors/_tokens.scss new file mode 100644 index 0000000000..914bbed069 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/styles/palettes/colors/_tokens.scss @@ -0,0 +1 @@ +$accent: rgb(153, 170, 187); diff --git a/tests/fixtures/sass-tsconfig-paths/styles/special/_colors.scss b/tests/fixtures/sass-tsconfig-paths/styles/special/_colors.scss new file mode 100644 index 0000000000..d7f913ae53 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/styles/special/_colors.scss @@ -0,0 +1 @@ +$border: rgb(119, 136, 153); diff --git a/tests/fixtures/sass-tsconfig-paths/styles/theme/_colors.scss b/tests/fixtures/sass-tsconfig-paths/styles/theme/_colors.scss new file mode 100644 index 0000000000..318de7f7ae --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/styles/theme/_colors.scss @@ -0,0 +1 @@ +$background: rgb(68, 85, 102); diff --git a/tests/fixtures/sass-tsconfig-paths/tsconfig.json b/tests/fixtures/sass-tsconfig-paths/tsconfig.json new file mode 100644 index 0000000000..22e6bcce75 --- /dev/null +++ b/tests/fixtures/sass-tsconfig-paths/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "paths": { + "@tokens": ["./styles/missing.scss", "./styles/_tokens.scss"], + "@tokens/*": ["./styles/theme/*"], + "@tokens/special/*": ["./styles/special/*"], + "@palette-*-tokens": ["./styles/missing/*", "./styles/palettes/*/_tokens.scss"], + "dependency-tokens": ["./styles/_tokens.scss"] + } + } +} diff --git a/tests/sass-options.test.ts b/tests/sass-options.test.ts index a20841074f..6cf9f558a7 100644 --- a/tests/sass-options.test.ts +++ b/tests/sass-options.test.ts @@ -25,6 +25,7 @@ import fsp from "node:fs/promises"; import { buildSassPreprocessorOptions, createSassCssUrlAssetImporter, + createSassTsconfigPathImporters, createSassTildeImporter, } from "../packages/vinext/src/plugins/sass.js"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -319,9 +320,111 @@ describe("createSassTildeImporter", () => { }); }); +describe("createSassTsconfigPathImporters", () => { + it("preserves exact, wildcard, longest-match, and target fallback semantics", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext sass aliases ")); + try { + const exact = path.join(root, "_exact.scss"); + const missing = path.join(root, "missing"); + const general = path.join(root, "general"); + const special = path.join(root, "special"); + await fsp.writeFile(exact, "$color: red;"); + const importers = createSassTsconfigPathImporters([ + { find: "@theme", replacements: [path.join(missing, "exact"), exact] }, + { find: "@theme/*", replacements: [path.join(general, "*")] }, + { find: "@theme/special/*", replacements: [path.join(special, "*")] }, + { + find: "@palette-*-tokens", + replacements: [path.join(missing, "*"), path.join(general, "*")], + }, + ]); + + expect(importers[0]?.findFileUrl("@theme")?.href).toBe( + pathToFileURL(path.join(missing, "exact")).href, + ); + expect(importers[1]?.findFileUrl("@theme")?.href).toBe(pathToFileURL(exact).href); + expect(importers[0]?.findFileUrl("@theme/colors")?.href).toBe( + pathToFileURL(path.join(general, "colors")).href, + ); + expect(importers[0]?.findFileUrl("@theme/special/colors")?.href).toBe( + pathToFileURL(path.join(special, "colors")).href, + ); + expect(importers[1]?.findFileUrl("@palette-colors-tokens")?.href).toBe( + pathToFileURL(path.join(general, "colors")).href, + ); + expect(importers[1]?.findFileUrl("@palette-$&-tokens")?.href).toBe( + pathToFileURL(path.join(general, "$&")).href, + ); + expect(importers[0]?.findFileUrl("@theme")).not.toBeNull(); + expect(importers[0]?.findFileUrl("@theme/other/special/colors")?.href).toBe( + pathToFileURL(path.join(general, "other", "special", "colors")).href, + ); + expect(importers[0]?.findFileUrl("@theme/")?.href).toBe( + pathToFileURL(general + path.sep).href, + ); + + const [equalPrefixImporter] = createSassTsconfigPathImporters([ + { find: "@*", replacements: [path.join(root, "first", "*")] }, + { find: "@*-tokens", replacements: [path.join(root, "second", "*")] }, + ]); + expect(equalPrefixImporter?.findFileUrl("@blue-tokens")?.href).toBe( + pathToFileURL(path.join(root, "first", "blue-tokens")).href, + ); + + const sass = await import("sass"); + expect( + sass.compileString('@use "@theme" as theme; .probe { color: theme.$color; }', { + importers, + }).css, + ).toContain("color: red"); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("does not intercept native Sass, file, relative, absolute, or remote URLs", () => { + const [importer] = createSassTsconfigPathImporters([ + { find: "sass:color", replacements: ["/wrong/sass"] }, + { find: "https:*", replacements: ["/wrong/remote/*"] }, + { find: "file:*", replacements: ["/wrong/file/*"] }, + ]); + + for (const url of [ + "sass:color", + "https://example.com/theme.scss", + "file:///tmp/theme.scss", + "//example.com/theme.scss", + "/absolute/theme.scss", + "./relative.scss", + "../relative.scss", + "~package/theme.scss", + ]) { + expect(importer?.findFileUrl(url)).toBeNull(); + } + }); + + it("does not apply project aliases inside node_modules stylesheets", () => { + const [importer] = createSassTsconfigPathImporters([ + { find: "@theme", replacements: ["/application/theme.scss"] }, + ]); + + expect( + importer?.findFileUrl("@theme", { + containingUrl: pathToFileURL("/workspace/node_modules/example/_entry.scss"), + }), + ).toBeNull(); + expect( + importer?.findFileUrl("@theme", { + containingUrl: pathToFileURL("/workspace/styles/_entry.scss"), + })?.href, + ).toBe(pathToFileURL("/application/theme.scss").href); + }); +}); + describe("vinext config hook threads sassOptions into css.preprocessorOptions", () => { async function runConfigHook( nextConfigSrc: string, + tsconfigSrc?: string, ): Promise | undefined> { const vinext = (await import("../packages/vinext/src/index.js")).default; const plugins = vinext(); @@ -341,6 +444,7 @@ describe("vinext config hook threads sassOptions into css.preprocessorOptions", `export default function Home() { return

Home

; }`, ); await fsp.writeFile(path.join(tmpDir, "next.config.mjs"), nextConfigSrc); + if (tsconfigSrc) await fsp.writeFile(path.join(tmpDir, "tsconfig.json"), tsconfigSrc); try { const mockConfig = { root: tmpDir, build: {}, plugins: [] }; @@ -391,4 +495,48 @@ describe("vinext config hook threads sassOptions into css.preprocessorOptions", const sassImporters: any[] = opts.sass.importers; expect(sassImporters.length).toBeGreaterThanOrEqual(1); }, 15000); + + it("preserves user importer order and precedence over tsconfig aliases", async () => { + const css = await runConfigHook( + ` + const first = { name: "first", findFileUrl() { return null; } }; + const second = { name: "second", findFileUrl() { return null; } }; + export default { sassOptions: { importers: [first, second] } }; + `, + JSON.stringify({ compilerOptions: { paths: { "@theme": ["./styles/theme.scss"] } } }), + ); + // oxlint-disable-next-line typescript/no-explicit-any + const scssImporters: any[] = (css as any)?.preprocessorOptions?.scss?.importers; + const scssUserIndexes = ["first", "second"].map((name) => + scssImporters.findIndex((importer) => importer.name === name), + ); + const scssAliasIndex = scssImporters.findIndex( + (importer) => importer.findFileUrl?.("@theme")?.protocol === "file:", + ); + expect(scssUserIndexes).toEqual([2, 3]); + expect(scssAliasIndex).toBeGreaterThan(scssUserIndexes[1]!); + // oxlint-disable-next-line typescript/no-explicit-any + const sassImporters: any[] = (css as any)?.preprocessorOptions?.sass?.importers; + const sassUserIndexes = ["first", "second"].map((name) => + sassImporters.findIndex((importer) => importer.name === name), + ); + const sassAliasIndex = sassImporters.findIndex( + (importer) => importer.findFileUrl?.("@theme")?.protocol === "file:", + ); + expect(sassUserIndexes).toEqual([2, 3]); + expect(sassAliasIndex).toBeGreaterThan(sassUserIndexes[1]!); + }, 15000); + + it("accepts a single user importer object", async () => { + const css = await runConfigHook(` + const only = { name: "only", findFileUrl() { return null; } }; + export default { sassOptions: { importers: only } }; + `); + // oxlint-disable-next-line typescript/no-explicit-any + const scssImporters: any[] = (css as any)?.preprocessorOptions?.scss?.importers; + expect(scssImporters.some((importer) => importer.name === "only")).toBe(true); + // oxlint-disable-next-line typescript/no-explicit-any + const sassImporters: any[] = (css as any)?.preprocessorOptions?.sass?.importers; + expect(sassImporters.some((importer) => importer.name === "only")).toBe(true); + }, 15000); }); diff --git a/tests/sass-tsconfig-paths.test.ts b/tests/sass-tsconfig-paths.test.ts new file mode 100644 index 0000000000..19ddcb99f5 --- /dev/null +++ b/tests/sass-tsconfig-paths.test.ts @@ -0,0 +1,80 @@ +/** + * Vinext intentionally makes tsconfig `paths` available to application Sass. + * Vite and current Next.js webpack builds do not provide this behavior, but it + * is useful when migrating Vite-oriented applications whose source and Sass + * already share aliases. Keep the extension scoped to application styles so + * dependencies retain normal package resolution. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import { createBuilder } from "vite"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; +import { createIsolatedFixture, fetchHtml, startFixtureServer } from "./helpers.js"; + +const FIXTURE = path.resolve(import.meta.dirname, "fixtures/sass-tsconfig-paths"); +const tempDirs: string[] = []; + +async function makeFixture(): Promise { + const root = await createIsolatedFixture(FIXTURE, "vinext-sass-tsconfig-paths-"); + await fs.cp(path.join(root, "dependency-fixture"), path.join(root, "vendor", "node_modules"), { + recursive: true, + }); + tempDirs.push(root); + return root; +} + +async function readCssOutput(root: string): Promise { + const clientDir = path.join(root, "dist", "client"); + const entries = await fs.readdir(clientDir, { recursive: true, withFileTypes: true }); + const css = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".css")) + .map((entry) => { + const parent = + (entry as { parentPath?: string; path?: string }).parentPath ?? + (entry as { path?: string }).path ?? + clientDir; + return fs.readFile(path.join(parent, entry.name), "utf8"); + }), + ); + return css.join("\n"); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("Sass tsconfig path aliases", () => { + it("resolves exact fallbacks, wildcard suffixes, and dependency Sass in dev", async () => { + const root = await makeFixture(); + const { server, baseUrl } = await startFixtureServer(root); + try { + const { res, html } = await fetchHtml(baseUrl, "/"); + expect(res.status).toBe(200); + expect(html).toContain("Sass tsconfig aliases"); + } finally { + await server.close(); + } + }, 60_000); + + it("builds aliased Sass and leaves external CSS URLs untouched", async () => { + const root = await makeFixture(); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [vinext({ appDir: root })], + logLevel: "silent", + }); + await builder.buildApp(); + + const css = await readCssOutput(root); + expect(css).toContain("https://example.com/external.css"); + expect(css).toMatch(/(?:#123(?:\b|;)|rgb\(17,\s*34,\s*51\))/i); + expect(css).toMatch(/(?:#456(?:\b|;)|rgb\(68,\s*85,\s*102\))/i); + expect(css).toMatch(/(?:#789(?:\b|;)|rgb\(119,\s*136,\s*153\))/i); + expect(css).toMatch(/(?:#9ab(?:\b|;)|rgb\(153,\s*170,\s*187\))/i); + expect(css).toMatch(/(?:#c12(?:\b|;)|rgb\(204,\s*17,\s*34\))/i); + }, 120_000); +});