Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand All @@ -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<boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'),
};
},
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
187 changes: 187 additions & 0 deletions packages/angular/build/src/utils/source-map.ts
Original file line number Diff line number Diff line change
@@ -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('');
}
90 changes: 90 additions & 0 deletions packages/angular/build/src/utils/source-map_spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Comment thread
clydin marked this conversation as resolved.

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