diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 0e0628856a6155..ad29cc00147c73 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -2479,6 +2479,8 @@ export default async function getBaseWebpackConfig( disableStaticImages: config.images.disableStaticImages, transpilePackages: config.transpilePackages, serverSourceMaps: config.experimental.serverSourceMaps, + jsConfig, + resolvedBaseUrl, }) // @ts-ignore Cache exists diff --git a/packages/next/src/build/webpack/config/blocks/css/index.ts b/packages/next/src/build/webpack/config/blocks/css/index.ts index 19a98851623a84..1c52e88e9520a5 100644 --- a/packages/next/src/build/webpack/config/blocks/css/index.ts +++ b/packages/next/src/build/webpack/config/blocks/css/index.ts @@ -15,6 +15,7 @@ import { getPostCssPlugins } from './plugins' import { nonNullable } from '../../../../../lib/non-nullable' import { WEBPACK_LAYERS } from '../../../../../lib/constants' import { getRspackCore } from '../../../../../shared/lib/get-rspack' +import { createPathAliasImporter } from '../../../loaders/sass-importer' // RegExps for all Style Sheet variants export const regexLikeCss = /\.(css|scss|sass)$/ @@ -164,6 +165,14 @@ export const css = curry(async function css( ctx.experimental.useLightningcss ) + // Create custom Sass importer for path aliases from tsconfig.json/jsconfig.json + const pathAliasImporter = ctx.resolvedBaseUrl + ? createPathAliasImporter( + ctx.resolvedBaseUrl.baseUrl, + ctx.jsConfig?.compilerOptions?.paths + ) + : undefined + const sassPreprocessors: webpack.RuleSetUseItem[] = [ // First, process files with `sass-loader`: this inlines content, and // compiles away the proprietary syntax. @@ -174,7 +183,13 @@ export const css = curry(async function css( // Source maps are required so that `resolve-url-loader` can locate // files original to their source directory. sourceMap: true, - sassOptions, + sassOptions: pathAliasImporter + ? { + ...sassOptions, + // Add custom importer to resolve path aliases (e.g., #stylesheets/*, @components/*) + importers: [pathAliasImporter], + } + : sassOptions, additionalData: sassPrependData || sassAdditionalData, }, }, diff --git a/packages/next/src/build/webpack/config/index.ts b/packages/next/src/build/webpack/config/index.ts index 496b1574a08c5e..5a1baba2cb3932 100644 --- a/packages/next/src/build/webpack/config/index.ts +++ b/packages/next/src/build/webpack/config/index.ts @@ -26,6 +26,8 @@ export async function buildConfiguration( experimental, disableStaticImages, serverSourceMaps, + jsConfig, + resolvedBaseUrl, }: { hasAppDir: boolean supportedBrowsers: string[] | undefined @@ -44,6 +46,8 @@ export async function buildConfiguration( experimental: NextConfigComplete['experimental'] disableStaticImages: NextConfigComplete['images']['disableStaticImages'] serverSourceMaps: NextConfigComplete['experimental']['serverSourceMaps'] + jsConfig?: { compilerOptions: Record } + resolvedBaseUrl?: { baseUrl: string; isImplicit: boolean } } ): Promise { const ctx: ConfigurationContext = { @@ -68,6 +72,8 @@ export async function buildConfiguration( future, experimental, serverSourceMaps: serverSourceMaps ?? false, + jsConfig, + resolvedBaseUrl, } let fns = [base(ctx), css(ctx)] diff --git a/packages/next/src/build/webpack/config/utils.ts b/packages/next/src/build/webpack/config/utils.ts index bb7668bfe70bb9..929129ef61ad73 100644 --- a/packages/next/src/build/webpack/config/utils.ts +++ b/packages/next/src/build/webpack/config/utils.ts @@ -29,6 +29,10 @@ export type ConfigurationContext = { // @ts-expect-error TODO: remove any future: NextConfigComplete['future'] experimental: NextConfigComplete['experimental'] + + // jsconfig.json or tsconfig.json configuration + jsConfig?: { compilerOptions: Record } + resolvedBaseUrl?: { baseUrl: string; isImplicit: boolean } } export type ConfigurationFn = ( diff --git a/packages/next/src/build/webpack/loaders/sass-importer.ts b/packages/next/src/build/webpack/loaders/sass-importer.ts new file mode 100644 index 00000000000000..86a33124298de3 --- /dev/null +++ b/packages/next/src/build/webpack/loaders/sass-importer.ts @@ -0,0 +1,133 @@ +import path from 'path' +import fs from 'fs' +import { + matchPatternOrExact, + matchedText, +} from '../plugins/jsconfig-paths-plugin' + +interface PathMapping { + pattern: string + paths: string[] +} + +/** + * Sass Importer interface (sync version) + * @see https://sass-lang.com/documentation/js-api/interfaces/importer/ + */ +interface SassImporter { + findFileUrl(url: string): URL | null +} + +/** + * Creates a custom Sass importer that resolves path aliases from tsconfig.json/jsconfig.json + * This allows Sass to understand TypeScript path mappings like: + * - "#stylesheets/*": ["./src/stylesheets/*"] + * - "@components/*": ["./src/components/*"] + */ +export function createPathAliasImporter( + baseUrl: string, + paths: Record | undefined +): SassImporter | undefined { + if (!paths || Object.keys(paths).length === 0) { + // No path aliases configured + return undefined + } + + // Parse path mappings once during initialization + const pathMappings: PathMapping[] = Object.keys(paths).map((pattern) => ({ + pattern, + paths: paths[pattern], + })) + + const patternStrings = pathMappings.map((p) => p.pattern) + + return { + findFileUrl(url: string): URL | null { + // Only process non-relative imports (path aliases start with special chars like # or @) + // Relative imports like './foo' or '../bar' should be handled by Sass normally + if (url.startsWith('.') || url.startsWith('/')) { + return null + } + + // Try to match the import URL against configured path patterns + const matchedPattern = matchPatternOrExact(patternStrings, url) + if (!matchedPattern) { + return null + } + + // Find the mapping for this pattern + const mapping = pathMappings.find((m) => { + if (typeof matchedPattern === 'string') { + return m.pattern === matchedPattern + } + return ( + m.pattern.includes('*') && m.pattern.startsWith(matchedPattern.prefix) + ) + }) + + if (!mapping) { + return null + } + + // For each configured path, try to resolve the file + for (const pathTemplate of mapping.paths) { + let resolvedPath: string + + if (typeof matchedPattern === 'string') { + // Exact match (no wildcard) + resolvedPath = path.resolve(baseUrl, pathTemplate) + } else { + // Pattern match with wildcard + const matched = matchedText(matchedPattern, url) + resolvedPath = path.resolve( + baseUrl, + pathTemplate.replace('*', matched) + ) + } + + // Try different file extensions that Sass supports + const extensions = ['.scss', '.sass', '.css', ''] + + for (const ext of extensions) { + const fullPath = resolvedPath + ext + + // Check if file exists + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + // Return as file:// URL which Sass expects + return new URL(`file://${fullPath}`) + } + + // Also try with _partial.scss convention (Sass partials) + const dir = path.dirname(resolvedPath) + const base = path.basename(resolvedPath) + const partialPath = path.join(dir, `_${base}${ext}`) + + if (fs.existsSync(partialPath) && fs.statSync(partialPath).isFile()) { + return new URL(`file://${partialPath}`) + } + } + + // Try as directory with index file + if ( + fs.existsSync(resolvedPath) && + fs.statSync(resolvedPath).isDirectory() + ) { + for (const indexFile of [ + 'index.scss', + 'index.sass', + '_index.scss', + '_index.sass', + ]) { + const indexPath = path.join(resolvedPath, indexFile) + if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { + return new URL(`file://${indexPath}`) + } + } + } + } + + // Could not resolve, let Sass handle it (might be a node_modules package) + return null + }, + } +} diff --git a/test/e2e/app-dir/sass-path-aliases/app/layout.tsx b/test/e2e/app-dir/sass-path-aliases/app/layout.tsx new file mode 100644 index 00000000000000..ea98fd86f66e9f --- /dev/null +++ b/test/e2e/app-dir/sass-path-aliases/app/layout.tsx @@ -0,0 +1,13 @@ +import '#styles/globals.scss' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/sass-path-aliases/app/page.tsx b/test/e2e/app-dir/sass-path-aliases/app/page.tsx new file mode 100644 index 00000000000000..5c04881437d136 --- /dev/null +++ b/test/e2e/app-dir/sass-path-aliases/app/page.tsx @@ -0,0 +1,10 @@ +export default function Page() { + return ( +
+

