Jelajahi Sumber

feat(cli): add option replace all imports in src/ (#775)

* chore: e2e pipeline

* fix: language-typescript eslint

* feat: materials update imports in porject

* feat: form-materials sh

* feat: save without formatting

* feat: e2e formatting

* feat: revert format

* chore: yml format
Yiwei Mao 4 bulan lalu
induk
melakukan
0dfca67aae

+ 1 - 1
.github/workflows/e2e.yml

@@ -24,7 +24,7 @@ jobs:
         run: node common/scripts/install-run-rush.js build
 
       - name: Install Playwright Browsers
-        run: pushd e2e/fixed-layout && npx playwright install --with-deps && popd
+        run: pushd e2e/fixed-layout && npx playwright install --with-deps --only-shell chromium && popd
 
       - name: Run E2E tests
         run: node common/scripts/install-run-rush.js e2e:test --verbose

+ 3 - 2
apps/cli/src/index.ts

@@ -24,8 +24,9 @@ program
   .command("materials")
   .description("Sync materials to the project")
   .argument('[string]', 'Material name')
-  .action(async (materialName) => {
-    await syncMaterial(materialName);
+  .option('--refresh-project-imports', 'Refresh project imports to copied materials', false)
+  .action(async (materialName, options) => {
+    await syncMaterial({ materialName, refreshProjectImports: options.refreshProjectImports });
   });
 
 program.parse(process.argv);

+ 23 - 9
apps/cli/src/materials/index.ts

@@ -10,17 +10,25 @@ import { copyMaterial, listAllMaterials, Material } from "./materials";
 import { loadNpm } from "../utils/npm";
 import path from "path";
 import { Project } from "../utils/project";
+import { executeRefreshProjectImport } from "./refresh-project-import";
+
+export async function syncMaterial(opts: {
+  materialName?: string;
+  refreshProjectImports?: boolean;
+}) {
+  const { materialName, refreshProjectImports } = opts;
 
-export async function syncMaterial(materialName?: string) {
   // materialName can be undefined
-  console.log(chalk.bgGreenBright("Welcome to @flowgram.ai form-materials!"));
+  console.log(chalk.bold("🚀 Welcome to @flowgram.ai form-materials!"));
 
   const project = await Project.getSingleton();
   project.printInfo();
 
   if (!project.flowgramVersion) {
     throw new Error(
-      "Please install @flowgram.ai/fixed-layout-editor or @flowgram.ai/free-layout-editor",
+      chalk.red(
+        "❌ Please install @flowgram.ai/fixed-layout-editor or @flowgram.ai/free-layout-editor",
+      ),
     );
   }
 
@@ -31,7 +39,7 @@ export async function syncMaterial(materialName?: string) {
 
   let material: Material | undefined; // material can be undefined
 
-  // Check if materialName is provided and exists in materials
+  // 1. Check if materialName is provided and exists in materials
   if (materialName) {
     const selectedMaterial = materials.find(
       (m) => `${m.type}/${m.name}` === materialName,
@@ -48,7 +56,7 @@ export async function syncMaterial(materialName?: string) {
     }
   }
 
-  // If material not found or materialName not provided, prompt user to select
+  // 2. If material not found or materialName not provided, prompt user to select
   if (!material) {
     // User select one component
     const result = await inquirer.prompt<{
@@ -74,9 +82,15 @@ export async function syncMaterial(materialName?: string) {
     process.exit(1);
   }
 
+  // 3. Refresh project imports
+  if (refreshProjectImports) {
+    console.log(chalk.bold("🚀 Refresh imports in your project"));
+    executeRefreshProjectImport(project, material);
+  }
+
   // 4. Copy the materials to the project
   console.log(
-    chalk.bold("The following materials will be added to your project"),
+    chalk.bold("🚀 The following materials will be added to your project"),
   );
   console.log(material);
   let { packagesToInstall } = copyMaterial(material, project, formMaterialPath);
@@ -84,10 +98,10 @@ export async function syncMaterial(materialName?: string) {
   // 4. Install the dependencies
   await project.addDependencies(packagesToInstall);
   console.log(
-    chalk.bold("These npm dependencies is added to your package.json"),
+    chalk.bold("These npm dependencies is added to your package.json"),
   );
   packagesToInstall.forEach((_package) => {
     console.log(`- ${_package}`);
-  })
-  console.log(chalk.bold("\nPlease run npm install to install dependencies"));
+  });
+  console.log(chalk.bold("\n➡️ Please run npm install to install dependencies\n"));
 }

+ 32 - 39
apps/cli/src/materials/materials.ts

@@ -3,16 +3,11 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { fileURLToPath } from "url";
 import path from "path";
 import fs from "fs";
 
-import { traverseRecursiveFiles } from "../utils/traverse-file";
-import { replaceImport, traverseFileImports } from "../utils/import";
 import { Project } from "../utils/project"; // Import ProjectInfo
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
+import { traverseRecursiveTsFiles } from "../utils/ts-file";
 
 // Added type definitions
 export interface Material {
@@ -77,7 +72,8 @@ export const copyMaterial = (
 ): {
   packagesToInstall: string[];
 } => {
-  const formMaterialDependencies = getFormMaterialDependencies(formMaterialPath);
+  const formMaterialDependencies =
+    getFormMaterialDependencies(formMaterialPath);
 
   const sourceDir: string = material.path;
   const materialRoot: string = path.join(
@@ -91,38 +87,35 @@ export const copyMaterial = (
 
   fs.cpSync(sourceDir, targetDir, { recursive: true });
 
-  for (const file of traverseRecursiveFiles(targetDir)) {
-    if ([".ts", ".tsx"].includes(file.suffix)) {
-      for (const importDeclaration of traverseFileImports(file.content)) {
-        const { source } = importDeclaration;
-
-        if (source.startsWith("@/")) {
-          // is inner import
-          console.log(
-            `Replace Import from ${source} to @flowgram.ai/form-materials`,
-          );
-          file.replace((content) =>
-            replaceImport(content, importDeclaration, [
-              { ...importDeclaration, source: "@flowgram.ai/form-materials" },
-            ]),
-          );
-          packagesToInstall.add(
-            `@flowgram.ai/form-materials@${project.flowgramVersion}`,
-          );
-        } else if (!source.startsWith(".") && !source.startsWith("react")) {
-          // check if is in form material dependencies
-          const [dep, version] =
-            Object.entries(formMaterialDependencies).find(([_key]) =>
-              source.startsWith(_key),
-            ) || [];
-          if (!dep) {
-            continue;
-          }
-          if (dep.startsWith("@flowgram.ai/")) {
-            packagesToInstall.add(`${dep}@${project.flowgramVersion}`);
-          } else {
-            packagesToInstall.add(`${dep}@${version}`);
-          }
+  for (const file of traverseRecursiveTsFiles(targetDir)) {
+    for (const importDeclaration of file.imports) {
+      const { source } = importDeclaration;
+
+      if (source.startsWith("@/")) {
+        // is inner import
+        console.log(
+          `Replace Import from ${source} to @flowgram.ai/form-materials`,
+        );
+        file.replaceImport(
+          [importDeclaration],
+          [{ ...importDeclaration, source: "@flowgram.ai/form-materials" }],
+        );
+        packagesToInstall.add(
+          `@flowgram.ai/form-materials@${project.flowgramVersion}`,
+        );
+      } else if (!source.startsWith(".") && !source.startsWith("react")) {
+        // check if is in form material dependencies
+        const [dep, version] =
+          Object.entries(formMaterialDependencies).find(([_key]) =>
+            source.startsWith(_key),
+          ) || [];
+        if (!dep) {
+          continue;
+        }
+        if (dep.startsWith("@flowgram.ai/")) {
+          packagesToInstall.add(`${dep}@${project.flowgramVersion}`);
+        } else {
+          packagesToInstall.add(`${dep}@${version}`);
         }
       }
     }

+ 68 - 0
apps/cli/src/materials/refresh-project-import.ts

@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import chalk from "chalk";
+import { ImportDeclaration } from "../utils/import";
+import { Project } from "../utils/project";
+import { getIndexTsFile, traverseRecursiveTsFiles } from "../utils/ts-file";
+import { Material } from "./materials";
+
+export function executeRefreshProjectImport(
+  project: Project,
+  material: Material,
+) {
+  const materialFile = getIndexTsFile(material.path);
+
+  if (!materialFile) {
+    console.warn(`Material ${material.name} not found`);
+    return;
+  }
+
+  const targetDir = `@/form-materials/${material.type}/${material.name}`;
+
+  const exportNames = materialFile.allExportNames;
+
+  console.log(`👀 The exports of ${material.name} is ${exportNames.join(",")}`);
+
+  for (const tsFile of traverseRecursiveTsFiles(project.srcPath)) {
+    for (const importDeclaration of tsFile.imports) {
+      if (importDeclaration.source === "@flowgram.ai/form-materials") {
+        const currentMaterialImports = importDeclaration.namedImports?.filter(
+          (item) => exportNames.includes(item.imported),
+        );
+        if (!currentMaterialImports?.length) {
+          continue;
+        }
+        const nextImports: ImportDeclaration[] = [
+          {
+            ...importDeclaration,
+            namedImports: currentMaterialImports,
+            source: targetDir,
+          },
+        ];
+
+        const keepImportNames = importDeclaration.namedImports?.filter(
+          (item) => !exportNames.includes(item.imported),
+        );
+
+        if (keepImportNames?.length) {
+          nextImports.unshift({
+            ...importDeclaration,
+            namedImports: keepImportNames,
+          });
+        }
+
+        tsFile.replaceImport([importDeclaration], nextImports);
+        console.log(chalk.green(`🔄 Refresh Imports In: ${tsFile.path}`));
+
+        console.log(
+          `From:\n${importDeclaration.statement}\nTo:\n${nextImports
+            .map((item) => item.statement)
+            .join("\n")}`,
+        );
+      }
+    }
+  }
+}

+ 117 - 0
apps/cli/src/utils/export.ts

@@ -0,0 +1,117 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export function extractNamedExports(content: string) {
+  const valueExports = [];
+  const typeExports = [];
+
+  // Collect all type definition names
+  const typeDefinitions = new Set();
+  const typePatterns = [
+    /\b(?:type|interface)\s+(\w+)/g,
+    /\bexport\s+(?:type|interface)\s+(\w+)/g,
+  ];
+
+  let match;
+  for (const pattern of typePatterns) {
+    while ((match = pattern.exec(content)) !== null) {
+      typeDefinitions.add(match[1]);
+    }
+  }
+
+  // Match various export patterns
+  const exportPatterns = [
+    // export const/var/let/function/class/type/interface
+    /\bexport\s+(const|var|let|function|class|type|interface)\s+(\w+)/g,
+    // export { name1, name2 }
+    /\bexport\s*\{([^}]+)\}/g,
+    // export { name as alias }
+    /\bexport\s*\{[^}]*\b(\w+)\s+as\s+(\w+)[^}]*\}/g,
+    // export default function name()
+    /\bexport\s+default\s+(?:function|class)\s+(\w+)/g,
+    // export type { Type1, Type2 }
+    /\bexport\s+type\s*\{([^}]+)\}/g,
+    // export type { Original as Alias }
+    /\bexport\s+type\s*\{[^}]*\b(\w+)\s+as\s+(\w+)[^}]*\}/g,
+  ];
+
+  // Handle first pattern: export const/var/let/function/class/type/interface
+  exportPatterns[0].lastIndex = 0;
+  while ((match = exportPatterns[0].exec(content)) !== null) {
+    const [, kind, name] = match;
+    if (kind === "type" || kind === "interface" || typeDefinitions.has(name)) {
+      typeExports.push(name);
+    } else {
+      valueExports.push(name);
+    }
+  }
+
+  // Handle second pattern: export { name1, name2 }
+  exportPatterns[1].lastIndex = 0;
+  while ((match = exportPatterns[1].exec(content)) !== null) {
+    const exportsList = match[1]
+      .split(",")
+      .map((item) => item.trim())
+      .filter((item) => item && !item.includes(" as "));
+
+    for (const name of exportsList) {
+      if (name.startsWith("type ")) {
+        typeExports.push(name.replace("type ", "").trim());
+      } else if (typeDefinitions.has(name)) {
+        typeExports.push(name);
+      } else {
+        valueExports.push(name);
+      }
+    }
+  }
+
+  // Handle third pattern: export { name as alias }
+  exportPatterns[2].lastIndex = 0;
+  while ((match = exportPatterns[2].exec(content)) !== null) {
+    const [, original, alias] = match;
+    if (typeDefinitions.has(original)) {
+      typeExports.push(alias);
+    } else {
+      valueExports.push(alias);
+    }
+  }
+
+  // Handle fourth pattern: export default function name()
+  exportPatterns[3].lastIndex = 0;
+  while ((match = exportPatterns[3].exec(content)) !== null) {
+    const name = match[1];
+    if (typeDefinitions.has(name)) {
+      typeExports.push(name);
+    } else {
+      valueExports.push(name);
+    }
+  }
+
+  // Handle fifth pattern: export type { Type1, Type2 }
+  exportPatterns[4].lastIndex = 0;
+  while ((match = exportPatterns[4].exec(content)) !== null) {
+    const exportsList = match[1]
+      .split(",")
+      .map((item) => item.trim())
+      .filter((item) => item && !item.includes(" as "));
+
+    for (const name of exportsList) {
+      typeExports.push(name);
+    }
+  }
+
+  // Handle sixth pattern: export type { Original as Alias }
+  exportPatterns[5].lastIndex = 0;
+  while ((match = exportPatterns[5].exec(content)) !== null) {
+    const [, original, alias] = match;
+    typeExports.push(alias);
+  }
+
+  // Deduplicate and sort
+  return {
+    values: [...new Set(valueExports)].sort(),
+    types: [...new Set(typeExports)].sort(),
+  };
+}

+ 1 - 1
apps/cli/src/utils/traverse-file.ts → apps/cli/src/utils/file.ts

@@ -6,7 +6,7 @@
 import path from 'path';
 import fs from 'fs';
 
-class File {
+export class File {
   content: string;
 
   isUtf8: boolean;

+ 3 - 3
apps/cli/src/utils/import.ts

@@ -11,7 +11,7 @@
  * import D, { type E, F } from 'module';
  * import A, { B as B1 } from 'module';
  */
-interface ImportDeclaration {
+export interface ImportDeclaration {
   // origin statement
   statement: string;
 
@@ -50,7 +50,7 @@ export function assembleImport(declaration: ImportDeclaration): string {
   if (namespaceImport) {
     importClauses.push(`* as ${namespaceImport}`);
   }
-  return `import ${importClauses.join(', ')} from '${source}'`;
+  return `import ${importClauses.join(', ')} from '${source}';`;
 }
 
 export function replaceImport(
@@ -66,7 +66,7 @@ export function replaceImport(
 export function* traverseFileImports(fileContent: string): Generator<ImportDeclaration> {
   // 匹配所有 import 语句的正则表达式
   const importRegex =
-    /import\s+([^{}*,]*?)?(?:\s*\*\s*as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,?)?(?:\s*\{([^}]*)\}\s*,?)?(?:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,?)?\s*from\s*['"`]([^'"`]+)['"`]/g;
+    /import\s+([^{}*,]*?)?(?:\s*\*\s*as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,?)?(?:\s*\{([^}]*)\}\s*,?)?(?:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,?)?\s*from\s*['"`]([^'"`]+)['"`]\;?/g;
 
   let match;
   while ((match = importRegex.exec(fileContent)) !== null) {

+ 3 - 0
apps/cli/src/utils/project.ts

@@ -24,6 +24,8 @@ export class Project {
 
   packageJson: PackageJson;
 
+  srcPath: string;
+
   protected constructor() {}
 
   async init() {
@@ -42,6 +44,7 @@ export class Project {
 
     this.projectPath = projectPath;
 
+    this.srcPath = path.join(projectPath, "src");
     this.packageJsonPath = path.join(projectPath, "package.json");
     this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, "utf8"));
 

+ 134 - 0
apps/cli/src/utils/ts-file.ts

@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import path from "path";
+import fs from "fs";
+import { File } from "./file";
+import { extractNamedExports } from "./export";
+import {
+  assembleImport,
+  ImportDeclaration,
+  traverseFileImports,
+} from "./import";
+
+class TsFile extends File {
+  exports: {
+    values: string[];
+    types: string[];
+  } = {
+    values: [],
+    types: [],
+  };
+
+  imports: ImportDeclaration[] = [];
+
+  get allExportNames() {
+    return [...this.exports.values, ...this.exports.types];
+  }
+
+  constructor(filePath: string) {
+    super(filePath);
+
+    this.exports = extractNamedExports(fs.readFileSync(filePath, "utf-8"));
+    this.imports = Array.from(
+      traverseFileImports(fs.readFileSync(filePath, "utf-8")),
+    );
+  }
+
+  addImport(importDeclarations: ImportDeclaration[]) {
+    importDeclarations.forEach((importDeclaration) => {
+      importDeclaration.statement = assembleImport(importDeclaration);
+    });
+    // add in last import statement
+    this.replace((content) => {
+      const lastImportStatement = this.imports[this.imports.length - 1];
+      return content.replace(
+        lastImportStatement.statement,
+        `${lastImportStatement?.statement}\n${importDeclarations.map(
+          (item) => item.statement,
+        )}\n`,
+      );
+    });
+    this.imports.push(...importDeclarations);
+  }
+
+  removeImport(importDeclarations: ImportDeclaration[]) {
+    this.replace((content) =>
+      importDeclarations.reduce(
+        (prev, cur) => prev.replace(cur.statement, ""),
+        content,
+      ),
+    );
+    this.imports = this.imports.filter(
+      (item) => !importDeclarations.includes(item),
+    );
+  }
+
+  replaceImport(
+    oldImports: ImportDeclaration[],
+    newImports: ImportDeclaration[],
+  ) {
+    newImports.forEach((importDeclaration) => {
+      importDeclaration.statement = assembleImport(importDeclaration);
+    });
+    this.replace((content) => {
+      oldImports.forEach((oldImport, idx) => {
+        const replaceTo = newImports[idx];
+        if (replaceTo) {
+          content = content.replace(oldImport.statement, replaceTo.statement);
+          this.imports.map((_import) => {
+            if (_import.statement === oldImport.statement) {
+              _import = replaceTo;
+            }
+          });
+        } else {
+          content = content.replace(oldImport.statement, "");
+          this.imports = this.imports.filter(
+            (_import) => _import.statement !== oldImport.statement,
+          );
+        }
+      });
+
+      const restNewImports = newImports.slice(oldImports.length);
+      if (restNewImports.length > 0) {
+        const lastImportStatement = newImports[oldImports.length - 1].statement;
+        content = content.replace(
+          lastImportStatement,
+          `${lastImportStatement}\n${restNewImports.map(
+            (item) => item.statement,
+          )}\n`,
+        );
+      }
+      this.imports.push(...restNewImports);
+
+      return content;
+    });
+  }
+}
+
+export function* traverseRecursiveTsFiles(folder: string): Generator<TsFile> {
+  const files = fs.readdirSync(folder);
+  for (const file of files) {
+    const filePath = path.join(folder, file);
+    if (fs.statSync(filePath).isDirectory()) {
+      yield* traverseRecursiveTsFiles(filePath);
+    } else {
+      if (file.endsWith(".ts") || file.endsWith(".tsx")) {
+        yield new TsFile(filePath);
+      }
+    }
+  }
+}
+
+export function getIndexTsFile(folder: string): TsFile | undefined {
+  // ts or tsx
+  const files = fs.readdirSync(folder);
+  for (const file of files) {
+    if (file === "index.ts" || file === "index.tsx") {
+      return new TsFile(path.join(folder, file));
+    }
+  }
+  return undefined;
+}

+ 18 - 3
apps/demo-free-layout/tsconfig.json

@@ -15,9 +15,24 @@
     "noImplicitAny": true,
     "allowJs": true,
     "resolveJsonModule": true,
-    "types": ["node"],
+    "types": [
+      "node"
+    ],
     "jsx": "react-jsx",
-    "lib": ["es6", "dom", "es2020", "es2019.Array"]
+    "lib": [
+      "es6",
+      "dom",
+      "es2020",
+      "es2019.Array"
+    ],
+    "baseUrl": ".",
+    "paths": {
+      "@/*": [
+        "./src/*"
+      ]
+    },
   },
-  "include": ["./src"],
+  "include": [
+    "./src"
+  ],
 }

+ 2 - 2
common/autoinstallers/license-header/index.js

@@ -19,7 +19,7 @@ const src = path.resolve(__dirname, '../../../');
 const header = ` Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  SPDX-License-Identifier: MIT`;
 
-const bashHeader = `#!/usr/bin/env`;
+const bashHeaders = [`#!/usr/bin/env`, `#!/bin/sh`];
 const rushPreHeader = `// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED.`;
 
 /**
@@ -80,7 +80,7 @@ function addLicenseHeader(targetDir, licenseContent, options = {}) {
       const originalContent = fs.readFileSync(filePath, "utf8");
 
       // Check if the license already exists (simple match at the beginning)
-      if (!force && (originalContent.startsWith(licensedText.trim()) || originalContent.startsWith(bashHeader.trim()) || originalContent.startsWith(rushPreHeader.trim()))) {
+      if (!force && (originalContent.startsWith(licensedText.trim()) || bashHeaders.some(_header => originalContent.startsWith(_header.trim())) || originalContent.startsWith(rushPreHeader.trim()))) {
         return;
       }
 

+ 1 - 1
e2e/fixed-layout/playwright.config.ts

@@ -7,7 +7,7 @@ import { defineConfig } from '@playwright/test';
 
 export default defineConfig({
   testDir: './tests',
-  timeout: 30 * 1000,
+  timeout: 60 * 1000,
   retries: 1,
   use: {
     baseURL: 'http://localhost:3000',

+ 1 - 1
e2e/free-layout/playwright.config.ts

@@ -7,7 +7,7 @@ import { defineConfig } from '@playwright/test';
 
 export default defineConfig({
   testDir: './tests',
-  timeout: 30 * 1000,
+  timeout: 60 * 1000,
   retries: 1,
   use: {
     baseURL: 'http://localhost:3000',

+ 6 - 1
packages/materials/coze-editor/src/language-typescript/worker.ts

@@ -3,7 +3,12 @@
  * SPDX-License-Identifier: MIT
  */
 
-/* eslint-disable import/no-unresolved */
+/* eslint-disable import/no-extraneous-dependencies */
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
 //  Copyright (c) 2025 coze-dev
 //  SPDX-License-Identifier: MIT
 

+ 1 - 1
packages/materials/form-materials/bin/run.sh

@@ -3,4 +3,4 @@
 #  SPDX-License-Identifier: MIT
 
 
-npx @flowgram.ai/cli@latest materials $1
+npx @flowgram.ai/cli@latest materials "$@"