336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
||
import type { DesignerDocument } from '@/components/visualDesigner/types'
|
||
import type { CustomComponent } from '@/proxy/developerKit/models'
|
||
import type React from 'react'
|
||
|
||
export type RuntimeComponent = React.ComponentType<Record<string, unknown>>
|
||
|
||
export interface CompiledBundle {
|
||
registry: Record<string, RuntimeComponent>
|
||
errors: Record<string, string>
|
||
}
|
||
|
||
/**
|
||
* Minimal surface of `@babel/standalone` used here. Declared locally so the
|
||
* (multi megabyte) package stays out of the static import graph.
|
||
*/
|
||
export interface BabelLike {
|
||
transform: (code: string, options: Record<string, unknown>) => { code?: string | null }
|
||
}
|
||
|
||
const BABEL_PRESETS = ['react', 'typescript']
|
||
|
||
const RESERVED_WORDS = new Set([
|
||
'break',
|
||
'case',
|
||
'catch',
|
||
'class',
|
||
'const',
|
||
'continue',
|
||
'debugger',
|
||
'default',
|
||
'delete',
|
||
'do',
|
||
'else',
|
||
'enum',
|
||
'export',
|
||
'extends',
|
||
'false',
|
||
'finally',
|
||
'for',
|
||
'function',
|
||
'if',
|
||
'implements',
|
||
'import',
|
||
'in',
|
||
'instanceof',
|
||
'interface',
|
||
'let',
|
||
'new',
|
||
'null',
|
||
'package',
|
||
'private',
|
||
'protected',
|
||
'public',
|
||
'return',
|
||
'static',
|
||
'super',
|
||
'switch',
|
||
'this',
|
||
'throw',
|
||
'true',
|
||
'try',
|
||
'typeof',
|
||
'var',
|
||
'void',
|
||
'while',
|
||
'with',
|
||
'yield',
|
||
])
|
||
|
||
/** Identifiers the generated bundle owns; scope entries may not shadow them. */
|
||
const INTERNAL_IDENTIFIERS = new Set(['componentRegistry', '__errors', '__resolve', '__define'])
|
||
|
||
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||
|
||
export const isUsableScopeKey = (key: string) =>
|
||
IDENTIFIER_PATTERN.test(key) && !RESERVED_WORDS.has(key) && !INTERNAL_IDENTIFIERS.has(key)
|
||
|
||
export const toErrorMessage = (error: unknown) =>
|
||
error instanceof Error ? error.message : String(error)
|
||
|
||
/**
|
||
* Builds the scope injected into every runtime component. Everything the UI kit
|
||
* exports is exposed automatically, so a newly added UI component is usable from
|
||
* custom components without touching this file.
|
||
*/
|
||
export const buildRuntimeScope = (modules: Record<string, unknown>) => {
|
||
const scope: Record<string, unknown> = {}
|
||
for (const [key, value] of Object.entries(modules)) {
|
||
if (value === undefined || !isUsableScopeKey(key)) continue
|
||
scope[key] = value
|
||
}
|
||
return scope
|
||
}
|
||
|
||
export const parseComponentDependencies = (raw?: string | null): string[] => {
|
||
if (!raw?.trim()) return []
|
||
const value = raw.trim()
|
||
try {
|
||
if (value.startsWith('[')) {
|
||
const parsed: unknown = JSON.parse(value)
|
||
return Array.isArray(parsed) ? parsed.filter((item): item is string => !!item) : []
|
||
}
|
||
} catch (error) {
|
||
console.warn('Component dependencies could not be parsed:', error)
|
||
return []
|
||
}
|
||
return value
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
/**
|
||
* Visual designer documents are the source of truth when present; otherwise the
|
||
* hand written code is used.
|
||
*/
|
||
export const getComponentRuntimeCode = (component: CustomComponent) => {
|
||
if (!component) return ''
|
||
try {
|
||
const props: unknown = component.props ? JSON.parse(component.props) : null
|
||
const document = (props as { visualDesigner?: DesignerDocument } | null)?.visualDesigner
|
||
if (
|
||
document?.version === 1 &&
|
||
document.sourceMode === 'visual' &&
|
||
Array.isArray(document.nodes)
|
||
) {
|
||
return generateDesignerCode(component.name, document)
|
||
}
|
||
} catch (error) {
|
||
console.warn(`Visual designer schema could not be read for ${component.name}:`, error)
|
||
}
|
||
return component.code
|
||
}
|
||
|
||
/**
|
||
* Stored components run inside a shared function scope. Imports are supplied by
|
||
* that scope and therefore cannot remain as nested ESM declarations.
|
||
*/
|
||
export const normalizeComponentCodeForBundle = (code: string) =>
|
||
code
|
||
.replace(/^\s*import\s+(?:(?:type\s+)?[\s\S]*?\s+from\s+)?['"][^'"\r\n]+['"]\s*;?\s*$/gm, '')
|
||
// Re-export statements have no meaning inside the shared scope.
|
||
.replace(/^\s*export\s+(?:\*|\{[\s\S]*?\})\s*(?:from\s*['"][^'"\r\n]+['"])?\s*;?\s*$/gm, '')
|
||
.replace(/\bexport\s+default\s+/g, '')
|
||
// `export const Foo = ...` keeps its declaration, loses the modifier.
|
||
.replace(
|
||
/^(\s*)export\s+(?=(?:default\s+)?(?:async\s+)?(?:const|let|var|function|class)\b)/gm,
|
||
'$1',
|
||
)
|
||
.trim()
|
||
|
||
export const toJavaScriptIdentifier = (value: string) => {
|
||
const identifier = value.replace(/[^A-Za-z0-9_$]/g, '_')
|
||
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Component_${identifier}`
|
||
}
|
||
|
||
/** Finds the identifier the stored code assigns the component to. */
|
||
export const extractComponentInfo = (code: string, defaultName = 'DynamicComponent') => {
|
||
const patterns = [
|
||
/const\s+([A-Za-z]\w*)\s*:\s*React\.FC/,
|
||
/export\s+default\s+([A-Za-z]\w*)/,
|
||
/function\s+([A-Za-z]\w*)/,
|
||
/const\s+([A-Za-z]\w*)\s*=/,
|
||
/class\s+([A-Za-z]\w*)/,
|
||
]
|
||
|
||
for (const pattern of patterns) {
|
||
const match = code.match(pattern)
|
||
// `export default function Foo` would otherwise yield the `function` keyword.
|
||
if (match && !RESERVED_WORDS.has(match[1])) return match[1]
|
||
}
|
||
return defaultName
|
||
}
|
||
|
||
/**
|
||
* Cheap fingerprint of everything that influences compilation. Used to skip
|
||
* recompilation when unrelated fields (description, audit data) change.
|
||
*/
|
||
export const getCompilationSignature = (components: CustomComponent[]) =>
|
||
components
|
||
.filter((component) => component.isActive)
|
||
.map((component) => `${component.id}|${component.name}|${component.code}|${component.props}`)
|
||
.join(' |