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 { builtinSchemaComponents, defaultComponentsMap } from './constants'
import { createDefaultComponentMap, getSupportedSchemaComponents } from './materials.js'
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'
])
function collectTemplateRefNames(nodes: any, collector = new Set<string>()) {
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<string>) {
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<string, string>
materials?: any[] | Record<string, any>
supportedComponents?: string[]
preserveComments?: boolean
strictMode?: boolean
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<string>
readText: (filePath: string) => Promise<string | null>
readDataUrl: (filePath: string) => Promise<string | null>
}
type ImportedAssetEntry = {
placeholder: string
filePath: string
name: string
resourceData: string
}
export class VueToDslConverter {
private options: VueToSchemaOptions
private rawOptions: VueToSchemaOptions
private static readonly NOOP_FUNCTION_VALUE = 'function noop() {}'
constructor(options: VueToSchemaOptions = {}) {
this.rawOptions = { ...options }
this.options = this.normalizeOptions(this.rawOptions)
}
private normalizeOptions(options: VueToSchemaOptions = {}) {
const normalizedOptions = {
preserveComments: false,
strictMode: false,
computed_flag: false,
customParsers: {},
...options
}
return {
...normalizedOptions,
componentMap: normalizedOptions.componentMap || createDefaultComponentMap(normalizedOptions)
}
}
private getSupportedSchemaComponents(options: VueToSchemaOptions = this.options) {
return getSupportedSchemaComponents(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<string, ImportedAssetEntry>
) {
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<string, ImportedAssetEntry>
) {
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<string, ImportedAssetEntry>,
localAssetImports = new Map<string, string>()
) {
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<string, string>()
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<string, ImportedAssetEntry>()
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 (builtinSchemaComponents.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<string>()) {
const visited = new Set<any>()
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<string, any>()
defaultComponentsMap.forEach((item: any) => {
const componentName = String(item?.componentName || '')
if (!componentName) return
defaultMapByName.set(componentName, { ...item })
})
const importLookup = new Map<string, { source: string; imported: string; kind: string }>()
;(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<string, string>()
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<string, any>()
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<string, any>()
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<ConvertResult> {
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))
}
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
}
}
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<ConvertResult> {
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<ConvertResult[]> {
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
}
private async walk(dir: string, filter: (p: string, stat: any) => boolean, acc: string[] = []): Promise<string[]> {
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 {
}
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<string, string>()
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<string>()
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 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
}
private getStaticStateValue(node: any): any {
if (!node) return undefined
if (t.isStringLiteral(node) || t.isNumericLiteral(node) || 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)) {
return node.expressions?.length ? undefined : node.quasis.map((item: any) => item.value?.cooked || '').join('')
}
if (t.isArrayExpression(node)) {
return node.elements.map((item: any) => (item ? this.getStaticStateValue(item) : null))
}
if (t.isObjectExpression(node)) {
const result: Record<string, any> = {}
node.properties.forEach((property: any) => {
if (!t.isObjectProperty(property) || property.computed) return
const keyName = this.getObjectKeyName(property.key)
if (!keyName) return
const value = this.getStaticStateValue(property.value)
if (value !== undefined) {
result[keyName] = value
}
})
return result
}
return undefined
}
private getReturnedObjectExpression(node: any) {
if (!node) return null
if (t.isArrowFunctionExpression(node) && !t.isBlockStatement(node.body)) {
return t.isObjectExpression(node.body) ? node.body : null
}
if (!t.isFunctionExpression(node) && !t.isObjectMethod(node) && !t.isArrowFunctionExpression(node)) {
return null
}
const body = (node as any).body
if (!t.isBlockStatement(body)) return null
const returnStatement = body.body.find((item: any) => t.isReturnStatement(item) && item.argument)
if (!returnStatement || !t.isReturnStatement(returnStatement) || !returnStatement.argument) {
return null
}
return t.isObjectExpression(returnStatement.argument) ? returnStatement.argument : null
}
private collectPiniaStoreState(code: string, filePath: string) {
const stores: any[] = []
try {
const ast: any = babelParse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx']
})
const fallbackId =
filePath
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') || 'store'
traverse(ast, {
CallExpression: (path: any) => {
const callee = path.node.callee
const isDefineStore =
(t.isIdentifier(callee) && callee.name === 'defineStore') ||
(t.isMemberExpression(callee) && t.isIdentifier(callee.property) && callee.property.name === 'defineStore')
if (!isDefineStore) return
const args = path.node.arguments || []
const firstArg = args[0]
const secondArg = args[1]
const optionsNode = t.isObjectExpression(firstArg)
? firstArg
: t.isObjectExpression(secondArg)
? secondArg
: null
if (!optionsNode) return
let id = t.isStringLiteral(firstArg) ? firstArg.value : fallbackId
let stateNode: any = null
optionsNode.properties.forEach((property: any) => {
if (t.isObjectMethod(property)) {
const keyName = this.getObjectKeyName(property.key)
if (keyName === 'state') {
stateNode = this.getReturnedObjectExpression(property)
}
return
}
if (!t.isObjectProperty(property)) return
const keyName = this.getObjectKeyName(property.key)
if (keyName === 'id' && !t.isStringLiteral(firstArg) && t.isStringLiteral(property.value)) {
id = property.value.value
return
}
if (keyName === 'state') {
stateNode = this.getReturnedObjectExpression(property.value)
}
})
const state = stateNode ? this.getStaticStateValue(stateNode) : null
if (state && typeof state === 'object' && !Array.isArray(state) && Object.keys(state).length > 0) {
stores.push({
id,
state,
getters: {},
actions: {}
})
}
}
})
} catch {
}
return stores
}
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<string, Map<string, any>>()
) {
const normalizedPath = this.normalizeVirtualPath(modulePath)
if (cache.has(normalizedPath)) {
return cache.get(normalizedPath)!
}
const exportsMap = new Map<string, any>()
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<string, any>()
const declaredUtils = new Map<string, any>()
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<string, any>, 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<string, Map<string, any>>()
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<string>()
const supportedComponents = this.getSupportedSchemaComponents()
const visit = (node: any) => {
if (!node || typeof node !== 'object') return
if (node.componentType === 'Block' && node.componentName && !supportedComponents.has(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<string>()
const existingBlockNames = new Set(
blockSchemas.map((item) => String(item?.fileName || item?.meta?.name || '')).filter(Boolean)
)
const processedPaths = new Set<string>()
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 collectRouterEntriesFromModuleContext(context: LocalModuleContext) {
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
isHome: boolean
resolvedFilePath: string | null
}> = []
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))) {
const importPath = match[3]
routeEntries.push({
routeName: match[1],
routePath: match[2],
importPath,
isHome: match[1] === homeName,
resolvedFilePath: this.resolveLocalVueFilePath(importPath, 'src/router/index.js', context)
})
}
return routeEntries
}
private async collectRoutedViewPathsFromModuleContext(context: LocalModuleContext) {
const routeEntries = await this.collectRouterEntriesFromModuleContext(context)
return new Set(routeEntries.map((item) => item.resolvedFilePath).filter((item): item is string => !!item))
}
private async createImportedUtilEntry(
spec: {
local: string
imported: string
source: string
destructuring: boolean
kind?: string
importerFile?: string
},
knownUtilsByName: Map<string, any>,
context?: LocalModuleContext,
cache?: Map<string, Map<string, any>>
) {
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<string, Map<string, any>>()
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<string, string[]>()
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<string>()
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) {
const flattenI18nMessages = (value: any, prefix = '', collector: Record<string, any> = {}) => {
if (value === null || value === undefined) return collector
if (Array.isArray(value)) {
value.forEach((item, index) => {
const nextKey = prefix ? `${prefix}.${index}` : String(index)
if (item !== null && typeof item === 'object') {
flattenI18nMessages(item, nextKey, collector)
} else {
collector[nextKey] = item
}
})
return collector
}
if (typeof value === 'object') {
Object.entries(value).forEach(([key, item]) => {
const nextKey = prefix ? `${prefix}.${key}` : key
if (item !== null && typeof item === 'object') {
flattenI18nMessages(item, nextKey, collector)
} else {
collector[nextKey] = item
}
})
return collector
}
if (prefix) {
collector[prefix] = value
}
return collector
}
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: flattenI18nMessages(JSON.parse(en)),
zh_CN: flattenI18nMessages(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 {
}
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/') && /\.(js|ts|mjs|cjs)$/.test(filePath))
for (const filePath of storeFiles) {
try {
const code = await context.readText(filePath)
if (!code || !/defineStore\s*\(/.test(code)) continue
globalState.push(...this.collectPiniaStoreState(code, filePath))
} catch {
}
}
return globalState
}
private async enrichPageSchemasWithRouterFromModuleContext(pageSchemas: any[], context: LocalModuleContext) {
try {
const routeEntries = await this.collectRouterEntriesFromModuleContext(context)
const byFile: Record<string, { routeName: string; routePath: string; isHome: boolean }> = {}
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.isHome }
})
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 routedViewPaths = await this.collectRoutedViewPathsFromModuleContext(context).catch(() => null)
const pageSchemas = pageResults
.filter((result: any) => {
if (!result?.schema) return false
const filePath = result?.scriptSchema?.__filePath || ''
if (!importedVueBlockData.blockedViewPaths.has(filePath)) return true
return routedViewPaths?.has(filePath) || false
})
.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<LocalModuleContext> {
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<LocalModuleContext> {
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<string> {
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<any>[] = []
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<LocalModuleContext> {
const fileArray = Array.from(files)
const readText = async (file: File) =>
await new Promise<string>((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<string, File>(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<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
}).catch(() => null)
}
}
}
async convertAppDirectory(appDir: string): Promise<any> {
const moduleContext = await this.createModuleContextFromAppDirectory(appDir)
return await this.buildAppSchemaFromModuleContext(moduleContext)
}
setOptions(options: VueToSchemaOptions) {
this.rawOptions = { ...this.rawOptions, ...options }
this.options = this.normalizeOptions(this.rawOptions)
}
getOptions(): VueToSchemaOptions {
return { ...this.options }
}
async convertAppFromZip(zipBuffer: ArrayBuffer | Uint8Array | Buffer): Promise<any> {
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<any> {
const moduleContext = await this.createModuleContextFromFileList(files)
return await this.buildAppSchemaFromModuleContext(moduleContext)
}
}