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..baa588ce12 --- /dev/null +++ b/packages/toolbars/upload/package.json @@ -0,0 +1,45 @@ +{ + "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": { + "@babel/parser": "^7.23.0", + "@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/ImportNoticeDialog.vue b/packages/toolbars/upload/src/ImportNoticeDialog.vue new file mode 100644 index 0000000000..d60ed02add --- /dev/null +++ b/packages/toolbars/upload/src/ImportNoticeDialog.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/packages/toolbars/upload/src/Main.vue b/packages/toolbars/upload/src/Main.vue new file mode 100644 index 0000000000..48150586c0 --- /dev/null +++ b/packages/toolbars/upload/src/Main.vue @@ -0,0 +1,1255 @@ + + + + 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 @@ + + + + + diff --git a/packages/toolbars/upload/src/assetImport.ts b/packages/toolbars/upload/src/assetImport.ts new file mode 100644 index 0000000000..6ec60d674e --- /dev/null +++ b/packages/toolbars/upload/src/assetImport.ts @@ -0,0 +1,172 @@ +const replaceStringByMap = (value: string, replacements: Map) => { + let nextValue = String(value ?? '') + + replacements.forEach((targetValue, sourceValue) => { + if (!sourceValue) return + nextValue = nextValue.split(sourceValue).join(targetValue) + }) + + return nextValue +} + +const hashString = (value: string) => { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0 + } + + return hash.toString(36) +} + +const getAssetExtension = (fileName = '') => { + const match = String(fileName).match(/(\.[^.]+)$/) + return match ? match[1].toLowerCase() : '' +} + +const normalizeResourceAssetExtension = (extension = '') => { + const normalized = String(extension || '').toLowerCase() + if (['.png', '.jpg', '.jpeg', '.svg'].includes(normalized)) { + return normalized + } + + return '.png' +} + +const getAssetBaseName = (filePath = '') => { + const normalized = String(filePath || '').replace(/\\/g, '/') + const fileName = normalized.split('/').pop() || normalized + return fileName.replace(/\.[^.]+$/, '') +} + +const sanitizeAssetSegment = (value = '') => + String(value || '') + .replace(/[^a-zA-Z0-9_=+(){}[\]\u4e00-\u9fa5-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + +export const getImportedAssets = (appSchema: any) => + Array.isArray(appSchema?.assets) + ? appSchema.assets.filter((item: any) => item?.placeholder && item?.resourceData) + : [] + +export const buildImportedAssetResourceName = (asset: any) => { + const extension = normalizeResourceAssetExtension(getAssetExtension(asset?.name || asset?.filePath || '') || '.png') + const baseName = + sanitizeAssetSegment(getAssetBaseName(asset?.filePath || asset?.name || 'imported_asset')) || 'imported_asset' + const fingerprint = hashString(`${asset?.filePath || ''}:${asset?.resourceData || ''}`).slice(0, 8) + + return `${baseName}_${fingerprint}${extension}` +} + +export const buildImportedAssetCreatePayload = ( + asset: any, + resourceGroupId: number | string, + requestMeta: { appId?: number | string; platformId?: number | string } = {} +) => ({ + name: buildImportedAssetResourceName(asset), + description: asset?.filePath || '', + resourceGroupId, + resourceData: asset?.resourceData || '', + resourceUrl: '', + category: 'image', + ...(requestMeta?.appId ? { appId: requestMeta.appId } : {}), + ...(requestMeta?.platformId ? { platformId: requestMeta.platformId } : {}) +}) + +const estimateImportedAssetPayloadSize = (payload: any) => JSON.stringify(payload || {}).length + +export const splitImportedAssetCreatePayloads = ( + payloads: any[] = [], + options: { maxBatchSize?: number; maxPayloadSize?: number } = {} +) => { + const maxBatchSize = Math.max(1, Number(options.maxBatchSize || 5)) + const maxPayloadSize = Math.max(1, Number(options.maxPayloadSize || 1_500_000)) + const batches: any[][] = [] + let currentBatch: any[] = [] + let currentPayloadSize = 2 + + payloads.forEach((payload) => { + const payloadSize = estimateImportedAssetPayloadSize(payload) + const nextBatchSize = currentBatch.length + 1 + const nextPayloadSize = currentPayloadSize + payloadSize + (currentBatch.length ? 1 : 0) + const shouldStartNewBatch = + currentBatch.length > 0 && (nextBatchSize > maxBatchSize || nextPayloadSize > maxPayloadSize) + + if (shouldStartNewBatch) { + batches.push(currentBatch) + currentBatch = [] + currentPayloadSize = 2 + } + + currentBatch.push(payload) + currentPayloadSize += payloadSize + (currentBatch.length > 1 ? 1 : 0) + }) + + if (currentBatch.length) { + batches.push(currentBatch) + } + + return batches +} + +export const replaceImportedAssetPlaceholders = (target: any, replacements: Map) => { + if (!target || replacements.size === 0) return target + + const walk = (value: any): any => { + if (Array.isArray(value)) { + return value.map((item) => walk(item)) + } + + if (typeof value === 'string') { + return replaceStringByMap(value, replacements) + } + + if (!value || typeof value !== 'object') { + return value + } + + Object.keys(value).forEach((key) => { + value[key] = walk(value[key]) + }) + + return value + } + + return walk(target) +} + +export const getImportedAssetResourceUrl = (resource: any) => + resource?.resourceUrl || + resource?.thumbnailUrl || + resource?.thumbnail_url || + resource?.url || + resource?.downloadUrl || + resource?.download_url || + resource?.resource_url || + '' + +export const findImportedAssetResource = (resources: any[] = [], target: any) => { + const normalizedName = String(target?.name || '') + const normalizedDescription = String(target?.description || '') + + return ( + resources.find((item: any) => String(item?.name || '') === normalizedName) || + resources.find((item: any) => normalizedDescription && String(item?.description || '') === normalizedDescription) || + null + ) +} + +export const normalizeImportedAssetResourceList = (response: any) => { + if (Array.isArray(response)) return response + + const candidates = [response?.data, response?.data?.list, response?.data?.records, response?.list, response?.records] + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate + } + } + + return [] +} diff --git a/packages/toolbars/upload/src/blockImport.ts b/packages/toolbars/upload/src/blockImport.ts new file mode 100644 index 0000000000..90d37bdcd0 --- /dev/null +++ b/packages/toolbars/upload/src/blockImport.ts @@ -0,0 +1,218 @@ +const BLOCK_EVENT_KEY_PREFIX = 'on' +const BLOCK_EVENT_KEY_PATTERN = /^on(?:[A-Z]|Update:)/ + +function toPascalCase(input = '') { + return String(input) + .split(/[-_\s]+/) + .filter(Boolean) + .map((item) => item.charAt(0).toUpperCase() + item.slice(1)) + .join('') +} + +function isImportedBlockEventBindingKey(key = '') { + return BLOCK_EVENT_KEY_PATTERN.test(String(key)) +} + +function toCamelCase(input = '') { + return String(input).replace(/-([a-zA-Z0-9])/g, (_match, segment) => String(segment).toUpperCase()) +} + +export function normalizeImportedBlockPropName(propName = '') { + const raw = String(propName || '').trim() + if (!raw) return '' + return toCamelCase(raw) +} + +export function toImportedBlockEventKey(eventName = '') { + const raw = String(eventName || '').trim() + if (!raw) return '' + if (isImportedBlockEventBindingKey(raw)) return raw + + if (raw.startsWith('update:')) { + const [head, ...rest] = raw.split(':') + return `${BLOCK_EVENT_KEY_PREFIX}${toPascalCase(head)}:${rest.join(':')}` + } + + return `${BLOCK_EVENT_KEY_PREFIX}${toPascalCase(raw)}` +} + +export function splitImportedBlockBindings(bindings: Record = {}) { + const props: Record = {} + const events: Record = {} + + Object.entries(bindings || {}).forEach(([key, value]) => { + if (!key || key === 'key' || key === 'ref') return + if (isImportedBlockEventBindingKey(key)) { + events[key] = value + return + } + + const normalizedKey = normalizeImportedBlockPropName(key) + if (!normalizedKey) return + props[normalizedKey] = value + }) + + return { props, events } +} + +export function buildImportedBlockEvents(emits: string[] = [], boundEvents: Record = {}) { + const events: Record = {} + const eventKeys = new Set() + + ;(Array.isArray(emits) ? emits : []).forEach((eventName) => { + const normalizedKey = toImportedBlockEventKey(eventName) + if (normalizedKey) eventKeys.add(normalizedKey) + }) + + Object.keys(boundEvents || {}).forEach((key) => { + if (isImportedBlockEventBindingKey(key)) eventKeys.add(key) + }) + + eventKeys.forEach((key) => { + events[key] = { + name: key, + label: { + zh_CN: key + }, + description: { + zh_CN: key + } + } + }) + + return events +} + +export function normalizeImportedBlockDefaultValue(value: any) { + if (value === null || value === undefined) return '' + if (typeof value === 'object' && value.type === 'JSExpression') { + return { ...value } + } + return value +} + +function isImportedBlockLiteralExpression(value = '') { + const raw = String(value || '').trim() + if (!raw) return false + + if (/^['"`].*['"`]$/.test(raw)) return true + if (/^-?\d+(\.\d+)?$/.test(raw)) return true + if (raw === 'true' || raw === 'false' || raw === 'null') return true + if ((raw.startsWith('[') && raw.endsWith(']')) || (raw.startsWith('{') && raw.endsWith('}'))) return true + + return false +} + +function isImportedBlockDynamicBindingValue(value: any) { + return ( + value?.type === 'JSExpression' && typeof value?.value === 'string' && !isImportedBlockLiteralExpression(value.value) + ) +} + +function isStringLiteralType(raw = '') { + return /^['"`].*['"`]$/.test(String(raw).trim()) +} + +function isNumericLiteralType(raw = '') { + return /^-?\d+(\.\d+)?$/.test(String(raw).trim()) +} + +function normalizeImportedSingleBlockPropType(raw = '') { + if (!raw) return '' + + const lowerType = raw.toLowerCase() + + if (['string', 'number', 'boolean', 'array', 'object', 'function'].includes(lowerType)) { + return lowerType + } + + if (lowerType === 'any' || lowerType === 'unknown' || lowerType === 'void' || lowerType === 'never') { + return 'string' + } + + if ( + lowerType.startsWith('array<') || + lowerType.startsWith('readonlyarray<') || + lowerType.endsWith('[]') || + lowerType.startsWith('tuple') + ) { + return 'array' + } + + if ( + lowerType === 'record' || + lowerType.startsWith('record<') || + lowerType.startsWith('map<') || + lowerType.startsWith('{') || + lowerType === 'date' + ) { + return 'object' + } + + if (lowerType.includes('=>') || lowerType.startsWith('(') || lowerType === 'fn') { + return 'function' + } + + if (isStringLiteralType(raw)) return 'string' + if (isNumericLiteralType(raw)) return 'number' + if (raw === 'true' || raw === 'false') return 'boolean' + + if (/^[A-Z]/.test(raw)) return 'object' + + return 'string' +} + +function normalizeImportedUnionType(type = '') { + const segments = String(type) + .split('|') + .map((item) => item.trim()) + .filter((item) => item && item !== 'undefined' && item !== 'null') + + if (!segments.length) return '' + if (segments.every((item) => item === 'string' || isStringLiteralType(item))) return 'string' + if (segments.every((item) => item === 'number' || isNumericLiteralType(item))) return 'number' + if (segments.every((item) => item === 'boolean' || item === 'true' || item === 'false')) return 'boolean' + + const normalizedSegments = segments.map((item) => normalizeImportedSingleBlockPropType(item)).filter(Boolean) + return normalizedSegments.length === 1 || new Set(normalizedSegments).size === 1 ? normalizedSegments[0] : 'string' +} + +export function normalizeImportedBlockPropType(type: any) { + const raw = String(type || '').trim() + if (!raw) return '' + + if (raw.toLowerCase().includes('|')) { + return normalizeImportedUnionType(raw) + } + + return normalizeImportedSingleBlockPropType(raw) +} + +export function inferImportedBlockPropTypeFromValue(value: any) { + if (value === null || value === undefined) return 'string' + if (Array.isArray(value)) return 'array' + if (typeof value === 'object') { + if (value.type === 'JSExpression') return 'string' + if (value.type === 'JSFunction') return 'function' + return 'object' + } + + if (['string', 'number', 'boolean', 'function'].includes(typeof value)) { + return typeof value + } + + return 'string' +} + +export function resolveImportedBlockPropType(declaredType: any, value: any) { + const normalizedDeclaredType = normalizeImportedBlockPropType(declaredType) + if (normalizedDeclaredType) return normalizedDeclaredType + return inferImportedBlockPropTypeFromValue(value) +} + +export function resolveImportedBlockDefaultValue(declaredDefault: any, value: any) { + if (isImportedBlockDynamicBindingValue(value)) return '' + if (value !== undefined) return normalizeImportedBlockDefaultValue(value) + if (declaredDefault !== undefined) return normalizeImportedBlockDefaultValue(declaredDefault) + return '' +} diff --git a/packages/toolbars/upload/src/http.ts b/packages/toolbars/upload/src/http.ts new file mode 100644 index 0000000000..3b079c60ed --- /dev/null +++ b/packages/toolbars/upload/src/http.ts @@ -0,0 +1,88 @@ +/** + * 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, getMergeMeta, META_SERVICE } from '@opentiny/tiny-engine-meta-register' + +const getResourceRequestMeta = () => ({ + appId: getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id, + platformId: getMergeMeta('engine.config')?.platformId +}) + +// 获取页面列表 +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) + +// 资源管理 - 获取资源分组列表 +export const fetchResourceGroups = () => + getMetaApi(META_SERVICE.Http).get( + `/material-center/api/resource-group/${getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id}` + ) + +// 资源管理 - 创建资源分组 +export const createResourceGroup = (params: any) => + getMetaApi(META_SERVICE.Http).post('/material-center/api/resource-group/create', { + ...params, + ...getResourceRequestMeta() + }) + +// 资源管理 - 获取分组下资源列表 +export const fetchResourceListByGroupId = (resourceGroupId: number | string) => + getMetaApi(META_SERVICE.Http).get(`/material-center/api/resource/find/${resourceGroupId}`) + +// 资源管理 - 批量创建资源 +export const batchCreateResource = (params: any[]) => + getMetaApi(META_SERVICE.Http).post( + '/material-center/api/resource/create/batch', + params.map((item: any) => ({ + ...item, + ...getResourceRequestMeta() + })) + ) diff --git a/packages/toolbars/upload/src/schemaImport.ts b/packages/toolbars/upload/src/schemaImport.ts new file mode 100644 index 0000000000..a4d5d3ae63 --- /dev/null +++ b/packages/toolbars/upload/src/schemaImport.ts @@ -0,0 +1,955 @@ +function isEscaped(code: string, index: number) { + let slashCount = 0 + + for (let cursor = index - 1; cursor >= 0 && code[cursor] === '\\'; cursor -= 1) { + slashCount += 1 + } + + return slashCount % 2 === 1 +} + +function stripOuterParens(code: string) { + let text = String(code || '').trim() + + while (text.startsWith('(') && text.endsWith(')')) { + let depth = 0 + let isBalanced = true + + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (char === '(') depth += 1 + if (char === ')') depth -= 1 + + if (depth === 0 && index < text.length - 1) { + isBalanced = false + break + } + } + + if (!isBalanced) break + text = text.slice(1, -1).trim() + } + + return text +} + +function findMatchingBracket(code: string, startIndex: number, openChar: string, closeChar: string) { + let depth = 0 + let quote = '' + let isTemplate = false + + for (let index = startIndex; index < code.length; index += 1) { + const char = code[index] + + if (quote) { + if (char === quote && !isEscaped(code, index)) { + quote = '' + } + continue + } + + if (isTemplate) { + if (char === '`' && !isEscaped(code, index)) { + isTemplate = false + } + continue + } + + if (char === "'" || char === '"') { + quote = char + continue + } + + if (char === '`') { + isTemplate = true + continue + } + + if (char === openChar) { + depth += 1 + continue + } + + if (char === closeChar) { + depth -= 1 + if (depth === 0) return index + } + } + + return -1 +} + +function findTopLevelOperator(code: string, operators: string[]) { + let parenDepth = 0 + let braceDepth = 0 + let bracketDepth = 0 + let quote = '' + let isTemplate = false + + for (let index = 0; index < code.length; index += 1) { + const char = code[index] + + if (quote) { + if (char === quote && !isEscaped(code, index)) { + quote = '' + } + continue + } + + if (isTemplate) { + if (char === '`' && !isEscaped(code, index)) { + isTemplate = false + } + continue + } + + if (char === "'" || char === '"') { + quote = char + continue + } + + if (char === '`') { + isTemplate = true + continue + } + + if (char === '(') parenDepth += 1 + if (char === ')') parenDepth -= 1 + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + if (char === '[') bracketDepth += 1 + if (char === ']') bracketDepth -= 1 + + if (parenDepth !== 0 || braceDepth !== 0 || bracketDepth !== 0) { + continue + } + + const matched = operators.find((operator) => code.startsWith(operator, index)) + if (matched) { + return { index, operator: matched } + } + } + + return null +} + +function splitTopLevelConditional(code: string) { + const text = String(code || '') + let parenDepth = 0 + let braceDepth = 0 + let bracketDepth = 0 + let quote = '' + let isTemplate = false + let questionIndex = -1 + let ternaryDepth = 0 + + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + + if (quote) { + if (char === quote && !isEscaped(text, index)) { + quote = '' + } + continue + } + + if (isTemplate) { + if (char === '`' && !isEscaped(text, index)) { + isTemplate = false + } + continue + } + + if (char === "'" || char === '"') { + quote = char + continue + } + + if (char === '`') { + isTemplate = true + continue + } + + if (char === '(') parenDepth += 1 + if (char === ')') parenDepth -= 1 + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + if (char === '[') bracketDepth += 1 + if (char === ']') bracketDepth -= 1 + + if (parenDepth !== 0 || braceDepth !== 0 || bracketDepth !== 0) { + continue + } + + if (char === '?') { + if (questionIndex === -1) questionIndex = index + ternaryDepth += 1 + continue + } + + if (char === ':' && ternaryDepth > 0) { + ternaryDepth -= 1 + if (ternaryDepth === 0 && questionIndex !== -1) { + return { + test: text.slice(0, questionIndex).trim(), + consequent: text.slice(questionIndex + 1, index).trim(), + alternate: text.slice(index + 1).trim() + } + } + } + } + + return null +} + +function getFunctionShape(functionCode: string) { + const code = stripOuterParens(functionCode) + if (!code) return null + + const arrowInfo = findTopLevelOperator(code, ['=>']) + if (arrowInfo) { + const body = code.slice(arrowInfo.index + arrowInfo.operator.length).trim() + if (body.startsWith('{')) { + const bodyEnd = findMatchingBracket(body, 0, '{', '}') + if (bodyEnd > 0) { + return { + type: 'block', + body: body.slice(1, bodyEnd).trim() + } + } + } + + return { + type: 'expression', + body: stripOuterParens(body) + } + } + + const functionIndex = code.indexOf('function') + if (functionIndex !== -1) { + const bodyStart = code.indexOf('{', functionIndex) + if (bodyStart !== -1) { + const bodyEnd = findMatchingBracket(code, bodyStart, '{', '}') + if (bodyEnd > bodyStart) { + return { + type: 'block', + body: code.slice(bodyStart + 1, bodyEnd).trim() + } + } + } + } + + return null +} + +function findTopLevelKeywordPositions(code: string, keyword: string) { + const positions: number[] = [] + let parenDepth = 0 + let braceDepth = 0 + let bracketDepth = 0 + let quote = '' + let isTemplate = false + + for (let index = 0; index < code.length; index += 1) { + const char = code[index] + + if (quote) { + if (char === quote && !isEscaped(code, index)) { + quote = '' + } + continue + } + + if (isTemplate) { + if (char === '`' && !isEscaped(code, index)) { + isTemplate = false + } + continue + } + + if (char === "'" || char === '"') { + quote = char + continue + } + + if (char === '`') { + isTemplate = true + continue + } + + if (char === '(') parenDepth += 1 + if (char === ')') parenDepth -= 1 + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + if (char === '[') bracketDepth += 1 + if (char === ']') bracketDepth -= 1 + + if (parenDepth !== 0 || braceDepth !== 0 || bracketDepth !== 0) { + continue + } + + if (!code.startsWith(keyword, index)) { + continue + } + + const before = code[index - 1] || '' + const after = code[index + keyword.length] || '' + const isWordBoundaryBefore = !before || !/[A-Za-z0-9_$]/.test(before) + const isWordBoundaryAfter = !after || !/[A-Za-z0-9_$]/.test(after) + + if (isWordBoundaryBefore && isWordBoundaryAfter) { + positions.push(index) + index += keyword.length - 1 + } + } + + return positions +} + +function extractTopLevelReturnExpression(body: string) { + const returnPositions = findTopLevelKeywordPositions(body, 'return') + if (returnPositions.length !== 1) return null + + const start = returnPositions[0] + const expressionStart = start + 'return'.length + let parenDepth = 0 + let braceDepth = 0 + let bracketDepth = 0 + let quote = '' + let isTemplate = false + let end = body.length + + for (let index = expressionStart; index < body.length; index += 1) { + const char = body[index] + + if (quote) { + if (char === quote && !isEscaped(body, index)) { + quote = '' + } + continue + } + + if (isTemplate) { + if (char === '`' && !isEscaped(body, index)) { + isTemplate = false + } + continue + } + + if (char === "'" || char === '"') { + quote = char + continue + } + + if (char === '`') { + isTemplate = true + continue + } + + if (char === '(') parenDepth += 1 + if (char === ')') parenDepth -= 1 + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + if (char === '[') bracketDepth += 1 + if (char === ']') bracketDepth -= 1 + + if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0 && char === ';') { + end = index + break + } + } + + return { + start, + end, + expression: body.slice(expressionStart, end).trim() + } +} + +function getStateReferenceName(expression = '') { + const match = String(expression || '') + .trim() + .match(/^(?:this\.)?state\.([A-Za-z_$][\w$]*)$/) + return match ? match[1] : '' +} + +function inferDefaultValueFromExpression(expression: string, knownDefaults: Map): any { + const text = stripOuterParens(expression) + if (!text) return undefined + + if (text.startsWith('[') && findMatchingBracket(text, 0, '[', ']') === text.length - 1) return [] + if (text.startsWith('{') && findMatchingBracket(text, 0, '{', '}') === text.length - 1) return {} + + if (/^(['"]).*\1$/.test(text)) { + return text.slice(1, -1) + } + + if (text.startsWith('`') && text.endsWith('`')) { + return text.includes('${') ? '' : text.slice(1, -1) + } + + if (/^-?\d+(\.\d+)?$/.test(text)) { + return Number(text) + } + + if (text === 'true') return true + if (text === 'false') return false + if (text === 'null') return null + + if (/^!\s*/.test(text)) return false + + if (/^-\s*\d+(\.\d+)?$/.test(text)) { + return Number(text.replace(/\s+/g, '')) + } + + if (/\.\s*length$/.test(text)) return 0 + + const stateKey = getStateReferenceName(text) + if (stateKey && knownDefaults.has(stateKey)) { + return knownDefaults.get(stateKey) + } + + if (/\.\s*(filter|map|slice|concat|flat|flatMap)\s*\(/.test(text)) return [] + if (/\.\s*(trim|toLowerCase|toUpperCase|substring|substr)\s*\(/.test(text)) return '' + if (/\.\s*(includes|startsWith|endsWith|some|every)\s*\(/.test(text)) return false + + const conditional = splitTopLevelConditional(text) + if (conditional) { + const consequent = inferDefaultValueFromExpression(conditional.consequent, knownDefaults) + const alternate = inferDefaultValueFromExpression(conditional.alternate, knownDefaults) + + if (Array.isArray(consequent) && Array.isArray(alternate)) return [] + if ( + consequent && + alternate && + typeof consequent === 'object' && + typeof alternate === 'object' && + !Array.isArray(consequent) && + !Array.isArray(alternate) + ) { + return {} + } + if (typeof consequent === 'number' && typeof alternate === 'number') return 0 + if (typeof consequent === 'string' && typeof alternate === 'string') return '' + if (typeof consequent === 'boolean' && typeof alternate === 'boolean') return false + return consequent !== undefined ? consequent : alternate + } + + const logicalOperator = findTopLevelOperator(text, ['||', '&&']) + if (logicalOperator) { + const left = inferDefaultValueFromExpression(text.slice(0, logicalOperator.index), knownDefaults) + const right = inferDefaultValueFromExpression( + text.slice(logicalOperator.index + logicalOperator.operator.length), + knownDefaults + ) + + if (logicalOperator.operator === '||') return left !== undefined ? left : right + if (logicalOperator.operator === '&&') return right !== undefined ? right : left + } + + const comparisonOperator = findTopLevelOperator(text, ['===', '!==', '==', '!=', '>=', '<=', '>', '<']) + if (comparisonOperator) return false + + const arithmeticOperator = findTopLevelOperator(text, ['+', '-', '*', '/', '%']) + if (arithmeticOperator) return 0 + + return undefined +} + +function inferComputedDefaultValue(functionCode: string, knownDefaults: Map) { + const shape = getFunctionShape(functionCode) + if (!shape) return undefined + + if (shape.type === 'expression') { + return inferDefaultValueFromExpression(shape.body, knownDefaults) + } + + const returnInfo = extractTopLevelReturnExpression(shape.body) + return returnInfo ? inferDefaultValueFromExpression(returnInfo.expression, knownDefaults) : undefined +} + +function stabilizeComputedGetterCode(code: string) { + if (!code) return code + + return code.replace( + /\b(this\.state\.([A-Za-z_$][\w$]*))\.(reduce|filter|map|slice|concat|flat|flatMap|some|every|find|findIndex)\(/g, + '($1 || []).$3(' + ) +} + +function normalizeImportedRuntimeHelpers(code: string) { + if (!code || typeof code !== 'string') return code + + return code + .replace(/\bthis\.\$router\b/g, 'this.router') + .replace(/\bthis\.\$route\b/g, 'this.route') + .replace(/\bawait\s+(?:this\.)?(?:\$?nextTick)\s*\(\s*\)/g, 'await Promise.resolve()') + .replace( + /\b(?:this\.)?(?:\$?nextTick)\s*\(\s*([^()]+|\([^)]*\)\s*=>[\s\S]*?|function[\s\S]*?)\s*\)/g, + (_match, callback) => { + return `Promise.resolve().then(${String(callback).trim()})` + } + ) +} + +function buildComputedGetterValue(key: string, computedValue: string) { + const shape = getFunctionShape(computedValue) + const safeComputedValue = normalizeImportedRuntimeHelpers(stabilizeComputedGetterCode(computedValue)) + const fallbackValue = `function getter() { this.state.${key} = (${safeComputedValue}).call(this) }` + + if (!shape) { + return fallbackValue + } + + if (shape.type === 'expression') { + const body = normalizeImportedRuntimeHelpers(stabilizeComputedGetterCode(shape.body)) + return body ? `function getter() { this.state.${key} = ${body} }` : fallbackValue + } + + const returnInfo = extractTopLevelReturnExpression(shape.body) + if (!returnInfo?.expression) { + return fallbackValue + } + + const beforeReturn = shape.body.slice(0, returnInfo.start).trim() + const getterStatements = [beforeReturn, `this.state.${key} = ${returnInfo.expression}`].filter(Boolean) + + return getterStatements.length + ? `function getter() { ${normalizeImportedRuntimeHelpers( + stabilizeComputedGetterCode(getterStatements.join('\n')) + )} }` + : fallbackValue +} + +function normalizeImportedRuntimeCodeEntries(target: any) { + if (!target || typeof target !== 'object') return + + Object.values(target).forEach((entry: any) => { + if (!entry || typeof entry !== 'object') return + + if (typeof entry.value === 'string') { + entry.value = normalizeImportedRuntimeHelpers(entry.value) + } + + if (entry.accessor?.getter?.value) { + entry.accessor.getter.value = normalizeImportedRuntimeHelpers(entry.accessor.getter.value) + } + + if (entry.accessor?.setter?.value) { + entry.accessor.setter.value = normalizeImportedRuntimeHelpers(entry.accessor.setter.value) + } + }) +} + +function collectImportedTemplateRefNames(nodes: any, collector = new Set()) { + if (!nodes) return collector + + if (Array.isArray(nodes)) { + nodes.forEach((item) => collectImportedTemplateRefNames(item, collector)) + return collector + } + + if (typeof nodes !== 'object') return collector + + const refName = nodes?.props?.ref + if (typeof refName === 'string' && refName.trim()) { + collector.add(refName.trim()) + } + + Object.values(nodes).forEach((item) => { + if (item && typeof item === 'object') { + collectImportedTemplateRefNames(item, collector) + } + }) + + return collector +} + +function replaceImportedTemplateRefCode(code: string, refNames: Set) { + if (!code || !refNames.size) return code + + let nextCode = String(code) + refNames.forEach((refName) => { + const pattern = new RegExp(`\\bthis\\.state\\.${refName}\\b`, 'g') + nextCode = nextCode.replace(pattern, `this.$('${refName}')`) + }) + + return nextCode +} + +function normalizeImportedTemplateRefs(schema: any) { + if (!schema || typeof schema !== 'object') return schema + + const refNames = collectImportedTemplateRefNames(schema.children) + if (!refNames.size) return schema + + const walk = (value: any) => { + if (!value || typeof value !== 'object') return + + if (Array.isArray(value)) { + value.forEach(walk) + return + } + + if ((value.type === 'JSExpression' || value.type === 'JSFunction') && typeof value.value === 'string') { + value.value = replaceImportedTemplateRefCode(value.value, refNames) + return + } + + if (value.accessor?.getter?.value) { + value.accessor.getter.value = replaceImportedTemplateRefCode(value.accessor.getter.value, refNames) + } + + if (value.accessor?.setter?.value) { + value.accessor.setter.value = replaceImportedTemplateRefCode(value.accessor.setter.value, refNames) + } + + Object.values(value).forEach((item) => walk(item)) + } + + walk(schema.methods) + walk(schema.computed) + walk(schema.lifeCycles) + walk(schema.state) + + refNames.forEach((refName) => { + if (schema.state && Object.prototype.hasOwnProperty.call(schema.state, refName)) { + delete schema.state[refName] + } + }) + + return schema +} + +function normalizeSchemaComputedDefaults(schema: any) { + if (!schema || typeof schema !== 'object' || !schema.state || !schema.computed) return schema + + const defaults = new Map() + Object.entries(schema.state || {}).forEach(([key, value]: [string, any]) => { + if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'defaultValue')) { + defaults.set(key, value.defaultValue) + } + }) + + Object.keys(schema.computed || {}).forEach((key) => { + const stateEntry = schema.state?.[key] + const computedEntry = schema.computed?.[key] + if (!stateEntry?.accessor?.getter?.value || stateEntry.defaultValue !== undefined || !computedEntry?.value) return + + const inferredDefaultValue = inferComputedDefaultValue(computedEntry.value, defaults) + if (inferredDefaultValue === undefined) return + + stateEntry.defaultValue = inferredDefaultValue + defaults.set(key, inferredDefaultValue) + }) + + Object.keys(schema.computed || {}).forEach((key) => { + const stateEntry = schema.state?.[key] + const computedEntry = schema.computed?.[key] + + if (!stateEntry?.accessor?.getter || !computedEntry?.value) return + + stateEntry.accessor.getter.value = buildComputedGetterValue(key, computedEntry.value) + }) + + return schema +} + +function getIconNameFromStateKey(stateKey: string) { + if (!/^TinyIcon[A-Z0-9]/.test(stateKey || '')) return '' + return `Icon${String(stateKey).slice('TinyIcon'.length)}` +} + +function isImportedIconStateEntry(entry: any) { + return ( + entry?.type === 'JSExpression' && + typeof entry?.value === 'string' && + /^icon[A-Z0-9].*\(\)$/.test(entry.value.trim()) + ) +} + +function createImportedIconSchema(iconName: string) { + return { + componentName: 'Icon', + props: { + name: iconName + } + } +} + +function normalizeImportedIconStates(schema: any) { + if (!schema || typeof schema !== 'object' || !schema.state) return schema + + const iconStateMap = new Map() + Object.entries(schema.state || {}).forEach(([key, value]: [string, any]) => { + if (!isImportedIconStateEntry(value)) return + + const iconName = getIconNameFromStateKey(key) + if (!iconName) return + + iconStateMap.set(key, iconName) + }) + + if (!iconStateMap.size) return schema + + const replaceIconExpression = (value: any) => { + if (value?.type !== 'JSExpression' || typeof value?.value !== 'string') return value + + const expression = value.value.trim() + for (const [stateKey, iconName] of iconStateMap.entries()) { + if (expression === `this.state.${stateKey}` || expression === `state.${stateKey}` || expression === stateKey) { + return createImportedIconSchema(iconName) + } + } + + return value + } + + const walk = (node: any) => { + if (!node || typeof node !== 'object') return + + if (Array.isArray(node)) { + node.forEach(walk) + return + } + + if (node.props && typeof node.props === 'object') { + Object.keys(node.props).forEach((key) => { + node.props[key] = replaceIconExpression(node.props[key]) + }) + } + + Object.values(node).forEach((item) => { + if (item && typeof item === 'object') { + walk(item) + } + }) + } + + walk(schema.children) + + const hasStateReference = (value: any, stateKey: string): boolean => { + if (!value || typeof value !== 'object') return false + + if (Array.isArray(value)) { + return value.some((item) => hasStateReference(item, stateKey)) + } + + if ( + (value.type === 'JSExpression' || value.type === 'JSFunction') && + typeof value.value === 'string' && + (value.value.includes(`this.state.${stateKey}`) || value.value.includes(`state.${stateKey}`)) + ) { + return true + } + + return Object.values(value).some((item) => hasStateReference(item, stateKey)) + } + + iconStateMap.forEach((_iconName, stateKey) => { + const stillReferenced = + hasStateReference(schema.children, stateKey) || + hasStateReference(schema.methods, stateKey) || + hasStateReference(schema.computed, stateKey) || + hasStateReference(schema.lifeCycles, stateKey) || + hasStateReference(schema.schema, stateKey) + + if (!stillReferenced) { + delete schema.state[stateKey] + } + }) + + return schema +} + +function normalizeImportedMultiRootSlots(schema: any) { + const wrapSlotRoots = (value: any) => { + if (!value || typeof value !== 'object') return value + + if (Array.isArray(value)) { + value.forEach((item) => wrapSlotRoots(item)) + return value + } + + if (value.type === 'JSSlot' && Array.isArray(value.value)) { + value.value.forEach((item: any) => wrapSlotRoots(item)) + + if (value.value.length > 1) { + value.value = [ + { + componentName: 'div', + props: {}, + children: value.value + } + ] + } + + return value + } + + Object.values(value).forEach((item) => { + if (item && typeof item === 'object') { + wrapSlotRoots(item) + } + }) + + return value + } + + wrapSlotRoots(schema?.children) + wrapSlotRoots(schema?.methods) + wrapSlotRoots(schema?.computed) + wrapSlotRoots(schema?.lifeCycles) + wrapSlotRoots(schema?.schema) + + return schema +} + +export function normalizeImportedSchema(schema: any) { + normalizeSchemaComputedDefaults(schema) + normalizeImportedRuntimeCodeEntries(schema?.methods) + normalizeImportedRuntimeCodeEntries(schema?.computed) + normalizeImportedRuntimeCodeEntries(schema?.lifeCycles) + normalizeImportedRuntimeCodeEntries(schema?.state) + normalizeImportedTemplateRefs(schema) + normalizeImportedIconStates(schema) + normalizeImportedMultiRootSlots(schema) + return schema +} + +export function normalizeImportedAppSchema(appSchema: any) { + if (!appSchema || typeof appSchema !== 'object') return appSchema + ;(appSchema.pageSchema || []).forEach((schema: any) => normalizeImportedSchema(schema)) + ;(appSchema.blockSchemas || []).forEach((schema: any) => normalizeImportedSchema(schema)) + + return appSchema +} + +function cloneImportedRuntimeValue(value: any): any { + if (Array.isArray(value)) { + return value.map((item) => cloneImportedRuntimeValue(item)) + } + + if (!value || typeof value !== 'object') { + return value + } + + if (value.type === 'JSExpression' || value.type === 'JSFunction' || value.type === 'JSSlot') { + return undefined + } + + if (Object.prototype.hasOwnProperty.call(value, 'defaultValue')) { + return cloneImportedRuntimeValue(value.defaultValue) + } + + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneImportedRuntimeValue(item)])) +} + +function buildImportedRuntimeState(schemaState: Record = {}) { + return Object.fromEntries(Object.entries(schemaState).map(([key, value]) => [key, cloneImportedRuntimeValue(value)])) +} + +function applyHydratedStateBackToSchema(schemaState: Record = {}, runtimeState: Record = {}) { + Object.keys(schemaState || {}).forEach((key) => { + const original = schemaState[key] + const nextValue = runtimeState[key] + + if (original && typeof original === 'object' && Object.prototype.hasOwnProperty.call(original, 'defaultValue')) { + original.defaultValue = nextValue + return + } + + schemaState[key] = nextValue + }) +} + +async function hydrateImportedSchemaState(schema: any) { + if (!schema || typeof schema !== 'object' || !schema.state || !schema.lifeCycles) return schema + + const mountCode = + schema.lifeCycles?.onMounted?.value || schema.lifeCycles?.mounted?.value || schema.lifeCycles?.created?.value + if (!mountCode || typeof mountCode !== 'string') return schema + + const runtimeState = buildImportedRuntimeState(schema.state) + const globalWindow = (globalThis as any).window + const originalSetTimeout = globalWindow?.setTimeout + const instance: Record = { + state: runtimeState, + props: {}, + utils: {}, + emit: () => undefined, + Modal: { message: () => undefined } + } + + if (typeof runtimeState.appItems === 'undefined') { + runtimeState.appItems = [] + } + + instance.wait = async () => undefined + + const pendingMethods = new Map() + Object.entries(schema.methods || {}).forEach(([key, value]: [string, any]) => { + const code = value?.value + if (typeof code === 'string' && code.trim()) { + pendingMethods.set(key, code) + } + }) + + let changed = true + while (pendingMethods.size > 0 && changed) { + changed = false + + for (const [key, code] of Array.from(pendingMethods.entries())) { + try { + const fn = new Function(`return (${code})`)() + if (typeof fn !== 'function') continue + + instance[key] = fn.bind(instance) + pendingMethods.delete(key) + changed = true + } catch { + // ignore unresolved helper methods + } + } + } + + try { + if (globalWindow && typeof globalWindow.setTimeout !== 'function') { + globalWindow.setTimeout = (handler: any) => { + handler?.() + return 0 + } + } + + const lifecycle = new Function(`return (${mountCode})`)() + if (typeof lifecycle === 'function') { + const result = lifecycle.call(instance) + if (result && typeof result.then === 'function') { + await result + } + + await Promise.resolve() + await Promise.resolve() + } + } catch { + return schema + } finally { + if (globalWindow) { + globalWindow.setTimeout = originalSetTimeout + } + } + + applyHydratedStateBackToSchema(schema.state, runtimeState) + return schema +} + +export async function hydrateImportedAppSchemaState(appSchema: any) { + if (!appSchema || typeof appSchema !== 'object') return appSchema + + for (const schema of appSchema.pageSchema || []) { + // only hydrate imported pages to avoid executing block-side logic unexpectedly + await hydrateImportedSchemaState(schema) + } + + return appSchema +} 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..5f96accfd4 --- /dev/null +++ b/packages/vue-to-dsl/README.md @@ -0,0 +1,258 @@ +# @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 = ` + + + +`; + +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 都会同步转换为兼容当前设计器运行时的 state accessor + 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`) + +页面与区块生成规则: + +- 单文件导入:默认按页面处理,生成 `Page` schema +- 整包导入时,`src/views/**/*.vue` 默认作为页面候选,进入 `pageSchema` +- 页面里 import 并在模板中实际使用到的本地 `.vue` 子组件,会沿 import 链递归转换为区块,进入 `blockSchemas` +- 如果某个被页面作为子组件使用的 `.vue` 文件本身位于 `src/views/` 下,则优先按区块处理,并从最终 `pageSchema` 中排除 +- `src/components/**/*.vue` 不再无条件全部生成区块;只有被页面或其子组件沿 import 链实际引用到时,才会进入 `blockSchemas` +- 本地 `.vue` 区块的收集优先基于 import 链路解析真实文件,不再为“模板里引用但无法解析到真实文件”的场景自动补空占位区块 + +与导入工具栏(`packages/toolbars/upload`)联动时的行为: + +- 项目目录导入与 zip 导入都会先调用本包聚合出 `appSchema` +- `pageSchema` 会被导入为页面;若存在重名页面,导入工具栏会先弹出覆盖确认,再决定是否创建/覆盖页面 +- `blockSchemas` 会被导入为区块;同名区块会走更新并发布,非同名区块会走创建并发布 +- 导入工具栏只会处理本包输出的 `pageSchema` / `blockSchemas` / `utils` / `dataSource` / `globalState` / `i18n` 等聚合结果,本包本身不负责页面落库、区块发布或覆盖交互 + +## 模板/脚本/样式支持 + +模板(`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' }` +- v-if / v-else-if / v-else:会转换为互斥的 `condition: { type: 'JSExpression', value }` 分支链条件 +- 事件与绑定:能解析简单字面量,复杂表达式以 `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` +- computed 兼容: + - 模板/脚本中对 computed 的引用会按 `this.state.xxx` 形式改写 + - 生成 schema 时会额外把 computed 映射为 state accessor,兼容当前设计器导入运行时 + - 当 `computed_flag=true` 时,仍会保留 `schema.computed` 字段,便于调试或后续处理 + +样式(`parseStyle` + 辅助) + +- 基础样式串:直出 `css` +- 辅助能力:`parseCSSRules`、`extractCSSVariables`、`hasMediaQueries`、`extractMediaQueries` + +## 输出 Schema 约定(页面级) + +- 根节点 `componentName: 'Page'`,自动补齐 `id`(8 位字母数字) +- `state`/`methods`/`computed`/`lifecycle` 值以 `{ type: 'JSFunction', value: string }` 表达(state 中基础类型按需折叠) +- `computed` 会同步展开为 `state` 中的 accessor getter,以便导入后的页面在当前运行时下正常显示 +- `children` 为模板树;属性中无法安全字面量化的表达式以 `JSExpression` 表达 +- 所有字符串做轻度“去换行/多空格”规整 + +## 已知限制 + +- `v-for` 目前优先支持常见写法,如 `item in list`、`(item, index) in list`、`(item, index) of list`;更复杂的解构参数、对象枚举、三参数以上的场景支持有限 +- `v-if / v-else-if / v-else` 会按同级相邻节点组成互斥分支链;如果模板经过复杂包装、注释穿插或非常规结构改写,分支链识别结果可能与原始意图不完全一致 +- `v-show` 会转换为 `JSExpression`,但其显示/隐藏语义仍依赖运行时组件对对应属性的支持 +- 本地 `.vue` 子组件转区块依赖 import 链路和模板实际使用情况;动态注册组件、运行时字符串组件名、非常规组件解析方式无法完整覆盖 +- `src/router/index.js`、`src/stores/*.js`、`src/utils(.js|.ts|index)`、`src/lowcodeConfig/dataSource.json` 等应用级信息仍然主要基于约定路径和轻量解析,复杂工程结构或自定义目录布局可能需要额外适配 +- `script setup` 与 Options API 的常见场景已覆盖,但宏展开、复杂 TS 类型推导、装饰器、非常规编译期语法等场景支持有限 +- 变量中的 JSX / `h()` 渲染函数已支持常见 slot 场景,但非常复杂的 render 函数组合、深层闭包、运行时生成 vnode 的逻辑仍可能需要额外适配 +- `computed` 已做导入兼容,会额外映射为 `state accessor`;如果原始 computed 强依赖运行时环境、副作用或复杂闭包,导入后仍建议重点校验 + +## 测试用例说明 + +测试目录位于 `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..bdc767e488 --- /dev/null +++ b/packages/vue-to-dsl/src/converter.ts @@ -0,0 +1,2147 @@ +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, defaultComponentsMap } 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) + +const HTML_TAGS = new Set([ + 'div', + 'span', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'a', + 'img', + 'button', + 'input', + 'form', + 'table', + 'tr', + 'td', + 'th', + 'thead', + 'tbody', + 'section', + 'article', + 'header', + 'footer', + 'nav', + 'aside', + 'main', + 'template', + 'slot' +]) + +const BUILTIN_SCHEMA_COMPONENTS = new Set([ + 'Page', + 'Block', + 'Text', + 'Icon', + 'Template', + 'Collection', + 'Slot', + 'slot', + 'RouterView', + 'RouterLink', + 'CanvasPlaceholder' +]) + +function collectTemplateRefNames(nodes: any, collector = new Set()) { + if (!nodes) return collector + + if (Array.isArray(nodes)) { + nodes.forEach((item) => collectTemplateRefNames(item, collector)) + return collector + } + + if (typeof nodes !== 'object') return collector + + const refName = nodes?.props?.ref + if (typeof refName === 'string' && refName.trim()) { + collector.add(refName.trim()) + } + + Object.values(nodes).forEach((item) => { + if (item && typeof item === 'object') { + collectTemplateRefNames(item, collector) + } + }) + + return collector +} + +function replaceTemplateRefCode(code: string, refNames: Set) { + if (!code || !refNames.size) return code + + let nextCode = String(code) + refNames.forEach((refName) => { + const pattern = new RegExp(`\\bthis\\.state\\.${refName}\\b`, 'g') + nextCode = nextCode.replace(pattern, `this.$('${refName}')`) + }) + + return nextCode +} + +function normalizeSchemaTemplateRefs(schema: any, scriptSchema: any, templateSchema: any[]) { + const refNames = collectTemplateRefNames(templateSchema) + if (!refNames.size || !schema || typeof schema !== 'object') return schema + + const walk = (value: any) => { + if (!value || typeof value !== 'object') return + + if (Array.isArray(value)) { + value.forEach(walk) + return + } + + if ((value.type === 'JSExpression' || value.type === 'JSFunction') && typeof value.value === 'string') { + value.value = replaceTemplateRefCode(value.value, refNames) + return + } + + if (value.accessor?.getter?.value) { + value.accessor.getter.value = replaceTemplateRefCode(value.accessor.getter.value, refNames) + } + + if (value.accessor?.setter?.value) { + value.accessor.setter.value = replaceTemplateRefCode(value.accessor.setter.value, refNames) + } + + Object.values(value).forEach((item) => walk(item)) + } + + walk(schema.methods) + walk(schema.computed) + walk(schema.lifeCycles) + walk(schema.children) + walk(schema.state) + + refNames.forEach((refName) => { + if ( + scriptSchema?.state?.[refName]?.type === 'ref' && + schema.state && + Object.prototype.hasOwnProperty.call(schema.state, refName) + ) { + delete schema.state[refName] + } + }) + + return schema +} + +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 + componentsMap?: any[] +} + +type LocalModuleContext = { + allFiles: string[] + fileSet: Set + readText: (filePath: string) => Promise + readDataUrl: (filePath: string) => Promise +} + +type ImportedAssetEntry = { + placeholder: string + filePath: string + name: string + resourceData: string +} + +export class VueToDslConverter { + private options: VueToSchemaOptions + + private static readonly NOOP_FUNCTION_VALUE = 'function noop() {}' + + constructor(options: VueToSchemaOptions = {}) { + this.options = { + componentMap: defaultComponentMap, + preserveComments: false, + strictMode: false, + computed_flag: false, + customParsers: {}, + ...options + } + } + + private isLocalModuleSource(source = '') { + return source.startsWith('.') || source.startsWith('@/') || source.startsWith('/') + } + + private stripQueryAndHash(source = '') { + return String(source || '') + .split('#')[0] + .split('?')[0] + } + + private getVirtualFileName(filePath = '') { + const normalized = this.normalizeVirtualPath(filePath) + const segments = normalized.split('/') + return segments[segments.length - 1] || normalized + } + + private getVirtualFileBaseName(filePath = '') { + return this.getVirtualFileName(filePath).replace(/\.[^.]+$/, '') + } + + private escapeRegExp(value = '') { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + + private extractStringLiteralValue(code = '') { + const trimmed = String(code || '').trim() + const quote = trimmed[0] + if (!trimmed || ![`'`, '"', '`'].includes(quote) || trimmed[trimmed.length - 1] !== quote) { + return null + } + + return trimmed.slice(1, -1) + } + + private isImageAssetPath(filePath = '') { + return /\.(png|jpe?g|gif|svg|webp|bmp|ico|avif)$/i.test(this.stripQueryAndHash(filePath)) + } + + private isLocalImageSource(source = '') { + const normalized = String(source || '').trim() + if (!normalized || !this.isImageAssetPath(normalized)) return false + + return ( + normalized.startsWith('./') || + normalized.startsWith('../') || + normalized.startsWith('@/') || + normalized.startsWith('/') + ) + } + + private getMimeTypeByAssetPath(filePath = '') { + const cleaned = this.stripQueryAndHash(filePath).toLowerCase() + if (cleaned.endsWith('.png')) return 'image/png' + if (cleaned.endsWith('.jpg') || cleaned.endsWith('.jpeg')) return 'image/jpeg' + if (cleaned.endsWith('.gif')) return 'image/gif' + if (cleaned.endsWith('.svg')) return 'image/svg+xml' + if (cleaned.endsWith('.webp')) return 'image/webp' + if (cleaned.endsWith('.bmp')) return 'image/bmp' + if (cleaned.endsWith('.ico')) return 'image/x-icon' + if (cleaned.endsWith('.avif')) return 'image/avif' + + return 'application/octet-stream' + } + + private resolveLocalAssetPath(source: string, importerFile: string, context: LocalModuleContext) { + if (!this.isLocalImageSource(source)) return null + + const cleanedSource = this.stripQueryAndHash(source) + const normalizedSource = this.normalizeVirtualPath(cleanedSource) + let candidates: string[] = [] + + if (normalizedSource.startsWith('@/')) { + candidates = [this.normalizeVirtualPath(`src/${normalizedSource.slice(2)}`)] + } else if (normalizedSource.startsWith('/')) { + const relativePath = normalizedSource.replace(/^\/+/, '') + candidates = [relativePath, this.normalizeVirtualPath(`public/${relativePath}`)] + } else { + candidates = [this.resolveVirtualRelativePath(this.getVirtualDirname(importerFile), normalizedSource)] + } + + const resolvedPath = candidates.find((item) => item && context.fileSet.has(item)) + return resolvedPath || null + } + + private createImportedAssetPlaceholder(index: number) { + return `__TE_IMPORTED_ASSET_${index}__` + } + + private buildImportedAssetName(filePath = '') { + return this.getVirtualFileName(filePath) + } + + private async getOrCreateImportedAssetEntry( + resolvedPath: string, + context: LocalModuleContext, + assetMap: Map + ) { + const normalizedPath = this.normalizeVirtualPath(resolvedPath) + const existing = assetMap.get(normalizedPath) + if (existing) return existing + + const resourceData = await context.readDataUrl(normalizedPath) + if (!resourceData) return null + + const assetEntry: ImportedAssetEntry = { + placeholder: this.createImportedAssetPlaceholder(assetMap.size + 1), + filePath: normalizedPath, + name: this.buildImportedAssetName(normalizedPath), + resourceData + } + + assetMap.set(normalizedPath, assetEntry) + return assetEntry + } + + private async replaceSchemaCssAssetUrls( + cssText: string, + importerFile: string, + context: LocalModuleContext, + assetMap: Map + ) { + const matches = Array.from(cssText.matchAll(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/g)) + if (!matches.length) return cssText + + let output = cssText + for (const match of matches) { + const originalSource = String(match[2] || '').trim() + const resolvedPath = this.resolveLocalAssetPath(originalSource, importerFile, context) + if (!resolvedPath) continue + + const assetEntry = await this.getOrCreateImportedAssetEntry(resolvedPath, context, assetMap) + if (!assetEntry) continue + + output = output.split(originalSource).join(assetEntry.placeholder) + } + + return output + } + + private async rewriteSchemaAssetReferences( + target: any, + importerFile: string, + context: LocalModuleContext, + assetMap: Map, + localAssetImports = new Map() + ) { + if (Array.isArray(target)) { + for (const item of target) { + await this.rewriteSchemaAssetReferences(item, importerFile, context, assetMap, localAssetImports) + } + return + } + + if (!target || typeof target !== 'object') { + return + } + + if (typeof target.css === 'string') { + target.css = await this.replaceSchemaCssAssetUrls(target.css, importerFile, context, assetMap) + } + + for (const [key, value] of Object.entries(target)) { + if (typeof value === 'string') { + const resolvedPath = this.resolveLocalAssetPath(value, importerFile, context) + if (resolvedPath) { + const assetEntry = await this.getOrCreateImportedAssetEntry(resolvedPath, context, assetMap) + if (assetEntry) { + ;(target as any)[key] = assetEntry.placeholder + } + } + continue + } + + if ( + value && + typeof value === 'object' && + (value as any).type === 'JSExpression' && + typeof (value as any).value === 'string' + ) { + const nextExpression = String((value as any).value) + const literalSource = this.extractStringLiteralValue(nextExpression) + + if (literalSource) { + const resolvedPath = this.resolveLocalAssetPath(literalSource, importerFile, context) + if (resolvedPath) { + const assetEntry = await this.getOrCreateImportedAssetEntry(resolvedPath, context, assetMap) + if (assetEntry) { + ;(value as any).value = `'${assetEntry.placeholder}'` + } + } + } + + for (const [localName, resolvedPath] of localAssetImports.entries()) { + const assetEntry = await this.getOrCreateImportedAssetEntry(resolvedPath, context, assetMap) + if (!assetEntry) continue + + const assetLiteral = `'${assetEntry.placeholder}'` + const importRefPattern = new RegExp(`\\bthis\\.state\\.${this.escapeRegExp(localName)}\\b`, 'g') + ;(value as any).value = String((value as any).value).replace(importRefPattern, assetLiteral) + } + } + + if (value && typeof value === 'object') { + await this.rewriteSchemaAssetReferences(value, importerFile, context, assetMap, localAssetImports) + } + } + } + + private collectLocalImageImports(scriptSchema: any, context: LocalModuleContext) { + const importerFile = scriptSchema?.__filePath + const importMap = new Map() + + if (!importerFile || !Array.isArray(scriptSchema?.imports)) { + return importMap + } + + scriptSchema.imports.forEach((item: any) => { + const resolvedPath = this.resolveLocalAssetPath(item?.source || '', importerFile, context) + if (!resolvedPath) return + ;(item.specifiers || []).forEach((spec: any) => { + if (!spec?.local) return + importMap.set(String(spec.local), resolvedPath) + }) + }) + + return importMap + } + + private async collectImportedAssetsFromResults(results: ConvertResult[], context: LocalModuleContext) { + const assetMap = new Map() + + for (const result of results) { + const schema = result?.schema + const importerFile = result?.scriptSchema?.__filePath || schema?.__sourceFilePath + if (!schema || !importerFile) continue + + const localAssetImports = this.collectLocalImageImports(result?.scriptSchema, context) + await this.rewriteSchemaAssetReferences(schema, importerFile, context, assetMap, localAssetImports) + } + + return Array.from(assetMap.values()) + } + + private isSchemaBuiltinComponent(componentName = '') { + if (!componentName) return true + if (BUILTIN_SCHEMA_COMPONENTS.has(componentName)) return true + + const lower = componentName.toLowerCase() + if (HTML_TAGS.has(lower)) return true + if (lower.includes('-') && !lower.startsWith('tiny-')) return true + + return false + } + + private getComponentPackageVersion(source = '') { + if (source === '@opentiny/vue' || source === '@opentiny/vue-icon') { + return '^3.10.0' + } + return '' + } + + private createPackageComponentMapEntry( + componentName: string, + source: string, + exportName: string, + destructuring = true + ) { + if (!componentName || !source || this.isLocalModuleSource(source)) return null + + const entry: any = { + componentName, + package: source, + exportName: exportName || componentName, + destructuring + } + + const version = this.getComponentPackageVersion(source) + if (version) { + entry.version = version + } + + return entry + } + + private collectTemplateComponentNames(nodes: any[] = [], target = new Set()) { + const visited = new Set() + + const walkAny = (value: any) => { + if (!value || typeof value !== 'object') return + if (visited.has(value)) return + visited.add(value) + + if (Array.isArray(value)) { + value.forEach((item) => walkAny(item)) + return + } + + if (typeof value.componentName === 'string' && value.componentName) { + target.add(value.componentName) + } + + Object.values(value).forEach((item) => { + if (item && typeof item === 'object') { + walkAny(item) + } + }) + } + + nodes.forEach((node: any) => walkAny(node)) + return target + } + + private buildComponentsMapFromTemplateAndScript(templateSchema: any[] = [], scriptSchema: any = {}) { + const componentNames = [...this.collectTemplateComponentNames(templateSchema)] + const defaultMapByName = new Map() + defaultComponentsMap.forEach((item: any) => { + const componentName = String(item?.componentName || '') + if (!componentName) return + defaultMapByName.set(componentName, { ...item }) + }) + + const importLookup = new Map() + ;(scriptSchema?.imports || []).forEach((imp: any) => { + const source = String(imp?.source || '') + ;(imp?.specifiers || []).forEach((spec: any) => { + const local = String(spec?.local || '') + if (!local) return + + importLookup.set(local, { + source, + imported: String(spec?.imported || local), + kind: String(spec?.kind || 'named') + }) + }) + }) + + const iconFactoryLookup = new Map() + Object.entries(scriptSchema?.state || {}).forEach(([name, stateItem]: [string, any]) => { + if (!/^TinyIcon[A-Z0-9]/.test(name)) return + const rawValue = typeof stateItem?.value === 'string' ? stateItem.value.trim() : '' + const match = rawValue.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\(\)$/) + if (match?.[1]) { + iconFactoryLookup.set(name, match[1]) + } + }) + + const merged = new Map() + const addEntry = (entry: any) => { + const name = String(entry?.componentName || '') + if (!name || merged.has(name)) return + merged.set(name, entry) + } + + componentNames.forEach((componentName) => { + if (this.isSchemaBuiltinComponent(componentName)) return + + const defaultEntry = defaultMapByName.get(componentName) + if (defaultEntry) { + addEntry(defaultEntry) + return + } + + const directImport = importLookup.get(componentName) + if ( + directImport && + !this.isFrameworkImport(directImport.source) && + !this.isLocalModuleSource(directImport.source) + ) { + const exportName = + directImport.imported === 'default' || directImport.imported === '*' ? componentName : directImport.imported + const destructuring = directImport.kind === 'named' + const entry = this.createPackageComponentMapEntry(componentName, directImport.source, exportName, destructuring) + if (entry) { + addEntry(entry) + return + } + } + + if (/^TinyIcon[A-Z0-9]/.test(componentName)) { + const inferredFactory = iconFactoryLookup.get(componentName) || `icon${componentName.slice('TinyIcon'.length)}` + const iconImport = importLookup.get(inferredFactory) + + if (iconImport && iconImport.source === '@opentiny/vue-icon') { + const exportName = iconImport.imported === 'default' ? inferredFactory : iconImport.imported + const entry = this.createPackageComponentMapEntry(componentName, iconImport.source, exportName, true) + if (entry) { + addEntry(entry) + return + } + } + + const iconEntry = this.createPackageComponentMapEntry( + componentName, + '@opentiny/vue-icon', + inferredFactory, + true + ) + if (iconEntry) { + addEntry(iconEntry) + return + } + } + + if (/^Tiny[A-Z0-9]/.test(componentName)) { + const tinyEntry = this.createPackageComponentMapEntry( + componentName, + '@opentiny/vue', + componentName.replace(/^Tiny/, ''), + true + ) + if (tinyEntry) { + addEntry(tinyEntry) + } + } + }) + + return [...merged.values()] + } + + private mergeComponentsMaps(componentMaps: any[][] = []) { + const merged = new Map() + + componentMaps.flat().forEach((item: any) => { + const componentName = String(item?.componentName || '') + if (!componentName || merged.has(componentName)) return + merged.set(componentName, { ...item }) + }) + + return [...merged.values()] + } + + private collectComponentsMapFromResults(results: ConvertResult[] = []) { + const resultMaps = results + .map((result: any) => (Array.isArray(result?.componentsMap) ? result.componentsMap : [])) + .filter((items) => items.length > 0) + + return this.mergeComponentsMaps([defaultComponentsMap as any[], ...resultMaps]) + } + + 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 || {}, + runtimeAliases: scriptSchema.runtimeAliases || {} + } 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 = normalizeSchemaTemplateRefs( + await generateSchema(templateSchema, scriptSchema, styleSchema, this.options as any), + scriptSchema, + templateSchema + ) + const componentsMap = this.buildComponentsMapFromTemplateAndScript(templateSchema, scriptSchema) + + return { + schema, + dependencies: [...new Set(dependencies)], + errors, + warnings, + scriptSchema, + componentsMap + } + } 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: VueToDslConverter.NOOP_FUNCTION_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: VueToDslConverter.NOOP_FUNCTION_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: VueToDslConverter.NOOP_FUNCTION_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: VueToDslConverter.NOOP_FUNCTION_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 resolveLocalVueFilePath(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] + + if (!/\.vue$/i.test(basePath)) { + candidates.push(`${basePath}.vue`) + candidates.push(`${basePath}/index.vue`) + } + + const found = candidates.find((candidate) => context.fileSet.has(this.normalizeVirtualPath(candidate))) + return found ? this.normalizeVirtualPath(found) : null + } + + private collectBlockComponentNames(schema: any) { + const names = new Set() + + const visit = (node: any) => { + if (!node || typeof node !== 'object') return + if (node.componentType === 'Block' && node.componentName) { + names.add(String(node.componentName)) + } + if (Array.isArray(node.children)) { + node.children.forEach(visit) + } + } + + if (Array.isArray(schema?.children)) { + schema.children.forEach(visit) + } + + return names + } + + private getReferencedLocalVueImports(result: ConvertResult, context: LocalModuleContext) { + const refs: Array<{ filePath: string; componentName: string }> = [] + const schema = result?.schema + const scriptSchema: any = result?.scriptSchema + + if (!schema || !scriptSchema?.imports?.length || !scriptSchema?.__filePath) { + return refs + } + + const usedBlockNames = this.collectBlockComponentNames(schema) + + scriptSchema.imports.forEach((imp: any) => { + ;(imp.specifiers || []).forEach((spec: any) => { + if (!spec?.local || !usedBlockNames.has(String(spec.local))) return + + const targetPath = this.resolveLocalVueFilePath(imp.source, scriptSchema.__filePath, context) + if (!targetPath) return + + refs.push({ + filePath: targetPath, + componentName: String(spec.local) + }) + }) + }) + + return refs + } + + private async collectImportedVueBlocks( + results: ConvertResult[], + context: LocalModuleContext, + baseBlockSchemas: any[] = [] + ) { + const blockSchemas = [...baseBlockSchemas] + const blockResults: ConvertResult[] = [] + const blockedViewPaths = new Set() + const existingBlockNames = new Set( + blockSchemas.map((item) => String(item?.fileName || item?.meta?.name || '')).filter(Boolean) + ) + const processedPaths = new Set() + const queue = results.flatMap((result) => this.getReferencedLocalVueImports(result, context)) + + while (queue.length) { + const current = queue.shift() + if (!current) continue + + const filePath = this.normalizeVirtualPath(current.filePath) + if (processedPaths.has(filePath)) continue + processedPaths.add(filePath) + + const code = await context.readText(filePath) + if (!code) continue + + const savedOptions = { ...this.options } + try { + this.options = { ...this.options, isBlock: true } as any + const result = await this.convertFromString(code, current.componentName) + if (result.scriptSchema) { + result.scriptSchema.__filePath = filePath + } + if (result.schema) { + result.schema.componentName = 'Block' + result.schema.fileName = current.componentName + result.schema.meta = { ...(result.schema.meta || {}), name: current.componentName } + ;(result.schema as any).__sourceFilePath = filePath + + if (!existingBlockNames.has(current.componentName)) { + existingBlockNames.add(current.componentName) + blockSchemas.push(result.schema) + } + } + + blockResults.push(result) + + if (filePath.startsWith('src/views/')) { + blockedViewPaths.add(filePath) + } + + queue.push(...this.getReferencedLocalVueImports(result, context)) + } finally { + this.options = savedOptions + } + } + + return { + blockSchemas, + blockResults, + blockedViewPaths + } + } + + 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) + } + + private getViewVueFiles(context: LocalModuleContext) { + return context.allFiles + .map((filePath) => this.normalizeVirtualPath(filePath)) + .filter((filePath) => filePath.startsWith('src/views/') && filePath.endsWith('.vue')) + } + + private async collectPageResultsFromModuleContext(context: LocalModuleContext) { + const vueFiles = this.getViewVueFiles(context) + const relativeViewPaths = vueFiles.map((filePath) => filePath.slice('src/views/'.length)) + + const fileMap = new Map() + relativeViewPaths.forEach((relativePath) => { + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || '' + if (!fileMap.has(baseName)) { + fileMap.set(baseName, []) + } + fileMap.get(baseName)!.push(relativePath) + }) + + const needsSpecialNaming = new Set() + for (const paths of fileMap.values()) { + if (paths.length > 1) { + paths.forEach((item) => needsSpecialNaming.add(item)) + } + } + + const pageResults: ConvertResult[] = [] + for (const filePath of vueFiles) { + const relativePath = filePath.slice('src/views/'.length) + const baseName = + relativePath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || 'Page' + + try { + const vueCode = await context.readText(filePath) + if (!vueCode) { + pageResults.push({ + schema: null, + dependencies: [], + errors: [`Failed to read ${filePath}`], + warnings: [] + }) + continue + } + + const fileName = needsSpecialNaming.has(relativePath) + ? this.buildConflictResolvedFileName(relativePath, baseName, fileMap.get(baseName) || [], relativeViewPaths) + : baseName + + const result = await this.convertFromString(vueCode, fileName) + if (result.scriptSchema) { + result.scriptSchema.__filePath = filePath + } + pageResults.push(result) + } catch (error: any) { + pageResults.push({ + schema: null, + dependencies: [], + errors: [`Failed to convert ${filePath}: ${error.message}`], + warnings: [] + }) + } + } + + return pageResults + } + + private async collectAppI18nFromModuleContext(context: LocalModuleContext) { + try { + const [en, zh] = await Promise.all([ + context.readText('src/i18n/en_US.json').then((content) => content || '{}'), + context.readText('src/i18n/zh_CN.json').then((content) => content || '{}') + ]) + + return { en_US: JSON.parse(en), zh_CN: JSON.parse(zh) } + } catch { + return { en_US: {}, zh_CN: {} } + } + } + + private async collectDataSourceFromModuleContext(context: LocalModuleContext) { + const dataSource: any = { list: [] } + + try { + const raw = await context.readText('src/lowcodeConfig/dataSource.json') + if (!raw) return dataSource + + const json = JSON.parse(raw) + if (Array.isArray(json.list)) dataSource.list = json.list + } catch { + // ignore + } + + return dataSource + } + + private async collectGlobalStateFromModuleContext(context: LocalModuleContext) { + const globalState: any[] = [] + const storeFiles = context.allFiles + .map((filePath) => this.normalizeVirtualPath(filePath)) + .filter((filePath) => filePath.startsWith('src/stores/') && filePath.endsWith('.js')) + + for (const filePath of storeFiles) { + try { + const code = await context.readText(filePath) + if (!code || !/defineStore\s*\(/.test(code)) continue + + const idMatch = code.match(/id:\s*['"]([^'"]+)['"]/) + const stateMatch = code.match(/state:\s*\(\)\s*=>\s*\((\{[\s\S]*?\})\)/) + const fallbackId = + filePath + .split('/') + .pop() + ?.replace(/\.[^.]+$/, '') || 'store' + const entry: any = { id: idMatch ? idMatch[1] : fallbackId } + + if (stateMatch) { + try { + const objText = stateMatch[1] + entry.state = Function(`return (${objText})`)() + } catch { + entry.state = {} + } + } else { + continue + } + + if (entry.state && typeof entry.state === 'object' && Object.keys(entry.state).length > 0) { + globalState.push(entry) + } + } catch { + // ignore + } + } + + return globalState + } + + private async enrichPageSchemasWithRouterFromModuleContext(pageSchemas: any[], context: LocalModuleContext) { + try { + const rcode = await context.readText('src/router/index.js') + if (!rcode) { + throw new Error('router file not found') + } + + 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 match: RegExpExecArray | null + + while ((match = routeRegex.exec(rclean))) { + routeEntries.push({ routeName: match[1], routePath: match[2], importPath: match[3] }) + } + + const byFile: Record = {} + routeEntries.forEach((item) => { + const base = + item.importPath + .split('/') + .pop() + ?.replace(/\.vue$/i, '') || '' + if (!base) return + byFile[base] = { routeName: item.routeName, routePath: item.routePath, isHome: item.routeName === homeName } + }) + + for (const pageSchema of pageSchemas) { + const fileName = pageSchema?.fileName + if (!fileName) continue + + let info = byFile[fileName] + if (!info) { + for (const [base, routeInfo] of Object.entries(byFile)) { + if (fileName.toLowerCase() === base.toLowerCase()) { + info = routeInfo + break + } + if (fileName.endsWith(base.charAt(0).toUpperCase() + base.slice(1))) { + info = routeInfo + break + } + } + } + + pageSchema.meta = pageSchema.meta || {} + if (info) { + const routerPath = info.routePath.startsWith('/') ? info.routePath.slice(1) : info.routePath + pageSchema.meta.router = routerPath + pageSchema.meta.isPage = true + pageSchema.meta.isHome = !!info.isHome + } else { + pageSchema.meta.router = fileName.toLowerCase() + pageSchema.meta.isPage = true + } + } + } catch { + for (const pageSchema of pageSchemas) { + pageSchema.meta = pageSchema.meta || {} + if (!pageSchema.meta.router) { + pageSchema.meta.router = (pageSchema.fileName || 'page').toLowerCase() + pageSchema.meta.isPage = true + } + } + } + } + + private async buildAppSchemaFromModuleContext(context: LocalModuleContext) { + const pageResults = await this.collectPageResultsFromModuleContext(context) + const allResults: ConvertResult[] = [...pageResults] + const i18n = await this.collectAppI18nFromModuleContext(context) + let utils: any[] = await this.collectRootUtils(context) + const dataSource = await this.collectDataSourceFromModuleContext(context) + const globalState = await this.collectGlobalStateFromModuleContext(context) + + let blockSchemas: any[] = [] + const importedVueBlockData = await this.collectImportedVueBlocks(pageResults, context, blockSchemas) + blockSchemas = importedVueBlockData.blockSchemas + allResults.push(...importedVueBlockData.blockResults) + const assets = await this.collectImportedAssetsFromResults(allResults, context) + + const pageSchemas = pageResults + .filter( + (result: any) => + result?.schema && !importedVueBlockData.blockedViewPaths.has(result?.scriptSchema?.__filePath || '') + ) + .map((result) => result.schema) + .filter(Boolean) + + await this.enrichPageSchemasWithRouterFromModuleContext(pageSchemas, context) + utils = await this.enrichUtilsFromScriptResults(allResults, utils, context) + const componentsMap = this.collectComponentsMapFromResults(allResults) + + return generateAppSchema(pageSchemas, { + i18n, + utils, + assets, + dataSource, + globalState, + blockSchemas, + componentsMap + }) + } + + private async createModuleContextFromAppDirectory(appDir: string): Promise { + const appFiles = (await this.walk(appDir, (_p) => true)).map((filePath) => + this.normalizeVirtualPath(path.relative(appDir, filePath)) + ) + + return { + 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 + } + }, + readDataUrl: async (filePath: string) => { + try { + const normalizedPath = this.normalizeVirtualPath(filePath) + const fileBuffer = await fs.readFile(path.join(appDir, ...normalizedPath.split('/'))) + const mimeType = this.getMimeTypeByAssetPath(normalizedPath) + + return `data:${mimeType};base64,${fileBuffer.toString('base64')}` + } catch { + return null + } + } + } + } + + private createGitignoreFilter(gitignoreContent: string) { + const lines = gitignoreContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + + const patterns = lines.map((line) => { + const isNegative = line.startsWith('!') + const pattern = isNegative ? line.slice(1) : line + const regexString = pattern + .replace(/([.+?^${}()|[\]\\])/g, '\\$1') + .replace(/\/\*\*$/, '/.*') + .replace(/\*\*/g, '.*') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') + + if (regexString.endsWith('/')) { + return { regex: new RegExp(`^${regexString}`), isNegative } + } + + return { regex: new RegExp(`^${regexString}(/.*)?$`), isNegative } + }) + + return (targetPath: string) => { + let isIgnored = false + for (const { regex, isNegative } of patterns) { + if (regex.test(targetPath)) { + isIgnored = !isNegative + } + } + return !isIgnored + } + } + + private async createModuleContextFromBrowserZip( + zipBuffer: ArrayBuffer | Uint8Array | Buffer + ): Promise { + const zip = await JSZip.loadAsync(zipBuffer as any) + const allFiles = Object.keys((zip as any).files || {}) + .filter((filePath) => !(zip as any).files[filePath].dir) + .filter((filePath) => !filePath.startsWith('__MACOSX/')) + + const topLevels = new Set( + allFiles + .map((filePath) => filePath.split('/')[0]) + .filter((segment) => !!segment && segment !== '.' && segment !== '..') + ) + const rootPrefix = topLevels.size === 1 ? `${[...topLevels][0]}/` : '' + const joinRoot = (subPath: string) => + rootPrefix ? rootPrefix + subPath.replace(/^\/+/, '') : subPath.replace(/^\/+/, '') + const readText = async (relPath: string) => { + const file = zip.file(relPath) + return file ? await file.async('string') : null + } + const appFiles = allFiles.map((filePath) => + this.normalizeVirtualPath( + rootPrefix && filePath.startsWith(rootPrefix) ? filePath.slice(rootPrefix.length) : filePath + ) + ) + + return { + allFiles: appFiles, + fileSet: new Set(appFiles), + readText: async (filePath: string) => readText(joinRoot(filePath)), + readDataUrl: async (filePath: string) => { + const normalizedPath = this.normalizeVirtualPath(filePath) + const zipFile = zip.file(joinRoot(normalizedPath)) + if (!zipFile) return null + + const mimeType = this.getMimeTypeByAssetPath(normalizedPath) + const base64Content = await zipFile.async('base64') + return `data:${mimeType};base64,${base64Content}` + } + } + } + + private async unzipZipBufferToAppDirectory(zipBuffer: ArrayBuffer | Uint8Array | Buffer): Promise { + 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) => { + if (relPath.startsWith('__MACOSX/')) return + const outPath = path.join(tmpBase, relPath) + if (file.dir) { + writeTasks.push(fs.mkdir(outPath, { recursive: true })) + return + } + + 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) + + const topLevels = new Set( + fileEntries + .map((filePath) => filePath.split('/')[0]) + .filter((segment) => !!segment && segment !== '.' && segment !== '..') + ) + + if (topLevels.size === 1) { + return path.join(tmpBase, [...topLevels][0]) + } + + return tmpBase + } + + private async createModuleContextFromFileList(files: FileList): Promise { + const fileArray = Array.from(files) + const readText = async (file: File) => + await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsText(file) + }) + + let relevantFiles = fileArray + const gitignoreFile = fileArray.find((file) => file.webkitRelativePath.endsWith('/.gitignore')) + + if (gitignoreFile) { + const gitignoreContent = await readText(gitignoreFile) + const rootDir = gitignoreFile.webkitRelativePath.split('/')[0] + const filter = this.createGitignoreFilter(gitignoreContent) + + relevantFiles = fileArray.filter((file) => { + const relativePath = file.webkitRelativePath.slice(rootDir.length + 1) + return relativePath && filter(relativePath) + }) + } else { + 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])) + + return { + 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 + }, + readDataUrl: async (filePath: string) => { + const file = filesByPath.get(this.normalizeVirtualPath(filePath)) + if (!file) return null + + return await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) + }).catch(() => null) + } + } + } + + // Convert a full app directory (e.g., test/full/input/appdemo01) into an aggregated schema.json + async convertAppDirectory(appDir: string): Promise { + const moduleContext = await this.createModuleContextFromAppDirectory(appDir) + return await this.buildAppSchemaFromModuleContext(moduleContext) + } + + 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 moduleContext = await this.createModuleContextFromBrowserZip(zipBuffer) + return await this.buildAppSchemaFromModuleContext(moduleContext) + } + + const appRoot = await this.unzipZipBufferToAppDirectory(zipBuffer) + return await this.convertAppDirectory(appRoot) + } + + async convertAppFromDirectory(files: FileList): Promise { + const moduleContext = await this.createModuleContextFromFileList(files) + return await this.buildAppSchemaFromModuleContext(moduleContext) + } +} 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..cd3b3582fe --- /dev/null +++ b/packages/vue-to-dsl/src/generator/index.ts @@ -0,0 +1,575 @@ +import { parse as babelParse } from '@babel/parser' +import { defaultComponentsMap } from '../constants' + +function parseFunctionExpression(functionCode: string) { + if (!functionCode || typeof functionCode !== 'string') return null + + try { + const wrappedCode = `(${functionCode})` + const ast: any = babelParse(wrappedCode, { + sourceType: 'module', + plugins: ['typescript', 'jsx'] + }) + const expression = ast?.program?.body?.[0]?.expression + if (!expression) return null + + return { + wrappedCode, + expression + } + } catch (_error) { + return null + } +} + +function getReturnedExpression(functionCode: string) { + const parsed = parseFunctionExpression(functionCode) + const expression = parsed?.expression + + if (!expression) return null + + if (expression.type === 'ArrowFunctionExpression') { + if (expression.body?.type === 'BlockStatement') { + const returnStatement = expression.body.body.find( + (item: any) => item?.type === 'ReturnStatement' && item.argument + ) + return returnStatement?.argument || null + } + return expression.body || null + } + + if (expression.body?.type === 'BlockStatement') { + const returnStatement = expression.body.body.find((item: any) => item?.type === 'ReturnStatement' && item.argument) + return returnStatement?.argument || null + } + + return null +} + +function getNodeSource(code: string, node: any) { + if (!code || !node || typeof node.start !== 'number' || typeof node.end !== 'number') { + return '' + } + + return code.slice(node.start, node.end) +} + +function applySourceReplacements(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 collectComputedReturnReplacements( + node: any, + key: string, + wrappedCode: string, + replacements: Array<{ start: number; end: number; text: string }> +) { + if (!node) return + + switch (node.type) { + case 'BlockStatement': + node.body?.forEach((statement: any) => + collectComputedReturnReplacements(statement, key, wrappedCode, replacements) + ) + return + case 'ReturnStatement': { + if (!node.argument) return + const returnedCode = getNodeSource(wrappedCode, node.argument).trim() + if (!returnedCode) return + + replacements.push({ + start: node.start, + end: node.end, + text: `this.state.${key} = ${returnedCode}; return` + }) + return + } + case 'IfStatement': + collectComputedReturnReplacements(node.consequent, key, wrappedCode, replacements) + collectComputedReturnReplacements(node.alternate, key, wrappedCode, replacements) + return + case 'ForStatement': + case 'ForInStatement': + case 'ForOfStatement': + case 'WhileStatement': + case 'DoWhileStatement': + case 'LabeledStatement': + case 'WithStatement': + collectComputedReturnReplacements(node.body, key, wrappedCode, replacements) + return + case 'SwitchStatement': + node.cases?.forEach((caseNode: any) => { + caseNode.consequent?.forEach((statement: any) => + collectComputedReturnReplacements(statement, key, wrappedCode, replacements) + ) + }) + return + case 'TryStatement': + collectComputedReturnReplacements(node.block, key, wrappedCode, replacements) + collectComputedReturnReplacements(node.handler?.body, key, wrappedCode, replacements) + collectComputedReturnReplacements(node.finalizer, key, wrappedCode, replacements) + return + default: + return + } +} + +function buildComputedGetterStatements(key: string, wrappedCode: string, statements: any[]) { + const replacements: Array<{ start: number; end: number; text: string }> = [] + statements.forEach((statement: any) => collectComputedReturnReplacements(statement, key, wrappedCode, replacements)) + + if (!replacements.length) { + return statements + .map((statement: any) => getNodeSource(wrappedCode, statement)) + .filter(Boolean) + .join('\n') + } + + const bodyStart = statements[0]?.start + const bodyEnd = statements[statements.length - 1]?.end + const bodyCode = getNodeSource(wrappedCode, { start: bodyStart, end: bodyEnd }) + const relativeReplacements = replacements + .filter((item) => typeof item.start === 'number' && typeof item.end === 'number') + .map((item) => ({ + start: item.start - bodyStart, + end: item.end - bodyStart, + text: item.text + })) + + const getterBody = applySourceReplacements(bodyCode, relativeReplacements) + + return getterBody +} + +function getStateReferenceName(node: any) { + if (!node || node.type !== 'MemberExpression' || node.computed) return '' + + if (node.object?.type === 'Identifier' && node.object.name === 'state' && node.property?.type === 'Identifier') { + return node.property.name + } + + if ( + node.object?.type === 'MemberExpression' && + !node.object.computed && + node.object.object?.type === 'ThisExpression' && + node.object.property?.type === 'Identifier' && + node.object.property.name === 'state' && + node.property?.type === 'Identifier' + ) { + return node.property.name + } + + return '' +} + +function convertLiteralAstToValue(node: any): any { + if (!node) return undefined + + switch (node.type) { + case 'StringLiteral': + case 'NumericLiteral': + case 'BooleanLiteral': + return node.value + case 'NullLiteral': + return null + case 'TemplateLiteral': + return node.expressions?.length ? undefined : node.quasis?.map((item: any) => item.value?.cooked || '').join('') + case 'ArrayExpression': { + const items: any[] = [] + for (const element of node.elements || []) { + if (!element) return undefined + const itemValue = convertLiteralAstToValue(element) + if (itemValue === undefined) return undefined + items.push(itemValue) + } + return items + } + case 'ObjectExpression': { + const result: Record = {} + for (const property of node.properties || []) { + if (property?.type !== 'ObjectProperty' || property.computed) return undefined + + const keyNode = property.key + const key = + keyNode?.type === 'Identifier' + ? keyNode.name + : keyNode?.type === 'StringLiteral' + ? keyNode.value + : keyNode?.type === 'NumericLiteral' + ? String(keyNode.value) + : '' + + if (!key) return undefined + + const propertyValue = convertLiteralAstToValue(property.value) + if (propertyValue === undefined) return undefined + result[key] = propertyValue + } + return result + } + default: + return undefined + } +} + +function inferDefaultValueFromExpression(node: any, knownDefaults: Map): any { + if (!node) return undefined + + switch (node.type) { + case 'ArrayExpression': + case 'ObjectExpression': { + const literalValue = convertLiteralAstToValue(node) + if (literalValue !== undefined) return literalValue + return node.type === 'ArrayExpression' ? [] : {} + } + case 'StringLiteral': + return node.value + case 'TemplateLiteral': + return node.expressions?.length ? '' : node.quasis?.map((item: any) => item.value?.cooked || '').join('') + case 'NumericLiteral': + return node.value + case 'BooleanLiteral': + return node.value + case 'NullLiteral': + return null + case 'UnaryExpression': + if (node.operator === '!' || node.operator === 'delete') return false + if (node.operator === '-' && node.argument?.type === 'NumericLiteral') return -node.argument.value + return inferDefaultValueFromExpression(node.argument, knownDefaults) + case 'MemberExpression': { + if (!node.computed && node.property?.type === 'Identifier' && node.property.name === 'length') { + return 0 + } + const stateKey = getStateReferenceName(node) + if (stateKey && knownDefaults.has(stateKey)) { + return knownDefaults.get(stateKey) + } + return undefined + } + case 'CallExpression': { + if ( + node.callee?.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property?.type === 'Identifier' + ) { + const methodName = node.callee.property.name + if (['filter', 'map', 'slice', 'concat', 'flat', 'flatMap'].includes(methodName)) return [] + if (['trim', 'toLowerCase', 'toUpperCase', 'substring', 'substr'].includes(methodName)) return '' + if (['includes', 'startsWith', 'endsWith', 'some', 'every'].includes(methodName)) return false + } + return undefined + } + case 'ConditionalExpression': { + const consequent = inferDefaultValueFromExpression(node.consequent, knownDefaults) + const alternate = inferDefaultValueFromExpression(node.alternate, knownDefaults) + if (Array.isArray(consequent) && Array.isArray(alternate)) return [] + if ( + consequent && + alternate && + typeof consequent === 'object' && + typeof alternate === 'object' && + !Array.isArray(consequent) && + !Array.isArray(alternate) + ) { + return {} + } + if (typeof consequent === 'number' && typeof alternate === 'number') return 0 + if (typeof consequent === 'string' && typeof alternate === 'string') return '' + if (typeof consequent === 'boolean' && typeof alternate === 'boolean') return false + return consequent !== undefined ? consequent : alternate + } + case 'LogicalExpression': { + const left = inferDefaultValueFromExpression(node.left, knownDefaults) + const right = inferDefaultValueFromExpression(node.right, knownDefaults) + if (node.operator === '||') return left !== undefined ? left : right + if (node.operator === '&&') return right !== undefined ? right : left + return undefined + } + case 'BinaryExpression': + return ['===', '!==', '==', '!=', '>', '>=', '<', '<='].includes(node.operator) ? false : 0 + default: + return undefined + } +} + +function inferComputedDefaultValue(functionCode: string, knownDefaults: Map) { + const returnedExpression = getReturnedExpression(functionCode) + return inferDefaultValueFromExpression(returnedExpression, knownDefaults) +} + +function stabilizeComputedGetterCode(code: string) { + if (!code) return code + + return code.replace( + /\b(this\.state\.([A-Za-z_$][\w$]*))\.(reduce|filter|map|slice|concat|flat|flatMap|some|every|find|findIndex)\(/g, + '($1 || []).$3(' + ) +} + +function buildComputedGetterValue(key: string, computedValue: string) { + const parsed = parseFunctionExpression(computedValue) + const expression = parsed?.expression + const wrappedCode = parsed?.wrappedCode || '' + const safeComputedValue = stabilizeComputedGetterCode(computedValue) + const fallbackValue = `function getter() { this.state.${key} = (${safeComputedValue}).call(this) }` + + if (!expression) { + return fallbackValue + } + + if (expression.type === 'ArrowFunctionExpression' && expression.body?.type !== 'BlockStatement') { + const returnedCode = stabilizeComputedGetterCode(getNodeSource(wrappedCode, expression.body).trim()) + + return returnedCode ? `function getter() { this.state.${key} = ${returnedCode} }` : fallbackValue + } + + if (expression.body?.type !== 'BlockStatement') { + return fallbackValue + } + + const statements = expression.body.body + if (!statements.length) { + return fallbackValue + } + + const getterStatements = buildComputedGetterStatements(key, wrappedCode, statements) + + if (!getterStatements || !String(getterStatements).trim()) { + return fallbackValue + } + + return `function getter() { ${stabilizeComputedGetterCode(String(getterStatements))} }` +} +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 transformComputedToState(computed: Record) { + const result: Record = {} + const inferredDefaults = new Map() + + Object.keys(computed || {}).forEach((key) => { + const computedItem = computed[key] + const computedValue = + typeof computedItem === 'object' && computedItem.value + ? computedItem.value + : typeof computedItem === 'string' + ? computedItem + : 'function() { return undefined }' + const defaultValue = inferComputedDefaultValue(computedValue, inferredDefaults) + + inferredDefaults.set(key, defaultValue) + + result[key] = { + defaultValue, + accessor: { + getter: { + type: 'JSFunction', + value: buildComputedGetterValue(key, computedValue) + } + } + } + }) + + 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.computed) { + schema.state = { + ...(schema.state || {}), + ...transformComputedToState(scriptSchema.computed) + } + } + 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 (Array.isArray(scriptSchema.emits) && scriptSchema.emits.length > 0) schema.emits = [...scriptSchema.emits] + } + 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 || [], + assets: options.assets || [], + 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..8bb012dc81 --- /dev/null +++ b/packages/vue-to-dsl/src/index.d.ts @@ -0,0 +1,157 @@ +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[] + scriptSchema?: ScriptSchema + componentsMap?: Array> + } + + 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..796b1ac2c0 --- /dev/null +++ b/packages/vue-to-dsl/src/parsers/scriptParser.ts @@ -0,0 +1,2250 @@ +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 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 createExpressionValue(node: any, source?: string) { + const value = source ? sanitizeCodeFromNode(node, source) : '' + + if (!value) return 'undefined' + + return { + type: 'JSExpression', + value + } +} + +function getNodeValue(node: any, source = ''): 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.isTemplateLiteral(node)) { + if ((node.expressions?.length ?? 0) === 0) { + return node.quasis.map((item: any) => item.value.cooked).join('') + } + + return createExpressionValue(node, source) + } + if (t.isCallExpression(node)) { + return createExpressionValue(node, source) + } + 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, source) + } + }) + return obj + } + if (t.isArrayExpression(node)) { + return node.elements.map((el: any) => (el ? getNodeValue(el, source) : null)) + } + return 'undefined' +} + +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 ensureRuntimeAliasRegistry(result: any) { + if (!result.runtimeAliases || typeof result.runtimeAliases !== 'object') { + result.runtimeAliases = { + router: [], + route: [], + nextTick: [] + } + } + + return result.runtimeAliases +} + +function addRuntimeAlias(result: any, target: 'router' | 'route' | 'nextTick', localName: string) { + if (!localName) return + + const registry = ensureRuntimeAliasRegistry(result) + const current = Array.isArray(registry[target]) ? registry[target] : [] + + if (!current.includes(localName)) { + registry[target] = [...current, localName] + } +} + +function getImportedLocalNames(result: any, source: string, imported: string) { + const names = new Set() + + ;(result?.imports || []).forEach((imp: any) => { + if (imp?.source !== source) return + ;(imp.specifiers || []).forEach((spec: any) => { + if (spec?.imported === imported && spec?.local) { + names.add(spec.local) + } + }) + }) + + return names +} + +function isImportedCallExpression(init: any, result: any, source: string, imported: string) { + if (!t.isCallExpression(init) || !t.isIdentifier(init.callee)) return false + + return getImportedLocalNames(result, source, imported).has(init.callee.name) +} + +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 || {})) + const runtimeAliases = result?.runtimeAliases || {} + const routerNames = new Set(runtimeAliases.router || []) + const routeNames = new Set(runtimeAliases.route || []) + const nextTickNames = new Set([ + ...(runtimeAliases.nextTick || []), + ...getImportedLocalNames(result, 'vue', 'nextTick') + ]) + + return { + propNames, + stateNames, + refStateNames, + methodNames, + computedNames, + routerNames, + routeNames, + nextTickNames, + 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 (name === '$router') return 'this.router' + if (name === '$route') return 'this.route' + if (context.routerNames.has(name)) return 'this.router' + if (context.routeNames.has(name)) return 'this.route' + + if (context.propNames.has(name)) return `this.props.${name}` + if (context.stateNames.has(name)) return `this.state.${name}` + if (context.methodNames.has(name)) return `this.${name}` + if (context.computedNames.has(name)) return `this.state.${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 buildNextTickReplacement = (args: any[] = []) => { + const callback = args[0] + + if (!callback) { + return 'Promise.resolve()' + } + + const callbackCode = sanitizeCodeFromNode(callback, code) + const rewrittenCallback = rewriteScriptContextInCode(callbackCode, result, localNames) + + return `Promise.resolve().then(${rewrittenCallback})` + } + const isRewritableIdentifier = (path: any) => { + if (path.isReferencedIdentifier()) return true + + return path.parentPath?.isAssignmentPattern?.() && path.parent?.right === path.node + } + + 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, { + CallExpression(path: any) { + const callee = path.node.callee + const args = path.node.arguments || [] + + if (t.isIdentifier(callee) && context.nextTickNames.has(callee.name)) { + const binding = path.scope.getBinding(callee.name) + if (!binding || binding.kind === 'module') { + pushReplacement(path.node.start, path.node.end, buildNextTickReplacement(args)) + path.skip() + return + } + } + + if ( + t.isMemberExpression(callee) && + !callee.computed && + t.isThisExpression(callee.object) && + t.isIdentifier(callee.property) && + callee.property.name === '$nextTick' + ) { + pushReplacement(path.node.start, path.node.end, buildNextTickReplacement(args)) + path.skip() + } + }, + MemberExpression(path: any) { + if ( + !path.node.computed && + t.isThisExpression(path.node.object) && + t.isIdentifier(path.node.property) && + path.node.property.name === '$router' + ) { + pushReplacement(path.node.start, path.node.end, 'this.router') + return + } + + if ( + !path.node.computed && + t.isThisExpression(path.node.object) && + t.isIdentifier(path.node.property) && + path.node.property.name === '$route' + ) { + pushReplacement(path.node.start, path.node.end, 'this.route') + return + } + + 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 = `this.state.${refLikeName}` + pushReplacement(path.node.start, path.node.end, replacement) + }, + Identifier(path: any) { + if (!isRewritableIdentifier(path)) 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 resolveStateRuntimeValue(value: any): any { + if (Array.isArray(value)) { + return value.map((item) => resolveStateRuntimeValue(item)) + } + + if (!value || typeof value !== 'object') { + return value + } + + if (value.type === 'JSExpression' || value.type === 'JSFunction' || value.type === 'JSSlot') { + return undefined + } + + if (Object.prototype.hasOwnProperty.call(value, 'type') && Object.prototype.hasOwnProperty.call(value, 'value')) { + return resolveStateRuntimeValue(value.value) + } + + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveStateRuntimeValue(item)])) +} + +function createStateEvaluationContext(result: any) { + const context: Record = {} + + Object.entries(result?.state || {}).forEach(([key, entry]: [string, any]) => { + const resolvedValue = resolveStateRuntimeValue(entry) + + if (entry?.type === 'ref') { + context[key] = { value: resolvedValue } + return + } + + context[key] = resolvedValue + }) + + return context +} + +function createMethodEvaluationContext(result: any, stateContext: Record) { + const methodEntries = Object.entries(result?.methods || {}) + const context: Record = {} + const pendingMethods = new Map() + + methodEntries.forEach(([key, entry]: [string, any]) => { + const code = typeof entry === 'string' ? entry : entry?.value + if (typeof code === 'string' && code.trim()) { + pendingMethods.set(key, code) + } + }) + + let changed = true + while (pendingMethods.size > 0 && changed) { + changed = false + + for (const [key, code] of Array.from(pendingMethods.entries())) { + const runtimeContext = { ...stateContext, ...context } + const argNames = Object.keys(runtimeContext) + const argValues = Object.values(runtimeContext) + + try { + const evaluator = new Function(...argNames, `"use strict"; return (${code});`) + const methodValue = evaluator(...argValues) + + if (typeof methodValue === 'function') { + context[key] = methodValue + pendingMethods.delete(key) + changed = true + } + } catch { + // Skip methods whose dependencies are not ready yet; they can be retried in the next pass. + } + } + } + + return context +} + +function hasStateInitializerDependency(node: any, source: string, result: any) { + const code = sanitizeCodeFromNode(node, source) + const stateNames = new Set(Object.keys(result?.state || {})) + const methodNames = new Set(Object.keys(result?.methods || {})) + + if (!code || (stateNames.size === 0 && methodNames.size === 0)) return false + + try { + const ast = parse(`(${code})`, { sourceType: 'module', plugins: ['typescript', 'jsx'] as any }) + let hasDependency = false + + traverse(ast as any, { + Identifier(path: any) { + if (hasDependency || !path.isReferencedIdentifier()) return + + const { node, parent } = path + const name = node?.name + if (!name || (!stateNames.has(name) && !methodNames.has(name))) return + + 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 + } + + hasDependency = true + path.stop() + } + }) + + return hasDependency + } catch { + return false + } +} + +function tryEvaluateStateExpression(node: any, source: string, result: any) { + const code = sanitizeCodeFromNode(node, source) + if (!code) return { ok: false } + if (!hasStateInitializerDependency(node, source, result)) return { ok: false } + + const stateContext = createStateEvaluationContext(result) + const methodContext = createMethodEvaluationContext(result, stateContext) + const context = { ...stateContext, ...methodContext } + const argNames = Object.keys(context) + const argValues = Object.values(context) + + try { + const evaluator = new Function(...argNames, `"use strict"; return (${code});`) + return { + ok: true, + value: evaluator(...argValues) + } + } catch { + return { ok: false } + } +} + +function getStateInitializerFallbackValue(node: any, source: string) { + const fallbackValue = getNodeValue(node, source) + + if (fallbackValue !== 'undefined') return fallbackValue + if (!t.isExpression(node) || t.isIdentifier(node)) return fallbackValue + + return createExpressionValue(node, source) +} + +function getStateInitializerValue(node: any, source: string, result: any): any { + const evaluated = tryEvaluateStateExpression(node, source, result) + if (evaluated.ok) return evaluated.value + + return getStateInitializerFallbackValue(node, source) +} + +function rewriteNestedStateValue(value: any, result: any, localNames: string[] = [], rewriteExpressions = true): any { + if (Array.isArray(value)) { + return value.map((item) => rewriteNestedStateValue(item, result, localNames, rewriteExpressions)) + } + + if (!value || typeof value !== 'object') { + return value + } + + if (value.type === 'JSExpression' && typeof value.value === 'string') { + if (!rewriteExpressions) return value + return { + ...value, + value: rewriteScriptContextInCode(value.value, result, localNames) + } + } + + if (value.type === 'JSFunction' && typeof value.value === 'string') { + if (!rewriteExpressions) return value + 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], true) + } + } + + const output: Record = Array.isArray(value) ? [] : {} + Object.keys(value).forEach((key) => { + output[key] = rewriteNestedStateValue(value[key], result, localNames, rewriteExpressions) + }) + return output +} + +function rewriteScriptContextInResult(result: any) { + result.state = rewriteNestedStateValue(result.state || {}, result, [], false) + result.methods = rewriteScriptContextInEntries(result.methods || {}, result) + result.computed = rewriteScriptContextInEntries(result.computed || {}, result) + result.lifeCycles = rewriteScriptContextInEntries(result.lifeCycles || {}, 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 functionParamToCode(node: any, source: string): string { + if (t.isAssignmentPattern(node)) { + return `${functionParamToCode(node.left, source)} = ${sanitizeCodeFromNode(node.right, source)}` + } + + if (t.isRestElement(node)) { + return `...${functionParamToCode(node.argument, source)}` + } + + return sanitizeCodeFromNode(node, source) +} + +function arrowToFunctionString(name: string, node: any, source: string) { + const asyncStr = node.async ? 'async ' : '' + const params = node.params.map((p) => functionParamToCode(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: any, source: string) { + const asyncStr = (node as any).async ? 'async ' : '' + const params = (node as any).params.map((p: any) => functionParamToCode(p, source)).join(', ') + const body = sanitizeCodeFromNode((node as any).body, source) + return `${asyncStr}function ${name}(${params}) ${body}` +} + +function functionDeclarationToNamedFunctionString(name: string, node: any, source: string) { + const asyncStr = node.async ? 'async ' : '' + const params = node.params.map((p) => functionParamToCode(p, source)).join(', ') + const body = sanitizeCodeFromNode(node.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 + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/package.json b/packages/vue-to-dsl/test/full/input/appdemo01/package.json new file mode 100644 index 0000000000..6f3161e7ca --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/package.json @@ -0,0 +1,29 @@ +{ + "name": "portal-app", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "dependencies": { + "@opentiny/tiny-engine-i18n-host": "^1.0.0", + "@opentiny/vue": "3.24.0", + "@opentiny/vue-icon": "3.24.0", + "axios": "latest", + "axios-mock-adapter": "^1.19.0", + "vue": "^3.3.9", + "vue-i18n": "^9.2.0-beta.3", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "@opentiny/tiny-engine-builtin-component": "^2.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.2", + "@vitejs/plugin-vue-jsx": "^4.0.1", + "vite": "^5.4.2" + } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/public/favicon.ico b/packages/vue-to-dsl/test/full/input/appdemo01/public/favicon.ico new file mode 100644 index 0000000000..6271b2d812 Binary files /dev/null and b/packages/vue-to-dsl/test/full/input/appdemo01/public/favicon.ico differ diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/App.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/App.vue new file mode 100644 index 0000000000..72b6032dea --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/http/axios.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/axios.js new file mode 100644 index 0000000000..3654c619b4 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/axios.js @@ -0,0 +1,143 @@ +/** + * 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 axios from 'axios' +import MockAdapter from 'axios-mock-adapter' + +export default (config) => { + const instance = axios.create(config) + const defaults = {} + let mock + + if (typeof MockAdapter.prototype.proxy === 'undefined') { + MockAdapter.prototype.proxy = function ({ url, config = {}, proxy, response, handleData } = {}) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let stream = this + const request = (proxy, any) => { + return (setting) => { + return new Promise((resolve) => { + config.responseType = 'json' + axios + .get(any ? proxy + setting.url + '.json' : proxy, config) + .then(({ data }) => { + if (typeof handleData === 'function') { + data = handleData.call(null, data, setting) + } + resolve([200, data]) + }) + .catch((error) => { + resolve([error.response.status, error.response.data]) + }) + }) + } + } + + if (url === '*' && proxy && typeof proxy === 'string') { + stream = proxy === '*' ? this.onAny().passThrough() : this.onAny().reply(request(proxy, true)) + } else { + if (proxy && typeof proxy === 'string') { + stream = this.onAny(url).reply(request(proxy)) + } else if (typeof response === 'function') { + stream = this.onAny(url).reply(response) + } + } + + return stream + } + } + + return { + request(config) { + return instance(config) + }, + get(url, config) { + return instance.get(url, config) + }, + delete(url, config) { + return instance.delete(url, config) + }, + head(url, config) { + return instance.head(url, config) + }, + post(url, data, config) { + return instance.post(url, data, config) + }, + put(url, data, config) { + return instance.put(url, data, config) + }, + patch(url, data, config) { + return instance.patch(url, data, config) + }, + all(iterable) { + return axios.all(iterable) + }, + spread(callback) { + return axios.spread(callback) + }, + defaults(key, value) { + if (key && typeof key === 'string') { + if (typeof value === 'undefined') { + return instance.defaults[key] + } + instance.defaults[key] = value + defaults[key] = value + } else { + return instance.defaults + } + }, + defaultSettings() { + return defaults + }, + interceptors: { + request: { + use(fnHandle, fnError) { + return instance.interceptors.request.use(fnHandle, fnError) + }, + eject(id) { + return instance.interceptors.request.eject(id) + } + }, + response: { + use(fnHandle, fnError) { + return instance.interceptors.response.use(fnHandle, fnError) + }, + eject(id) { + return instance.interceptors.response.eject(id) + } + } + }, + mock(config) { + if (!mock) { + mock = new MockAdapter(instance) + } + + if (Array.isArray(config)) { + config.forEach((item) => { + mock.proxy(item) + }) + } + + return mock + }, + disableMock() { + if (mock) { + mock.restore() + } + mock = undefined + }, + isMock() { + return typeof mock !== 'undefined' + }, + CancelToken: axios.CancelToken, + isCancel: axios.isCancel + } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/http/config.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/config.js new file mode 100644 index 0000000000..cfa3714e17 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/config.js @@ -0,0 +1,15 @@ +/** + * 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. + * + */ + +export default { + withCredentials: false +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/http/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/index.js new file mode 100644 index 0000000000..b0a08546a6 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/http/index.js @@ -0,0 +1,27 @@ +/** + * 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 axios from './axios' +import config from './config' + +export default (dataHandler) => { + const http = axios(config) + + http.interceptors.response.use(dataHandler, (error) => { + const response = error.response + if (response.status === 403 && response.headers && response.headers['x-login-url']) { + // TODO 处理无权限时,重新登录再发送请求 + } + }) + + return http +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/en_US.json b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/en_US.json new file mode 100644 index 0000000000..be5c684e5e --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/en_US.json @@ -0,0 +1,25 @@ +{ + "lowcode.c257d5e8": "search", + "lowcode.61c8ac8c": "dsdsa", + "lowcode.f53187a0": "test", + "lowcode.97ad00dd": "createMaterial", + "lowcode.61dcef52": "sadasda", + "lowcode.45f4c42a": "gfdgfd", + "lowcode.c6f5a652": "fsdafds", + "lowcode.34923432": "fdsafds", + "lowcode.6534943e": "fdsafdsa", + "lowcode.44252642": "aaaa", + "lowcode.2a743651": "fdsaf", + "lowcode.24315357": "fsdafds", + "lowcode.44621691": "sd", + "lowcode.65636226": "fdsfsd", + "lowcode.6426a4e2": "fdsafsd", + "lowcode.e41c6636": "aa", + "lowcode.51c23164": "aa", + "lowcode.17245b46": "aa", + "lowcode.4573143c": "a", + "lowcode.56432442": "aa", + "lowcode.33566643": "aa", + "lowcode.565128f3": "aa", + "lowcode.56643835": "aa" +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/index.js new file mode 100644 index 0000000000..f6c510b279 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/index.js @@ -0,0 +1,9 @@ +import i18n from '@opentiny/tiny-engine-i18n-host' +import lowcode from '../lowcodeConfig/lowcode' +import locale from './locale.js' + +i18n.lowcode = lowcode +i18n.global.mergeLocaleMessage('en_US', locale.en_US) +i18n.global.mergeLocaleMessage('zh_CN', locale.zh_CN) + +export default i18n diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/locale.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/locale.js new file mode 100644 index 0000000000..75308fc752 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/locale.js @@ -0,0 +1,4 @@ +import en_US from './en_US.json' +import zh_CN from './zh_CN.json' + +export default { en_US, zh_CN } diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/zh_CN.json b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/zh_CN.json new file mode 100644 index 0000000000..59357fdfcc --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/i18n/zh_CN.json @@ -0,0 +1,26 @@ +{ + "lowcode.c257d5e8": "查询", + "lowcode.61c8ac8c": "地方", + "lowcode.f53187a0": "测试", + "lowcode.97ad00dd": "创建物料资产包", + "lowcode.61dcef52": "terterere", + "lowcode.45f4c42a": "gdfgdf", + "lowcode.c6f5a652": "fsdaf", + "lowcode.34923432": "fdsafdsa", + "lowcode.48521e45": "fdsfds", + "lowcode.6534943e": "fdsafds", + "lowcode.44252642": "fdsafds", + "lowcode.2a743651": "sda", + "lowcode.24315357": "fdsafds", + "lowcode.44621691": "fdsafsd", + "lowcode.65636226": "fdsaf", + "lowcode.6426a4e2": "sd", + "lowcode.e41c6636": "aa", + "lowcode.51c23164": "aa", + "lowcode.17245b46": "aa", + "lowcode.4573143c": "aa", + "lowcode.56432442": "aa", + "lowcode.33566643": "aa", + "lowcode.565128f3": "aa", + "lowcode.56643835": "aa" +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/bridge.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/bridge.js new file mode 100644 index 0000000000..7a19e4a116 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/bridge.js @@ -0,0 +1,13 @@ +/** + * 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. + * + */ + +export default () => {} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.js new file mode 100644 index 0000000000..f82f146bfc --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.js @@ -0,0 +1,104 @@ +/** + * 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 useHttp from '../http' +import dataSources from './dataSource.json' + +const dataSourceMap = {} + +// 暂时使用 eval 解析 JSON 数据里的函数 +const createFn = (fnContent) => { + return (...args) => { + // eslint-disable-next-line no-eval + window.eval('var fn = ' + fnContent) + // eslint-disable-next-line no-undef + return fn.apply(this, args) + } +} + +const globalDataHandle = dataSources.dataHandler ? createFn(dataSources.dataHandler.value) : (res) => res + +const load = (http, options, dataSource, shouldFetch) => (params, customUrl) => { + // 如果没有配置远程请求,则直接返回静态数据,返回前可能会有全局数据处理 + if (!options) { + return globalDataHandle(dataSource.config.data) + } + + if (!shouldFetch()) { + return + } + + dataSource.status = 'loading' + + const { method, uri: url, params: defaultParams, timeout, headers } = options + const config = { method, url, headers, timeout } + + const data = params || defaultParams + + config.url = customUrl || config.url + + if (method.toLowerCase() === 'get') { + config.params = data + } else { + config.data = data + } + + return http.request(config) +} + +dataSources.list.forEach((config) => { + const http = useHttp(globalDataHandle) + const dataSource = { config } + + dataSourceMap[config.name] = dataSource + + const shouldFetch = config.shouldFetch?.value ? createFn(config.shouldFetch.value) : () => true + const willFetch = config.willFetch?.value ? createFn(config.willFetch.value) : (options) => options + + const dataHandler = (res) => { + const data = config.dataHandler?.value ? createFn(config.dataHandler.value)(res) : res + dataSource.status = 'loaded' + dataSource.data = data + return data + } + + const errorHandler = (error) => { + if (config.errorHandler?.value) { + createFn(config.errorHandler.value)(error) + } + dataSource.status = 'error' + dataSource.error = error + } + + http.interceptors.request.use(willFetch, errorHandler) + http.interceptors.response.use(dataHandler, errorHandler) + + if (import.meta.env.VITE_APP_MOCK === 'mock') { + http.mock([ + { + url: config.options?.uri, + response() { + return Promise.resolve([200, { data: config.data }]) + } + }, + { + url: '*', + proxy: '*' + } + ]) + } + + dataSource.status = 'init' + dataSource.load = load(http, config.options, dataSource, shouldFetch) +}) + +export default dataSourceMap diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.json b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.json new file mode 100644 index 0000000000..73ff9cb058 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/dataSource.json @@ -0,0 +1,632 @@ +{ + "list": [ + { + "id": 132, + "name": "getAllComponent", + "data": [], + "type": "array" + }, + { + "id": 133, + "name": "getAllList", + "columns": [ + { + "name": "test", + "title": "测试", + "field": "test", + "type": "string", + "format": {} + }, + { + "name": "test1", + "title": "测试1", + "field": "test1", + "type": "string", + "format": {} + } + ], + "type": "array", + "data": [ + { + "test": "test1", + "test1": "test1", + "_id": "341efc48" + }, + { + "test": "test2", + "test1": "test1", + "_id": "b86b516c" + }, + { + "test": "test3", + "test1": "test1", + "_id": "f680cd78" + } + ], + "options": { + "uri": "", + "method": "GET" + }, + "dataHandler": { + "type": "JSFunction", + "value": "function dataHandler(data) { \n return data \n}" + }, + "willFetch": { + "type": "JSFunction", + "value": "function willFetch(option) {\n return option \n}" + }, + "shouldFetch": { + "type": "JSFunction", + "value": "function shouldFetch(option) {\n return true \n}" + }, + "errorHandler": { + "type": "JSFunction", + "value": "function errorHandler(err) {}" + } + }, + { + "id": 135, + "name": "getAllMaterialList", + "columns": [ + { + "name": "id", + "title": "id", + "field": "id", + "type": "string", + "format": {} + }, + { + "name": "name", + "title": "name", + "field": "name", + "type": "string", + "format": {} + }, + { + "name": "framework", + "title": "framework", + "field": "framework", + "type": "string", + "format": { + "required": true + } + }, + { + "name": "components", + "title": "components", + "field": "components", + "type": "string", + "format": {} + }, + { + "name": "content", + "title": "content", + "field": "content", + "type": "string", + "format": {} + }, + { + "name": "url", + "title": "url", + "field": "url", + "type": "string", + "format": {} + }, + { + "name": "published_at", + "title": "published_at", + "field": "published_at", + "type": "string", + "format": {} + }, + { + "name": "created_at", + "title": "created_at", + "field": "created_at", + "type": "string", + "format": {} + }, + { + "name": "updated_at", + "title": "updated_at", + "field": "updated_at", + "type": "string", + "format": {} + }, + { + "name": "published", + "title": "published", + "field": "published", + "type": "string", + "format": {} + }, + { + "name": "last_build_info", + "title": "last_build_info", + "field": "last_build_info", + "type": "string", + "format": {} + }, + { + "name": "tenant", + "title": "tenant", + "field": "tenant", + "type": "string", + "format": {} + }, + { + "name": "version", + "title": "version", + "field": "version", + "type": "string", + "format": {} + }, + { + "name": "description", + "title": "description", + "field": "description", + "type": "string", + "format": {} + } + ], + "type": "array", + "data": [ + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "2a23e653" + }, + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "06b253be" + }, + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "c55a41ed" + }, + { + "id": "f37123ec", + "url": "", + "name": "ng-material", + "tenant": "", + "content": "", + "version": "1.0.0", + "framework": "Angular", + "published": "", + "components": "", + "created_at": "2021-11-02T11:32:22.000Z", + "updated_at": "2021-11-02T11:32:22.000Z", + "description": "angular组件库物料", + "published_at": "2021-11-02T11:32:22.000Z", + "last_build_info": "", + "_id": "f37123ec" + }, + { + "id": "7a63c1a2", + "url": "", + "name": "tiny-vue", + "tenant": "", + "content": "Tiny Vue物料", + "version": "1.0.0", + "framework": "Vue", + "published": "", + "components": "", + "created_at": "", + "updated_at": "", + "description": "Tiny Vue物料", + "published_at": "", + "last_build_info": "", + "_id": "7a63c1a2" + } + ], + "options": { + "uri": "", + "method": "GET" + }, + "willFetch": { + "type": "JSFunction", + "value": "function willFetch(option) {\n return option \n}" + }, + "dataHandler": { + "type": "JSFunction", + "value": "function dataHandler(data) { \n return data \n}" + }, + "shouldFetch": { + "type": "JSFunction", + "value": "function shouldFetch(option) {\n return true \n}" + }, + "errorHandler": { + "type": "JSFunction", + "value": "function errorHandler(err) {}" + } + }, + { + "id": 139, + "name": "treedata", + "data": [ + { + "label": "level111", + "value": "111", + "id": "f6609643", + "pid": "", + "_RID": "row_4" + }, + { + "label": "level1-son", + "value": "111-1", + "id": "af1f937f", + "pid": "f6609643", + "_RID": "row_5" + }, + { + "label": "level222", + "value": "222", + "id": "28e3709c", + "pid": "", + "_RID": "row_6" + }, + { + "label": "level2-son", + "value": "222-1", + "id": "6b571bef", + "pid": "28e3709c", + "_RID": "row_5" + }, + { + "id": "6317c2cc", + "pid": "fdfa", + "label": "fsdfaa", + "value": "fsadf", + "_RID": "row_6" + }, + { + "id": "9cce369f", + "pid": "test", + "label": "test1", + "value": "001" + } + ], + "type": "tree" + }, + { + "id": 150, + "name": "componentList", + "data": [ + { + "_RID": "row_1", + "name": "表单", + "isSelected": "true", + "description": "由按钮、输入框、选择器、单选框、多选框等控件组成,用以收集、校验、提交数据" + }, + { + "name": "按钮", + "isSelected": "false", + "description": "常用的操作按钮,提供包括默认按钮、图标按钮、图片按钮、下拉按钮等类型" + }, + { + "id": "490f8a00", + "_RID": "row_3", + "name": "表单项", + "framework": "", + "materials": "", + "description": "Form 组件下的 FormItem 配置" + }, + { + "id": "c259b8b3", + "_RID": "row_4", + "name": "开关", + "framework": "", + "materials": "", + "description": "关闭或打开" + }, + { + "id": "083ed9c7", + "_RID": "row_5", + "name": "互斥按钮组", + "framework": "", + "materials": "", + "description": "以按钮组的方式出现,常用于多项类似操作" + }, + { + "id": "09136cea", + "_RID": "row_6", + "name": "提示框", + "framework": "", + "materials": "", + "description": "Popover可通过对一个触发源操作触发弹出框,支持自定义弹出内容,延迟触发和渐变动画" + }, + { + "id": "a63b57d5", + "_RID": "row_7", + "name": "文字提示框", + "framework": "", + "materials": "", + "description": "动态显示提示信息,一般通过鼠标事件进行响应;提供 warning、error、info、success 四种类型显示不同类别的信" + }, + { + "id": "a0f6e8a3", + "_RID": "row_8", + "name": "树", + "framework": "", + "materials": "", + "description": "可进行展示有父子层级的数据,支持选择,异步加载等功能。但不推荐用它来展示菜单,展示菜单推荐使用树菜单" + }, + { + "id": "d1aa18fc", + "_RID": "row_9", + "name": "分页", + "framework": "", + "materials": "", + "description": "当数据量过多时,使用分页分解数据,常用于 Grid 和 Repeater 组件" + }, + { + "id": "ca49cc52", + "_RID": "row_10", + "name": "表格", + "framework": "", + "materials": "", + "description": "提供了非常强大数据表格功能,可以展示数据列表,可以对数据列表进行选择、编辑等" + }, + { + "id": "4e20ecc9", + "name": "搜索框", + "framework": "", + "materials": "", + "description": "指定条件对象进行搜索数据" + }, + { + "id": "6b093ee5", + "name": "折叠面板", + "framework": "", + "materials": "", + "description": "内容区可指定动态页面或自定义 html 等,支持展开收起操作" + }, + { + "id": "0a09abc0", + "name": "对话框", + "framework": "", + "materials": "", + "description": "模态对话框,在浮层中显示,引导用户进行相关操作" + }, + { + "id": "f814b901", + "name": "标签页签项", + "framework": "", + "materials": "", + "description": "tab页签" + }, + { + "id": "c5ae797c", + "name": "单选", + "framework": "", + "materials": "", + "description": "用于配置不同场景的选项,在一组备选项中进行单选" + }, + { + "id": "33d0c590", + "_RID": "row_13", + "name": "弹出编辑", + "framework": "", + "materials": "", + "description": "该组件只能在弹出的面板中选择数据,不能手动输入数据;弹出面板中显示为 Tree 组件或者 Grid 组件" + }, + { + "id": "16711dfa", + "_RID": "row_14", + "name": "下拉框", + "framework": "", + "materials": "", + "description": "Select 选择器是一种通过点击弹出下拉列表展示数据并进行选择的 UI 组件" + }, + { + "id": "a9fd190a", + "_RID": "row_15", + "name": "折叠面板项", + "framework": "", + "materials": "", + "description": "内容区可指定动态页面或自定义 html 等,支持展开收起操作" + }, + { + "id": "a7dfa9ec", + "_RID": "row_16", + "name": "复选框", + "framework": "", + "materials": "", + "description": "用于配置不同场景的选项,提供用户可在一组选项中进行多选" + }, + { + "id": "d4bb8330", + "name": "输入框", + "framework": "", + "materials": "", + "description": "通过鼠标或键盘输入字符" + }, + { + "id": "ced3dc83", + "name": "时间线", + "framework": "", + "materials": "", + "description": "时间线" + } + ], + "type": "array", + "columns": [ + { + "name": "name", + "type": "string", + "field": "name", + "title": "name", + "format": { + "max": 0, + "min": 0, + "dateTime": false, + "required": false, + "stringType": "" + } + }, + { + "name": "description", + "type": "string", + "field": "description", + "title": "description", + "format": { + "max": 0, + "min": 0, + "dateTime": false, + "required": false, + "stringType": "" + } + }, + { + "name": "isSelected", + "type": "string", + "field": "isSelected", + "title": "isSelected", + "format": { + "max": 0, + "min": 0, + "dateTime": false, + "required": false, + "stringType": "" + } + } + ], + "options": { + "uri": "http://localhost:9090/assets/json/bundle.json", + "method": "GET" + }, + "willFetch": { + "type": "JSFunction", + "value": "function willFetch(option) {\n return option \n}" + }, + "dataHandler": { + "type": "JSFunction", + "value": "function dataHandler(data) { \n return data \n}" + }, + "shouldFetch": { + "type": "JSFunction", + "value": "function shouldFetch(option) {\n return true \n}" + }, + "errorHandler": { + "type": "JSFunction", + "value": "function errorHandler(err) {}" + } + }, + { + "id": 151, + "name": "selectedComponents", + "columns": [ + { + "name": "name", + "title": "name", + "field": "name", + "type": "string", + "format": { + "required": false, + "stringType": "", + "min": 0, + "max": 0, + "dateTime": false + } + }, + { + "name": "description", + "title": "description", + "field": "description", + "type": "string", + "format": { + "required": false, + "stringType": "", + "min": 0, + "max": 0, + "dateTime": false + } + }, + { + "name": "isSelected", + "title": "isSelected", + "field": "isSelected", + "type": "string", + "format": { + "required": false, + "stringType": "", + "min": 0, + "max": 0, + "dateTime": false + } + } + ], + "type": "array", + "data": [ + { + "name": "标签页", + "description": "分隔内容上有关联但属于不同类别的数据集合", + "isSelected": "true", + "_RID": "row_2" + }, + { + "name": "布局列", + "description": "列配置信息", + "isSelected": "true", + "id": "76a7080a", + "_RID": "row_4" + }, + { + "name": "日期选择器", + "description": "用于设置/选择日期,包括年月/年月日/年月日时分/年月日时分秒日期格式", + "isSelected": "true", + "id": "76b20d73", + "_RID": "row_1" + }, + { + "name": "走马灯", + "description": "常用于一组图片或卡片轮播,当内容空间不足时,可以用走马灯的形式进行收纳,进行轮播展现", + "isSelected": "true", + "id": "4c884c3d" + } + ] + } + ], + "dataHandler": { + "type": "JSFunction", + "value": "function dataHanlder(res){\n return res;\n}" + } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/lowcode.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/lowcode.js new file mode 100644 index 0000000000..29da8186b5 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/lowcode.js @@ -0,0 +1,86 @@ +/** + * 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 { getCurrentInstance, nextTick, provide, inject } from 'vue' +import { useRouter, useRoute } from 'vue-router' +import { I18nInjectionKey } from 'vue-i18n' +import dataSourceMap from './dataSource' +import * as utils from '../utils' +import * as bridge from './bridge' +import { useStores } from './store' + +export const lowcodeWrap = (props, context) => { + const global = {} + const instance = getCurrentInstance() + const router = useRouter() + const route = useRoute() + const { t, locale } = inject(I18nInjectionKey).global + const emit = context.emit + const ref = (ref) => instance.refs[ref] + + const setState = (newState, callback) => { + Object.assign(global.state, newState) + nextTick(() => callback.apply(global)) + } + + const getLocale = () => locale.value + const setLocale = (val) => { + locale.value = val + } + + const location = () => window.location + const history = () => window.history + + Object.defineProperties(global, { + props: { get: () => props }, + emit: { get: () => emit }, + setState: { get: () => setState }, + router: { get: () => router }, + route: { get: () => route }, + i18n: { get: () => t }, + getLocale: { get: () => getLocale }, + setLocale: { get: () => setLocale }, + location: { get: location }, + history: { get: history }, + utils: { get: () => utils }, + bridge: { get: () => bridge }, + dataSourceMap: { get: () => dataSourceMap }, + $: { get: () => ref } + }) + + const wrap = (fn) => { + if (typeof fn === 'function') { + return (...args) => fn.apply(global, args) + } + + Object.entries(fn).forEach(([name, value]) => { + Object.defineProperty(global, name, { + get: () => value + }) + }) + + fn.t = t + + return fn + } + + return wrap +} + +export default () => { + const i18n = inject(I18nInjectionKey) + provide(I18nInjectionKey, i18n) + + const stores = useStores() + + return { t: i18n.global.t, lowcodeWrap, stores } +} diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/store.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/store.js new file mode 100644 index 0000000000..f7f39c7a84 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/lowcodeConfig/store.js @@ -0,0 +1,13 @@ +import * as useDefinedStores from '@/stores' + +const useStores = () => { + const stores = {} + + Object.values({ ...useDefinedStores }).forEach((store) => { + stores[store.$id] = store() + }) + + return stores +} + +export { useStores } diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/main.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/main.js new file mode 100644 index 0000000000..c4574461b3 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/main.js @@ -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 { createApp } from 'vue' +import router from './router' +import { createPinia } from 'pinia' +import App from './App.vue' + +const pinia = createPinia() + +createApp(App).use(pinia).use(router).mount('#app') diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/router/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/router/index.js new file mode 100644 index 0000000000..a06668a4c0 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/router/index.js @@ -0,0 +1,37 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +const routes = [ + { + path: '/', + children: [ + { + name: '1', + path: 'CreateVm', + component: () => import('@/views/CreateVm.vue'), + children: [] + }, + { + name: '1GhxcwoNeestd4aI', + path: 'Demopage', + component: () => import('@/views/DemoPage.vue'), + children: [] + }, + { + name: 'MQSQpz7noWlTRnse', + path: 'Lifecycle', + component: () => import('@/views/LifeCyclePage.vue'), + children: [] + }, + { + name: 'mPX398RIysZI3CRG', + path: 'UntitledA', + component: () => import('@/views/UntitledA.vue'), + children: [] + } + ] + } +] + +export default createRouter({ + history: createWebHashHistory(), + routes +}) diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/index.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/index.js new file mode 100644 index 0000000000..380fa26bf6 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/index.js @@ -0,0 +1 @@ +export { testState } from './testState' diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/testState.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/testState.js new file mode 100644 index 0000000000..c2312435e9 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/stores/testState.js @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia' +export const testState = defineStore({ + id: 'testState', + state: () => ({ + name: 'testName', + license: '', + age: 18, + food: ['apple', 'orange', 'banana', 19], + desc: { description: 'hello world', money: 100, other: '', rest: ['a', 'b', 'c', 20] } + }), + getters: { + getAge: function getAge() { + return this.age + }, + getName: function getName() { + return this.name + } + }, + actions: { + setAge: function setAge(age) { + this.age = age + }, + setName: function setName(name) { + this.name = name + } + } +}) diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/utils.js b/packages/vue-to-dsl/test/full/input/appdemo01/src/utils.js new file mode 100644 index 0000000000..42009b621d --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/utils.js @@ -0,0 +1,13 @@ +import axios from 'axios' +import { Button } from '@opentiny/vue' +import { NavMenu } from '@opentiny/vue' +import { Modal } from '@opentiny/vue' +import { Pager } from '@opentiny/vue' +const npm = '' +const test = function test() { + return 'test' +} +const util = function util() { + console.log(321) +} +export { axios, Button, NavMenu, Modal, npm, Pager, test, util } diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/DemoPage.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/DemoPage.vue new file mode 100644 index 0000000000..cecb287f05 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/DemoPage.vue @@ -0,0 +1,27 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/LifeCyclePage.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/LifeCyclePage.vue new file mode 100644 index 0000000000..8be8b629bf --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/LifeCyclePage.vue @@ -0,0 +1,60 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/UntitledA.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/UntitledA.vue new file mode 100644 index 0000000000..c9b57ed7cc --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/UntitledA.vue @@ -0,0 +1,38 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/src/views/createVm.vue b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/createVm.vue new file mode 100644 index 0000000000..ed8ec0b426 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/src/views/createVm.vue @@ -0,0 +1,442 @@ + + + + diff --git a/packages/vue-to-dsl/test/full/input/appdemo01/vite.config.js b/packages/vue-to-dsl/test/full/input/appdemo01/vite.config.js new file mode 100644 index 0000000000..b611ebc4a3 --- /dev/null +++ b/packages/vue-to-dsl/test/full/input/appdemo01/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import path from 'path' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + plugins: [vue(), vueJsx()], + define: { + 'process.env': { ...process.env } + }, + build: { + minify: true, + commonjsOptions: { + transformMixedEsModules: true + }, + cssCodeSplit: false + }, + base: './' +}) diff --git a/packages/vue-to-dsl/test/sfc/converter.test.js b/packages/vue-to-dsl/test/sfc/converter.test.js new file mode 100644 index 0000000000..b7a8cb4a4b --- /dev/null +++ b/packages/vue-to-dsl/test/sfc/converter.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { VueToDslConverter } from '../../src/converter' + +describe('VueToDslConverter', () => { + const converter = new VueToDslConverter({ computed_flag: true }) + + it('should convert simple Vue SFC to DSL', async () => { + const vueCode = ` + + + + + + ` + + const result = await converter.convertFromString(vueCode) + + expect(result.errors).toHaveLength(0) + expect(result.schema).toBeDefined() + expect(result.schema.componentName).toBe('Page') + expect(result.schema.state).toBeDefined() + expect(result.schema.methods).toBeDefined() + expect(result.schema.css).toBeDefined() + expect(result.schema.children).toBeDefined() + }) + + it('should handle Vue Options API', async () => { + const vueCode = ` + + + + ` + + const result = await converter.convertFromString(vueCode) + + expect(result.errors).toHaveLength(0) + expect(result.schema).toBeDefined() + expect(result.schema.state).toBeDefined() + expect(result.schema.methods).toBeDefined() + expect(result.schema.lifeCycles).toBeDefined() + }) + + it('should parse + ` + + const result = await converter.convertFromString(vueCode) + + expect(result.errors).toHaveLength(0) + expect(result.schema.state.count).toBeDefined() + // lifecycle hook stored with key 'onMounted' + expect(result.schema.lifeCycles.onMounted).toBeDefined() + expect(result.schema.methods.inc).toBeDefined() + }) + + it('should omit computed by default when flag is false', async () => { + const vueCode = ` + + + + ` + + const defaultConverter = new VueToDslConverter() + const result = await defaultConverter.convertFromString(vueCode) + expect(result.errors).toHaveLength(0) + expect(result.schema.computed).toBeUndefined() + }) +}) diff --git a/packages/vue-to-dsl/test/testcases/001_simple/expected/schema.json b/packages/vue-to-dsl/test/testcases/001_simple/expected/schema.json new file mode 100644 index 0000000000..ffcad68012 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/001_simple/expected/schema.json @@ -0,0 +1,60 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "switchStatus": true + }, + "methods": { + "onClickNew": { + "type": "JSFunction", + "value": "function onClickNew(event) {\n this.state.switchStatus = !this.state.switchStatus\n}" + } + }, + "computed": {}, + "lifeCycles": { + "setup": { + "type": "JSFunction", + "value": "function setup({ props, state, watch, onMounted }) {\n console.log('setup')\n state.switchStatus = false\n}" + } + }, + "css": ".page-base-style {\n padding: 24px;\n background: #ffffff;\n}\n\n.block-base-style {\n margin: 16px;\n}\n\n.component-base-style {\n margin: 8px;\n}", + "children": [ + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "TinySwitch", + "props": { + "className": "component-base-style", + "modelValue": { + "type": "JSExpression", + "value": "this.state.switchStatus", + "model": true + } + }, + "children": [], + "id": "fv6wuu63" + }, + { + "componentName": "TinyButton", + "props": { + "text": "按钮文案", + "className": "component-base-style", + "onClick": { + "type": "JSExpression", + "value": "this.onClickNew" + } + }, + "children": [], + "id": "88gmxjxz" + } + ], + "id": "ngzpirht" + } + ], + "id": "g6s705pv" +} diff --git a/packages/vue-to-dsl/test/testcases/001_simple/input/component.vue b/packages/vue-to-dsl/test/testcases/001_simple/input/component.vue new file mode 100644 index 0000000000..c930701a99 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/001_simple/input/component.vue @@ -0,0 +1,49 @@ + + + + diff --git a/packages/vue-to-dsl/test/testcases/002_createVM/expected/schema.json b/packages/vue-to-dsl/test/testcases/002_createVM/expected/schema.json new file mode 100644 index 0000000000..b9d2c6319a --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/002_createVM/expected/schema.json @@ -0,0 +1,1125 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "dataDisk": [1, 2, 3] + }, + "methods": {}, + "computed": {}, + "lifeCycles": {}, + "css": "body {\n background-color: #eef0f5;\n margin-bottom: 80px;\n}", + "children": [ + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "div", + "props": { + "style": "padding-bottom: 10px; padding-top: 10px" + }, + "children": [ + { + "componentName": "TinyTimeLine", + "props": { + "active": "2", + "style": "border-radius: 0px", + "horizontal": true, + "data": [ + { + "name": "基础配置" + }, + { + "name": "网络配置" + }, + { + "name": "高级配置" + }, + { + "name": "确认配置" + } + ] + }, + "children": [], + "id": "f6o3ajqj" + } + ], + "id": "md6cwygl" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "计费模式" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "包年/包月", + "value": "1" + }, + { + "text": "按需计费", + "value": "2" + } + ] + }, + "children": [], + "id": "ubwd8lfk" + } + ], + "id": "676a0faj" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "区域" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "style": "border-radius: 0px; margin-right: 10px", + "data": [ + { + "text": "乌兰察布二零一", + "value": "1" + } + ] + }, + "children": [], + "id": "s13qy75a" + }, + { + "componentName": "span", + "props": { + "style": "background-color: [object Event]; color: #8a8e99; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "温馨提示:页面左上角切换区域" + }, + "id": "43spgaa5" + } + ], + "id": "sfcukuue" + }, + { + "componentName": "span", + "props": { + "style": "display: block; color: #8a8e99; border-radius: 0px; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "不同区域的云服务产品之间内网互不相通;请就近选择靠近您业务的区域,可减少网络时延,提高访问速度" + }, + "id": "bkvslsjy" + } + ], + "id": "dsregz0u" + } + ], + "id": "i6ol9j9w" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "可用区", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "可用区1", + "value": "1" + }, + { + "text": "可用区2", + "value": "2" + }, + { + "text": "可用区3", + "value": "3" + } + ] + }, + "children": [], + "id": "vwnsns4b" + } + ], + "id": "l3xly2oq" + } + ], + "id": "4ekrjalz" + } + ], + "id": "rvsu4gu6" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "CPU架构" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "x86计算", + "value": "1" + }, + { + "text": "鲲鹏计算", + "value": "2" + } + ] + }, + "children": [], + "id": "sa4dv21x" + } + ], + "id": "kgjdu3xq" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "区域" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex; justify-content: flex-start; align-items: center" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex; align-items: center; margin-right: 10px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 80px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "vCPUs" + }, + "id": "3qi9sj2n" + } + ], + "id": "pjytdebw" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "y0t8wovv" + } + ], + "id": "fx3sn05n" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; align-items: center; margin-right: 10px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 80px; border-radius: 0px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "内存" + }, + "id": "vp34oq3k" + } + ], + "id": "0ft31pas" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "zt8zcs8t" + } + ], + "id": "95z4v9c1" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; align-items: center" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 80px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "规格名称" + }, + "id": "hib8vlsc" + } + ], + "id": "q1qpyi0h" + }, + { + "componentName": "TinySearch", + "props": { + "modelValue": "", + "placeholder": "输入关键词" + }, + "children": [], + "id": "krtylew9" + } + ], + "id": "fapyalc6" + } + ], + "id": "vs6rq4a7" + }, + { + "componentName": "div", + "props": { + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "style": "border-radius: 0px; margin-top: 12px", + "data": [ + { + "text": "通用计算型", + "value": "1" + }, + { + "text": "通用计算增强型", + "value": "2" + }, + { + "text": "内存优化型", + "value": "3" + }, + { + "text": "内存优化型", + "value": "4" + }, + { + "text": "磁盘增强型", + "value": "5" + }, + { + "text": "超高I/O型", + "value": "6" + }, + { + "text": "GPU加速型", + "value": "7" + } + ] + }, + "children": [], + "id": "w2rvp1zj" + }, + { + "componentName": "TinyGrid", + "props": { + "style": "margin-top: 12px; border-radius: 0px", + "auto-resize": true, + "editConfig": { + "trigger": "click", + "mode": "cell", + "showStatus": true + }, + "columns": [ + { + "type": "radio", + "width": 60 + }, + { + "field": "employees", + "title": "规格名称" + }, + { + "field": "created_date", + "title": "vCPUs | 内存(GiB)", + "sortable": true + }, + { + "field": "city", + "title": "CPU", + "sortable": true + }, + { + "title": "基准 / 最大带宽\t", + "sortable": true + }, + { + "title": "内网收发包", + "sortable": true + } + ], + "data": [ + { + "id": "1", + "name": "GFD科技有限公司", + "city": "福州", + "employees": 800, + "created_date": "2014-04-30 00:56:00", + "boole": false + }, + { + "id": "2", + "name": "WWW科技有限公司", + "city": "深圳", + "employees": 300, + "created_date": "2016-07-08 12:36:22", + "boole": true + } + ] + }, + "children": [], + "id": "56qdrmjc" + }, + { + "componentName": "div", + "props": { + "style": "margin-top: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "width: 150px; display: inline-block" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "当前规格" + }, + "id": "j0lbbokj" + } + ], + "id": "819b4ldc" + }, + { + "componentName": "span", + "props": { + "style": "font-weight: 700" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "通用计算型 | Si2.large.2 | 2vCPUs | 4 GiB" + }, + "id": "mhxn1jsh" + } + ], + "id": "nroj6wso" + } + ], + "id": "kf0lnpnv" + } + ], + "id": "tk67ku85" + } + ], + "id": "08j8y1ff" + } + ], + "id": "dqcoos16" + } + ], + "id": "wgnx6mrw" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "镜像", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": "1", + "data": [ + { + "text": "公共镜像", + "value": "1" + }, + { + "text": "私有镜像", + "value": "2" + }, + { + "text": "共享镜像", + "value": "3" + } + ] + }, + "children": [], + "id": "3pmrh1zz" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; margin-top: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 170px; margin-right: 10px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "4s9tm0oo" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 340px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "i0ou3048" + } + ], + "id": "rawlzvaa" + }, + { + "componentName": "div", + "props": { + "style": "margin-top: 12px" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "color: #e37d29" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "请注意操作系统的语言类型。" + }, + "id": "vlnnyrxv" + } + ], + "id": "fkhps62d" + } + ], + "id": "laqstaoh" + } + ], + "id": "m8as5pxu" + } + ], + "id": "7pb83c31" + } + ], + "id": "3ng26b65" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-radius: 4px;\n border-color: #fff;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n margin-bottom: 10px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "系统盘", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex" + }, + "children": [ + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 200px; margin-right: 10px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "w3pe0p5l" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px; margin-right: 10px" + }, + "children": [], + "id": "hhs8g284" + }, + { + "componentName": "span", + "props": { + "style": "color: #575d6c; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "GiB IOPS上限240,IOPS突发上限5,000" + }, + "id": "zj1dyvyc" + } + ], + "id": "b5accadg" + } + ], + "id": "ro8z737r" + } + ], + "id": "c6513skc" + } + ], + "id": "bqckjdbt" + }, + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "数据盘", + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin-top: 12px; display: flex" + }, + "children": [ + { + "componentName": "Icon", + "props": { + "style": "margin-right: 10px; width: 16px; height: 16px", + "name": "IconPanelMini" + }, + "children": [], + "id": "in4uom22" + }, + { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "style": "width: 200px; margin-right: 10px", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + }, + "children": [], + "id": "f9b3adim" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px; margin-right: 10px" + }, + "children": [], + "id": "hkc6mfzd" + }, + { + "componentName": "span", + "props": { + "style": "color: #575d6c; font-size: 12px; margin-right: 10px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "GiB IOPS上限600,IOPS突发上限5,000" + }, + "id": "gu5suwyv" + } + ], + "id": "4f38cvox" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px" + }, + "children": [], + "id": "oj3nt5s1" + } + ], + "loop": { + "type": "JSExpression", + "value": "this.state.dataDisk" + }, + "id": "c74s7y9n" + }, + { + "componentName": "div", + "props": { + "style": "display: flex; margin-top: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "Icon", + "props": { + "style": "width: 16px; height: 16px; margin-right: 10px", + "name": "IconPlus" + }, + "children": [], + "id": "ze75z2il" + }, + { + "componentName": "span", + "props": { + "style": "font-size: 12px; border-radius: 0px; margin-right: 10px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "增加一块数据盘" + }, + "id": "wmbogfnt" + } + ], + "id": "jrvc6fj3" + }, + { + "componentName": "span", + "props": { + "style": "color: #8a8e99; font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "您还可以挂载 21 块磁盘(云硬盘)" + }, + "id": "7w1z3fem" + } + ], + "id": "pczkwydn" + } + ], + "id": "dn6ab3hv" + } + ], + "id": "j260lifx" + } + ], + "id": "g46smnve" + } + ], + "id": "ccze0iwn" + }, + { + "componentName": "div", + "props": { + "style": "\n border-width: 1px;\n border-style: solid;\n border-color: #ffffff;\n padding-top: 10px;\n padding-left: 10px;\n padding-right: 10px;\n box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px;\n background-color: #fff;\n position: fixed;\n inset: auto 0% 0% 0%;\n height: 80px;\n line-height: 80px;\n border-radius: 0px;\n " + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top", + "label-position": "left ", + "label-width": "150px", + "style": "border-radius: 0px", + "inline": false + }, + "children": [], + "id": "k4z8lqi7" + }, + { + "componentName": "TinyRow", + "props": { + "style": "border-radius: 0px; height: 100%" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": "8" + }, + "children": [ + { + "componentName": "TinyRow", + "props": { + "style": "border-radius: 0px" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": "5", + "style": "display: flex" + }, + "children": [ + { + "componentName": "span", + "props": { + "style": "margin-right: 10px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "购买量" + }, + "id": "o1jcb13q" + } + ], + "id": "qnptw0yo" + }, + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "style": "width: 120px; margin-right: 10px" + }, + "children": [], + "id": "jwlwlvz0" + }, + { + "componentName": "span", + "props": {}, + "children": [ + { + "componentName": "Text", + "props": { + "text": "台" + }, + "id": "jmfizbf2" + } + ], + "id": "uxtzimy2" + } + ], + "id": "pm92xrj9" + }, + { + "componentName": "TinyCol", + "props": { + "span": "7" + }, + "children": [ + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "span", + "props": { + "style": "font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "配置费用" + }, + "id": "xxiefanz" + } + ], + "id": "ae4vk0vk" + }, + { + "componentName": "span", + "props": { + "style": "padding-left: 10px; padding-right: 10px; color: #de504e" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "¥1.5776" + }, + "id": "i40ub6sn" + } + ], + "id": "cb02qhdh" + }, + { + "componentName": "span", + "props": { + "style": "font-size: 12px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "/小时" + }, + "id": "adkjij9o" + } + ], + "id": "k2x66nf0" + } + ], + "id": "n1d16xku" + }, + { + "componentName": "div", + "props": {}, + "children": [ + { + "componentName": "span", + "props": { + "style": "font-size: 12px; border-radius: 0px" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "参考价格,具体扣费请以账单为准。" + }, + "id": "vmd1mfi5" + } + ], + "id": "dpi9os07" + }, + { + "componentName": "span", + "props": { + "style": "font-size: 12px; color: #344899" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "了解计费详情" + }, + "id": "eq0byqdt" + } + ], + "id": "n8tt8sko" + } + ], + "id": "q9wmr8ma" + } + ], + "id": "cwequlwq" + } + ], + "id": "8jwt4esp" + } + ], + "id": "sy3gd183" + }, + { + "componentName": "TinyCol", + "props": { + "span": "4", + "style": "\n display: flex;\n flex-direction: row-reverse;\n border-radius: 0px;\n height: 100%;\n justify-content: flex-start;\n align-items: center;\n " + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": "下一步: 网络配置", + "type": "danger", + "style": "max-width: unset" + }, + "children": [], + "id": "skzmpiap" + } + ], + "id": "n9wdnvab" + } + ], + "id": "rjuainel" + } + ], + "id": "veqg3b07" + } + ], + "id": "z9geov13" + } + ], + "id": "ozevdge6" +} diff --git a/packages/vue-to-dsl/test/testcases/002_createVM/input/component.vue b/packages/vue-to-dsl/test/testcases/002_createVM/input/component.vue new file mode 100644 index 0000000000..57881bbb7a --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/002_createVM/input/component.vue @@ -0,0 +1,411 @@ + + + + diff --git a/packages/vue-to-dsl/test/testcases/003_login/expected/schema.json b/packages/vue-to-dsl/test/testcases/003_login/expected/schema.json new file mode 100644 index 0000000000..da4a931af2 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/003_login/expected/schema.json @@ -0,0 +1,254 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "form": { + "username": "", + "password": "" + } + }, + "methods": { + "onSubmit": { + "type": "JSFunction", + "value": "function onSubmit() {\n if (!state.form.username || !state.form.password) return\n console.log('login', state.form)\n}" + }, + "onReset": { + "type": "JSFunction", + "value": "function onReset() {\n state.form.username = ''\n state.form.password = ''\n}" + } + }, + "computed": {}, + "lifeCycles": {}, + "css": ".login-page {\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n background: linear-gradient(135deg, #f5f7fa 0%, #e9eff7 100%);\n}\n\n.login-card {\n width: 420px;\n background: #fff;\n border: 1px solid #eef0f5;\n border-radius: 12px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);\n padding: 24px 24px 16px;\n}\n\n.login-header {\n text-align: center;\n margin-bottom: 12px;\n}\n\n.login-title {\n margin: 0;\n font-size: 22px;\n color: #1f2329;\n}\n\n.login-subtitle {\n margin: 6px 0 0;\n color: #6b7280;\n font-size: 12px;\n}\n\n.login-extra {\n display: flex;\n justify-content: space-between;\n font-size: 12px;\n}\n\n.link {\n color: #344899;\n text-decoration: none;\n}\n\n.link:hover {\n text-decoration: underline;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "login-page" + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "login-card" + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "login-header" + }, + "children": [ + { + "componentName": "h2", + "props": { + "className": "login-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "欢迎登录" + }, + "id": "ofvczp1s" + } + ], + "id": "x8ef07lh" + } + ], + "id": "1jfod2b7" + }, + { + "componentName": "TinyForm", + "props": { + "label-width": "80px", + "label-position": "left", + "inline": false + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "用户名" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入用户名", + "modelValue": { + "type": "JSExpression", + "value": "this.state.form.username", + "model": true + } + }, + "children": [], + "id": "1ygfx967" + } + ], + "id": "9c9vcnnl" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "密码" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "type": "password", + "placeholder": "请输入密码", + "modelValue": { + "type": "JSExpression", + "value": "this.state.form.password", + "model": true + } + }, + "children": [], + "id": "n7apt8qz" + } + ], + "id": "ic28zqv9" + }, + { + "componentName": "TinyFormItem", + "props": {}, + "children": [ + { + "componentName": "TinyRow", + "props": { + "gutter": 8 + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "type": "primary", + "style": "width: 100%", + "onClick": { + "type": "JSExpression", + "value": "this.onSubmit" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "登录" + }, + "id": "2nrmrn45" + } + ], + "id": "19hic5dx" + } + ], + "id": "y407fl72" + }, + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "type": "default", + "style": "width: 100%", + "onClick": { + "type": "JSExpression", + "value": "this.onReset" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "重置" + }, + "id": "whtbv216" + } + ], + "id": "vbz134gr" + } + ], + "id": "pttzhyn8" + } + ], + "id": "rk9r36ku" + } + ], + "id": "61dtkdhp" + }, + { + "componentName": "TinyFormItem", + "props": {}, + "children": [ + { + "componentName": "div", + "props": { + "className": "login-extra" + }, + "children": [ + { + "componentName": "a", + "props": { + "href": "#", + "className": "link" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "忘记密码?" + }, + "id": "3sho8h0e" + } + ], + "id": "z3ct2qcg" + }, + { + "componentName": "a", + "props": { + "href": "#", + "className": "link" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "注册账号" + }, + "id": "sl7n8oa0" + } + ], + "id": "vcbhy095" + } + ], + "id": "hs39mc41" + } + ], + "id": "fwkcsif8" + } + ], + "id": "vznait0f" + } + ], + "id": "2ia7lcq3" + } + ], + "id": "p79r4yv8" + } + ], + "id": "qxpzn62z" +} diff --git a/packages/vue-to-dsl/test/testcases/003_login/input/component.vue b/packages/vue-to-dsl/test/testcases/003_login/input/component.vue new file mode 100644 index 0000000000..34020e34ea --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/003_login/input/component.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/vue-to-dsl/test/testcases/004_dashboard/expected/schema.json b/packages/vue-to-dsl/test/testcases/004_dashboard/expected/schema.json new file mode 100644 index 0000000000..13938d5ab6 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/004_dashboard/expected/schema.json @@ -0,0 +1,408 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "activeStep": 1, + "filters": { + "keyword": "", + "module": "" + }, + "category": "all", + "steps": [ + { + "name": "准备" + }, + { + "name": "处理" + }, + { + "name": "完成" + } + ], + "columns": [ + { + "field": "name", + "title": "名称" + }, + { + "field": "path", + "title": "路径" + } + ], + "quickLinks": [ + { + "id": 1, + "name": "用户管理", + "path": "/users" + }, + { + "id": 2, + "name": "订单管理", + "path": "/orders" + } + ], + "modules": [ + { + "value": "user", + "label": "用户" + }, + { + "value": "order", + "label": "订单" + }, + { + "value": "report", + "label": "报表" + } + ], + "categories": [ + { + "text": "全部", + "value": "all" + }, + { + "text": "常用", + "value": "fav" + }, + { + "text": "最近", + "value": "recent" + } + ], + "todoCols": [ + { + "field": "title", + "title": "标题" + }, + { + "field": "deadline", + "title": "截止时间" + } + ], + "todos": [ + { + "id": 1, + "title": "修复登录问题", + "deadline": "2025-09-30" + }, + { + "id": 2, + "title": "升级依赖", + "deadline": "2025-10-10" + } + ] + }, + "methods": { + "onSearch": { + "type": "JSFunction", + "value": "function onSearch() {\n console.log('search with', state.filters, state.category)\n}" + }, + "onReset": { + "type": "JSFunction", + "value": "function onReset() {\n state.filters.keyword = ''\n state.filters.module = ''\n state.category = 'all'\n}" + }, + "go": { + "type": "JSFunction", + "value": "function go(item) {\n console.log('go to', item.path)\n}" + } + }, + "computed": {}, + "lifeCycles": {}, + "css": ".dashboard-page {\n padding: 16px;\n}\n\n.panel {\n padding: 12px;\n border: 1px solid #eee;\n border-radius: 4px;\n background: #fff;\n}\n\n.panel-title {\n margin: 0 0 8px;\n font-size: 16px;\n font-weight: 600;\n}\n\n.quick-links {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 12px;\n}\n\n.quick-link-tile {\n height: 80px;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n background: #fafafa;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n transition: all 0.2s ease;\n}\n\n.quick-link-tile:hover {\n background: #f0f6ff;\n border-color: #cfe0ff;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n}\n\n.quick-link-text {\n color: #1f2329;\n font-size: 14px;\n font-weight: 500;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "dashboard-page" + }, + "children": [ + { + "componentName": "TinyTimeLine", + "props": { + "style": "margin-bottom: 12px", + "horizontal": true, + "active": { + "type": "JSExpression", + "value": "this.state.activeStep" + }, + "data": { + "type": "JSExpression", + "value": "this.state.steps" + } + }, + "children": [], + "id": "f59raon9" + }, + { + "componentName": "div", + "props": { + "className": "panel", + "style": "margin-bottom: 12px" + }, + "children": [ + { + "componentName": "h3", + "props": { + "className": "panel-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "筛选" + }, + "id": "kkgfrui1" + } + ], + "id": "dwwurpcj" + }, + { + "componentName": "TinyForm", + "props": { + "label-width": "80px", + "label-position": "left", + "inline": true + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "关键词" + }, + "children": [ + { + "componentName": "TinySearch", + "props": { + "placeholder": "请输入关键词", + "modelValue": { + "type": "JSExpression", + "value": "this.state.filters.keyword", + "model": true + } + }, + "children": [], + "id": "5m0pkah7" + } + ], + "id": "y3goleeb" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "模块" + }, + "children": [ + { + "componentName": "TinySelect", + "props": { + "placeholder": "请选择模块", + "modelValue": { + "type": "JSExpression", + "value": "this.state.filters.module", + "model": true + }, + "options": { + "type": "JSExpression", + "value": "this.state.modules" + } + }, + "children": [], + "id": "g7sbqnft" + } + ], + "id": "caz07g6b" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "分类" + }, + "children": [ + { + "componentName": "TinyButtonGroup", + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.category", + "model": true + }, + "data": { + "type": "JSExpression", + "value": "this.state.categories" + } + }, + "children": [], + "id": "lwb0qf2l" + } + ], + "id": "ta8d6xus" + }, + { + "componentName": "TinyFormItem", + "props": {}, + "children": [ + { + "componentName": "TinyButton", + "props": { + "type": "primary", + "onClick": { + "type": "JSExpression", + "value": "this.onSearch" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "搜索" + }, + "id": "fiygyf9b" + } + ], + "id": "acrjkrp2" + }, + { + "componentName": "TinyButton", + "props": { + "style": "margin-left: 8px", + "onClick": { + "type": "JSExpression", + "value": "this.onReset" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "重置" + }, + "id": "w2optwrv" + } + ], + "id": "hkb5xq8q" + } + ], + "id": "no24uuys" + } + ], + "id": "i87ca3b3" + } + ], + "id": "tjkp78uw" + }, + { + "componentName": "TinyRow", + "props": {}, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "panel" + }, + "children": [ + { + "componentName": "h3", + "props": { + "className": "panel-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "快速入口" + }, + "id": "5135tk3c" + } + ], + "id": "rjy5v72m" + }, + { + "componentName": "TinyGrid", + "props": { + "columns": { + "type": "JSExpression", + "value": "this.state.columns" + }, + "data": { + "type": "JSExpression", + "value": "this.state.quickLinks" + }, + "auto-resize": true + }, + "children": [], + "id": "xciifp02" + } + ], + "id": "h27vtj8q" + } + ], + "id": "7565lb42" + }, + { + "componentName": "TinyCol", + "props": { + "span": 12 + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "panel" + }, + "children": [ + { + "componentName": "h3", + "props": { + "className": "panel-title" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "待办事项" + }, + "id": "2vpoheon" + } + ], + "id": "45q9o8z0" + }, + { + "componentName": "TinyGrid", + "props": { + "columns": { + "type": "JSExpression", + "value": "this.state.todoCols" + }, + "data": { + "type": "JSExpression", + "value": "this.state.todos" + }, + "auto-resize": true + }, + "children": [], + "id": "6o2fqs22" + } + ], + "id": "e1u7pepk" + } + ], + "id": "g32ur0yx" + } + ], + "id": "phzx3kdm" + } + ], + "id": "1z55g7br" + } + ], + "id": "6kkb8knu" +} diff --git a/packages/vue-to-dsl/test/testcases/004_dashboard/input/component.vue b/packages/vue-to-dsl/test/testcases/004_dashboard/input/component.vue new file mode 100644 index 0000000000..e5dd7a0507 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/004_dashboard/input/component.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/vue-to-dsl/test/testcases/005_survey/expected/schema.json b/packages/vue-to-dsl/test/testcases/005_survey/expected/schema.json new file mode 100644 index 0000000000..0dc5e78ee0 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/005_survey/expected/schema.json @@ -0,0 +1,223 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": { + "name": "", + "satisfaction": 3, + "feedback": "", + "recommend": true + }, + "methods": { + "submitSurvey": { + "type": "JSFunction", + "value": "function submitSurvey() {\n console.log('提交满意度调查:', {\n name: this.state.name,\n satisfaction: this.state.satisfaction,\n feedback: this.state.feedback,\n recommend: this.state.recommend\n })\n // 这里可以添加提交到服务器的逻辑\n alert('感谢您的反馈!')\n}" + } + }, + "computed": {}, + "lifeCycles": {}, + "css": ".page-base-style {\n padding: 24px;\n background: #ffffff;\n}\n\n.block-base-style {\n margin: 16px;\n}\n\n.component-base-style {\n margin: 8px;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "page-base-style" + }, + "children": [ + { + "componentName": "TinyForm", + "props": { + "labelWidth": "100px", + "className": "component-base-style" + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "姓名" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入姓名", + "className": "component-base-style", + "modelValue": { + "type": "JSExpression", + "value": "this.state.name", + "model": true + } + }, + "children": [], + "id": "btf77s93" + } + ], + "id": "m0nep3a0" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "整体满意度" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "display: flex; flex-wrap: wrap; gap: 12px" + }, + "children": [ + { + "componentName": "TinyRadio", + "props": { + "text": "非常满意", + "label": 5, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "r0o027ty" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "满意", + "label": 4, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "moq6l88f" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "一般", + "label": 3, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "hwydklui" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "不满意", + "label": 2, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "hptyv62f" + }, + { + "componentName": "TinyRadio", + "props": { + "text": "非常不满意", + "label": 1, + "modelValue": { + "type": "JSExpression", + "value": "this.state.satisfaction", + "model": true + } + }, + "children": [], + "id": "mr5ipgmo" + } + ], + "id": "nfer8dbn" + } + ], + "id": "1dpt0uh8" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "具体意见" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "type": "textarea", + "placeholder": "请提出宝贵意见", + "className": "component-base-style", + "modelValue": { + "type": "JSExpression", + "value": "this.state.feedback", + "model": true + } + }, + "children": [], + "id": "c5qczlnv" + } + ], + "id": "94ebcml9" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "是否愿意推荐" + }, + "children": [ + { + "componentName": "TinySwitch", + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.recommend", + "model": true + } + }, + "children": [], + "id": "m2pgtpsd" + } + ], + "id": "6p4xx6mb" + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "" + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": "提交", + "type": "primary", + "className": "component-base-style", + "onClick": { + "type": "JSExpression", + "value": "this.submitSurvey" + } + }, + "children": [], + "id": "q1isiake" + } + ], + "id": "mx5qz17f" + } + ], + "id": "3ny8gp70" + } + ], + "id": "t58jntyo" + } + ], + "id": "88gufz3b" +} diff --git a/packages/vue-to-dsl/test/testcases/005_survey/input/component.vue b/packages/vue-to-dsl/test/testcases/005_survey/input/component.vue new file mode 100644 index 0000000000..fb3155e636 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/005_survey/input/component.vue @@ -0,0 +1,85 @@ + + + + diff --git a/packages/vue-to-dsl/test/testcases/006_lifecycle/expected/schema.json b/packages/vue-to-dsl/test/testcases/006_lifecycle/expected/schema.json new file mode 100644 index 0000000000..70e7caa872 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/006_lifecycle/expected/schema.json @@ -0,0 +1,64 @@ +{ + "componentName": "Page", + "fileName": "component", + "meta": { + "name": "component" + }, + "state": {}, + "methods": {}, + "computed": {}, + "lifeCycles": { + "onMounted": { + "type": "JSFunction", + "value": "function onMounted() {\n console.log('mounted')\n}" + }, + "onUpdated": { + "type": "JSFunction", + "value": "function onUpdated() {\n console.log('updated')\n}" + }, + "onUnmounted": { + "type": "JSFunction", + "value": "function onUnmounted() {\n console.log('unmounted')\n}" + }, + "onBeforeMount": { + "type": "JSFunction", + "value": "function onBeforeMount() {\n console.log('before mount')\n}" + }, + "onBeforeUpdate": { + "type": "JSFunction", + "value": "function onBeforeUpdate() {\n console.log('before update')\n}" + }, + "onBeforeUnmount": { + "type": "JSFunction", + "value": "function onBeforeUnmount() {\n console.log('before unmount')\n}" + }, + "onActivated": { + "type": "JSFunction", + "value": "function onActivated() {\n console.log('activated')\n}" + }, + "onDeactivated": { + "type": "JSFunction", + "value": "function onDeactivated() {\n console.log('deactivated')\n}" + } + }, + "css": ".lifecycle-container {\n color: #333;\n}", + "children": [ + { + "componentName": "div", + "props": { + "className": "lifecycle-container" + }, + "children": [ + { + "componentName": "Text", + "props": { + "text": "Lifecycle" + }, + "id": "lnvsla7q" + } + ], + "id": "rrfdkail" + } + ], + "id": "b30pdz9q" +} diff --git a/packages/vue-to-dsl/test/testcases/006_lifecycle/input/component.vue b/packages/vue-to-dsl/test/testcases/006_lifecycle/input/component.vue new file mode 100644 index 0000000000..befbe30bd5 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/006_lifecycle/input/component.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/vue-to-dsl/test/testcases/index.test.js b/packages/vue-to-dsl/test/testcases/index.test.js new file mode 100644 index 0000000000..d44d7369f3 --- /dev/null +++ b/packages/vue-to-dsl/test/testcases/index.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { VueToDslConverter } from '../../src/converter' +import fs from 'fs' +import path from 'path' + +describe('VueToDslConverter testcases', () => { + const baseDir = path.resolve(__dirname, '.') + const converter = new VueToDslConverter({ computed_flag: true }) + + const cases = fs.readdirSync(baseDir).filter((name) => /\d+_/.test(name)) + + cases.forEach((caseName) => { + const caseDir = path.join(baseDir, caseName) + const inputFile = path.join(caseDir, 'input', 'component.vue') + const expectFile = path.join(caseDir, 'expected', 'schema.json') + + it(`case ${caseName} should convert correctly`, async () => { + const result = await converter.convertFromFile(inputFile) + const expected = JSON.parse(fs.readFileSync(expectFile, 'utf-8')) + + // 保存到output目录 + const outputFile = path.join(caseDir, 'output', 'schema.json') + fs.mkdirSync(path.dirname(outputFile), { recursive: true }) + fs.writeFileSync(outputFile, JSON.stringify(result.schema, null, 2)) + + // helper: deep clean (remove dynamic keys like meta / id) + const deepClean = (val) => { + if (Array.isArray(val)) { + return val.map((v) => deepClean(v)) + } + if (val && typeof val === 'object') { + const out = {} + Object.keys(val).forEach((k) => { + if (k === 'meta' || k === 'id') return + out[k] = deepClean(val[k]) + }) + return out + } + return val + } + + // helper: normalize line endings by removing carriage returns to avoid Windows/Linux diffs + const normalizeCR = (val) => { + if (Array.isArray(val)) { + return val.map((v) => normalizeCR(v)) + } + if (val && typeof val === 'object') { + const out = {} + Object.keys(val).forEach((k) => { + out[k] = normalizeCR(val[k]) + }) + return out + } + if (typeof val === 'string') { + return val.replace(/\r/g, '') + } + return val + } + + // helper: expect actual to be a superset of expected (subset match) + const expectSubset = (actual, exp) => { + if (Array.isArray(exp)) { + expect(Array.isArray(actual)).toBe(true) + // Only check the first N items where N = exp.length + for (let i = 0; i < exp.length; i++) { + expectSubset(actual[i], exp[i]) + } + return + } + if (exp && typeof exp === 'object') { + expect(actual && typeof actual === 'object').toBe(true) + Object.keys(exp).forEach((k) => { + expectSubset(actual[k], exp[k]) + }) + return + } + // primitives + expect(actual).toEqual(exp) + } + + if (expected.error) { + expect(result.errors.length).toBeGreaterThan(0) + // 允许部分 schema 存在 + expect(result.schema).not.toBeUndefined() + } else { + expect(result.errors).toHaveLength(0) + expect(result.schema).toBeDefined() + const actualClean = deepClean(result.schema) + const expectedClean = deepClean(expected) + // Normalize CR to avoid Windows/Linux line ending diffs + const actualNorm = normalizeCR(actualClean) + const expectedNorm = normalizeCR(expectedClean) + // 进行部分匹配断言(忽略 meta/id 且仅要求包含期望结构) + expectSubset(actualNorm, expectedNorm) + } + }) + }) +}) diff --git a/packages/vue-to-dsl/tsconfig.json b/packages/vue-to-dsl/tsconfig.json new file mode 100644 index 0000000000..ac67ac085c --- /dev/null +++ b/packages/vue-to-dsl/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "emitDeclarationOnly": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "strict": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/vue-to-dsl/vite.config.cli.mjs b/packages/vue-to-dsl/vite.config.cli.mjs new file mode 100644 index 0000000000..fd24027a48 --- /dev/null +++ b/packages/vue-to-dsl/vite.config.cli.mjs @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite' +import path from 'node:path' + +// 专用于打包 CLI 入口 cli.ts,输出为 dist/cli.js(CJS) +export default defineConfig({ + build: { + lib: { + entry: path.resolve(__dirname, 'cli.ts'), + name: 'tiny-vue-to-dsl-cli', + formats: ['cjs'], + fileName: () => 'cli.cjs' + }, + rollupOptions: { + external: [ + 'fs', + 'fs/promises', + 'path', + 'url', + 'os', + 'jszip', + 'vue', + '@vue/compiler-sfc', + '@vue/compiler-dom', + '@babel/parser', + '@babel/traverse', + '@babel/types' + ] + }, + sourcemap: false, + emptyOutDir: false + } +}) diff --git a/packages/vue-to-dsl/vite.config.js b/packages/vue-to-dsl/vite.config.js new file mode 100644 index 0000000000..df1526a237 --- /dev/null +++ b/packages/vue-to-dsl/vite.config.js @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + name: 'TinyEngineVueToDsl', + formats: ['es', 'cjs'], + fileName: (format) => `tiny-engine-vue-to-dsl.${format === 'es' ? 'js' : format}` + }, + rollupOptions: { + external: [ + 'vue', + '@vue/compiler-sfc', + '@vue/compiler-dom', + '@babel/parser', + '@babel/traverse', + '@babel/types', + 'node:fs', + 'node:fs/promises', + 'node:path', + 'node:url' + ], + output: { + globals: { + vue: 'Vue', + '@vue/compiler-sfc': 'VueCompilerSFC', + '@vue/compiler-dom': 'VueCompilerDOM', + '@babel/parser': 'BabelParser', + '@babel/traverse': 'BabelTraverse', + '@babel/types': 'BabelTypes' + } + } + } + }, + resolve: { + alias: { + '@': '/src' + } + } +}) diff --git a/tsconfig.app.json b/tsconfig.app.json index bfaf4cfb34..4d935d1ca8 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -41,6 +41,7 @@ "@opentiny/tiny-engine-toolbar-clean": ["packages/toolbars/clean/index"], "@opentiny/tiny-engine-toolbar-fullscreen": ["packages/toolbars/fullscreen/index"], "@opentiny/tiny-engine-toolbar-generate-code": ["packages/toolbars/generate-code/index"], + "@opentiny/tiny-engine-toolbar-upload": ["packages/toolbars/upload/index"], "@opentiny/tiny-engine-toolbar-lang": ["packages/toolbars/lang/index"], "@opentiny/tiny-engine-toolbar-lock": ["packages/toolbars/lock/index"], "@opentiny/tiny-engine-toolbar-logo": ["packages/toolbars/logo/index"],