Skip to content
Draft
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
128 changes: 100 additions & 28 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,10 @@ import { resolvePostcssStringPlugins } from "./plugins/postcss.js";
import {
buildSassPreprocessorOptions,
createSassCssUrlAssetImporter,
createSassTsconfigPathImporters,
createSassTildeImporter,
createSassAwareFileSystemLoader,
type SassTsconfigPathAlias,
} from "./plugins/sass.js";
import {
createClientFileNameConfig,
Expand Down Expand Up @@ -782,20 +784,63 @@ function resolveTsconfigExtends(configPath: string, specifier: string): string |
return null;
}

type MaterializedTsconfigPathAliases = {
vite: Record<string, string>;
sass: SassTsconfigPathAlias[];
};

function mergeSassTsconfigPathAliases(
...groups: readonly (readonly SassTsconfigPathAlias[])[]
): SassTsconfigPathAlias[] {
const merged = new Map<string, SassTsconfigPathAlias>();
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<string, unknown>,
baseUrl: string,
projectRoot: string,
): Record<string, string> {
const aliases: Record<string, string> = {};
): MaterializedTsconfigPathAliases {
const vite: Record<string, string> = {};
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -870,26 +917,30 @@ function loadTsconfigPathAliases(
configPath: string,
projectRoot: string,
seen = new Set<string>(),
): Record<string, string> {
): MaterializedTsconfigPathAliases {
const normalizedPath = tryRealpathSync(configPath) ?? configPath;
if (seen.has(normalizedPath)) return {};
if (seen.has(normalizedPath)) return { vite: {}, sass: [] };
seen.add(normalizedPath);

let parsed: Record<string, unknown> | 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<string, string> = {};
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),
};
}
}

Expand All @@ -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),
};
}

Expand Down Expand Up @@ -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<string, Record<string, string>>();
type ResolvedTsconfigPathAliases = {
vite: Record<string, string>;
sass: SassTsconfigPathAlias[];
};

const _tsconfigAliasCache = new Map<string, ResolvedTsconfigPathAliases>();

/**
* Order materialized tsconfig path aliases by descending prefix length.
Expand All @@ -978,19 +1035,23 @@ function sortTsconfigAliasesBySpecificity(aliases: Record<string, string>): Reco
function resolveTsconfigAliases(
projectRoot: string,
configuredPath?: string,
): Record<string, string> {
): ResolvedTsconfigPathAliases {
const configPath = configuredPath ? path.resolve(projectRoot, configuredPath) : undefined;
const cacheKey = configPath ?? projectRoot;
if (_tsconfigAliasCache.has(cacheKey)) {
return _tsconfigAliasCache.get(cacheKey)!;
}

let aliases: Record<string, string> = {};
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;
}

Expand Down Expand Up @@ -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<string, string> = {};
let sassTsconfigPathAliases: SassTsconfigPathAlias[] = [];
const swcHelpersAlias = resolveSwcHelpersAlias(root);

// Load .env files into process.env before anything else.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -3018,7 +3089,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
importers: [
tildeImporter,
...(cssUrlAssetImporter ? [cssUrlAssetImporter] : []),
...((sassPreprocessorOptions?.importers as unknown[]) ?? []),
...userImporters,
...tsconfigPathImporters,
] as SassPreprocessorOptions["importers"],
};

Expand Down
117 changes: 117 additions & 0 deletions packages/vinext/src/plugins/sass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/sass-tsconfig-paths/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import "../styles/global.scss";

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
3 changes: 3 additions & 0 deletions tests/fixtures/sass-tsconfig-paths/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function HomePage() {
return <main className="alias-probe">Sass tsconfig aliases</main>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$dependency: rgb(204, 17, 34);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@use "dependency-tokens" as tokens;

$dependency-color: tokens.$dependency;
1 change: 1 addition & 0 deletions tests/fixtures/sass-tsconfig-paths/styles/_tokens.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$foreground: rgb(17, 34, 51);
Loading
Loading