From 3e9175d1b90fb333198eb2c332c23ebc3290edcf Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Wed, 25 Dec 2024 14:31:56 +0900 Subject: [PATCH] feat(css): support preprocessor with lightningcss --- packages/vite/src/node/plugins/css.ts | 229 ++++++++++++++++++++++--- playground/css/__tests__/css.spec.ts | 3 +- playground/css/lightningcss-plugins.js | 173 +++++++++++++++++++ playground/css/package.json | 1 + playground/css/vite.config.js | 17 ++ pnpm-lock.yaml | 3 + 6 files changed, 403 insertions(+), 23 deletions(-) create mode 100644 playground/css/lightningcss-plugins.js diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index c088bbb4f7963a..3845d15868b589 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -1279,10 +1279,6 @@ async function compileCSS( deps?: Set }> { const { config } = environment - if (config.css.transformer === 'lightningcss') { - return compileLightningCSS(id, code, environment, urlResolver) - } - const lang = CSS_LANGS_RE.exec(id)?.[1] as CssLang | undefined const deps = new Set() @@ -1299,8 +1295,71 @@ async function compileCSS( code = preprocessorResult.code preprocessorMap = preprocessorResult.map preprocessorResult.deps?.forEach((dep) => deps.add(dep)) + } else if (lang === 'sss' && config.css.transformer === 'lightningcss') { + const sssResult = await transformSugarSS(environment, id, code) + code = sssResult.code + preprocessorMap = sssResult.map + } + + const transformResult = await (config.css.transformer === 'lightningcss' + ? compileLightningCSS( + environment, + id, + code, + deps, + workerController, + urlResolver, + ) + : compilePostCSS( + environment, + id, + code, + deps, + lang, + workerController, + urlResolver, + )) + + if (!transformResult) { + return { + code, + map: config.css.devSourcemap ? preprocessorMap : { mappings: '' }, + deps, + } } + return { + ...transformResult, + map: config.css.devSourcemap + ? combineSourcemapsIfExists( + cleanUrl(id), + typeof transformResult.map === 'string' + ? JSON.parse(transformResult.map) + : transformResult.map, + preprocessorMap, + ) + : { mappings: '' }, + deps, + } +} + +async function compilePostCSS( + environment: PartialEnvironment, + id: string, + code: string, + deps: Set, + lang: CssLang | undefined, + workerController: PreprocessorWorkerController, + urlResolver?: CssUrlResolver, +): Promise< + | { + code: string + map?: Exclude + modules?: Record + } + | undefined +> { + const { config } = environment const { modules: modulesOptions, devSourcemap } = config.css const isModule = modulesOptions !== false && cssModuleRE.test(id) // although at serve time it can work without processing, we do need to @@ -1319,7 +1378,7 @@ async function compileCSS( !needInlineImport && !hasUrl ) { - return { code, map: preprocessorMap ?? null, deps } + return } // postcss @@ -1443,11 +1502,7 @@ async function compileCSS( lang === 'sss' ? loadSss(config.root) : postcssOptions.parser if (!postcssPlugins.length && !postcssParser) { - return { - code, - map: preprocessorMap, - deps, - } + return } let postcssResult: PostCSS.Result @@ -1527,12 +1582,10 @@ async function compileCSS( code: postcssResult.css, map: { mappings: '' }, modules, - deps, } } const rawPostcssMap = postcssResult.map.toJSON() - const postcssMap = await formatPostcssSourceMap( // version property of rawPostcssMap is declared as string // but actually it is a number @@ -1542,9 +1595,92 @@ async function compileCSS( return { code: postcssResult.css, - map: combineSourcemapsIfExists(cleanUrl(id), postcssMap, preprocessorMap), + map: postcssMap, modules, - deps, + } +} + +// TODO: dedupe +async function transformSugarSS( + environment: PartialEnvironment, + id: string, + code: string, +) { + const { config } = environment + const { devSourcemap } = config.css + + let postcssResult: PostCSS.Result + try { + const source = removeDirectQuery(id) + const postcss = await importPostcss() + // postcss is an unbundled dep and should be lazy imported + postcssResult = await postcss.default().process(code, { + parser: loadSss(config.root), + to: source, + from: source, + ...(devSourcemap + ? { + map: { + inline: false, + annotation: false, + // postcss may return virtual files + // we cannot obtain content of them, so this needs to be enabled + sourcesContent: true, + // when "prev: preprocessorMap", the result map may include duplicate filename in `postcssResult.map.sources` + // prev: preprocessorMap, + }, + } + : {}), + }) + + for (const message of postcssResult.messages) { + if (message.type === 'warning') { + const warning = message as PostCSS.Warning + let msg = `[vite:css] ${warning.text}` + msg += `\n${generateCodeFrame( + code, + { + line: warning.line, + column: warning.column - 1, // 1-based + }, + warning.endLine !== undefined && warning.endColumn !== undefined + ? { + line: warning.endLine, + column: warning.endColumn - 1, // 1-based + } + : undefined, + )}` + environment.logger.warn(colors.yellow(msg)) + } + } + } catch (e) { + e.message = `[postcss] ${e.message}` + e.code = code + e.loc = { + file: e.file, + line: e.line, + column: e.column - 1, // 1-based + } + throw e + } + + if (!devSourcemap) { + return { + code: postcssResult.css, + } + } + + const rawPostcssMap = postcssResult.map.toJSON() + const postcssMap = await formatPostcssSourceMap( + // version property of rawPostcssMap is declared as string + // but actually it is a number + rawPostcssMap as Omit as ExistingRawSourceMap, + cleanUrl(id), + ) + + return { + code: postcssResult.css, + map: postcssMap, } } @@ -3194,13 +3330,18 @@ function isPreProcessor(lang: any): lang is PreprocessLang { const importLightningCSS = createCachedImport(() => import('lightningcss')) async function compileLightningCSS( + environment: PartialEnvironment, id: string, src: string, - environment: PartialEnvironment, + deps: Set, + workerController: PreprocessorWorkerController, urlResolver?: CssUrlResolver, -): ReturnType { +): Promise<{ + code: string + map?: string | undefined + modules?: Record +}> { const { config } = environment - const deps = new Set() // replace null byte as lightningcss treats that as a string terminator // https://github.com/parcel-bundler/lightningcss/issues/874 const filename = id.replace('\0', NULL_BYTE_PLACEHOLDER) @@ -3223,11 +3364,32 @@ async function compileLightningCSS( // projectRoot is needed to get stable hash when using CSS modules projectRoot: config.root, resolver: { - read(filePath) { + async read(filePath) { if (filePath === filename) { return src } - return fs.readFileSync(filePath, 'utf-8') + + const code = fs.readFileSync(filePath, 'utf-8') + const lang = CSS_LANGS_RE.exec(filePath)?.[1] as + | CssLang + | undefined + if (isPreProcessor(lang)) { + const result = await compileCSSPreprocessors( + environment, + id, + lang, + code, + workerController, + ) + result.deps?.forEach((dep) => deps.add(dep)) + // TODO: support source map + return result.code + } else if (lang === 'sss') { + const sssResult = await transformSugarSS(environment, id, code) + // TODO: support source map + return sssResult.code + } + return code }, async resolve(id, from) { const publicFile = checkPublicFile( @@ -3238,10 +3400,34 @@ async function compileLightningCSS( return publicFile } - const resolved = await getAtImportResolvers( + // NOTE: with `transformer: 'postcss'`, CSS modules `composes` tried to resolve with + // all resolvers, but in `transformer: 'lightningcss'`, only the one for the + // current file type is used. + const atImportResolvers = getAtImportResolvers( environment.getTopLevelConfig(), - ).css(environment, id, from) + ) + const lang = CSS_LANGS_RE.exec(from)?.[1] as CssLang | undefined + let resolver: ResolveIdFn + switch (lang) { + case 'css': + case 'sss': + case 'styl': + case 'stylus': + case undefined: + resolver = atImportResolvers.css + break + case 'sass': + case 'scss': + resolver = atImportResolvers.sass + break + case 'less': + resolver = atImportResolvers.less + break + default: + throw new Error(`Unknown lang: ${lang satisfies never}`) + } + const resolved = await resolver(environment, id, from) if (resolved) { deps.add(resolved) return resolved @@ -3356,7 +3542,6 @@ async function compileLightningCSS( return { code: css, map: 'map' in res ? res.map?.toString() : undefined, - deps, modules, } } diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index 42f50aa92ce2df..6d3688151fdb4d 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -289,7 +289,8 @@ test('PostCSS dir-dependency', async () => { expect(await getColor(el2)).toBe('grey') expect(await getColor(el3)).toBe('grey') - if (!isBuild) { + // FIXME: skip for now as lightningcss does not support registering dependencies in plugins + if (!isBuild && false) { editFile('glob-dep/foo.css', (code) => code.replace('color: grey', 'color: blue'), ) diff --git a/playground/css/lightningcss-plugins.js b/playground/css/lightningcss-plugins.js new file mode 100644 index 00000000000000..3b5b33daa20c56 --- /dev/null +++ b/playground/css/lightningcss-plugins.js @@ -0,0 +1,173 @@ +import path from 'node:path' +import { normalizePath } from 'vite' +import { bundle as bundleWithLightningCss } from 'lightningcss' +import { globSync } from 'tinyglobby' + +/** + * @param {string} filename + * @returns {import('lightningcss').StyleSheet} + * + * based on https://github.com/sardinedev/lightningcss-plugins/blob/9fb379486e402a4b4b8950d09e655b4cbf8a118b/packages/global-custom-queries/src/globalCustomQueries.ts#L9-L29 + * https://github.com/sardinedev/lightningcss-plugins/blob/main/LICENSE + */ +function obtainLightningCssAst(filename) { + let ast + try { + bundleWithLightningCss({ + filename, + visitor: { + StyleSheet(stylesheet) { + ast = stylesheet + }, + }, + }) + return ast + } catch (error) { + throw Error(`failed to obtain lightning css AST`, { cause: error }) + } +} + +/** @returns {import('lightningcss').Visitor} */ +export function testDirDep() { + /** @type {string[]} */ + let currentStyleSheetSources + return { + StyleSheet(stylesheet) { + currentStyleSheetSources = stylesheet.sources + }, + Rule: { + unknown: { + test(rule) { + const location = rule.loc + const from = currentStyleSheetSources[location.source_index] + const pattern = normalizePath( + path.resolve(path.dirname(from), './glob-dep/**/*.css'), + ) + // FIXME: there's no way to add a dependency + const files = globSync(pattern, { + expandDirectories: false, + absolute: true, + }) + return files.flatMap((file) => obtainLightningCssAst(file).rules) + }, + }, + }, + } +} + +/** @returns {import('lightningcss').Visitor} */ +export function testSourceInput() { + /** @type {string[]} */ + let currentStyleSheetSources + return { + StyleSheet(stylesheet) { + currentStyleSheetSources = stylesheet.sources + }, + Rule: { + unknown: { + 'source-input': (rule) => { + const location = rule.loc + const from = currentStyleSheetSources[location.source_index] + return [ + { + type: 'style', + value: { + // .source-input::before + selectors: [ + [ + { type: 'class', name: 'source-input' }, + { type: 'pseudo-element', kind: 'before' }, + ], + ], + // content: ${JSON.stringify(from)}; + declarations: { + declarations: [ + { + property: 'custom', + value: + /** @satisfies {import('lightningcss').CustomProperty} */ ({ + name: 'content', + value: [ + { + type: 'token', + value: { type: 'string', value: from }, + }, + ], + }), + }, + ], + }, + loc: rule.loc, + }, + }, + ] + }, + }, + }, + } +} + +/** + * really simplified implementation of https://github.com/postcss/postcss-nested + * + * @returns {import('lightningcss').Visitor} + */ +export function nestedLikePlugin() { + return { + Rule: { + style(rule) { + // NOTE: multiple selectors are not supported + if (rule.value.selectors.length > 1) { + return + } + const parentSelector = rule.value.selectors[0] + + const nestedRules = rule.value.rules + /** @type {import('lightningcss').Rule[]} */ + const additionalRules = [] + if (nestedRules) { + const filteredNestedRules = [] + for (const nestedRule of nestedRules) { + if (nestedRule.type === 'style') { + const selectors = nestedRule.value.selectors + // NOTE: multiple selectors are not supported + if (selectors.length === 1) { + const selector = selectors[0] + if ( + selector.length >= 2 && + selector[0].type === 'nesting' && + selector[1].type === 'type' + ) { + const lastParentSelectorComponent = + parentSelector[parentSelector.length - 1] + if ('name' in lastParentSelectorComponent) { + const newSelector = [ + ...parentSelector.slice(0, -1), + { + ...lastParentSelectorComponent, + name: + lastParentSelectorComponent.name + selector[1].name, + }, + ] + additionalRules.push({ + type: 'style', + value: { + selectors: [newSelector], + declarations: nestedRule.value.declarations, + loc: nestedRule.value.loc, + }, + }) + continue + } + } + } + } + filteredNestedRules.push(nestedRule) + } + rule.value.rules = filteredNestedRules + } + return [rule, ...additionalRules] + }, + }, + } +} diff --git a/playground/css/package.json b/playground/css/package.json index a6dd850d30985c..6f71f0bc377573 100644 --- a/playground/css/package.json +++ b/playground/css/package.json @@ -22,6 +22,7 @@ "@vitejs/test-css-proxy-dep": "file:./css-proxy-dep", "@vitejs/test-scss-proxy-dep": "file:./scss-proxy-dep", "less": "^4.2.1", + "lightningcss": "^1.29.1", "postcss-nested": "^7.0.2", "sass": "^1.83.4", "stylus": "^0.64.0", diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index 115db67dcaaa53..1691e4aa9d5700 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -2,6 +2,12 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import stylus from 'stylus' import { defineConfig } from 'vite' +import { composeVisitors } from 'lightningcss' +import { + nestedLikePlugin, + testDirDep, + testSourceInput, +} from './lightningcss-plugins' // trigger scss bug: https://github.com/sass/dart-sass/issues/710 // make sure Vite handles safely @@ -38,6 +44,17 @@ export default defineConfig({ }, }, css: { + transformer: 'lightningcss', + lightningcss: { + cssModules: { + pattern: '[name]__[local]___[hash]', + }, + visitor: composeVisitors([ + nestedLikePlugin(), + testDirDep(), + testSourceInput(), + ]), + }, modules: { generateScopedName: '[name]__[local]___[hash:base64:5]', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd58a32d53e6a9..af5f622446a924 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,9 @@ importers: less: specifier: ^4.2.1 version: 4.2.1 + lightningcss: + specifier: ^1.29.1 + version: 1.29.1 postcss-nested: specifier: ^7.0.2 version: 7.0.2(postcss@8.5.1)