sozsoft-platform/ui/src/contexts/componentRuntime.ts
2026-08-07 00:19:34 +03:00

336 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}
}