diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 45a2709758cd45..3f51a1ee099006 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -205,7 +205,7 @@ export const cssConfigDefaults = Object.freeze({ } satisfies CSSOptions) export type ResolvedCSSOptions = Omit & - Required> & { + Required> & { lightningcss?: LightningCSSOptions } @@ -1294,7 +1294,11 @@ async function compileCSSPreprocessors( lang: PreprocessLang, code: string, workerController: PreprocessorWorkerController, -): Promise<{ code: string; map?: ExistingRawSourceMap; deps?: Set }> { +): Promise<{ + code: string + map?: ExistingRawSourceMap | { mappings: '' } + deps?: Set +}> { const { config } = environment const { preprocessorOptions, devSourcemap } = config.css const atImportResolvers = getAtImportResolvers( @@ -1368,15 +1372,11 @@ 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() // pre-processors: sass etc. - let preprocessorMap: ExistingRawSourceMap | undefined + let preprocessorMap: ExistingRawSourceMap | { mappings: '' } | undefined if (isPreProcessor(lang)) { const preprocessorResult = await compileCSSPreprocessors( environment, @@ -1388,8 +1388,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 @@ -1408,7 +1471,7 @@ async function compileCSS( !needInlineImport && !hasUrl ) { - return { code, map: preprocessorMap ?? null, deps } + return } // postcss @@ -1533,25 +1596,61 @@ async function compileCSS( lang === 'sss' ? loadSss(config.root) : postcssOptions.parser if (!postcssPlugins.length && !postcssParser) { - return { - code, - map: preprocessorMap, - deps, - } + return } + const result = await runPostCSS( + id, + code, + postcssPlugins, + { ...postcssOptions, parser: postcssParser }, + deps, + environment.logger, + devSourcemap, + ) + return { ...result, modules } +} + +async function transformSugarSS( + environment: PartialEnvironment, + id: string, + code: string, +) { + const { config } = environment + const { devSourcemap } = config.css + + const result = await runPostCSS( + id, + code, + [], + { parser: loadSss(config.root) }, + undefined, + environment.logger, + devSourcemap, + ) + return result +} + +async function runPostCSS( + id: string, + code: string, + plugins: PostCSS.AcceptedPlugin[], + options: PostCSS.ProcessOptions, + deps: Set | undefined, + logger: Logger, + enableSourcemap: boolean, +) { 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(postcssPlugins).process(code, { - ...postcssOptions, - parser: postcssParser, + postcssResult = await postcss.default(plugins).process(code, { + ...options, to: source, from: source, - ...(devSourcemap + ...(enableSourcemap ? { map: { inline: false, @@ -1569,7 +1668,7 @@ async function compileCSS( // record CSS dependencies from @imports for (const message of postcssResult.messages) { if (message.type === 'dependency') { - deps.add(normalizePath(message.file as string)) + deps?.add(normalizePath(message.file as string)) } else if (message.type === 'dir-dependency') { // https://github.com/postcss/postcss/blob/main/docs/guidelines/plugin.md#3-dependencies const { dir, glob: globPattern = '**' } = message @@ -1580,7 +1679,7 @@ async function compileCSS( ignore: ['**/node_modules/**'], }) for (let i = 0; i < files.length; i++) { - deps.add(files[i]) + deps?.add(files[i]) } } else if (message.type === 'warning') { const warning = message as PostCSS.Warning @@ -1598,7 +1697,7 @@ async function compileCSS( } : undefined, )}` - environment.logger.warn(colors.yellow(msg)) + logger.warn(colors.yellow(msg)) } } } catch (e) { @@ -1612,17 +1711,14 @@ async function compileCSS( throw e } - if (!devSourcemap) { + if (!enableSourcemap) { return { code: postcssResult.css, - map: { mappings: '' }, - modules, - deps, + map: { mappings: '' as const }, } } const rawPostcssMap = postcssResult.map.toJSON() - const postcssMap = await formatPostcssSourceMap( // version property of rawPostcssMap is declared as string // but actually it is a number @@ -1632,9 +1728,7 @@ async function compileCSS( return { code: postcssResult.css, - map: combineSourcemapsIfExists(cleanUrl(id), postcssMap, preprocessorMap), - modules, - deps, + map: postcssMap, } } @@ -1729,17 +1823,21 @@ export async function formatPostcssSourceMap( function combineSourcemapsIfExists( filename: string, - map1: ExistingRawSourceMap | undefined, - map2: ExistingRawSourceMap | undefined, -): ExistingRawSourceMap | undefined { - return map1 && map2 - ? (combineSourcemaps(filename, [ - // type of version property of ExistingRawSourceMap is number - // but it is always 3 - map1 as RawSourceMap, - map2 as RawSourceMap, - ]) as ExistingRawSourceMap) - : map1 + map1: ExistingRawSourceMap | { mappings: '' } | undefined, + map2: ExistingRawSourceMap | { mappings: '' } | undefined, +): ExistingRawSourceMap | { mappings: '' } | undefined { + if (!map1 || !map2) { + return map1 + } + if (map1.mappings === '' || map2.mappings === '') { + return { mappings: '' } + } + return combineSourcemaps(filename, [ + // type of version property of ExistingRawSourceMap is number + // but it is always 3 + map1 as RawSourceMap, + map2 as RawSourceMap, + ]) as ExistingRawSourceMap } const viteHashUpdateMarker = '/*$vite$:1*/' @@ -3296,13 +3394,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 = removeDirectQuery(id).replace('\0', NULL_BYTE_PLACEHOLDER) @@ -3325,11 +3428,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( @@ -3340,10 +3464,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 @@ -3458,7 +3606,6 @@ async function compileLightningCSS( return { code: css, map: 'map' in res ? res.map?.toString() : undefined, - deps, modules, } } diff --git a/playground/css-sourcemap/__tests__/lightningcss/lightningcss.spec.ts b/playground/css-sourcemap/__tests__/lightningcss/lightningcss.spec.ts new file mode 100644 index 00000000000000..98f128cdd4ff29 --- /dev/null +++ b/playground/css-sourcemap/__tests__/lightningcss/lightningcss.spec.ts @@ -0,0 +1,252 @@ +import { URL } from 'node:url' +import { describe, expect, test } from 'vitest' +import { + extractSourcemap, + formatSourcemapForSnapshot, + isBuild, + isServe, + page, + serverLogs, +} from '~utils' + +test.runIf(isBuild)('should not output sourcemap warning (#4939)', () => { + serverLogs.forEach((log) => { + expect(log).not.toMatch('Sourcemap is likely to be incorrect') + }) +}) + +describe.runIf(isServe)('serve', () => { + const getStyleTagContentIncluding = async (content: string) => { + const styles = await page.$$('style') + for (const style of styles) { + const text = await style.textContent() + if (text.includes(content)) { + return text + } + } + throw new Error('Not found') + } + + test('linked css', async () => { + const res = await page.request.get( + new URL('./linked.css', page.url()).href, + { + headers: { + accept: 'text/css', + }, + }, + ) + const css = await res.text() + expect(css).not.toContain('sourceMappingURL') + }) + + test('linked css with import', async () => { + const res = await page.request.get( + new URL('./linked-with-import.css', page.url()).href, + { + headers: { + accept: 'text/css', + }, + }, + ) + const css = await res.text() + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "mappings": "ACAA;;;;ADEA", + "sourceRoot": null, + "sources": [ + "linked-with-import.css", + "be-imported.css", + ], + "sourcesContent": [ + "@import '@/be-imported.css'; + + .linked-with-import { + color: red; + } + ", + ".be-imported { + color: red; + } + ", + ], + "version": 3, + } + `) + }) + + test.runIf(isServe)( + 'js .css request does not include sourcemap', + async () => { + const res = await page.request.get( + new URL('./linked-with-import.css', page.url()).href, + ) + const content = await res.text() + expect(content).not.toMatch('//#s*sourceMappingURL') + }, + ) + + test('imported css', async () => { + const css = await getStyleTagContentIncluding('.imported ') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "mappings": "AAAA", + "sourceRoot": null, + "sources": [ + "imported.css", + ], + "sourcesContent": [ + ".imported { + color: red; + } + ", + ], + "version": 3, + } + `) + }) + + test('imported css with import', async () => { + const css = await getStyleTagContentIncluding('.imported-with-import ') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "mappings": "ACAA;;;;ADEA", + "sourceRoot": null, + "sources": [ + "imported-with-import.css", + "be-imported.css", + ], + "sourcesContent": [ + "@import '@/be-imported.css'; + + .imported-with-import { + color: red; + } + ", + ".be-imported { + color: red; + } + ", + ], + "version": 3, + } + `) + }) + + test('imported sass', async () => { + const css = await getStyleTagContentIncluding('.imported-sass ') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "ignoreList": [], + "mappings": "AAGE", + "sources": [ + "/root/imported.sass", + ], + "sourcesContent": [ + "@use "/imported-nested.sass" + + .imported + &-sass + color: imported-nested.$primary + ", + ], + "version": 3, + } + `) + }) + + test('imported sass module', async () => { + const css = await getStyleTagContentIncluding('_imported-sass-module') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "ignoreList": [], + "mappings": "AACE", + "sources": [ + "/root/imported.module.sass", + ], + "sourcesContent": [ + ".imported + &-sass-module + color: red + ", + ], + "version": 3, + } + `) + }) + + test('imported less', async () => { + const css = await getStyleTagContentIncluding('.imported-less ') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "ignoreList": [], + "mappings": "AACE", + "sources": [ + "/root/imported.less", + ], + "sourcesContent": [ + ".imported { + &-less { + color: @color; + } + } + ", + ], + "version": 3, + } + `) + }) + + test('imported stylus', async () => { + const css = await getStyleTagContentIncluding('.imported-stylus ') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "ignoreList": [], + "mappings": "AACE", + "sources": [ + "/root/imported.styl", + ], + "sourcesContent": [ + ".imported + &-stylus + color blue-red-mixed + ", + ], + "version": 3, + } + `) + }) + + test('imported sugarss', async () => { + const css = await getStyleTagContentIncluding('.imported-sugarss ') + const map = extractSourcemap(css) + expect(formatSourcemapForSnapshot(map)).toMatchInlineSnapshot(` + { + "ignoreList": [], + "mappings": "AAAA", + "sources": [ + "/root/imported.sss", + ], + "sourcesContent": [ + ".imported-sugarss + color: red + ", + ], + "version": 3, + } + `) + }) + + test('should not output missing source file warning', () => { + serverLogs.forEach((log) => { + expect(log).not.toMatch(/Sourcemap for .+ points to missing source files/) + }) + }) +}) diff --git a/playground/css-sourcemap/package.json b/playground/css-sourcemap/package.json index 8f09ba8cb91545..1c012555ec23c3 100644 --- a/playground/css-sourcemap/package.json +++ b/playground/css-sourcemap/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "less": "^4.2.2", + "lightningcss": "^1.29.2", "magic-string": "^0.30.17", "sass": "^1.85.1", "stylus": "^0.64.0", diff --git a/playground/css-sourcemap/vite.config-lightningcss.js b/playground/css-sourcemap/vite.config-lightningcss.js new file mode 100644 index 00000000000000..cf987bc98f032e --- /dev/null +++ b/playground/css-sourcemap/vite.config-lightningcss.js @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from 'vite' +import baseConfig from './vite.config.js' + +export default mergeConfig( + baseConfig, + defineConfig({ + css: { + transformer: 'lightningcss', + }, + }), +) diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index ee565ab31aea3a..c5506838313f49 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -1,521 +1,3 @@ -import { readFileSync } from 'node:fs' -import { expect, test } from 'vitest' -import { sassModuleTests, sassOtherTests, sassTest } from './sass-tests' -import { - editFile, - findAssetFile, - getBg, - getColor, - isBuild, - page, - removeFile, - serverLogs, - untilUpdated, - viteTestUrl, - withRetry, -} from '~utils' +import { tests } from './tests' -// note: tests should retrieve the element at the beginning of test and reuse it -// in later assertions to ensure CSS HMR doesn't reload the page -test('imported css', async () => { - const glob = await page.textContent('.imported-css-glob') - expect(glob).toContain('.dir-import') - const globEager = await page.textContent('.imported-css-globEager') - expect(globEager).toContain('.dir-import') -}) - -test('linked css', async () => { - const linked = await page.$('.linked') - const atImport = await page.$('.linked-at-import') - - expect(await getColor(linked)).toBe('blue') - expect(await getColor(atImport)).toBe('red') - - if (isBuild) return - - editFile('linked.css', (code) => code.replace('color: blue', 'color: red')) - await untilUpdated(() => getColor(linked), 'red') - - editFile('linked-at-import.css', (code) => - code.replace('color: red', 'color: blue'), - ) - await untilUpdated(() => getColor(atImport), 'blue') -}) - -test('css import from js', async () => { - const imported = await page.$('.imported') - const atImport = await page.$('.imported-at-import') - - expect(await getColor(imported)).toBe('green') - expect(await getColor(atImport)).toBe('purple') - - if (isBuild) return - - editFile('imported.css', (code) => code.replace('color: green', 'color: red')) - await untilUpdated(() => getColor(imported), 'red') - - editFile('imported-at-import.css', (code) => - code.replace('color: purple', 'color: blue'), - ) - await untilUpdated(() => getColor(atImport), 'blue') -}) - -test('css import asset with space', async () => { - const importedWithSpace = await page.$('.import-with-space') - - expect(await getBg(importedWithSpace)).toMatch(/.*\/ok.*\.png/) -}) - -test('postcss config', async () => { - const imported = await page.$('.postcss .nesting') - expect(await getColor(imported)).toBe('pink') - - if (isBuild) return - - editFile('imported.css', (code) => code.replace('color: pink', 'color: red')) - await untilUpdated(() => getColor(imported), 'red') -}) - -test('postcss plugin that injects url()', async () => { - const imported = await page.$('.postcss-inject-url') - // alias should be resolved - expect(await getBg(imported)).toMatch( - /localhost(?::\d+)?\/(?:assets\/)?ok.*\.png/, - ) -}) - -sassTest() - -test('less', async () => { - const imported = await page.$('.less') - const atImport = await page.$('.less-at-import') - const atImportAlias = await page.$('.less-at-import-alias') - const atImportUrlOmmer = await page.$('.less-at-import-url-ommer') - const urlStartsWithVariable = await page.$('.less-url-starts-with-variable') - - expect(await getColor(imported)).toBe('blue') - expect(await getColor(atImport)).toBe('darkslateblue') - expect(await getBg(atImport)).toMatch(isBuild ? /base64/ : '/nested/icon.png') - expect(await getColor(atImportAlias)).toBe('darkslateblue') - expect(await getBg(atImportAlias)).toMatch( - isBuild ? /base64/ : '/nested/icon.png', - ) - expect(await getColor(atImportUrlOmmer)).toBe('darkorange') - expect(await getBg(urlStartsWithVariable)).toMatch( - isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, - ) - - if (isBuild) return - - editFile('less.less', (code) => code.replace('@color: blue', '@color: red')) - await untilUpdated(() => getColor(imported), 'red') - - editFile('nested/nested.less', (code) => - code.replace('color: darkslateblue', 'color: blue'), - ) - await untilUpdated(() => getColor(atImport), 'blue') -}) - -test('less-plugin', async () => { - const body = await page.$('.less-js-plugin') - expect(await getBg(body)).toBe( - 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGD4/x8AAwIB/8myre4AAAAASUVORK5CYII=")', - ) -}) - -test('stylus', async () => { - const imported = await page.$('.stylus') - const additionalData = await page.$('.stylus-additional-data') - const relativeImport = await page.$('.stylus-import') - const relativeImportAlias = await page.$('.stylus-import-alias') - const optionsRelativeImport = await page.$('.stylus-options-relative-import') - const optionsAbsoluteImport = await page.$('.stylus-options-absolute-import') - const optionsDefineVar = await page.$('.stylus-options-define-var') - const optionsDefineFunc = await page.$('.stylus-options-define-func') - - expect(await getColor(imported)).toBe('blue') - expect(await getColor(additionalData)).toBe('orange') - expect(await getColor(relativeImport)).toBe('darkslateblue') - expect(await getColor(relativeImportAlias)).toBe('darkslateblue') - expect(await getBg(relativeImportAlias)).toMatch( - isBuild ? /base64/ : '/nested/icon.png', - ) - expect(await getColor(optionsRelativeImport)).toBe('green') - expect(await getColor(optionsAbsoluteImport)).toBe('red') - expect(await getColor(optionsDefineVar)).toBe('rgb(51, 197, 255)') - expect(await getColor(optionsDefineFunc)).toBe('rgb(255, 0, 98)') - - if (isBuild) return - - editFile('stylus.styl', (code) => - code.replace('$color ?= blue', '$color ?= red'), - ) - await untilUpdated(() => getColor(imported), 'red') - - editFile('nested/nested.styl', (code) => - code.replace('color: darkslateblue', 'color: blue'), - ) - await untilUpdated(() => getColor(relativeImport), 'blue') -}) - -test('css modules', async () => { - const imported = await page.$('.modules') - expect(await getColor(imported)).toBe('turquoise') - - // check if the generated CSS module class name is indeed using the - // format specified in vite.config.js - expect(await imported.getAttribute('class')).toMatch( - /.mod-module__apply-color___[\w-]{5}/, - ) - - if (isBuild) return - - editFile('mod.module.css', (code) => - code.replace('color: turquoise', 'color: red'), - ) - await untilUpdated(() => getColor(imported), 'red') -}) - -test('css modules composes/from path resolving', async () => { - const imported = await page.$('.path-resolved-modules-css') - expect(await getColor(imported)).toBe('turquoise') - - // check if the generated CSS module class name is indeed using the - // format specified in vite.config.js - expect(await imported.getAttribute('class')).toMatch( - /.composed-module__apply-color___[\w-]{5}/, - ) - - expect(await imported.getAttribute('class')).toMatch( - /.composes-path-resolving-module__path-resolving-css___[\w-]{5}/, - ) - - // @todo HMR is not working on this situation. - // editFile('composed.module.css', (code) => - // code.replace('color: turquoise', 'color: red') - // ) - // await untilUpdated(() => getColor(imported), 'red') -}) - -sassModuleTests() - -test('less modules composes/from path resolving', async () => { - const imported = await page.$('.path-resolved-modules-less') - expect(await getColor(imported)).toBe('blue') - - // check if the generated CSS module class name is indeed using the - // format specified in vite.config.js - expect(await imported.getAttribute('class')).toMatch( - /.composed-module__apply-color___[\w-]{5}/, - ) - - expect(await imported.getAttribute('class')).toMatch( - /.composes-path-resolving-module__path-resolving-less___[\w-]{5}/, - ) - - // @todo HMR is not working on this situation. - // editFile('composed.module.scss', (code) => - // code.replace('color: orangered', 'color: red') - // ) - // await untilUpdated(() => getColor(imported), 'red') -}) - -test('inline css modules', async () => { - const css = await page.textContent('.modules-inline') - expect(css).toMatch(/\.inline-module__apply-color-inline___[\w-]{5}/) -}) - -test.runIf(isBuild)('@charset hoist', async () => { - serverLogs.forEach((log) => { - // no warning from esbuild css minifier - expect(log).not.toMatch('"@charset" must be the first rule in the file') - }) -}) - -test('layers', async () => { - expect(await getColor('.layers-blue')).toMatch('blue') - expect(await getColor('.layers-green')).toMatch('green') -}) - -test('@import dependency w/ style entry', async () => { - expect(await getColor('.css-dep')).toBe('purple') -}) - -test('@import dependency w/ style export mapping', async () => { - expect(await getColor('.css-dep-exports')).toBe('purple') -}) - -test('@import dependency that @import another dependency', async () => { - expect(await getColor('.css-proxy-dep')).toBe('purple') -}) - -test('@import scss dependency that has @import with a css extension pointing to another dependency', async () => { - expect(await getColor('.scss-proxy-dep')).toBe('purple') -}) - -sassOtherTests() - -test('async chunk', async () => { - const el = await page.$('.async') - expect(await getColor(el)).toBe('teal') - - if (isBuild) { - // assert that the css is extracted into its own file instead of in the - // main css file - expect(findAssetFile(/index-[-\w]{8}\.css$/)).not.toMatch('teal') - expect(findAssetFile(/async-[-\w]{8}\.css$/)).toMatch('.async{color:teal}') - } else { - // test hmr - editFile('async.css', (code) => code.replace('color: teal', 'color: blue')) - await untilUpdated(() => getColor(el), 'blue') - } -}) - -test('treeshaken async chunk', async () => { - if (isBuild) { - // should be absent in prod - expect( - await page.evaluate(() => { - return document.querySelector('.async-treeshaken') - }), - ).toBeNull() - // assert that the css is not present anywhere - expect(findAssetFile(/\.css$/)).not.toMatch('plum') - expect(findAssetFile(/index-[-\w]+\.js$/)).not.toMatch('.async{color:plum}') - expect(findAssetFile(/async-[-\w]+\.js$/)).not.toMatch('.async{color:plum}') - // should have no chunk! - expect(findAssetFile(/async-treeshaken/)).toBe('') - } else { - // should be present in dev - const el = await page.$('.async-treeshaken') - editFile('async-treeshaken.css', (code) => - code.replace('color: plum', 'color: blue'), - ) - await untilUpdated(() => getColor(el), 'blue') - } -}) - -test('PostCSS dir-dependency', async () => { - const el1 = await page.$('.dir-dep') - const el2 = await page.$('.dir-dep-2') - const el3 = await page.$('.dir-dep-3') - - expect(await getColor(el1)).toBe('grey') - expect(await getColor(el2)).toBe('grey') - expect(await getColor(el3)).toBe('grey') - - if (!isBuild) { - editFile('glob-dep/foo.css', (code) => - code.replace('color: grey', 'color: blue'), - ) - await untilUpdated(() => getColor(el1), 'blue') - expect(await getColor(el2)).toBe('grey') - - editFile('glob-dep/bar.css', (code) => - code.replace('color: grey', 'color: red'), - ) - await untilUpdated(() => getColor(el2), 'red') - expect(await getColor(el1)).toBe('blue') - - editFile('glob-dep/nested (dir)/baz.css', (code) => - code.replace('color: grey', 'color: green'), - ) - await untilUpdated(() => getColor(el3), 'green') - expect(await getColor(el1)).toBe('blue') - expect(await getColor(el2)).toBe('red') - - // test add/remove - removeFile('glob-dep/bar.css') - await untilUpdated(() => getColor(el2), 'black') - } -}) - -test('import dependency includes css import', async () => { - expect(await getColor('.css-js-dep')).toBe('green') - expect(await getColor('.css-js-dep-module')).toBe('green') -}) - -test('URL separation', async () => { - const urlSeparated = await page.$('.url-separated') - const baseUrl = 'url(images/dog.webp)' - const cases = new Array(5) - .fill('') - .flatMap((_, i) => - [',', ' ,', ', ', ' , '].map( - (sep) => - `background-image:${new Array(i + 1).fill(baseUrl).join(sep)};`, - ), - ) - - // Insert the base case - cases.unshift('background-image:url(images/cat.webp),url(images/dog.webp)') - - for (const [c, i] of cases.map((c, i) => [c, i]) as [string, number][]) { - // Replace the previous case - if (i > 0) editFile('imported.css', (code) => code.replace(cases[i - 1], c)) - - expect(await getBg(urlSeparated)).toMatch( - /^url\(.+\)(?:\s*,\s*url\(.+\))*$/, - ) - } -}) - -test('inlined', async () => { - // should not insert css - expect(await getColor('.inlined')).toBe('black') -}) - -test('inlined-code', async () => { - const code = await page.textContent('.inlined-code') - // should resolve assets - expect(code).toContain('background:') - expect(code).not.toContain('__VITE_ASSET__') - - if (isBuild) { - expect(code.trim()).not.toContain('\n') // check minified - } -}) - -test('minify css', async () => { - if (!isBuild) { - return - } - - // should keep the rgba() syntax - const cssFile = findAssetFile(/index-[-\w]+\.css$/) - expect(cssFile).toMatch('rgba(') - expect(cssFile).not.toMatch('#ffff00b3') -}) - -test('?url', async () => { - expect(await getColor('.url-imported-css')).toBe('yellow') -}) - -test('?raw', async () => { - const rawImportCss = await page.$('.raw-imported-css') - - expect(await rawImportCss.textContent()).toBe( - readFileSync(require.resolve('../raw-imported.css'), 'utf-8'), - ) - - if (!isBuild) { - editFile('raw-imported.css', (code) => - code.replace('color: yellow', 'color: blue'), - ) - await untilUpdated( - () => page.textContent('.raw-imported-css'), - 'color: blue', - ) - } -}) - -test('import css in less', async () => { - expect(await getColor('.css-in-less')).toBe('yellow') - expect(await getColor('.css-in-less-2')).toBe('blue') -}) - -test("relative path rewritten in Less's data-uri", async () => { - // relative path passed to Less's data-uri is rewritten to absolute, - // the Less inlines it - expect(await getBg('.form-box-data-uri')).toMatch( - /^url\("data:image\/svg\+xml,%3Csvg/, - ) -}) - -test('PostCSS source.input.from includes query', async () => { - const code = await page.textContent('.postcss-source-input') - // should resolve assets - expect(code).toContain('/postcss-source-input.css?inline&query=foo') -}) - -test('aliased css has content', async () => { - expect(await getColor('.aliased')).toBe('blue') - // skipped: currently not supported see #8936 - // expect(await page.textContent('.aliased-content')).toMatch('.aliased') - expect(await getColor('.aliased-module')).toBe('blue') -}) - -test('resolve imports field in CSS', async () => { - expect(await getColor('.imports-field')).toBe('red') -}) - -test.runIf(isBuild)('warning can be suppressed by esbuild.logOverride', () => { - serverLogs.forEach((log) => { - // no warning from esbuild css minifier - expect(log).not.toMatch('unsupported-css-property') - }) -}) - -test('sugarss', async () => { - const imported = await page.$('.sugarss') - const atImport = await page.$('.sugarss-at-import') - const atImportAlias = await page.$('.sugarss-at-import-alias') - - expect(await getColor(imported)).toBe('blue') - expect(await getColor(atImport)).toBe('darkslateblue') - expect(await getBg(atImport)).toMatch(isBuild ? /base64/ : '/nested/icon.png') - expect(await getColor(atImportAlias)).toBe('darkslateblue') - expect(await getBg(atImportAlias)).toMatch( - isBuild ? /base64/ : '/nested/icon.png', - ) - - if (isBuild) return - - editFile('sugarss.sss', (code) => code.replace('color: blue', 'color: coral')) - await untilUpdated(() => getColor(imported), 'coral') - - editFile('nested/nested.sss', (code) => - code.replace('color: darkslateblue', 'color: blue'), - ) - await untilUpdated(() => getColor(atImport), 'blue') -}) - -// NOTE: the match inline snapshot should generate by build mode -test('async css order', async () => { - await withRetry(async () => { - expect(await getColor('.async-green')).toMatchInlineSnapshot('"green"') - expect(await getColor('.async-blue')).toMatchInlineSnapshot('"blue"') - }) -}) - -test('async css order with css modules', async () => { - await withRetry(async () => { - expect(await getColor('.modules-pink')).toMatchInlineSnapshot('"pink"') - }) -}) - -test('@import scss', async () => { - expect(await getColor('.at-import-scss')).toBe('red') -}) - -test.runIf(isBuild)('manual chunk path', async () => { - // assert that the manual-chunk css is output in the directory specified in manualChunk (#12072) - expect( - findAssetFile(/dir\/dir2\/manual-chunk-[-\w]{8}\.css$/), - ).not.toBeUndefined() -}) - -test.runIf(isBuild)('CSS modules should be treeshaken if not used', () => { - const css = findAssetFile(/\.css$/, undefined, undefined, true) - expect(css).not.toContain('treeshake-module-b') -}) - -test.runIf(isBuild)('Scoped CSS via cssScopeTo should be treeshaken', () => { - const css = findAssetFile(/\.css$/, undefined, undefined, true) - expect(css).not.toContain('treeshake-module-b') - expect(css).not.toContain('treeshake-module-c') -}) - -test.runIf(isBuild)( - 'Scoped CSS via cssScopeTo should be bundled separately', - () => { - const scopedIndexCss = findAssetFile(/treeshakeScoped-[-\w]{8}\.css$/) - expect(scopedIndexCss).toContain('treeshake-scoped-barrel-a') - expect(scopedIndexCss).not.toContain('treeshake-scoped-barrel-b') - const scopedAnotherCss = findAssetFile( - /treeshakeScopedAnother-[-\w]{8}\.css$/, - ) - expect(scopedAnotherCss).toContain('treeshake-scoped-barrel-b') - expect(scopedAnotherCss).not.toContain('treeshake-scoped-barrel-a') - }, -) +tests(false) diff --git a/playground/css/__tests__/lightningcss/lightningcss.spec.ts b/playground/css/__tests__/lightningcss/lightningcss.spec.ts new file mode 100644 index 00000000000000..9b9b35db56e605 --- /dev/null +++ b/playground/css/__tests__/lightningcss/lightningcss.spec.ts @@ -0,0 +1,4 @@ +// NOTE: a separate directory from `playground/css` is created by playground/vitestGlobalSetup.ts +import { tests } from '../tests' + +tests(true) diff --git a/playground/css/__tests__/tests.ts b/playground/css/__tests__/tests.ts new file mode 100644 index 00000000000000..4f399dc6ca7045 --- /dev/null +++ b/playground/css/__tests__/tests.ts @@ -0,0 +1,552 @@ +import { readFileSync } from 'node:fs' +import { expect, test } from 'vitest' +import { sassModuleTests, sassOtherTests, sassTest } from './sass-tests' +import { + editFile, + findAssetFile, + getBg, + getColor, + isBuild, + page, + removeFile, + serverLogs, + untilUpdated, + viteTestUrl, + withRetry, +} from '~utils' + +export const tests = (isLightningCSS: boolean) => { + // note: tests should retrieve the element at the beginning of test and reuse it + // in later assertions to ensure CSS HMR doesn't reload the page + test('imported css', async () => { + const glob = await page.textContent('.imported-css-glob') + expect(glob).toContain('.dir-import') + const globEager = await page.textContent('.imported-css-globEager') + expect(globEager).toContain('.dir-import') + }) + + test('linked css', async () => { + const linked = await page.$('.linked') + const atImport = await page.$('.linked-at-import') + + expect(await getColor(linked)).toBe('blue') + expect(await getColor(atImport)).toBe('red') + + if (isBuild) return + + editFile('linked.css', (code) => code.replace('color: blue', 'color: red')) + await untilUpdated(() => getColor(linked), 'red') + + editFile('linked-at-import.css', (code) => + code.replace('color: red', 'color: blue'), + ) + await untilUpdated(() => getColor(atImport), 'blue') + }) + + test('css import from js', async () => { + const imported = await page.$('.imported') + const atImport = await page.$('.imported-at-import') + + expect(await getColor(imported)).toBe('green') + expect(await getColor(atImport)).toBe('purple') + + if (isBuild) return + + editFile('imported.css', (code) => + code.replace('color: green', 'color: red'), + ) + await untilUpdated(() => getColor(imported), 'red') + + editFile('imported-at-import.css', (code) => + code.replace('color: purple', 'color: blue'), + ) + await untilUpdated(() => getColor(atImport), 'blue') + }) + + test('css import asset with space', async () => { + const importedWithSpace = await page.$('.import-with-space') + + expect(await getBg(importedWithSpace)).toMatch(/.*\/ok.*\.png/) + }) + + test('postcss config', async () => { + const imported = await page.$('.postcss .nesting') + expect(await getColor(imported)).toBe('pink') + + if (isBuild) return + + editFile('imported.css', (code) => + code.replace('color: pink', 'color: red'), + ) + await untilUpdated(() => getColor(imported), 'red') + }) + + if (!isLightningCSS) { + test('postcss plugin that injects url()', async () => { + const imported = await page.$('.postcss-inject-url') + // alias should be resolved + expect(await getBg(imported)).toMatch( + /localhost(?::\d+)?\/(?:assets\/)?ok.*\.png/, + ) + }) + } + + sassTest() + + test('less', async () => { + const imported = await page.$('.less') + const atImport = await page.$('.less-at-import') + const atImportAlias = await page.$('.less-at-import-alias') + const atImportUrlOmmer = await page.$('.less-at-import-url-ommer') + const urlStartsWithVariable = await page.$('.less-url-starts-with-variable') + + expect(await getColor(imported)).toBe('blue') + expect(await getColor(atImport)).toBe('darkslateblue') + expect(await getBg(atImport)).toMatch( + isBuild ? /base64/ : '/nested/icon.png', + ) + expect(await getColor(atImportAlias)).toBe('darkslateblue') + expect(await getBg(atImportAlias)).toMatch( + isBuild ? /base64/ : '/nested/icon.png', + ) + expect(await getColor(atImportUrlOmmer)).toBe('darkorange') + expect(await getBg(urlStartsWithVariable)).toMatch( + isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, + ) + + if (isBuild) return + + editFile('less.less', (code) => code.replace('@color: blue', '@color: red')) + await untilUpdated(() => getColor(imported), 'red') + + editFile('nested/nested.less', (code) => + code.replace('color: darkslateblue', 'color: blue'), + ) + await untilUpdated(() => getColor(atImport), 'blue') + }) + + test('less-plugin', async () => { + const body = await page.$('.less-js-plugin') + expect(await getBg(body)).toBe( + 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGD4/x8AAwIB/8myre4AAAAASUVORK5CYII=")', + ) + }) + + test('stylus', async () => { + const imported = await page.$('.stylus') + const additionalData = await page.$('.stylus-additional-data') + const relativeImport = await page.$('.stylus-import') + const relativeImportAlias = await page.$('.stylus-import-alias') + const optionsRelativeImport = await page.$( + '.stylus-options-relative-import', + ) + const optionsAbsoluteImport = await page.$( + '.stylus-options-absolute-import', + ) + const optionsDefineVar = await page.$('.stylus-options-define-var') + const optionsDefineFunc = await page.$('.stylus-options-define-func') + + expect(await getColor(imported)).toBe('blue') + expect(await getColor(additionalData)).toBe('orange') + expect(await getColor(relativeImport)).toBe('darkslateblue') + expect(await getColor(relativeImportAlias)).toBe('darkslateblue') + expect(await getBg(relativeImportAlias)).toMatch( + isBuild ? /base64/ : '/nested/icon.png', + ) + expect(await getColor(optionsRelativeImport)).toBe('green') + expect(await getColor(optionsAbsoluteImport)).toBe('red') + expect(await getColor(optionsDefineVar)).toBe('rgb(51, 197, 255)') + expect(await getColor(optionsDefineFunc)).toBe('rgb(255, 0, 98)') + + if (isBuild) return + + editFile('stylus.styl', (code) => + code.replace('$color ?= blue', '$color ?= red'), + ) + await untilUpdated(() => getColor(imported), 'red') + + editFile('nested/nested.styl', (code) => + code.replace('color: darkslateblue', 'color: blue'), + ) + await untilUpdated(() => getColor(relativeImport), 'blue') + }) + + test('css modules', async () => { + const imported = await page.$('.modules') + expect(await getColor(imported)).toBe('turquoise') + + // check if the generated CSS module class name is indeed using the + // format specified in vite.config.js + expect(await imported.getAttribute('class')).toMatch( + /.mod-module__apply-color___[\w-]{5}/, + ) + + if (isBuild) return + + editFile('mod.module.css', (code) => + code.replace('color: turquoise', 'color: red'), + ) + await untilUpdated(() => getColor(imported), 'red') + }) + + test('css modules composes/from path resolving', async () => { + const imported = await page.$('.path-resolved-modules-css') + expect(await getColor(imported)).toBe('turquoise') + + // check if the generated CSS module class name is indeed using the + // format specified in vite.config.js + expect(await imported.getAttribute('class')).toMatch( + /.composed-module__apply-color___[\w-]{5}/, + ) + + expect(await imported.getAttribute('class')).toMatch( + /.composes-path-resolving-module__path-resolving-css___[\w-]{5}/, + ) + + // @todo HMR is not working on this situation. + // editFile('composed.module.css', (code) => + // code.replace('color: turquoise', 'color: red') + // ) + // await untilUpdated(() => getColor(imported), 'red') + }) + + sassModuleTests() + + test('less modules composes/from path resolving', async () => { + const imported = await page.$('.path-resolved-modules-less') + expect(await getColor(imported)).toBe('blue') + + // check if the generated CSS module class name is indeed using the + // format specified in vite.config.js + expect(await imported.getAttribute('class')).toMatch( + /.composed-module__apply-color___[\w-]{5}/, + ) + + expect(await imported.getAttribute('class')).toMatch( + /.composes-path-resolving-module__path-resolving-less___[\w-]{5}/, + ) + + // @todo HMR is not working on this situation. + // editFile('composed.module.scss', (code) => + // code.replace('color: orangered', 'color: red') + // ) + // await untilUpdated(() => getColor(imported), 'red') + }) + + test('inline css modules', async () => { + const css = await page.textContent('.modules-inline') + expect(css).toMatch(/\.inline-module__apply-color-inline___[\w-]{5}/) + }) + + test.runIf(isBuild)('@charset hoist', async () => { + serverLogs.forEach((log) => { + // no warning from esbuild css minifier + expect(log).not.toMatch('"@charset" must be the first rule in the file') + }) + }) + + test('layers', async () => { + expect(await getColor('.layers-blue')).toMatch('blue') + expect(await getColor('.layers-green')).toMatch('green') + }) + + test('@import dependency w/ style entry', async () => { + expect(await getColor('.css-dep')).toBe('purple') + }) + + test('@import dependency w/ style export mapping', async () => { + expect(await getColor('.css-dep-exports')).toBe('purple') + }) + + test('@import dependency that @import another dependency', async () => { + expect(await getColor('.css-proxy-dep')).toBe('purple') + }) + + test('@import scss dependency that has @import with a css extension pointing to another dependency', async () => { + expect(await getColor('.scss-proxy-dep')).toBe('purple') + }) + + sassOtherTests() + + test('async chunk', async () => { + const el = await page.$('.async') + expect(await getColor(el)).toBe('teal') + + if (isBuild) { + // assert that the css is extracted into its own file instead of in the + // main css file + expect(findAssetFile(/index-[-\w]{8}\.css$/)).not.toMatch('teal') + expect(findAssetFile(/async-[-\w]{8}\.css$/)).toMatch( + '.async{color:teal}', + ) + } else { + // test hmr + editFile('async.css', (code) => + code.replace('color: teal', 'color: blue'), + ) + await untilUpdated(() => getColor(el), 'blue') + } + }) + + test('treeshaken async chunk', async () => { + if (isBuild) { + // should be absent in prod + expect( + await page.evaluate(() => { + return document.querySelector('.async-treeshaken') + }), + ).toBeNull() + // assert that the css is not present anywhere + expect(findAssetFile(/\.css$/)).not.toMatch('plum') + expect(findAssetFile(/index-[-\w]+\.js$/)).not.toMatch( + '.async{color:plum}', + ) + expect(findAssetFile(/async-[-\w]+\.js$/)).not.toMatch( + '.async{color:plum}', + ) + // should have no chunk! + expect(findAssetFile(/async-treeshaken/)).toBe('') + } else { + // should be present in dev + const el = await page.$('.async-treeshaken') + editFile('async-treeshaken.css', (code) => + code.replace('color: plum', 'color: blue'), + ) + await untilUpdated(() => getColor(el), 'blue') + } + }) + + test('PostCSS dir-dependency', async () => { + const el1 = await page.$('.dir-dep') + const el2 = await page.$('.dir-dep-2') + const el3 = await page.$('.dir-dep-3') + + expect(await getColor(el1)).toBe('grey') + expect(await getColor(el2)).toBe('grey') + expect(await getColor(el3)).toBe('grey') + + // NOTE: lightningcss does not support registering dependencies in plugins + if (!isBuild && !isLightningCSS) { + editFile('glob-dep/foo.css', (code) => + code.replace('color: grey', 'color: blue'), + ) + await untilUpdated(() => getColor(el1), 'blue') + expect(await getColor(el2)).toBe('grey') + + editFile('glob-dep/bar.css', (code) => + code.replace('color: grey', 'color: red'), + ) + await untilUpdated(() => getColor(el2), 'red') + expect(await getColor(el1)).toBe('blue') + + editFile('glob-dep/nested (dir)/baz.css', (code) => + code.replace('color: grey', 'color: green'), + ) + await untilUpdated(() => getColor(el3), 'green') + expect(await getColor(el1)).toBe('blue') + expect(await getColor(el2)).toBe('red') + + // test add/remove + removeFile('glob-dep/bar.css') + await untilUpdated(() => getColor(el2), 'black') + } + }) + + test('import dependency includes css import', async () => { + expect(await getColor('.css-js-dep')).toBe('green') + expect(await getColor('.css-js-dep-module')).toBe('green') + }) + + test('URL separation', async () => { + const urlSeparated = await page.$('.url-separated') + const baseUrl = 'url(images/dog.webp)' + const cases = new Array(5) + .fill('') + .flatMap((_, i) => + [',', ' ,', ', ', ' , '].map( + (sep) => + `background-image:${new Array(i + 1).fill(baseUrl).join(sep)};`, + ), + ) + + // Insert the base case + cases.unshift('background-image:url(images/cat.webp),url(images/dog.webp)') + + for (const [c, i] of cases.map((c, i) => [c, i]) as [string, number][]) { + // Replace the previous case + if (i > 0) + editFile('imported.css', (code) => code.replace(cases[i - 1], c)) + + expect(await getBg(urlSeparated)).toMatch( + /^url\(.+\)(?:\s*,\s*url\(.+\))*$/, + ) + } + }) + + test('inlined', async () => { + // should not insert css + expect(await getColor('.inlined')).toBe('black') + }) + + test('inlined-code', async () => { + const code = await page.textContent('.inlined-code') + // should resolve assets + expect(code).toContain('background:') + expect(code).not.toContain('__VITE_ASSET__') + + if (isBuild) { + expect(code.trim()).not.toContain('\n') // check minified + } + }) + + test('minify css', async () => { + if (!isBuild) { + return + } + + // should keep the rgba() syntax + const cssFile = findAssetFile(/index-[-\w]+\.css$/) + expect(cssFile).toMatch('rgba(') + expect(cssFile).not.toMatch('#ffff00b3') + }) + + test('?url', async () => { + expect(await getColor('.url-imported-css')).toBe('yellow') + }) + + test('?raw', async () => { + const rawImportCss = await page.$('.raw-imported-css') + + expect(await rawImportCss.textContent()).toBe( + readFileSync(require.resolve('../raw-imported.css'), 'utf-8'), + ) + + if (!isBuild) { + editFile('raw-imported.css', (code) => + code.replace('color: yellow', 'color: blue'), + ) + await untilUpdated( + () => page.textContent('.raw-imported-css'), + 'color: blue', + ) + } + }) + + test('import css in less', async () => { + expect(await getColor('.css-in-less')).toBe('yellow') + expect(await getColor('.css-in-less-2')).toBe('blue') + }) + + test("relative path rewritten in Less's data-uri", async () => { + // relative path passed to Less's data-uri is rewritten to absolute, + // the Less inlines it + expect(await getBg('.form-box-data-uri')).toMatch( + /^url\("data:image\/svg\+xml,%3Csvg/, + ) + }) + + test('PostCSS source.input.from includes query', async () => { + const code = await page.textContent('.postcss-source-input') + // should resolve assets + expect(code).toContain('/postcss-source-input.css?inline&query=foo') + }) + + test('aliased css has content', async () => { + expect(await getColor('.aliased')).toBe('blue') + // skipped: currently not supported see #8936 + // expect(await page.textContent('.aliased-content')).toMatch('.aliased') + expect(await getColor('.aliased-module')).toBe('blue') + }) + + test('resolve imports field in CSS', async () => { + expect(await getColor('.imports-field')).toBe('red') + }) + + test.runIf(isBuild)( + 'warning can be suppressed by esbuild.logOverride', + () => { + serverLogs.forEach((log) => { + // no warning from esbuild css minifier + expect(log).not.toMatch('unsupported-css-property') + }) + }, + ) + + test('sugarss', async () => { + const imported = await page.$('.sugarss') + const atImport = await page.$('.sugarss-at-import') + const atImportAlias = await page.$('.sugarss-at-import-alias') + + expect(await getColor(imported)).toBe('blue') + expect(await getColor(atImport)).toBe('darkslateblue') + expect(await getBg(atImport)).toMatch( + isBuild ? /base64/ : '/nested/icon.png', + ) + expect(await getColor(atImportAlias)).toBe('darkslateblue') + expect(await getBg(atImportAlias)).toMatch( + isBuild ? /base64/ : '/nested/icon.png', + ) + + if (isBuild) return + + editFile('sugarss.sss', (code) => + code.replace('color: blue', 'color: coral'), + ) + await untilUpdated(() => getColor(imported), 'coral') + + editFile('nested/nested.sss', (code) => + code.replace('color: darkslateblue', 'color: blue'), + ) + await untilUpdated(() => getColor(atImport), 'blue') + }) + + // NOTE: the match inline snapshot should generate by build mode + test('async css order', async () => { + await withRetry(async () => { + expect(await getColor('.async-green')).toMatchInlineSnapshot('"green"') + expect(await getColor('.async-blue')).toMatchInlineSnapshot('"blue"') + }) + }) + + test('async css order with css modules', async () => { + await withRetry(async () => { + expect(await getColor('.modules-pink')).toMatchInlineSnapshot('"pink"') + }) + }) + + test('@import scss', async () => { + expect(await getColor('.at-import-scss')).toBe('red') + }) + + test.runIf(isBuild)('manual chunk path', async () => { + // assert that the manual-chunk css is output in the directory specified in manualChunk (#12072) + expect( + findAssetFile(/dir\/dir2\/manual-chunk-[-\w]{8}\.css$/), + ).not.toBeUndefined() + }) + + test.runIf(isBuild)('CSS modules should be treeshaken if not used', () => { + const css = findAssetFile(/\.css$/, undefined, undefined, true) + expect(css).not.toContain('treeshake-module-b') + }) + + test.runIf(isBuild)('Scoped CSS via cssScopeTo should be treeshaken', () => { + const css = findAssetFile(/\.css$/, undefined, undefined, true) + expect(css).not.toContain('treeshake-module-b') + expect(css).not.toContain('treeshake-module-c') + }) + + test.runIf(isBuild)( + 'Scoped CSS via cssScopeTo should be bundled separately', + () => { + const scopedIndexCss = findAssetFile(/treeshakeScoped-[-\w]{8}\.css$/) + expect(scopedIndexCss).toContain('treeshake-scoped-barrel-a') + expect(scopedIndexCss).not.toContain('treeshake-scoped-barrel-b') + const scopedAnotherCss = findAssetFile( + /treeshakeScopedAnother-[-\w]{8}\.css$/, + ) + expect(scopedAnotherCss).toContain('treeshake-scoped-barrel-b') + expect(scopedAnotherCss).not.toContain('treeshake-scoped-barrel-a') + }, + ) +} 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 b22da1f5983a3c..9578a7558a7055 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.2", + "lightningcss": "^1.29.2", "postcss-nested": "^7.0.2", "sass": "^1.85.1", "stylus": "^0.64.0", diff --git a/playground/css/vite.config-lightningcss.js b/playground/css/vite.config-lightningcss.js new file mode 100644 index 00000000000000..87c1bd2345478f --- /dev/null +++ b/playground/css/vite.config-lightningcss.js @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import { composeVisitors } from 'lightningcss' +import baseConfig from './vite.config.js' +import { + nestedLikePlugin, + testDirDep, + testSourceInput, +} from './lightningcss-plugins' + +export default defineConfig({ + ...baseConfig, + css: { + ...baseConfig.css, + transformer: 'lightningcss', + lightningcss: { + cssModules: { + pattern: '[name]__[local]___[hash]', + }, + visitor: composeVisitors([ + nestedLikePlugin(), + testDirDep(), + testSourceInput(), + ]), + }, + }, + cacheDir: 'node_modules/.vite-no-css-minify', +}) diff --git a/playground/vitestGlobalSetup.ts b/playground/vitestGlobalSetup.ts index 3ae5d53fcdda9b..d50e005edd5ca3 100644 --- a/playground/vitestGlobalSetup.ts +++ b/playground/vitestGlobalSetup.ts @@ -43,7 +43,7 @@ export async function setup({ provide }: TestProject): Promise { }) // also setup dedicated copy for "variant" tests for (const [original, variants] of [ - ['css', ['sass-legacy', 'sass-modern']], + ['css', ['sass-legacy', 'sass-modern', 'lightningcss']], ['css-sourcemap', ['sass-legacy', 'sass-modern']], ] as const) { for (const variant of variants) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c67f60fbb858cb..6514f546223f71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,6 +587,9 @@ importers: less: specifier: ^4.2.2 version: 4.2.2 + lightningcss: + specifier: ^1.29.2 + version: 1.29.2 postcss-nested: specifier: ^7.0.2 version: 7.0.2(postcss@8.5.3) @@ -637,6 +640,9 @@ importers: less: specifier: ^4.2.2 version: 4.2.2 + lightningcss: + specifier: ^1.29.2 + version: 1.29.2 magic-string: specifier: ^0.30.17 version: 0.30.17