import { formatFiles, getProjects, Tree, visitNotIgnoredFiles } from '@nx/devkit';
import {
BindingElement,
CallExpression,
ImportDeclaration,
ImportSpecifier,
Node,
ObjectBindingPattern,
Project,
QuoteKind,
SourceFile
} from 'ts-morph';
import { findTsConfigInProjectRoot } from '../utils/ts-morph-utils';
import { NxTreeFileSystemHost } from '../utils/ts-morph-tree-file-system-host';
import { parse, stringify } from 'comment-json';
import { containsPageConfiguration } from '../utils/file-utils';
interface PackageMigration {
readonly symbols: readonly string[];
readonly targetPackage: string;
}
const COMMON_PACKAGE = '@allianz/taly-common';
const CORE_PACKAGE = '@allianz/taly-core';
// Target sub-packages introduced
const NEW_STANDALONE_FORM_PACKAGE = `${CORE_PACKAGE}/dynamic-form/standalone`;
const NEW_BUILDING_BLOCKS_PACKAGE = `${CORE_PACKAGE}/building-blocks`;
const NEW_DEVTOOLS_PACKAGE = `${CORE_PACKAGE}/devtools`;
const NEW_UI_PACKAGE = `${CORE_PACKAGE}/ui`;
const NEW_FRAME_PACKAGE = `${CORE_PACKAGE}/frame`;
const DYNAMIC_FORM_PACKAGE = `${CORE_PACKAGE}/dynamic-form`;
const DF_FORM_COMPONENT = 'DfFormComponent';
const INTERNAL_DF_FORM_COMPONENT = 'InternalDfFormComponent';
const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies'];
// Whole sub-path imports that moved as a unit
const SUB_PATH_MAPPINGS: Record<string, string> = {
[`${COMMON_PACKAGE}/headline`]: NEW_UI_PACKAGE,
[`${CORE_PACKAGE}/internal-headline`]: NEW_UI_PACKAGE,
[`${CORE_PACKAGE}/validation-errors`]: NEW_UI_PACKAGE,
[`${COMMON_PACKAGE}/normalize-url`]: CORE_PACKAGE,
[`${COMMON_PACKAGE}/typed-forms`]: CORE_PACKAGE,
[`${COMMON_PACKAGE}/utils`]: CORE_PACKAGE,
[`${CORE_PACKAGE}/dynamic-form-editor`]: `${NEW_DEVTOOLS_PACKAGE}/dynamic-form-editor`
};
// Individual symbols that moved to a different package
const SYMBOL_MIGRATIONS: readonly PackageMigration[] = [
{
targetPackage: CORE_PACKAGE,
symbols: ['NormalizeUrlPipe', 'NormalizeUrlModule', 'DEPLOY_URL', 'Deferred']
},
{
targetPackage: NEW_STANDALONE_FORM_PACKAGE,
symbols: [
'provideDynamicFormNativeComponents',
'TalyDynamicFormComponent',
'provideTalyStandaloneDynamicForm'
]
},
{
targetPackage: NEW_BUILDING_BLOCKS_PACKAGE,
symbols: [
// abstract-building-block.ts
'AbstractBuildingBlock',
'RecordObjectLike',
'NoArray',
// building-block-interface.ts
'BuildingBlockInterface',
'BusinessEvent',
// navigation.ts
'BUILDING_BLOCK_NAVIGATION_TYPE',
'BuildingBlockNavigationEvent',
'isImplicitNavigation',
// create-building-block-provider.ts
'createBuildingBlockProvider',
// facade
'AbstractBuildingBlockFacade',
'NoopFacade',
// page
'AbstractBuildingBlockPage',
'ChunkLoadingErrorPageComponent',
'ChunkLoadingErrorPageModule',
// taly-placeholder-bb
'PlaceholderComponent',
'PlaceholderModule',
'PlaceHolderResources',
'PlaceholderComponentState',
'placeholderExampleData',
// services
'BuildingBlockMetaData',
'BuildingBlockPageMetaData',
'BuildingBlockMetaServiceInterface',
'BuildingBlockMetaService',
'TalyResourcesService',
// dynamic-form-bb
'DynamicFormBuildingBlock',
'DynamicFormBuildingBlockModule',
'DynamicFormBbResources',
'DynamicFormBbState',
'dynamicFormBuildingBlockExampleData'
]
},
{
targetPackage: NEW_DEVTOOLS_PACKAGE,
symbols: [
'BuildingBlockDebuggerModule',
'BuildingBlockDebugger',
'DynamicFormDebuggerService',
'DynamicFormDebugger',
'DynamicFormDebuggerAction',
'ShowroomHeaderComponent',
'ShowroomHeaderModule',
'JourneyInsightsModule',
'JourneyInsightsComponent',
'JourneyInsights',
'DebugToolsService'
]
},
{
targetPackage: NEW_UI_PACKAGE,
symbols: ['MarkdownToHtmlComponent', 'TalyMarkdownToHtmlService']
},
{
targetPackage: NEW_FRAME_PACKAGE,
symbols: ['TalyFootnoteComponent']
}
];
export default async function updateCoreRestructure(tree: Tree) {
const projects = getProjects(tree);
let hasUpdate = false;
for (const [, project] of projects) {
const tsConfigFilePath = findTsConfigInProjectRoot(tree, project.root);
const tsMorphProject = new Project({
tsConfigFilePath,
fileSystem: new NxTreeFileSystemHost(tree),
manipulationSettings: {
quoteKind: QuoteKind.Single
}
});
visitNotIgnoredFiles(tree, project.root, (filePath) => {
const updatedContent = processFile(tree, filePath, tsMorphProject);
if (updatedContent) {
hasUpdate = true;
}
});
}
// Update root package.json
const updatedContent = handlePackageJson(tree, 'package.json');
if (updatedContent) {
hasUpdate = true;
}
if (hasUpdate) {
await formatFiles(tree);
}
}
function processFile(tree: Tree, filePath: string, tsMorphProject: Project): boolean {
const content = tree.read(filePath, 'utf-8');
if (content === null) {
throw new Error(`Could not read file content from "${filePath}"!`);
}
if (filePath.endsWith('project.json')) {
return handleProjectJson(tree, filePath, content);
}
if (filePath.endsWith('package.json')) {
return handlePackageJson(tree, filePath);
}
if (filePath.endsWith('.ts')) {
return handleTsFile(tree, filePath, content, tsMorphProject);
}
if (isTalyJsonConfigFile(filePath, content)) {
return handleJsoncFile(tree, filePath, content);
}
return false;
}
function handleProjectJson(tree: Tree, filePath: string, content: string): boolean {
if (!content.includes(COMMON_PACKAGE)) {
return false;
}
// Replacing any occurrence of the common package with the core package.
const updatedContent = content.replaceAll(COMMON_PACKAGE, CORE_PACKAGE);
tree.write(filePath, updatedContent);
return true;
}
function handlePackageJson(tree: Tree, filePath: string): boolean {
const updatedContent = updatePackageJsonDependencies(tree, filePath);
if (updatedContent) {
tree.write(filePath, updatedContent);
return true;
}
return false;
}
function handleTsFile(
tree: Tree,
filePath: string,
originalContent: string,
tsMorphProject: Project
): boolean {
const sourceFile = tsMorphProject.addSourceFileAtPath(filePath);
const updatedContent = migratePackageImports(sourceFile);
if (updatedContent && updatedContent !== originalContent) {
tree.write(filePath, updatedContent);
return true;
}
return false;
}
function handleJsoncFile(tree: Tree, filePath: string, content: string): boolean {
const updatedContent = updateJsoncFile(content);
if (updatedContent && updatedContent !== content) {
tree.write(filePath, updatedContent);
return true;
}
return false;
}
function migratePackageImports(sourceFile: SourceFile): string | undefined {
const fullSourceContent = sourceFile.getFullText();
if (!fullSourceContent.includes(COMMON_PACKAGE) && !fullSourceContent.includes(CORE_PACKAGE)) {
return undefined;
}
updateDynamicImports(sourceFile);
updateImportDeclarations(sourceFile);
return sourceFile.getFullText();
}
function updateImportDeclarations(sourceFile: SourceFile): void {
const importDeclarations = sourceFile.getImportDeclarations().filter((importDecl) => {
const specifier = importDecl.getModuleSpecifierValue();
return specifier.includes(CORE_PACKAGE) || specifier.includes(COMMON_PACKAGE);
});
importDeclarations.forEach((declaration) => {
const specifier = declaration.getModuleSpecifierValue();
if (SUB_PATH_MAPPINGS[specifier]) {
declaration.setModuleSpecifier(SUB_PATH_MAPPINGS[specifier]);
return;
}
if (specifier === DYNAMIC_FORM_PACKAGE) {
renameImportSpecifier(declaration);
}
for (const migration of SYMBOL_MIGRATIONS) {
updateStaticImportPackage(migration, declaration, sourceFile);
}
});
sourceFile
.getImportDeclarations()
.filter((declaration) => declaration.getModuleSpecifierValue().includes(COMMON_PACKAGE))
.forEach((declaration) =>
declaration.setModuleSpecifier(
declaration.getModuleSpecifierValue().replace(COMMON_PACKAGE, CORE_PACKAGE)
)
);
}
// Rename DfFormComponent to InternalDfFormComponent
function renameImportSpecifier(declaration: ImportDeclaration): void {
const namedImports = declaration.getNamedImports();
namedImports.forEach((namedImport) => {
const importName = namedImport.getName();
if (importName === DF_FORM_COMPONENT) {
const aliasNode = namedImport.getAliasNode();
const nameNode = namedImport.getNameNode();
if (Node.isIdentifier(nameNode)) {
if (aliasNode) {
// Has alias: import { DfFormComponent as X }
// Only change the import name, not the alias usages
nameNode.replaceWithText(INTERNAL_DF_FORM_COMPONENT);
} else {
// No alias: import { DfFormComponent }
// Rename everywhere (providers, declarations, etc.)
nameNode.rename(INTERNAL_DF_FORM_COMPONENT);
}
}
}
});
}
/**
* Moves matched imports from a static import declaration to their new package.
*
* Example — all imports match:
* import { TalyPageService } from '@allianz/taly-core';
* import { TalyPageService } from '@allianz/taly-core/building-blocks'; (new)
*
* Example — mixed imports:
* import { TalyPageService, TalyStateService } from '@allianz/taly-core';
* import { TalyStateService } from '@allianz/taly-core'; (unchanged)
* import { TalyPageService } from '@allianz/taly-core/building-blocks'; (new)
*/
function updateStaticImportPackage(
migration: PackageMigration,
declaration: ImportDeclaration,
sourceFile: SourceFile
): void {
const symbolsToMigrate = new Set(migration.symbols);
const namedImports = declaration.getNamedImports();
const { matchingImports, otherImports } = separateMatchingImports(namedImports, symbolsToMigrate);
if (matchingImports.length === 0) {
return;
}
if (otherImports.length === 0) {
declaration.setModuleSpecifier(migration.targetPackage);
} else {
// Mixed imports: extract matched ones into a new declaration for the target package.
const matchedStructures = matchingImports.map((imp) => imp.getStructure());
matchingImports.forEach((imp) => imp.remove());
// Insert after current declaration to maintain order
const insertIndex = declaration.getChildIndex();
sourceFile.insertImportDeclaration(insertIndex + 1, {
moduleSpecifier: migration.targetPackage,
namedImports: matchedStructures
});
}
}
function updateDynamicImports(sourceFile: SourceFile): void {
sourceFile.forEachDescendant((node) => {
// Only process import() expressions
if (!Node.isCallExpression(node) || !Node.isImportExpression(node.getExpression())) return;
const specifierNode = node.getArguments()[0];
if (!Node.isStringLiteral(specifierNode)) return;
const specifierValue = specifierNode.getLiteralValue();
if (SUB_PATH_MAPPINGS[specifierValue]) {
specifierNode.replaceWithText(`'${SUB_PATH_MAPPINGS[specifierValue]}'`);
return;
}
if (specifierValue === DYNAMIC_FORM_PACKAGE) {
renameDynamicImportBinding(node);
}
splitDynamicImport(node);
// Re-check specifier value in case splitDynamicImport modified it
const currentSpecifierValue = specifierNode.getLiteralValue();
if (currentSpecifierValue.includes(COMMON_PACKAGE)) {
specifierNode.replaceWithText(
`'${currentSpecifierValue.replace(COMMON_PACKAGE, CORE_PACKAGE)}'`
);
}
});
}
function renameDynamicImportBinding(callExpression: CallExpression): void {
const variableDeclaration = callExpression.getParent()?.getParent();
if (!Node.isVariableDeclaration(variableDeclaration)) return;
const bindingName = variableDeclaration.getNameNode();
if (!Node.isObjectBindingPattern(bindingName)) return;
bindingName.getElements().forEach((element) => {
const propertyName = element.getPropertyNameNode();
const nameNode = element.getNameNode();
// Check if this is DfFormComponent (either as property name or direct name)
const isDfFormComponent = propertyName
? propertyName.getText() === DF_FORM_COMPONENT
: nameNode?.getText() === DF_FORM_COMPONENT;
if (isDfFormComponent && nameNode && Node.isIdentifier(nameNode)) {
if (propertyName && propertyName !== nameNode) {
// Has alias: { DfFormComponent: alias }
// Only change the import property name
propertyName.replaceWithText(INTERNAL_DF_FORM_COMPONENT);
} else {
// No alias: { DfFormComponent }
// Rename everywhere (providers, declarations, lazy loading, etc.)
nameNode.rename(INTERNAL_DF_FORM_COMPONENT);
}
}
});
}
function splitDynamicImport(callExpression: CallExpression): void {
const variableDeclaration = callExpression.getParent()?.getParent();
if (!Node.isVariableDeclaration(variableDeclaration)) return;
const specifierNode = callExpression.getArguments()[0];
if (!Node.isStringLiteral(specifierNode)) return;
for (const migration of SYMBOL_MIGRATIONS) {
// Refresh binding pattern for each iteration since it may have been modified
const bindingName = variableDeclaration.getNameNode();
if (!Node.isObjectBindingPattern(bindingName)) continue;
updateDynamicImportPackage(migration, callExpression, bindingName);
}
}
/**
* Moves matched imports from a dynamic import to their new package.
*
* Example — all imports match:
* const { TalyPageService } = await import('@allianz/taly-core');
* const { TalyPageService } = await import('@allianz/taly-core/building-blocks'); (new)
*
* Example — mixed imports:
* const { TalyPageService, TalyStateService } = await import('@allianz/taly-core');
* const { TalyStateService } = await import('@allianz/taly-core'); (unchanged)
* const { TalyPageService } = await import('@allianz/taly-core/building-blocks'); (new)
*/
function updateDynamicImportPackage(
migration: PackageMigration,
callExpression: CallExpression,
bindingPattern: ObjectBindingPattern
) {
const symbolsToMigrate = new Set(migration.symbols);
const elements = bindingPattern.getElements();
const { matchingImports, otherImports } = separateMatchingImports(elements, symbolsToMigrate);
if (matchingImports.length === 0) return;
if (otherImports.length === 0) {
// All elements go to new package
callExpression.getArguments()[0].replaceWithText(`'${migration.targetPackage}'`);
} else {
// Mixed imports: extract matched ones into a new import() for the target package.
const remainingText = otherImports.map((element) => element.getText()).join(', ');
const matchedText = matchingImports.map((element) => element.getText()).join(', ');
bindingPattern.replaceWithText(`{ ${remainingText} }`);
const newStatement = `const { ${matchedText} } = await import('${migration.targetPackage}');`;
// Insert new statement after current statement
const variableStatement = callExpression.getFirstAncestor(Node.isVariableStatement);
const parent = variableStatement?.getParent();
if (parent && Node.isStatemented(parent)) {
parent.insertStatements(
variableStatement ? variableStatement.getChildIndex() + 1 : 0,
newStatement
);
}
}
}
function updatePackageJsonDependencies(tree: Tree, packageJsonPath: string): string | undefined {
if (!tree.exists(packageJsonPath)) {
return undefined;
}
const packageJsonContent = tree.read(packageJsonPath, 'utf-8');
if (!packageJsonContent?.includes(COMMON_PACKAGE)) {
return undefined;
}
try {
const packageJson = JSON.parse(packageJsonContent);
let hasChanges = false;
for (const section of DEPENDENCY_SECTIONS) {
const dependencies = packageJson[section];
if (!dependencies || !(COMMON_PACKAGE in dependencies)) {
continue;
}
if (CORE_PACKAGE in dependencies) {
// taly-core exists, just remove taly-common
delete dependencies[COMMON_PACKAGE];
} else {
// taly-core doesn't exist, replace taly-common with taly-core
dependencies[CORE_PACKAGE] = dependencies[COMMON_PACKAGE];
delete dependencies[COMMON_PACKAGE];
}
hasChanges = true;
}
return hasChanges ? JSON.stringify(packageJson, null, 2) : undefined;
} catch (error) {
console.error(`Failed to parse JSON from ${packageJsonPath}:`, error);
return undefined;
}
}
function updateJsoncFile(content: string): string | undefined {
try {
const jsonContent = parse(content);
let hasChanges = false;
visitJsonNodes(jsonContent, (node) => {
if (
(node['module'] === 'PlaceholderModule' && node['package'] === CORE_PACKAGE) ||
(node['module'] === 'DynamicFormBuildingBlockModule' && node['package'] === COMMON_PACKAGE)
) {
node['package'] = NEW_BUILDING_BLOCKS_PACKAGE;
hasChanges = true;
}
});
return hasChanges ? stringify(jsonContent, null, 2) : undefined;
} catch (error) {
console.error('Failed to parse JSONC content:', error);
return undefined;
}
}
function visitJsonNodes(node: unknown, visitor: (obj: Record<string, unknown>) => void): void {
if (Array.isArray(node)) {
node.forEach((item) => visitJsonNodes(item, visitor));
} else if (node !== null && typeof node === 'object') {
visitor(node as Record<string, unknown>);
Object.values(node).forEach((value) => visitJsonNodes(value, visitor));
}
}
function isTalyJsonConfigFile(filePath: string, content: string): boolean {
return containsPageConfiguration(filePath) && content.includes('@allianz/taly');
}
/**
* Splits import specifiers or binding elements into two groups based on whether
* they appear in the migration's symbol set.
*
* Example:
* import { TalyPageService, TalyStateService } from '@allianz/taly-core';
* symbolsToMigrate = ['TalyPageService']
* matchingImports: [TalyPageService], otherImports: [TalyStateService]
*/
function separateMatchingImports<T extends ImportSpecifier | BindingElement>(
items: readonly T[],
symbolsToMigrate: Set<string>
): { matchingImports: T[]; otherImports: T[] } {
return items.reduce(
(acc, item) => {
let importedSymbolName = item.getName();
// For aliased dynamic imports like `{ TalyPageService: MyService }`,
// getName() returns the local alias ('MyService'), not the imported symbol ('TalyPageService').
// We need the property name (the original symbol) to correctly match against symbolsToMigrate.
if (Node.isBindingElement(item)) {
const propertyNameNode = item.getPropertyNameNode();
if (propertyNameNode) {
importedSymbolName = propertyNameNode.getText();
}
}
if (symbolsToMigrate.has(importedSymbolName)) {
acc.matchingImports.push(item);
} else {
acc.otherImports.push(item);
}
return acc;
},
{ matchingImports: [] as T[], otherImports: [] as T[] }
);
}