Skip to content

Commit

Permalink
feat(css): support sass modern api (#17728)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Jul 30, 2024
1 parent 116e37a commit 73a3de0
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 10 deletions.
8 changes: 7 additions & 1 deletion docs/config/shared-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -243,6 +243,12 @@ export default defineConfig({
$specialColor: new stylus.nodes.RGBA(51, 197, 255, 1),
},
},
scss: {
api: 'modern', // or "legacy"
importers: [
// ...
],
},
},
},
})
Expand Down
124 changes: 115 additions & 9 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2144,11 +2144,7 @@ const makeScssWorker = (
}
: {}),
}
return new Promise<{
css: string
map?: string | undefined
stats: Sass.LegacyResult['stats']
}>((resolve, reject) => {
return new Promise<ScssWorkerResult>((resolve, reject) => {
sass.render(finalOptions, (err, res) => {
if (err) {
reject(err)
Expand Down Expand Up @@ -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<string | null> => {
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<Sass.LegacyResult['stats'], 'includedFiles'>
}

const scssProcessor = (
maxWorkers: number | undefined,
): SassStylePreprocessor => {
Expand All @@ -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)!
Expand Down Expand Up @@ -2251,7 +2357,7 @@ async function rebaseUrls(
alias: Alias[],
variablePrefix: string,
resolver: ResolveFn,
): Promise<Sass.LegacyImporterResult> {
): 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)
Expand Down Expand Up @@ -2681,7 +2787,7 @@ const createPreprocessorWorkerController = (maxWorkers: number | undefined) => {
return scss.process(
source,
root,
{ ...options, indentedSyntax: true },
{ ...options, indentedSyntax: true, syntax: 'indented' },
resolvers,
)
}
Expand Down
1 change: 1 addition & 0 deletions playground/css/__tests__/sass-modern/sass-modern.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '../css.spec'
31 changes: 31 additions & 0 deletions playground/css/vite.config-sass-modern.js
Original file line number Diff line number Diff line change
@@ -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',
}
},
},
],
},
},
},
})
6 changes: 6 additions & 0 deletions playground/vitestGlobalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export async function setup({ provide }: GlobalSetupContext): Promise<void> {
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<void> {
Expand Down
9 changes: 9 additions & 0 deletions playground/vitestSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down

0 comments on commit 73a3de0

Please sign in to comment.