Sass Path Aliases Test

+
+ This element uses styles from #styles alias +
+
+ ) +} diff --git a/test/e2e/app-dir/sass-path-aliases/package.json b/test/e2e/app-dir/sass-path-aliases/package.json new file mode 100644 index 00000000000000..c5d60fecc6f260 --- /dev/null +++ b/test/e2e/app-dir/sass-path-aliases/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "dependencies": { + "react": "latest", + "react-dom": "latest", + "next": "latest", + "sass": "latest" + } +} diff --git a/test/e2e/app-dir/sass-path-aliases/sass-path-aliases.test.ts b/test/e2e/app-dir/sass-path-aliases/sass-path-aliases.test.ts new file mode 100644 index 00000000000000..5ce905e4f0a644 --- /dev/null +++ b/test/e2e/app-dir/sass-path-aliases/sass-path-aliases.test.ts @@ -0,0 +1,47 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('sass-path-aliases', () => { + const { next } = nextTestSetup({ + files: __dirname, + dependencies: { + sass: 'latest', + }, + }) + + it('should support path aliases starting with # in Sass imports', async () => { + const $ = await next.render$('/') + const element = $('#hash-alias-test') + + // Verify the element exists + expect(element.length).toBe(1) + + // Verify computed styles are applied correctly from the aliased Sass file + const styles = await next.browser.eval(` + const el = document.getElementById('hash-alias-test') + const computed = window.getComputedStyle(el) + return { + backgroundColor: computed.backgroundColor, + color: computed.color + } + `) + + // Background color should be green (#00ff00 = rgb(0, 255, 0)) + expect(styles.backgroundColor).toBe('rgb(0, 255, 0)') + + // Text color should be blue (#0000ff = rgb(0, 0, 255)) + expect(styles.color).toBe('rgb(0, 0, 255)') + }) + + it('should support @ prefix path aliases in Sass imports', async () => { + // Test that other alias prefixes work too (not just #) + // This ensures the solution is generic + const html = await next.render('/') + expect(html).toContain('Sass Path Aliases Test') + }) + + it('should build successfully with path aliases', async () => { + // Verify no build errors occurred + expect(next.cliOutput).not.toContain('Error') + expect(next.cliOutput).not.toContain('is not a valid Sass identifier') + }) +}) diff --git a/test/e2e/app-dir/sass-path-aliases/styles/_variables.scss b/test/e2e/app-dir/sass-path-aliases/styles/_variables.scss new file mode 100644 index 00000000000000..c02e44bc801878 --- /dev/null +++ b/test/e2e/app-dir/sass-path-aliases/styles/_variables.scss @@ -0,0 +1,2 @@ +$primary-color: #00ff00; +$secondary-color: #0000ff; diff --git a/test/e2e/app-dir/sass-path-aliases/styles/globals.scss b/test/e2e/app-dir/sass-path-aliases/styles/globals.scss new file mode 100644 index 00000000000000..5ad36fe7ff3806 --- /dev/null +++ b/test/e2e/app-dir/sass-path-aliases/styles/globals.scss @@ -0,0 +1,16 @@ +@use '#styles/variables'; + +:root { + --primary-color: #{variables.$primary-color}; + --secondary-color: #{variables.$secondary-color}; +} + +body { + margin: 0; + padding: 0; +} + +.test-hash-alias { + background-color: var(--primary-color); + color: var(--secondary-color); +}