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..9cf366994d --- /dev/null +++ b/packages/toolbars/upload/src/Main.vue @@ -0,0 +1,724 @@ + + + + + + + + + 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..453a10ad1c --- /dev/null +++ b/packages/toolbars/upload/src/http.ts @@ -0,0 +1,30 @@ +/** + * 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) 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..1bcd471c48 --- /dev/null +++ b/packages/vue-to-dsl/src/converter.ts @@ -0,0 +1,1214 @@ +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 fs from 'fs/promises' +import path from 'path' +import os from 'os' +import JSZip from 'jszip' + +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[] +} + +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 || [] + } 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 + } + } 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 + } + + // 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') + + // 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)) + } + } + + // Helper function to convert path to camelCase + const pathToCamelCase = (relativePath: string, baseName: string): string => { + const parts = relativePath.replace(/\.vue$/i, '').split(/[\\/]/) + if (parts.length === 1) { + return baseName + } + // Join directory parts with the filename in camelCase + const dirParts = parts.slice(0, -1) + const camelCaseDir = dirParts + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + return camelCaseDir + baseName.charAt(0).toUpperCase() + baseName.slice(1) + } + + // 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 = pathToCamelCase(relativePath, baseName) + } else { + fileName = baseName + } + + const result = await this.convertFromString(vueCode, fileName) + 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.js (very lightweight parser) + const utils: any[] = [] + try { + const utilsPath = path.join(srcDir, 'utils.js') + const code = await fs.readFile(utilsPath, 'utf-8') + const importRegex = /import\s+(?:{\s*([\w,\s]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + const imports: Array<{ local: string; source: string; destructuring: boolean }> = [] + let m: RegExpExecArray | null + while ((m = importRegex.exec(code))) { + const named = m[1] + const def = m[2] + const source = m[3] + if (named) { + named + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((n) => imports.push({ local: n, source, destructuring: true })) + } else if (def) { + imports.push({ local: def, source, destructuring: false }) + } + } + // exported names + const exportRegex = /export\s*{([^}]+)}/ + const expMatch = code.match(exportRegex) + const exported = expMatch + ? expMatch[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + // Build utils array for exported imports; fallback for exported local functions/vars + for (const name of exported) { + const found = imports.find((imp) => imp.local === name) + if (found) { + utils.push({ + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: name + } + }) + } else { + // treat as function utility placeholder + utils.push({ name, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + } catch { + // ignore + } + + // 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.schema) { + result.schema.componentName = 'Block' + blockSchemas.push(result.schema) + } + } 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) + + // 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 + } + + // 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)) + } + } + + // Helper function to convert path to camelCase + const pathToCamelCase = (relativePath: string, baseName: string): string => { + const parts = relativePath.replace(/\.vue$/i, '').split('/') + if (parts.length === 1) { + return baseName + } + // Join directory parts with the filename in camelCase + const dirParts = parts.slice(0, -1) + const camelCaseDir = dirParts + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + return camelCaseDir + baseName.charAt(0).toUpperCase() + baseName.slice(1) + } + + // Convert files with appropriate naming + 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 = pathToCamelCase(relativePath, baseName) + } else { + fileName = baseName + } + + const res = await this.convertFromString(code, fileName) + 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.js + const utils: any[] = [] + try { + const code = await readText(joinRoot('src/utils.js')) + if (code) { + const importRegex = /import\s+(?:{\s*([\w,\s]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + const imports: Array<{ local: string; source: string; destructuring: boolean }> = [] + let m: RegExpExecArray | null + while ((m = importRegex.exec(code))) { + const named = m[1] + const def = m[2] + const source = m[3] + if (named) { + named + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((n) => imports.push({ local: n, source, destructuring: true })) + } else if (def) { + imports.push({ local: def, source, destructuring: false }) + } + } + const exportRegex = /export\s*{([^}]+)}/ + const expMatch = code.match(exportRegex) + const exported = expMatch + ? expMatch[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + for (const name of exported) { + const found = imports.find((imp) => imp.local === name) + if (found) { + utils.push({ + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: name + } + }) + } else { + utils.push({ name, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + } + } catch { + // ignore + } + + // 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.schema) { + res.schema.componentName = 'Block' + blockSchemas.push(res.schema) + } + } catch { + // skip individual component conversion errors + } + } + + // Also scan page schemas for componentType=Block nodes + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + + // 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')) + } + + // 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)) + } + } + + // Helper function to convert path to camelCase + const pathToCamelCase = (relativePath: string, baseName: string): string => { + const parts = relativePath.replace(/\.vue$/i, '').split('/') + if (parts.length === 1) { + return baseName + } + // Join directory parts with the filename in camelCase + const dirParts = parts.slice(0, -1) + const camelCaseDir = dirParts + .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + return camelCaseDir + baseName.charAt(0).toUpperCase() + baseName.slice(1) + } + + 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 = pathToCamelCase(relativePath, baseName) + } else { + fileName = baseName + } + + const res = await this.convertFromString(code, fileName) + 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.js + const utils: any[] = [] + try { + const utilsFile = relevantFiles.find((f) => f.webkitRelativePath.endsWith('src/utils.js')) + if (utilsFile) { + const code = await readText(utilsFile) + const importRegex = /import\s+(?:{\s*([\w,\s]+)\s*}|([\w$]+))\s+from\s+['"]([^'"]+)['"]/g + const imports: Array<{ local: string; source: string; destructuring: boolean }> = [] + let m: RegExpExecArray | null + while ((m = importRegex.exec(code))) { + const named = m[1] + const def = m[2] + const source = m[3] + if (named) { + named + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .forEach((n) => imports.push({ local: n, source, destructuring: true })) + } else if (def) { + imports.push({ local: def, source, destructuring: false }) + } + } + const exportRegex = /export\s*{([^}]+)}/ + const expMatch = code.match(exportRegex) + const exported = expMatch + ? expMatch[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [] + for (const name of exported) { + const found = imports.find((imp) => imp.local === name) + if (found) { + utils.push({ + name, + type: 'npm', + content: { + type: 'JSFunction', + value: '', + package: found.source, + destructuring: found.destructuring, + exportName: name + } + }) + } else { + utils.push({ name, type: 'function', content: { type: 'JSFunction', value: '' } }) + } + } + } + } catch { + // ignore + } + + // 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.schema) { + res.schema.componentName = 'Block' + blockSchemas.push(res.schema) + } + } catch { + // skip individual component conversion errors + } + } + + // Also scan page schemas for componentType=Block nodes + this.collectBlockRefsFromSchemas(pageSchemas, blockSchemas) + + // 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..69d9933740 --- /dev/null +++ b/packages/vue-to-dsl/src/parsers/scriptParser.ts @@ -0,0 +1,614 @@ +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 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 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 arrowToFunctionString(name: string, node: t.ArrowFunctionExpression, source: string) { + const asyncStr = node.async ? 'async ' : '' + const params = node.params.map((p) => getSource(p, source)).join(', ') + if (t.isBlockStatement(node.body)) { + const body = getSource(node.body, source) + return `${asyncStr}function ${name}(${params}) ${body}` + } + const expr = getSource(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) => getSource(p, source)).join(', ') + const body = getSource((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 +