sozsoft-platform/ui/src/components/visualDesigner/codeGenerator.ts

1433 lines
72 KiB
TypeScript
Raw Normal View History

import {
getDesignerCollectionProperty,
getDesignerTabSlotValue,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
getSqlDataSourceKeySource,
hasSqlDataSourceUrlParams,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
isDesignerDateProperty,
isDesignerOptionComponent,
isSqlDataSourceNode,
normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER,
SQL_DATA_SOURCE_SLOTS,
type DesignerDataSource,
type DesignerDocument,
type DesignerNode,
} from './types'
2026-08-05 20:51:43 +00:00
/**
* Record scope opened by a SqlDataSource. The record state is named
* `data_<nodeId>`, which is exactly what `bindingExpression` emits for a binding
* whose `sourceId` is the container's node id so children bind to columns
* through the regular binding machinery and write back through `setterName`.
*/
interface FormScope {
sourceId: string
setterName: string
}
2026-08-05 20:51:43 +00:00
const safeIdentifier = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_')
return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}`
}
/**
* Whether the emitted component carries the `refs` runtime. It is switched on by
* `generateDesignerCode` for the whole pass when a script actually addresses
* another component, so a page that uses no refs keeps its previous output byte
* for byte instead of paying for a state hook and a guard around every node.
*/
let refRuntimeEnabled = false
/** Prop that turns a component off; react-select spells it differently. */
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')
/** A script with `await` in it has to be wrapped in an async function. */
export const usesAwait = (script: string) => /\bawait\b/.test(script || '')
const refPropsExpression = (ref: string) => `designerRefProps(${JSON.stringify(ref)})`
const isRefControlled = (node: DesignerNode) => refRuntimeEnabled && Boolean(node.ref)
2026-08-06 13:17:59 +00:00
const staticValueExpression = (value: unknown): string => {
if (typeof value === 'string') {
return value.startsWith('::') ? `translate(${JSON.stringify(value)})` : JSON.stringify(value)
2026-08-06 13:17:59 +00:00
}
if (Array.isArray(value)) return `[${value.map(staticValueExpression).join(', ')}]`
if (value && typeof value === 'object') {
return `{ ${Object.entries(value)
.map(([key, item]) => `${JSON.stringify(key)}: ${staticValueExpression(item)}`)
.join(', ')} }`
}
if (value === undefined) return 'undefined'
return JSON.stringify(value)
2026-08-05 20:51:43 +00:00
}
2026-08-06 13:17:59 +00:00
const serializeValue = (value: unknown) => `{${staticValueExpression(value)}}`
2026-08-05 20:51:43 +00:00
const bindingExpression = (node: DesignerNode, propertyName: string, itemVariable?: string) => {
const binding = node.bindings?.[propertyName]
if (!binding?.sourceId) return ''
const path = binding.path.trim()
if (itemVariable && (path === '$item' || path.startsWith('$item.'))) {
return `getByPath(${itemVariable}, ${JSON.stringify(path === '$item' ? '' : path.slice(6))})`
}
return `getByPath(data_${safeIdentifier(binding.sourceId)}, ${JSON.stringify(binding.path || '')})`
}
interface RuntimeStateSpec {
propertyName: string
eventName: string
stateName: string
setterName: string
initialValue: unknown
}
const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
const configured: Record<string, { propertyName: string; eventName: string }> = {
AutoComplete: { propertyName: 'value', eventName: 'onInputChange' },
Dropdown: { propertyName: 'activeKey', eventName: 'onSelect' },
Input: { propertyName: 'value', eventName: 'onChange' },
2026-08-06 13:17:59 +00:00
ImageViewer: { propertyName: 'activeIndex', eventName: 'onIndexChange' },
Menu: { propertyName: 'defaultActiveKeys', eventName: 'onSelect' },
Pagination: { propertyName: 'currentPage', eventName: 'onChange' },
'Radio.Group': { propertyName: 'value', eventName: 'onChange' },
Select: { propertyName: 'value', eventName: 'onChange' },
2026-08-06 13:17:59 +00:00
Steps: { propertyName: 'current', eventName: 'onChange' },
Tabs: { propertyName: 'value', eventName: 'onChange' },
2026-08-06 13:17:59 +00:00
Upload: { propertyName: 'fileList', eventName: 'onChange' },
}
const fallback =
'checked' in node.props
? { propertyName: 'checked', eventName: 'onChange' }
: 'value' in node.props
? { propertyName: 'value', eventName: 'onChange' }
: null
const state = configured[node.type] || fallback
if (!state || node.bindings?.[state.propertyName]?.sourceId) return null
let initialValue = node.props[state.propertyName]
if (node.type === 'Select') {
const options = Array.isArray(node.props.options) ? node.props.options : []
initialValue = node.props.isMulti
? options.filter(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
Array.isArray(node.props.value) &&
node.props.value.includes((option as Record<string, unknown>).value),
)
: options.find(
(option) =>
option &&
typeof option === 'object' &&
!Array.isArray(option) &&
(option as Record<string, unknown>).value === node.props.value,
) || null
} else if (node.type === 'Menu') {
initialValue = normalizeDesignerKeyList(initialValue)
} else if (node.type === 'Tabs') {
initialValue = initialValue === undefined || initialValue === null ? '' : String(initialValue)
} else if (node.type === 'Pagination') {
initialValue = Number(initialValue) || 1
} else if (initialValue === undefined) {
initialValue = state.propertyName === 'checked' ? false : ''
}
const identifier = safeIdentifier(node.id)
return {
...state,
stateName: `runtime_${identifier}_${safeIdentifier(state.propertyName)}`,
setterName: `setRuntime_${identifier}_${safeIdentifier(state.propertyName)}`,
initialValue,
}
}
/** Prop of a node that the enclosing SqlDataSource writes the record back from. */
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
if (!formScope) return null
const propertyName = (['value', 'checked'] as const).find(
(name) => node.bindings?.[name]?.sourceId === formScope.sourceId,
)
if (!propertyName) return null
return {
propertyName,
eventName: node.type === 'AutoComplete' ? 'onInputChange' : 'onChange',
path: node.bindings[propertyName].path,
setterName: formScope.setterName,
sourceId: formScope.sourceId,
}
}
const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) =>
isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type)
/**
* Options expression for a data component: the endpoint binding mapped through
* `toSelectOptions` (honouring the configured label/value columns), otherwise the
* static collection maintained in the designer.
*/
const optionCollectionExpression = (node: DesignerNode, itemVariable?: string) => {
const collectionProperty = getDesignerCollectionProperty(node.type)
const binding = node.bindings?.[collectionProperty]
const boundExpression = binding?.sourceId
? bindingExpression(node, collectionProperty, itemVariable)
: ''
if (boundExpression) {
return `toSelectOptions(${boundExpression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
}
const staticOptions = node.props[collectionProperty]
return staticValueExpression(Array.isArray(staticOptions) ? staticOptions : [])
}
/** Node stripped of its collection prop/binding; those are rendered as children. */
const withoutCollection = (node: DesignerNode) => {
const collectionProperty = getDesignerCollectionProperty(node.type)
const props = { ...node.props }
const bindings = { ...(node.bindings || {}) }
delete props[collectionProperty]
delete bindings[collectionProperty]
return { node: { ...node, props, bindings }, props, bindings }
}
const propsToCode = (
node: DesignerNode,
itemVariable?: string,
omitProperties: string[] = [],
formScope?: FormScope,
) => {
const omitted = new Set(omitProperties)
const runtimeState = getRuntimeStateSpec(node)
const formField = getFormScopeField(node, formScope)
2026-08-05 20:51:43 +00:00
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
// Inside a SqlDataSource the bound value is editable: the change is written to
// the record, so the input must not be forced read-only.
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField)
2026-08-05 20:51:43 +00:00
const props = Object.entries(node.props)
.filter(
([key, value]) =>
key !== 'children' &&
key !== runtimeState?.propertyName &&
!omitted.has(key) &&
2026-08-05 20:51:43 +00:00
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
// A field the SqlDataSource writes back must stay editable, whatever the
// node happens to carry from the toolbox defaults.
!(formField && (key === 'readOnly' || key === 'disabled')) &&
2026-08-05 20:51:43 +00:00
!node.bindings?.[key]?.sourceId &&
value !== '' &&
value !== undefined,
)
.map(([key, value]) =>
// Dates live in the document as ISO strings; pickers need Date instances.
isDesignerDateProperty(node.type, key)
? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}`
: `${key}=${serializeValue(value)}`,
)
2026-08-05 20:51:43 +00:00
Object.keys(node.bindings || {}).forEach((propertyName) => {
if (propertyName === 'children') return
const expression = bindingExpression(node, propertyName, itemVariable)
if (expression) {
// Label/value columns come from the bound property itself; the collection
// is named `items` on some data components and `options` on others.
const binding = node.bindings?.[propertyName]
const propertyExpression = isOptionCollectionProperty(node, propertyName)
? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})`
: // Pickers need a Date instance; an endpoint column is an ISO string.
isDesignerDateProperty(node.type, propertyName)
? `toDesignerDate(${expression}, ${isDesignerDateOnlyComponent(node.type)})`
: // Select is controlled by the option object, not by the raw column value.
node.type === 'Select' && propertyName === 'value'
? `toSelectValue(${optionCollectionExpression(node, itemVariable)}, ${expression}, ${Boolean(node.props.isMulti)})`
: // An empty record (New mode) would otherwise hand React `undefined`,
// which flips the field to uncontrolled and makes it unusable.
formField?.propertyName === propertyName
? `(${expression}) ?? ${propertyName === 'checked' ? 'false' : '""'}`
: expression
2026-08-05 20:51:43 +00:00
props.push(`${propertyName}={${propertyExpression}}`)
}
})
if (hasBoundInputValue && !hasInputChangeHandler) props.push('readOnly={true}')
if (runtimeState && !omitted.has(runtimeState.propertyName)) {
props.push(`${runtimeState.propertyName}={${runtimeState.stateName}}`)
}
const handlerNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName),
)
if (runtimeState) handlerNames.add(runtimeState.eventName)
if (formField) handlerNames.add(formField.eventName)
handlerNames.forEach((eventName) =>
props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`),
)
2026-08-05 20:51:43 +00:00
// Spread first so anything configured on the node overrides the defaults.
const leading = node.type === 'Select' ? ['{...selectMenuProps}'] : []
// …and the ref overrides spread last, because a script that disables or
// refills a component has to win over both the defaults and the design values.
const trailing = isRefControlled(node) ? [`{...${refPropsExpression(node.ref as string)}}`] : []
const allProps = [...leading, ...props, ...trailing]
return allProps.length ? ` ${allProps.join(' ')}` : ''
2026-08-05 20:51:43 +00:00
}
const indent = (text: string, level: number) =>
text
.split('\n')
.map((line) => `${' '.repeat(level)}${line}`)
.join('\n')
const sqlIdentifiers = (node: DesignerNode) => {
const identifier = safeIdentifier(node.id)
return {
identifier,
record: `data_${identifier}`,
setRecord: `setData_${identifier}`,
rows: `sqlRows_${identifier}`,
busy: `sqlBusy_${identifier}`,
setBusy: `setSqlBusy_${identifier}`,
error: `sqlError_${identifier}`,
setError: `setSqlError_${identifier}`,
key: `sqlKey_${identifier}`,
keyParam: `sqlKeyParam_${identifier}`,
hasKey: `sqlHasKey_${identifier}`,
original: `sqlOriginal_${identifier}`,
setOriginal: `setSqlOriginal_${identifier}`,
mode: `sqlMode_${identifier}`,
setMode: `setSqlMode_${identifier}`,
index: `sqlIndex_${identifier}`,
setIndex: `setSqlIndex_${identifier}`,
selectUrl: `sqlSelectUrl_${identifier}`,
urlKey: `sqlUrlKey_${identifier}`,
setField: `sqlSetField_${identifier}`,
reload: `sqlReload_${identifier}`,
refresh: `sqlRefresh_${identifier}`,
create: `sqlNew_${identifier}`,
save: `sqlSave_${identifier}`,
remove: `sqlDelete_${identifier}`,
previous: `sqlPrev_${identifier}`,
next: `sqlNext_${identifier}`,
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
}
}
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
const sqlSlotLiteral = (
node: DesignerNode,
property: string,
method: string,
dataSources: DesignerDataSource[],
) => {
const source = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, property),
)
if (!source?.url.trim()) return 'null'
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
}
const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[]) => {
const names = sqlIdentifiers(node)
const keyField = getSqlDataSourceKeyField(node)
const selectSource = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
)
// The Select endpoint already owns a fetch hook, so the container reuses that
// state instead of issuing a second request for the same URL.
const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null'
// Must mirror the setter emitted by the data hooks, or the reload would call an
// identifier that was never declared.
const setSelectData =
selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/')
? `setData_${safeIdentifier(selectSource.id)}`
: ''
const keySource = getSqlDataSourceKeySource(node)
const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})`
const slots = SQL_DATA_SOURCE_SLOTS.map(
(slot) =>
` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`,
).join('\n')
return `${slots}
const ${names.key} = ${JSON.stringify(keyField)}
const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))}
const [${names.record}, ${names.setRecord}] = React.useState({})
const [${names.original}, ${names.setOriginal}] = React.useState({})
const [${names.mode}, ${names.setMode}] = React.useState("edit")
const [${names.index}, ${names.setIndex}] = React.useState(0)
const [${names.busy}, ${names.setBusy}] = React.useState(false)
const [${names.error}, ${names.setError}] = React.useState("")
// The Select key can come from the page URL, which is how a detail page reads
// /api/app/orders/{id} or /api/app/orders?id=… for a single record.
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
const ${names.selectUrl} = React.useMemo(() => {
if (!${names.slot('selectEndpoint')}) return ""
if (!${names.urlKey}) return ${names.slot('selectEndpoint')}.url
const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam})
return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey})
}, [${names.urlKey}])
// A key in the page URL always narrows the result: a list endpoint ignores the
// parameter server side, so the requested record is picked out here. Without a
// key every row is kept and the navigation below takes over.
const ${names.rows} = React.useMemo(
() => filterSqlRowsByKey(${rowsExpression}, ${names.key}, ${names.urlKey}),
[${selectData}, ${names.urlKey}],
)
// A fresh result set resets the position; New mode is left untouched so an
// unsaved draft is not overwritten by a re-render.
React.useEffect(() => {
${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0))
}, [${names.rows}])
React.useEffect(() => {
const row = ${names.rows}[${names.index}]
if (!row) return
${names.setRecord}(row)
${names.setOriginal}(row)
${names.setMode}("edit")
}, [${names.rows}, ${names.index}])
const ${names.hasKey} = (() => {
const value = readSqlField(${names.record}, ${names.key})
return value !== undefined && value !== null && value !== ""
})()
const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), [])
const ${names.reload} = React.useCallback(async () => {${
setSelectData
? `
if (!${names.selectUrl}) return
// Calling a URL that still holds a placeholder is a guaranteed 400, so the
// missing key is reported in the component instead.
if (hasSqlUrlParams(${names.selectUrl})) {
throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.")
}
const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" })
${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))`
: ''
}
}, [${setSelectData ? names.selectUrl : ''}])
// Reload wrapped with the busy/error handling the toolbar and mount effect need,
// so a failing Select is reported in the component instead of the console.
const ${names.refresh} = React.useCallback(async () => {
${names.setBusy}(true)
${names.setError}("")
try {
await ${names.reload}()
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
} finally {
${names.setBusy}(false)
}
}, [${names.reload}])
const ${names.create} = React.useCallback(() => {
${names.setError}("")
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
}, [])
const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), [])
const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}])
const ${names.save} = async () => {
const isNew = ${names.mode} === "new"
const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')}
if (!target) {
${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.")
return
}
// Update carries only the edited columns plus the key; Insert sends the record.
const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key})
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
${names.setError}("Kaydedilecek bir değişiklik yok.")
return
}
${names.setBusy}(true)
${names.setError}("")
try {
const response = await callSqlEndpoint(target, ${names.record}, ${names.key}, payload)
// With a Select endpoint the reload is the source of truth and its effect
// repopulates the form; without one, the response row is all there is.
if (${names.slot('selectEndpoint')}) {
await ${names.reload}()
} else {
const saved = toSqlRows(response?.data, "")[0]
if (saved) {
${names.setRecord}(saved)
${names.setOriginal}(saved)
}
}
${names.setMode}("edit")
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
} finally {
${names.setBusy}(false)
}
}
const ${names.remove} = async () => {
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return
// Deleting cannot be undone from the form, so it always asks first.
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
${names.setBusy}(true)
${names.setError}("")
try {
await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key})
${names.setRecord}({})
${names.setOriginal}({})
${names.setMode}("new")
await ${names.reload}()
} catch (error) {
${names.setError}(toSqlErrorMessage(error))
} finally {
${names.setBusy}(false)
}
}${
node.props.autoLoad === false
? ''
: `
React.useEffect(() => { void ${names.refresh}() }, [${names.refresh}])`
}`
}
const SQL_TOOLBAR_BUTTON_CLASS =
'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40'
const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
const names = sqlIdentifiers(node)
const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
.join('\n')
const className = JSON.stringify(String(node.props.className || ''))
const style = `{ display: "flex", flexDirection: "column", gap: ${Number(node.props.gap) || 0} }`
const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
// Navigation appears on its own whenever there is more than one record to walk.
const navigation = `
${indent(`{${names.rows}.length > 1 ? (`, level + 2)}
${indent('<>', level + 3)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 4)}
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 4)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 4)}
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 4)}
${indent('</>', level + 3)}
${indent(') : null}', level + 2)}`
const toolbar =
node.props.showToolbar === false
? ''
: `
${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 1)}${navigation}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 2)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 2)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 2)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 2)}
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 2)}
${indent('</div>', level + 1)}`
const error = `
${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 1)}`
return `${indent(`<div className=${className} style={${style}}>`, level)}
${children}${toolbar}${error}
${indent('</div>', level)}`
}
/**
* Wraps a node in its visibility guard. Tabs is the one component emitted as a
* braced expression rather than an element, so its outer `{…}` is turned into
* parentheses inside the ternary the code is read as an expression, not as a
* JSX child.
*/
const withVisibilityGuard = (code: string, node: DesignerNode, level: number) => {
if (!isRefControlled(node)) return code
const trimmed = code.trim()
const body =
trimmed.startsWith('{') && trimmed.endsWith('}')
? code.replace(/^(\s*)\{/, '$1(').replace(/\}(\s*)$/, ')$1')
: code
return `${indent(`{designerRefHidden(${JSON.stringify(node.ref)}) ? null : (`, level)}
${body}
${indent(')}', level)}`
}
const nodeToCode = (
node: DesignerNode,
level = 0,
itemVariable?: string,
formScope?: FormScope,
): string =>
withVisibilityGuard(
renderNodeToCode(node, isRefControlled(node) ? level + 1 : level, itemVariable, formScope),
node,
level,
)
const renderNodeToCode = (
node: DesignerNode,
level = 0,
itemVariable?: string,
formScope?: FormScope,
): string => {
if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable)
2026-08-05 20:51:43 +00:00
if (node.type === 'Spacer') {
return indent(
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
2026-08-05 20:51:43 +00:00
level,
)
}
if (node.type === 'FlexRow') {
const style = `{ display: "flex", gap: ${Number(node.props.gap) || 0}, flexWrap: "${node.props.wrap ? 'wrap' : 'nowrap'}", alignItems: "${String(node.props.align || 'stretch')}" }`
const className = node.props.className
? ` className=${JSON.stringify(node.props.className)}`
: ''
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
2026-08-05 20:51:43 +00:00
.join('\n')
return `${indent(`<div${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
}
if (
node.type === 'PageContainer' ||
node.type === 'TwoColumns' ||
node.type === 'SidebarContent' ||
node.type === 'HeaderContent'
) {
const gap = Number(node.props.gap) || 0
const className = JSON.stringify(String(node.props.className || ''))
const style =
node.type === 'PageContainer'
? `{ display: "flex", flexDirection: "column", gap: ${gap}, margin: "0 auto", maxWidth: ${JSON.stringify(String(node.props.maxWidth || '1280px'))}, padding: ${Number(node.props.padding) || 0}, width: "100%" }`
: node.type === 'HeaderContent'
? `{ display: "flex", flexDirection: "column", gap: ${gap} }`
: `{ display: "grid", gap: ${gap}, gridTemplateColumns: ${JSON.stringify(node.type === 'SidebarContent' ? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)` : 'repeat(2, minmax(0, 1fr))')} }`
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
2026-08-05 20:51:43 +00:00
.join('\n')
return `${indent(`<div className=${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
}
if (node.type === 'Table') {
const rowCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.rowCount) || 2)))
const columnCount = Math.min(20, Math.max(1, Math.floor(Number(node.props.columnCount) || 3)))
const rows = Array.from({ length: rowCount }, (_, rowIndex) => {
const cells = Array.from({ length: columnCount }, (_, columnIndex) => {
const slot = `table:${rowIndex}:${columnIndex}`
const cellChildren = node.children.filter(
(child, childIndex) =>
(child.slot ||
`table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`) === slot,
)
const content = cellChildren.length
? cellChildren
.map((child) => nodeToCode(child, level + 3, itemVariable, formScope))
.join('\n')
: indent('<span />', level + 3)
return `${indent('<td>', level + 2)}\n${content}\n${indent('</td>', level + 2)}`
}).join('\n')
return `${indent('<tr>', level + 1)}\n${cells}\n${indent('</tr>', level + 1)}`
}).join('\n')
const className = JSON.stringify(String(node.props.className || ''))
return `${indent(`<UiKit.Table className={${className} + " [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5"} borderlessRow compact={${Boolean(node.props.compact)}} hoverable={${node.props.hoverable !== false}} overflow={${node.props.overflow !== false}} style={{ minWidth: "100%", tableLayout: "fixed", width: "100%" }}>`, level)}
${indent('<tbody>', level + 1)}
${rows}
${indent('</tbody>', level + 1)}
${indent('</UiKit.Table>', level)}`
2026-08-05 20:51:43 +00:00
}
if (node.type === 'Grid') {
const columns = Number(node.props.cols) || 3
const gap = (Number(node.props.gap) || 0) * 4
const itemsExpression =
bindingExpression(node, 'items', itemVariable) ||
(Array.isArray(node.props.items) ? JSON.stringify(node.props.items) : '')
const itemsBinding = node.bindings?.items
const sourceRootExpression =
!itemVariable && itemsBinding?.sourceId ? `data_${safeIdentifier(itemsBinding.sourceId)}` : ''
const collectionExpression =
sourceRootExpression && sourceRootExpression !== itemsExpression
? `(Array.isArray(${itemsExpression}) ? ${itemsExpression} : Array.isArray(${sourceRootExpression}) ? ${sourceRootExpression} : null)`
: itemsExpression
const gridClassName = JSON.stringify(String(node.props.className || ''))
const gridStart = `<div className=${gridClassName} style={{ display: "grid", gridTemplateColumns: "repeat(${columns}, minmax(0, 1fr))", gap: ${gap} }}>`
if (collectionExpression && node.children.length === 0) {
const identifier = safeIdentifier(node.id)
const itemsVariable = `gridItems_${identifier}`
const columnsVariable = `gridColumns_${identifier}`
const itemVariable = `gridItem_${identifier}`
const valueVariable = `cellValue_${identifier}`
const configuredColumns = Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
: null
const columnsExpression = configuredColumns
? JSON.stringify(configuredColumns)
: `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]`
const compact = Boolean(node.props.compact)
const borderlessRow = Boolean(node.props.borderlessRow)
const hoverable = node.props.hoverable !== false
const overflow = node.props.overflow !== false
const tableTextClass = compact ? 'text-[11px]' : 'text-xs'
const cellSpacingClass = compact ? 'px-2 py-1' : 'px-3 py-2'
const rowClass = [
borderlessRow ? '' : 'border-b border-slate-100 last:border-b-0 dark:border-slate-800',
hoverable ? 'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70' : '',
]
.filter(Boolean)
.join(' ')
const overflowClass = overflow ? 'overflow-auto' : 'overflow-visible'
const table = `${indent('{(() => {', level + 1)}
${indent(`const ${itemsVariable} = ${collectionExpression}`, level + 2)}
${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)}
${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)}
${indent('return (', level + 2)}
${indent(`<div className="min-w-0 ${overflowClass} rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900" style={{ gridColumn: "1 / -1" }}>`, level + 3)}
${indent(`<table className="w-full border-collapse text-left ${tableTextClass}">`, level + 4)}
${indent('<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200"><tr>', level + 5)}
${indent(`{${columnsVariable}.map((column) => <th key={column} className="whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${cellSpacingClass}">{column === "value" ? "Value" : column}</th>)}`, level + 6)}
${indent('</tr></thead>', level + 5)}
${indent('<tbody>', level + 5)}
${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, level + 7)}
${indent(`{${columnsVariable}.map((column) => {`, level + 8)}
${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)}
${indent(`const text = typeof ${valueVariable} === "object" && ${valueVariable} !== null && !(${valueVariable} instanceof Date) ? JSON.stringify(${valueVariable}) : formatLocaleValue(${valueVariable}) || "—"`, level + 9)}
2026-08-05 20:51:43 +00:00
${indent(`return <td key={column} className="max-w-64 truncate text-slate-800 dark:text-slate-100 ${cellSpacingClass}" title={text}>{text}</td>`, level + 9)}
${indent('})}', level + 8)}
${indent('</tr>', level + 7)}
${indent('))}', level + 6)}
${indent('</tbody>', level + 5)}
${indent('</table>', level + 4)}
${indent('</div>', level + 3)}
${indent(')', level + 2)}
${indent('})()}', level + 1)}`
return `${indent(gridStart, level)}\n${table}\n${indent('</div>', level)}`
}
const repeatedItemVariable = `item_${safeIdentifier(node.id)}`
const staticChildren = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
2026-08-05 20:51:43 +00:00
.join('\n')
const repeatedChildren = node.children.length
? node.children
.map((child) => nodeToCode(child, level + 3, repeatedItemVariable, formScope))
.join('\n')
2026-08-05 20:51:43 +00:00
: indent(
`<pre className="min-w-0 overflow-auto rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}</pre>`,
level + 3,
)
const children = collectionExpression
? `${indent(`{Array.isArray(${collectionExpression}) ? ${collectionExpression}.map((${repeatedItemVariable}, index) => (`, level + 1)}
${indent(`<React.Fragment key={${repeatedItemVariable}?.id ?? index}>`, level + 2)}
${repeatedChildren}
${indent('</React.Fragment>', level + 2)}
${indent(')) : (', level + 1)}
${indent('<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>', level + 2)}
${indent(')}', level + 1)}`
: staticChildren
return `${indent(gridStart, level)}\n${children}\n${indent('</div>', level)}`
}
if (node.kind === 'platform') {
const viewNames: Record<string, string> = {
ListView: 'List',
DataGridView: 'Grid',
TreeView: 'Tree',
GanttView: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
PivotView: 'Pivot',
ChartView: 'Chart',
}
return indent(
`<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))} />`,
level,
)
}
if (node.type === 'Menu') {
const { node: menuNode, props: menuProps } = withoutCollection(node)
delete menuProps.variant
2026-08-05 20:51:43 +00:00
menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const menuPropsCode = propsToCode(menuNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable)
2026-08-05 20:51:43 +00:00
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
}
if (node.type === 'Dropdown') {
const { node: dropdownNode, props: dropdownProps, bindings } = withoutCollection(node)
// Rendered from the active option below, so `title` must not also be emitted
// by propsToCode — whether it is static or bound to an endpoint field.
const boundTitle = bindings.title?.sourceId
? bindingExpression(node, 'title', itemVariable)
: ''
const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '')
delete dropdownProps.title
delete bindings.title
const dropdownPropsCode = propsToCode(dropdownNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable)
const activeKeyExpression =
getRuntimeStateSpec(node)?.stateName ||
bindingExpression(node, 'activeKey', itemVariable) ||
staticValueExpression(node.props.activeKey ?? '')
const titleExpression = `toDropdownTitle(${optionsExpression}, ${activeKeyExpression}, ${fallbackTitleExpression})`
return `${indent(`<UiKit.Dropdown${dropdownPropsCode} title={${titleExpression}}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Dropdown.Item key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Öğe \${optionIndex + 1}\`)}</UiKit.Dropdown.Item>)}`, level + 1)}\n${indent('</UiKit.Dropdown>', level)}`
}
2026-08-06 13:17:59 +00:00
if (node.type === 'Pagination') {
const { node: paginationNode } = withoutCollection(node)
const paginationPropsCode = propsToCode(paginationNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable)
2026-08-06 13:17:59 +00:00
return indent(
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
level,
)
}
if (node.type === 'Tabs') {
const { node: tabsNode } = withoutCollection(node)
// `value` is emitted below, resolved against the (possibly async) tab list.
const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'], formScope)
const identifier = safeIdentifier(node.id)
const optionsVariable = `tabItems_${identifier}`
const activeVariable = `tabValue_${identifier}`
const optionVariable = `tabItem_${identifier}`
const activeStateExpression =
getRuntimeStateSpec(node)?.stateName ||
bindingExpression(node, 'value', itemVariable) ||
staticValueExpression(node.props.value ?? '')
const childrenVariable = `tabChildren_${identifier}`
const fallbackVariable = `tabFallback_${identifier}`
// Children are assigned to a tab through a `tab:<value>` slot. Building a
// lookup keyed by tab value keeps this working when the tab list itself is
// only known at runtime (endpoint driven tabs).
const slotGroups = new Map<string, DesignerNode[]>()
node.children.forEach((child) => {
const key = getDesignerTabSlotValue(child.slot)
const group = slotGroups.get(key)
if (group) group.push(child)
else slotGroups.set(key, [child])
})
const fragmentFor = (children: DesignerNode[], fragmentLevel: number) =>
`${indent('<>', fragmentLevel)}\n${children
.map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable, formScope))
.join('\n')}\n${indent('</>', fragmentLevel)}`
const slottedEntries = [...slotGroups]
.filter(([key]) => key !== '')
.map(
([key, children]) =>
`${indent(`${JSON.stringify(key)}: (`, level + 2)}\n${fragmentFor(children, level + 3)}\n${indent('),', level + 2)}`,
)
.join('\n')
// Children saved before per-tab slots existed belong to the first tab.
const fallbackChildren = slotGroups.get('') || []
return `${indent('{(() => {', level)}
${indent(`const ${optionsVariable} = ${optionCollectionExpression(node, itemVariable)}`, level + 1)}
${indent(`const ${activeVariable} = toTabValue(${optionsVariable}, ${activeStateExpression})`, level + 1)}
${indent(`const ${childrenVariable} = {`, level + 1)}
${slottedEntries}
${indent('}', level + 1)}
${indent(`const ${fallbackVariable} = (`, level + 1)}
${fallbackChildren.length ? fragmentFor(fallbackChildren, level + 2) : indent('null', level + 2)}
${indent(')', level + 1)}
${indent('return (', level + 1)}
${indent(`<UiKit.Tabs${tabsPropsCode} value={${activeVariable}}>`, level + 2)}
${indent('<UiKit.Tabs.TabList>', level + 3)}
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => <UiKit.Tabs.TabNav key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)} disabled={Boolean(${optionVariable}.disabled)}>{String(${optionVariable}.label ?? ${optionVariable}.value ?? \`Sekme \${tabIndex + 1}\`)}</UiKit.Tabs.TabNav>)}`, level + 4)}
${indent('</UiKit.Tabs.TabList>', level + 3)}
${indent(`{${optionsVariable}.map((${optionVariable}, tabIndex) => (`, level + 3)}
${indent(`<UiKit.Tabs.TabContent key={String(${optionVariable}.value ?? tabIndex)} value={String(${optionVariable}.value ?? tabIndex)}>`, level + 4)}
${indent(`{${optionVariable}.content ? <div>{String(${optionVariable}.content)}</div> : null}`, level + 5)}
${indent(`{${childrenVariable}[String(${optionVariable}.value ?? tabIndex)] ?? null}`, level + 5)}
${indent(`{tabIndex === 0 ? ${fallbackVariable} : null}`, level + 5)}
${indent('</UiKit.Tabs.TabContent>', level + 4)}
${indent('))}', level + 3)}
${indent('</UiKit.Tabs>', level + 2)}
${indent(')', level + 1)}
${indent('})()}', level)}`
}
if (node.type === 'Radio.Group') {
const { node: groupNode, props: groupProps } = withoutCollection(node)
delete groupProps.checked
delete groupProps.defaultChecked
delete groupProps.readOnly
const groupPropsCode = propsToCode(groupNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable)
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
}
2026-08-05 20:51:43 +00:00
const tag =
node.type === 'checkbox'
? 'input'
: node.kind === 'ui'
? `UiKit.${node.type}`
: node.kind === 'custom'
? safeIdentifier(node.type)
: node.type
const props = { ...node.props }
if (node.type === 'checkbox') props.type = 'checkbox'
const normalizedNode = { ...node, props }
const propCode = propsToCode(normalizedNode, itemVariable, [], formScope)
2026-08-05 20:51:43 +00:00
const childrenText = String(node.props.children ?? '')
const childrenBinding = bindingExpression(node, 'children', itemVariable)
const hasChildren = node.children.length > 0 || childrenText.length > 0 || !!childrenBinding
const voidElement = ['input', 'img'].includes(tag)
if (voidElement) return indent(`<${tag}${propCode} />`, level)
if (!hasChildren) return indent(`<${tag}${propCode} />`, level)
const nested = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
.join('\n')
// Static text is emitted through the ref override so `setText` reaches it: a
// spread `children` prop loses to JSX children, which is what a label is.
const staticText = isRefControlled(node)
? `${refPropsExpression(node.ref as string)}.children ?? ${staticValueExpression(childrenText)}`
: staticValueExpression(childrenText)
2026-08-05 20:51:43 +00:00
const text = childrenBinding
? indent(`{${childrenBinding}}`, level + 1)
: childrenText
? indent(`{${staticText}}`, level + 1)
2026-08-05 20:51:43 +00:00
: ''
const body = [text, nested].filter(Boolean).join('\n')
return `${indent(`<${tag}${propCode}>`, level)}\n${body}\n${indent(`</${tag}>`, level)}`
}
/** Every script of the document, so ref usage can be detected before emitting. */
const collectDesignerScripts = (document: DesignerDocument) => {
const scripts: string[] = [document.lifecycle?.onMount || '']
const visit = (nodes: DesignerNode[]) =>
nodes.forEach((node) => {
Object.values(node.events || {}).forEach((script) => scripts.push(script || ''))
visit(node.children || [])
})
visit(document.nodes)
return scripts.join('\n')
}
interface RefTarget {
node: DesignerNode
/** Reads the node's current value, whatever holds it. */
getExpression: string
/** Statement that writes a new value back to that same holder. */
setStatement: string
extras: string[]
}
/**
* `refs.<name>` entry of a node: the value accessor is wired to whichever state
* actually owns the value the enclosing SqlDataSource record, the node's own
* runtime state, or the ref override store for a component that has neither.
*/
const refTargetFor = (
node: DesignerNode,
formField: ReturnType<typeof getFormScopeField>,
runtimeState: RuntimeStateSpec | null,
): RefTarget => {
const ref = node.ref as string
const extras: string[] = []
if (isSqlDataSourceNode(node.type)) {
const names = sqlIdentifiers(node)
extras.push(
` getRecord: () => ${names.record}`,
` setField: (path, value) => ${names.setField}(path, value)`,
` getRows: () => ${names.rows}`,
` getMode: () => ${names.mode}`,
` isBusy: () => ${names.busy}`,
` getError: () => ${names.error}`,
` goToRow: (index) => ${names.setIndex}(Number(index) || 0)`,
` reload: () => ${names.refresh}()`,
` newRecord: () => ${names.create}()`,
` save: () => ${names.save}()`,
` remove: () => ${names.remove}()`,
)
return {
node,
getExpression: names.record,
setStatement: `${names.setRecord}(value)`,
extras,
}
}
if (formField) {
return {
node,
getExpression: `getByPath(data_${safeIdentifier(formField.sourceId)}, ${JSON.stringify(formField.path)})`,
setStatement: `${formField.setterName}(${JSON.stringify(formField.path)}, value)`,
extras,
}
}
if (runtimeState) {
return {
node,
getExpression: runtimeState.stateName,
setStatement: `${runtimeState.setterName}(value)`,
extras,
}
}
const valueProperty =
'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
return {
node,
getExpression: `${refPropsExpression(ref)}.${valueProperty} ?? ${staticValueExpression(node.props[valueProperty])}`,
setStatement: `patchDesignerRef(${JSON.stringify(ref)}, { props: { ${valueProperty}: value } })`,
extras,
}
}
/**
* The `refs` object handed to every event script and to `onMount`. Overrides are
* kept in one state map keyed by ref name, so a script can change any prop of any
* other component without that component needing a hook of its own.
*/
const refRuntimeCode = (targets: RefTarget[]) => {
const entries = targets
.map((target) => {
const ref = target.node.ref as string
const key = JSON.stringify(ref)
const disabledProperty = JSON.stringify(getDisabledProperty(target.node.type))
return ` ${key}: {
name: ${key},
type: ${JSON.stringify(target.node.type)},
getValue: () => ${target.getExpression},
setValue: (value) => { ${target.setStatement} },
getProps: () => designerRefProps(${key}),
setProps: (patch) => patchDesignerRef(${key}, { props: patch || {} }),
setProp: (property, value) => patchDesignerRef(${key}, { props: { [property]: value } }),
isVisible: () => !designerRefHidden(${key}),
setVisible: (visible) => patchDesignerRef(${key}, { hidden: visible === false }),
show: () => patchDesignerRef(${key}, { hidden: false }),
hide: () => patchDesignerRef(${key}, { hidden: true }),
isEnabled: () => (designerRefProps(${key})[${disabledProperty}] ?? ${JSON.stringify(Boolean(target.node.props[getDisabledProperty(target.node.type)]))}) !== true,
setEnabled: (enabled) => patchDesignerRef(${key}, { props: { [${disabledProperty}]: enabled === false } }),
setReadOnly: (readOnly) => patchDesignerRef(${key}, { props: { readOnly: readOnly !== false } }),
setText: (text) => patchDesignerRef(${key}, { props: { children: text } }),
reset: () => patchDesignerRef(${key}, { props: null, hidden: false }),${
target.extras.length ? `\n${target.extras.join(',\n')},` : ''
}
}`
})
.join(',\n')
return ` // Component references. Every node on the canvas is addressable by its ref,
// so an event script can read or change another component without prop drilling.
const [designerRefState, setDesignerRefState] = React.useState({})
const designerRefProps = (name) => designerRefState[name]?.props || {}
const designerRefHidden = (name) => Boolean(designerRefState[name]?.hidden)
const patchDesignerRef = (name, patch) => setDesignerRefState((current) => ({
...current,
[name]: {
...current[name],
...patch,
// \`props: null\` resets the overrides; anything else is merged on top.
props: patch.props === null ? {} : { ...(current[name]?.props || {}), ...(patch.props || {}) },
},
}))
const refs = {
${entries}
}`
}
/**
* `api` and `notify`, the two helpers an event script needs to do something that
* is not just moving values between components. `apiService`, `toast` and
* `Notification` are already in the compiled component's scope, so this is only
* about giving the scripts a small, obvious surface over them.
*/
const SCRIPT_API_CODE = ` const api = {
get: (url, params) => apiService.fetchData({ url, method: "GET", params }).then((response) => response.data),
post: (url, data, params) => apiService.fetchData({ url, method: "POST", data, params }).then((response) => response.data),
put: (url, data, params) => apiService.fetchData({ url, method: "PUT", data, params }).then((response) => response.data),
patch: (url, data, params) => apiService.fetchData({ url, method: "PATCH", data, params }).then((response) => response.data),
delete: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
remove: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
request: (config) => apiService.fetchData(config).then((response) => response.data),
errorMessage: (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı.",
}`
const SCRIPT_NOTIFY_CODE = ` const notify = (message, type = "info") => toast.push(
<Notification type={type} duration={2500}>{typeof message === "string" ? message : JSON.stringify(message)}</Notification>,
{ placement: "bottom-end" },
)`
2026-08-05 20:51:43 +00:00
export const generateDesignerCode = (name: string, document: DesignerDocument) => {
const componentName = safeIdentifier(name || 'VisualComponent')
const scripts = collectDesignerScripts(document)
// Only pages whose scripts address a ref pay for the ref runtime.
refRuntimeEnabled = /\brefs\b/.test(scripts)
const scriptHelpers = [
/\bapi\b/.test(scripts) ? SCRIPT_API_CODE : '',
/\bnotify\b/.test(scripts) ? SCRIPT_NOTIFY_CODE : '',
]
.filter(Boolean)
.join('\n\n')
const refTargets: RefTarget[] = []
2026-08-05 20:51:43 +00:00
const handlers: string[] = []
const runtimeStateHooks: string[] = []
2026-08-05 20:51:43 +00:00
let hasSelect = false
let hasDataTable = false
let hasDropdown = false
let hasSelectComponent = false
let hasDatePicker = false
let hasTabs = false
const sqlDataSourceNodes: DesignerNode[] = []
/** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */
const sqlManagedSelectSourceIds = new Set<string>()
2026-08-05 20:51:43 +00:00
const visit = (nodes: DesignerNode[], formScope?: FormScope) => {
2026-08-05 20:51:43 +00:00
nodes.forEach((node) => {
if (isDesignerOptionComponent(node.type)) hasSelect = true
if (node.type === 'Select') hasSelectComponent = true
if (node.type === 'Dropdown') hasDropdown = true
if (node.type === 'Grid') hasDataTable = true
if (node.type === 'Tabs') hasTabs = true
if (isDesignerDateComponent(node.type)) hasDatePicker = true
if (isSqlDataSourceNode(node.type)) {
sqlDataSourceNodes.push(node)
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
if (selectId) sqlManagedSelectSourceIds.add(selectId)
}
const formField = getFormScopeField(node, formScope)
const runtimeState = getRuntimeStateSpec(node)
if (refRuntimeEnabled && node.ref) {
refTargets.push(refTargetFor(node, formField, runtimeState))
}
if (runtimeState) {
// Date pickers hold a Date in state but an ISO string in the document.
const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName)
? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)}, ${isDesignerDateOnlyComponent(node.type)})`
: JSON.stringify(runtimeState.initialValue)
runtimeStateHooks.push(
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
)
}
const eventNames = new Set(
Object.entries(node.events)
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName),
)
if (runtimeState) eventNames.add(runtimeState.eventName)
if (formField) eventNames.add(formField.eventName)
eventNames.forEach((eventName) => {
const script = node.events[eventName]?.trim() || ''
// A form scoped field replaces the local state update: the edit belongs
// to the SqlDataSource record, which is what Save posts back.
const formUpdate =
formField?.eventName === eventName
? `${formField.setterName}(${JSON.stringify(formField.path)}, ${
node.type === 'Select'
? 'Array.isArray(valueOrEvent) ? valueOrEvent.map((option) => option?.value ?? option) : (valueOrEvent?.value ?? null)'
: formField.propertyName === 'checked'
? 'typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent)'
: isDesignerDateComponent(node.type)
? `fromDesignerDate(valueOrEvent ?? null, ${isDesignerDateOnlyComponent(node.type)})`
: node.type === 'AutoComplete'
? 'valueOrEvent ?? ""'
: 'valueOrEvent?.target?.value ?? valueOrEvent'
})`
: ''
const stateUpdate =
formUpdate ||
(runtimeState?.eventName === eventName
? node.type === 'Select'
? `${runtimeState.setterName}(valueOrEvent)`
: node.type === 'AutoComplete'
? `${runtimeState.setterName}(valueOrEvent ?? "")`
: node.type === 'Menu'
? `${runtimeState.setterName}([String(valueOrEvent ?? "")])`
: node.type === 'Tabs'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Dropdown'
? `${runtimeState.setterName}(String(valueOrEvent ?? ""))`
: node.type === 'Pagination'
? `${runtimeState.setterName}(Number(valueOrEvent) || 1)`
: node.type === 'Steps' || node.type === 'ImageViewer'
? `${runtimeState.setterName}(Number(valueOrEvent) || 0)`
: node.type === 'Upload'
? `${runtimeState.setterName}(Array.isArray(originalEvent) ? originalEvent : [])`
: runtimeState.propertyName === 'checked'
? `${runtimeState.setterName}(typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent))`
: node.type === 'Input' || node.kind === 'html'
? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)`
: `${runtimeState.setterName}(valueOrEvent)`
: '')
const eventDeclaration =
node.type === 'Checkbox' && eventName === 'onChange'
? ' const event = { checked: Boolean(valueOrEvent), originalEvent, target: originalEvent?.target }'
: ' const event = valueOrEvent'
// A script that awaits an API call needs an async handler, otherwise the
// `await` is a syntax error the user only finds out about at compile time.
const asyncPrefix = usesAwait(script) ? 'async ' : ''
2026-08-05 20:51:43 +00:00
handlers.push(
` const handle_${safeIdentifier(node.id)}_${eventName} = ${asyncPrefix}(valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`,
2026-08-05 20:51:43 +00:00
)
})
visit(
node.children,
isSqlDataSourceNode(node.type)
? { sourceId: node.id, setterName: sqlIdentifiers(node).setField }
: formScope,
)
2026-08-05 20:51:43 +00:00
})
}
visit(document.nodes)
const dataSources = document.dataSources || []
const selectHelpers = hasSelect
? ` const toSelectOptions = (value, labelPath = "", valuePath = "") => {
if (!Array.isArray(value)) return []
return value.map((item, index) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) return { label: String(item ?? ""), value: item ?? index }
const primitiveKeys = Object.keys(item).filter((key) => item[key] === null || ["string", "number", "boolean"].includes(typeof item[key]))
const labelKey = ["label", "children", "name", "title", "text", "description"].find((key) => item[key] !== undefined)
const valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined)
2026-08-05 20:51:43 +00:00
const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]]
const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? primitiveKeys[0]]
return { ...item, label: String(optionLabel ?? \`Seçenek \${index + 1}\`), value: optionValue ?? index }
})
}`
: ''
// A bound Select receives a raw column value, but react-select is controlled by
// the option object itself.
const selectValueHelpers = hasSelectComponent
? ` const toSelectValue = (options, value, isMulti) => {
if (isMulti) return options.filter((option) => Array.isArray(value) && value.includes(option.value))
return options.find((option) => option.value === value) ?? null
}`
: ''
// Dropdown paints its toggle from `title`; `activeKey` only marks the item
// inside the closed menu, so the label of the active option is resolved here.
const dropdownHelpers = hasDropdown
? ` const toDropdownTitle = (options, activeKey, fallbackTitle) => {
const key = activeKey === undefined || activeKey === null ? "" : String(activeKey)
const selected = key ? options.find((option) => String(option?.value ?? "") === key) : undefined
if (selected) return String(selected.label ?? selected.value ?? key)
return String(fallbackTitle ?? "") || ${JSON.stringify(DESIGNER_DROPDOWN_PLACEHOLDER)}
}`
: ''
// Falls back to the first tab while an endpoint driven tab list is still empty
// or when the stored value no longer exists.
const tabHelpers = hasTabs
? ` const toTabValue = (options, value) => {
const key = value === undefined || value === null ? "" : String(value)
if (key && options.some((option) => String(option?.value ?? "") === key)) return key
return options.length ? String(options[0]?.value ?? "") : ""
}`
: ''
// Grid cells print raw endpoint columns, so dates and decimals are localised
// here — the culture is published on `<html lang>` because a runtime compiled
// component has neither hooks nor imports to reach the store.
const localeHelpers = hasDataTable
? ` const localeCulture = () => (typeof document !== "undefined" && document.documentElement.lang) || "en"
const localeDateOptions = { year: "numeric", month: "2-digit", day: "2-digit" }
const localeTimeOptions = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }
const localeIsoPattern = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/
const formatLocaleValue = (value) => {
if (value === null || value === undefined) return ""
const culture = localeCulture()
if (typeof value === "number") {
if (!Number.isFinite(value)) return String(value)
return new Intl.NumberFormat(culture, { useGrouping: false, maximumFractionDigits: 20 }).format(value)
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? "" : new Intl.DateTimeFormat(culture, localeTimeOptions).format(value)
}
if (typeof value !== "string") return String(value)
const parts = localeIsoPattern.exec(value.trim())
if (!parts) return value
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
if (parts[4] === undefined) {
const day = new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
return new Intl.DateTimeFormat(culture, localeDateOptions).format(day)
}
return new Intl.DateTimeFormat(culture, localeTimeOptions).format(parsed)
}`
: ''
// Pickers check `instanceof Date`, but the document only stores ISO strings.
// Both directions stay on the local wall clock: a date column carries no
// timezone, so converting through UTC would move 29/07 to 28/07.
const dateHelpers = hasDatePicker
? ` const sqlPad = (value) => String(value).padStart(2, "0")
const isoDateTime = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2}))?)?/
const toDesignerDate = (value, dateOnly) => {
if (Array.isArray(value)) return value.map((entry) => toDesignerDate(entry, dateOnly))
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value !== "string" || !value.trim()) return null
const parts = isoDateTime.exec(value.trim())
const midnight = parts && dateOnly && !Number(parts[4]) && !Number(parts[5]) && !Number(parts[6])
if (parts && (parts[4] === undefined || midnight)) {
return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const fromDesignerDate = (value, dateOnly) => {
if (Array.isArray(value)) return value.map((entry) => fromDesignerDate(entry, dateOnly))
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return null
const day = value.getFullYear() + "-" + sqlPad(value.getMonth() + 1) + "-" + sqlPad(value.getDate())
if (dateOnly) return day
return day + "T" + sqlPad(value.getHours()) + ":" + sqlPad(value.getMinutes()) + ":" + sqlPad(value.getSeconds())
}
return typeof value === "string" && value.trim() ? value : null
}`
: ''
// Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table,
// Card) would cut it off. A body portal keeps the menu fully visible.
const selectMenuHelpers = hasSelectComponent
? ` const selectMenuProps = {
menuPosition: "fixed",
menuPortalTarget: typeof window === "undefined" ? null : window.document.body,
styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) },
}`
: ''
// Shared SqlDataSource runtime: row extraction, record writes, URL parameter
// binding and the actual Insert/Update/Delete calls.
const sqlHelpers = sqlDataSourceNodes.length
? ` const readSqlField = (record, field) => {
if (!record || typeof record !== "object" || Array.isArray(record)) return undefined
if (field in record) return record[field]
const matched = Object.keys(record).find((key) => key.toLowerCase() === String(field).toLowerCase())
return matched === undefined ? undefined : record[matched]
}
const toSqlRows = (value, collectionPath) => {
const source = collectionPath ? getByPath(value, collectionPath) : value
const rows = Array.isArray(source)
? source
: source && typeof source === "object"
? (Array.isArray(source.items) ? source.items : [source])
: []
return rows.filter((item) => item && typeof item === "object" && !Array.isArray(item))
}
const setSqlField = (record, path, value) => {
const keys = String(path || "").split(".").filter(Boolean)
if (!keys.length) return record
const next = { ...(record || {}) }
let target = next
for (let index = 0; index < keys.length - 1; index += 1) {
const child = target[keys[index]]
target[keys[index]] = child && typeof child === "object" && !Array.isArray(child) ? { ...child } : {}
target = target[keys[index]]
}
target[keys[keys.length - 1]] = value
return next
}
const bindSqlUrl = (url, record, keyField) => {
let keyBound = false
const boundUrl = String(url).replace(/\\{([^}]+)\\}|(?<=\\/):([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, colon) => {
const name = (braced || colon || "").trim()
if (!name) return match
const value = readSqlField(record, name) ?? readSqlField(record, keyField)
if (value === undefined || value === null || value === "") return match
if (name.toLowerCase() === String(keyField).toLowerCase()) keyBound = true
return encodeURIComponent(String(value))
})
return { url: boundUrl, keyBound }
}
const appendQueryParam = (url, name, value) =>
url + (url.includes("?") ? "&" : "?") + encodeURIComponent(name) + "=" + encodeURIComponent(value)
const hasSqlUrlParams = (url) => /\\{[^}]+\\}|(?<=\\/):[A-Za-z_][A-Za-z0-9_]*/.test(String(url))
const readUrlKey = (source, name) => {
if (typeof window === "undefined") return ""
if (source === "query") return new URLSearchParams(window.location.search).get(name) ?? ""
const segments = window.location.pathname.split("/").filter(Boolean)
return segments.length ? decodeURIComponent(segments[segments.length - 1]) : ""
}
const filterSqlRowsByKey = (rows, keyField, keyValue) => {
if (!keyValue) return rows
const matched = rows.filter((row) => String(readSqlField(row, keyField) ?? "") === String(keyValue))
// No match means the endpoint already filtered server side, or the key column
// is named differently — keeping the rows beats showing an empty form.
return matched.length ? matched : rows
}
// Update payload: only the columns the user actually edited, plus the key.
const toSqlChanges = (original, current, keyField) => {
const changes = {}
const isSame = (left, right) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
Object.keys(current || {}).forEach((column) => {
if (!isSame(original ? original[column] : undefined, current[column])) changes[column] = current[column]
})
const keyColumn = Object.keys(current || {}).find((column) => column.toLowerCase() === String(keyField).toLowerCase())
if (keyColumn !== undefined && changes[keyColumn] === undefined) changes[keyColumn] = current[keyColumn]
return changes
}
const callSqlEndpoint = (target, record, keyField, payload) => {
// The URL is always bound from the full record: a placeholder may reference a
// column that the update payload does not carry.
const bound = bindSqlUrl(target.url, record, keyField)
const request = { url: bound.url, method: target.method }
if (target.method === "POST" || target.method === "PUT") request.data = payload === undefined ? record : payload
const keyValue = readSqlField(record, keyField)
const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ""
// The key still has to reach the endpoint when the URL carries no placeholder.
if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue }
return apiService.fetchData(request)
}
const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."`
: ''
const sqlHooks = sqlDataSourceNodes
.map((node) => sqlDataSourceHooks(node, dataSources))
.join('\n\n')
2026-08-05 20:51:43 +00:00
const dataHelpers =
dataSources.length || hasDataTable || sqlDataSourceNodes.length
2026-08-05 20:51:43 +00:00
? ` const getByPath = (value, path) => {
if (!path) return value
const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target)
const directValue = readPath(value, path)
if (directValue !== undefined) return directValue
if (path.startsWith('result.')) return readPath(value, path.slice('result.'.length))
return value?.result === undefined ? undefined : readPath(value.result, path)
}`
: ''
const dataHooks = dataSources
.map((source) => {
const identifier = safeIdentifier(source.id)
// POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command;
// they hold no readable state and must never be written to.
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) {
2026-08-05 20:51:43 +00:00
return ` const [data_${identifier}] = React.useState(null)`
}
// A GetById style URL is not fetched on mount — requesting a literal `{id}`
// is a guaranteed 400 — and neither is a source the owning SqlDataSource
// loads itself. Both still need the setter that container writes through.
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)`
}
2026-08-05 20:51:43 +00:00
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)
React.useEffect(() => {
let active = true
apiService.fetchData({ url: ${JSON.stringify(source.url)}, method: 'GET' }).then((response) => {
if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')}))
}).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error))
return () => { active = false }
}, [])`
})
.join('\n\n')
// Declared after the data/SQL/runtime state it reads, and before the handlers
// and the mount effect that call into it.
const refRuntime = refTargets.length ? refRuntimeCode(refTargets) : ''
// An effect callback must not return a promise, so an awaiting onMount is run
// through an async IIFE instead of being made async itself.
2026-08-05 20:51:43 +00:00
const mount = document.lifecycle.onMount.trim()
? usesAwait(document.lifecycle.onMount)
? ` React.useEffect(() => {\n void (async () => {\n${indent(document.lifecycle.onMount, 3)}\n })()\n }, [])`
: ` React.useEffect(() => {\n${indent(document.lifecycle.onMount, 2)}\n }, [])`
2026-08-05 20:51:43 +00:00
: ''
const body = document.nodes.map((node) => nodeToCode(node, 3)).join('\n')
const designerBackup = encodeURIComponent(JSON.stringify(document))
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, dataHooks, sqlHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
2026-08-05 20:51:43 +00:00
}