From 73a3de01d2baaeefeabfa46c28fb49550643b23a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 30 Jul 2024 14:35:18 +0900 Subject: [PATCH] feat(css): support sass modern api (#17728) --- docs/config/shared-options.md | 8 +- packages/vite/src/node/plugins/css.ts | 124 ++++++++++++++++-- .../__tests__/sass-modern/sass-modern.spec.ts | 1 + playground/css/vite.config-sass-modern.js | 31 +++++ playground/vitestGlobalSetup.ts | 6 + playground/vitestSetup.ts | 9 ++ 6 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 playground/css/__tests__/sass-modern/sass-modern.spec.ts create mode 100644 playground/css/vite.config-sass-modern.js diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index 19eb96e0f74439..fa3ccba14128aa 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -225,7 +225,7 @@ Note if an inline config is provided, Vite will not search for other PostCSS con 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: -- `sass`/`scss` - [Options](https://sass-lang.com/documentation/js-api/interfaces/LegacyStringOptions). +- `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/). - `less` - [Options](https://lesscss.org/usage/#less-options). - `styl`/`stylus` - Only [`define`](https://stylus-lang.com/docs/js.html#define-name-node) is supported, which can be passed as an object. @@ -243,6 +243,12 @@ export default defineConfig({ $specialColor: new stylus.nodes.RGBA(51, 197, 255, 1), }, }, + scss: { + api: 'modern', // or "legacy" + importers: [ + // ... + ], + }, }, }, }) diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 3218ccf5321ee6..4fc5619e615d81 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -2110,7 +2110,7 @@ const makeScssWorker = ( // eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker const sass: typeof Sass = require(sassPath) // eslint-disable-next-line no-restricted-globals - const path = require('node:path') + const path: typeof import('node:path') = require('node:path') // NOTE: `sass` always runs it's own importer first, and only falls back to // the `importer` option when it can't resolve a path @@ -2144,11 +2144,7 @@ const makeScssWorker = ( } : {}), } - return new Promise<{ - css: string - map?: string | undefined - stats: Sass.LegacyResult['stats'] - }>((resolve, reject) => { + return new Promise((resolve, reject) => { sass.render(finalOptions, (err, res) => { if (err) { reject(err) @@ -2179,6 +2175,114 @@ const makeScssWorker = ( return worker } +const makeModernScssWorker = ( + resolvers: CSSAtImportResolvers, + alias: Alias[], + maxWorkers: number | undefined, +) => { + const internalCanonicalize = async ( + url: string, + importer: string, + ): Promise => { + importer = cleanScssBugUrl(importer) + const resolved = await resolvers.sass(url, importer) + return resolved ?? null + } + + const internalLoad = async (file: string, rootFile: string) => { + const result = await rebaseUrls(file, rootFile, alias, '$', resolvers.sass) + if (result.contents) { + return result.contents + } + return await fsp.readFile(result.file, 'utf-8') + } + + const worker = new WorkerWithFallback( + () => + async ( + sassPath: string, + data: string, + // additionalData can a function that is not cloneable but it won't be used + options: SassStylePreprocessorOptions & { additionalData: undefined }, + ) => { + // eslint-disable-next-line no-restricted-globals -- this function runs inside a cjs worker + const sass: typeof Sass = require(sassPath) + // eslint-disable-next-line no-restricted-globals + const path: typeof import('node:path') = require('node:path') + + const { fileURLToPath, pathToFileURL }: typeof import('node:url') = + // eslint-disable-next-line no-restricted-globals + require('node:url') + + const sassOptions = { ...options } as Sass.StringOptions<'async'> + sassOptions.url = pathToFileURL(options.filename) + sassOptions.sourceMap = options.enableSourcemap + + const internalImporter: Sass.Importer<'async'> = { + async canonicalize(url, context) { + const importer = context.containingUrl + ? fileURLToPath(context.containingUrl) + : options.filename + const resolved = await internalCanonicalize(url, importer) + return resolved ? pathToFileURL(resolved) : null + }, + async load(canonicalUrl) { + const ext = path.extname(canonicalUrl.pathname) + let syntax: Sass.Syntax = 'scss' + if (ext === '.sass') { + syntax = 'indented' + } else if (ext === '.css') { + syntax = 'css' + } + const contents = await internalLoad( + fileURLToPath(canonicalUrl), + options.filename, + ) + return { contents, syntax } + }, + } + sassOptions.importers = [ + ...(sassOptions.importers ?? []), + internalImporter, + ] + + const result = await sass.compileStringAsync(data, sassOptions) + return { + css: result.css, + map: result.sourceMap ? JSON.stringify(result.sourceMap) : undefined, + stats: { + includedFiles: result.loadedUrls + .filter((url) => url.protocol === 'file:') + .map((url) => fileURLToPath(url)), + }, + } satisfies ScssWorkerResult + }, + { + parentFunctions: { + internalCanonicalize, + internalLoad, + }, + shouldUseFake(_sassPath, _data, options) { + // functions and importer is a function and is not serializable + // in that case, fallback to running in main thread + return !!( + (options.functions && Object.keys(options.functions).length > 0) || + (options.importers && + (!Array.isArray(options.importers) || options.importers.length > 0)) + ) + }, + max: maxWorkers, + }, + ) + return worker +} + +type ScssWorkerResult = { + css: string + map?: string | undefined + stats: Pick +} + const scssProcessor = ( maxWorkers: number | undefined, ): SassStylePreprocessor => { @@ -2196,7 +2300,9 @@ const scssProcessor = ( if (!workerMap.has(options.alias)) { workerMap.set( options.alias, - makeScssWorker(resolvers, options.alias, maxWorkers), + options.api === 'modern' + ? makeModernScssWorker(resolvers, options.alias, maxWorkers) + : makeScssWorker(resolvers, options.alias, maxWorkers), ) } const worker = workerMap.get(options.alias)! @@ -2251,7 +2357,7 @@ async function rebaseUrls( alias: Alias[], variablePrefix: string, resolver: ResolveFn, -): Promise { +): Promise<{ file: string; contents?: string }> { file = path.resolve(file) // ensure os-specific flashes // in the same dir, no need to rebase const fileDir = path.dirname(file) @@ -2681,7 +2787,7 @@ const createPreprocessorWorkerController = (maxWorkers: number | undefined) => { return scss.process( source, root, - { ...options, indentedSyntax: true }, + { ...options, indentedSyntax: true, syntax: 'indented' }, resolvers, ) } diff --git a/playground/css/__tests__/sass-modern/sass-modern.spec.ts b/playground/css/__tests__/sass-modern/sass-modern.spec.ts new file mode 100644 index 00000000000000..90a9b9774d3ebc --- /dev/null +++ b/playground/css/__tests__/sass-modern/sass-modern.spec.ts @@ -0,0 +1 @@ +import '../css.spec' diff --git a/playground/css/vite.config-sass-modern.js b/playground/css/vite.config-sass-modern.js new file mode 100644 index 00000000000000..9f7acb3a098179 --- /dev/null +++ b/playground/css/vite.config-sass-modern.js @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import baseConfig from './vite.config.js' + +export default defineConfig({ + ...baseConfig, + css: { + ...baseConfig.css, + preprocessorOptions: { + ...baseConfig.css.preprocessorOptions, + scss: { + api: 'modern', + additionalData: `$injectedColor: orange;`, + importers: [ + { + canonicalize(url) { + return url === 'virtual-dep' + ? new URL('custom-importer:virtual-dep') + : null + }, + load() { + return { + contents: ``, + syntax: 'scss', + } + }, + }, + ], + }, + }, + }, +}) diff --git a/playground/vitestGlobalSetup.ts b/playground/vitestGlobalSetup.ts index b1bc77b32fcaf2..193c47b6047eac 100644 --- a/playground/vitestGlobalSetup.ts +++ b/playground/vitestGlobalSetup.ts @@ -41,6 +41,12 @@ export async function setup({ provide }: GlobalSetupContext): Promise { throw error } }) + // also setup dedicated copy for "variant" tests + await fs.cp( + path.resolve(tempDir, 'css'), + path.resolve(tempDir, 'css__sass-modern'), + { recursive: true }, + ) } export async function teardown(): Promise { diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index 62ee5a3ee9405f..eb28b5f544d453 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -136,6 +136,15 @@ beforeAll(async (s) => { const testCustomRoot = path.resolve(testDir, 'root') rootDir = fs.existsSync(testCustomRoot) ? testCustomRoot : testDir + // separate rootDir for variant + const variantName = path.basename(path.dirname(testPath)) + if (variantName !== '__tests__') { + const variantTestDir = testDir + '__' + variantName + if (fs.existsSync(variantTestDir)) { + rootDir = testDir = variantTestDir + } + } + const testCustomServe = [ path.resolve(path.dirname(testPath), 'serve.ts'), path.resolve(path.dirname(testPath), 'serve.js'),