diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index 4fa97dd222c1..54f8da15e830 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -59,7 +59,8 @@ export function findTrailingSourceMapComment( /** * Removes `//# sourceMappingURL=` comments safely from the given JavaScript code, - * ignoring any occurrences that are inside string literals, template literals, or block comments. + * ignoring any occurrences that are inside string literals, template literals, regex literals, + * or block comments. * * For raw Uint8Array / Buffer inputs, it optimizes performance by inspecting trailing byte sequences * to slice the buffer directly without full string decoding. @@ -93,6 +94,120 @@ export function removeSourceMappingURL(code: string | Uint8Array): string | Uint return stripped === text ? code : Buffer.from(stripped, 'utf-8'); } +const REGEX_PRECEDING_KEYWORDS = new Set([ + 'await', + 'case', + 'default', + 'delete', + 'do', + 'else', + 'in', + 'instanceof', + 'new', + 'of', + 'return', + 'throw', + 'typeof', + 'void', + 'yield', +]); + +const REGEX_PRECEDING_PUNCTUATION = new Set([ + '(', + ',', + '=', + ':', + '[', + '!', + '&', + '|', + '?', + '{', + '}', + ';', + '+', + '-', + '*', + '%', + '~', + '^', + '<', + '>', + '/', +]); + +function isIdentifierChar(char: string): boolean { + const code = char.charCodeAt(0); + + return ( + (code >= 97 && code <= 122) || // a-z + (code >= 65 && code <= 90) || // A-Z + (code >= 48 && code <= 57) || // 0-9 + code === 95 || // _ + code === 36 || // $ + code > 127 // Unicode / non-ASCII + ); +} + +function consumeIdentifier( + code: string, + start: number, +): { nextIndex: number; regexAllowed: boolean } { + const len = code.length; + let i = start + 1; + while (i < len && isIdentifierChar(code[i])) { + i++; + } + const word = code.slice(start, i); + let prevIdx = start - 1; + while ( + prevIdx >= 0 && + (code[prevIdx] === ' ' || + code[prevIdx] === '\t' || + code[prevIdx] === '\n' || + code[prevIdx] === '\r') + ) { + prevIdx--; + } + const regexAllowed = (prevIdx < 0 || code[prevIdx] !== '.') && REGEX_PRECEDING_KEYWORDS.has(word); + + return { nextIndex: i, regexAllowed }; +} + +function consumeRegexLiteral( + code: string, + start: number, +): { nextIndex: number; regexAllowed: boolean } { + const len = code.length; + let i = start; + let inCharClass = false; + + while (i < len) { + const char = code[i]; + if (char === '\\') { + const nextChar = code[i + 1]; + if (nextChar === '\n' || nextChar === '\r') { + return { nextIndex: i + 2, regexAllowed: true }; + } + i += 2; + continue; + } + if (char === '\n' || char === '\r') { + return { nextIndex: i + 1, regexAllowed: true }; + } + if (char === '[') { + inCharClass = true; + } else if (char === ']' && inCharClass) { + inCharClass = false; + } else if (char === '/' && !inCharClass) { + return { nextIndex: i + 1, regexAllowed: false }; + } + i++; + } + + return { nextIndex: len, regexAllowed: false }; +} + function removeSourceMappingURLFromString(code: string): string { if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) { return code; @@ -108,6 +223,7 @@ function removeSourceMappingURLFromString(code: string): string { let currentState: 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = 'normal'; + let regexAllowed = true; while (i < len) { const char = code[i]; @@ -120,24 +236,7 @@ function removeSourceMappingURLFromString(code: string): string { continue; } if (char === '/' && nextChar === '/') { - // Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`). - let isEscaped = false; - let prevIdx = i - 1; - while (prevIdx >= 0 && /\s/.test(code[prevIdx])) { - prevIdx--; - } - if (prevIdx >= 0 && code[prevIdx] === '\\') { - let bsCount = 0; - while (prevIdx >= 0 && code[prevIdx] === '\\') { - bsCount++; - prevIdx--; - } - if (bsCount % 2 === 1) { - isEscaped = true; - } - } - - if (!isEscaped && code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { + if (code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { if (i > lastCopiedIndex) { result.push(code.slice(lastCopiedIndex, i)); } @@ -156,17 +255,20 @@ function removeSourceMappingURLFromString(code: string): string { } if (char === '"') { currentState = 'string_double'; + regexAllowed = false; i++; continue; } if (char === "'") { currentState = 'string_single'; + regexAllowed = false; i++; continue; } if (char === '`') { currentState = 'template'; stack.push({ type: 'template', braceDepth: 0 }); + regexAllowed = false; i++; continue; } @@ -175,6 +277,7 @@ function removeSourceMappingURLFromString(code: string): string { if (top) { top.braceDepth++; } + regexAllowed = true; i++; continue; } @@ -186,14 +289,39 @@ function removeSourceMappingURLFromString(code: string): string { // Exiting a template literal interpolation ${ ... } stack.pop(); currentState = 'template'; + regexAllowed = false; i++; continue; } } + regexAllowed = true; + i++; + continue; + } + if ((char === '+' && nextChar === '+') || (char === '-' && nextChar === '-')) { + regexAllowed = false; + i += 2; + continue; + } + if (char === '/') { + if (regexAllowed) { + ({ nextIndex: i, regexAllowed } = consumeRegexLiteral(code, i + 1)); + } else { + regexAllowed = true; + i++; + } + continue; + } + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { i++; continue; } + if (isIdentifierChar(char)) { + ({ nextIndex: i, regexAllowed } = consumeIdentifier(code, i)); + continue; + } + regexAllowed = REGEX_PRECEDING_PUNCTUATION.has(char); i++; } else if (currentState === 'string_double') { if (char === '\\') { @@ -202,6 +330,7 @@ function removeSourceMappingURLFromString(code: string): string { } if (char === '"') { currentState = 'normal'; + regexAllowed = false; } i++; } else if (currentState === 'string_single') { @@ -211,6 +340,7 @@ function removeSourceMappingURLFromString(code: string): string { } if (char === "'") { currentState = 'normal'; + regexAllowed = false; } i++; } else if (currentState === 'template') { @@ -222,12 +352,14 @@ function removeSourceMappingURLFromString(code: string): string { // Entering template literal interpolation context currentState = 'normal'; stack.push({ type: 'template', braceDepth: 0 }); + regexAllowed = true; i += 2; continue; } if (char === '`') { stack.pop(); currentState = 'normal'; + regexAllowed = false; } i++; } else if (currentState === 'comment_block') { diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts index a5680d61fa4f..03c422b5003b 100644 --- a/packages/angular/build/src/utils/source-map_spec.ts +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -83,11 +83,90 @@ describe('removeSourceMappingURL', () => { expect(removeSourceMappingURL(code)).toBe(code); }); + it('should not corrupt strings following a regex literal containing a double quote', () => { + const code = 'const pattern = /"/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not corrupt strings following a regex literal containing single quotes or backticks', () => { + const singleQuoteCode = + "const pattern = /'/; const text = '//# sourceMappingURL=not-a-comment';"; + expect(removeSourceMappingURL(singleQuoteCode)).toBe(singleQuoteCode); + + const backtickCode = 'const pattern = /`/; const text = `//# sourceMappingURL=not-a-comment`;'; + expect(removeSourceMappingURL(backtickCode)).toBe(backtickCode); + }); + + it('should handle regex character classes containing slashes and quotes', () => { + const code = + 'const pattern = /[/"\'`]/; const text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const pattern = /[/"\'`]/; const text = "//# sourceMappingURL=not-a-comment";\n', + ); + + const charClassComment = 'const re = /[//# sourceMappingURL=foo.js.map]/;'; + expect(removeSourceMappingURL(charClassComment)).toBe(charClassComment); + }); + + it('should handle escaped slashes, brackets, and quotes inside regex literals', () => { + const code = + 'const pattern = /\\/"\\[/; const text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const pattern = /\\/"\\[/; const text = "//# sourceMappingURL=not-a-comment";\n', + ); + + const escapedBracketInClass = + 'const pattern = /[\\]/"\']/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(escapedBracketInClass)).toBe(escapedBracketInClass); + }); + + it('should handle regex literals after keywords including default', () => { + const code = + 'function test() { return /"/; }\nconst text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'function test() { return /"/; }\nconst text = "//# sourceMappingURL=not-a-comment";\n', + ); + + const exportDefaultCode = + 'export default /"/;\nconst text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(exportDefaultCode)).toBe( + 'export default /"/;\nconst text = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + + it('should handle Unicode identifiers before regex literals and division', () => { + const regexAfterUnicodeVar = + 'const à = /"/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(regexAfterUnicodeVar)).toBe(regexAfterUnicodeVar); + + const divisionAfterUnicodeVar = + 'const x = à / b; const y = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(divisionAfterUnicodeVar)).toBe( + 'const x = à / b; const y = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + + it('should handle postfix increment and decrement before division', () => { + const code = + 'const x = i++ / 2 + j-- / 2; const y = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const x = i++ / 2 + j-- / 2; const y = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + 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 handle division expressions followed by strings and comments', () => { + const code = + 'const x = a / b; const y = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const x = a / b; const y = "//# sourceMappingURL=not-a-comment";\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');