diff --git a/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js b/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js index d914af40b4..cf0c78dd87 100644 --- a/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js +++ b/packages/build/vite-config/src/vite-plugins/devAliasPlugin.js @@ -44,6 +44,7 @@ const getDevAlias = (useSourceAlias) => { '@opentiny/tiny-engine-toolbar-media': path.resolve(basePath, 'packages/toolbars/media/index.ts'), '@opentiny/tiny-engine-toolbar-preview': path.resolve(basePath, 'packages/toolbars/preview/index.ts'), '@opentiny/tiny-engine-toolbar-generate-code': path.resolve(basePath, 'packages/toolbars/generate-code/index.ts'), + '@opentiny/tiny-engine-toolbar-upload': path.resolve(basePath, 'packages/toolbars/upload/index.ts'), '@opentiny/tiny-engine-toolbar-refresh': path.resolve(basePath, 'packages/toolbars/refresh/index.ts'), '@opentiny/tiny-engine-toolbar-redoundo': path.resolve(basePath, 'packages/toolbars/redoundo/index.ts'), '@opentiny/tiny-engine-toolbar-clean': path.resolve(basePath, 'packages/toolbars/clean/index.ts'), @@ -70,7 +71,8 @@ const getDevAlias = (useSourceAlias) => { '@opentiny/tiny-engine-workspace-template-center': path.resolve( basePath, 'packages/workspace/template-center/index.ts' - ) + ), + '@opentiny/tiny-engine-vue-to-dsl': path.resolve(basePath, 'packages/vue-to-dsl/src/index.ts') } } diff --git a/packages/design-core/package.json b/packages/design-core/package.json index 02cc0c14c5..931f4c7a0e 100644 --- a/packages/design-core/package.json +++ b/packages/design-core/package.json @@ -76,6 +76,7 @@ "@opentiny/tiny-engine-toolbar-collaboration": "workspace:*", "@opentiny/tiny-engine-toolbar-fullscreen": "workspace:*", "@opentiny/tiny-engine-toolbar-generate-code": "workspace:*", + "@opentiny/tiny-engine-toolbar-upload": "workspace:*", "@opentiny/tiny-engine-toolbar-lang": "workspace:*", "@opentiny/tiny-engine-toolbar-lock": "workspace:*", "@opentiny/tiny-engine-toolbar-logo": "workspace:*", diff --git a/packages/design-core/re-export.js b/packages/design-core/re-export.js index 01c5a72a5b..2d3a51cd25 100644 --- a/packages/design-core/re-export.js +++ b/packages/design-core/re-export.js @@ -12,6 +12,7 @@ export { default as Clean } from '@opentiny/tiny-engine-toolbar-clean' export { default as ThemeSwitch, ThemeSwitchService } from '@opentiny/tiny-engine-toolbar-theme-switch' export { default as Preview } from '@opentiny/tiny-engine-toolbar-preview' export { default as GenerateCode, SaveLocalService } from '@opentiny/tiny-engine-toolbar-generate-code' +export { default as Upload } from '@opentiny/tiny-engine-toolbar-upload' export { default as Refresh } from '@opentiny/tiny-engine-toolbar-refresh' export { default as Collaboration } from '@opentiny/tiny-engine-toolbar-collaboration' export { default as Setting } from '@opentiny/tiny-engine-toolbar-setting' diff --git a/packages/design-core/registry.js b/packages/design-core/registry.js index 9108e25304..799c92254b 100644 --- a/packages/design-core/registry.js +++ b/packages/design-core/registry.js @@ -26,6 +26,7 @@ import { ThemeSwitch, Preview, GenerateCode, + Upload, Refresh, Collaboration, Materials, @@ -153,6 +154,7 @@ export default { __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.preview'] === false ? null : Preview, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.refresh'] === false ? null : Refresh, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.generate-code'] === false ? null : GenerateCode, + __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.upload'] === false ? null : Upload, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.save'] === false ? null : Save, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.fullscreen'] === false ? null : Fullscreen, __TINY_ENGINE_REMOVED_REGISTRY['engine.toolbars.lang'] === false ? null : Lang, diff --git a/packages/layout/src/defaultLayout.js b/packages/layout/src/defaultLayout.js index b234bfc19e..080b50fe98 100644 --- a/packages/layout/src/defaultLayout.js +++ b/packages/layout/src/defaultLayout.js @@ -28,7 +28,7 @@ export default { right: [ [META_APP.Robot, META_APP.ThemeSwitch, META_APP.RedoUndo, META_APP.Clean], [META_APP.Preview], - [META_APP.GenerateCode, META_APP.Save] + [META_APP.Upload, META_APP.GenerateCode, META_APP.Save] ], collapse: [ [META_APP.Collaboration], diff --git a/packages/register/src/constants.ts b/packages/register/src/constants.ts index e4a355d6e2..b2bdce1b00 100644 --- a/packages/register/src/constants.ts +++ b/packages/register/src/constants.ts @@ -41,6 +41,7 @@ export const META_APP = { Refresh: 'engine.toolbars.refresh', Save: 'engine.toolbars.save', GenerateCode: 'engine.toolbars.generate-code', + Upload: 'engine.toolbars.upload', Preview: 'engine.toolbars.preview', RedoUndo: 'engine.toolbars.redoundo', Fullscreen: 'engine.toolbars.fullscreen', diff --git a/packages/toolbars/upload/index.ts b/packages/toolbars/upload/index.ts new file mode 100644 index 0000000000..5efbd88c4b --- /dev/null +++ b/packages/toolbars/upload/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import entry from './src/Main.vue' +import metaData from './meta' +import './src/styles/vars.less' + +export default { + ...metaData, + entry +} diff --git a/packages/toolbars/upload/meta.js b/packages/toolbars/upload/meta.js new file mode 100644 index 0000000000..0d8f8a47cd --- /dev/null +++ b/packages/toolbars/upload/meta.js @@ -0,0 +1,11 @@ +export default { + id: 'engine.toolbars.upload', + type: 'toolbars', + title: 'upload', + options: { + icon: { + default: 'upload' + }, + renderType: 'button' + } +} diff --git a/packages/toolbars/upload/package.json b/packages/toolbars/upload/package.json new file mode 100644 index 0000000000..3d8d7c1490 --- /dev/null +++ b/packages/toolbars/upload/package.json @@ -0,0 +1,44 @@ +{ + "name": "@opentiny/tiny-engine-toolbar-upload", + "version": "2.10.0", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vite build" + }, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/opentiny/tiny-engine", + "directory": "packages/toolbars/upload" + }, + "bugs": { + "url": "https://github.com/opentiny/tiny-engine/issues" + }, + "author": "OpenTiny Team", + "license": "MIT", + "homepage": "https://opentiny.design/tiny-engine", + "dependencies": { + "@opentiny/tiny-engine-common": "workspace:*", + "@opentiny/tiny-engine-meta-register": "workspace:*", + "@opentiny/tiny-engine-utils": "workspace:*", + "@opentiny/tiny-engine-vue-to-dsl": "workspace:*" + }, + "devDependencies": { + "@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*", + "@vitejs/plugin-vue": "^5.1.2", + "@vitejs/plugin-vue-jsx": "^4.0.1", + "vite": "^5.4.2" + }, + "peerDependencies": { + "@opentiny/vue": "^3.20.0", + "@opentiny/vue-icon": "^3.20.0", + "vue": "^3.4.15" + } +} diff --git a/packages/toolbars/upload/src/Main.vue b/packages/toolbars/upload/src/Main.vue new file mode 100644 index 0000000000..7af8123c4c --- /dev/null +++ b/packages/toolbars/upload/src/Main.vue @@ -0,0 +1,889 @@ + + + + + + + + + triggerUpload('file')">Vue 文件 + triggerUpload('directory')">项目目录 + triggerUpload('zip')">项目压缩包 + + + + + + + (state.showOverwriteDialog = v)" + @confirm="handleOverwriteConfirm" + @cancel="handleOverwriteCancel" + /> + + + + + diff --git a/packages/toolbars/upload/src/OverwriteDialog.vue b/packages/toolbars/upload/src/OverwriteDialog.vue new file mode 100644 index 0000000000..6d2f326352 --- /dev/null +++ b/packages/toolbars/upload/src/OverwriteDialog.vue @@ -0,0 +1,157 @@ + + + 检测到以下同名页面,请勾选需要覆盖的项: + + + 全选 + + + + toggle(item, v)"> + {{ item }} + + + + + + + + + + + + diff --git a/packages/toolbars/upload/src/http.ts b/packages/toolbars/upload/src/http.ts new file mode 100644 index 0000000000..bf8e951e0b --- /dev/null +++ b/packages/toolbars/upload/src/http.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/* metaService: engine.toolbars.upload.http */ + +import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' + +// 获取页面列表 +export const fetchPageList = (appId: string) => getMetaApi(META_SERVICE.Http).get(`/app-center/api/pages/list/${appId}`) + +// 获取区块分组列表 +export const fetchBlockGroups = (params?: any) => + getMetaApi(META_SERVICE.Http).get('/material-center/api/block-groups', { params: { ...params, from: 'block' } }) + +// 创建区块分组 +export const createBlockGroup = (params: any) => + getMetaApi(META_SERVICE.Http).post('/material-center/api/block-groups/create', params) + +// 创建区块 +export const createBlock = (params: any) => + getMetaApi(META_SERVICE.Http).post('/material-center/api/block/create', params) + +// 根据标签查询区块 +export const fetchBlockByLabel = (label: string) => + getMetaApi(META_SERVICE.Http).get(`/material-center/api/block?label=${label}`) + +// 更新区块 +export const updateBlock = (blockId: string, params: any, appId: string) => + getMetaApi(META_SERVICE.Http).post(`/material-center/api/block/update/${blockId}`, params, { + params: { appId } + }) + +// 发布区块 +export const deployBlock = (params: any) => + getMetaApi(META_SERVICE.Http).post('/material-center/api/block/deploy', params) + +// 获取工具类列表 +export const fetchUtilsResourceList = (appId: string) => + getMetaApi(META_SERVICE.Http).get(`/app-center/api/apps/extension/list?app=${appId}&category=utils`) + +// 创建工具类 +export const createUtilsResource = (params: any) => + getMetaApi(META_SERVICE.Http).post('/app-center/api/apps/extension/create', params) + +// 更新工具类 +export const updateUtilsResource = (params: any) => + getMetaApi(META_SERVICE.Http).post('/app-center/api/apps/extension/update', params) diff --git a/packages/toolbars/upload/src/styles/vars.less b/packages/toolbars/upload/src/styles/vars.less new file mode 100644 index 0000000000..4f35f889b5 --- /dev/null +++ b/packages/toolbars/upload/src/styles/vars.less @@ -0,0 +1,12 @@ +:root { + --te-toolbars-upload-button-bg-color: var(--te-common-bg-prompt); + --te-toolbars-upload-text-color-primary: var(--te-common-text-primary); + --te-toolbars-upload-text-color-secondary: var(--te-common-text-secondary); + --te-toolbars-upload-icon-color: var(--te-common-icon-secondary); + --te-toolbars-upload-icon-color-primary: var(--te-common-icon-primary); + --te-toolbars-upload-bg-color-primary: var(--te-common-bg-primary); + --te-toolbars-upload-bg-color: var(--te-common-bg-default); + --te-toolbars-upload-bg-color-hover: var(--te-common-bg-container); + --te-toolbars-upload-border-color-checked: var(--te-common-border-checked); + --te-toolbars-upload-border-color-divider: var(--te-common-border-divider); +} diff --git a/packages/toolbars/upload/vite.config.ts b/packages/toolbars/upload/vite.config.ts new file mode 100644 index 0000000000..af7bc73920 --- /dev/null +++ b/packages/toolbars/upload/vite.config.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { defineConfig } from 'vite' +import path from 'path' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import generateComment from '@opentiny/tiny-engine-vite-plugin-meta-comments' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [generateComment(), vue(), vueJsx()], + publicDir: false, + resolve: {}, + build: { + sourcemap: true, + lib: { + entry: path.resolve(__dirname, './index.ts'), + name: 'toolbar-upload', + fileName: (_format, entryName) => `${entryName}.js`, + formats: ['es'] + }, + rollupOptions: { + output: { + banner: 'import "./style.css"' + }, + external: ['vue', /@opentiny\/tiny-engine.*/, /@opentiny\/vue.*/] + } + } +}) diff --git a/packages/vue-to-dsl/.gitignore b/packages/vue-to-dsl/.gitignore new file mode 100644 index 0000000000..9b1960e711 --- /dev/null +++ b/packages/vue-to-dsl/.gitignore @@ -0,0 +1 @@ +output/ \ No newline at end of file diff --git a/packages/vue-to-dsl/README.md b/packages/vue-to-dsl/README.md new file mode 100644 index 0000000000..c041ca2597 --- /dev/null +++ b/packages/vue-to-dsl/README.md @@ -0,0 +1,224 @@ +# @opentiny/tiny-engine-vue-to-dsl + +> 将 Vue 代码文件/项目反向转换为 TinyEngine DSL Schema 的工具包 + +## 简介 + +`@opentiny/tiny-engine-vue-to-dsl` 解析 Vue 代码文件,生成可用于 TinyEngine 的 DSL Schema。同时内置“整包应用”转换能力,可从项目目录或 zip 包中聚合出 App 级 Schema(含 i18n、数据源、全局状态、页面元信息等)。 + +## 主要特性 + +- 支持模板、脚本(Options API / script setup)、样式的完整解析 +- 从 Vue 工程目录或 zip 文件生成 AppSchema +- 可配置组件映射、可插拔自定义解析器 +- TypeScript 实现,导出完整类型;提供单元与集成测试 + +## 安装 + +```bash +pnpm add @opentiny/tiny-engine-vue-to-dsl +``` + +## 目录结构 + +```text +src/ +├─ converter.ts # 主转换器(含 app 级聚合与 zip 支持) +├─ generator/ # schema 生成与归一 +├─ parser/ # SFC 粗分(template/script/style 块) +├─ parsers/ # 各块解析实现 +├─ constants.ts # 组件映射与组件包清单 +├─ index.ts # 包导出入口 +└─ types/ # 类型导出 +``` + +## 快速开始 + +```ts +import { VueToDslConverter } from '@opentiny/tiny-engine-vue-to-dsl'; + +const converter = new VueToDslConverter(); +const vueCode = ` + + + {{ state.title }} + Click me + + + + +`; + +const result = await converter.convertFromString(vueCode, 'Hello.vue'); +console.log(result.schema); +``` + +## API 概览 + +入口:`src/index.ts` + +导出: + +- `VueToDslConverter` 主转换器 +- 解析工具:`parseVueFile`、`parseSFC` +- 生成器:`generateSchema`、`generateAppSchema` +- 细分解析器:`parseTemplate`、`parseScript`、`parseStyle` +- 类型与常量:`types/*`、默认组件映射 `defaultComponentMap`、默认组件包清单 `defaultComponentsMap` + +### VueToDslConverter + +```ts +new VueToDslConverter(options?: VueToSchemaOptions) + +interface VueToSchemaOptions { + componentMap?: Record + preserveComments?: boolean + strictMode?: boolean + // 控制是否输出 computed 字段(默认 false) + computed_flag?: boolean + customParsers?: { + template?: { parse: (code: string) => any } + script?: { parse: (code: string) => any } + style?: { parse: (code: string) => any } + } + fileName?: string + path?: string + title?: string + description?: string +} + +type ConvertResult = { + schema: any | null + dependencies: string[] + errors: string[] + warnings: string[] +} +``` + +实例方法: + +- `convertFromString(code, fileName?)`:从字符串转换 +- `convertFromFile(filePath)`:从文件转换 +- `convertMultipleFiles(filePaths)`:批量转换 +- `convertAppDirectory(appDir)`:从工程目录(约定 src/ 结构)生成 App 级 schema +- `convertAppFromZip(zipBuffer)`:从 zip Buffer 生成 App 级 schema(Node 与浏览器均可用) +- `setOptions(partial)` / `getOptions()`:运行期更新/读取配置 + +### App 级聚合产物(convertAppDirectory/convertAppFromZip) + +输出结构(概要): + +```ts +{ + meta: { name, description, generatedAt, generator }, + i18n: { en_US: {}, zh_CN: {} }, + utils: Array<{ + name: string, + type: 'npm' | 'function', + content: { type: 'JSFunction', value: string, package?: string, destructuring?: boolean, exportName?: string } + }>, + dataSource: { list: any[] }, + globalState: Array<{ id: string, state: Record }>, + pageSchema: any[], + componentsMap: typeof defaultComponentsMap +} +``` + +数据来源约定: + +- 页面:`src/views/**/*.vue` +- i18n:`src/i18n/en_US.json`、`src/i18n/zh_CN.json` +- 工具函数:`src/utils.js`(简单 import/export 分析,支持命名/默认导入导出) +- 数据源:`src/lowcodeConfig/dataSource.json` +- 全局状态:`src/stores/*.js`(简易 Pinia `defineStore` 解析,只提取 state 返回对象) +- 路由:`src/router/index.js`(提取 name/path 与 import 的页面文件,设置 `meta.router/isPage/isHome`) + +## 模板/脚本/样式支持 + +模板(`parseTemplate`) + +- HTML 标签与自定义组件;通过 `componentMap` 做名称映射 +- 指令:`v-if`/`v-for`/`v-show`/`v-model`/`v-on`/`v-bind`/`v-slot` 等核心指令 +- v-for:尝试抽取迭代表达式,写入 `loop: { type: 'JSExpression', value: 'this.xxx' }` +- 事件与绑定:能解析简单字面量,复杂表达式以 `JSExpression` 形式保留 +- 文本与插值:转为 `Text` 组件;插值为 `JSExpression` +- 特殊:`tiny-icon-*` 归一为通用 `Icon` 组件并写入 `name` 属性 + +脚本(`parseScript`) + +- script setup: + - `reactive`/`ref` 识别到 state;`computed` 识别到 computed + - 顶层函数与返回对象内成员识别到 methods + - onMounted/onUpdated... 等生命周期识别 +- Options API: + - `props`(数组语法)/`methods`/`computed`/生命周期基础支持 +- import 收集:用于返回 `dependencies` + +样式(`parseStyle` + 辅助) + +- 基础样式串:直出 `css` +- 辅助能力:`parseCSSRules`、`extractCSSVariables`、`hasMediaQueries`、`extractMediaQueries` + +## 输出 Schema 约定(页面级) + +- 根节点 `componentName: 'Page'`,自动补齐 `id`(8 位字母数字) +- `state`/`methods`/`computed`/`lifecycle` 值以 `{ type: 'JSFunction', value: string }` 表达(state 中基础类型按需折叠) +- `children` 为模板树;属性中无法安全字面量化的表达式以 `JSExpression` 表达 +- 所有字符串做轻度“去换行/多空格”规整 + +## 测试用例说明 + +测试目录位于 `test/`,包含: + +- `test/sfc/`:单个 SFC 的基础转换测试 +- `test/testcases/`:按用例目录组织的场景测试(新增用例放这里) +- `test/full/`:整包项目/zip 的端到端转换测试 + +在本包目录 `packages/vue-to-dsl` 下使用 Vitest 进行单元与集成测试,运行: + +```bash +pnpm i +pnpm test +# 或 +npx vitest run +``` + +运行后会将每个用例的结果写入 `output/schema.json`,便于比对。 + +用例结构(示例): + +```text +test/testcases/ + 001_simple/ + input/component.vue # 输入 SFC + expected/schema.json # 期望 Schema(可为“子集”) + output/schema.json # 测试生成(自动写入) +``` + +断言规则(见 `test/testcases/index.test.js`): + +- 忽略动态字段:递归忽略所有层级的 `meta` 与 `id` +- 子集匹配:实际输出只需“包含” expected 的结构和值(数组按 expected 长度顺序比对前 N 项) +- 若 expected 含 `error: true`:仅断言发生错误并允许 schema 存在部分内容 + +因此 expected 可仅保留关键片段,无需完全复制整个 schema,适合 children 很多的页面。 + +新增用例步骤: + +1. 在 `test/testcases/` 新建目录(序号递增) +2. 添加 `input/component.vue` +3. 添加最小化 `expected/schema.json`(仅关键字段) +4. 运行测试,参考 `output/schema.json` 微调 expected + +组件映射: + +- 本测试文件内已设置常用 OpenTiny 组件映射(`tiny-form`、`tiny-grid`、`tiny-select`、`tiny-button-group`、`tiny-time-line` 等) +- 如使用未映射组件,可在测试中补充 `componentMap`,或在用例中用已映射组件替代 diff --git a/packages/vue-to-dsl/cli.ts b/packages/vue-to-dsl/cli.ts new file mode 100644 index 0000000000..3fabf3ea22 --- /dev/null +++ b/packages/vue-to-dsl/cli.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ +/** + * Vue To DSL CLI Tool (TypeScript) + * 命令行工具,用于将Vue SFC文件转换为TinyEngine DSL Schema + */ + +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' +import { VueToDslConverter } from './src/converter' + +// 解决在 ESM 下使用 __dirname/__filename +const __filename = fileURLToPath(import.meta.url) + +// 命令行参数解析 +const args = process.argv.slice(2) + +const showHelp = args.includes('--help') || args.includes('-h') +if (args.length === 0 || showHelp) { + console.log(` +使用方法: + node ${path.basename(__filename)} [options] + +选项: + --output, -o 输出文件路径 + --format, -f 输出格式 (json, js) 默认: json + --help, -h 显示帮助信息 + --computed 启用输出 computed 字段(默认关闭) + +示例: + node ${path.basename(__filename)} ./components/MyComponent.vue + node ${path.basename(__filename)} ./components/MyComponent.vue --output ./output/schema.json + node ${path.basename(__filename)} ./components/MyComponent.vue --format js --output ./output/schema.js +`) + process.exit(0) +} + +// 解析参数 +const inputFile = args[0] +let outputFile: string | undefined +let format: 'json' | 'js' = 'json' +let computedFlag = false + +for (let i = 1; i < args.length; ) { + const option = args[i] + const value = args[i + 1] + + switch (option) { + case '--output': + case '-o': + outputFile = value + i += 2 + break + case '--format': + case '-f': + if (value === 'json' || value === 'js') { + format = value + } + i += 2 + break + case '--help': + case '-h': + console.log('显示帮助信息...') + process.exit(0) + break + case '--computed': + computedFlag = true + i += 1 + break + default: + // 跳过无法识别的参数,避免死循环 + i += 1 + } +} + +// 设置默认输出文件 +if (!outputFile) { + const baseName = path.basename(inputFile, '.vue') + outputFile = `${baseName}-schema.${format}` +} +const outputPath = outputFile as string + +/** + * 获取Schema统计信息 + */ +function getSchemaStats(schema: any) { + return { + stateCount: schema.state ? Object.keys(schema.state).length : 0, + methodCount: schema.methods ? Object.keys(schema.methods).length : 0, + computedCount: schema.computed ? Object.keys(schema.computed).length : 0, + lifecycleCount: schema.lifeCycles ? Object.keys(schema.lifeCycles).length : 0, + childrenCount: schema.children ? schema.children.length : 0, + cssLength: schema.css ? schema.css.length : 0 + } +} + +async function main() { + try { + console.log('🚀 开始转换Vue文件到DSL Schema...') + console.log(`📁 输入文件: ${inputFile}`) + console.log(`📄 输出文件: ${outputPath}`) + console.log(`📋 输出格式: ${format}`) + console.log() + + // 检查输入文件是否存在 + try { + await fs.access(inputFile) + } catch (error) { + console.error(`❌ 错误: 文件不存在 - ${inputFile}`) + process.exit(1) + } + + // 创建转换器 + const converter = new VueToDslConverter({ + componentMap: { + button: 'TinyButton', + input: 'TinyInput', + form: 'TinyForm' + }, + preserveComments: false, + strictMode: false, + computed_flag: computedFlag + }) + + // 执行转换 + const result = await converter.convertFromFile(inputFile) + + // 显示转换结果 + if (result.errors.length > 0) { + console.log('⚠️ 转换过程中的错误:') + result.errors.forEach((error: string) => console.log(` - ${error}`)) + console.log() + } + + if (result.warnings.length > 0) { + console.log('⚠️ 转换过程中的警告:') + result.warnings.forEach((warning: string) => console.log(` - ${warning}`)) + console.log() + } + + if (result.dependencies.length > 0) { + console.log('📦 发现的依赖项:') + result.dependencies.forEach((dep: string) => console.log(` - ${dep}`)) + console.log() + } + + if (!result.schema) { + console.error('❌ 转换失败,未生成Schema') + process.exit(1) + } + + // 生成输出内容 + let outputContent: string + if (format === 'json') { + outputContent = JSON.stringify(result.schema, null, 2) + } else if (format === 'js') { + outputContent = `// Generated DSL Schema from ${inputFile} +// Generated at: ${new Date().toISOString()} + +export default ${JSON.stringify(result.schema, null, 2)} +` + } else { + console.error(`❌ 错误: 不支持的输出格式 - ${format}`) + process.exit(1) + return + } + + // 确保输出目录存在 + const outputDir = path.dirname(outputPath) + if (outputDir !== '.' && outputDir !== '') { + await fs.mkdir(outputDir, { recursive: true }) + } + + // 写入输出文件 + await fs.writeFile(outputPath, outputContent, 'utf-8') + + console.log('✅ 转换完成!') + console.log(`📁 输出文件已保存到: ${outputPath}`) + + // 显示Schema统计信息 + const stats = getSchemaStats(result.schema) + console.log() + console.log('📊 Schema统计信息:') + console.log(` 组件名称: ${result.schema.componentName}`) + console.log(` 文件名称: ${result.schema.fileName}`) + console.log(` 状态数量: ${stats.stateCount}`) + console.log(` 方法数量: ${stats.methodCount}`) + console.log(` 计算属性: ${stats.computedCount}`) + console.log(` 生命周期: ${stats.lifecycleCount}`) + console.log(` 子组件数: ${stats.childrenCount}`) + console.log(` CSS长度: ${stats.cssLength} 字符`) + } catch (error: any) { + console.error('❌ 转换过程中发生错误:') + console.error(error?.message || error) + if (error?.stack) console.error(error.stack) + process.exit(1) + } +} + +// 运行主函数 +void main() diff --git a/packages/vue-to-dsl/package.json b/packages/vue-to-dsl/package.json new file mode 100644 index 0000000000..acbea172ed --- /dev/null +++ b/packages/vue-to-dsl/package.json @@ -0,0 +1,55 @@ +{ + "name": "@opentiny/tiny-engine-vue-to-dsl", + "version": "1.0.0", + "description": "Convert Vue SFC files back to TinyEngine DSL schema", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "dist/tiny-engine-vue-to-dsl.cjs", + "module": "dist/tiny-engine-vue-to-dsl.js", + "types": "dist/index.d.ts", + "bin": { + "tiny-vue-to-dsl": "dist/cli.cjs" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run build:types && vite build && pnpm run build:cli", + "build:types": "tsc -p tsconfig.json", + "build:cli": "vite build --config vite.config.cli.mjs", + "test": "vitest", + "test:unit": "vitest run", + "coverage": "vitest run --coverage", + "dev": "vite build --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com/opentiny/tiny-engine", + "directory": "packages/vue-to-dsl" + }, + "bugs": { + "url": "https://github.com/opentiny/tiny-engine/issues" + }, + "author": "OpenTiny Team", + "license": "MIT", + "homepage": "https://opentiny.design/tiny-engine", + "dependencies": { + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0", + "@vue/compiler-dom": "^3.4.15", + "@vue/compiler-sfc": "^3.4.15", + "jszip": "^3.10.1", + "vue": "^3.4.15" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@vitest/coverage-v8": "^1.4.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0", + "vite": "^5.4.2", + "vitest": "^1.4.0" + } +} diff --git a/packages/vue-to-dsl/src/constants.ts b/packages/vue-to-dsl/src/constants.ts new file mode 100644 index 0000000000..2a77f66ce0 --- /dev/null +++ b/packages/vue-to-dsl/src/constants.ts @@ -0,0 +1,217 @@ +export const defaultComponentMap: Record = { + 'tiny-form': 'TinyForm', + 'tiny-form-item': 'TinyFormItem', + 'tiny-button': 'TinyButton', + 'tiny-button-group': 'TinyButtonGroup', + 'tiny-switch': 'TinySwitch', + 'tiny-select': 'TinySelect', + 'tiny-search': 'TinySearch', + 'tiny-input': 'TinyInput', + 'tiny-grid': 'TinyGrid', + 'tiny-grid-item': 'TinyGridItem', + 'tiny-col': 'TinyCol', + 'tiny-row': 'TinyRow', + 'tiny-time-line': 'TinyTimeLine', + 'tiny-card': 'TinyCard' +} + +export const defaultComponentsMap = [ + { + componentName: 'TinyCarouselItem', + package: '@opentiny/vue', + exportName: 'CarouselItem', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyCheckboxButton', + package: '@opentiny/vue', + exportName: 'CheckboxButton', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyTree', package: '@opentiny/vue', exportName: 'Tree', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyPopover', + package: '@opentiny/vue', + exportName: 'Popover', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyTooltip', + package: '@opentiny/vue', + exportName: 'Tooltip', + destructuring: true, + version: '3.2.0' + }, + { componentName: 'TinyCol', package: '@opentiny/vue', exportName: 'Col', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyDropdownItem', + package: '@opentiny/vue', + exportName: 'DropdownItem', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyPager', package: '@opentiny/vue', exportName: 'Pager', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyPlusAccessdeclined', + package: '@opentiny/vue', + exportName: 'AccessDeclined', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinyPlusFrozenPage', + package: '@opentiny/vue', + exportName: 'FrozenPage', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinyPlusNonSupportRegion', + package: '@opentiny/vue', + exportName: 'NonSupportRegion', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinyPlusBeta', + package: '@opentiny/vue', + exportName: 'Beta', + destructuring: true, + version: '3.4.1' + }, + { + componentName: 'TinySearch', + package: '@opentiny/vue', + exportName: 'Search', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyRow', package: '@opentiny/vue', exportName: 'Row', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyFormItem', + package: '@opentiny/vue', + exportName: 'FormItem', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyAlert', package: '@opentiny/vue', exportName: 'Alert', destructuring: true, version: '3.2.0' }, + { componentName: 'TinyInput', package: '@opentiny/vue', exportName: 'Input', destructuring: true, version: '3.24.0' }, + { componentName: 'TinyTabs', package: '@opentiny/vue', exportName: 'Tabs', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyDropdownMenu', + package: '@opentiny/vue', + exportName: 'DropdownMenu', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyDialogBox', + package: '@opentiny/vue', + exportName: 'DialogBox', + destructuring: true, + version: '3.2.0' + }, + { + componentName: 'TinySwitch', + package: '@opentiny/vue', + exportName: 'Switch', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyTimeLine', + package: '@opentiny/vue', + exportName: 'TimeLine', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyTabItem', + package: '@opentiny/vue', + exportName: 'TabItem', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'TinyRadio', package: '@opentiny/vue', exportName: 'Radio', destructuring: true, version: '3.24.0' }, + { componentName: 'TinyForm', package: '@opentiny/vue', exportName: 'Form', destructuring: true, version: '3.24.0' }, + { componentName: 'TinyGrid', package: '@opentiny/vue', exportName: 'Grid', destructuring: true, version: '3.24.0' }, + { + componentName: 'TinyNumeric', + package: '@opentiny/vue', + exportName: 'Numeric', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyCheckboxGroup', + package: '@opentiny/vue', + exportName: 'CheckboxGroup', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinySelect', + package: '@opentiny/vue', + exportName: 'Select', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyButtonGroup', + package: '@opentiny/vue', + exportName: 'ButtonGroup', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyButton', + package: '@opentiny/vue', + exportName: 'Button', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyCarousel', + package: '@opentiny/vue', + exportName: 'Carousel', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyPopeditor', + package: '@opentiny/vue', + exportName: 'Popeditor', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyDatePicker', + package: '@opentiny/vue', + exportName: 'DatePicker', + destructuring: true, + version: '3.24.0' + }, + { + componentName: 'TinyDropdown', + package: '@opentiny/vue', + exportName: 'Dropdown', + destructuring: true, + version: '0.1.20' + }, + { + componentName: 'TinyChartHistogram', + package: '@opentiny/vue', + exportName: 'ChartHistogram', + destructuring: true, + version: '3.24.0' + }, + { componentName: 'PortalHome', main: 'common/components/home', destructuring: false, version: '1.0.0' }, + { componentName: 'PreviewBlock1', main: 'preview', destructuring: false, version: '1.0.0' }, + { componentName: 'PortalHeader', main: 'common', destructuring: false, version: '1.0.0' }, + { componentName: 'PortalBlock', main: 'portal', destructuring: false, version: '1.0.0' }, + { componentName: 'PortalPermissionBlock', main: '', destructuring: false, version: '1.0.0' }, + { componentName: 'TinyCard', exportName: 'Card', package: '@opentiny/vue', version: '^3.10.0', destructuring: true } +] diff --git a/packages/vue-to-dsl/src/converter.ts b/packages/vue-to-dsl/src/converter.ts new file mode 100644 index 0000000000..ce84b99683 --- /dev/null +++ b/packages/vue-to-dsl/src/converter.ts @@ -0,0 +1,1872 @@ +import { parseSFC } from './parser/index' +import { parseTemplate } from './parsers/templateParser' +import { parseScript } from './parsers/scriptParser' +import { parseStyle } from './parsers/styleParser' +import { generateSchema, generateAppSchema } from './generator/index' +import { defaultComponentMap } from './constants' +import { parse as babelParse } from '@babel/parser' +import traverseModule from '@babel/traverse' +import * as t from '@babel/types' +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import JSZip from 'jszip' + +const traverse: any = (traverseModule as any)?.default ?? (traverseModule as any) + +export interface VueToSchemaOptions { + componentMap?: Record + preserveComments?: boolean + strictMode?: boolean + // 控制是否在出码结果中包含 computed 字段,默认 false + computed_flag?: boolean + customParsers?: { + template?: { parse: (code: string) => any } + script?: { parse: (code: string) => any } + style?: { parse: (code: string) => any } + } + fileName?: string + path?: string + title?: string + description?: string +} + +export interface ConvertResult { + schema: any | null + dependencies: string[] + errors: string[] + warnings: string[] + scriptSchema?: any +} + +type LocalModuleContext = { + allFiles: string[] + fileSet: Set + readText: (filePath: string) => Promise +} + +export class VueToDslConverter { + private options: VueToSchemaOptions + + constructor(options: VueToSchemaOptions = {}) { + this.options = { + componentMap: defaultComponentMap, + preserveComments: false, + strictMode: false, + computed_flag: false, + customParsers: {}, + ...options + } + } + + async convertFromString(vueCode: string, fileName?: string): Promise { + const errors: string[] = [] + const warnings: string[] = [] + const dependencies: string[] = [] + + try { + const sfcResult = parseSFC(vueCode) + if (!sfcResult.template && !sfcResult.scriptSetup && !sfcResult.script) { + throw new Error('Invalid Vue SFC: no template or script found') + } + + let templateSchema: any[] = [] + let scriptSchema: any = {} + let styleSchema: any = {} + + const scriptContent = sfcResult.scriptSetup || sfcResult.script + if (scriptContent) { + try { + scriptSchema = this.options.customParsers?.script + ? this.options.customParsers.script.parse(scriptContent) + : parseScript(scriptContent, { + isSetup: !!sfcResult.scriptSetup, + ...(this.options as any) + }) + + if (scriptSchema.imports) { + dependencies.push(...scriptSchema.imports.map((imp: any) => imp.source)) + } + + // Surface script parser soft errors returned by parseScript + if ((scriptSchema as any).error) { + const msg = (scriptSchema as any).error + errors.push(`Script parsing error: ${msg}`) + if (this.options.strictMode) throw new Error(msg) + } + } catch (error: any) { + errors.push(`Script parsing error: ${error.message}`) + if (this.options.strictMode) throw error + } + } + + if (sfcResult.template) { + try { + templateSchema = this.options.customParsers?.template + ? this.options.customParsers.template.parse(sfcResult.template) + : parseTemplate(sfcResult.template, { + ...this.options, + imports: scriptSchema.imports || [], + props: scriptSchema.props || [], + state: scriptSchema.state || {}, + methods: scriptSchema.methods || {}, + computed: scriptSchema.computed || {} + } as any) + } catch (error: any) { + errors.push(`Template parsing error: ${error.message}`) + if (this.options.strictMode) throw error + } + } + + if (sfcResult.style) { + try { + styleSchema = this.options.customParsers?.style + ? this.options.customParsers.style.parse(sfcResult.style) + : parseStyle(sfcResult.style, this.options as any) + } catch (error: any) { + errors.push(`Style parsing error: ${error.message}`) + if (this.options.strictMode) throw error + } + } + + // Set fileName in options for schema generation + if (fileName) { + this.options.fileName = fileName.replace(/\.vue$/i, '') + } + + const schema = await generateSchema(templateSchema, scriptSchema, styleSchema, this.options as any) + + return { + schema, + dependencies: [...new Set(dependencies)], + errors, + warnings, + scriptSchema + } + } catch (error: any) { + errors.push(`Conversion error: ${error.message}`) + return { schema: null, dependencies: [], errors, warnings } + } + } + + async convertFromFile(filePath: string): Promise { + try { + const vueCode = await fs.readFile(filePath, 'utf-8') + const fileName = path.basename(filePath, '.vue') + const result = await this.convertFromString(vueCode, fileName) + return result + } catch (error: any) { + return { schema: null, dependencies: [], errors: [`File reading error: ${error.message}`], warnings: [] } + } + } + + async convertMultipleFiles(filePaths: string[]): Promise { + const results: ConvertResult[] = [] + for (const filePath of filePaths) { + try { + const result = await this.convertFromFile(filePath) + results.push(result) + } catch (error: any) { + results.push({ + schema: null, + dependencies: [], + errors: [`Failed to convert ${filePath}: ${error.message}`], + warnings: [] + }) + } + } + return results + } + + // Recursively walk a directory and collect files that match a predicate + private async walk(dir: string, filter: (p: string, stat: any) => boolean, acc: string[] = []): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true } as any) + for (const entry of entries as any[]) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) { + await this.walk(p, filter, acc) + } else if (entry.isFile() && filter(p, entry)) { + acc.push(p) + } + } + } catch { + // ignore missing dirs + } + return acc + } + + private parseImportEntries(code: string) { + const imports: Array<{ local: string; imported: string; source: string; destructuring: boolean }> = [] + const importRegex = /import\s+(?:\*\s+as\s+([\w$]+)|{\s*([^}]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + let match: RegExpExecArray | null + + while ((match = importRegex.exec(code))) { + const namespaceLocal = match[1] + const named = match[2] + const defaultLocal = match[3] + const source = match[4] + + if (namespaceLocal) { + imports.push({ local: namespaceLocal, imported: '*', source, destructuring: false }) + continue + } + + if (named) { + named + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .forEach((item) => { + const aliasMatch = item.match(/^([\w$]+)\s+as\s+([\w$]+)$/) + const imported = aliasMatch ? aliasMatch[1] : item + const local = aliasMatch ? aliasMatch[2] : item + imports.push({ local, imported, source, destructuring: true }) + }) + continue + } + + if (defaultLocal) { + imports.push({ local: defaultLocal, imported: 'default', source, destructuring: false }) + } + } + + return imports + } + + private parseExportedNames(code: string) { + const exported = new Map() + const exportListRegex = /export\s*{([^}]+)}/g + let match: RegExpExecArray | null + + while ((match = exportListRegex.exec(code))) { + match[1] + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .forEach((item) => { + const aliasMatch = item.match(/^([\w$]+)\s+as\s+([\w$]+)$/) + if (aliasMatch) exported.set(aliasMatch[2], aliasMatch[1]) + else exported.set(item, item) + }) + } + + const directExportRegex = /export\s+(?:async\s+function|function|const|let|var|class)\s+([\w$]+)/g + while ((match = directExportRegex.exec(code))) { + exported.set(match[1], match[1]) + } + + return exported + } + + private parseUtilsModule(code: string) { + const utils: any[] = [] + const imports = this.parseImportEntries(code) + const exported = this.parseExportedNames(code) + + for (const [exportedName, localName] of exported.entries()) { + const found = imports.find((imp) => imp.local === localName) + if (found) { + utils.push({ + name: exportedName, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: found.imported === 'default' || found.imported === '*' ? found.local : found.imported + } + }) + } else { + utils.push({ name: exportedName, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + + return utils + } + + private getScriptCodeStrings(scriptSchema: any = {}) { + const codeStrings: string[] = [] + + ;['methods', 'computed', 'lifeCycles'].forEach((section) => { + const entries = scriptSchema?.[section] || {} + Object.values(entries).forEach((entry: any) => { + if (typeof entry === 'string') codeStrings.push(entry) + else if (entry?.value && typeof entry.value === 'string') codeStrings.push(entry.value) + }) + }) + + return codeStrings + } + + private isImportUsed(localName: string, codeStrings: string[]) { + if (!localName) return false + const escaped = localName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp(`\\b${escaped}\\b`) + return codeStrings.some((code) => pattern.test(code)) + } + + private isFrameworkImport(source: string) { + return ['vue', 'vue-i18n'].includes(source) + } + + private isLocalUtilitySource(source: string) { + return /(^|[\\/])utils(?:[/\\]index)?(?:\.[a-z]+)?$/i.test(source) || source === '@/utils' + } + + private getNodeSource(node: any, source: string) { + if (!node) return '' + const start = node?.start + const end = node?.end + if (typeof start !== 'number' || typeof end !== 'number') return '' + return source.slice(start, end) + } + + private sanitizeModuleCodeFromNode(node: any, source: string) { + const raw = this.getNodeSource(node, source) + const baseStart = node?.start + const baseEnd = node?.end + if (!raw || typeof baseStart !== 'number' || typeof baseEnd !== 'number') return raw + + let wrappedNode: any + if (t.isStatement(node)) { + wrappedNode = node + } else if (t.isExpression(node)) { + wrappedNode = t.expressionStatement(node) + } else { + wrappedNode = t.functionDeclaration(t.identifier('__temp__'), [node as any], t.blockStatement([])) + } + + const fileAst = t.file(t.program([wrappedNode])) + const replacements: Array<{ start: number; end: number; text: string }> = [] + const seenRanges = new Set() + + const pushReplacement = (start: number, end: number, text = '') => { + if (start >= end) return + const relativeStart = start - baseStart + const relativeEnd = end - baseStart + if (relativeStart < 0 || relativeEnd > raw.length) return + const key = `${relativeStart}:${relativeEnd}:${text}` + if (seenRanges.has(key)) return + seenRanges.add(key) + replacements.push({ start: relativeStart, end: relativeEnd, text }) + } + + traverse(fileAst as any, { + TSTypeAnnotation(path: any) { + pushReplacement(path.node.start, path.node.end) + }, + TSTypeParameterInstantiation(path: any) { + pushReplacement(path.node.start, path.node.end) + }, + TSTypeParameterDeclaration(path: any) { + pushReplacement(path.node.start, path.node.end) + }, + TSAsExpression(path: any) { + pushReplacement(path.node.expression.end, path.node.end) + }, + TSTypeAssertion(path: any) { + pushReplacement(path.node.start, path.node.expression.start) + }, + TSNonNullExpression(path: any) { + pushReplacement(path.node.expression.end, path.node.end) + }, + TSInstantiationExpression(path: any) { + pushReplacement(path.node.expression.end, path.node.end) + }, + Identifier(path: any) { + if (!path.node.optional) return + const typeAnnotationStart = path.node.typeAnnotation?.start + const optionalStart = path.node.start + String(path.node.name || '').length + if (typeof typeAnnotationStart === 'number') { + pushReplacement(optionalStart, typeAnnotationStart) + } else { + pushReplacement(optionalStart, optionalStart + 1) + } + }, + CallExpression(path: any) { + const typeParameters = path.node.typeParameters || path.node.typeArguments + if (typeParameters) pushReplacement(typeParameters.start, typeParameters.end) + }, + NewExpression(path: any) { + const typeParameters = path.node.typeParameters || path.node.typeArguments + if (typeParameters) pushReplacement(typeParameters.start, typeParameters.end) + } + }) + + return replacements + .sort((a, b) => b.start - a.start || b.end - a.end) + .reduce((output, item) => `${output.slice(0, item.start)}${item.text}${output.slice(item.end)}`, raw) + } + + private normalizeVirtualPath(filePath = '') { + return String(filePath || '') + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/\/+/g, '/') + .replace(/\/$/, '') + } + + private buildConflictResolvedFileName( + relativePath: string, + baseName: string, + duplicatePaths: string[] = [], + allPaths: string[] = [] + ) { + const normalizeParts = (filePath: string) => + this.normalizeVirtualPath(filePath) + .replace(/\.vue$/i, '') + .split('/') + .filter(Boolean) + + const toCamelCase = (parts: string[]) => + parts.map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))).join('') + + const currentParts = normalizeParts(relativePath) + const dirParts = currentParts.slice(0, -1) + if (dirParts.length === 0) { + return baseName + } + + const normalizedAllPaths = allPaths.map((filePath) => this.normalizeVirtualPath(filePath)) + const normalizedDuplicates = duplicatePaths.map((filePath) => this.normalizeVirtualPath(filePath)) + const currentPath = this.normalizeVirtualPath(relativePath) + const currentDir = dirParts.join('/') + const isSinglePageFileInParent = + normalizedAllPaths.filter((filePath) => { + const parts = normalizeParts(filePath) + return parts.slice(0, -1).join('/') === currentDir + }).length === 1 + const buildName = (suffixParts: string[]) => { + const prefix = toCamelCase(suffixParts) + if (isSinglePageFileInParent) { + return prefix + } + return `${prefix}${baseName.charAt(0).toUpperCase()}${baseName.slice(1)}` + } + + for (let depth = 1; depth <= dirParts.length; depth++) { + const currentSuffixParts = dirParts.slice(-depth) + const currentSuffix = currentSuffixParts.join('/') + const hasConflict = normalizedDuplicates.some((filePath) => { + if (filePath === currentPath) return false + const candidateDirParts = normalizeParts(filePath).slice(0, -1) + return candidateDirParts.slice(-depth).join('/') === currentSuffix + }) + + if (!hasConflict) { + return buildName(currentSuffixParts) + } + } + + return buildName(dirParts) + } + + private getVirtualDirname(filePath = '') { + const normalized = this.normalizeVirtualPath(filePath) + const index = normalized.lastIndexOf('/') + return index === -1 ? '' : normalized.slice(0, index) + } + + private resolveVirtualRelativePath(baseDir: string, relativePath: string) { + const normalizedBase = this.normalizeVirtualPath(baseDir) + const normalizedRelative = this.normalizeVirtualPath(relativePath) + const baseParts = normalizedBase ? normalizedBase.split('/') : [] + const nextParts = normalizedRelative.split('/') + const output = normalizedRelative.startsWith('/') ? [] : [...baseParts] + + nextParts.forEach((part) => { + if (!part || part === '.') return + if (part === '..') { + output.pop() + return + } + output.push(part) + }) + + return output.join('/') + } + + private resolveLocalModulePath(source: string, importerFile: string, context: LocalModuleContext) { + if (!source || (!source.startsWith('.') && !source.startsWith('@/'))) return null + + const basePath = source.startsWith('@/') + ? this.normalizeVirtualPath(`src/${source.slice(2)}`) + : this.resolveVirtualRelativePath(this.getVirtualDirname(importerFile), source) + + const candidates = [basePath] + const extensions = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'] + + extensions.forEach((ext) => { + candidates.push(`${basePath}${ext}`) + }) + + extensions.forEach((ext) => { + candidates.push(`${basePath}/index${ext}`) + }) + + const found = candidates.find((candidate) => context.fileSet.has(this.normalizeVirtualPath(candidate))) + return found ? this.normalizeVirtualPath(found) : null + } + + private createEmptyFunctionUtilEntry(name: string) { + return { + name, + type: 'function', + content: { + type: 'JSFunction', + value: '' + } + } + } + + private cloneUtilEntry(item: any, name = item?.name) { + return { + ...item, + name, + content: item?.content ? { ...item.content } : item?.content + } + } + + private createNpmUtilEntry( + name: string, + source: string, + imported: string, + options: { destructuring?: boolean; exportName?: string } = {} + ) { + return { + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: source, + destructuring: options.destructuring ?? (imported !== 'default' && imported !== '*'), + exportName: options.exportName || (imported === 'default' || imported === '*' ? name : imported || name) + } + } + } + + private createDeclaredUtilEntry(name: string, node: any, sourceCode: string) { + if ( + t.isFunctionDeclaration(node) || + t.isFunctionExpression(node) || + t.isArrowFunctionExpression(node) || + t.isClassDeclaration(node) || + t.isClassExpression(node) + ) { + return { + name, + type: 'function', + content: { + type: 'JSFunction', + value: this.sanitizeModuleCodeFromNode(node, sourceCode) + } + } + } + + return this.createEmptyFunctionUtilEntry(name) + } + + private getModuleExportName(node: any, fallback = '') { + if (t.isIdentifier(node)) return node.name + if (t.isStringLiteral(node)) return node.value + return fallback + } + + private async collectModuleUtilExports( + modulePath: string, + context: LocalModuleContext, + cache = new Map>() + ) { + const normalizedPath = this.normalizeVirtualPath(modulePath) + + if (cache.has(normalizedPath)) { + return cache.get(normalizedPath)! + } + + const exportsMap = new Map() + cache.set(normalizedPath, exportsMap) + + const code = await context.readText(normalizedPath) + if (!code) return exportsMap + + let ast: any + try { + ast = babelParse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) + } catch { + this.parseUtilsModule(code).forEach((item) => exportsMap.set(item.name, item)) + return exportsMap + } + + const importsByLocal = new Map() + const declaredUtils = new Map() + + const resolveImportedUtil = async (importInfo: any, exportedName: string) => { + if (!importInfo?.source) return null + + if (this.isFrameworkImport(importInfo.source) || /\.vue$/i.test(importInfo.source)) { + return null + } + + if (importInfo.source.startsWith('.') || importInfo.source.startsWith('@/')) { + const targetPath = this.resolveLocalModulePath(importInfo.source, normalizedPath, context) + if (!targetPath) return null + const targetExports = await this.collectModuleUtilExports(targetPath, context, cache) + const target = + targetExports.get(exportedName) || (exportedName === 'default' ? targetExports.get('default') : null) + return target ? this.cloneUtilEntry(target) : null + } + + return this.createNpmUtilEntry(exportedName, importInfo.source, importInfo.imported || exportedName, { + destructuring: importInfo.kind === 'named', + exportName: + importInfo.kind === 'named' + ? importInfo.imported || exportedName + : importInfo.kind === 'namespace' + ? importInfo.local + : importInfo.local + }) + } + + const collectDeclaredVariables = (declaration: any, targetMap: Map, shouldExport = false) => { + declaration.declarations.forEach((item: any) => { + if (!t.isIdentifier(item.id)) return + const utilEntry = this.createDeclaredUtilEntry(item.id.name, item.init, code) + targetMap.set(item.id.name, utilEntry) + if (shouldExport) exportsMap.set(item.id.name, this.cloneUtilEntry(utilEntry)) + }) + } + + for (const statement of ast.program.body) { + if (t.isImportDeclaration(statement)) { + statement.specifiers.forEach((spec: any) => { + if (t.isImportSpecifier(spec)) { + importsByLocal.set(spec.local.name, { + source: statement.source.value, + local: spec.local.name, + imported: t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value, + kind: 'named' + }) + return + } + + if (t.isImportNamespaceSpecifier(spec)) { + importsByLocal.set(spec.local.name, { + source: statement.source.value, + local: spec.local.name, + imported: '*', + kind: 'namespace' + }) + return + } + + importsByLocal.set(spec.local.name, { + source: statement.source.value, + local: spec.local.name, + imported: 'default', + kind: 'default' + }) + }) + continue + } + + if (t.isFunctionDeclaration(statement) && statement.id) { + declaredUtils.set(statement.id.name, this.createDeclaredUtilEntry(statement.id.name, statement, code)) + continue + } + + if (t.isClassDeclaration(statement) && statement.id) { + declaredUtils.set(statement.id.name, this.createDeclaredUtilEntry(statement.id.name, statement, code)) + continue + } + + if (t.isVariableDeclaration(statement)) { + collectDeclaredVariables(statement, declaredUtils) + continue + } + + if (t.isExportNamedDeclaration(statement)) { + if (statement.declaration) { + if (t.isFunctionDeclaration(statement.declaration) && statement.declaration.id) { + const utilEntry = this.createDeclaredUtilEntry(statement.declaration.id.name, statement.declaration, code) + declaredUtils.set(statement.declaration.id.name, utilEntry) + exportsMap.set(statement.declaration.id.name, this.cloneUtilEntry(utilEntry)) + continue + } + + if (t.isClassDeclaration(statement.declaration) && statement.declaration.id) { + const utilEntry = this.createDeclaredUtilEntry(statement.declaration.id.name, statement.declaration, code) + declaredUtils.set(statement.declaration.id.name, utilEntry) + exportsMap.set(statement.declaration.id.name, this.cloneUtilEntry(utilEntry)) + continue + } + + if (t.isVariableDeclaration(statement.declaration)) { + collectDeclaredVariables(statement.declaration, declaredUtils, true) + continue + } + } + + if (statement.source) { + for (const specifier of statement.specifiers) { + if (!t.isExportSpecifier(specifier)) continue + const importedName = this.getModuleExportName(specifier.local) + const exportedName = this.getModuleExportName(specifier.exported, importedName) + const importInfo = { + source: statement.source.value, + imported: importedName, + local: importedName, + kind: importedName === 'default' ? 'default' : 'named' + } + const utilEntry = await resolveImportedUtil(importInfo, importedName) + if (utilEntry) exportsMap.set(exportedName, this.cloneUtilEntry(utilEntry, exportedName)) + } + continue + } + + for (const specifier of statement.specifiers) { + if (!t.isExportSpecifier(specifier)) continue + const localName = this.getModuleExportName(specifier.local) + const exportedName = this.getModuleExportName(specifier.exported, localName) + + if (declaredUtils.has(localName)) { + exportsMap.set(exportedName, this.cloneUtilEntry(declaredUtils.get(localName), exportedName)) + continue + } + + if (importsByLocal.has(localName)) { + const utilEntry = await resolveImportedUtil( + importsByLocal.get(localName), + importsByLocal.get(localName).imported + ) + if (utilEntry) { + exportsMap.set(exportedName, this.cloneUtilEntry(utilEntry, exportedName)) + } else { + exportsMap.set(exportedName, this.createEmptyFunctionUtilEntry(exportedName)) + } + } + } + + continue + } + + if (t.isExportAllDeclaration(statement)) { + if (!statement.source) continue + const importInfo = { source: statement.source.value, imported: '*', local: '*', kind: 'namespace' } + const targetPath = this.resolveLocalModulePath(importInfo.source, normalizedPath, context) + if (!targetPath) continue + const targetExports = await this.collectModuleUtilExports(targetPath, context, cache) + targetExports.forEach((item, exportName) => { + if (exportName === 'default') return + exportsMap.set(exportName, this.cloneUtilEntry(item, exportName)) + }) + continue + } + + if (t.isExportDefaultDeclaration(statement)) { + const declaration = statement.declaration + + if ( + t.isFunctionDeclaration(declaration) || + t.isFunctionExpression(declaration) || + t.isArrowFunctionExpression(declaration) || + t.isClassDeclaration(declaration) || + t.isClassExpression(declaration) + ) { + exportsMap.set('default', this.createDeclaredUtilEntry('default', declaration, code)) + continue + } + + if (t.isIdentifier(declaration)) { + if (declaredUtils.has(declaration.name)) { + exportsMap.set('default', this.cloneUtilEntry(declaredUtils.get(declaration.name), 'default')) + continue + } + + if (importsByLocal.has(declaration.name)) { + const utilEntry = await resolveImportedUtil( + importsByLocal.get(declaration.name), + importsByLocal.get(declaration.name).imported + ) + if (utilEntry) exportsMap.set('default', this.cloneUtilEntry(utilEntry, 'default')) + } + } + } + } + + return exportsMap + } + + private async collectRootUtils(context: LocalModuleContext) { + const entryCandidates = [ + 'src/utils.js', + 'src/utils.ts', + 'src/utils.jsx', + 'src/utils.tsx', + 'src/utils.mjs', + 'src/utils.cjs', + 'src/utils/index.js', + 'src/utils/index.ts', + 'src/utils/index.jsx', + 'src/utils/index.tsx', + 'src/utils/index.mjs', + 'src/utils/index.cjs' + ] + + const cache = new Map>() + const utils: any[] = [] + + for (const candidate of entryCandidates) { + if (!context.fileSet.has(candidate)) continue + const exportsMap = await this.collectModuleUtilExports(candidate, context, cache) + exportsMap.forEach((item, exportName) => { + if (exportName === 'default') return + utils.push(this.cloneUtilEntry(item, exportName)) + }) + } + + return this.mergeUtils([], utils) + } + + private async createImportedUtilEntry( + spec: { + local: string + imported: string + source: string + destructuring: boolean + kind?: string + importerFile?: string + }, + knownUtilsByName: Map, + context?: LocalModuleContext, + cache?: Map> + ) { + if (this.isFrameworkImport(spec.source)) return null + if (/\.vue$/i.test(spec.source)) return null + + if (context && spec.importerFile && (spec.source.startsWith('.') || spec.source.startsWith('@/'))) { + const targetPath = this.resolveLocalModulePath(spec.source, spec.importerFile, context) + if (targetPath) { + const targetExports = await this.collectModuleUtilExports(targetPath, context, cache) + const target = + targetExports.get(spec.imported) || (spec.imported === 'default' ? targetExports.get('default') : null) + if (target) { + return this.cloneUtilEntry(target, spec.local) + } + } + } + + if (this.isLocalUtilitySource(spec.source)) { + const known = knownUtilsByName.get(spec.imported) || knownUtilsByName.get(spec.local) + if (known) { + return { + ...known, + name: spec.local, + content: { + ...(known.content || {}), + exportName: + known.type === 'npm' + ? spec.imported === 'default' || spec.imported === '*' + ? known.content?.exportName || spec.local + : spec.imported + : known.content?.exportName + } + } + } + } + + if (spec.source.startsWith('.') || spec.source.startsWith('@/')) { + return this.createEmptyFunctionUtilEntry(spec.local) + } + + return this.createNpmUtilEntry(spec.local, spec.source, spec.imported, { destructuring: spec.destructuring }) + } + + private mergeUtils(existing: any[], incoming: any[]) { + const merged = [...existing] + const knownNames = new Set(existing.map((item) => item?.name).filter(Boolean)) + + incoming.forEach((item) => { + if (!item?.name || knownNames.has(item.name)) return + knownNames.add(item.name) + merged.push(item) + }) + + return merged + } + + private async enrichUtilsFromScriptResults(results: ConvertResult[], baseUtils: any[], context?: LocalModuleContext) { + const knownUtilsByName = new Map(baseUtils.map((item) => [item.name, item])) + const discovered: any[] = [] + const cache = new Map>() + + for (const result of results as any[]) { + const scriptSchema = result?.scriptSchema + if (!scriptSchema?.imports?.length) continue + + const usedImports = + Array.isArray(scriptSchema?.usedUtilsImports) && scriptSchema.usedUtilsImports.length + ? scriptSchema.usedUtilsImports + : scriptSchema.imports.flatMap((imp: any) => { + const codeStrings = this.getScriptCodeStrings(scriptSchema) + return (imp.specifiers || []) + .filter((spec: any) => spec?.local && this.isImportUsed(spec.local, codeStrings)) + .map((spec: any) => ({ + local: spec.local, + imported: spec.imported || 'default', + source: imp.source, + kind: spec.kind || (spec.imported === 'default' ? 'default' : 'named') + })) + }) + + for (const usedImport of usedImports) { + const utilEntry = await this.createImportedUtilEntry( + { + local: usedImport.local, + imported: usedImport.imported || 'default', + source: usedImport.source, + destructuring: + usedImport.kind === 'named' || (usedImport.imported !== 'default' && usedImport.imported !== '*'), + kind: usedImport.kind, + importerFile: scriptSchema.__filePath + }, + knownUtilsByName, + context, + cache + ) + if (utilEntry) { + discovered.push(utilEntry) + knownUtilsByName.set(utilEntry.name, utilEntry) + } + } + } + + return this.mergeUtils(baseUtils, discovered) + } + // Convert a full app directory (e.g., test/full/input/appdemo01) into an aggregated schema.json + async convertAppDirectory(appDir: string): Promise { + const srcDir = path.join(appDir, 'src') + const viewsDir = path.join(srcDir, 'views') + const appFiles = (await this.walk(appDir, (_p) => true)).map((filePath) => + this.normalizeVirtualPath(path.relative(appDir, filePath)) + ) + const moduleContext: LocalModuleContext = { + allFiles: appFiles, + fileSet: new Set(appFiles), + readText: async (filePath: string) => { + try { + return await fs.readFile(path.join(appDir, ...this.normalizeVirtualPath(filePath).split('/')), 'utf-8') + } catch { + return null + } + } + } + + // 1) Collect page schemas from all .vue files under src/views/** + const vueFiles = await this.walk(viewsDir, (p) => p.endsWith('.vue')) + + // First pass: collect all files and detect naming conflicts + const fileMap = new Map() + for (const filePath of vueFiles) { + const relativePath = path.relative(viewsDir, filePath) + const baseName = path.basename(relativePath, '.vue') + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + } + + // Determine which files need special naming (camelCase with directory prefix) + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + // Multiple files with same basename, all need special naming + paths.forEach((p) => needsSpecialNaming.add(p)) + } + } + + // Convert files with appropriate naming + const pageResults: ConvertResult[] = [] + for (const filePath of vueFiles) { + try { + const vueCode = await fs.readFile(filePath, 'utf-8') + const relativePath = path.relative(viewsDir, filePath) + const baseName = path.basename(relativePath, '.vue') + + // Use camelCase naming if there are conflicts, otherwise use basename + let fileName: string + if (needsSpecialNaming.has(relativePath)) { + fileName = this.buildConflictResolvedFileName( + relativePath, + baseName, + fileMap.get(baseName) || [], + Array.from(vueFiles, (file) => path.relative(viewsDir, file)) + ) + } else { + fileName = baseName + } + + const result = await this.convertFromString(vueCode, fileName) + if (result.scriptSchema) { + result.scriptSchema.__filePath = this.normalizeVirtualPath(path.relative(appDir, filePath)) + } + pageResults.push(result) + } catch (error: any) { + pageResults.push({ + schema: null, + dependencies: [], + errors: [`Failed to convert ${filePath}: ${error.message}`], + warnings: [] + }) + } + } + + const pageSchemas = pageResults.map((r) => r.schema).filter(Boolean) + + // 2) Load i18n + let i18n: any = { en_US: {}, zh_CN: {} } + try { + const enPath = path.join(srcDir, 'i18n', 'en_US.json') + const zhPath = path.join(srcDir, 'i18n', 'zh_CN.json') + const [en, zh] = await Promise.all([ + fs.readFile(enPath, 'utf-8').catch(() => '{}'), + fs.readFile(zhPath, 'utf-8').catch(() => '{}') + ]) + i18n = { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + // keep defaults + } + + // 3) Load utils from src/utils or src/utils/index + let utils: any[] = await this.collectRootUtils(moduleContext) + + // 4) Load dataSource from lowcodeConfig/dataSource.json + const dataSource: any = { list: [] } + try { + const dsPath = path.join(srcDir, 'lowcodeConfig', 'dataSource.json') + const dsRaw = await fs.readFile(dsPath, 'utf-8') + const dsJson = JSON.parse(dsRaw) + // pass through; keep shape as-is + if (Array.isArray(dsJson.list)) dataSource.list = dsJson.list + } catch { + // ignore + } + + // 5) Load globalState from src/stores/*.js (very light support for pinia defineStore) + const globalState: any[] = [] + try { + const storesDir = path.join(srcDir, 'stores') + const storeFiles = await this.walk(storesDir, (p) => p.endsWith('.js')) + for (const sf of storeFiles) { + const code = await fs.readFile(sf, 'utf-8') + // Skip files that don't define a Pinia store (e.g., re-export index.js) + if (!/defineStore\s*\(/.test(code)) continue + // naive extraction: id: 'xxx' + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const entry: any = { id: idMatch ? idMatch[1] : path.basename(sf, path.extname(sf)) } + if (stateMatch) { + try { + // very naive: turn JS object to JSON by removing trailing commas and function values + const objText = stateMatch[1] + const stateObj = Function(`return (${objText})`)() + entry.state = stateObj + } catch { + entry.state = {} + } + } else { + // No state found, skip this file to avoid empty entries + continue + } + // Only push when we have some keys in state (avoid empty {}) + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } + } catch { + // ignore + } + + // 6) Read router info to enrich page meta (router path, isPage, isHome) + try { + const routerPath = path.join(srcDir, 'router', 'index.js') + const rcode = await fs.readFile(routerPath, 'utf-8') + // find root redirect name (home) + // Simply capture the first redirect name (root level in this project) + const homeMatch = rcode.match(/redirect:\s*\{\s*name:\s*['"]([^'"]+)['"]/) + const homeName = homeMatch ? homeMatch[1] : '' + + // To avoid incorrectly pairing the redirect name with the first route's path/component, + // remove the redirect object before extracting route entries. + const rclean = rcode.replace(/redirect\s*:\s*\{[\s\S]*?\}/, '') + + const routeEntries: Array<{ routeName: string; routePath: string; importPath: string }> = [] + const routeRegex = + /name:\s*['"]([^'"]+)['"][\s\S]*?path:\s*['"]([^'"]+)['"][\s\S]*?component:\s*\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g + let m: RegExpExecArray | null + while ((m = routeRegex.exec(rclean))) { + routeEntries.push({ routeName: m[1], routePath: m[2], importPath: m[3] }) + } + // Build map by fileName (basename of the import .vue) + const byFile: Record = {} + for (const e of routeEntries) { + const base = path.basename(e.importPath).replace(/\.vue$/i, '') + byFile[base] = { routeName: e.routeName, routePath: e.routePath, isHome: e.routeName === homeName } + } + // Enrich page schemas + for (const ps of pageSchemas) { + const fileName = ps?.fileName + if (!fileName) continue + let info = byFile[fileName] + // If not found, try to match by checking if fileName ends with the base name (for camelCase names) + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + ps.meta = ps.meta || {} + if (info) { + // Remove leading slash from router path + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + ps.meta.router = routerPath + ps.meta.isPage = true + ps.meta.isHome = !!info.isHome + } else { + // Generate default router path from fileName if no match found + ps.meta.router = fileName.toLowerCase() + ps.meta.isPage = true + } + } + } catch (error) { + // If router enrichment fails, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + + // 7) Collect sub-components from src/components/**/*.vue and convert to block schemas + const blockSchemas: any[] = [] + try { + const componentsDir = path.join(srcDir, 'components') + const componentVueFiles = await this.walk(componentsDir, (p) => p.endsWith('.vue')) + for (const filePath of componentVueFiles) { + try { + const vueCode = await fs.readFile(filePath, 'utf-8') + const baseName = path.basename(filePath, '.vue') + const savedOptions = { ...this.options } + this.options = { ...this.options, isBlock: true } as any + const result = await this.convertFromString(vueCode, baseName) + this.options = savedOptions + if (result.scriptSchema) { + result.scriptSchema.__filePath = this.normalizeVirtualPath(path.relative(appDir, filePath)) + } + if (result.schema) { + result.schema.componentName = 'Block' + blockSchemas.push(result.schema) + } + pageResults.push(result) + } catch { + // skip individual component conversion errors + } + } + } catch { + // ignore if src/components doesn't exist + } + + // Also scan page schemas for componentType=Block nodes and ensure they have block schemas + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + utils = await this.enrichUtilsFromScriptResults(pageResults, utils, moduleContext) + + // 8) Assemble app schema + const appSchema = generateAppSchema(pageSchemas, { + i18n, + utils, + dataSource, + globalState, + blockSchemas + }) + + return appSchema + } + + // Recursively collect componentType=Block references from page schemas + // to ensure all referenced blocks have corresponding block schemas + private collectBlockRefsFromSchemas(pageSchemas: any[], blockSchemas: any[]): void { + const existingBlockNames = new Set(blockSchemas.map((b) => b.fileName)) + + const collectBlockNames = (node: any): void => { + if (!node || typeof node !== 'object') return + if (node.componentType === 'Block' && node.componentName && !existingBlockNames.has(node.componentName)) { + // Create a placeholder block schema for referenced but not found components + existingBlockNames.add(node.componentName) + blockSchemas.push({ + componentName: 'Block', + fileName: node.componentName, + meta: { name: node.componentName }, + children: node.children || [], + props: node.props || {}, + state: {}, + methods: {} + }) + } + if (Array.isArray(node.children)) { + node.children.forEach(collectBlockNames) + } + } + + for (const ps of pageSchemas) { + if (ps?.children) { + ps.children.forEach(collectBlockNames) + } + } + } + + setOptions(options: VueToSchemaOptions) { + this.options = { ...this.options, ...options } + } + + getOptions(): VueToSchemaOptions { + return { ...this.options } + } + + // Convert an app from a zip buffer (in-memory). The buffer should be the content of the zip file (not a path). + async convertAppFromZip(zipBuffer: ArrayBuffer | Uint8Array | Buffer): Promise { + // Browser-safe path: avoid fs/path/os, work fully in-memory + if (typeof window !== 'undefined' && typeof (window as any).document !== 'undefined') { + const zip = await JSZip.loadAsync(zipBuffer as any) + + // Collect file entries (posix paths in zip) + const allFiles = Object.keys((zip as any).files || {}) + .filter((p) => !(zip as any).files[p].dir) + .filter((p) => !p.startsWith('__MACOSX/')) + + // Determine root prefix (top-level folder) + const topLevels = new Set( + allFiles.map((p) => p.split('/')[0]).filter((seg) => !!seg && seg !== '.' && seg !== '..') + ) + let rootPrefix = '' + if (topLevels.size === 1) rootPrefix = [...topLevels][0] + '/' + + const joinRoot = (sub: string) => (rootPrefix ? rootPrefix + sub.replace(/^\/+/, '') : sub.replace(/^\/+/, '')) + const readText = async (rel: string) => { + const file = zip.file(rel) + return file ? await file.async('string') : null + } + const appFiles = allFiles.map((filePath) => + this.normalizeVirtualPath( + rootPrefix && filePath.startsWith(rootPrefix) ? filePath.slice(rootPrefix.length) : filePath + ) + ) + const moduleContext: LocalModuleContext = { + allFiles: appFiles, + fileSet: new Set(appFiles), + readText: async (filePath: string) => readText(joinRoot(filePath)) + } + + // 1) Pages: src/views/**/*.vue + const viewPrefix = joinRoot('src/views/') + const vueFiles = allFiles.filter((p) => p.startsWith(viewPrefix) && p.endsWith('.vue')) + + // First pass: collect all files and detect naming conflicts + const fileMap = new Map() + for (const vf of vueFiles) { + const relativePath = vf.substring(viewPrefix.length) + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || '' + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + } + + // Determine which files need special naming (camelCase with directory prefix) + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + // Multiple files with same basename, all need special naming + paths.forEach((p) => needsSpecialNaming.add(p)) + } + } + + // Convert files with appropriate naming + const pageResults: ConvertResult[] = [] + const pageSchemas: any[] = [] + for (const vf of vueFiles) { + const code = await readText(vf) + if (!code) continue + + const relativePath = vf.substring(viewPrefix.length) + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || 'Page' + + // Use camelCase naming if there are conflicts, otherwise use basename + let fileName: string + if (needsSpecialNaming.has(relativePath)) { + fileName = this.buildConflictResolvedFileName( + relativePath, + baseName, + fileMap.get(baseName) || [], + vueFiles.map((file) => file.substring(viewPrefix.length)) + ) + } else { + fileName = baseName + } + + const res = await this.convertFromString(code, fileName) + if (res.scriptSchema) { + res.scriptSchema.__filePath = this.normalizeVirtualPath( + rootPrefix && vf.startsWith(rootPrefix) ? vf.slice(rootPrefix.length) : vf + ) + } + pageResults.push(res) + if (res.schema) pageSchemas.push(res.schema) + } + + // 2) i18n + let i18n: any = { en_US: {}, zh_CN: {} } + try { + const en = (await readText(joinRoot('src/i18n/en_US.json'))) || '{}' + const zh = (await readText(joinRoot('src/i18n/zh_CN.json'))) || '{}' + i18n = { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + // keep defaults + } + + // 3) utils from src/utils or src/utils/index + let utils: any[] = await this.collectRootUtils(moduleContext) + + // 4) dataSource + const dataSource: any = { list: [] } + try { + const dsRaw = await readText(joinRoot('src/lowcodeConfig/dataSource.json')) + if (dsRaw) { + const dsJson = JSON.parse(dsRaw) + if (Array.isArray(dsJson.list)) dataSource.list = dsJson.list + } + } catch { + // ignore + } + + // 5) globalState from src/stores/*.js + const storesPrefix = joinRoot('src/stores/') + const storeFiles = allFiles.filter((p) => p.startsWith(storesPrefix) && p.endsWith('.js')) + const globalState: any[] = [] + for (const sf of storeFiles) { + try { + const code = await readText(sf) + if (!code || !/defineStore\s*\(/.test(code)) continue + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const entry: any = { id: idMatch ? idMatch[1] : (sf.split('/').pop() || 'store').replace(/\.[^.]+$/, '') } + if (stateMatch) { + try { + const objText = stateMatch[1] + const stateObj = Function(`return (${objText})`)() + entry.state = stateObj + } catch { + entry.state = {} + } + } else { + continue + } + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } catch { + // ignore + } + } + + // 6) router enrichment + try { + const rcode = await readText(joinRoot('src/router/index.js')) + if (rcode) { + const homeMatch = rcode.match(/redirect:\s*\{\s*name:\s*['"]([^'"]+)['"]/) + const homeName = homeMatch ? homeMatch[1] : '' + const rclean = rcode.replace(/redirect\s*:\s*\{[\s\S]*?\}/, '') + const routeEntries: Array<{ routeName: string; routePath: string; importPath: string }> = [] + const routeRegex = + /name:\s*['"]([^'"]+)['"][\s\S]*?path:\s*['"]([^'"]+)['"][\s\S]*?component:\s*\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g + let m: RegExpExecArray | null + while ((m = routeRegex.exec(rclean))) + routeEntries.push({ routeName: m[1], routePath: m[2], importPath: m[3] }) + const byFile: Record = {} + for (const e of routeEntries) { + const base = (e.importPath.split('/').pop() || '').replace(/\.vue$/i, '') + byFile[base] = { routeName: e.routeName, routePath: e.routePath, isHome: e.routeName === homeName } + } + for (const ps of pageSchemas) { + const fileName = ps?.fileName + if (!fileName) continue + let info = byFile[fileName] + // If not found, try to match by checking if fileName ends with the base name (for camelCase names) + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + // Try exact match (case-insensitive) + if (fileName.toLowerCase() === base.toLowerCase()) { + info = routeInfo + break + } + // Try matching if fileName ends with base name (for camelCase names) + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + ps.meta = ps.meta || {} + if (info) { + // Remove leading slash from router path + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + ps.meta.router = routerPath + ps.meta.isPage = true + ps.meta.isHome = !!info.isHome + } else { + // Generate default router path from fileName if no match found + ps.meta.router = fileName.toLowerCase() + ps.meta.isPage = true + } + } + } else { + // If router file not found, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + } catch (error) { + // If router enrichment fails, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + + // 7) Collect sub-components from src/components/**/*.vue and convert to block schemas + const blockSchemas: any[] = [] + const componentsPrefix = joinRoot('src/components/') + const componentVueFiles = allFiles.filter((p) => p.startsWith(componentsPrefix) && p.endsWith('.vue')) + for (const cf of componentVueFiles) { + try { + const code = await readText(cf) + if (!code) continue + const baseName = (cf.split('/').pop() || '').replace(/\.vue$/i, '') || 'Block' + const savedOptions = { ...this.options } + this.options = { ...this.options, isBlock: true } as any + const res = await this.convertFromString(code, baseName) + this.options = savedOptions + if (res.scriptSchema) { + res.scriptSchema.__filePath = this.normalizeVirtualPath( + rootPrefix && cf.startsWith(rootPrefix) ? cf.slice(rootPrefix.length) : cf + ) + } + if (res.schema) { + res.schema.componentName = 'Block' + blockSchemas.push(res.schema) + } + pageResults.push(res) + } catch { + // skip individual component conversion errors + } + } + + // Also scan page schemas for componentType=Block nodes + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + utils = await this.enrichUtilsFromScriptResults(pageResults, utils, moduleContext) + + // 8) Assemble app schema + const appSchema = generateAppSchema(pageSchemas, { + i18n, + utils, + dataSource, + globalState, + blockSchemas + }) + + return appSchema + } + + // Node.js path: unzip to temp and reuse directory-based converter + // 1) Unzip into a temp directory + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'vue-to-dsl-')) + const zip = await JSZip.loadAsync(zipBuffer as any) + + const fileEntries: string[] = [] + const writeTasks: Promise[] = [] + zip.forEach((relPath, file) => { + // Skip macOS metadata + if (relPath.startsWith('__MACOSX/')) return + const outPath = path.join(tmpBase, relPath) + if (file.dir) { + writeTasks.push(fs.mkdir(outPath, { recursive: true })) + } else { + fileEntries.push(relPath) + writeTasks.push( + (async () => { + await fs.mkdir(path.dirname(outPath), { recursive: true }) + const content = await file.async('nodebuffer') + await fs.writeFile(outPath, content) + })() + ) + } + }) + await Promise.all(writeTasks) + + // 2) Determine the root app directory inside the zip + const topLevels = new Set( + fileEntries.map((p) => p.split('/')[0]).filter((seg) => !!seg && seg !== '.' && seg !== '..') + ) + + let appRoot = tmpBase + if (topLevels.size === 1) { + const only = [...topLevels][0] + appRoot = path.join(tmpBase, only) + } + + // 3) Delegate to convertAppDirectory + const schema = await this.convertAppDirectory(appRoot) + return schema + } + + async convertAppFromDirectory(files: FileList): Promise { + const fileArray = Array.from(files) + let relevantFiles = [] + + const readText = async (file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsText(file) + }) + } + + const createGitignoreFilter = (gitignoreContent: string) => { + const lines = gitignoreContent + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + const patterns = lines.map((line) => { + const isNegative = line.startsWith('!') + const pattern = isNegative ? line.slice(1) : line + + // Convert gitignore pattern to regex + const regexString = pattern + .replace(/([.+?^${}()|[\]\\])/g, '\\$1') // Escape special chars + .replace(/\/\*\*$/, '/.*') // '/**' at the end + .replace(/\*\*/g, '.*') // '**' + .replace(/\*/g, '[^/]*') // '*' + .replace(/\?/g, '[^/]') // '?' + + // Handle directory matching + if (regexString.endsWith('/')) { + return { regex: new RegExp(`^${regexString}`), isNegative } + } + + return { regex: new RegExp(`^${regexString}(/.*)?$`), isNegative } + }) + + return (path: string) => { + let isIgnored = false + for (const { regex, isNegative } of patterns) { + if (regex.test(path)) { + isIgnored = !isNegative + } + } + return !isIgnored + } + } + + const gitignoreFile = fileArray.find((file) => file.webkitRelativePath.endsWith('/.gitignore')) + + if (gitignoreFile) { + const gitignoreContent = await readText(gitignoreFile) + const rootDir = gitignoreFile.webkitRelativePath.split('/')[0] + const filter = createGitignoreFilter(gitignoreContent) + + relevantFiles = fileArray.filter((file) => { + const relativePath = file.webkitRelativePath.slice(rootDir.length + 1) + return relativePath && filter(relativePath) + }) + } else { + // Filter out node_modules + relevantFiles = fileArray.filter((file) => !file.webkitRelativePath.includes('node_modules')) + } + + const getAppRelativePath = (file: File) => + this.normalizeVirtualPath(file.webkitRelativePath.split('/').slice(1).join('/')) + const filesByPath = new Map(relevantFiles.map((file) => [getAppRelativePath(file), file])) + const moduleContext: LocalModuleContext = { + allFiles: Array.from(filesByPath.keys()), + fileSet: new Set(filesByPath.keys()), + readText: async (filePath: string) => { + const file = filesByPath.get(this.normalizeVirtualPath(filePath)) + return file ? await readText(file) : null + } + } + + // 1) Pages: src/views/**/*.vue + const vueFiles = relevantFiles.filter( + (file) => file.webkitRelativePath.includes('src/views/') && file.name.endsWith('.vue') + ) + + // First pass: collect all files and detect naming conflicts + const fileMap = new Map() + for (const vf of vueFiles) { + const webkitPath = vf.webkitRelativePath + const viewsIndex = webkitPath.indexOf('src/views/') + const relativePath = viewsIndex >= 0 ? webkitPath.substring(viewsIndex + 'src/views/'.length) : vf.name + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || '' + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + } + + // Determine which files need special naming (camelCase with directory prefix) + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + // Multiple files with same basename, all need special naming + paths.forEach((p) => needsSpecialNaming.add(p)) + } + } + + const pageResults: ConvertResult[] = [] + const pageSchemas: any[] = [] + for (const vf of vueFiles) { + const code = await readText(vf) + if (!code) continue + + const webkitPath = vf.webkitRelativePath + const viewsIndex = webkitPath.indexOf('src/views/') + const relativePath = viewsIndex >= 0 ? webkitPath.substring(viewsIndex + 'src/views/'.length) : vf.name + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || 'Page' + + // Use camelCase naming if there are conflicts, otherwise use basename + let fileName: string + if (needsSpecialNaming.has(relativePath)) { + fileName = this.buildConflictResolvedFileName( + relativePath, + baseName, + fileMap.get(baseName) || [], + vueFiles.map((file) => { + const webkitPath = file.webkitRelativePath + const fileViewsIndex = webkitPath.indexOf('src/views/') + return fileViewsIndex >= 0 ? webkitPath.substring(fileViewsIndex + 'src/views/'.length) : file.name + }) + ) + } else { + fileName = baseName + } + + const res = await this.convertFromString(code, fileName) + if (res.scriptSchema) { + res.scriptSchema.__filePath = getAppRelativePath(vf) + } + pageResults.push(res) + if (res.schema) pageSchemas.push(res.schema) + } + + // 2) i18n + let i18n: any = { en_US: {}, zh_CN: {} } + try { + const enFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/i18n/en_US.json')) + const zhFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/i18n/zh_CN.json')) + const en = enFile ? await readText(enFile) : '{}' + const zh = zhFile ? await readText(zhFile) : '{}' + i18n = { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + // keep defaults + } + + // 3) utils from src/utils or src/utils/index + let utils: any[] = await this.collectRootUtils(moduleContext) + + // 4) dataSource + const dataSource: any = { list: [] } + try { + const dsFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/lowcodeConfig/dataSource.json')) + if (dsFile) { + const dsRaw = await readText(dsFile) + const dsJson = JSON.parse(dsRaw) + if (Array.isArray(dsJson.list)) dataSource.list = dsJson.list + } + } catch { + // ignore + } + + // 5) globalState from src/stores/*.js + const storeFiles = relevantFiles.filter( + (f) => f.webkitRelativePath.includes('src/stores/') && f.name.endsWith('.js') + ) + const globalState: any[] = [] + for (const sf of storeFiles) { + try { + const code = await readText(sf) + if (!code || !/defineStore\s*\(/.test(code)) continue + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const entry: any = { id: idMatch ? idMatch[1] : sf.name.replace(/\.[^.]+$/, '') } + if (stateMatch) { + try { + const objText = stateMatch[1] + const stateObj = Function(`return (${objText})`)() + entry.state = stateObj + } catch { + entry.state = {} + } + } else { + continue + } + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } catch { + // ignore + } + } + + // 6) router enrichment + try { + const routerFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/router/index.js')) + if (routerFile) { + const rcode = await readText(routerFile) + const homeMatch = rcode.match(/redirect:\s*\{\s*name:\s*['"]([^'"]+)['"]/) + const homeName = homeMatch ? homeMatch[1] : '' + const rclean = rcode.replace(/redirect\s*:\s*\{[\s\S]*?\}/, '') + const routeEntries: Array<{ routeName: string; routePath: string; importPath: string }> = [] + const routeRegex = + /name:\s*['"]([^'"]+)['"][\s\S]*?path:\s*['"]([^'"]+)['"][\s\S]*?component:\s*\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g + let m: RegExpExecArray | null + while ((m = routeRegex.exec(rclean))) routeEntries.push({ routeName: m[1], routePath: m[2], importPath: m[3] }) + const byFile: Record = {} + for (const e of routeEntries) { + const base = (e.importPath.split('/').pop() || '').replace(/\.vue$/i, '') + byFile[base] = { routeName: e.routeName, routePath: e.routePath, isHome: e.routeName === homeName } + } + for (const ps of pageSchemas) { + const fileName = ps?.fileName + if (!fileName) continue + let info = byFile[fileName] + // If not found, try to match by checking if fileName ends with the base name (for camelCase names) + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + // Try exact match (case-insensitive) + if (fileName.toLowerCase() === base.toLowerCase()) { + info = routeInfo + break + } + // Try matching if fileName ends with base name (for camelCase names) + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + ps.meta = ps.meta || {} + if (info) { + // Remove leading slash from router path + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + ps.meta.router = routerPath + ps.meta.isPage = true + ps.meta.isHome = !!info.isHome + } else { + // Generate default router path from fileName if no match found + ps.meta.router = fileName.toLowerCase() + ps.meta.isPage = true + } + } + } else { + // If router file not found, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + } catch (error) { + // If router enrichment fails, set default router for all pages + for (const ps of pageSchemas) { + ps.meta = ps.meta || {} + if (!ps.meta.router) { + ps.meta.router = (ps.fileName || 'page').toLowerCase() + ps.meta.isPage = true + } + } + } + + // 7) Collect sub-components from src/components/**/*.vue and convert to block schemas + const blockSchemas: any[] = [] + const componentVueFiles = relevantFiles.filter( + (file) => file.webkitRelativePath.includes('src/components/') && file.name.endsWith('.vue') + ) + for (const cf of componentVueFiles) { + try { + const code = await readText(cf) + if (!code) continue + const baseName = cf.name.replace(/\.vue$/i, '') || 'Block' + const savedOptions = { ...this.options } + this.options = { ...this.options, isBlock: true } as any + const res = await this.convertFromString(code, baseName) + this.options = savedOptions + if (res.scriptSchema) { + res.scriptSchema.__filePath = getAppRelativePath(cf) + } + if (res.schema) { + res.schema.componentName = 'Block' + blockSchemas.push(res.schema) + } + pageResults.push(res) + } catch { + // skip individual component conversion errors + } + } + + // Also scan page schemas for componentType=Block nodes + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + utils = await this.enrichUtilsFromScriptResults(pageResults, utils, moduleContext) + + // 8) Assemble app schema + const appSchema = generateAppSchema(pageSchemas, { + i18n, + utils, + dataSource, + globalState, + blockSchemas + }) + + return appSchema + } +} diff --git a/packages/vue-to-dsl/src/generator/index.ts b/packages/vue-to-dsl/src/generator/index.ts new file mode 100644 index 0000000000..fd5f2e2416 --- /dev/null +++ b/packages/vue-to-dsl/src/generator/index.ts @@ -0,0 +1,194 @@ +import { defaultComponentsMap } from '../constants' +function convertToPlainValue(expr: any) { + // If it's already an object or array, return as-is (for nested reactive objects) + if (typeof expr === 'object' && expr !== null) return expr + if (typeof expr !== 'string') return expr + const trimmed = expr.trim() + if (/^['"].*['"]$/.test(trimmed)) return trimmed.slice(1, -1) + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed) + if (trimmed === 'true') return true + if (trimmed === 'false') return false + if (trimmed === 'null') return null + return trimmed +} + +function extractRefPrimitive(expr: any) { + // If it's already an object or array, return as-is + if (typeof expr === 'object' && expr !== null) return expr + if (typeof expr !== 'string') return expr + const m = expr.match(/^ref\((.*)\)$/) + if (!m) return expr + const inner = m[1].trim() + return convertToPlainValue(inner) +} + +function transformState(state: Record) { + const result: Record = {} + Object.keys(state).forEach((key) => { + const stateItem = state[key] + if (typeof stateItem === 'object' && stateItem.type) { + switch (stateItem.type) { + case 'reactive': + result[key] = convertToPlainValue(stateItem.value) + break + case 'ref': + result[key] = extractRefPrimitive(stateItem.value) + break + default: + result[key] = stateItem.value || stateItem + } + } else { + result[key] = stateItem + } + }) + return result +} + +function transformMethods(methods: Record) { + const result: Record = {} + Object.keys(methods).forEach((key) => { + const method = methods[key] + if (typeof method === 'object' && method.value) { + result[key] = { type: 'JSFunction', value: method.value } + } else if (typeof method === 'string') { + result[key] = { type: 'JSFunction', value: method } + } else { + result[key] = { type: 'JSFunction', value: 'function() { /* method implementation */ }' } + } + }) + return result +} + +function transformComputed(computed: Record) { + const result: Record = {} + Object.keys(computed).forEach((key) => { + const computedItem = computed[key] + if (typeof computedItem === 'object' && computedItem.value) { + result[key] = { type: 'JSFunction', value: computedItem.value } + } else if (typeof computedItem === 'string') { + result[key] = { type: 'JSFunction', value: computedItem } + } else { + result[key] = { type: 'JSFunction', value: 'function() { /* computed getter */ }' } + } + }) + return result +} + +function transformLifeCycles(lifecycle: Record) { + const result: Record = {} + Object.keys(lifecycle).forEach((key) => { + const lifecycleItem = lifecycle[key] + if (typeof lifecycleItem === 'object' && lifecycleItem.value) { + result[key] = { type: 'JSFunction', value: lifecycleItem.value } + } else if (typeof lifecycleItem === 'string') { + result[key] = { type: 'JSFunction', value: lifecycleItem } + } else { + result[key] = { type: 'JSFunction', value: 'function() { /* lifecycle hook */ }' } + } + }) + return result +} + +function transformProps(props: any[]) { + return props.map((prop) => { + if (typeof prop === 'string') return { name: prop, type: 'any', default: undefined } + if (typeof prop === 'object') + return { + name: prop.name || 'unknownProp', + type: prop.type || 'any', + default: prop.default, + required: prop.required || false + } + return prop + }) +} + +// Generate an 8-char id with lowercase letters and digits +function generateId(): string { + let s = '' + while (s.length < 8) s += Math.random().toString(36).slice(2) + return s.slice(0, 8) +} + +// Recursively assign id to nodes with componentName +function assignComponentIds(node: any): void { + if (!node || typeof node !== 'object') return + if (typeof node.componentName === 'string') { + if (!node.id) node.id = generateId() + } + if (Array.isArray(node.children)) node.children.forEach(assignComponentIds) +} + +// Deeply sanitize all string values in the schema +function sanitizeSchemaStrings(obj: any): any { + if (obj === null || obj === undefined) return obj + if (typeof obj === 'string') return obj + if (Array.isArray(obj)) return obj.map((v) => sanitizeSchemaStrings(v)) + if (typeof obj === 'object') { + const out: any = Array.isArray(obj) ? [] : {} + Object.keys(obj).forEach((k) => { + out[k] = sanitizeSchemaStrings(obj[k]) + }) + return out + } + return obj +} + +export async function generateSchema(templateSchema: any[], scriptSchema: any, styleSchema: any, options: any = {}) { + const fileName = options.fileName || 'UnnamedPage' + // Capitalize first letter for display name + const displayName = fileName.charAt(0).toUpperCase() + fileName.slice(1) + + const schema: any = { + componentName: options.isBlock ? 'Block' : 'Page', + fileName: fileName, + meta: { + name: displayName + } + } + if (scriptSchema) { + if (scriptSchema.state) schema.state = transformState(scriptSchema.state) + if (scriptSchema.methods) schema.methods = transformMethods(scriptSchema.methods) + // only output computed when computed_flag is explicitly enabled + if (options.computed_flag === true && scriptSchema.computed) { + schema.computed = transformComputed(scriptSchema.computed) + } + if (scriptSchema.lifeCycles) schema.lifeCycles = transformLifeCycles(scriptSchema.lifeCycles) + if (scriptSchema.props && scriptSchema.props.length > 0) schema.props = transformProps(scriptSchema.props) + } + if (styleSchema && styleSchema.css) schema.css = styleSchema.css + if (templateSchema && templateSchema.length > 0) schema.children = templateSchema + // sanitize all strings to remove newlines in the final output + const sanitized = sanitizeSchemaStrings(schema) + // assign 8-char ids to all component nodes (including Page root) + assignComponentIds(sanitized) + return sanitized +} + +export function generateAppSchema(pageSchemas: any[], options: any = {}) { + // Ensure all pages have a router path without leading slash + if (pageSchemas && Array.isArray(pageSchemas)) { + for (const ps of pageSchemas) { + if (ps && ps.meta && ps.meta.router && typeof ps.meta.router === 'string') { + // Remove leading slash from router path + if (ps.meta.router.startsWith('/')) { + ps.meta.router = ps.meta.router.slice(1) + } + } + } + } + + return { + meta: { + name: options.name || 'Generated App', + description: options.description || 'App generated from Vue SFC files' + }, + i18n: options.i18n || { en_US: {}, zh_CN: {} }, + utils: options.utils || [], + dataSource: options.dataSource || { list: [] }, + globalState: options.globalState || [], + pageSchema: pageSchemas || [], + blockSchemas: options.blockSchemas || [], + componentsMap: options.componentsMap || defaultComponentsMap + } +} diff --git a/packages/vue-to-dsl/src/index.d.ts b/packages/vue-to-dsl/src/index.d.ts new file mode 100644 index 0000000000..d1241e660c --- /dev/null +++ b/packages/vue-to-dsl/src/index.d.ts @@ -0,0 +1,155 @@ +declare module '@opentiny/tiny-engine-vue-to-dsl' { + export interface VueToSchemaOptions { + // 组件映射配置 + componentMap?: Record + // 是否保留注释 + preserveComments?: boolean + // 是否严格模式 + strictMode?: boolean + // 控制是否输出 computed 字段(默认 false) + computed_flag?: boolean + // 自定义解析器 + customParsers?: { + template?: TemplateParser + script?: ScriptParser + style?: StyleParser + } + } + + export interface TemplateParser { + parse(template: string, options?: any): TemplateSchema + } + + export interface ScriptParser { + parse(script: string, options?: any): ScriptSchema + } + + export interface StyleParser { + parse(style: string, options?: any): StyleSchema + } + + export interface TemplateSchema { + componentName: string + props?: Record + children?: TemplateSchema[] + condition?: string + loop?: string + key?: string + ref?: string + [key: string]: any + } + + export interface ScriptSchema { + state?: Record + methods?: Record + computed?: Record + lifeCycles?: Record + imports?: ImportInfo[] + props?: PropInfo[] + emits?: string[] + } + + export interface StyleSchema { + css: string + scoped?: boolean + lang?: string + } + + export interface ImportInfo { + source: string + specifiers: string[] + default?: string + } + + export interface PropInfo { + name: string + type?: string + default?: any + required?: boolean + } + + export interface PageSchema { + componentName: 'Page' + fileName: string + path: string + meta?: Record + state?: Record + methods?: Record + computed?: Record + lifeCycles?: Record + props?: PropInfo[] + css?: string + children?: TemplateSchema[] + } + + export interface ConvertResult { + schema: PageSchema + dependencies: string[] + errors: string[] + warnings: string[] + } + + export class VueToDslConverter { + constructor(options?: VueToSchemaOptions) + + /** + * 将Vue SFC文件内容转换为DSL Schema + */ + convertFromString(vueCode: string): Promise + + /** + * 将Vue SFC文件转换为DSL Schema + */ + convertFromFile(filePath: string): Promise + + /** + * 批量转换多个Vue文件 + */ + convertMultipleFiles(filePaths: string[]): Promise + } + + /** + * 解析Vue SFC文件 + */ + export function parseVueFile(filePath: string): Promise<{ + template?: string + script?: string + style?: string + scriptSetup?: string + }> + + /** + * 解析Vue SFC代码字符串 + */ + export function parseSFC(vueCode: string): { + template?: string + script?: string + style?: string + scriptSetup?: string + } + + /** + * 生成DSL Schema + */ + export function generateSchema( + template: string, + script: string, + style?: string, + options?: VueToSchemaOptions + ): Promise + + /** + * 解析模板 + */ + export function parseTemplate(template: string): TemplateSchema[] + + /** + * 解析脚本 + */ + export function parseScript(script: string): ScriptSchema + + /** + * 解析样式 + */ + export function parseStyle(style: string): StyleSchema +} diff --git a/packages/vue-to-dsl/src/index.ts b/packages/vue-to-dsl/src/index.ts new file mode 100644 index 0000000000..6075591824 --- /dev/null +++ b/packages/vue-to-dsl/src/index.ts @@ -0,0 +1,7 @@ +import './index.d.ts' + +export { VueToDslConverter } from './converter' +export { parseVueFile, parseSFC } from './parser' +export { generateSchema, generateAppSchema } from './generator' +export { parseTemplate, parseScript, parseStyle } from './parsers' +export * from './types/index' diff --git a/packages/vue-to-dsl/src/parser/index.ts b/packages/vue-to-dsl/src/parser/index.ts new file mode 100644 index 0000000000..40eb747e9d --- /dev/null +++ b/packages/vue-to-dsl/src/parser/index.ts @@ -0,0 +1,63 @@ +import { parse } from '@vue/compiler-sfc' +import fs from 'fs/promises' + +export function parseSFC(vueCode: string): any { + const { descriptor, errors } = parse(vueCode) + if (errors && (errors as any[]).length > 0) { + // eslint-disable-next-line no-console + console.warn('SFC parsing warnings:', errors) + } + + const result: any = {} + if (descriptor.template) { + result.template = descriptor.template.content + result.templateLang = descriptor.template.lang || 'html' + } + if (descriptor.scriptSetup) { + result.scriptSetup = descriptor.scriptSetup.content + result.scriptSetupLang = descriptor.scriptSetup.lang || 'js' + } + if (descriptor.script) { + result.script = descriptor.script.content + result.scriptLang = descriptor.script.lang || 'js' + } + if (descriptor.styles && descriptor.styles.length > 0) { + result.style = descriptor.styles.map((style) => style.content).join('\n\n') + result.styleBlocks = descriptor.styles.map((style) => ({ + content: style.content, + lang: style.lang || 'css', + scoped: style.scoped || false, + module: style.module || false + })) + } + if (descriptor.customBlocks && descriptor.customBlocks.length > 0) { + result.customBlocks = descriptor.customBlocks.map((block) => ({ + type: block.type, + content: (block as any).content, + attrs: (block as any).attrs + })) + } + return result +} + +export async function parseVueFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8') + return parseSFC(content) +} + +export function validateSFC(sfcResult: any): boolean { + return !!(sfcResult.template || sfcResult.script || sfcResult.scriptSetup) +} + +export function getSFCMeta(sfcResult: any) { + return { + hasTemplate: !!sfcResult.template, + hasScript: !!sfcResult.script, + hasScriptSetup: !!sfcResult.scriptSetup, + hasStyle: !!sfcResult.style, + templateLang: sfcResult.templateLang, + scriptLang: sfcResult.scriptLang || sfcResult.scriptSetupLang, + styleBlocks: sfcResult.styleBlocks || [], + customBlocks: sfcResult.customBlocks || [] + } +} diff --git a/packages/vue-to-dsl/src/parsers/index.ts b/packages/vue-to-dsl/src/parsers/index.ts new file mode 100644 index 0000000000..9b596456c2 --- /dev/null +++ b/packages/vue-to-dsl/src/parsers/index.ts @@ -0,0 +1,3 @@ +export { parseTemplate } from './templateParser' +export { parseScript } from './scriptParser' +export { parseStyle, parseCSSRules, extractCSSVariables, hasMediaQueries, extractMediaQueries } from './styleParser' diff --git a/packages/vue-to-dsl/src/parsers/scriptParser.ts b/packages/vue-to-dsl/src/parsers/scriptParser.ts new file mode 100644 index 0000000000..392afd4e7a --- /dev/null +++ b/packages/vue-to-dsl/src/parsers/scriptParser.ts @@ -0,0 +1,1662 @@ +import { parse } from '@babel/parser' +import traverseModule from '@babel/traverse' +import * as t from '@babel/types' + +const traverse: any = (traverseModule as any)?.default ?? (traverseModule as any) + +const JS_GLOBALS = new Set([ + 'Math', + 'Number', + 'String', + 'Boolean', + 'Array', + 'Object', + 'Date', + 'JSON', + 'console', + 'Intl', + 'RegExp', + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + 'Promise', + 'Symbol', + 'BigInt', + 'parseInt', + 'parseFloat', + 'isNaN', + 'isFinite', + 'encodeURI', + 'decodeURI', + 'encodeURIComponent', + 'decodeURIComponent', + 'undefined', + 'NaN', + 'Infinity', + 'window', + 'document', + 'localStorage', + 'sessionStorage', + 'navigator', + 'location', + 'history', + 'fetch', + 'URL', + 'URLSearchParams', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'alert' +]) + +const LIFECYCLE_HOOKS = [ + 'onMounted', + 'onUpdated', + 'onUnmounted', + 'onBeforeMount', + 'onBeforeUpdate', + 'onBeforeUnmount', + 'onActivated', + 'onDeactivated', + 'mounted', + 'updated', + 'unmounted', + 'beforeMount', + 'beforeUpdate', + 'beforeUnmount', + 'activated', + 'deactivated', + 'created', + 'beforeCreate', + 'destroyed', + 'beforeDestroy', + 'setup' +] + +function isVueReactiveCall(node: any, apiName: string) { + if (!t.isCallExpression(node)) return false + // direct call: reactive()/ref()/computed() + if (t.isIdentifier(node.callee) && node.callee.name === apiName) return true + // member call: vue.reactive()/Vue.ref()/anything.ref() + if (t.isMemberExpression(node.callee)) { + const callee = node.callee + const prop = callee.property + if (t.isIdentifier(prop) && prop.name === apiName) return true + } + return false +} + +function isLifecycleHook(name: string) { + return LIFECYCLE_HOOKS.includes(name) +} + +function getNodeValue(node: any): any { + if (t.isStringLiteral(node)) return node.value + if (t.isNumericLiteral(node)) return node.value + if (t.isBooleanLiteral(node)) return node.value + if (t.isNullLiteral(node)) return null + if (t.isUnaryExpression(node) && node.operator === '-' && t.isNumericLiteral(node.argument)) { + return -node.argument.value + } + if (t.isCallExpression(node)) { + let calleeStr = '' + if (t.isIdentifier(node.callee)) { + calleeStr = node.callee.name + } else if (t.isMemberExpression(node.callee)) { + const obj = node.callee.object as any + const prop = node.callee.property as any + const objStr = t.isIdentifier(obj) ? obj.name : '' + const propStr = t.isIdentifier(prop) ? prop.name : '' + if (objStr && propStr) calleeStr = `${objStr}.${propStr}` + } + const args = node.arguments.map((arg: any) => getNodeValue(arg)) + if (calleeStr) + return `${calleeStr}(${args.map((a: any) => (typeof a === 'string' ? `'${a}'` : String(a))).join(', ')})` + return 'undefined' + } + if (t.isObjectExpression(node)) { + const obj: Record = {} + node.properties.forEach((prop: any) => { + if (t.isObjectProperty(prop)) { + let keyName: string | null = null + if (t.isIdentifier(prop.key)) keyName = prop.key.name + else if (t.isStringLiteral(prop.key)) keyName = prop.key.value + else if (t.isNumericLiteral(prop.key)) keyName = String(prop.key.value) + if (keyName) obj[keyName] = getNodeValue(prop.value as any) + } + }) + return obj + } + if (t.isArrayExpression(node)) { + return node.elements.map((el: any) => (el ? getNodeValue(el) : null)) + } + return 'undefined' +} + +function getObjectKeyName(node: any): string | null { + if (t.isIdentifier(node)) return node.name + if (t.isStringLiteral(node)) return node.value + if (t.isNumericLiteral(node)) return String(node.value) + return null +} + +function getJSXAttributeName(node: any): string { + if (t.isJSXIdentifier(node)) return node.name + if (t.isJSXNamespacedName(node)) { + const namespace = t.isJSXIdentifier(node.namespace) ? node.namespace.name : '' + const name = t.isJSXIdentifier(node.name) ? node.name.name : '' + return `${namespace}:${name}` + } + if (t.isJSXMemberExpression(node)) { + const objectName = getJSXAttributeName(node.object) + const propertyName = getJSXAttributeName(node.property) + return [objectName, propertyName].filter(Boolean).join('.') + } + return '' +} + +function getJSXTagName(node: any): string { + if (t.isJSXIdentifier(node)) return node.name + if (t.isJSXMemberExpression(node)) { + const objectName = getJSXTagName(node.object) + const propertyName = getJSXTagName(node.property) + return [objectName, propertyName].filter(Boolean).join('.') + } + if (t.isJSXNamespacedName(node)) { + return `${getJSXAttributeName(node.namespace)}:${getJSXAttributeName(node.name)}` + } + return 'div' +} + +function getSource(node: any, source: string): string { + if (!node) return '' + const start = (node as any).start + const end = (node as any).end + if (typeof start === 'number' && typeof end === 'number') return source.slice(start, end) + return '' +} + +function unwrapExpression(node: any): any { + if (t.isTSAsExpression(node)) return unwrapExpression(node.expression) + if (t.isTSTypeAssertion(node)) return unwrapExpression(node.expression) + if (t.isTSNonNullExpression(node)) return unwrapExpression(node.expression) + if ((t as any).isParenthesizedExpression?.(node)) return unwrapExpression((node as any).expression) + return node +} + +function applyReplacements(code: string, replacements: Array<{ start: number; end: number; text: string }>) { + return replacements + .sort((a, b) => b.start - a.start || b.end - a.end) + .reduce((output, item) => `${output.slice(0, item.start)}${item.text}${output.slice(item.end)}`, code) +} + +function isFrameworkImportSource(source: string) { + return ['vue', 'vue-i18n'].includes(source) +} + +function sanitizeCodeFromNode(node: any, source: string): string { + const raw = getSource(node, source) + const baseStart = node?.start + const baseEnd = node?.end + if (!raw || typeof baseStart !== 'number' || typeof baseEnd !== 'number') return raw + + let wrappedNode: any + if (t.isStatement(node)) { + wrappedNode = node + } else if (t.isExpression(node)) { + wrappedNode = t.expressionStatement(node) + } else { + wrappedNode = t.functionDeclaration(t.identifier('__temp__'), [node as any], t.blockStatement([])) + } + const fileAst = t.file(t.program([wrappedNode])) + const replacements: Array<{ start: number; end: number; text: string }> = [] + const seenRanges = new Set() + + const pushReplacement = (start: number, end: number, text = '') => { + if (start >= end) return + const relativeStart = start - baseStart + const relativeEnd = end - baseStart + if (relativeStart < 0 || relativeEnd > raw.length) return + const key = `${relativeStart}:${relativeEnd}:${text}` + if (seenRanges.has(key)) return + seenRanges.add(key) + replacements.push({ start: relativeStart, end: relativeEnd, text }) + } + + traverse(fileAst as any, { + TSTypeAnnotation(path: any) { + pushReplacement(path.node.start, path.node.end) + }, + TSTypeParameterInstantiation(path: any) { + pushReplacement(path.node.start, path.node.end) + }, + TSTypeParameterDeclaration(path: any) { + pushReplacement(path.node.start, path.node.end) + }, + TSAsExpression(path: any) { + pushReplacement(path.node.expression.end, path.node.end) + }, + TSTypeAssertion(path: any) { + pushReplacement(path.node.start, path.node.expression.start) + }, + TSNonNullExpression(path: any) { + pushReplacement(path.node.expression.end, path.node.end) + }, + TSInstantiationExpression(path: any) { + pushReplacement(path.node.expression.end, path.node.end) + }, + Identifier(path: any) { + if (!path.node.optional) return + const typeAnnotationStart = path.node.typeAnnotation?.start + const optionalStart = path.node.start + String(path.node.name || '').length + if (typeof typeAnnotationStart === 'number') { + pushReplacement(optionalStart, typeAnnotationStart) + } else { + pushReplacement(optionalStart, optionalStart + 1) + } + }, + CallExpression(path: any) { + const typeParameters = path.node.typeParameters || path.node.typeArguments + if (typeParameters) pushReplacement(typeParameters.start, typeParameters.end) + }, + NewExpression(path: any) { + const typeParameters = path.node.typeParameters || path.node.typeArguments + if (typeParameters) pushReplacement(typeParameters.start, typeParameters.end) + } + }) + + return applyReplacements(raw, replacements) +} + +function getSlotParamNames(params: any[] = []) { + const firstParam = params[0] + if (t.isObjectPattern(firstParam)) { + return firstParam.properties + .map((item: any) => { + if (t.isObjectProperty(item) && t.isIdentifier(item.value)) return item.value.name + if (t.isRestElement(item) && t.isIdentifier(item.argument)) return item.argument.name + return null + }) + .filter(Boolean) + } + + if (t.isIdentifier(firstParam)) { + return [firstParam.name] + } + + return [] +} + +function getReturnedJSXNode(node: any) { + if (t.isArrowFunctionExpression(node)) { + if (t.isBlockStatement(node.body)) { + const returnStatement = node.body.body.find((item: any) => t.isReturnStatement(item) && item.argument) + if (t.isReturnStatement(returnStatement) && returnStatement.argument) { + return unwrapExpression(returnStatement.argument) + } + return null + } + + return unwrapExpression(node.body) + } + + if (t.isFunctionExpression(node)) { + const returnStatement = node.body.body.find((item: any) => t.isReturnStatement(item) && item.argument) + if (t.isReturnStatement(returnStatement) && returnStatement.argument) { + return unwrapExpression(returnStatement.argument) + } + return null + } + + return null +} + +function isJSXSlotFunction(node: any) { + if (!t.isArrowFunctionExpression(node) && !t.isFunctionExpression(node)) return false + const returnedNode = getReturnedJSXNode(node) + return t.isJSXElement(returnedNode) || t.isJSXFragment(returnedNode) +} + +function getEventHandlerExpression(node: any, source: string) { + const firstParam = + node?.params?.[0] && t.isIdentifier(node.params[0]) + ? node.params[0].name + : node?.params?.[0] && t.isRestElement(node.params[0]) && t.isIdentifier(node.params[0].argument) + ? node.params[0].argument.name + : null + + const resolveBodyExpression = (target: any) => { + const unwrapped = unwrapExpression(target) + if (t.isCallExpression(unwrapped)) return unwrapped + if (t.isBlockStatement(unwrapped)) { + const expressionStatement = unwrapped.body.find((item: any) => t.isExpressionStatement(item)) + if ( + t.isExpressionStatement(expressionStatement) && + t.isCallExpression(unwrapExpression(expressionStatement.expression)) + ) { + return unwrapExpression(expressionStatement.expression) + } + const returnStatement = unwrapped.body.find((item: any) => t.isReturnStatement(item) && item.argument) + if ( + t.isReturnStatement(returnStatement) && + returnStatement.argument && + t.isCallExpression(unwrapExpression(returnStatement.argument)) + ) { + return unwrapExpression(returnStatement.argument) + } + } + return null + } + + const callExpression = resolveBodyExpression(node.body) + if (!callExpression) return null + + const value = getSource(callExpression.callee, source) + const params = callExpression.arguments + .filter((arg: any, index: number) => { + if (index !== 0 || !firstParam) return true + return !(t.isIdentifier(arg) && arg.name === firstParam) + }) + .map((arg: any) => getSource(arg, source)) + .filter(Boolean) + + if (!value) return null + + return { + type: 'JSExpression', + value, + ...(params.length ? { params } : {}) + } +} + +function getModelUpdateTarget(node: any, source: string) { + const firstParam = + node?.params?.[0] && t.isIdentifier(node.params[0]) + ? node.params[0].name + : node?.params?.[0] && t.isRestElement(node.params[0]) && t.isIdentifier(node.params[0].argument) + ? node.params[0].argument.name + : null + + if (!firstParam) return null + + const resolveAssignment = (target: any): any => { + const unwrapped = unwrapExpression(target) + if (t.isAssignmentExpression(unwrapped)) return unwrapped + if (t.isBlockStatement(unwrapped)) { + const expressionStatement = unwrapped.body.find((item: any) => t.isExpressionStatement(item)) + if ( + t.isExpressionStatement(expressionStatement) && + t.isAssignmentExpression(unwrapExpression(expressionStatement.expression)) + ) { + return unwrapExpression(expressionStatement.expression) + } + } + return null + } + + const assignmentExpression = resolveAssignment(node.body) + if (!assignmentExpression) return null + if (!t.isIdentifier(assignmentExpression.right) || assignmentExpression.right.name !== firstParam) return null + + return getSource(assignmentExpression.left, source) +} + +function parseJSXExpressionValue(node: any, source: string) { + const target = unwrapExpression(node) + if (t.isStringLiteral(target)) return target.value + if (t.isNumericLiteral(target)) return target.value + if (t.isBooleanLiteral(target)) return target.value + if (t.isNullLiteral(target)) return null + + return { + type: 'JSExpression', + value: getSource(target, source) + } +} + +function parseJSXAttributes(attributes: any[], source: string) { + const props: Record = {} + const pendingModelTargets: Record = {} + + attributes.forEach((attr: any) => { + if (!t.isJSXAttribute(attr)) return + let attrName = getJSXAttributeName(attr.name) + if (!attrName) return + if (attrName === 'class') attrName = 'className' + + if (attr.value === null) { + props[attrName] = true + return + } + + if (t.isStringLiteral(attr.value)) { + props[attrName] = attr.value.value + return + } + + if (!t.isJSXExpressionContainer(attr.value) || t.isJSXEmptyExpression(attr.value.expression)) { + props[attrName] = '' + return + } + + const expression = unwrapExpression(attr.value.expression) + + if ((attrName === 'onUpdate:modelValue' || attrName === 'onUpdate') && t.isArrowFunctionExpression(expression)) { + const modelTarget = getModelUpdateTarget(expression, source) + if (modelTarget) { + pendingModelTargets.modelValue = modelTarget + return + } + } + + if (attrName.startsWith('on') && (t.isArrowFunctionExpression(expression) || t.isFunctionExpression(expression))) { + const handler = getEventHandlerExpression(expression, source) + if (handler) { + props[attrName] = handler + return + } + + props[attrName] = { + type: 'JSFunction', + value: sanitizeCodeFromNode(expression, source) + } + return + } + + props[attrName] = parseJSXExpressionValue(expression, source) + }) + + if (pendingModelTargets.modelValue && props.modelValue?.type === 'JSExpression') { + props.modelValue = { + ...props.modelValue, + model: true + } + } + + return props +} + +const jsxSchemaParser = { + parseChild(node: any, source: string): any { + if (t.isJSXText(node)) { + const text = node.value.replace(/\s+/g, ' ').trim() + return text || null + } + + if (t.isJSXExpressionContainer(node)) { + if (t.isJSXEmptyExpression(node.expression)) return null + const expression = unwrapExpression(node.expression) + if (t.isJSXElement(expression) || t.isJSXFragment(expression)) { + return this.parseReturn(expression, source) + } + return { + type: 'JSExpression', + value: getSource(expression, source) + } + } + + if (t.isJSXElement(node) || t.isJSXFragment(node)) { + return this.parseReturn(node, source) + } + + return null + }, + + normalizeChildren(children: any[], source: string) { + const normalizedChildren = children + .flatMap((child: any) => { + const parsed = this.parseChild(child, source) + if (Array.isArray(parsed)) return parsed + return parsed === null || parsed === undefined ? [] : [parsed] + }) + .filter((item) => item !== null && item !== undefined && item !== '') + + if (!normalizedChildren.length) return [] + if (normalizedChildren.length === 1) { + const [firstChild] = normalizedChildren + if (typeof firstChild === 'string' || firstChild?.type === 'JSExpression') { + return firstChild + } + } + + return normalizedChildren + }, + + parseElement(node: any, source: string): any { + const schema: any = { + componentName: getJSXTagName(node.openingElement.name), + props: parseJSXAttributes(node.openingElement.attributes || [], source) + } + + const normalizedChildren = this.normalizeChildren(node.children || [], source) + if (Array.isArray(normalizedChildren)) { + if (normalizedChildren.length) schema.children = normalizedChildren + } else if (normalizedChildren !== undefined) { + schema.children = normalizedChildren + } + + return schema + }, + + parseReturn(node: any, source: string): any[] { + const target = unwrapExpression(node) + if (t.isJSXFragment(target)) { + const normalizedChildren = this.normalizeChildren(target.children || [], source) + if (Array.isArray(normalizedChildren)) { + return normalizedChildren.flatMap((item: any) => (Array.isArray(item) ? item : [item])) + } + return normalizedChildren === null || normalizedChildren === undefined ? [] : [normalizedChildren] + } + if (t.isJSXElement(target)) { + return [this.parseElement(target, source)] + } + return [] + } +} + +function parseJSXReturnToSchema(node: any, source: string): any[] { + return jsxSchemaParser.parseReturn(node, source) +} + +function getReturnedRenderNode(node: any) { + if (t.isArrowFunctionExpression(node)) { + if (t.isBlockStatement(node.body)) { + const returnStatement = node.body.body.find((item: any) => t.isReturnStatement(item) && item.argument) + if (t.isReturnStatement(returnStatement) && returnStatement.argument) { + return unwrapExpression(returnStatement.argument) + } + return null + } + + return unwrapExpression(node.body) + } + + if (t.isFunctionExpression(node)) { + const returnStatement = node.body.body.find((item: any) => t.isReturnStatement(item) && item.argument) + if (t.isReturnStatement(returnStatement) && returnStatement.argument) { + return unwrapExpression(returnStatement.argument) + } + return null + } + + return null +} + +function isHCallExpression(node: any) { + const target = unwrapExpression(node) + return t.isCallExpression(target) && t.isIdentifier(target.callee) && target.callee.name === 'h' +} + +function parseHExpressionValue(node: any, source: string): any { + const target = unwrapExpression(node) + if (t.isStringLiteral(target)) return target.value + if (t.isNumericLiteral(target)) return target.value + if (t.isBooleanLiteral(target)) return target.value + if (t.isNullLiteral(target)) return null + + return { + type: 'JSExpression', + value: getSource(target, source) + } +} + +function parseHPropsObject(node: any, source: string) { + if (!t.isObjectExpression(node)) return {} + + const props: Record = {} + const pendingModelTargets: Record = {} + + node.properties.forEach((property: any) => { + if (!t.isObjectProperty(property)) return + const keyName = getObjectKeyName(property.key) + if (!keyName) return + const attrName = keyName === 'class' ? 'className' : keyName + const valueNode = unwrapExpression(property.value) + + if ((attrName === 'onUpdate:modelValue' || attrName === 'onUpdate') && t.isArrowFunctionExpression(valueNode)) { + const modelTarget = getModelUpdateTarget(valueNode, source) + if (modelTarget) { + pendingModelTargets.modelValue = modelTarget + return + } + } + + if (attrName.startsWith('on') && (t.isArrowFunctionExpression(valueNode) || t.isFunctionExpression(valueNode))) { + const handler = getEventHandlerExpression(valueNode, source) + if (handler) { + props[attrName] = handler + return + } + + props[attrName] = { + type: 'JSFunction', + value: sanitizeCodeFromNode(valueNode, source) + } + return + } + + props[attrName] = parseHExpressionValue(valueNode, source) + }) + + if (pendingModelTargets.modelValue && props.modelValue?.type === 'JSExpression') { + props.modelValue = { + ...props.modelValue, + model: true + } + } + + return props +} + +const hSchemaParser = { + parseChildren(node: any, source: string): any { + const target = unwrapExpression(node) + + if (t.isStringLiteral(target)) return target.value + if (t.isTemplateLiteral(target) && target.expressions.length === 0) { + return target.quasis.map((item: any) => item.value.cooked).join('') + } + if (t.isNumericLiteral(target) || t.isBooleanLiteral(target) || t.isNullLiteral(target)) { + return parseHExpressionValue(target, source) + } + if (isHCallExpression(target)) { + const parsed = this.parseCall(target, source) + return parsed ? [parsed] : [] + } + if (t.isArrayExpression(target)) { + return target.elements + .flatMap((item: any) => { + if (!item) return [] + const parsed = this.parseChildren(item, source) + return Array.isArray(parsed) ? parsed : parsed === null || parsed === undefined ? [] : [parsed] + }) + .filter(Boolean) + } + if (t.isArrowFunctionExpression(target) || t.isFunctionExpression(target)) { + const returnedNode = getReturnedRenderNode(target) + if (!returnedNode) return null + return this.parseChildren(returnedNode, source) + } + + return { + type: 'JSExpression', + value: getSource(target, source) + } + }, + + parseCall(node: any, source: string): any { + const target = unwrapExpression(node) + if (!t.isCallExpression(target) || !t.isIdentifier(target.callee) || target.callee.name !== 'h') return null + + const [componentArg, secondArg, thirdArg] = target.arguments + if (!componentArg) return null + + let propsArg: any = null + let childrenArg: any = null + + if ( + secondArg && + (t.isObjectExpression(unwrapExpression(secondArg)) || + t.isNullLiteral(unwrapExpression(secondArg)) || + t.isIdentifier(unwrapExpression(secondArg))) + ) { + propsArg = secondArg + childrenArg = thirdArg + } else { + childrenArg = secondArg + } + + const componentNode = unwrapExpression(componentArg) + let componentName = 'div' + if (t.isStringLiteral(componentNode)) { + componentName = componentNode.value + } else if (t.isIdentifier(componentNode)) { + componentName = componentNode.name + } else if (t.isMemberExpression(componentNode)) { + componentName = getSource(componentNode, source) + } + + const schema: any = { + componentName, + props: + propsArg && !t.isNullLiteral(unwrapExpression(propsArg)) + ? parseHPropsObject(unwrapExpression(propsArg), source) + : {} + } + + if (childrenArg) { + const parsedChildren = this.parseChildren(childrenArg, source) + if (Array.isArray(parsedChildren)) { + if (parsedChildren.length) schema.children = parsedChildren + } else if (parsedChildren !== null && parsedChildren !== undefined && parsedChildren !== '') { + schema.children = parsedChildren + } + } + + return schema + } +} + +function parseHCallToSchema(node: any, source: string): any { + return hSchemaParser.parseCall(node, source) +} + +function parseHSlotValue(node: any, source: string) { + if (!t.isArrowFunctionExpression(node) && !t.isFunctionExpression(node)) return null + + const returnedNode = getReturnedRenderNode(node) + if (!returnedNode) return null + + const parsedNode = parseHCallToSchema(returnedNode, source) + if (!parsedNode) return null + + return { + type: 'JSSlot', + params: getSlotParamNames(node.params || []), + value: [parsedNode] + } +} + +function parseJSSlotValue(node: any, result: any, source: string) { + if (isJSXSlotFunction(node)) { + const returnedNode = getReturnedJSXNode(node) + const slotValue = parseJSXReturnToSchema(returnedNode, source) + if (!slotValue.length) return null + + return { + type: 'JSSlot', + params: getSlotParamNames(node.params || []), + value: slotValue + } + } + + return parseHSlotValue(node, source) +} + +function createScriptRewriteContext(result: any, localNames: string[] = []) { + const stateEntries = result?.state || {} + const propNames = new Set((result?.props || []).map((prop: any) => prop?.name).filter(Boolean)) + const stateNames = new Set(Object.keys(stateEntries)) + const refStateNames = new Set( + Object.entries(stateEntries) + .filter(([, value]: [string, any]) => value?.type === 'ref') + .map(([name]) => name) + ) + const methodNames = new Set(Object.keys(result?.methods || {})) + const computedNames = new Set(Object.keys(result?.computed || {})) + + return { + propNames, + stateNames, + refStateNames, + methodNames, + computedNames, + localNames: new Set(localNames.filter(Boolean)) + } +} + +function resolveScriptIdentifierReplacement(name: string, context: any) { + if (!name || name === 'this' || JS_GLOBALS.has(name)) return null + if (context.localNames.has(name)) return null + + if (name === 'state') return 'this.state' + if (name === 'props') return 'this.props' + if (name === 'emit') return 'this.emit' + if (name === 'stores') return 'this.stores' + if (name === 'bridge') return 'this.bridge' + if (name === 'dataSourceMap') return 'this.dataSourceMap' + + if (context.propNames.has(name)) return `this.props.${name}` + if (context.stateNames.has(name)) return `this.state.${name}` + if (context.methodNames.has(name) || context.computedNames.has(name)) return `this.${name}` + + return null +} + +function rewriteScriptContextInCode(code: string, result: any, localNames: string[] = []) { + if (!code) return code + + const context = createScriptRewriteContext(result, localNames) + if ( + context.propNames.size === 0 && + context.stateNames.size === 0 && + context.methodNames.size === 0 && + context.computedNames.size === 0 + ) { + return code + } + + try { + const ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'] as any }) + const replacements: Array<{ start: number; end: number; text: string }> = [] + const seenRanges = new Set() + + const pushReplacement = (start: number, end: number, text: string) => { + if (typeof start !== 'number' || typeof end !== 'number' || start >= end) return + const key = `${start}:${end}:${text}` + if (seenRanges.has(key)) return + seenRanges.add(key) + replacements.push({ start, end, text }) + } + + traverse(ast as any, { + MemberExpression(path: any) { + if (path.node.computed) return + + const objectNode = path.node.object + const propertyNode = path.node.property + if (!t.isIdentifier(objectNode) || !t.isIdentifier(propertyNode)) return + + const refLikeName = objectNode.name + const isRefLikeState = context.refStateNames.has(refLikeName) + const isComputedRef = context.computedNames.has(refLikeName) + if (!isRefLikeState && !isComputedRef) return + if (propertyNode.name !== 'value') return + + const binding = path.scope.getBinding(refLikeName) + if (binding && binding.kind !== 'module') return + + const replacement = isComputedRef ? `this.${refLikeName}` : `this.state.${refLikeName}` + pushReplacement(path.node.start, path.node.end, replacement) + }, + Identifier(path: any) { + if (!path.isReferencedIdentifier()) return + + const { node, parent } = path + const name = node.name + const binding = path.scope.getBinding(name) + if (binding && binding.kind !== 'module') return + + if ( + path.parentPath.isMemberExpression({ property: node }) && + parent && + parent.property === node && + !parent.computed + ) { + return + } + + if (path.parentPath.isObjectProperty({ key: node }) && parent && parent.key === node && !parent.computed) { + return + } + + if ( + path.parentPath.isMemberExpression() && + path.parent.object === path.node && + !path.parent.computed && + t.isIdentifier(path.parent.property) && + path.parent.property.name === 'value' && + (context.refStateNames.has(name) || context.computedNames.has(name)) + ) { + return + } + + const replacement = resolveScriptIdentifierReplacement(name, context) + if (!replacement || replacement === name) return + + if (path.parentPath.isObjectProperty() && parent?.shorthand && parent.value === node) { + pushReplacement(path.parent.start, path.parent.end, `${name}: ${replacement}`) + return + } + + pushReplacement(path.node.start, path.node.end, replacement) + } + }) + + return applyReplacements(code, replacements) + } catch { + return code + } +} + +function rewriteScriptContextInEntries(entries: Record, result: any) { + Object.keys(entries || {}).forEach((key) => { + const entry = entries[key] + if (!entry) return + if (typeof entry === 'string') { + entries[key] = rewriteScriptContextInCode(entry, result) + return + } + if (typeof entry.value === 'string') { + entry.value = rewriteScriptContextInCode(entry.value, result) + } + }) + return entries +} + +function rewriteNestedStateValue(value: any, result: any, localNames: string[] = []): any { + if (Array.isArray(value)) { + return value.map((item) => rewriteNestedStateValue(item, result, localNames)) + } + + if (!value || typeof value !== 'object') { + return value + } + + if (value.type === 'JSExpression' && typeof value.value === 'string') { + return { + ...value, + value: rewriteScriptContextInCode(value.value, result, localNames) + } + } + + if (value.type === 'JSFunction' && typeof value.value === 'string') { + return { + ...value, + value: rewriteScriptContextInCode(value.value, result, localNames) + } + } + + if (value.type === 'JSSlot') { + const slotParams = Array.isArray(value.params) ? value.params : [] + return { + ...value, + value: rewriteNestedStateValue(value.value || [], result, [...localNames, ...slotParams]) + } + } + + const output: Record = Array.isArray(value) ? [] : {} + Object.keys(value).forEach((key) => { + output[key] = rewriteNestedStateValue(value[key], result, localNames) + }) + return output +} + +function rewriteScriptContextInResult(result: any) { + result.methods = rewriteScriptContextInEntries(result.methods || {}, result) + result.computed = rewriteScriptContextInEntries(result.computed || {}, result) + result.lifeCycles = rewriteScriptContextInEntries(result.lifeCycles || {}, result) + result.state = rewriteNestedStateValue(result.state || {}, result) +} + +function addUsedUtilImport(collector: any[], item: any) { + if (!Array.isArray(collector) || !item?.source || !item?.local) return + const exists = collector.some( + (entry) => entry?.source === item.source && entry?.imported === item.imported && entry?.local === item.local + ) + if (!exists) collector.push(item) +} + +function getImportedUtilsMap(imports: any[] = []) { + const importedUtils = new Map() + + imports.forEach((imp: any) => { + if (!imp?.source || isFrameworkImportSource(imp.source) || /\.vue$/i.test(imp.source)) return + ;(imp.specifiers || []).forEach((spec: any) => { + if (!spec?.local) return + importedUtils.set(spec.local, { ...spec, source: imp.source }) + }) + }) + + return importedUtils +} + +function rewriteImportedUtilsInCode(code: string, imports: any[] = [], usedImports: any[] = []) { + const importedUtils = getImportedUtilsMap(imports) + + if (!code || importedUtils.size === 0) return code + + try { + const ast = parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'] as any }) + const replacements: Array<{ start: number; end: number; text: string }> = [] + const seenRanges = new Set() + + const pushReplacement = (start: number, end: number, text: string) => { + if (typeof start !== 'number' || typeof end !== 'number' || start >= end) return + const key = `${start}:${end}:${text}` + if (seenRanges.has(key)) return + seenRanges.add(key) + replacements.push({ start, end, text }) + } + + traverse(ast as any, { + MemberExpression(path: any) { + if (path.node.computed) return + const objectNode = path.node.object + const propertyNode = path.node.property + if (!t.isIdentifier(objectNode) || !t.isIdentifier(propertyNode)) return + + const spec = importedUtils.get(objectNode.name) + if (!spec || spec.kind !== 'namespace') return + + const binding = path.scope.getBinding(objectNode.name) + if (binding && binding.kind !== 'module') return + + addUsedUtilImport(usedImports, { + source: spec.source, + imported: propertyNode.name, + local: propertyNode.name, + kind: 'named' + }) + pushReplacement(path.node.start, path.node.end, `this.utils.${propertyNode.name}`) + }, + Identifier(path: any) { + const name = path.node?.name + const spec = importedUtils.get(name) + if (!name || !spec || !path.isReferencedIdentifier()) return + + const binding = path.scope.getBinding(name) + if (binding && binding.kind !== 'module') return + + if ( + spec.kind === 'namespace' && + path.parentPath?.isMemberExpression() && + path.parent.object === path.node && + !path.parent.computed + ) { + return + } + + addUsedUtilImport(usedImports, { + source: spec.source, + imported: spec.imported || 'default', + local: spec.local, + kind: spec.kind || 'named' + }) + + if ( + path.parentPath?.isObjectProperty() && + path.parent.shorthand && + path.parent.value === path.node && + path.parent.key === path.node + ) { + pushReplacement(path.parent.start, path.parent.end, `${name}: this.utils.${name}`) + return + } + + pushReplacement(path.node.start, path.node.end, `this.utils.${name}`) + } + }) + + return applyReplacements(code, replacements) + } catch { + return code + } +} + +function rewriteImportedUtilsInEntries(entries: Record, imports: any[] = [], usedImports: any[] = []) { + Object.keys(entries || {}).forEach((key) => { + const entry = entries[key] + if (!entry) return + if (typeof entry === 'string') { + entries[key] = rewriteImportedUtilsInCode(entry, imports, usedImports) + return + } + if (typeof entry.value === 'string') { + entry.value = rewriteImportedUtilsInCode(entry.value, imports, usedImports) + } + }) + return entries +} + +function rewriteImportedUtilsInResult(result: any) { + const imports = result?.imports || [] + result.usedUtilsImports = [] + result.methods = rewriteImportedUtilsInEntries(result.methods || {}, imports, result.usedUtilsImports) + result.computed = rewriteImportedUtilsInEntries(result.computed || {}, imports, result.usedUtilsImports) + result.lifeCycles = rewriteImportedUtilsInEntries(result.lifeCycles || {}, imports, result.usedUtilsImports) +} + +function arrowToFunctionString(name: string, node: t.ArrowFunctionExpression, source: string) { + const asyncStr = node.async ? 'async ' : '' + const params = node.params.map((p) => sanitizeCodeFromNode(p, source)).join(', ') + if (t.isBlockStatement(node.body)) { + const body = sanitizeCodeFromNode(node.body, source) + return `${asyncStr}function ${name}(${params}) ${body}` + } + const expr = sanitizeCodeFromNode(node.body, source) + return `${asyncStr}function ${name}(${params}) { return ${expr}; }` +} + +function functionExpressionToNamedFunctionString( + name: string, + node: t.FunctionExpression | t.ObjectMethod, + source: string +) { + const asyncStr = (node as any).async ? 'async ' : '' + const params = (node as any).params.map((p: any) => sanitizeCodeFromNode(p, source)).join(', ') + const body = sanitizeCodeFromNode((node as any).body, source) + return `${asyncStr}function ${name}(${params}) ${body}` +} + +// ---- setup 专属逻辑的小分支封装(共享生命周期处理主干)---- +const isSetupName = (name: string) => name === 'setup' + +function setLifecycleEntry(result: any, name: string, code: string, opts: { noOverride?: boolean } = {}) { + if (opts.noOverride && result.lifeCycles[name]) return + result.lifeCycles[name] = { type: 'lifecycle', value: code || (name ? `function ${name}(){}` : 'function() {}') } +} + +function setMethodEntry(result: any, name: string, code: string) { + result.methods[name] = { type: 'function', value: code || `function ${name}(){}` } +} + +function routeFunctionLikeByName(result: any, name: string, code: string) { + if (isSetupName(name)) setLifecycleEntry(result, name, code) + else setMethodEntry(result, name, code) +} + +// Helpers to reduce duplication when handling variable declarators in +