sozsoft-platform/ui/src/contexts/componentRuntime.ts

337 lines
11 KiB
TypeScript
Raw Normal View History

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('')
interface ComponentSibling {
name: string
referenceName: string
}
interface ComponentBundleEntry extends ComponentSibling {
nameCapitalized: string
internalName: string
transpiledCode: string
}
const transpileComponents = (components: CustomComponent[], Babel: BabelLike) => {
const entries: ComponentBundleEntry[] = []
const siblings: ComponentSibling[] = []
const errors: Record<string, string> = {}
for (const component of components) {
const name = component.name
const nameCapitalized = name.charAt(0).toUpperCase() + name.slice(1)
const referenceName = toJavaScriptIdentifier(nameCapitalized)
const runtimeCode = getComponentRuntimeCode(component)
// Recorded even when compilation fails, so components referencing a broken
// sibling render a placeholder instead of throwing a ReferenceError.
siblings.push({ name, referenceName })
try {
// Transpiling per component (instead of one big pass) keeps a single
// syntax error from taking down every other custom component.
const transpiled = Babel.transform(normalizeComponentCodeForBundle(runtimeCode), {
presets: BABEL_PRESETS,
filename: `${referenceName}.tsx`,
// The output is inlined into a function body, so any surviving module
// syntax must fail here (attributable to this component) instead of
// breaking the shared bundle at evaluation time.
sourceType: 'script',
}).code
if (!transpiled) throw new Error('Boş derleme çıktısı')
entries.push({
name,
nameCapitalized,
referenceName,
internalName: extractComponentInfo(runtimeCode, referenceName),
transpiledCode: transpiled,
})
} catch (error) {
errors[name] = toErrorMessage(error)
console.error(`Custom component derlenemedi: ${name}`, error)
}
}
return { entries, siblings, errors }
}
const buildBundleSource = (
entries: ComponentBundleEntry[],
siblings: ComponentSibling[],
scopeKeys: string[],
seedErrors: Record<string, string>,
) => {
const definitions = entries
.map((entry) => {
const siblingNames = new Set<string>()
const siblingBindings = siblings
.filter((other) => other.name !== entry.name)
.filter((other) => {
if (other.referenceName === entry.internalName || siblingNames.has(other.referenceName)) {
return false
}
siblingNames.add(other.referenceName)
return true
})
// Resolved through the registry (not a captured variable) so definition
// order never matters and partially failed bundles still cross-render.
.map((other) => `const ${other.referenceName} = __resolve(${JSON.stringify(other.name)});`)
.join('\n ')
return `
try {
__define(${JSON.stringify(entry.name)}, ${JSON.stringify(entry.nameCapitalized)}, function() {
${siblingBindings}
${entry.transpiledCode}
return ${entry.internalName};
});
} catch (error) {
__errors[${JSON.stringify(entry.name)}] = (error && error.message) || String(error);
console.error('Custom component yüklenemedi: ${entry.name}', error);
}`
})
.join('\n')
return `
(function(${scopeKeys.join(', ')}) {
const { useState, useEffect, useCallback, useMemo, useRef, useReducer, useContext, createContext, memo, Fragment } = React;
const componentRegistry = {};
const __errors = ${JSON.stringify(seedErrors)};
const __resolve = (name) => (props) => {
const Resolved = componentRegistry[name];
if (Resolved) return React.createElement(Resolved, props);
return React.createElement(
'div',
{ className: 'text-sm text-red-600' },
__errors[name] ? name + ': ' + __errors[name] : 'Component not found: ' + name,
);
};
const __define = (name, capitalized, factory) => {
const Component = factory();
componentRegistry[name] = Component;
componentRegistry[capitalized] = Component;
};
${definitions}
return { registry: componentRegistry, errors: __errors };
})
`
}
/**
* Compiles every active component into a single cross-referencing registry.
* Failures are isolated per component: a broken component reports an error while
* the rest keep working.
*/
export const compileComponentBundle = (
components: CustomComponent[],
scope: Record<string, unknown>,
Babel: BabelLike,
): CompiledBundle => {
const { entries, siblings, errors } = transpileComponents(components, Babel)
if (!entries.length) return { registry: {}, errors }
const scopeKeys = Object.keys(scope)
const source = buildBundleSource(entries, siblings, scopeKeys, errors)
try {
// The bundle is a function expression whose parameters are the scope keys,
// so the injected modules are ordinary locals inside every component.
// Parenthesised on one line with `return`: a newline here would trigger
// automatic semicolon insertion and yield `undefined`.
const factory = new Function(`return (${source})`)() as (...args: unknown[]) => CompiledBundle
const bundle = factory(...scopeKeys.map((key) => scope[key]))
return { registry: bundle.registry, errors: { ...errors, ...bundle.errors } }
} catch (error) {
console.error('Custom component paketi çalıştırılamadı:', error)
const message = toErrorMessage(error)
for (const entry of entries) errors[entry.name] = message
return { registry: {}, errors }
}
}