File

libs/nx/src/migrations/update-core-restructure/update-core-restructure.ts

Index

Properties

Properties

symbols
targetPackage
Type string
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[] }
  );
}

results matching ""

    No results matching ""