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'); + }); +});