@angular/core
Version:
Angular - the core framework
488 lines (482 loc) • 20 kB
JavaScript
;
/**
* @license Angular v21.0.5
* (c) 2010-2025 Google LLC. https://angular.io/
* License: MIT
*/
;
var ts = require('typescript');
require('@angular/compiler-cli');
var migrations = require('@angular/compiler-cli/private/migrations');
require('node:path');
var project_paths = require('./project_paths-DvD50ouC.cjs');
var compiler = require('@angular/compiler');
var apply_import_manager = require('./apply_import_manager-1Zs_gpB6.cjs');
var imports = require('./imports-DP72APSx.cjs');
var parse_html = require('./parse_html-8VLCL37B.cjs');
var ng_component_template = require('./ng_component_template-Dsuq1Lw7.cjs');
require('@angular-devkit/core');
require('node:path/posix');
require('@angular-devkit/schematics');
require('./project_tsconfig_paths-CDVxT6Ov.cjs');
require('./ng_decorators-DSFlWYQY.cjs');
require('./property_name-BBwFuqMe.cjs');
const ngStyleStr = 'NgStyle';
const commonModuleStr = '@angular/common';
const commonModuleImportsStr = 'CommonModule';
function migrateNgStyleBindings(template, config, componentNode, typeChecker) {
const parsed = parse_html.parseTemplate(template);
if (!parsed.tree || !parsed.tree.rootNodes.length) {
return { migrated: template, changed: false, replacementCount: 0, canRemoveCommonModule: false };
}
const visitor = new NgStyleCollector(template, componentNode, typeChecker);
compiler.visitAll(visitor, parsed.tree.rootNodes, config);
let newTemplate = template;
let changedOffset = 0;
let replacementCount = 0;
for (const { start, end, replacement } of visitor.replacements) {
const currentLength = newTemplate.length;
newTemplate = replaceTemplate(newTemplate, replacement, start, end, changedOffset);
changedOffset += newTemplate.length - currentLength;
replacementCount++;
}
const changed = newTemplate !== template;
return {
migrated: newTemplate,
changed,
replacementCount,
canRemoveCommonModule: changed ? parse_html.canRemoveCommonModule(newTemplate) : false,
};
}
/**
* Creates a Replacement to remove `NgStyle` from a component's `imports` array.
* Uses ReflectionHost + PartialEvaluator for robust AST analysis.
*/
function createNgStyleImportsArrayRemoval(classNode, file, typeChecker, removeCommonModule) {
const reflector = new migrations.TypeScriptReflectionHost(typeChecker);
const decorators = reflector.getDecoratorsOfDeclaration(classNode);
if (!decorators) {
return null;
}
const componentDecorator = decorators.find((decorator) => decorator.name === 'Component');
if (!componentDecorator?.node) {
return null;
}
const decoratorNode = componentDecorator.node;
if (!ts.isDecorator(decoratorNode) ||
!ts.isCallExpression(decoratorNode.expression) ||
decoratorNode.expression.arguments.length === 0 ||
!ts.isObjectLiteralExpression(decoratorNode.expression.arguments[0])) {
return null;
}
const metadata = decoratorNode.expression.arguments[0];
const importsProperty = metadata.properties.find((p) => ts.isPropertyAssignment(p) && p.name?.getText() === 'imports');
if (!importsProperty || !ts.isArrayLiteralExpression(importsProperty.initializer)) {
return null;
}
const importsArray = importsProperty.initializer;
const elementsToRemove = new Set([ngStyleStr]);
if (removeCommonModule) {
elementsToRemove.add(commonModuleImportsStr);
}
const originalElements = importsArray.elements;
const filteredElements = originalElements.filter((el) => !ts.isIdentifier(el) || !elementsToRemove.has(el.text));
if (filteredElements.length === originalElements.length) {
return null; // No changes needed.
}
// If the array becomes empty, remove the entire `imports` property.
if (filteredElements.length === 0) {
const removalRange = getPropertyRemovalRange(importsProperty);
return new project_paths.Replacement(file, new project_paths.TextUpdate({
position: removalRange.start,
end: removalRange.end,
toInsert: '',
}));
}
const printer = ts.createPrinter();
const newArray = ts.factory.updateArrayLiteralExpression(importsArray, filteredElements);
const newText = printer.printNode(ts.EmitHint.Unspecified, newArray, classNode.getSourceFile());
return new project_paths.Replacement(file, new project_paths.TextUpdate({
position: importsArray.getStart(),
end: importsArray.getEnd(),
toInsert: newText,
}));
}
/**
* Calculates the removal range for a property in an object literal,
* including the trailing comma if it's not the last property.
*/
function getPropertyRemovalRange(property) {
const parent = property.parent;
if (!ts.isObjectLiteralExpression(parent)) {
return { start: property.getStart(), end: property.getEnd() };
}
const properties = parent.properties;
const propertyIndex = properties.indexOf(property);
const end = property.getEnd();
if (propertyIndex < properties.length - 1) {
const nextProperty = properties[propertyIndex + 1];
return { start: property.getStart(), end: nextProperty.getStart() };
}
return { start: property.getStart(), end };
}
function calculateImportReplacements(info, sourceFiles, filesToRemoveCommonModule) {
const importReplacements = {};
const importManager = new migrations.ImportManager();
for (const sf of sourceFiles) {
const file = project_paths.projectFile(sf, info);
// Always remove NgStyle if it's imported directly.
importManager.removeImport(sf, ngStyleStr, commonModuleStr);
// Conditionally remove CommonModule if it's no longer needed.
if (filesToRemoveCommonModule.has(file.id)) {
importManager.removeImport(sf, commonModuleImportsStr, commonModuleStr);
}
const addRemove = [];
apply_import_manager.applyImportManagerChanges(importManager, addRemove, [sf], info);
if (addRemove.length > 0) {
importReplacements[file.id] = {
add: [],
addAndRemove: addRemove,
};
}
}
return importReplacements;
}
function replaceTemplate(template, replaceValue, start, end, offset) {
return template.slice(0, start + offset) + replaceValue + template.slice(end + offset);
}
/**
* Visitor class that scans Angular templates and collects replacements
* for [ngStyle] bindings that use static object literals.
*/
class NgStyleCollector extends compiler.RecursiveVisitor {
originalTemplate;
replacements = [];
isNgStyleImported = true; // Default to true (permissive)
constructor(originalTemplate, componentNode, typeChecker) {
super();
this.originalTemplate = originalTemplate;
// If we have enough information, check if NgStyle is actually imported.
// If not, we can confidently disable the migration for this component.
if (componentNode && typeChecker) {
const imports$1 = imports.getImportSpecifiers(componentNode.getSourceFile(), commonModuleStr, [
ngStyleStr,
commonModuleImportsStr,
]);
if (imports$1.length === 0) {
this.isNgStyleImported = false;
}
}
}
visitElement(element, config) {
// If NgStyle is not imported, do not attempt to migrate.
if (!this.isNgStyleImported) {
return;
}
for (const attr of element.attrs) {
if (attr.name !== '[ngStyle]' && attr.name !== 'ngStyle') {
continue;
}
if (attr.name === '[ngStyle]' && attr.valueSpan) {
const expr = this.originalTemplate.slice(attr.valueSpan.start.offset, attr.valueSpan.end.offset);
const staticMatch = parseStaticObjectLiteral(expr);
if (staticMatch === null) {
if (config.bestEffortMode && !isObjectLiteralSyntax(expr.trim())) {
const keyReplacement = this.originalTemplate
.slice(attr.sourceSpan.start.offset, attr.valueSpan.start.offset)
.replace('[ngStyle]', '[style]');
this.replacements.push({
start: attr.sourceSpan.start.offset,
end: attr.valueSpan.start.offset,
replacement: keyReplacement,
});
}
continue;
}
let replacement;
if (staticMatch.length === 0) {
replacement = '';
}
else if (staticMatch.length === 1) {
const { key, value } = staticMatch[0];
// Special case: If the key is an empty string, use [style]=""
if (key === '') {
replacement = '';
}
else {
// Normal single condition: use [style.styleName]="condition"
replacement = `[style.${key}]="${value}"`;
}
}
else {
replacement = `[style]="${expr}"`;
}
this.replacements.push({
start: attr.sourceSpan.start.offset,
end: attr.sourceSpan.end.offset,
replacement,
});
continue;
}
if (attr.name === 'ngStyle' && attr.value) {
this.replacements.push({
start: attr.sourceSpan.start.offset,
end: attr.sourceSpan.end.offset,
replacement: `style="${attr.value}"`,
});
}
}
return super.visitElement(element, config);
}
}
function parseStaticObjectLiteral(expr) {
const trimmedExpr = expr.trim();
if (trimmedExpr === '{}' || trimmedExpr === '[]') {
return [];
}
if (!isObjectLiteralSyntax(trimmedExpr)) {
return null;
}
const objectLiteral = parseAsObjectLiteral(trimmedExpr);
if (!objectLiteral) {
return null;
}
return extractStyleBindings(objectLiteral);
}
/**
* Validates basic object literal syntax
*/
function isObjectLiteralSyntax(expr) {
return expr.startsWith('{') && expr.endsWith('}');
}
/**
* Parses expression as TypeScript object literal
*/
function parseAsObjectLiteral(expr) {
const sourceFile = ts.createSourceFile('temp.ts', `const obj = ${expr}`, ts.ScriptTarget.Latest, true);
const variableStatement = sourceFile.statements[0];
if (!ts.isVariableStatement(variableStatement)) {
return null;
}
const declaration = variableStatement.declarationList.declarations[0];
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) {
return null;
}
return declaration.initializer;
}
function extractStyleBindings(objectLiteral) {
const result = [];
for (const property of objectLiteral.properties) {
if (ts.isShorthandPropertyAssignment(property)) {
return null;
}
else if (ts.isPropertyAssignment(property)) {
const keyText = extractPropertyKey(property.name);
const valueText = extractPropertyValue(property.initializer);
if (keyText === '' && valueText) {
result.push({ key: '', value: valueText });
continue;
}
if (!keyText || !valueText) {
return null;
}
result.push({ key: keyText, value: valueText });
}
else {
return null;
}
}
return result;
}
/**
* Extracts text from property key (name)
*/
function extractPropertyKey(name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
/**
* Extracts text from property value
*/
function extractPropertyValue(initializer) {
// String literals: 'value' or "value"
if (ts.isStringLiteral(initializer)) {
return `'${initializer.text}'`;
}
// Numeric literals: 42, 3.14
if (ts.isNumericLiteral(initializer) || ts.isIdentifier(initializer)) {
return initializer.text;
}
// Boolean and null keywords
if (initializer.kind === ts.SyntaxKind.TrueKeyword) {
return 'true';
}
if (initializer.kind === ts.SyntaxKind.FalseKeyword) {
return 'false';
}
if (initializer.kind === ts.SyntaxKind.NullKeyword) {
return 'null';
}
return initializer.getText();
}
class NgStyleMigration extends project_paths.TsurgeFunnelMigration {
config;
constructor(config = {}) {
super();
this.config = config;
}
processTemplate(template, node, file, info, typeChecker) {
const { migrated, changed, replacementCount, canRemoveCommonModule } = migrateNgStyleBindings(template.content, this.config, node, typeChecker);
if (!changed) {
return null;
}
const fileToMigrate = template.inline
? file
: project_paths.projectFile(template.filePath, info);
const end = template.start + template.content.length;
return {
replacements: [prepareTextReplacement(fileToMigrate, migrated, template.start, end)],
replacementCount,
canRemoveCommonModule,
};
}
async analyze(info) {
const { sourceFiles, program } = info;
const typeChecker = program.getTypeChecker();
const ngStyleReplacements = [];
const filesWithNgStyleDeclarations = new Set();
const filesToRemoveCommonModule = new Set();
for (const sf of sourceFiles) {
ts.forEachChild(sf, (node) => {
if (!ts.isClassDeclaration(node)) {
return;
}
const file = project_paths.projectFile(sf, info);
if (this.config.shouldMigrate && !this.config.shouldMigrate(file)) {
return;
}
const templateVisitor = new ng_component_template.NgComponentTemplateVisitor(typeChecker);
templateVisitor.visitNode(node);
const replacementsForStyle = [];
let replacementCountForStyle = 0;
let canRemoveCommonModuleForFile = true;
for (const template of templateVisitor.resolvedTemplates) {
const result = this.processTemplate(template, node, file, info, typeChecker);
if (result) {
replacementsForStyle.push(...result.replacements);
replacementCountForStyle += result.replacementCount;
if (!result.canRemoveCommonModule) {
canRemoveCommonModuleForFile = false;
}
}
}
if (replacementsForStyle.length > 0) {
if (canRemoveCommonModuleForFile) {
filesToRemoveCommonModule.add(file.id);
}
// Handle the `@Component({ imports: [...] })` array.
const importsRemoval = createNgStyleImportsArrayRemoval(node, file, typeChecker, canRemoveCommonModuleForFile);
if (importsRemoval) {
replacementsForStyle.push(importsRemoval);
}
ngStyleReplacements.push({
file,
replacementCount: replacementCountForStyle,
replacements: replacementsForStyle,
});
filesWithNgStyleDeclarations.add(sf);
}
});
}
const importReplacements = calculateImportReplacements(info, filesWithNgStyleDeclarations, filesToRemoveCommonModule);
return project_paths.confirmAsSerializable({
ngStyleReplacements,
importReplacements,
});
}
async combine(unitA, unitB) {
const importReplacements = {};
for (const unit of [unitA, unitB]) {
for (const fileIDStr of Object.keys(unit.importReplacements)) {
const fileID = fileIDStr;
importReplacements[fileID] = unit.importReplacements[fileID];
}
}
return project_paths.confirmAsSerializable({
ngStyleReplacements: [...unitA.ngStyleReplacements, ...unitB.ngStyleReplacements],
importReplacements,
});
}
async globalMeta(combinedData) {
return project_paths.confirmAsSerializable({
ngStyleReplacements: combinedData.ngStyleReplacements,
importReplacements: combinedData.importReplacements,
});
}
async stats(globalMetadata) {
const touchedFilesCount = globalMetadata.ngStyleReplacements.length;
const replacementCount = globalMetadata.ngStyleReplacements.reduce((acc, cur) => acc + cur.replacementCount, 0);
return project_paths.confirmAsSerializable({
touchedFilesCount,
replacementCount,
});
}
async migrate(globalData) {
const replacements = [];
replacements.push(...globalData.ngStyleReplacements.flatMap(({ replacements }) => replacements));
for (const fileIDStr of Object.keys(globalData.importReplacements)) {
const fileID = fileIDStr;
const importReplacements = globalData.importReplacements[fileID];
replacements.push(...importReplacements.addAndRemove);
}
return { replacements };
}
}
function prepareTextReplacement(file, replacement, start, end) {
return new project_paths.Replacement(file, new project_paths.TextUpdate({
position: start,
end: end,
toInsert: replacement,
}));
}
function migrate(options) {
return async (tree, context) => {
await project_paths.runMigrationInDevkit({
tree,
getMigration: (fs) => new NgStyleMigration({
bestEffortMode: options.bestEffortMode,
shouldMigrate: (file) => {
return (file.rootRelativePath.startsWith(fs.normalize(options.path)) &&
!/(^|\/)node_modules\//.test(file.rootRelativePath));
},
}),
beforeProgramCreation: (tsconfigPath, stage) => {
if (stage === project_paths.MigrationStage.Analysis) {
context.logger.info(`Preparing analysis for: ${tsconfigPath}...`);
}
else {
context.logger.info(`Running migration for: ${tsconfigPath}...`);
}
},
beforeUnitAnalysis: (tsconfigPath) => {
context.logger.info(`Scanning for component tags: ${tsconfigPath}...`);
},
afterAllAnalyzed: () => {
context.logger.info(``);
context.logger.info(`Processing analysis data between targets...`);
context.logger.info(``);
},
afterAnalysisFailure: () => {
context.logger.error('Migration failed unexpectedly with no analysis data');
},
whenDone: ({ touchedFilesCount, replacementCount, }) => {
context.logger.info('');
context.logger.info(`Successfully migrated to style bindings from ngStyle 🎉`);
context.logger.info(` -> Migrated ${replacementCount} ngStyle to style bindings in ${touchedFilesCount} files.`);
},
});
};
}
exports.migrate = migrate;