Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion packages/next/src/build/webpack/config/blocks/css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/
Expand Down Expand Up @@ -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.
Expand All @@ -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,
},
},
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/build/webpack/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export async function buildConfiguration(
experimental,
disableStaticImages,
serverSourceMaps,
jsConfig,
resolvedBaseUrl,
}: {
hasAppDir: boolean
supportedBrowsers: string[] | undefined
Expand All @@ -44,6 +46,8 @@ export async function buildConfiguration(
experimental: NextConfigComplete['experimental']
disableStaticImages: NextConfigComplete['images']['disableStaticImages']
serverSourceMaps: NextConfigComplete['experimental']['serverSourceMaps']
jsConfig?: { compilerOptions: Record<string, any> }
resolvedBaseUrl?: { baseUrl: string; isImplicit: boolean }
}
): Promise<webpack.Configuration> {
const ctx: ConfigurationContext = {
Expand All @@ -68,6 +72,8 @@ export async function buildConfiguration(
future,
experimental,
serverSourceMaps: serverSourceMaps ?? false,
jsConfig,
resolvedBaseUrl,
}

let fns = [base(ctx), css(ctx)]
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/build/webpack/config/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }
resolvedBaseUrl?: { baseUrl: string; isImplicit: boolean }
}

export type ConfigurationFn = (
Expand Down
133 changes: 133 additions & 0 deletions packages/next/src/build/webpack/loaders/sass-importer.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> | 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
},
}
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/sass-path-aliases/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import '#styles/globals.scss'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/sass-path-aliases/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function Page() {
return (
<div>
<h1>Sass Path Aliases Test</h1>
<div className="test-hash-alias" id="hash-alias-test">
This element uses styles from #styles alias
</div>
</div>
)
}
9 changes: 9 additions & 0 deletions test/e2e/app-dir/sass-path-aliases/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"private": true,
"dependencies": {
"react": "latest",
"react-dom": "latest",
"next": "latest",
"sass": "latest"
}
}
47 changes: 47 additions & 0 deletions test/e2e/app-dir/sass-path-aliases/sass-path-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
2 changes: 2 additions & 0 deletions test/e2e/app-dir/sass-path-aliases/styles/_variables.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
$primary-color: #00ff00;
$secondary-color: #0000ff;
16 changes: 16 additions & 0 deletions test/e2e/app-dir/sass-path-aliases/styles/globals.scss
Original file line number Diff line number Diff line change
@@ -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);
}
Loading