diff --git a/src/compiler/build/build-hmr.ts b/src/compiler/build/build-hmr.ts index 50f0d6002b5..85f343d9605 100644 --- a/src/compiler/build/build-hmr.ts +++ b/src/compiler/build/build-hmr.ts @@ -50,6 +50,7 @@ export const generateHmr = (config: d.Config, compilerCtx: d.CompilerCtx, buildC styleId: getScopeId(s.styleTag, s.styleMode), styleTag: s.styleTag, styleText: s.styleText, + styleSourceMap: s.styleSourceMap, } as d.HmrStyleUpdate; }), (s) => s.styleId, diff --git a/src/compiler/bundle/app-data-plugin.ts b/src/compiler/bundle/app-data-plugin.ts index 5ec4dd1779f..f39e835cccf 100644 --- a/src/compiler/bundle/app-data-plugin.ts +++ b/src/compiler/bundle/app-data-plugin.ts @@ -126,7 +126,7 @@ export const appDataPlugin = ( }); buildCtx.diagnostics.push(...loadTypeScriptDiagnostics(results.diagnostics)); - if (config.sourceMap) { + if (config.sourceMapJs) { // generate the sourcemap for global script const codeMs = new MagicString(code); const codeMap = codeMs.generateMap({ diff --git a/src/compiler/bundle/bundle-output.ts b/src/compiler/bundle/bundle-output.ts index 7686975f0c8..026548b2929 100644 --- a/src/compiler/bundle/bundle-output.ts +++ b/src/compiler/bundle/bundle-output.ts @@ -157,7 +157,7 @@ export const getRollupOptions = ( resolveIdWithTypeScript(config, compilerCtx), rollupCommonjsPlugin({ include: /node_modules/, - sourceMap: config.sourceMap, + sourceMap: config.sourceMapJs, transformMixedEsModules: false, ...config.commonjs, }), diff --git a/src/compiler/bundle/ext-transforms-plugin.ts b/src/compiler/bundle/ext-transforms-plugin.ts index f48ff60133f..3139175fe91 100644 --- a/src/compiler/bundle/ext-transforms-plugin.ts +++ b/src/compiler/bundle/ext-transforms-plugin.ts @@ -19,12 +19,33 @@ import { parseImportPath } from '../transformers/stencil-import-path'; * } * ``` * - * Whenever one of the files change, we can propagate a correct concatenated - * version of all styles to the browser by setting `buildCtx.stylesUpdated`. + * Whenever one of the files changes, we can propagate the complete ordered + * style list and its matching source maps to the browser via `buildCtx.stylesUpdated`. */ -type ComponentStyleMap = Map; +type ComponentStyle = { + styleText: string; + styleSourceMap?: string; +}; +type ComponentStyleMap = Map; const allCmpStyles = new Map(); +const getComponentStyleEntries = ( + cmpStyles: ComponentStyleMap | undefined, + externalStyles: d.ExternalStyleCompiler[] | undefined, + fallbackStyle: ComponentStyle, +): Array => { + if (!cmpStyles) { + return [fallbackStyle]; + } + if (!externalStyles) { + return [...cmpStyles.values()]; + } + + return externalStyles.map((externalStyle) => { + return cmpStyles.get(externalStyle.originalComponentPath) || cmpStyles.get(externalStyle.absolutePath); + }); +}; + /** * A Rollup plugin which bundles up some transformation of CSS imports as well * as writing some files to disk for the `DIST_COLLECTION` output target. @@ -128,7 +149,8 @@ export const extTransformsPlugin = ( addTagTransformers: !!buildCtx.config.extras.additionalTagTransformers, encapsulation: data.encapsulation, mode: data.mode, - sourceMap: config.sourceMap, + sourceMap: config.sourceMapCss, + map: pluginTransforms.map, minify: config.minifyCss, autoprefixer: config.autoprefixCss, docs: config.buildDocs, @@ -138,7 +160,10 @@ export const extTransformsPlugin = ( * persist component styles for transformed stylesheet */ if (cmpStyles) { - cmpStyles.set(filePath, cssTransformResults.styleText); + cmpStyles.set(filePath, { + styleText: cssTransformResults.styleText, + styleSourceMap: cssTransformResults.styleSourceMap, + }); } // Set style docs @@ -170,35 +195,27 @@ export const extTransformsPlugin = ( if (!hasUpdatedStyle && data.tag && data.mode) { const externalStyles = cmp?.styles?.[0]?.externalStyles; - /** - * if component has external styles, use a list to keep the order to which - * styles are applied. - */ - const styleText = cmpStyles - ? externalStyles - ? /** - * attempt to find the original `filePath` key through `originalComponentPath` - * and `absolutePath` as path can differ based on how Stencil is installed - * e.g. through `npm link` or `npm install` - */ - externalStyles - .map((es) => cmpStyles.get(es.originalComponentPath) || cmpStyles.get(es.absolutePath)) - .join('\n') - : /** - * if `externalStyles` is not defined, then created the style text in the - * order of which the styles were compiled. - */ - [...cmpStyles.values()].join('\n') - : /** - * if `cmpStyles` is not defined, then use the style text from the transform - * as it is not connected to a component. - */ - cssTransformResults.styleText; + const styleEntries = getComponentStyleEntries(cmpStyles, externalStyles, { + styleText: cssTransformResults.styleText, + styleSourceMap: cssTransformResults.styleSourceMap, + }); + const separateStyles = config.sourceMapCss && styleEntries.length > 1; + const styleText = separateStyles + ? styleEntries.map((entry) => entry?.styleText || '') + : styleEntries.map((entry) => entry?.styleText || '').join('\n'); + const styleSourceMap = !config.sourceMapCss + ? undefined + : styleEntries.length === 1 + ? styleEntries[0]?.styleSourceMap + : styleEntries.every((entry) => entry?.styleSourceMap != null) + ? styleEntries.map((entry) => entry.styleSourceMap!) + : undefined; buildCtx.stylesUpdated.push({ styleTag: data.tag, styleMode: data.mode, styleText, + styleSourceMap, }); } diff --git a/src/compiler/bundle/test/ext-transforms-plugin.spec.ts b/src/compiler/bundle/test/ext-transforms-plugin.spec.ts index 984ee2d7a80..a0ef1c8704d 100644 --- a/src/compiler/bundle/test/ext-transforms-plugin.spec.ts +++ b/src/compiler/bundle/test/ext-transforms-plugin.spec.ts @@ -71,6 +71,7 @@ describe('extTransformsPlugin', () => { writeFileSpy, transformCssToEsmSpy, cssText, + compilerComponentMeta, }; } @@ -81,10 +82,10 @@ describe('extTransformsPlugin', () => { it('should return early if no data can be gleaned from the id', async () => { const { plugin } = setup(); - // @ts-ignore we're testing something which shouldn't normally happen, + // @ts-expect-error we're testing something which shouldn't normally happen, // but might if an argument of the wrong type were passed as `id` const parseSpy = jest.spyOn(importPathLib, 'parseImportPath').mockReturnValue({ data: null }); - // @ts-ignore the Rollup plugins expect to be called in a Rollup context + // @ts-expect-error the Rollup plugins expect to be called in a Rollup context expect(await plugin.transform('asdf', 'foo.css')).toBe(null); parseSpy.mockRestore(); }); @@ -101,5 +102,75 @@ describe('extTransformsPlugin', () => { expect(css).toBe(':host { text: pink; }'); }); + + it('should pass the CSS source-map setting and plugin map to the CSS transform', async () => { + const { plugin, config, transformCssToEsmSpy } = setup(); + const map = { + version: 3, + file: '/some/stubbed/path/foo.css', + names: [] as string[], + mappings: 'AAAA', + sources: ['/some/stubbed/path/foo.scss'], + sourcesContent: [':host { text: pink; }'], + }; + + config.sourceMap = { css: true, js: false }; + config.sourceMapCss = true; + config.plugins = [ + { + transform: () => ({ + code: ':host { text: pink; }', + map: JSON.stringify(map), + }), + }, + ]; + + // @ts-expect-error the Rollup plugins expect to be called in a Rollup context + await plugin.transform('asdf', '/some/stubbed/path/foo.css?tag=my-component'); + + expect(transformCssToEsmSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourceMap: true, + map, + }), + ); + }); + + it('should keep source-mapped external styles in styleUrl order', async () => { + const { plugin, config, buildCtx, compilerComponentMeta, transformCssToEsmSpy } = setup(); + const firstStylePath = '/some/stubbed/path/first.css'; + const secondStylePath = '/some/stubbed/path/second.css'; + compilerComponentMeta.styles = [ + { + modeName: 'default', + externalStyles: [ + { absolutePath: firstStylePath, originalComponentPath: firstStylePath }, + { absolutePath: secondStylePath, originalComponentPath: secondStylePath }, + ], + }, + ] as any; + config.sourceMap = { css: true, js: false }; + config.sourceMapCss = true; + transformCssToEsmSpy.mockImplementation(async ({ file }) => ({ + styleText: file === firstStylePath ? 'FIRST' : 'SECOND', + styleSourceMap: file === firstStylePath ? 'FIRST_MAP' : 'SECOND_MAP', + output: '', + map: null, + diagnostics: [], + imports: [], + defaultVarName: 'foo', + styleDocs: [], + })); + + // Process the second style first to prove HMR updates do not use transform completion order. + // @ts-expect-error the Rollup plugins expect to be called in a Rollup context + await plugin.transform('asdf', `${secondStylePath}?tag=my-component`); + // @ts-expect-error the Rollup plugins expect to be called in a Rollup context + await plugin.transform('asdf', `${firstStylePath}?tag=my-component`); + + const latestUpdate = buildCtx.stylesUpdated[buildCtx.stylesUpdated.length - 1]; + expect(latestUpdate.styleText).toEqual(['FIRST', 'SECOND']); + expect(latestUpdate.styleSourceMap).toEqual(['FIRST_MAP', 'SECOND_MAP']); + }); }); }); diff --git a/src/compiler/config/test/validate-config-sourcemap.spec.ts b/src/compiler/config/test/validate-config-sourcemap.spec.ts index d82b4b47026..78b14a6e5fc 100644 --- a/src/compiler/config/test/validate-config-sourcemap.spec.ts +++ b/src/compiler/config/test/validate-config-sourcemap.spec.ts @@ -59,7 +59,7 @@ describe('stencil config - sourceMap option', () => { const { config: validatedConfig } = validateConfig(userConfig, {}); expect(validatedConfig.sourceMap).toBe(true); - mockTsConfigParser(validatedConfig.sourceMap); + mockTsConfigParser(validatedConfig.sourceMapJs); const testConfig = getLoadConfigForTests({ config: userConfig }); const loadConfigResults = await loadConfig(testConfig); @@ -73,7 +73,7 @@ describe('stencil config - sourceMap option', () => { const { config: validatedConfig } = validateConfig(userConfig, {}); expect(validatedConfig.sourceMap).toBe(false); - mockTsConfigParser(validatedConfig.sourceMap); + mockTsConfigParser(validatedConfig.sourceMapJs); const testConfig = getLoadConfigForTests({ config: userConfig }); const loadConfigResults = await loadConfig(testConfig); @@ -87,7 +87,7 @@ describe('stencil config - sourceMap option', () => { const { config: validatedConfig } = validateConfig(userConfig, {}); expect(validatedConfig.sourceMap).toBe(false); - mockTsConfigParser(validatedConfig.sourceMap); + mockTsConfigParser(validatedConfig.sourceMapJs); const testConfig = getLoadConfigForTests({ config: userConfig }); const loadConfigResults = await loadConfig(testConfig); @@ -101,7 +101,7 @@ describe('stencil config - sourceMap option', () => { const { config: validatedConfig } = validateConfig(userConfig, {}); expect(validatedConfig.sourceMap).toBe(true); - mockTsConfigParser(validatedConfig.sourceMap); + mockTsConfigParser(validatedConfig.sourceMapJs); const testConfig = getLoadConfigForTests({ config: userConfig }); const loadConfigResults = await loadConfig(testConfig); @@ -115,7 +115,7 @@ describe('stencil config - sourceMap option', () => { const { config: validatedConfig } = validateConfig(userConfig, {}); expect(validatedConfig.sourceMap).toBe(true); - mockTsConfigParser(validatedConfig.sourceMap); + mockTsConfigParser(validatedConfig.sourceMapJs); const testConfig = getLoadConfigForTests({ config: userConfig }); const loadConfigResults = await loadConfig(testConfig); @@ -129,7 +129,7 @@ describe('stencil config - sourceMap option', () => { const { config: validatedConfig } = validateConfig(userConfig, {}); expect(validatedConfig.sourceMap).toBe(false); - mockTsConfigParser(validatedConfig.sourceMap); + mockTsConfigParser(validatedConfig.sourceMapJs); const testConfig = getLoadConfigForTests({ config: userConfig }); const loadConfigResults = await loadConfig(testConfig); @@ -137,4 +137,31 @@ describe('stencil config - sourceMap option', () => { expect(sourceMap).toBe(false); expect(inlineSources).toBe(false); }); + + it('enables only CSS source maps when configured with css', () => { + const userConfig: d.UnvalidatedConfig = { sourceMap: { css: true }, devMode: false }; + const { config: validatedConfig } = validateConfig(userConfig, {}); + + expect(validatedConfig.sourceMap).toEqual({ css: true }); + expect(validatedConfig.sourceMapCss).toBe(true); + expect(validatedConfig.sourceMapJs).toBe(false); + }); + + it('enables only JavaScript source maps when configured with js', () => { + const userConfig: d.UnvalidatedConfig = { sourceMap: { js: true }, devMode: false }; + const { config: validatedConfig } = validateConfig(userConfig, {}); + + expect(validatedConfig.sourceMap).toEqual({ js: true }); + expect(validatedConfig.sourceMapCss).toBe(false); + expect(validatedConfig.sourceMapJs).toBe(true); + }); + + it('keeps legacy boolean source maps enabled for JavaScript only', () => { + const userConfig: d.UnvalidatedConfig = { sourceMap: true, devMode: false }; + const { config: validatedConfig } = validateConfig(userConfig, {}); + + expect(validatedConfig.sourceMap).toBe(true); + expect(validatedConfig.sourceMapCss).toBe(false); + expect(validatedConfig.sourceMapJs).toBe(true); + }); }); diff --git a/src/compiler/config/transpile-options.ts b/src/compiler/config/transpile-options.ts index 4b92fdd3676..d5c9935b741 100644 --- a/src/compiler/config/transpile-options.ts +++ b/src/compiler/config/transpile-options.ts @@ -138,6 +138,7 @@ export const getTranspileConfig = (input: TranspileOptions): TranspileConfig => file: compileOpts.file, style: compileOpts.style as any, styleImportData: compileOpts.styleImportData as any, + sourceMapCss: compileOpts.sourceMap !== false, target: compileOpts.target as any, }; diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 38b4abdf1b9..375ee5d5888 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -117,6 +117,7 @@ export const validateConfig = ( } const hashFileNames = config.hashFileNames ?? !devMode; + const sourceMap = validateSourceMap(config.sourceMap, devMode); const validatedConfig: ValidatedConfig = { devServer: {}, // assign `devServer` before spreading `config`, in the event 'devServer' is not a key on `config` @@ -135,8 +136,7 @@ export const validateConfig = ( minifyJs: config.minifyJs ?? !devMode, outputTargets: config.outputTargets ?? [], rollupConfig: validateRollupConfig(config), - sourceMap: - config.sourceMap === true || (devMode && (config.sourceMap === 'dev' || typeof config.sourceMap === 'undefined')), + ...sourceMap, sys: config.sys ?? bootstrapConfig.sys ?? createNodeSys({ logger }), testing: config.testing ?? {}, docs: validateDocs(config, logger), @@ -299,3 +299,20 @@ export const validateConfig = ( diagnostics, }; }; + +const validateSourceMap = (sourceMap: UnvalidatedConfig['sourceMap'], devMode: boolean) => { + if (sourceMap != null && typeof sourceMap === 'object') { + return { + sourceMap, + sourceMapCss: sourceMap.css === true, + sourceMapJs: sourceMap.js === true, + }; + } + + const enabled = sourceMap === true || (devMode && (sourceMap === 'dev' || sourceMap == null)); + return { + sourceMap: enabled, + sourceMapCss: false, + sourceMapJs: enabled, + }; +}; diff --git a/src/compiler/optimize/autoprefixer.ts b/src/compiler/optimize/autoprefixer.ts index e51aa7b2939..1da8346ad82 100644 --- a/src/compiler/optimize/autoprefixer.ts +++ b/src/compiler/optimize/autoprefixer.ts @@ -14,9 +14,16 @@ let cssProcessor: CssProcessor; * * @param cssText the text to be prefixed * @param opts an optional param with options for Autoprefixer + * @param sourceMap an optional source map from the previous CSS transform + * @param filePath the path of the CSS file being transformed * @returns a Promise wrapping some prefixed CSS as well as diagnostics */ -export const autoprefixCss = async (cssText: string, opts: boolean | null | d.AutoprefixerOptions) => { +export const autoprefixCss = async ( + cssText: string, + opts: boolean | null | d.AutoprefixerOptions, + sourceMap?: d.SourceMap | string, + filePath?: string, +) => { const output: d.OptimizeCssOutput = { output: cssText, diagnostics: [], @@ -26,7 +33,16 @@ export const autoprefixCss = async (cssText: string, opts: boolean | null | d.Au const autoprefixerOpts = opts != null && typeof opts === 'object' ? opts : DEFAULT_AUTOPREFIX_OPTIONS; const processor = getProcessor(autoprefixerOpts); - const result = await processor.process(cssText, { map: null }); + const result = await processor.process(cssText, { + from: filePath, + map: sourceMap + ? { + annotation: false, + inline: false, + prev: sourceMap, + } + : null, + }); result.warnings().forEach((warning: any) => { output.diagnostics.push({ @@ -39,6 +55,9 @@ export const autoprefixCss = async (cssText: string, opts: boolean | null | d.Au }); output.output = result.css; + if (result.map) { + output.map = result.map.toJSON(); + } } catch (e: any) { const diagnostic: d.Diagnostic = { header: `Autoprefix CSS`, diff --git a/src/compiler/optimize/minify-css.ts b/src/compiler/optimize/minify-css.ts index 122a202675a..c9a71515abf 100644 --- a/src/compiler/optimize/minify-css.ts +++ b/src/compiler/optimize/minify-css.ts @@ -1,5 +1,8 @@ +import { SourceMapGenerator } from '@jridgewell/source-map'; import { hasError, isFunction, isString } from '@utils'; +import sourceMapMerge from 'merge-source-map'; +import type * as d from '../../declarations'; import { CssNode, CssNodeType } from '../style/css-parser/css-parse-declarations'; import { parseCss } from '../style/css-parser/parse-css'; import { serializeCss } from '../style/css-parser/serialize-css'; @@ -11,7 +14,13 @@ import { serializeCss } from '../style/css-parser/serialize-css'; * @param input An object containing the CSS string and an optional resolveUrl function to handle URL resolution. * @returns A promise that resolves to the minified CSS string. */ -export const minifyCss = async (input: { css: string; resolveUrl?: (url: string) => Promise | string }) => { +export const minifyCss = async (input: { + css: string; + filePath?: string; + map?: d.SourceMap | string; + sourceMap?: boolean; + resolveUrl?: (url: string) => Promise | string; +}): Promise => { const parseResults = parseCss(input.css); if (hasError(parseResults.diagnostics)) { @@ -21,7 +30,82 @@ export const minifyCss = async (input: { css: string; resolveUrl?: (url: string) await resolveStylesheetUrl(parseResults.stylesheet.rules, input.resolveUrl, new Map()); } - return serializeCss(parseResults.stylesheet, {}); + const output = serializeCss(parseResults.stylesheet, {}); + if (!input.sourceMap) { + return output; + } + + const map = createCssSourceMap(input.css, output, input.filePath || getSourceMapFile(input.map)); + const previousMap = parseSourceMap(input.map); + return { + output, + map: previousMap ? sourceMapMerge(previousMap, map) : map, + }; +}; + +const parseSourceMap = (map?: d.SourceMap | string) => { + if (typeof map === 'string') { + try { + return JSON.parse(map) as d.SourceMap; + } catch (e) { + return undefined; + } + } + return map; +}; + +const getSourceMapFile = (map?: d.SourceMap | string) => parseSourceMap(map)?.file; + +export const createCssSourceMap = (input: string, output: string, sourceFile = 'input.css'): d.SourceMap => { + const generator = new SourceMapGenerator({ file: sourceFile }); + generator.setSourceContent(sourceFile, input); + + const tokenRe = /[^\s{};,:]+|[{};,:]/g; + const inputLineStarts = getLineStarts(input); + const outputLineStarts = getLineStarts(output); + let match: RegExpExecArray | null; + let searchFrom = 0; + while ((match = tokenRe.exec(output))) { + const token = match[0]; + const inputIndex = input.indexOf(token, searchFrom); + const originalIndex = inputIndex === -1 ? Math.min(searchFrom, input.length - 1) : inputIndex; + searchFrom = Math.max(searchFrom, originalIndex + token.length); + const original = getLineAndColumn(inputLineStarts, originalIndex); + const generated = getLineAndColumn(outputLineStarts, match.index); + generator.addMapping({ + generated, + original, + source: sourceFile, + name: '', + }); + } + + return generator.toJSON() as d.SourceMap; +}; + +const getLineStarts = (text: string) => { + const lineStarts = [0]; + for (let index = 0; index < text.length; index++) { + if (text[index] === '\n') { + lineStarts.push(index + 1); + } + } + return lineStarts; +}; + +const getLineAndColumn = (lineStarts: number[], index: number) => { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + if (lineStarts[middle] <= index) { + low = middle + 1; + } else { + high = middle - 1; + } + } + const lineIndex = Math.max(0, high); + return { line: lineIndex + 1, column: index - lineStarts[lineIndex] }; }; const resolveStylesheetUrl = async ( diff --git a/src/compiler/optimize/optimize-css.ts b/src/compiler/optimize/optimize-css.ts index 8a024c5eef3..1a6517622bd 100644 --- a/src/compiler/optimize/optimize-css.ts +++ b/src/compiler/optimize/optimize-css.ts @@ -2,7 +2,7 @@ import { hasError } from '@utils'; import { OptimizeCssInput, OptimizeCssOutput } from '../../declarations'; import { autoprefixCss } from './autoprefixer'; -import { minifyCss } from './minify-css'; +import { createCssSourceMap, minifyCss } from './minify-css'; /** * Optimize a CSS file, optionally running an autoprefixer and a minifier @@ -15,18 +15,36 @@ export const optimizeCss = async (inputOpts: OptimizeCssInput): Promise ({ + postcss: require('postcss'), + autoprefixer: require('autoprefixer'), + }), + { virtual: true }, +); + +import { optimizeCss } from '../optimize-css'; + +describe('optimizeCss source maps', () => { + it('returns a PostCSS source map when autoprefixing CSS', async () => { + const result = await optimizeCss({ + input: '.button { box-shadow: 1px 1px; }', + filePath: '/src/button.css', + autoprefixer: true, + minify: false, + sourceMap: true, + }); + + expect(result.output).toContain('-webkit-box'); + expect(result.map).toEqual( + expect.objectContaining({ + version: 3, + sources: ['/src/button.css'], + }), + ); + }); + + it('composes a plugin source map through CSS minification', async () => { + const result = await optimizeCss({ + input: '.button { color: red; }', + filePath: '/src/button.css', + autoprefixer: false, + minify: true, + sourceMap: true, + map: { + version: 3, + file: '/src/button.css', + names: [], + mappings: 'AAAA', + sources: ['/src/button.scss'], + sourcesContent: ['.button { color: red; }'], + }, + } as any); + + expect(result.output).toBe('.button{color:red}'); + expect(result.map).toEqual( + expect.objectContaining({ + version: 3, + sources: expect.arrayContaining(['/src/button.scss']), + }), + ); + }); +}); diff --git a/src/compiler/output-targets/dist-collection/index.ts b/src/compiler/output-targets/dist-collection/index.ts index 24465e54021..217ca929b3d 100644 --- a/src/compiler/output-targets/dist-collection/index.ts +++ b/src/compiler/output-targets/dist-collection/index.ts @@ -41,7 +41,7 @@ export const outputCollection = async ( const collectionModuleFiles = changedModuleFiles.filter( (mod) => config.devMode || !mod.cmps.some((cmp) => shouldExcludeComponent(cmp.tagName, config.excludeComponents)), ); - const bundlingEventMessage = `generate collections${config.sourceMap ? ' + source maps' : ''}`; + const bundlingEventMessage = `generate collections${config.sourceMapJs ? ' + source maps' : ''}`; const timespan = buildCtx.createTimeSpan(`${bundlingEventMessage} started`, true); try { await Promise.all( diff --git a/src/compiler/output-targets/dist-custom-elements/index.ts b/src/compiler/output-targets/dist-custom-elements/index.ts index 2fae740f8fe..f6684ce0259 100644 --- a/src/compiler/output-targets/dist-custom-elements/index.ts +++ b/src/compiler/output-targets/dist-custom-elements/index.ts @@ -52,7 +52,7 @@ export const outputCustomElements = async ( return; } - const bundlingEventMessage = `generate custom elements${config.sourceMap ? ' + source maps' : ''}`; + const bundlingEventMessage = `generate custom elements${config.sourceMapJs ? ' + source maps' : ''}`; const timespan = buildCtx.createTimeSpan(`${bundlingEventMessage} started`); await Promise.all(outputTargets.map((target) => bundleCustomElements(config, compilerCtx, buildCtx, target))); @@ -128,7 +128,7 @@ export const bundleCustomElements = async ( const rollupOutput = await build.generate({ banner: generatePreamble(config), format: 'esm', - sourcemap: config.sourceMap, + sourcemap: config.sourceMapJs, chunkFileNames: outputTarget.externalRuntime || !config.hashFileNames ? '[name].js' : 'p-[hash].js', entryFileNames: '[name].js', hoistTransitiveImports: false, @@ -348,6 +348,7 @@ const getCustomBeforeTransformers = ( proxy: null, style: 'static', styleImportData: 'queryparams', + sourceMapCss: config.sourceMapCss, }; const customBeforeTransformers = [ addDefineCustomElementFunctions(compilerCtx, components, outputTarget), diff --git a/src/compiler/output-targets/dist-hydrate-script/bundle-hydrate-factory.ts b/src/compiler/output-targets/dist-hydrate-script/bundle-hydrate-factory.ts index 8fe36847557..66194a3166a 100644 --- a/src/compiler/output-targets/dist-hydrate-script/bundle-hydrate-factory.ts +++ b/src/compiler/output-targets/dist-hydrate-script/bundle-hydrate-factory.ts @@ -77,6 +77,7 @@ const getCustomBeforeTransformers = ( proxy: null, style: 'static', styleImportData: 'queryparams', + sourceMapCss: config.sourceMapCss, }; const customBeforeTransformers = [updateStencilCoreImports(transformOpts.coreImportPath)]; diff --git a/src/compiler/output-targets/dist-lazy/generate-cjs.ts b/src/compiler/output-targets/dist-lazy/generate-cjs.ts index 9a94baf62aa..58c93ae6457 100644 --- a/src/compiler/output-targets/dist-lazy/generate-cjs.ts +++ b/src/compiler/output-targets/dist-lazy/generate-cjs.ts @@ -22,7 +22,7 @@ export const generateCjs = async ( format: 'cjs', entryFileNames: '[name].cjs.js', assetFileNames: '[name]-[hash][extname]', - sourcemap: config.sourceMap, + sourcemap: config.sourceMapJs, plugins: [lazyBundleIdPlugin(buildCtx, config, false, '.cjs')], }; diff --git a/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts b/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts index a0dc4d8e770..d0036ddf84b 100644 --- a/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts +++ b/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts @@ -22,7 +22,7 @@ export const generateEsmBrowser = async ( entryFileNames: '[name].esm.js', chunkFileNames: config.hashFileNames ? 'p-[hash].js' : '[name]-[hash].js', assetFileNames: config.hashFileNames ? 'p-[hash][extname]' : '[name]-[hash][extname]', - sourcemap: config.sourceMap, + sourcemap: config.sourceMapJs, plugins: [lazyBundleIdPlugin(buildCtx, config, config.hashFileNames, '', true)], }; diff --git a/src/compiler/output-targets/dist-lazy/generate-esm.ts b/src/compiler/output-targets/dist-lazy/generate-esm.ts index fdf9920bced..5368f9d22f7 100644 --- a/src/compiler/output-targets/dist-lazy/generate-esm.ts +++ b/src/compiler/output-targets/dist-lazy/generate-esm.ts @@ -22,7 +22,7 @@ export const generateEsm = async ( format: 'es', entryFileNames: '[name].js', assetFileNames: '[name]-[hash][extname]', - sourcemap: config.sourceMap, + sourcemap: config.sourceMapJs, plugins: [lazyBundleIdPlugin(buildCtx, config, false, '')], }; const outputTargetType = esmOutputs[0].type; diff --git a/src/compiler/output-targets/dist-lazy/generate-system.ts b/src/compiler/output-targets/dist-lazy/generate-system.ts index 785f7ff19fd..625ec8be3b1 100644 --- a/src/compiler/output-targets/dist-lazy/generate-system.ts +++ b/src/compiler/output-targets/dist-lazy/generate-system.ts @@ -23,7 +23,7 @@ export const generateSystem = async ( entryFileNames: config.hashFileNames ? 'p-[hash].system.js' : '[name].system.js', chunkFileNames: config.hashFileNames ? 'p-[hash].system.js' : '[name]-[hash].system.js', assetFileNames: config.hashFileNames ? 'p-[hash][extname]' : '[name]-[hash][extname]', - sourcemap: config.sourceMap, + sourcemap: config.sourceMapJs, plugins: [lazyBundleIdPlugin(buildCtx, config, config.hashFileNames, '.system', true)], }; const results = await generateRollupOutput(rollupBuild, esmOpts, config, buildCtx.entryModules); diff --git a/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts b/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts index 9e56e964a40..010a4aa4c5c 100644 --- a/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts +++ b/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts @@ -43,7 +43,7 @@ export const lazyBundleIdPlugin = ( // For browser builds, loader entries are skipped by writeLazyEntry // So we need to delete their sourcemaps to avoid orphaned files - if (isBrowserBuild && config.sourceMap) { + if (isBrowserBuild && config.sourceMapJs) { for (const [key, file] of files) { if (file.type === 'chunk' && file.isEntry && file.name === 'loader') { const mapFileName = key + '.map'; @@ -64,7 +64,7 @@ export const lazyBundleIdPlugin = ( map.set(file.fileName, newFileName); // If we're renaming the file, mark the old sourcemap for deletion - if (file.fileName !== newFileName && config.sourceMap) { + if (file.fileName !== newFileName && config.sourceMapJs) { const oldMapFileName = file.fileName + '.map'; if (bundle[oldMapFileName]) { filesToDelete.push(oldMapFileName); @@ -99,7 +99,7 @@ export const lazyBundleIdPlugin = ( }); file.code = magicString.toString(); - if (config.sourceMap && file.map) { + if (config.sourceMapJs && file.map) { // Update the file name in the existing sourcemap, // preserving the original mappings file.map.file = newFileName; diff --git a/src/compiler/output-targets/dist-lazy/lazy-output.ts b/src/compiler/output-targets/dist-lazy/lazy-output.ts index 46a0b32961c..f7f51245f5c 100644 --- a/src/compiler/output-targets/dist-lazy/lazy-output.ts +++ b/src/compiler/output-targets/dist-lazy/lazy-output.ts @@ -36,7 +36,7 @@ export const outputLazy = async ( return; } - const bundleEventMessage = `generate lazy${config.sourceMap ? ' + source maps' : ''}`; + const bundleEventMessage = `generate lazy${config.sourceMapJs ? ' + source maps' : ''}`; const timespan = buildCtx.createTimeSpan(`${bundleEventMessage} started`); try { @@ -121,6 +121,7 @@ const getCustomBeforeTransformers = ( proxy: null, style: 'static', styleImportData: 'queryparams', + sourceMapCss: config.sourceMapCss, }; const customBeforeTransformers = [updateStencilCoreImports(transformOpts.coreImportPath)]; diff --git a/src/compiler/plugin/plugin.ts b/src/compiler/plugin/plugin.ts index e0e6b4dc836..0f7c2adb83e 100644 --- a/src/compiler/plugin/plugin.ts +++ b/src/compiler/plugin/plugin.ts @@ -1,4 +1,5 @@ import { buildError, catchError, isFunction, isOutputTargetDocs, isString, relative } from '@utils'; +import sourceMapMerge from 'merge-source-map'; import { basename } from 'path'; import type * as d from '../../declarations'; @@ -98,6 +99,7 @@ export const runPluginTransforms = async ( const transformResults = { code: sourceText, id: id, + map: undefined as string | d.SourceMap | undefined, dependencies: [] as string[], } satisfies PluginTransformResults; @@ -151,6 +153,9 @@ export const runPluginTransforms = async ( if (isString(pluginTransformResults.id)) { transformResults.id = pluginTransformResults.id; } + if (pluginTransformResults.map != null) { + transformResults.map = mergePluginSourceMaps(transformResults.map, pluginTransformResults.map); + } /** * add dependencies from plugin transform results, e.g. transformed sass files @@ -227,7 +232,7 @@ export const runPluginTransformsEsmImports = async ( const transformResults = { code, id, - map: undefined as string | undefined, + map: undefined as string | d.SourceMap | undefined, diagnostics: [] as d.Diagnostic[], dependencies: [] as string[], } satisfies PluginTransformResults; @@ -266,6 +271,9 @@ export const runPluginTransformsEsmImports = async ( if (isString(pluginTransformResults.id)) { transformResults.id = pluginTransformResults.id; } + if (pluginTransformResults.map != null) { + transformResults.map = mergePluginSourceMaps(transformResults.map, pluginTransformResults.map); + } if (Array.isArray(pluginTransformResults.dependencies)) { const imports = pluginTransformResults.dependencies.filter( (f) => !transformResults.dependencies.includes(f), @@ -306,3 +314,25 @@ export const runPluginTransformsEsmImports = async ( return transformResults; }; + +const mergePluginSourceMaps = (previousMap: string | d.SourceMap | undefined, nextMap: string | d.SourceMap) => { + const parsedNextMap = parsePluginSourceMap(nextMap); + if (!previousMap) { + return parsedNextMap; + } + return sourceMapMerge(parsePluginSourceMap(previousMap), parsedNextMap); +}; + +const parsePluginSourceMap = (map: string | d.SourceMap): d.SourceMap => { + if (typeof map !== 'string') { + return map; + } + + const encodedMap = map.match(/^data:application\/json(?:;charset=[^;,]+)?(;base64)?,(.*)$/i); + if (encodedMap) { + const json = encodedMap[1] ? Buffer.from(encodedMap[2], 'base64').toString() : decodeURIComponent(encodedMap[2]); + return JSON.parse(json); + } + + return JSON.parse(map); +}; diff --git a/src/compiler/prerender/prerender-optimize.ts b/src/compiler/prerender/prerender-optimize.ts index 64e15886cb4..226a36cb076 100644 --- a/src/compiler/prerender/prerender-optimize.ts +++ b/src/compiler/prerender/prerender-optimize.ts @@ -242,7 +242,7 @@ export const hashAssets = async ( let css = await sys.readFile(filePath); if (isString(css) && css.length > 0) { - css = await minifyCss({ + const minifyResult = await minifyCss({ css, async resolveUrl(urlProp) { const assetUrl = new URL(urlProp, stylesheetUrl); @@ -251,6 +251,7 @@ export const hashAssets = async ( return assetUrl.pathname + assetUrl.search; }, }); + css = typeof minifyResult === 'string' ? minifyResult : minifyResult.output; sys.writeFileSync(filePath, css); } } catch (e: any) { diff --git a/src/compiler/style/css-to-esm.ts b/src/compiler/style/css-to-esm.ts index b574cef0efc..70d3e16010d 100644 --- a/src/compiler/style/css-to-esm.ts +++ b/src/compiler/style/css-to-esm.ts @@ -1,17 +1,19 @@ import { catchError, createJsVarName, DEFAULT_STYLE_MODE, hasError, isString, normalizePath, resolve } from '@utils'; import { scopeCss } from '@utils/shadow-css'; import MagicString from 'magic-string'; +import sourceMapMerge from 'merge-source-map'; import path from 'path'; import type * as d from '../../declarations'; +import { STENCIL_CORE_ID } from '../bundle/entry-alias-ids'; import { parseStyleDocs } from '../docs/style-docs'; +import { createCssSourceMap } from '../optimize/minify-css'; import { optimizeCss } from '../optimize/optimize-css'; +import { TRANSFORM_TAG } from '../transformers/core-runtime-apis'; import { serializeImportPath } from '../transformers/stencil-import-path'; +import { addTagTransformToCssString } from '../transformers/transform-utils'; import { getScopeId } from './scope-css'; import { stripCssComments } from './style-utils'; -import { addTagTransformToCssString } from '../transformers/transform-utils'; -import { TRANSFORM_TAG } from '../transformers/core-runtime-apis'; -import { STENCIL_CORE_ID } from '../bundle/entry-alias-ids'; /** * A regular expression for matching CSS import statements @@ -68,7 +70,8 @@ export const transformCssToEsm = async (input: d.TransformCssToEsmInput): Promis autoprefixer: input.autoprefixer, input: results.styleText, filePath: input.file, - minify: true, + map: results.styleMap, + minify: input.minify !== false, sourceMap: input.sourceMap, }); @@ -77,6 +80,7 @@ export const transformCssToEsm = async (input: d.TransformCssToEsmInput): Promis return results; } results.styleText = optimizeResults.output; + results.styleMap = optimizeResults.map; return generateTransformCssToEsm(input, results); }; @@ -121,6 +125,7 @@ const transformCssToEsmModule = (input: d.TransformCssToEsmInput): d.TransformCs results.styleText = scopeCss(results.styleText, scopeId, false); } + // Production bundle transforms resolve CSS imports before this function. Keep this fallback for the public transpile API. const cssImports = getCssToEsmImports(varNames, results.styleText, input.file, input.mode); cssImports.forEach((cssImport) => { // remove the original css @imports @@ -148,6 +153,14 @@ const transformCssToEsmModule = (input: d.TransformCssToEsmInput): d.TransformCs catchError(results.diagnostics, e); } + if (input.sourceMap) { + results.styleMap = createCssSourceMap(input.input, results.styleText, input.file || 'input.css'); + const previousMap = parseSourceMap(input.map); + if (previousMap) { + results.styleMap = mergeSourceMaps(previousMap, results.styleMap); + } + } + return results; }; @@ -164,6 +177,7 @@ const generateTransformCssToEsm = ( results: d.TransformCssToEsmOutput, ): d.TransformCssToEsmOutput => { const s = new MagicString(''); + results.styleSourceMap = results.styleMap ? sourceMapToDataUrl(results.styleMap) : undefined; // Escape backslashes first so the escape sequences added below are not // re-escaped afterwards. Escaping backslashes last would turn the @@ -175,9 +189,9 @@ const generateTransformCssToEsm = ( // inside a template literal (backticks and dollar-brace). results.styleText = results.styleText .replace(/\\/g, '\\\\') - .replace(/\n/g, ' ') - .replace(/\r/g, ' ') - .replace(/\t/g, ' ') + .replace(/\n/g, input.sourceMap ? '\n' : ' ') + .replace(/\r/g, input.sourceMap ? '\r' : ' ') + .replace(/\t/g, input.sourceMap ? '\t' : ' ') .replace(/\u000c/g, '\\\\f') .replace(/\u0008/g, '\\\\b') .replace(/\u000b/g, '\\\\v') @@ -189,10 +203,12 @@ const generateTransformCssToEsm = ( results.styleText = addTagTransformToCssString(results.styleText, input.tags); } - // Strip CSS comments before embedding in template literal - // This includes /** */ and /*! */ comments (Sass loud comments) - // Note: Style docs have already been parsed in transformCssToEsmModule - results.styleText = stripCssComments(results.styleText); + if (!input.sourceMap) { + // Strip CSS comments before embedding in template literal + // This includes /** */ and /*! */ comments (Sass loud comments) + // Note: Style docs have already been parsed in transformCssToEsmModule + results.styleText = stripCssComments(results.styleText); + } if (input.module === 'cjs') { // CommonJS @@ -203,14 +219,11 @@ const generateTransformCssToEsm = ( s.append(`const ${cssImport.varName} = require('${cssImport.importPath}');\n`); }); - s.append(`const ${results.defaultVarName} = () => `); - - results.imports.forEach((cssImport) => { - s.append(`${cssImport.varName} + `); - }); - - s.append(`\`${results.styleText}\`;\n`); + appendStyleFactory(s, results); s.append(`module.exports = ${results.defaultVarName};`); + if (input.sourceMap) { + s.append(`\nmodule.exports.sourceMap = ${JSON.stringify(results.styleSourceMap) ?? 'undefined'};`); + } } else { // ESM if (input.addTagTransformers) { @@ -220,20 +233,44 @@ const generateTransformCssToEsm = ( s.append(`import ${cssImport.varName} from '${cssImport.importPath}';\n`); }); - s.append(`const ${results.defaultVarName} = () => `); - - results.imports.forEach((cssImport) => { - s.append(`${cssImport.varName} + `); - }); - - s.append(`\`${results.styleText}\`;\n`); + appendStyleFactory(s, results); s.append(`export default ${results.defaultVarName};`); + if (input.sourceMap) { + s.append(`\nexport const sourceMap = ${JSON.stringify(results.styleSourceMap) ?? 'undefined'};`); + } } results.output = s.toString(); return results; }; +const appendStyleFactory = (output: MagicString, results: d.TransformCssToEsmOutput) => { + output.append(`const ${results.defaultVarName} = () => `); + results.imports.forEach((cssImport) => { + output.append(`${cssImport.varName} + `); + }); + output.append(`\`${results.styleText}\`;\n`); +}; + +const sourceMapToDataUrl = (sourceMap: d.SourceMap) => + `data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(sourceMap)).toString('base64')}`; + +const parseSourceMap = (map?: d.SourceMap | string) => { + if (typeof map === 'string') { + try { + return JSON.parse(map) as d.SourceMap; + } catch (e) { + return undefined; + } + } + return map; +}; + +const mergeSourceMaps = (previousMap: d.SourceMap, nextMap: d.SourceMap) => { + // The package maps generated positions in `nextMap` through the original positions in `previousMap`. + return sourceMapMerge(previousMap, nextMap) as d.SourceMap; +}; + /** * Get all of the CSS imports in a file * diff --git a/src/compiler/style/global-styles.ts b/src/compiler/style/global-styles.ts index d77b8946344..2d9cbf52035 100644 --- a/src/compiler/style/global-styles.ts +++ b/src/compiler/style/global-styles.ts @@ -1,4 +1,5 @@ import { catchError, isOutputTargetDistGlobalStyles, normalizePath } from '@utils'; +import { basename } from 'path'; import type * as d from '../../declarations'; import { runPluginTransforms } from '../plugin/plugin'; @@ -17,7 +18,7 @@ export const generateGlobalStyles = async ( const globalStyles = await buildGlobalStyles(config, compilerCtx, buildCtx); if (globalStyles) { - await Promise.all(outputTargets.map((o) => compilerCtx.fs.writeFile(o.file, globalStyles))); + await Promise.all(outputTargets.map((o) => writeGlobalStyle(compilerCtx, o.file, globalStyles))); } return globalStyles; @@ -43,6 +44,7 @@ const buildGlobalStyles = async (config: d.ValidatedConfig, compilerCtx: d.Compi if (transformResults) { let cssCode: string; let dependencies: string[] | undefined; + let transformMap: d.SourceMap | string | undefined; if (typeof transformResults === 'string') { // Handle case where transformResults is a string (the CSS code directly) @@ -52,13 +54,26 @@ const buildGlobalStyles = async (config: d.ValidatedConfig, compilerCtx: d.Compi // Handle case where transformResults is a PluginTransformationDescriptor object cssCode = transformResults.code; dependencies = transformResults.dependencies; + transformMap = transformResults.map; } else { // Invalid transformResults compilerCtx.cachedGlobalStyle = null; return null; } - const optimizedCss = await optimizeCss(config, compilerCtx, buildCtx.diagnostics, cssCode, globalStylePath); + const optimizedResult = await optimizeCss( + config, + compilerCtx, + buildCtx.diagnostics, + cssCode, + globalStylePath, + true, + transformMap, + ); + const optimizedCss = + typeof optimizedResult === 'string' + ? optimizedResult + : appendSourceMap(optimizedResult.output, optimizedResult.map); compilerCtx.cachedGlobalStyle = optimizedCss; if (Array.isArray(dependencies)) { @@ -77,7 +92,8 @@ const buildGlobalStyles = async (config: d.ValidatedConfig, compilerCtx: d.Compi buildCtx.stylesUpdated.push({ styleTag: 'global', styleMode: undefined, - styleText: optimizedCss, + styleText: typeof optimizedResult === 'string' ? optimizedResult : optimizedResult.output, + styleSourceMap: typeof optimizedResult === 'string' ? undefined : sourceMapToDataUrl(optimizedResult.map), }); } @@ -92,6 +108,45 @@ const buildGlobalStyles = async (config: d.ValidatedConfig, compilerCtx: d.Compi return null; }; +const appendSourceMap = (css: string, sourceMap?: d.SourceMap) => { + if (!sourceMap) { + return css; + } + const sourceMapUrl = sourceMapToDataUrl(sourceMap); + return `${css}\n/*# sourceMappingURL=${sourceMapUrl} */`; +}; + +const writeGlobalStyle = async (compilerCtx: d.CompilerCtx, filePath: string, css: string) => { + const external = externalizeSourceMap(css, filePath); + await compilerCtx.fs.writeFile(filePath, external.css); + if (external.map) { + await compilerCtx.fs.writeFile(`${filePath}.map`, JSON.stringify(external.map)); + } +}; + +const externalizeSourceMap = (css: string, filePath: string) => { + const match = css.match(/\n\/\*# sourceMappingURL=data:application\/json;charset=utf-8;base64,([^*]+) \*\/$/); + if (!match || match.index == null) { + return { css, map: undefined }; + } + + try { + const map = JSON.parse(Buffer.from(match[1], 'base64').toString()) as d.SourceMap; + map.file = basename(filePath); + return { + css: `${css.slice(0, match.index)}\n/*# sourceMappingURL=${basename(filePath)}.map */`, + map, + }; + } catch (e) { + return { css, map: undefined }; + } +}; + +const sourceMapToDataUrl = (sourceMap?: d.SourceMap) => + sourceMap + ? `data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(sourceMap)).toString('base64')}` + : undefined; + const canSkipGlobalStyles = async (config: d.ValidatedConfig, compilerCtx: d.CompilerCtx, buildCtx: d.BuildCtx) => { if (!compilerCtx.cachedGlobalStyle) { return false; diff --git a/src/compiler/style/optimize-css.ts b/src/compiler/style/optimize-css.ts index c8145a6bea5..784887c2830 100644 --- a/src/compiler/style/optimize-css.ts +++ b/src/compiler/style/optimize-css.ts @@ -11,13 +11,15 @@ export const optimizeCss = async ( // TODO(STENCIL-1076): Investigate removing this parameter, which appears to be unused. This function is exported by // the compiler, making this a breaking change should we remove it. filePath: string, + returnMap = false, + map?: d.SourceMap | string, ) => { if (typeof styleText !== 'string' || !styleText.length) { // don't bother with invalid data return styleText; } - if ((config.autoprefixCss === false || config.autoprefixCss === null) && !config.minifyCss) { + if ((config.autoprefixCss === false || config.autoprefixCss === null) && !config.minifyCss && !config.sourceMapCss) { // don't wanna autoprefix or minify, so just skip this return styleText; } @@ -31,12 +33,22 @@ export const optimizeCss = async ( filePath: filePath, autoprefixer: config.autoprefixCss, minify: config.minifyCss, + map, + sourceMap: config.sourceMapCss, }; const cacheKey = await compilerCtx.cache.createKey('optimizeCss', optimizeCssId, opts); const cachedContent = await compilerCtx.cache.get(cacheKey); if (cachedContent != null) { // let's use the cached data we already figured out + if (returnMap && config.sourceMapCss) { + const cachedMap = await compilerCtx.cache.get(cacheKey + 'Map'); + return { + output: cachedContent, + diagnostics: [], + map: cachedMap ? JSON.parse(cachedMap) : undefined, + }; + } return cachedContent; } @@ -51,8 +63,11 @@ export const optimizeCss = async ( // only cache if we got a cache key, if not it probably has an @import await compilerCtx.cache.put(cacheKey, minifyResults.output); + if (minifyResults.map) { + await compilerCtx.cache.put(cacheKey + 'Map', JSON.stringify(minifyResults.map)); + } - return minifyResults.output; + return returnMap ? minifyResults : minifyResults.output; } return styleText; diff --git a/src/compiler/style/test/css-to-esm.spec.ts b/src/compiler/style/test/css-to-esm.spec.ts index b15789ee838..0aea4b5845b 100644 --- a/src/compiler/style/test/css-to-esm.spec.ts +++ b/src/compiler/style/test/css-to-esm.spec.ts @@ -10,8 +10,8 @@ jest.mock('@utils/shadow-css', () => { }; }); -import { transformCssToEsm, transformCssToEsmSync } from '../css-to-esm'; import type * as d from '../../../declarations'; +import { transformCssToEsm, transformCssToEsmSync } from '../css-to-esm'; describe('transformCssToEsm', () => { let mockInput: d.TransformCssToEsmInput; @@ -110,6 +110,20 @@ describe('transformCssToEsm', () => { expect(result.styleText).not.toContain('@import'); }); + it('should keep imported styles as concatenated runtime entries', async () => { + mockInput.input = ` + @import './variables.css'; + .my-class { color: red; } + `; + mockInput.sourceMap = true; + + const result = await transformCssToEsm(mockInput); + + expect(result.output).toContain("import mdVariablesCss from './variables.css'"); + expect(result.output).toContain('mdVariablesCss + `'); + expect(result.output).not.toContain('sourceMap as mdVariablesCssSourceMap'); + }); + it('should handle multiple CSS imports', async () => { mockInput.input = ` @import './variables.css'; @@ -331,8 +345,43 @@ describe('transformCssToEsm', () => { const result = await transformCssToEsm(mockInput); - // Source map handling is done in optimization phase - expect(result.map).toBeNull(); // Before optimization + expect(result.styleMap).toEqual( + expect.objectContaining({ + version: 3, + sources: [mockInput.file], + }), + ); + expect(result.output).toContain('sourceMap'); + expect(result.output).not.toContain('/*# sourceMappingURL='); + }); + + it('preserves generated CSS layout when source maps are enabled', async () => { + mockInput.input = '/* keep positions */\n.my-class {\n color: red;\n}'; + mockInput.minify = false; + mockInput.sourceMap = true; + + const result = transformCssToEsmSync(mockInput); + + expect(result.styleText).toContain('\n'); + expect(result.output).toContain('/* keep positions */\n.my-class'); + }); + + it('includes scoped selector changes in the composed source map', async () => { + mockInput.encapsulation = 'scoped'; + mockInput.sourceMap = true; + mockInput.map = { + version: 3, + file: mockInput.file, + names: [], + mappings: 'AAAA', + sources: ['/src/components/my-component/my-component.scss'], + sourcesContent: ['.my-class { color: red; }'], + }; + + const result = await transformCssToEsm(mockInput); + + expect(result.styleText).toContain('.sc-my-component'); + expect(result.styleMap?.sources).toContain('/src/components/my-component/my-component.scss'); }); }); diff --git a/src/compiler/style/test/global-styles.spec.ts b/src/compiler/style/test/global-styles.spec.ts new file mode 100644 index 00000000000..ba9c781886c --- /dev/null +++ b/src/compiler/style/test/global-styles.spec.ts @@ -0,0 +1,52 @@ +jest.mock('../../plugin/plugin', () => ({ + runPluginTransforms: jest.fn(), +})); +jest.mock('../optimize-css', () => ({ + optimizeCss: jest.fn(), +})); + +import { mockBuildCtx, mockCompilerCtx, mockValidatedConfig } from '@stencil/core/testing'; + +import { runPluginTransforms } from '../../plugin/plugin'; +import { generateGlobalStyles } from '../global-styles'; +import { optimizeCss } from '../optimize-css'; + +describe('generateGlobalStyles', () => { + it('writes an external source map beside the generated global stylesheet', async () => { + const config = mockValidatedConfig({ + globalStyle: '/src/global.css', + outputTargets: [{ type: 'dist-global-styles', file: '/dist/global.css' }], + sourceMapCss: true, + }); + const compilerCtx = mockCompilerCtx(config); + const buildCtx = mockBuildCtx(config, compilerCtx); + const sourceMap = { + version: 3, + file: '/src/global.css', + names: [] as string[], + mappings: 'AAAA', + sources: ['/src/global.scss'], + sourcesContent: ['.button { color: red; }'], + }; + + jest.mocked(runPluginTransforms).mockResolvedValue({ + code: '.button { color: red; }', + id: '/src/global.css', + map: undefined, + dependencies: [], + }); + jest.mocked(optimizeCss).mockResolvedValue({ + output: '.button{color:red}', + map: sourceMap, + diagnostics: [], + }); + + await generateGlobalStyles(config, compilerCtx, buildCtx); + + const css = await compilerCtx.fs.readFile('/dist/global.css'); + const map = await compilerCtx.fs.readFile('/dist/global.css.map'); + expect(css).toContain('sourceMappingURL=global.css.map'); + expect(css).not.toContain('data:application/json'); + expect(JSON.parse(map as string)).toEqual({ ...sourceMap, file: 'global.css' }); + }); +}); diff --git a/src/compiler/sys/typescript/typescript-config.ts b/src/compiler/sys/typescript/typescript-config.ts index b8ffad87a0d..dcfd2fdf46c 100644 --- a/src/compiler/sys/typescript/typescript-config.ts +++ b/src/compiler/sys/typescript/typescript-config.ts @@ -112,8 +112,8 @@ export const validateTsConfig = async (config: d.ValidatedConfig, sys: d.Compile warn.messageText = `To improve bundling, it is always recommended to set the tsconfig.json “module” setting to “esnext”. Note that the compiler will automatically handle bundling both modern and legacy builds.`; } - tsconfig.compilerOptions.sourceMap = config.sourceMap; - tsconfig.compilerOptions.inlineSources = config.sourceMap; + tsconfig.compilerOptions.sourceMap = config.sourceMapJs; + tsconfig.compilerOptions.inlineSources = config.sourceMapJs; } } } @@ -178,8 +178,8 @@ const createDefaultTsConfig = (config: d.ValidatedConfig) => jsx: 'react', jsxFactory: 'h', jsxFragmentFactory: 'Fragment', - sourceMap: config.sourceMap, - inlineSources: config.sourceMap, + sourceMap: config.sourceMapJs, + inlineSources: config.sourceMapJs, }, include: [relative(config.rootDir, config.srcDir)], }, diff --git a/src/compiler/transformers/add-static-style.ts b/src/compiler/transformers/add-static-style.ts index 56e59277455..16ccc7158e7 100644 --- a/src/compiler/transformers/add-static-style.ts +++ b/src/compiler/transformers/add-static-style.ts @@ -6,6 +6,10 @@ import type * as d from '../../declarations'; import { getScopeId } from '../style/scope-css'; import { addTagTransformToCssTsAST, createStaticGetter, getExternalStyles } from './transform-utils'; +interface StyleIdentifierOptions { + separateStyles?: boolean; +} + /** * Adds static "style" getter within the class * ```typescript @@ -16,6 +20,7 @@ import { addTagTransformToCssTsAST, createStaticGetter, getExternalStyles } from * @param classMembers a class to existing members of a class. **this parameter will be mutated** rather than returning * a cloned version * @param cmp the metadata associated with the component being evaluated + * @param buildCtx the current build context */ export const addStaticStyleGetterWithinClass = ( classMembers: ts.ClassElement[], @@ -25,6 +30,10 @@ export const addStaticStyleGetterWithinClass = ( const styleLiteral = getStyleLiteral(cmp, buildCtx); if (styleLiteral) { classMembers.push(createStaticGetter('style', styleLiteral)); + const sourceMapLiteral = createStyleSourceMapLiteral(cmp, buildCtx); + if (sourceMapLiteral) { + classMembers.push(createStaticGetter('sourceMap', sourceMapLiteral)); + } } }; @@ -38,6 +47,7 @@ export const addStaticStyleGetterWithinClass = ( * * @param styleStatements a list of statements containing style assignments to a class * @param cmp the metadata associated with the component being evaluated + * @param buildCtx the current build context */ export const addStaticStylePropertyToClass = ( styleStatements: ts.Statement[], @@ -53,7 +63,56 @@ export const addStaticStylePropertyToClass = ( ), ); styleStatements.push(statement); + + const sourceMapLiteral = createStyleSourceMapLiteral(cmp, buildCtx); + if (sourceMapLiteral) { + styleStatements.push( + ts.factory.createExpressionStatement( + ts.factory.createAssignment( + ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(cmp.componentClassName), 'sourceMap'), + sourceMapLiteral, + ), + ), + ); + } + } +}; + +export const createStyleSourceMapLiteral = (cmp: d.ComponentCompilerMeta, buildCtx: d.BuildCtx) => { + if (!buildCtx.config.sourceMapCss) { + return null; + } + + const sourceMapModes: ts.ObjectLiteralElementLike[] = []; + cmp.styles.forEach((style) => { + if (Array.isArray(style.externalStyles) && style.externalStyles.length > 0) { + const identifiers = style.sourceMapIdentifiers || []; + if (identifiers.length === 0) { + return; + } + const value = + identifiers.length === 1 + ? ts.factory.createIdentifier(identifiers[0]) + : ts.factory.createArrayLiteralExpression( + identifiers.map((identifier) => ts.factory.createIdentifier(identifier)), + ); + sourceMapModes.push(ts.factory.createPropertyAssignment(style.modeName, value)); + } else if (typeof style.styleIdentifier === 'string') { + const sourceMapIdentifier = `${style.styleIdentifier}SourceMap`; + style.sourceMapIdentifiers = [sourceMapIdentifier]; + sourceMapModes.push( + ts.factory.createPropertyAssignment(style.modeName, ts.factory.createIdentifier(sourceMapIdentifier)), + ); + } + }); + + if (sourceMapModes.length === 0) { + return null; } + if (sourceMapModes.length === 1 && cmp.styles.length === 1 && cmp.styles[0].modeName === DEFAULT_STYLE_MODE) { + return (sourceMapModes[0] as ts.PropertyAssignment).initializer; + } + return ts.factory.createObjectLiteralExpression(sourceMapModes, true); }; const getStyleLiteral = (cmp: d.ComponentCompilerMeta, buildCtx: d.BuildCtx) => { @@ -89,7 +148,7 @@ const getMultipleModeStyle = (cmp: d.ComponentCompilerMeta, styles: d.StyleCompi // import generated from @Component() styleUrls option // import myTagIosStyle from './import-path.css'; // static get style() { return { ios: myTagIosStyle }; } - const styleUrlIdentifier = createStyleIdentifier(cmp, style); + const styleUrlIdentifier = createStyleIdentifier(cmp, style, { separateStyles: buildCtx.config.sourceMapCss }); const propUrlIdentifier = ts.factory.createPropertyAssignment(style.modeName, styleUrlIdentifier); styleModes.push(propUrlIdentifier); } else if (typeof style.styleIdentifier === 'string') { @@ -122,7 +181,7 @@ const getSingleStyle = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler, bu // import generated from @Component() styleUrls option // import myTagStyle from './import-path.css'; // static get style() { return myTagStyle; } - return createStyleIdentifier(cmp, style); + return createStyleIdentifier(cmp, style, { separateStyles: buildCtx.config.sourceMapCss }); } if (typeof style.styleIdentifier === 'string') { @@ -160,9 +219,15 @@ const createStyleLiteral = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler * * @param cmp the metadata associated with the component being evaluated * @param style style meta data - * @returns an assignment expression to be applied to the `style` property of a component class (e.g. `_myComponentCssStyle + _myComponentIosCssStyle` based on the example) + * @param options controls whether each external style remains a separate runtime entry + * @returns an expression to be applied to the `style` property of a component class */ -export const createStyleIdentifier = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler) => { +export const createStyleIdentifier = ( + cmp: d.ComponentCompilerMeta, + style: d.StyleCompiler, + options: StyleIdentifierOptions = {}, +) => { + const separateStyles = options.separateStyles === true; const externalStyles = getExternalStyles(style); /** * Set a styleIdentifier which will be propagated to the component and @@ -175,7 +240,12 @@ export const createStyleIdentifier = (cmp: d.ComponentCompilerMeta, style: d.Sty style.styleIdentifier += dashToPascalCase(style.modeName); } style.styleIdentifier += 'Style'; - return createIdentifierFromStyleIdentifier(style.styleIdentifier, Object.keys(externalStyles)); + if (separateStyles) { + style.sourceMapIdentifiers = Object.keys(externalStyles).map( + (externalStyleId) => `${style.styleIdentifier}SourceMap${externalStyleId}`, + ); + } + return createIdentifierFromStyleIdentifier(style.styleIdentifier, Object.keys(externalStyles), options); }; /** @@ -200,28 +270,39 @@ export const createStyleIdentifier = (cmp: d.ComponentCompilerMeta, style: d.Sty * export class MyComponent { * // ... * } - * MyComponent.style = MyComponentStyle0 + MyComponentStyle1; + * MyComponent.style = [MyComponentStyle0(), MyComponentStyle1()]; * ``` * * Note: style imports are made in [`createEsmStyleImport`](src/compiler/transformers/style-imports.ts). * * @param styleIdentifier identifier to be used for the style * @param externalStyleIds numeric ids of the external styles - * @returns an assignment expression to be applied to the `style` property of a component class (e.g. `_myComponentCssStyle + _myComponentIosCssStyle` based on the example) + * @param options controls whether each external style remains a separate runtime entry + * @returns an expression to be assigned to the `style` property of a component class */ const createIdentifierFromStyleIdentifier = ( styleIdentifier: string, externalStyleIds: string[], -): ts.CallExpression | ts.BinaryExpression => { + options: StyleIdentifierOptions = {}, +): ts.CallExpression | ts.ArrayLiteralExpression | ts.BinaryExpression => { + const separateStyles = options.separateStyles === true; const id = externalStyleIds[0]; if (externalStyleIds.length === 1) { return ts.factory.createCallExpression(ts.factory.createIdentifier(styleIdentifier + id), undefined, []); } - return ts.factory.createBinaryExpression( - createIdentifierFromStyleIdentifier(styleIdentifier, [id]), - ts.SyntaxKind.PlusToken, - createIdentifierFromStyleIdentifier(styleIdentifier, externalStyleIds.slice(1)), + if (!separateStyles) { + return ts.factory.createBinaryExpression( + createIdentifierFromStyleIdentifier(styleIdentifier, [id]), + ts.SyntaxKind.PlusToken, + createIdentifierFromStyleIdentifier(styleIdentifier, externalStyleIds.slice(1)), + ); + } + + return ts.factory.createArrayLiteralExpression( + externalStyleIds.map((externalStyleId) => + ts.factory.createCallExpression(ts.factory.createIdentifier(styleIdentifier + externalStyleId), undefined, []), + ), ); }; diff --git a/src/compiler/transformers/component-native/native-static-style.ts b/src/compiler/transformers/component-native/native-static-style.ts index f2eb8fb31b0..c1414cb802a 100644 --- a/src/compiler/transformers/component-native/native-static-style.ts +++ b/src/compiler/transformers/component-native/native-static-style.ts @@ -4,7 +4,7 @@ import ts from 'typescript'; import type * as d from '../../../declarations'; import { getScopeId } from '../../style/scope-css'; -import { createStyleIdentifier } from '../add-static-style'; +import { createStyleIdentifier, createStyleSourceMapLiteral } from '../add-static-style'; import { addTagTransformToCssTsAST, createStaticGetter } from '../transform-utils'; export const addNativeStaticStyle = ( @@ -48,7 +48,7 @@ const addMultipleModeStyleGetter = ( // import generated from @Component() styleUrls option // import myTagIosStyle from './import-path.css'; // static get style() { return { "ios": myTagIosStyle }; } - const styleUrlIdentifier = createStyleIdentifier(cmp, style); + const styleUrlIdentifier = createStyleIdentifier(cmp, style, { separateStyles: buildCtx.config.sourceMapCss }); const propUrlIdentifier = ts.factory.createPropertyAssignment(style.modeName, styleUrlIdentifier); styleModes.push(propUrlIdentifier); } else if (typeof style.styleIdentifier === 'string') { @@ -64,6 +64,10 @@ const addMultipleModeStyleGetter = ( const styleObj = ts.factory.createObjectLiteralExpression(styleModes, true); classMembers.push(createStaticGetter('style', styleObj)); + const sourceMapLiteral = createStyleSourceMapLiteral(cmp, buildCtx); + if (sourceMapLiteral) { + classMembers.push(createStaticGetter('sourceMap', sourceMapLiteral)); + } }; const addSingleStyleGetter = ( @@ -83,18 +87,30 @@ const addSingleStyleGetter = ( // static get style() { return "string"; } const styleLiteral = createStyleLiteral(cmp, style, buildCtx); classMembers.push(createStaticGetter('style', styleLiteral)); + const sourceMapLiteral = createStyleSourceMapLiteral(cmp, buildCtx); + if (sourceMapLiteral) { + classMembers.push(createStaticGetter('sourceMap', sourceMapLiteral)); + } } else if (Array.isArray(style.externalStyles) && style.externalStyles.length > 0) { // import generated from @Component() styleUrls option // import myTagStyle from './import-path.css'; // static get style() { return myTagStyle; } - const styleUrlIdentifier = createStyleIdentifier(cmp, style); + const styleUrlIdentifier = createStyleIdentifier(cmp, style, { separateStyles: buildCtx.config.sourceMapCss }); classMembers.push(createStaticGetter('style', styleUrlIdentifier)); + const sourceMapLiteral = createStyleSourceMapLiteral(cmp, buildCtx); + if (sourceMapLiteral) { + classMembers.push(createStaticGetter('sourceMap', sourceMapLiteral)); + } } else if (typeof style.styleIdentifier === 'string') { // direct import already written in the source code // import myTagStyle from './import-path.css'; // static get style() { return myTagStyle; } const styleIdentifier = ts.factory.createIdentifier(style.styleIdentifier); classMembers.push(createStaticGetter('style', styleIdentifier)); + const sourceMapLiteral = createStyleSourceMapLiteral(cmp, buildCtx); + if (sourceMapLiteral) { + classMembers.push(createStaticGetter('sourceMap', sourceMapLiteral)); + } } }; diff --git a/src/compiler/transformers/decorators-to-static/decorators-constants.ts b/src/compiler/transformers/decorators-to-static/decorators-constants.ts index e7e5e9f6de1..d8169c7fb27 100644 --- a/src/compiler/transformers/decorators-to-static/decorators-constants.ts +++ b/src/compiler/transformers/decorators-to-static/decorators-constants.ts @@ -63,6 +63,7 @@ export const STATIC_GETTER_NAMES = [ 'slotAssignment', 'states', 'style', + 'sourceMap', 'styleMode', 'styleUrl', 'styleUrls', diff --git a/src/compiler/transformers/style-imports.ts b/src/compiler/transformers/style-imports.ts index 28ae1533b31..fa143df9c6f 100644 --- a/src/compiler/transformers/style-imports.ts +++ b/src/compiler/transformers/style-imports.ts @@ -108,11 +108,14 @@ const updateEsmStyleImportPath = ( if (n.importClause.name && n.importClause.name.escapedText === style.styleIdentifier) { const orgImportPath = n.moduleSpecifier.text; const importPath = getStyleImportPath(transformOpts, tsSourceFile, cmp, style, orgImportPath); + const namedBindings = transformOpts.sourceMapCss + ? addStyleSourceMapImport(n.importClause.namedBindings, style) + : n.importClause.namedBindings; statements[i] = ts.factory.updateImportDeclaration( n, retrieveTsModifiers(n), - n.importClause, + ts.factory.updateImportClause(n.importClause, false, n.importClause.name, namedBindings), ts.factory.createStringLiteral(importPath), undefined, ); @@ -123,6 +126,21 @@ const updateEsmStyleImportPath = ( return statements; }; +const addStyleSourceMapImport = (namedBindings: ts.NamedImportBindings | undefined, style: d.StyleCompiler) => { + const sourceMapIdentifier = `${style.styleIdentifier}SourceMap`; + style.sourceMapIdentifiers = [sourceMapIdentifier]; + const sourceMapImport = ts.factory.createImportSpecifier( + false, + ts.factory.createIdentifier('sourceMap'), + ts.factory.createIdentifier(sourceMapIdentifier), + ); + + if (namedBindings && ts.isNamedImports(namedBindings)) { + return ts.factory.updateNamedImports(namedBindings, [...namedBindings.elements, sourceMapImport]); + } + return ts.factory.createNamedImports([sourceMapImport]); +}; + /** * Add import or require statement for each style * e.g. `import CMP__import_path_css from './import-path.css';` @@ -175,13 +193,25 @@ const createStyleImport = ( */ const styleIdentifier = `${style.styleIdentifier}${i}`; const importIdentifier = ts.factory.createIdentifier(styleIdentifier); + const sourceMapIdentifier = `${style.styleIdentifier}SourceMap${i}`; + style.sourceMapIdentifiers ||= []; + style.sourceMapIdentifiers[Number(i)] = sourceMapIdentifier; const importPath = getStyleImportPath(transformOpts, tsSourceFile, cmp, style, externalStyle); if (moduleType === 'esm') { + const namedBindings = transformOpts.sourceMapCss + ? ts.factory.createNamedImports([ + ts.factory.createImportSpecifier( + false, + ts.factory.createIdentifier('sourceMap'), + ts.factory.createIdentifier(sourceMapIdentifier), + ), + ]) + : undefined; imports.push( ts.factory.createImportDeclaration( undefined, - ts.factory.createImportClause(false, importIdentifier, undefined), + ts.factory.createImportClause(false, importIdentifier, namedBindings), ts.factory.createStringLiteral(importPath), ) as ImportDeclarationOrVariableStatementType, ); @@ -206,6 +236,24 @@ const createStyleImport = ( ), ) as ImportDeclarationOrVariableStatementType, ); + if (transformOpts.sourceMapCss) { + imports.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier(sourceMapIdentifier), + undefined, + undefined, + ts.factory.createPropertyAccessExpression(importIdentifier, 'sourceMap'), + ), + ], + ts.NodeFlags.Const, + ), + ) as ImportDeclarationOrVariableStatementType, + ); + } } else { throw new Error(`Invalid module type: ${moduleType}`); } @@ -228,18 +276,53 @@ const updateCjsStyleRequires = ( moduleFile: d.Module, ) => { const styleRequires: ts.Statement[] = []; + const styleSourceMapRequires: ts.Statement[] = []; moduleFile.cmps.forEach((cmp) => { cmp.styles.forEach((style) => { - if (typeof style.styleIdentifier === 'string' && style.externalStyles.length > 0) { - // add style imports built from @Component() styleUrl option - styleRequires.push(...createStyleImport(transformOpts, tsSourceFile, cmp, style)); + if (typeof style.styleIdentifier === 'string') { + if (style.externalStyles.length > 0) { + // add style imports built from @Component() styleUrl option + styleRequires.push(...createStyleImport(transformOpts, tsSourceFile, cmp, style)); + } else if (transformOpts.sourceMapCss) { + const sourceMapIdentifier = `${style.styleIdentifier}SourceMap`; + style.sourceMapIdentifiers = [sourceMapIdentifier]; + styleSourceMapRequires.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier(sourceMapIdentifier), + undefined, + undefined, + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier(style.styleIdentifier), + 'sourceMap', + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } } }); }); - if (styleRequires.length > 0) { - return ts.factory.updateSourceFile(tsSourceFile, [...styleRequires, ...tsSourceFile.statements]); + if (styleRequires.length > 0 || styleSourceMapRequires.length > 0) { + // Keep imports at the top so generated source-map declarations execute after their required style bindings. + const firstNonImportIndex = tsSourceFile.statements.findIndex((statement) => !ts.isImportDeclaration(statement)); + const importStatements = + firstNonImportIndex === -1 ? tsSourceFile.statements : tsSourceFile.statements.slice(0, firstNonImportIndex); + const nonImportStatements = firstNonImportIndex === -1 ? [] : tsSourceFile.statements.slice(firstNonImportIndex); + return ts.factory.updateSourceFile(tsSourceFile, [ + ...styleRequires, + ...importStatements, + ...styleSourceMapRequires, + ...nonImportStatements, + ]); } return tsSourceFile; diff --git a/src/compiler/transformers/test/add-static-style.spec.ts b/src/compiler/transformers/test/add-static-style.spec.ts index 0516c34a6e2..7019c57f4e7 100644 --- a/src/compiler/transformers/test/add-static-style.spec.ts +++ b/src/compiler/transformers/test/add-static-style.spec.ts @@ -12,6 +12,7 @@ jest.mock('@utils/shadow-css', () => { import { mockBuildCtx } from '@stencil/core/testing'; import ts from 'typescript'; + import type * as d from '../../../declarations'; import { addStaticStyleGetterWithinClass, @@ -116,6 +117,44 @@ describe('add-static-style', () => { expect(output).toContain('md: MyComponentMdStyle0'); }); + it('should expose a CSS source map getter for external style URLs', () => { + const classMembers: ts.ClassElement[] = []; + (buildCtx.config as any).sourceMapCss = true; + mockComponent.styles = [ + { + modeName: 'md', + externalStyles: [{ absolutePath: '/path/to/style.css' }], + } as d.StyleCompiler, + ]; + + addStaticStyleGetterWithinClass(classMembers, mockComponent, buildCtx); + + const output = classMembers + .map((member) => printer.printNode(ts.EmitHint.Unspecified, member, sourceFile)) + .join('\n'); + expect(output).toContain('static get sourceMap()'); + expect(output).toContain('MyComponentMdStyleSourceMap0'); + }); + + it('should expose one source map per external style URL', () => { + const classMembers: ts.ClassElement[] = []; + (buildCtx.config as any).sourceMapCss = true; + mockComponent.styles = [ + { + modeName: 'md', + externalStyles: [{ absolutePath: '/path/to/style1.css' }, { absolutePath: '/path/to/style2.css' }], + } as d.StyleCompiler, + ]; + + addStaticStyleGetterWithinClass(classMembers, mockComponent, buildCtx); + + const output = classMembers + .map((member) => printer.printNode(ts.EmitHint.Unspecified, member, sourceFile)) + .join('\n'); + expect(output).toContain('md: [MyComponentMdStyleSourceMap0, MyComponentMdStyleSourceMap1]'); + expect(output).not.toContain('length:'); + }); + it('should add static style getter for direct style identifier', () => { const classMembers: ts.ClassElement[] = []; mockComponent.styles = [ @@ -336,21 +375,20 @@ describe('add-static-style', () => { expect(ts.isCallExpression(result)).toBe(true); }); - it('should create binary expression for multiple external styles', () => { + it('should create an array for two external styles', () => { const style: d.StyleCompiler = { modeName: 'md', externalStyles: [{ absolutePath: '/path/to/style1.css' }, { absolutePath: '/path/to/style2.css' }], } as d.StyleCompiler; - const result = createStyleIdentifier(mockComponent, style); + const result = createStyleIdentifier(mockComponent, style, { separateStyles: true }); expect(style.styleIdentifier).toBe('MyComponentMdStyle'); - // MyComponentMdStyle0() + MyComponentMdStyle1() - expect(ts.isBinaryExpression(result)).toBe(true); + expect(ts.isArrayLiteralExpression(result)).toBe(true); // Check actual output const output = printer.printNode(ts.EmitHint.Unspecified, result, sourceFile); - expect(output).toBe('MyComponentMdStyle0() + MyComponentMdStyle1()'); + expect(output).toBe('[MyComponentMdStyle0(), MyComponentMdStyle1()]'); }); it('should handle component with dashes in tag name', () => { @@ -379,7 +417,7 @@ describe('add-static-style', () => { expect(ts.isCallExpression(result)).toBe(true); }); - it('should handle multiple external styles with complex binary expression', () => { + it('should create an array for three external styles', () => { const style: d.StyleCompiler = { modeName: 'ios', externalStyles: [ @@ -389,30 +427,14 @@ describe('add-static-style', () => { ], } as d.StyleCompiler; - const result = createStyleIdentifier(mockComponent, style); + const result = createStyleIdentifier(mockComponent, style, { separateStyles: true }); expect(style.styleIdentifier).toBe('MyComponentIosStyle'); - // MyComponentIosStyle0() + MyComponentIosStyle1() + MyComponentIosStyle2() - expect(ts.isBinaryExpression(result)).toBe(true); - - let binaryExpr = result as ts.BinaryExpression; - expect(binaryExpr.operatorToken.kind).toBe(ts.SyntaxKind.PlusToken); - // Check that the right side is also a binary expression (nested) - let rightBinary = binaryExpr.right; - // If rightBinary is a CallExpression, check if its callee is a ParenthesizedExpression wrapping a BinaryExpression - if (ts.isCallExpression(rightBinary)) { - const callExpr = rightBinary; - if (ts.isParenthesizedExpression(callExpr.expression)) { - const inner = callExpr.expression.expression; - if (ts.isBinaryExpression(inner)) { - rightBinary = inner; - } - } - } + expect(ts.isArrayLiteralExpression(result)).toBe(true); // Check actual output const output = printer.printNode(ts.EmitHint.Unspecified, result, sourceFile); - expect(output).toBe('MyComponentIosStyle0() + (MyComponentIosStyle1() + MyComponentIosStyle2())'); + expect(output).toBe('[MyComponentIosStyle0(), MyComponentIosStyle1(), MyComponentIosStyle2()]'); }); }); diff --git a/src/compiler/transformers/test/lazy-component.spec.ts b/src/compiler/transformers/test/lazy-component.spec.ts index 4371bbf891b..53f98900543 100644 --- a/src/compiler/transformers/test/lazy-component.spec.ts +++ b/src/compiler/transformers/test/lazy-component.spec.ts @@ -156,6 +156,33 @@ describe('lazy-component', () => { ), ); + it('emits CSS source-map metadata alongside an external style import', () => { + const buildCtx = mockBuildCtx(); + (buildCtx.config as any).sourceMapCss = true; + const transformOpts: d.TransformOptions = { + coreImportPath: '@stencil/core', + componentExport: 'lazy', + componentMetadata: null, + currentDirectory: '/', + proxy: null, + style: 'static', + styleImportData: null, + sourceMapCss: true, + }; + + const transformer = lazyComponentTransform(buildCtx.compilerCtx, transformOpts, buildCtx); + const t = transpileModule( + `@Component({ tag: 'cmp-a', styleUrl: 'cmp-a.css' }) export class CmpA {}`, + null, + buildCtx.compilerCtx, + [], + [transformer], + ); + + expect(t.outputText).toContain('sourceMap as CmpAStyleSourceMap0'); + expect(t.outputText).toContain('CmpA.sourceMap = CmpAStyleSourceMap0'); + }); + // eslint-disable-next-line jest/expect-expect it( 'using `styleUrls` parameter as object', diff --git a/src/compiler/transformers/test/native-component.spec.ts b/src/compiler/transformers/test/native-component.spec.ts index 5823c6e7616..b5a85f2ccfb 100644 --- a/src/compiler/transformers/test/native-component.spec.ts +++ b/src/compiler/transformers/test/native-component.spec.ts @@ -1,6 +1,6 @@ -import * as ts from 'typescript'; import * as d from '@stencil/core/declarations'; import { mockBuildCtx } from '@stencil/core/testing'; +import * as ts from 'typescript'; import { nativeComponentTransform } from '../component-native/tranform-to-native-component'; import { transpileModule } from './transpile'; diff --git a/src/compiler/transformers/test/native-static-style.spec.ts b/src/compiler/transformers/test/native-static-style.spec.ts new file mode 100644 index 00000000000..f9e9cbb07eb --- /dev/null +++ b/src/compiler/transformers/test/native-static-style.spec.ts @@ -0,0 +1,34 @@ +import { mockBuildCtx } from '@stencil/core/testing'; +import ts from 'typescript'; + +import type * as d from '../../../declarations'; +import { addNativeStaticStyle } from '../component-native/native-static-style'; + +describe('native static style', () => { + it('exposes a source map for a single external default style', () => { + const buildCtx = mockBuildCtx(); + (buildCtx.config as any).sourceMapCss = true; + const component = { + componentClassName: 'MyComponent', + tagName: 'my-component', + encapsulation: 'shadow', + styles: [ + { + modeName: '$', + externalStyles: [{ absolutePath: '/path/to/style.css' }], + }, + ], + } as d.ComponentCompilerMeta; + const classMembers: ts.ClassElement[] = []; + + addNativeStaticStyle(classMembers, component, buildCtx); + + expect(classMembers).toHaveLength(2); + expect(ts.isGetAccessor(classMembers[0])).toBe(true); + expect(ts.isIdentifier((classMembers[0] as ts.GetAccessorDeclaration).name)).toBe(true); + expect(((classMembers[0] as ts.GetAccessorDeclaration).name as ts.Identifier).text).toBe('style'); + expect(ts.isGetAccessor(classMembers[1])).toBe(true); + expect(ts.isIdentifier((classMembers[1] as ts.GetAccessorDeclaration).name)).toBe(true); + expect(((classMembers[1] as ts.GetAccessorDeclaration).name as ts.Identifier).text).toBe('sourceMap'); + }); +}); diff --git a/src/compiler/transpile/ts-config.ts b/src/compiler/transpile/ts-config.ts index d6db5491d86..3a5e18f7fbf 100644 --- a/src/compiler/transpile/ts-config.ts +++ b/src/compiler/transpile/ts-config.ts @@ -33,8 +33,8 @@ export const getTsOptionsToExtend = (config: d.ValidatedConfig): ts.CompilerOpti : ts.ModuleResolutionKind.NodeJs, noEmitOnError: config.tsCompilerOptions?.noEmitOnError || false, outDir: config.cacheDir || config.sys.tmpDirSync(), - sourceMap: config.sourceMap, - inlineSources: config.sourceMap, + sourceMap: config.sourceMapJs, + inlineSources: config.sourceMapJs, }; return tsOptions; }; diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index a1b22b6f22e..8bb5f8a8007 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -298,8 +298,9 @@ export interface BuildCtx { export interface BuildStyleUpdate { styleTag: string; - styleText: string; + styleText: string | string[]; styleMode: string; + styleSourceMap?: string | string[]; } export type BuildTask = any; @@ -968,7 +969,8 @@ export interface ComponentConstructor { watchers?: ComponentConstructorChangeHandlers; events?: ComponentConstructorEvent[]; listeners?: ComponentConstructorListener[]; - style?: string; + style?: string | string[] | { [modeName: string]: string | string[] }; + sourceMap?: StyleSourceMapValue | { [modeName: string]: StyleSourceMapValue }; styleId?: string; encapsulation?: ComponentConstructorEncapsulation; observedAttributes?: string[]; @@ -979,6 +981,14 @@ export interface ComponentConstructor { deserializers?: ComponentConstructorChangeHandlers; } +export interface StyleSourceMapSegment { + map: string; + length: number; +} + +export type StyleSourceMap = string | StyleSourceMapSegment; +export type StyleSourceMapValue = StyleSourceMap | StyleSourceMapValue[]; + /** * A mapping from class member names to a list of methods which are watching * them. @@ -1390,7 +1400,7 @@ export type PluginTransformResults = PluginTransformationDescriptor | string | n export interface PluginTransformationDescriptor { code?: string; - map?: string; + map?: string | SourceMap; id?: string; diagnostics?: Diagnostic[]; dependencies?: string[]; @@ -1932,7 +1942,7 @@ export interface PlatformRuntime { ce: (eventName: string, opts?: any) => CustomEvent; } -export type StyleMap = Map; +export type StyleMap = Map>; export type RootAppliedStyleMap = WeakMap>; @@ -2157,6 +2167,7 @@ export interface StyleCompiler { styleId: string; styleStr: string; styleIdentifier: string; + sourceMapIdentifiers?: string[]; externalStyles: ExternalStyleCompiler[]; } @@ -2221,6 +2232,7 @@ export interface TransformCssToEsmInput { */ mode?: string; sourceMap?: boolean; + map?: SourceMap | string; minify?: boolean; docs?: boolean; autoprefixer?: any; @@ -2231,6 +2243,8 @@ export interface TransformCssToEsmOutput { styleText: string; output: string; map: any; + styleMap?: SourceMap; + styleSourceMap?: string; diagnostics: Diagnostic[]; defaultVarName: string; styleDocs: StyleDoc[]; diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 45b9d9fb198..222782b5f25 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -135,12 +135,12 @@ export interface StencilConfig { plugins?: any[]; /** - * Generate js source map files for all bundles. - * Set to `true` to always generate source maps, `false` to never generate source maps. - * Set to `'dev'` to only generate source maps when the `--dev` flag is passed. + * Generate source maps for generated JavaScript and CSS. Legacy boolean and + * `'dev'` values retain JavaScript behavior; use an object to enable CSS maps + * independently. * Defaults to `'dev'`. */ - sourceMap?: boolean | 'dev'; + sourceMap?: SourceMapOption; /** * The srcDir config specifies the directory which should contain the source typescript files @@ -496,6 +496,8 @@ export type ConfigExtras = ConfigExtrasBase & (ConfigExtrasSlotFixes | ConfigExtrasSlotFixes); export interface Config extends StencilConfig { + sourceMapCss?: boolean; + sourceMapJs?: boolean; buildAppCore?: boolean; buildDocs?: boolean; configPath?: string; @@ -514,6 +516,13 @@ export interface Config extends StencilConfig { _isTesting?: boolean; } +export interface SourceMapOptions { + css?: boolean; + js?: boolean; +} + +export type SourceMapOption = boolean | 'dev' | SourceMapOptions; + /** * A 'loose' type useful for wrapping an incomplete / possible malformed * object as we work on getting it comply with a particular Interface T. @@ -598,7 +607,9 @@ type StrictConfigFields = keyof Pick< * validations have occurred at runtime. */ export type ValidatedConfig = RequireFields & { - sourceMap: boolean; + sourceMap: boolean | SourceMapOptions; + sourceMapCss: boolean; + sourceMapJs: boolean; }; export interface HydratedFlag { @@ -1573,7 +1584,8 @@ export interface HotModuleReplacement { export interface HmrStyleUpdate { styleId: string; styleTag: string; - styleText: string; + styleText: string | string[]; + styleSourceMap?: string | string[]; } export type BuildOnEventRemove = () => boolean; @@ -2963,6 +2975,7 @@ export interface OptimizeCssInput { autoprefixer?: boolean | null | AutoprefixerOptions; minify?: boolean; sourceMap?: boolean; + map?: any; resolveUrl?: (url: string) => Promise | string; } @@ -2983,6 +2996,7 @@ export type AutoprefixerOptions = Object; export interface OptimizeCssOutput { output: string; diagnostics: Diagnostic[]; + map?: any; } export interface OptimizeJsInput { @@ -3228,6 +3242,7 @@ export interface TransformOptions { proxy: 'defineproperty' | null; style: 'static' | null; styleImportData: 'queryparams' | null; + sourceMapCss?: boolean; target?: string; } diff --git a/src/dev-server/client/hmr-inline-styles.ts b/src/dev-server/client/hmr-inline-styles.ts index a6ecfb25f79..1cd9d4312d1 100644 --- a/src/dev-server/client/hmr-inline-styles.ts +++ b/src/dev-server/client/hmr-inline-styles.ts @@ -36,11 +36,26 @@ export const hmrInlineStyles = (elm: Element, versionId: string, stylesUpdatedDa const hmrStyleElement = (elm: Element, versionId: string, stylesUpdated: HmrStyleUpdate) => { const styleId = elm.getAttribute('sty-id'); - if (styleId === stylesUpdated.styleId && stylesUpdated.styleText) { + const styleIndex = Number(elm.getAttribute('data-style-index') || 0); + const styleText = Array.isArray(stylesUpdated.styleText) + ? stylesUpdated.styleText[styleIndex] + : stylesUpdated.styleText; + const sourceMap = Array.isArray(stylesUpdated.styleSourceMap) + ? stylesUpdated.styleSourceMap[styleIndex] + : stylesUpdated.styleSourceMap; + if (styleId === stylesUpdated.styleId && styleText != null) { // if we made it this far then it's a match! // update the new style text - elm.innerHTML = stylesUpdated.styleText.replace(/\\n/g, '\n'); + elm.innerHTML = appendStyleSourceMap(styleText.replace(/\\n/g, '\n'), sourceMap); // TODO(STENCIL-958): determine if we need to set this attribute elm.setAttribute('data-hmr', versionId); } }; + +const appendStyleSourceMap = (styleText: string, sourceMap?: string) => { + const withoutSourceMap = styleText.replace(/\s*\/\*[#@]\s*sourceMappingURL=[^*]*\*\/\s*$/i, ''); + if (!sourceMap) { + return withoutSourceMap; + } + return `${withoutSourceMap}\n/*# sourceMappingURL=${sourceMap} */`; +}; diff --git a/src/dev-server/client/test/hmr-util.spec.ts b/src/dev-server/client/test/hmr-util.spec.ts index 876dc125c7a..0deb67d4f35 100644 --- a/src/dev-server/client/test/hmr-util.spec.ts +++ b/src/dev-server/client/test/hmr-util.spec.ts @@ -1,5 +1,31 @@ +import { hmrInlineStyles } from '../hmr-inline-styles'; import { getHmrHref, updateCssUrlValue } from '../hmr-util'; +describe('hmrInlineStyles', () => { + it('updates separate style elements with their individual source maps', () => { + const container = document.createElement('div'); + const firstStyle = document.createElement('style'); + const secondStyle = document.createElement('style'); + firstStyle.setAttribute('sty-id', 'sc-cmp-a'); + secondStyle.setAttribute('sty-id', 'sc-cmp-a'); + firstStyle.setAttribute('data-style-index', '0'); + secondStyle.setAttribute('data-style-index', '1'); + container.append(firstStyle, secondStyle); + + hmrInlineStyles(container, '1234', [ + { + styleId: 'sc-cmp-a', + styleTag: 'cmp-a', + styleText: ['.first{}', '.second{}'], + styleSourceMap: ['first.css.map', 'second.css.map'], + }, + ]); + + expect(firstStyle.innerHTML).toContain('sourceMappingURL=first.css.map'); + expect(secondStyle.innerHTML).toContain('sourceMappingURL=second.css.map'); + }); +}); + describe('updateCssUrlValue', () => { const versionId = '1234'; diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index b317d55ab81..0e85e8a7bdf 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -159,10 +159,11 @@ export const forceModeUpdate = (elm: d.RenderNode) => { const oldScopeId = elm['s-sc']; const scopeId = getScopeId(cmpMeta, mode); const style = (elm.constructor as any).style[mode]; + const sourceMap = (elm.constructor as any).sourceMap?.[mode]; const flags = cmpMeta.$flags$; if (style) { if (!styles.has(scopeId)) { - registerStyle(scopeId, style, !!(flags & CMP_FLAGS.shadowDomEncapsulation)); + registerStyle(scopeId, style, !!(flags & CMP_FLAGS.shadowDomEncapsulation), sourceMap); } hostRef.$modeName$ = mode; elm.classList.remove(oldScopeId + '-h', oldScopeId + '-s'); diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index f407cd283e7..59053eef9fc 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -120,10 +120,12 @@ export const initializeClientHydrate = ( cmpMeta.$cmpMeta$, BUILD.mode ? childRenderNode.$elm$.getAttribute('s-mode') : undefined, ); - const styleSheet = win.document.querySelector(`style[sty-id="${scopeId}"]`); + const styleSheets = win.document.querySelectorAll(`style[sty-id="${scopeId}"]`); - if (styleSheet) { - shadowRootNodes.unshift(styleSheet.cloneNode(true) as d.RenderNode); + if (styleSheets.length) { + shadowRootNodes.unshift( + ...Array.from(styleSheets, (styleSheet) => styleSheet.cloneNode(true) as d.RenderNode), + ); } } } diff --git a/src/runtime/initialize-component.ts b/src/runtime/initialize-component.ts index 388fe753e98..b9eda8efdf3 100644 --- a/src/runtime/initialize-component.ts +++ b/src/runtime/initialize-component.ts @@ -138,9 +138,10 @@ export const initializeComponent = async ( /** * this component has styles but we haven't registered them yet */ - let style: string | undefined; + let style: string | string[] | undefined; + let sourceMap: d.StyleSourceMapValue | undefined; - if (typeof Cstr.style === 'string') { + if (typeof Cstr.style === 'string' || Array.isArray(Cstr.style)) { /** * in case the component has a `styleUrl` defined, e.g. * ```ts @@ -151,6 +152,13 @@ export const initializeComponent = async ( * ``` */ style = Cstr.style; + if ( + typeof Cstr.sourceMap === 'string' || + Array.isArray(Cstr.sourceMap) || + (Cstr.sourceMap && typeof Cstr.sourceMap === 'object' && 'map' in Cstr.sourceMap) + ) { + sourceMap = Cstr.sourceMap as d.StyleSourceMapValue; + } } else if (BUILD.mode && typeof Cstr.style !== 'string') { /** * in case the component has a `styleUrl` object defined, e.g. @@ -167,6 +175,14 @@ export const initializeComponent = async ( hostRef.$modeName$ = computeMode(elm) as string | undefined; if (hostRef.$modeName$) { style = Cstr.style[hostRef.$modeName$]; + const modeSourceMaps = Cstr.sourceMap; + sourceMap = + modeSourceMaps && + typeof modeSourceMaps === 'object' && + !Array.isArray(modeSourceMaps) && + !('map' in modeSourceMaps) + ? modeSourceMaps[hostRef.$modeName$] + : undefined; } if (BUILD.hydrateServerSide && hostRef.$modeName$) { @@ -180,12 +196,14 @@ export const initializeComponent = async ( if (BUILD.hydrateServerSide && BUILD.shadowDom) { if (cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss) { - style = scopeCss(style, scopeId, true); + style = Array.isArray(style) + ? style.map((styleText) => scopeCss(styleText, scopeId, true)) + : scopeCss(style, scopeId, true); } else if (needsScopedSSR()) { - style = expandPartSelectors(style); + style = Array.isArray(style) ? style.map(expandPartSelectors) : expandPartSelectors(style); } } - registerStyle(scopeId, style, !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)); + registerStyle(scopeId, style, !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation), sourceMap); endRegisterStyles(); } } diff --git a/src/runtime/styles.ts b/src/runtime/styles.ts index a4da2e5377f..88065088803 100644 --- a/src/runtime/styles.ts +++ b/src/runtime/styles.ts @@ -16,6 +16,9 @@ import { createTime } from './profile'; import { HYDRATED_STYLE_ID, NODE_TYPE, SLOT_FB_CSS } from './runtime-constants'; export const rootAppliedStyles: d.RootAppliedStyleMap = /*@__PURE__*/ new WeakMap(); +const rootAppliedStyleSheets = /*@__PURE__*/ new WeakMap>(); + +type StyleText = string | StyleText[]; /** * Register the styles for a component by creating a stylesheet and then @@ -27,22 +30,115 @@ export const rootAppliedStyles: d.RootAppliedStyleMap = /*@__PURE__*/ new WeakMa * @param scopeId the scope ID for the component of interest * @param cssText styles for the component of interest * @param allowCS whether or not to use a constructable stylesheet + * @param sourceMap optional CSS source map URLs matching the style text entries */ -export const registerStyle = (scopeId: string, cssText: string, allowCS: boolean) => { +export const registerStyle = ( + scopeId: string, + cssText: StyleText, + allowCS: boolean, + sourceMap?: d.StyleSourceMapValue, +) => { let style = styles.get(scopeId); + const cssTexts = flattenStyleText(cssText); + const sourceMaps = Array.isArray(cssText) ? flattenStyleSourceMaps(sourceMap) : [sourceMap]; + const styleTexts = cssTexts.map((text, index) => appendStyleSourceMap(text, sourceMaps[index])); if (supportsConstructableStylesheets && allowCS) { - style = (style || new CSSStyleSheet()) as CSSStyleSheet; - if (typeof style === 'string') { - style = cssText; + if (styleTexts.length === 1) { + const existingStyleSheet = Array.isArray(style) + ? style.find((entry): entry is CSSStyleSheet => typeof entry !== 'string') + : style && typeof style !== 'string' + ? style + : undefined; + style = existingStyleSheet || new CSSStyleSheet(); + if (typeof style === 'string') { + style = styleTexts[0]; + } else { + style.replaceSync(styleTexts[0]); + } } else { - style.replaceSync(cssText); + const existingStyleSheets = Array.isArray(style) + ? style.filter((entry): entry is CSSStyleSheet => typeof entry !== 'string') + : style && typeof style !== 'string' + ? [style] + : []; + style = styleTexts.map((text, index) => { + const stylesheet = existingStyleSheets[index] || new CSSStyleSheet(); + stylesheet.replaceSync(text); + return stylesheet; + }); } } else { - style = cssText; + style = styleTexts.length === 1 ? styleTexts[0] : styleTexts; } styles.set(scopeId, style); }; +const flattenStyleText = (cssText: StyleText): string[] => { + // Keep this recursive to support the optional ES5 runtime without requiring Array.prototype.flat. + if (Array.isArray(cssText)) { + return cssText.reduce((styles, text) => styles.concat(flattenStyleText(text)), []); + } + return [cssText]; +}; + +const flattenStyleSourceMaps = (sourceMap: unknown): d.StyleSourceMap[] => { + if (Array.isArray(sourceMap)) { + return sourceMap.reduce( + (sourceMaps, map) => sourceMaps.concat(flattenStyleSourceMaps(map)), + [], + ); + } + return sourceMap ? [sourceMap as d.StyleSourceMap] : []; +}; + +export const appendStyleSourceMap = (styleText: string, sourceMap?: d.StyleSourceMapValue) => { + if (!sourceMap) { + return styleText; + } + const withoutSourceMap = styleText.replace(/\s*\/\*[#@]\s*sourceMappingURL=[^*]*\*\/\s*$/i, ''); + const sourceMaps = flattenStyleSourceMaps(sourceMap); + const indexMap = createStyleSourceMapIndex(sourceMaps); + if (indexMap) { + return `${withoutSourceMap}\n/*# sourceMappingURL=${indexMap} */`; + } + return `${withoutSourceMap}${sourceMaps + .map((map) => `\n/*# sourceMappingURL=${typeof map === 'string' ? map : map.map} */`) + .join('')}`; +}; + +const createStyleSourceMapIndex = (sourceMaps: d.StyleSourceMap[]) => { + if (sourceMaps.length < 2 || sourceMaps.some((sourceMap) => typeof sourceMap === 'string')) { + return null; + } + + const sections = []; + let column = 0; + for (const sourceMap of sourceMaps) { + const map = decodeInlineSourceMap((sourceMap as d.StyleSourceMapSegment).map); + if (!map) { + return null; + } + sections.push({ offset: { line: 0, column }, map }); + column += (sourceMap as d.StyleSourceMapSegment).length; + } + + return `data:application/json,${encodeURIComponent(JSON.stringify({ version: 3, sections }))}`; +}; + +const decodeInlineSourceMap = (sourceMap: string) => { + const match = sourceMap.match(/^data:application\/json[^,]*,(.*)$/i); + if (!match) { + return null; + } + try { + const encoded = match[1]; + const header = match[0].slice(0, match[0].indexOf(',')); + return JSON.parse(/;base64/i.test(header) ? atob(encoded) : decodeURIComponent(encoded)); + } catch (e) { + return null; + } +}; + /** * Attach the styles for a given component to the DOM * @@ -68,27 +164,48 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet styleContainerNode = styleContainerNode.nodeType === NODE_TYPE.DocumentFragment ? styleContainerNode : win.document; if (style) { - if (typeof style === 'string') { + if (typeof style === 'string' || (Array.isArray(style) && typeof style[0] === 'string')) { styleContainerNode = styleContainerNode.head || (styleContainerNode as HTMLElement); let appliedStyles = rootAppliedStyles.get(styleContainerNode); - let styleElm; if (!appliedStyles) { rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Set())); } - // Check if style element already exists (for HMR updates) - // For shadow DOM components, directly update their dedicated style element - // For scoped components, check if they have their own HMR-created style element - const existingStyleElm: HTMLStyleElement = - (BUILD.hydrateClientSide || BUILD.hotModuleReplacement) && - styleContainerNode.querySelector(`[${HYDRATED_STYLE_ID}="${scopeId}"]`); + const styleTexts = (Array.isArray(style) ? style : [style]) as string[]; + const isStyleApplied = appliedStyles.has(scopeId); + const shadowStyleReference = + 'host' in styleContainerNode && !supportsConstructableStylesheets + ? styleContainerNode.querySelector('style') + : null; + const headReferenceNode = + styleContainerNode.nodeName === 'HEAD' + ? (() => { + const preconnectLinks = styleContainerNode.querySelectorAll('link[rel=preconnect]'); + return preconnectLinks.length > 0 + ? preconnectLinks[preconnectLinks.length - 1].nextSibling + : styleContainerNode.querySelector('style'); + })() + : null; + for (let styleIndex = 0; styleIndex < styleTexts.length; styleIndex++) { + const styleText = styleTexts[styleIndex]; + // Check if style element already exists (for HMR updates) + // For shadow DOM components, directly update their dedicated style element + // For scoped components, check if they have their own HMR-created style element + const existingStyleElm: HTMLStyleElement = + (BUILD.hydrateClientSide || BUILD.hotModuleReplacement) && + styleContainerNode.querySelectorAll(`[${HYDRATED_STYLE_ID}="${scopeId}"]`).item(styleIndex); - if (existingStyleElm) { - // Update existing style element (for hydration or HMR) - existingStyleElm.textContent = style; - } else if (!appliedStyles.has(scopeId)) { - styleElm = win.document.createElement('style'); - styleElm.textContent = style; + let styleElm: HTMLStyleElement; + if (existingStyleElm) { + // Update existing style element (for hydration or HMR) + existingStyleElm.textContent = styleText; + continue; + } else if (isStyleApplied && !BUILD.hotModuleReplacement) { + continue; + } else { + styleElm = win.document.createElement('style'); + styleElm.textContent = styleText; + } // Apply CSP nonce to the style tag if it exists const nonce = plt.$nonce$ ?? queryNonceMetaTagContent(win.document); @@ -104,6 +221,7 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet ) { styleElm.setAttribute(HYDRATED_STYLE_ID, scopeId); } + styleElm.setAttribute('data-style-index', `${styleIndex}`); /** * attach styles at the end of the head tag if we render scoped components @@ -114,14 +232,9 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet * if the page contains preconnect links, we want to insert the styles * after the last preconnect link to ensure the styles are preloaded */ - const preconnectLinks = styleContainerNode.querySelectorAll('link[rel=preconnect]'); - const referenceNode = - preconnectLinks.length > 0 - ? preconnectLinks[preconnectLinks.length - 1].nextSibling - : styleContainerNode.querySelector('style'); (styleContainerNode as HTMLElement).insertBefore( styleElm, - referenceNode?.parentNode === styleContainerNode ? referenceNode : null, + headReferenceNode?.parentNode === styleContainerNode ? headReferenceNode : null, ); } else if ('host' in styleContainerNode) { if (supportsConstructableStylesheets) { @@ -137,7 +250,7 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet */ const currentWindow = styleContainerNode.defaultView ?? styleContainerNode.ownerDocument.defaultView; const stylesheet = new currentWindow.CSSStyleSheet(); - stylesheet.replaceSync(style); + stylesheet.replaceSync(styleText); /** * > If the array needs to be modified, use in-place mutations like push(). @@ -162,11 +275,14 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet * During HMR, create separate style elements for scoped components so they can be * updated independently without affecting other components' styles. */ - const existingStyleContainer: HTMLStyleElement = styleContainerNode.querySelector('style'); - if (existingStyleContainer && !BUILD.hotModuleReplacement) { - existingStyleContainer.textContent = style + existingStyleContainer.textContent; - } else { + if (shadowStyleReference && !BUILD.hotModuleReplacement && styleTexts.length === 1) { + shadowStyleReference.textContent = styleText + shadowStyleReference.textContent; + } else if (shadowStyleReference) { + styleContainerNode.insertBefore(styleElm, shadowStyleReference); + } else if (styleTexts.length === 1) { (styleContainerNode as HTMLElement).prepend(styleElm); + } else { + styleContainerNode.append(styleElm); } } } else { @@ -185,49 +301,67 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet if (cmpMeta.$flags$ & CMP_FLAGS.hasSlotRelocation) { styleElm.textContent += SLOT_FB_CSS; } - - if (appliedStyles) { - appliedStyles.add(scopeId); - } + } + if (!isStyleApplied) { + appliedStyles.add(scopeId); } } else if (BUILD.constructableCSS) { let appliedStyles = rootAppliedStyles.get(styleContainerNode); if (!appliedStyles) { rootAppliedStyles.set(styleContainerNode, (appliedStyles = new Set())); } - if (!appliedStyles.has(scopeId)) { + let appliedStyleSheets = rootAppliedStyleSheets.get(styleContainerNode); + if (!appliedStyleSheets) { + rootAppliedStyleSheets.set(styleContainerNode, (appliedStyleSheets = new Map())); + } + const previousStyleSheets = appliedStyleSheets.get(scopeId); + if (!appliedStyles.has(scopeId) || (BUILD.hotModuleReplacement && previousStyleSheets)) { /** * Constructable stylesheets can't be shared between windows, * we need to create a new one for the current window if necessary */ const currentWindow = styleContainerNode.defaultView ?? styleContainerNode.ownerDocument.defaultView; - let stylesheet: CSSStyleSheet; - if (style.constructor === currentWindow.CSSStyleSheet) { - stylesheet = style; - } else { - stylesheet = new currentWindow.CSSStyleSheet(); - for (let i = 0; i < style.cssRules.length; i++) { - stylesheet.insertRule(style.cssRules[i].cssText, i); + const styleSheets = (Array.isArray(style) ? style : [style]) as CSSStyleSheet[]; + const currentStyleSheets = styleSheets.map((sourceStyleSheet) => { + let currentStyleSheet: CSSStyleSheet; + if (sourceStyleSheet.constructor === currentWindow.CSSStyleSheet) { + currentStyleSheet = sourceStyleSheet; + } else { + currentStyleSheet = new currentWindow.CSSStyleSheet(); + for (let i = 0; i < sourceStyleSheet.cssRules.length; i++) { + currentStyleSheet.insertRule(sourceStyleSheet.cssRules[i].cssText, i); + } } - } + return currentStyleSheet; + }); + + const adoptedStyleSheets = previousStyleSheets + ? styleContainerNode.adoptedStyleSheets.filter( + (stylesheet: CSSStyleSheet) => !previousStyleSheets.includes(stylesheet), + ) + : styleContainerNode.adoptedStyleSheets; + const nextAdoptedStyleSheets = [...adoptedStyleSheets, ...currentStyleSheets]; /** * > If the array needs to be modified, use in-place mutations like push(). * https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets */ - if (supportsMutableAdoptedStyleSheets) { - styleContainerNode.adoptedStyleSheets.push(stylesheet); + if (previousStyleSheets || !supportsMutableAdoptedStyleSheets) { + styleContainerNode.adoptedStyleSheets = nextAdoptedStyleSheets; } else { - styleContainerNode.adoptedStyleSheets = [...styleContainerNode.adoptedStyleSheets, stylesheet]; + if (supportsMutableAdoptedStyleSheets) { + styleContainerNode.adoptedStyleSheets.push(...currentStyleSheets); + } } + appliedStyleSheets.set(scopeId, currentStyleSheets); appliedStyles.add(scopeId); // Remove SSR style element from shadow root now that adoptedStyleSheets is in use // Only remove from shadow roots, not from document head (for scoped components) if (BUILD.hydrateClientSide && 'host' in styleContainerNode) { - const ssrStyleElm = styleContainerNode.querySelector(`[${HYDRATED_STYLE_ID}="${scopeId}"]`); - if (ssrStyleElm) { - writeTask(() => ssrStyleElm.remove()); + const ssrStyleElms = styleContainerNode.querySelectorAll(`[${HYDRATED_STYLE_ID}="${scopeId}"]`); + if (ssrStyleElms.length > 0) { + writeTask(() => ssrStyleElms.forEach((ssrStyleElm: Element) => ssrStyleElm.remove())); } } } @@ -315,10 +449,18 @@ export const hydrateScopedToShadow = () => { } const styles = win.document.querySelectorAll(`[${HYDRATED_STYLE_ID}]`); - let i = 0; - for (; i < styles.length; i++) { - registerStyle(styles[i].getAttribute(HYDRATED_STYLE_ID), convertScopedToShadow(styles[i].innerHTML), true); + const stylesByScope = new Map(); + for (let i = 0; i < styles.length; i++) { + const scopeId = styles[i].getAttribute(HYDRATED_STYLE_ID); + if (scopeId) { + const scopeStyles = stylesByScope.get(scopeId) || []; + scopeStyles.push(convertScopedToShadow(styles[i].innerHTML)); + stylesByScope.set(scopeId, scopeStyles); + } } + stylesByScope.forEach((scopeStyles, scopeId) => { + registerStyle(scopeId, scopeStyles, true); + }); }; declare global { diff --git a/src/runtime/test/styles.spec.ts b/src/runtime/test/styles.spec.ts new file mode 100644 index 00000000000..d19ac9ef766 --- /dev/null +++ b/src/runtime/test/styles.spec.ts @@ -0,0 +1,82 @@ +import { styles } from '@platform'; + +import { appendStyleSourceMap, registerStyle } from '../styles'; + +describe('style source maps', () => { + afterEach(() => styles.delete('source-map-test')); + + it('retains the CSS source map when registering a non-constructable style', () => { + registerStyle('source-map-test', '.button{color:red}', false, 'data:application/json;base64,AAAA'); + + expect(styles.get('source-map-test')).toBe( + '.button{color:red}\n/*# sourceMappingURL=data:application/json;base64,AAAA */', + ); + }); + + it('keeps each external style and its source map separate', () => { + registerStyle('source-map-test', ['.first{color:red}', '.second{color:blue}'], false, [ + 'https://cdn.example.com/first.css.map', + 'https://cdn.example.com/second.css.map', + ]); + + expect(styles.get('source-map-test')).toEqual([ + '.first{color:red}\n/*# sourceMappingURL=https://cdn.example.com/first.css.map */', + '.second{color:blue}\n/*# sourceMappingURL=https://cdn.example.com/second.css.map */', + ]); + }); + + it('flattens imported style entries before registering their source maps', () => { + registerStyle('source-map-test', [['.imported{color:green}', '.local{color:red}'], '.second{color:blue}'], false, [ + ['imported.css.map', 'local.css.map'], + 'second.css.map', + ]); + + expect(styles.get('source-map-test')).toEqual([ + '.imported{color:green}\n/*# sourceMappingURL=imported.css.map */', + '.local{color:red}\n/*# sourceMappingURL=local.css.map */', + '.second{color:blue}\n/*# sourceMappingURL=second.css.map */', + ]); + }); + + it('adds a CSS source-map annotation to runtime style text', () => { + expect(appendStyleSourceMap('.button{color:red}', 'data:application/json;base64,AAAA')).toBe( + '.button{color:red}\n/*# sourceMappingURL=data:application/json;base64,AAAA */', + ); + }); + + it('does not add an annotation when CSS source maps are disabled', () => { + expect(appendStyleSourceMap('.button{color:red}', undefined)).toBe('.button{color:red}'); + }); + + it('preserves an existing source-map annotation when no replacement map is provided', () => { + const style = '.button{color:red}\n/*# sourceMappingURL=styles.css.map */'; + expect(appendStyleSourceMap(style, undefined)).toBe(style); + }); + + it('does not duplicate a source-map annotation during HMR updates', () => { + const style = '.button{color:red}\n/*# sourceMappingURL=data:application/json;base64,AAAA */'; + expect(appendStyleSourceMap(style, 'data:application/json;base64,BBBB')).toBe( + '.button{color:red}\n/*# sourceMappingURL=data:application/json;base64,BBBB */', + ); + }); + + it('combines source maps for concatenated CSS styles into one index map', () => { + const firstMap = JSON.stringify({ version: 3, sources: ['first.scss'], names: [], mappings: 'AAAA' }); + const secondMap = JSON.stringify({ version: 3, sources: ['second.scss'], names: [], mappings: 'AAAA' }); + const firstMapUrl = `data:application/json;base64,${Buffer.from(firstMap).toString('base64')}`; + const secondMapUrl = `data:application/json;base64,${Buffer.from(secondMap).toString('base64')}`; + + const output = appendStyleSourceMap('abcde', [ + { map: firstMapUrl, length: 3 }, + { map: secondMapUrl, length: 2 }, + ]); + const sourceMapUrl = output.match(/sourceMappingURL=(.*?) \*\//)?.[1]; + + expect(sourceMapUrl).toContain('data:application/json,'); + const sourceMap = JSON.parse(decodeURIComponent(sourceMapUrl!.split(',')[1])); + expect(sourceMap.sections).toEqual([ + expect.objectContaining({ offset: { line: 0, column: 0 } }), + expect.objectContaining({ offset: { line: 0, column: 3 } }), + ]); + }); +}); diff --git a/src/testing/mocks.ts b/src/testing/mocks.ts index 114ea12d9ad..090385da580 100644 --- a/src/testing/mocks.ts +++ b/src/testing/mocks.ts @@ -47,6 +47,8 @@ export function mockValidatedConfig(overrides: Partial = {}): packageJsonFilePath: path.join(rootDir, 'package.json'), rootDir, sourceMap: true, + sourceMapCss: false, + sourceMapJs: true, srcDir: '/src', srcIndexHtml: 'src/index.html', suppressReservedPublicNameWarnings: false,