Skip to content

Commit 73a3de0

Browse files
authored
feat(css): support sass modern api (#17728)
1 parent 116e37a commit 73a3de0

File tree

6 files changed

+169
-10
lines changed

6 files changed

+169
-10
lines changed

docs/config/shared-options.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ Note if an inline config is provided, Vite will not search for other PostCSS con
225225

226226
Specify options to pass to CSS pre-processors. The file extensions are used as keys for the options. The supported options for each preprocessors can be found in their respective documentation:
227227

228-
- `sass`/`scss` - [Options](https://sass-lang.com/documentation/js-api/interfaces/LegacyStringOptions).
228+
- `sass`/`scss` - top level option `api: "legacy" | "modern"` (default `"legacy"`) allows switching which sass API to use. [Options (legacy)](https://sass-lang.com/documentation/js-api/interfaces/LegacyStringOptions), [Options (modern)](https://sass-lang.com/documentation/js-api/interfaces/stringoptions/).
229229
- `less` - [Options](https://lesscss.org/usage/#less-options).
230230
- `styl`/`stylus` - Only [`define`](https://stylus-lang.com/docs/js.html#define-name-node) is supported, which can be passed as an object.
231231

@@ -243,6 +243,12 @@ export default defineConfig({
243243
$specialColor: new stylus.nodes.RGBA(51, 197, 255, 1),
244244
},
245245
},
246+
scss: {
247+
api: 'modern', // or "legacy"
248+
importers: [
249+
// ...
250+
],
251+
},
246252
},
247253
},
248254
})

packages/vite/src/node/plugins/css.ts

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,7 +2110,7 @@ const makeScssWorker = (
21102110
// eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker
21112111
const sass: typeof Sass = require(sassPath)
21122112
// eslint-disable-next-line no-restricted-globals
2113-
const path = require('node:path')
2113+
const path: typeof import('node:path') = require('node:path')
21142114

21152115
// NOTE: `sass` always runs it's own importer first, and only falls back to
21162116
// the `importer` option when it can't resolve a path
@@ -2144,11 +2144,7 @@ const makeScssWorker = (
21442144
}
21452145
: {}),
21462146
}
2147-
return new Promise<{
2148-
css: string
2149-
map?: string | undefined
2150-
stats: Sass.LegacyResult['stats']
2151-
}>((resolve, reject) => {
2147+
return new Promise<ScssWorkerResult>((resolve, reject) => {
21522148
sass.render(finalOptions, (err, res) => {
21532149
if (err) {
21542150
reject(err)
@@ -2179,6 +2175,114 @@ const makeScssWorker = (
21792175
return worker
21802176
}
21812177

2178+
const makeModernScssWorker = (
2179+
resolvers: CSSAtImportResolvers,
2180+
alias: Alias[],
2181+
maxWorkers: number | undefined,
2182+
) => {
2183+
const internalCanonicalize = async (
2184+
url: string,
2185+
importer: string,
2186+
): Promise<string | null> => {
2187+
importer = cleanScssBugUrl(importer)
2188+
const resolved = await resolvers.sass(url, importer)
2189+
return resolved ?? null
2190+
}
2191+
2192+
const internalLoad = async (file: string, rootFile: string) => {
2193+
const result = await rebaseUrls(file, rootFile, alias, '$', resolvers.sass)
2194+
if (result.contents) {
2195+
return result.contents
2196+
}
2197+
return await fsp.readFile(result.file, 'utf-8')
2198+
}
2199+
2200+
const worker = new WorkerWithFallback(
2201+
() =>
2202+
async (
2203+
sassPath: string,
2204+
data: string,
2205+
// additionalData can a function that is not cloneable but it won't be used
2206+
options: SassStylePreprocessorOptions & { additionalData: undefined },
2207+
) => {
2208+
// eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker
2209+
const sass: typeof Sass = require(sassPath)
2210+
// eslint-disable-next-line no-restricted-globals
2211+
const path: typeof import('node:path') = require('node:path')
2212+
2213+
const { fileURLToPath, pathToFileURL }: typeof import('node:url') =
2214+
// eslint-disable-next-line no-restricted-globals
2215+
require('node:url')
2216+
2217+
const sassOptions = { ...options } as Sass.StringOptions<'async'>
2218+
sassOptions.url = pathToFileURL(options.filename)
2219+
sassOptions.sourceMap = options.enableSourcemap
2220+
2221+
const internalImporter: Sass.Importer<'async'> = {
2222+
async canonicalize(url, context) {
2223+
const importer = context.containingUrl
2224+
? fileURLToPath(context.containingUrl)
2225+
: options.filename
2226+
const resolved = await internalCanonicalize(url, importer)
2227+
return resolved ? pathToFileURL(resolved) : null
2228+
},
2229+
async load(canonicalUrl) {
2230+
const ext = path.extname(canonicalUrl.pathname)
2231+
let syntax: Sass.Syntax = 'scss'
2232+
if (ext === '.sass') {
2233+
syntax = 'indented'
2234+
} else if (ext === '.css') {
2235+
syntax = 'css'
2236+
}
2237+
const contents = await internalLoad(
2238+
fileURLToPath(canonicalUrl),
2239+
options.filename,
2240+
)
2241+
return { contents, syntax }
2242+
},
2243+
}
2244+
sassOptions.importers = [
2245+
...(sassOptions.importers ?? []),
2246+
internalImporter,
2247+
]
2248+
2249+
const result = await sass.compileStringAsync(data, sassOptions)
2250+
return {
2251+
css: result.css,
2252+
map: result.sourceMap ? JSON.stringify(result.sourceMap) : undefined,
2253+
stats: {
2254+
includedFiles: result.loadedUrls
2255+
.filter((url) => url.protocol === 'file:')
2256+
.map((url) => fileURLToPath(url)),
2257+
},
2258+
} satisfies ScssWorkerResult
2259+
},
2260+
{
2261+
parentFunctions: {
2262+
internalCanonicalize,
2263+
internalLoad,
2264+
},
2265+
shouldUseFake(_sassPath, _data, options) {
2266+
// functions and importer is a function and is not serializable
2267+
// in that case, fallback to running in main thread
2268+
return !!(
2269+
(options.functions && Object.keys(options.functions).length > 0) ||
2270+
(options.importers &&
2271+
(!Array.isArray(options.importers) || options.importers.length > 0))
2272+
)
2273+
},
2274+
max: maxWorkers,
2275+
},
2276+
)
2277+
return worker
2278+
}
2279+
2280+
type ScssWorkerResult = {
2281+
css: string
2282+
map?: string | undefined
2283+
stats: Pick<Sass.LegacyResult['stats'], 'includedFiles'>
2284+
}
2285+
21822286
const scssProcessor = (
21832287
maxWorkers: number | undefined,
21842288
): SassStylePreprocessor => {
@@ -2196,7 +2300,9 @@ const scssProcessor = (
21962300
if (!workerMap.has(options.alias)) {
21972301
workerMap.set(
21982302
options.alias,
2199-
makeScssWorker(resolvers, options.alias, maxWorkers),
2303+
options.api === 'modern'
2304+
? makeModernScssWorker(resolvers, options.alias, maxWorkers)
2305+
: makeScssWorker(resolvers, options.alias, maxWorkers),
22002306
)
22012307
}
22022308
const worker = workerMap.get(options.alias)!
@@ -2251,7 +2357,7 @@ async function rebaseUrls(
22512357
alias: Alias[],
22522358
variablePrefix: string,
22532359
resolver: ResolveFn,
2254-
): Promise<Sass.LegacyImporterResult> {
2360+
): Promise<{ file: string; contents?: string }> {
22552361
file = path.resolve(file) // ensure os-specific flashes
22562362
// in the same dir, no need to rebase
22572363
const fileDir = path.dirname(file)
@@ -2681,7 +2787,7 @@ const createPreprocessorWorkerController = (maxWorkers: number | undefined) => {
26812787
return scss.process(
26822788
source,
26832789
root,
2684-
{ ...options, indentedSyntax: true },
2790+
{ ...options, indentedSyntax: true, syntax: 'indented' },
26852791
resolvers,
26862792
)
26872793
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import '../css.spec'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { defineConfig } from 'vite'
2+
import baseConfig from './vite.config.js'
3+
4+
export default defineConfig({
5+
...baseConfig,
6+
css: {
7+
...baseConfig.css,
8+
preprocessorOptions: {
9+
...baseConfig.css.preprocessorOptions,
10+
scss: {
11+
api: 'modern',
12+
additionalData: `$injectedColor: orange;`,
13+
importers: [
14+
{
15+
canonicalize(url) {
16+
return url === 'virtual-dep'
17+
? new URL('custom-importer:virtual-dep')
18+
: null
19+
},
20+
load() {
21+
return {
22+
contents: ``,
23+
syntax: 'scss',
24+
}
25+
},
26+
},
27+
],
28+
},
29+
},
30+
},
31+
})

playground/vitestGlobalSetup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export async function setup({ provide }: GlobalSetupContext): Promise<void> {
4141
throw error
4242
}
4343
})
44+
// also setup dedicated copy for "variant" tests
45+
await fs.cp(
46+
path.resolve(tempDir, 'css'),
47+
path.resolve(tempDir, 'css__sass-modern'),
48+
{ recursive: true },
49+
)
4450
}
4551

4652
export async function teardown(): Promise<void> {

playground/vitestSetup.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ beforeAll(async (s) => {
136136
const testCustomRoot = path.resolve(testDir, 'root')
137137
rootDir = fs.existsSync(testCustomRoot) ? testCustomRoot : testDir
138138

139+
// separate rootDir for variant
140+
const variantName = path.basename(path.dirname(testPath))
141+
if (variantName !== '__tests__') {
142+
const variantTestDir = testDir + '__' + variantName
143+
if (fs.existsSync(variantTestDir)) {
144+
rootDir = testDir = variantTestDir
145+
}
146+
}
147+
139148
const testCustomServe = [
140149
path.resolve(path.dirname(testPath), 'serve.ts'),
141150
path.resolve(path.dirname(testPath), 'serve.js'),

0 commit comments

Comments
 (0)