From a6c7b73b882970b1e0472111bde7cf0bd1cb765f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:15:53 -0400 Subject: [PATCH] fix(@angular/build): prevent stripping nested sourceMappingURL comments When compiling files, the dev server and transformers strip original `//# sourceMappingURL=` comments. However, using a naive RegExp like `/^\/\/# sourceMappingURL=[^\r\n]*/gm` matches the comment even when it is embedded inside multiline template literals or string literals (such as inline web worker code), which causes template literals to become unterminated and fail compilation. This introduces a robust, lexer-style utility `removeSourceMappingURL` that ignores occurrences of the comment when they appear inside single/double quotes, template literals, or block comments, preventing syntax errors in compiled chunks. --- .../esbuild/javascript-transformer-worker.ts | 10 +- .../tools/esbuild/javascript-transformer.ts | 6 +- .../vite/plugins/angular-memory-plugin.ts | 3 +- packages/angular/build/src/utils/index.ts | 1 + .../angular/build/src/utils/source-map.ts | 187 ++++++++++++++++++ .../build/src/utils/source-map_spec.ts | 90 +++++++++ 6 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 packages/angular/build/src/utils/source-map.ts create mode 100644 packages/angular/build/src/utils/source-map_spec.ts diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index d4639d3a2565..f2e5c0e64886 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -11,6 +11,7 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import Piscina from 'piscina'; +import { removeSourceMappingURL } from '../../utils/source-map'; interface JavaScriptTransformRequest { filename: string; @@ -51,8 +52,7 @@ export default async function transformJavaScript( * Cached instance of the compiler-cli linker's createEs2015LinkerPlugin function. */ let linkerPluginCreator: - | typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin - | undefined; + typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin | undefined; async function transformWithBabel( filename: string, @@ -119,7 +119,7 @@ async function transformWithBabel( // If no additional transformations are needed, return the data directly if (plugins.length === 0) { // Strip sourcemaps if they should not be used - return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + return useInputSourcemap ? data : removeSourceMappingURL(data); } const result = await transformAsync(data, { @@ -137,9 +137,7 @@ async function transformWithBabel( // Strip sourcemaps if they should not be used. // Babel will keep the original comments even if sourcemaps are disabled. - return useInputSourcemap - ? outputCode - : outputCode.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + return useInputSourcemap ? outputCode : removeSourceMappingURL(outputCode); } async function requiresLinking(path: string, source: string): Promise { diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index 36c505d714fc..de4103f57559 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; +import { removeSourceMappingURL } from '../../utils/source-map'; import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; import { Cache } from './cache'; @@ -205,10 +206,7 @@ export class JavaScriptTransformer { this.#commonOptions.sourcemap && (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); - return Buffer.from( - keepSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), - 'utf-8', - ); + return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8'); } return this.#runWithThrottle(() => diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts index 2d34c38bb94c..c6dc243385a8 100644 --- a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'; import type * as Vite from 'vite' with { 'resolution-mode': 'import', }; +import { removeSourceMappingURL } from '../../../utils/source-map'; import { AngularMemoryOutputFiles } from '../utils'; interface AngularMemoryPluginOptions { @@ -104,7 +105,7 @@ export async function createAngularMemoryPlugin( return { // Remove source map URL comments from the code if a sourcemap is present. // Vite will inline and add an additional sourcemap URL for the sourcemap. - code: mapContents ? code.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '') : code, + code: mapContents ? removeSourceMappingURL(code) : code, map: mapContents && Buffer.from(mapContents).toString('utf-8'), }; }, diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts index 1a7cb15cd9c3..9df32fc10568 100644 --- a/packages/angular/build/src/utils/index.ts +++ b/packages/angular/build/src/utils/index.ts @@ -10,3 +10,4 @@ export * from './normalize-asset-patterns'; export * from './normalize-optimization'; export * from './normalize-source-maps'; export * from './load-proxy-config'; +export * from './source-map'; diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts new file mode 100644 index 000000000000..354539a154ec --- /dev/null +++ b/packages/angular/build/src/utils/source-map.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Removes `//# sourceMappingURL=` comments safely from the given JavaScript code, + * ignoring any occurrences that are inside string literals, template literals, or block comments. + * + * It uses a lightweight state-machine parser to accurately handle nested template literals. + * + * @param code The JavaScript source code. + * @returns The code with top-level sourcemap comments removed. + */ +export function removeSourceMappingURL(code: string): string { + const result: string[] = []; + let i = 0; + const len = code.length; + + // Stack to track template literal state and curly brace depth for nested interpolations. + const stack: { type: 'template'; braceDepth: number }[] = []; + let currentState: + 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = + 'normal'; + let isStrippingComment = false; + + while (i < len) { + const char = code[i]; + const nextChar = code[i + 1]; + + if (currentState === 'normal') { + if (char === '/' && nextChar === '*') { + currentState = 'comment_block'; + result.push('/*'); + i += 2; + continue; + } + if (char === '/' && nextChar === '/') { + // Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`). + let isEscaped = false; + let prevIdx = result.length - 1; + while (prevIdx >= 0 && /\s/.test(result[prevIdx])) { + prevIdx--; + } + if (prevIdx >= 0 && result[prevIdx] === '\\') { + let bsCount = 0; + while (prevIdx >= 0 && result[prevIdx] === '\\') { + bsCount++; + prevIdx--; + } + if (bsCount % 2 === 1) { + isEscaped = true; + } + } + + if (!isEscaped && code.startsWith('//# sourceMappingURL=', i)) { + currentState = 'comment_line'; + isStrippingComment = true; + i += 21; // Skip the '//# sourceMappingURL=' prefix + continue; + } else { + currentState = 'comment_line'; + isStrippingComment = false; + result.push('//'); + i += 2; + continue; + } + } + if (char === '"') { + currentState = 'string_double'; + result.push('"'); + i++; + continue; + } + if (char === "'") { + currentState = 'string_single'; + result.push("'"); + i++; + continue; + } + if (char === '`') { + currentState = 'template'; + stack.push({ type: 'template', braceDepth: 0 }); + result.push('`'); + i++; + continue; + } + if (char === '{') { + const top = stack[stack.length - 1]; + if (top) { + top.braceDepth++; + } + result.push('{'); + i++; + continue; + } + if (char === '}') { + const top = stack[stack.length - 1]; + if (top) { + top.braceDepth--; + if (top.braceDepth < 0) { + // Exiting a template literal interpolation ${ ... } + stack.pop(); + currentState = 'template'; + result.push('}'); + i++; + continue; + } + } + result.push('}'); + i++; + continue; + } + + result.push(char); + i++; + } else if (currentState === 'string_double') { + if (char === '\\') { + result.push(char, nextChar || ''); + i += 2; + continue; + } + if (char === '"') { + currentState = 'normal'; + } + result.push(char); + i++; + } else if (currentState === 'string_single') { + if (char === '\\') { + result.push(char, nextChar || ''); + i += 2; + continue; + } + if (char === "'") { + currentState = 'normal'; + } + result.push(char); + i++; + } else if (currentState === 'template') { + if (char === '\\') { + result.push(char, nextChar || ''); + i += 2; + continue; + } + if (char === '$' && nextChar === '{') { + // Entering template literal interpolation context + currentState = 'normal'; + stack.push({ type: 'template', braceDepth: 0 }); + result.push('${'); + i += 2; + continue; + } + if (char === '`') { + stack.pop(); + currentState = 'normal'; + } + result.push(char); + i++; + } else if (currentState === 'comment_block') { + if (char === '*' && nextChar === '/') { + currentState = 'normal'; + result.push('*/'); + i += 2; + continue; + } + result.push(char); + i++; + } else if (currentState === 'comment_line') { + if (char === '\n' || char === '\r') { + currentState = 'normal'; + isStrippingComment = false; + result.push(char); + i++; + continue; + } + if (!isStrippingComment) { + result.push(char); + } + i++; + } + } + + return result.join(''); +} diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts new file mode 100644 index 000000000000..7b424b019403 --- /dev/null +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { removeSourceMappingURL } from './source-map'; + +describe('removeSourceMappingURL', () => { + it('should remove top-level sourcemap comments', () => { + const code = 'console.log("hello");\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('console.log("hello");\n'); + }); + + it('should not remove sourcemap comments inside double-quoted strings', () => { + const code = 'const str = "//# sourceMappingURL=inline.js.map";'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside single-quoted strings', () => { + const code = "const str = '//# sourceMappingURL=inline.js.map';"; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside template literals', () => { + const code = 'const str = `\n//# sourceMappingURL=inline.js.map\n`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside block comments', () => { + const code = '/*\n//# sourceMappingURL=inline.js.map\n*/'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside normal single-line comments', () => { + const code = '// Some description of //# sourceMappingURL=inline.js.map'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should remove multiple top-level sourcemap comments', () => { + const code = + '//# sourceMappingURL=first.js.map\nconsole.log("mid");\n//# sourceMappingURL=second.js.map'; + expect(removeSourceMappingURL(code)).toBe('\nconsole.log("mid");\n'); + }); + + it('should not remove sourcemap comments inside strings containing escaped quotes', () => { + const codeDouble = 'const str = "escaped \\" //# sourceMappingURL=inline.js.map";'; + expect(removeSourceMappingURL(codeDouble)).toBe(codeDouble); + + const codeSingle = "const str = 'escaped \\' //# sourceMappingURL=inline.js.map';"; + expect(removeSourceMappingURL(codeSingle)).toBe(codeSingle); + }); + + it('should handle strings containing escaped backslashes correctly', () => { + const code = 'const str = "backslash \\\\";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('const str = "backslash \\\\";\n'); + }); + + it('should not remove sourcemap comments inside template literal interpolations', () => { + const code = 'const str = `hello ${"//# sourceMappingURL=inline.js.map"} world`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside nested template literals', () => { + const code = 'const str = `nested ${`inner ${"//# sourceMappingURL=inline.js.map"}`}`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside nested template literals without inner quotes', () => { + const code = 'const str = `nested ${`inner //# sourceMappingURL=inline.js.map`}`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside regex literals', () => { + const code = 'const regex = /\\/\\/# sourceMappingURL=inline.js.map/;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not affect normal division operators', () => { + const code = 'const ratio = 10 / 2;\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('const ratio = 10 / 2;\n'); + }); + + it('should only remove exact sourceMappingURL prefix comments', () => { + const code = '// # sourceMappingURL=main.js.map\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('// # sourceMappingURL=main.js.map\n'); + }); +});