Restructure repo into five-package AFI architecture

Mizan is an Application Framework Interface (AFI) with five
independent packages:

  packages/
    mizan-ast/       Language layer (source → KDL schema)
    mizan-schema/    IR layer (KDL schema definition)
    mizan-rpc/       Protocol layer (client gen + server adapters)
      adapters/django/   ← was django/
      generator/         ← was react/src/generator/
    mizan-csr/       State layer (client state engine)
      adapters/react/    ← was react/
    mizan-ssr/       Rendering layer (server-side rendering)

Each package is independent. The adapter directories contain the
framework-specific implementations. Stub packages (ast, schema, ssr)
establish the structure for future work.

264 Django tests + 33 React tests pass from new locations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:31:19 -04:00
parent 01d33173a4
commit b28ee72c67
139 changed files with 25 additions and 16 deletions

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env node
/**
* mizan Code Generator CLI
*
* Generate TypeScript types, React provider, and hooks from Django schemas.
*
* Usage:
* npx mizan-generate # Run once
* npx mizan-generate --watch # Watch mode
*/
import { promises as fs } from 'fs'
import path from 'path'
import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs'
import { generateMizanFiles } from './lib/mizan.mjs'
import { generateChannelsFiles } from './lib/channels.mjs'
import { generateIndex } from './lib/index.mjs'
// Use cwd — the script runs via `npx mizan-generate` from the frontend root
const frontendDir = process.cwd()
/**
* Load configuration from django.config.mjs
*/
async function loadConfig(configPath) {
const fullPath = path.resolve(frontendDir, configPath)
try {
await fs.access(fullPath)
} catch {
throw new Error(`Config file not found: ${fullPath}`)
}
// Convert to file:// URL for Windows compatibility
const fileUrl = new URL(`file://${fullPath.replace(/\\/g, '/')}`)
if (configPath.endsWith('.mjs') || configPath.endsWith('.js')) {
const module = await import(fileUrl)
return module.default
}
if (configPath.endsWith('.ts')) {
try {
const module = await import(fileUrl)
return module.default
} catch {
throw new Error(
`Cannot load TypeScript config directly. Either:\n` +
` 1. Install tsx: npm install -D tsx\n` +
` 2. Use django.config.mjs instead`
)
}
}
throw new Error(`Unsupported config file format: ${configPath}`)
}
/**
* Write generated code to file.
*/
async function writeOutput(filePath, content) {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, content, 'utf8')
}
/**
* Run schema generation.
*/
async function generate(config, options = {}) {
const { output } = options
console.log('[mizan] Starting schema generation...')
const outputPath = output || config.output || 'src/api/generated.ts'
let channelsSchema = null
let mizanSchema = null
// Fetch and generate channels if available
try {
console.log('[mizan] Fetching channels schema...')
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
const channelCount = channelsSchema['x-mizan-channels']?.length || 0
if (channelCount > 0) {
console.log(`[mizan] Found ${channelCount} channels`)
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
const channelsHooksPath = outputPath.replace(/\.ts$/, '.channels.hooks.tsx')
const fullChannelsHooksPath = path.resolve(frontendDir, channelsHooksPath)
const channelsSchemaPath = outputPath.replace(/\.ts$/, '.channels.schema.json')
const fullChannelsSchemaPath = path.resolve(frontendDir, channelsSchemaPath)
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
console.log(`[mizan] Generating -> ${channelsTypesPath}`)
await writeOutput(fullChannelsTypesPath, channelsTypes)
if (channelsHooks) {
console.log(`[mizan] Generating -> ${channelsHooksPath}`)
await writeOutput(fullChannelsHooksPath, channelsHooks)
}
console.log(`[mizan] Generating -> ${channelsSchemaPath}`)
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
} else {
console.log('[mizan] No channels registered, skipping channels generation')
}
} catch (err) {
console.log(`[mizan] Channels schema not available: ${err.message}`)
}
// Fetch and generate mizan files
try {
console.log('[mizan] Fetching mizan schema...')
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
const functionCount = mizanSchema['x-mizan-functions']?.length || 0
if (functionCount > 0) {
console.log(`[mizan] Found ${functionCount} mizan functions`)
const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts')
const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath)
const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx')
const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath)
const mizanServerPath = outputPath.replace(/\.ts$/, '.server.ts')
const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath)
const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath)
const mizanSchemaPath = outputPath.replace(/\.ts$/, '.mizan.schema.json')
const fullMizanSchemaPath = path.resolve(frontendDir, mizanSchemaPath)
const hasChannels = (channelsSchema?.['x-mizan-channels']?.length || 0) > 0
const { types: mizanTypes, provider: mizanProvider, server: mizanServer, forms: mizanForms } = await generateMizanFiles(mizanSchema, { hasChannels })
console.log(`[mizan] Generating -> ${mizanTypesPath}`)
await writeOutput(fullMizanTypesPath, mizanTypes)
if (mizanProvider) {
console.log(`[mizan] Generating -> ${mizanProviderPath}`)
await writeOutput(fullMizanProviderPath, mizanProvider)
}
if (mizanServer) {
console.log(`[mizan] Generating -> ${mizanServerPath}`)
await writeOutput(fullMizanServerPath, mizanServer)
}
if (mizanForms) {
console.log(`[mizan] Generating -> ${mizanFormsPath}`)
await writeOutput(fullMizanFormsPath, mizanForms)
}
console.log(`[mizan] Generating -> ${mizanSchemaPath}`)
await writeOutput(fullMizanSchemaPath, JSON.stringify(mizanSchema, null, 2))
} else {
console.log('[mizan] No mizan functions registered, skipping mizan generation')
}
} catch (err) {
console.log(`[mizan] mizan schema not available: ${err.message}`)
}
// Generate consolidated index.ts
const indexPath = path.dirname(outputPath) + '/index.ts'
const fullIndexPath = path.resolve(frontendDir, indexPath)
console.log(`[mizan] Generating -> ${indexPath}`)
const indexContent = generateIndex({
channelsSchema,
mizanSchema,
})
await writeOutput(fullIndexPath, indexContent)
console.log('[mizan] Generation complete!')
}
/**
* Watch for changes and regenerate.
*/
async function watch(config, options) {
const debounce = config.watch?.debounce || 1000
let timeout = null
let running = false
async function runGenerate() {
if (running) {
timeout = setTimeout(runGenerate, debounce)
return
}
running = true
try {
await generate(config, options)
} catch (err) {
console.error('[mizan] Generation failed:', err.message)
} finally {
running = false
}
}
await runGenerate()
console.log('[mizan] Watching for changes (press Ctrl+C to stop)...')
if (config.source.django) {
const { watch: chokidarWatch } = await import('chokidar')
const djangoDir = path.resolve(frontendDir, path.dirname(config.source.django.managePath))
const watcher = chokidarWatch([
path.join(djangoDir, '**/*.py'),
], {
ignored: [
'**/node_modules/**',
'**/__pycache__/**',
'**/migrations/**',
'**/.venv/**',
],
ignoreInitial: true,
})
watcher.on('change', (filePath) => {
console.log(`[mizan] Detected change: ${path.relative(djangoDir, filePath)}`)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(runGenerate, debounce)
})
}
process.on('SIGINT', () => {
console.log('\n[mizan] Stopping watch mode...')
process.exit(0)
})
}
/**
* Main entry point.
*/
async function main() {
const args = process.argv.slice(2)
let configPath = 'django.config.mjs'
let watchMode = false
let output = null
for (let i = 0; i < args.length; i++) {
if (args[i] === '--config' || args[i] === '-c') {
configPath = args[++i]
} else if (args[i] === '--watch' || args[i] === '-w') {
watchMode = true
} else if (args[i] === '--output' || args[i] === '-o') {
output = args[++i]
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
mizan Code Generator - Generate TypeScript from Django schemas
Usage:
npx mizan-generate [options]
Options:
-c, --config <path> Config file path (default: django.config.mjs)
-w, --watch Watch mode - regenerate on changes
-o, --output <path> Output file path (overrides config)
-h, --help Show this help message
`)
process.exit(0)
}
}
const config = await loadConfig(configPath)
const options = { output }
if (watchMode) {
await watch(config, options)
} else {
await generate(config, options)
}
}
main().catch(err => {
console.error('[mizan] Error:', err.message)
process.exit(1)
})

View File

@@ -0,0 +1,155 @@
/**
* Channels Code Generator
*
* Generates TypeScript types and React hooks from Channels OpenAPI schema.
* Uses openapi-typescript for robust type generation.
*/
import openapiTS, { astToString } from 'openapi-typescript'
/**
* Generate channels TypeScript types using openapi-typescript.
*/
export async function generateChannelsTypes(schema) {
// Generate types using openapi-typescript
const ast = await openapiTS(schema)
const typesCode = astToString(ast)
const lines = [
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// ============================================================================',
'// OpenAPI Types (generated by openapi-typescript)',
'// ============================================================================',
'',
typesCode,
'',
]
// Extract channel metadata from x-mizan-channels extension
const channels = schema['x-mizan-channels'] || []
if (channels.length > 0) {
lines.push('// ============================================================================')
lines.push('// Convenience Type Exports')
lines.push('// ============================================================================')
lines.push('')
for (const channel of channels) {
if (channel.hasParams) {
lines.push(`export type ${channel.paramsType} = components["schemas"]["${channel.paramsType}"]`)
}
if (channel.hasReactMessage) {
lines.push(`export type ${channel.reactMessageType} = components["schemas"]["${channel.reactMessageType}"]`)
}
if (channel.hasDjangoMessage) {
lines.push(`export type ${channel.djangoMessageType} = components["schemas"]["${channel.djangoMessageType}"]`)
}
}
lines.push('')
lines.push('// ============================================================================')
lines.push('// Channel Registry')
lines.push('// ============================================================================')
lines.push('')
lines.push('export const CHANNELS = {')
for (const channel of channels) {
lines.push(` ${channel.name}: {`)
lines.push(` name: '${channel.name}',`)
lines.push(` pascalName: '${channel.pascalName}',`)
lines.push(` hasParams: ${channel.hasParams},`)
lines.push(` hasReactMessage: ${channel.hasReactMessage},`)
lines.push(` hasDjangoMessage: ${channel.hasDjangoMessage},`)
if (channel.hasParams) {
lines.push(` paramsType: '${channel.paramsType}',`)
}
if (channel.hasReactMessage) {
lines.push(` reactMessageType: '${channel.reactMessageType}',`)
}
if (channel.hasDjangoMessage) {
lines.push(` djangoMessageType: '${channel.djangoMessageType}',`)
}
lines.push(` },`)
}
lines.push('} as const')
} else {
lines.push('export const CHANNELS = {} as const')
}
lines.push('')
return lines.join('\n')
}
/**
* Generate channel hooks from metadata.
*/
export function generateChannelsHooks(schema) {
const channels = schema['x-mizan-channels'] || []
if (channels.length === 0) {
return null
}
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
"import { useChannel, type ChannelSubscription } from 'mizan/channels'",
'',
]
// Collect type imports
const typeImports = []
for (const channel of channels) {
if (channel.hasParams) typeImports.push(channel.paramsType)
if (channel.hasReactMessage) typeImports.push(channel.reactMessageType)
if (channel.hasDjangoMessage) typeImports.push(channel.djangoMessageType)
}
if (typeImports.length > 0) {
lines.push(`import type { ${typeImports.join(', ')} } from './generated.channels'`)
lines.push('')
}
// Generate hooks for each channel
lines.push('// ============================================================================')
lines.push('// Channel Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const channel of channels) {
const paramsType = channel.hasParams ? channel.paramsType : 'Record<string, never>'
const reactMsgType = channel.hasReactMessage ? channel.reactMessageType : 'never'
const djangoMsgType = channel.hasDjangoMessage ? channel.djangoMessageType : 'never'
lines.push(`/**`)
lines.push(` * Hook for the ${channel.name} channel.`)
lines.push(` */`)
if (channel.hasParams) {
lines.push(`export function use${channel.pascalName}Channel(params: ${paramsType}): ChannelSubscription<${paramsType}, ${djangoMsgType}, ${reactMsgType}> {`)
lines.push(` return useChannel('${channel.name}', params)`)
} else {
lines.push(`export function use${channel.pascalName}Channel(): ChannelSubscription<Record<string, never>, ${djangoMsgType}, ${reactMsgType}> {`)
lines.push(` return useChannel('${channel.name}', {})`)
}
lines.push('}')
lines.push('')
}
return lines.join('\n')
}
/**
* Generate all channels files.
*/
export async function generateChannelsFiles(schema) {
const types = await generateChannelsTypes(schema)
const hooks = generateChannelsHooks(schema)
return { types, hooks }
}

View File

@@ -0,0 +1,88 @@
/**
* Schema Fetching
*
* Fetches mizan and channels schemas from Django management commands.
*/
import { spawn } from 'child_process'
import path from 'path'
/**
* Run a Django management command and parse JSON output.
*/
function runDjangoCommand(source, cwd, command) {
const managePath = path.resolve(cwd, source.django.managePath)
const manageDir = path.dirname(managePath)
let cmd, args
if (source.django.command) {
cmd = source.django.command[0]
args = [...source.django.command.slice(1), 'manage.py', command, '--indent', '0']
} else {
const python = source.django.python || 'python'
cmd = python
args = [managePath, command, '--indent', '0']
}
const env = source.django.env
? { ...process.env, ...source.django.env }
: undefined
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, {
cwd: manageDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env,
})
let stdout = ''
let stderr = ''
proc.stdout.on('data', (data) => { stdout += data.toString() })
proc.stderr.on('data', (data) => { stderr += data.toString() })
proc.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Django command failed (exit ${code}):\n${stderr}`))
return
}
const jsonStart = stdout.indexOf('{')
if (jsonStart === -1) {
reject(new Error(`No JSON found in Django output:\n${stdout}\n${stderr}`))
return
}
try {
resolve(JSON.parse(stdout.slice(jsonStart)))
} catch (err) {
reject(new Error(`Failed to parse JSON from Django:\n${err.message}\n${stdout}`))
}
})
proc.on('error', (err) => {
reject(new Error(`Failed to spawn Django command: ${err.message}`))
})
})
}
/**
* Fetch channels schema from Django.
*/
export async function fetchChannelsSchema(source, cwd) {
if (!source.django) {
throw new Error('Channels schema export requires django source configuration')
}
return runDjangoCommand(source, cwd, 'export_channels_schema')
}
/**
* Fetch mizan schema from Django.
*/
export async function fetchMizanSchema(source, cwd) {
if (!source.django) {
throw new Error('mizan schema export requires django source configuration')
}
return runDjangoCommand(source, cwd, 'export_mizan_schema')
}

View File

@@ -0,0 +1,164 @@
/**
* Index File Generator
*
* Generates a consolidated index.ts that re-exports everything
* from the generated files for clean imports.
*/
function pascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function toPascalCase(str) {
return str
.split(/[.\-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* Generate the consolidated index.ts file.
*/
export function generateIndex({ channelsSchema, mizanSchema }) {
const lines = [
'/**',
' * mizan API - Consolidated Exports',
' *',
' * Import everything from here:',
' *',
' * @example',
' * ```tsx',
' * import {',
' * MizanContext,',
' * useCurrentUser,',
' * useEcho,',
' * useChatChannel,',
' * } from \'@/api\'',
' * ```',
' */',
'',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
]
const functions = mizanSchema?.['x-mizan-functions'] || []
const contextGroups = mizanSchema?.['x-mizan-contexts'] || {}
const hasMizan = functions.length > 0
if (hasMizan) {
const globalContexts = functions.filter(fn => fn.isContext === 'global')
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
lines.push('// =============================================================================')
lines.push('// mizan Provider & Hooks')
lines.push('// =============================================================================')
lines.push('')
// Server exports
if (globalContexts.length > 0) {
lines.push('export {')
lines.push(' getMizanHydration,')
lines.push(' getDjangoHydration,')
lines.push(' type MizanHydrationData,')
lines.push(' type DjangoHydration,')
lines.push("} from './generated.server'")
lines.push('')
}
// Client exports
lines.push('export {')
lines.push(' // Provider')
lines.push(' MizanContext,')
lines.push(' type MizanContextProps,')
lines.push(' DjangoContext,')
lines.push(' type DjangoContextProps,')
// Global context hooks
if (globalContexts.length > 0) {
lines.push('')
lines.push(' // Global context hooks')
for (const ctx of globalContexts) {
const hookPascal = pascalCase(ctx.camelName)
lines.push(` use${hookPascal},`)
}
lines.push('')
lines.push(' // Refresh hooks')
lines.push(' useMizanRefresh,')
lines.push(' useDjangoRefresh,')
}
// Named context providers and hooks
if (namedContextEntries.length > 0) {
lines.push('')
lines.push(' // Named context providers')
for (const [ctxName, ctxMeta] of namedContextEntries) {
const ctxPascal = toPascalCase(ctxName)
lines.push(` ${ctxPascal}Context,`)
// Hooks for this context's functions
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(` use${hookPascal},`)
}
}
}
// Function hooks (mutations + plain)
if (regularFunctions.length > 0) {
lines.push('')
lines.push(' // Function hooks')
for (const fn of regularFunctions) {
const pascal = pascalCase(fn.camelName)
lines.push(` use${pascal},`)
}
}
lines.push('')
lines.push(' // Re-exports from mizan library')
lines.push(' useMizan,')
lines.push(' useMizanStatus,')
lines.push(' usePush,')
lines.push(' DjangoError,')
lines.push(' type ConnectionStatus,')
lines.push(' type PushMessage,')
lines.push(' type PushListener,')
lines.push("} from './generated.provider'")
lines.push('')
}
// ==========================================================================
// Channel Hooks
// ==========================================================================
const channels = channelsSchema?.['x-mizan-channels'] || []
if (channels.length > 0) {
lines.push('// =============================================================================')
lines.push('// Channel Hooks')
lines.push('// =============================================================================')
lines.push('')
lines.push('export {')
for (const ch of channels) {
lines.push(` use${ch.pascalName}Channel,`)
}
lines.push("} from './generated.channels.hooks'")
lines.push('')
lines.push('// =============================================================================')
lines.push('// Channel Types')
lines.push('// =============================================================================')
lines.push('')
lines.push('export type {')
for (const ch of channels) {
if (ch.hasParams) lines.push(` ${ch.paramsType},`)
if (ch.hasReactMessage) lines.push(` ${ch.reactMessageType},`)
if (ch.hasDjangoMessage) lines.push(` ${ch.djangoMessageType},`)
}
lines.push("} from './generated.channels'")
lines.push('')
}
return lines.join('\n')
}

View File

@@ -0,0 +1,959 @@
/**
* mizan Code Generator
*
* Generates TypeScript types and React provider from mizan OpenAPI schema.
* Uses openapi-typescript for robust type generation.
*
* Output structure:
* - generated.mizan.ts - Types only (from OpenAPI)
* - generated.provider.tsx - Typed provider wrapping MizanProvider + hooks
* - generated.forms.ts - Typed form hooks with Zod schemas
*/
import openapiTS, { astToString } from 'openapi-typescript'
// TypeScript SyntaxKind values for AST manipulation
const SyntaxKind = {
InterfaceDeclaration: 265,
TypeAliasDeclaration: 266,
PropertySignature: 172,
TypeReference: 184,
IndexedAccessType: 200,
Identifier: 80,
StringLiteral: 11,
}
/**
* Get identifier name from AST node.
*/
function idName(node) {
return node?.kind === SyntaxKind.Identifier ? node.escapedText : undefined
}
/**
* Extract schema names from openapi-typescript AST.
*/
function getSchemaNamesFromAst(ast) {
if (!Array.isArray(ast)) return []
const componentsNode = ast.find(
node =>
node?.kind === SyntaxKind.InterfaceDeclaration &&
idName(node?.name) === 'components'
)
if (!componentsNode?.members) return []
const schemasProp = componentsNode.members.find(
member =>
member?.kind === SyntaxKind.PropertySignature &&
idName(member?.name) === 'schemas' &&
Array.isArray(member?.type?.members)
)
if (!schemasProp) return []
return schemasProp.type.members
.map(member =>
member?.kind === SyntaxKind.PropertySignature ? idName(member.name) : undefined
)
.filter(n => typeof n === 'string')
}
/**
* Build convenience type exports for schemas.
*/
function buildSchemaExports(schemaNames) {
if (!schemaNames.length) return ''
return schemaNames
.map(name => `export type ${name} = components["schemas"]["${name}"]`)
.join('\n')
}
/**
* Generate the types file using openapi-typescript.
*/
export async function generateMizanTypes(schema) {
// Generate types using openapi-typescript
const ast = await openapiTS(schema)
const schemaNames = getSchemaNamesFromAst(ast)
const typesCode = astToString(ast)
const lines = [
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// ============================================================================',
'// OpenAPI Types (generated by openapi-typescript)',
'// ============================================================================',
'',
typesCode,
'',
'// ============================================================================',
'// Convenience Type Exports',
'// ============================================================================',
'',
buildSchemaExports(schemaNames),
'',
'// ============================================================================',
'// Function Registry (for reference)',
'// ============================================================================',
'',
"export type Transport = 'http' | 'websocket' | 'both'",
'',
]
// Extract function metadata from x-mizan-functions extension
const functions = schema['x-mizan-functions'] || []
if (functions.length > 0) {
lines.push('export const MIZAN_FUNCTIONS = {')
for (const fn of functions) {
lines.push(` ${fn.camelName}: {`)
lines.push(` name: '${fn.name}',`)
lines.push(` hasInput: ${fn.hasInput},`)
lines.push(` isContext: ${fn.isContext},`)
lines.push(` transport: '${fn.transport}' as Transport,`)
lines.push(` },`)
}
lines.push('} as const')
} else {
lines.push('export const MIZAN_FUNCTIONS = {} as const')
}
lines.push('')
return lines.join('\n')
}
/**
* Extract unique context names from an affects array.
* Both context-level and function-level affects resolve to context names.
*/
function getAffectedContexts(affects) {
const contexts = new Set()
for (const target of affects) {
if (target.type === 'context') {
contexts.add(target.name)
} else if (target.type === 'function' && target.context) {
contexts.add(target.context)
}
}
return [...contexts]
}
/**
* Map JSON schema type string to TypeScript type.
*/
function jsonTypeToTS(type) {
if (type === 'integer' || type === 'number') return 'number'
if (type === 'boolean') return 'boolean'
return 'string'
}
/**
* Generate the React provider that wraps MizanProvider with typed hooks.
*
* The generated provider:
* - MizanContext: Root provider with global context bundled fetch
* - Named context providers: <UserContext user_id={...}>
* - Mutation hooks with auto-invalidation
* - Plain function hooks
*/
export function generateMizanProvider(schema, options = {}) {
const { hasChannels = false } = options
const functions = schema['x-mizan-functions'] || []
const contextGroups = schema['x-mizan-contexts'] || {}
if (functions.length === 0) {
return null
}
// Partition functions
const globalContexts = functions.filter(fn => fn.isContext === 'global')
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
const mutationFunctions = regularFunctions.filter(fn => fn.affects)
const plainFunctions = regularFunctions.filter(fn => !fn.affects)
// Named context groups (everything except 'global')
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
// Collect type imports
const typeImports = []
for (const fn of functions) {
if (fn.hasInput && fn.inputType) {
typeImports.push(fn.inputType)
}
if (fn.outputType) {
typeImports.push(fn.outputType)
}
}
const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// This file provides typed wrappers around the mizan library.',
'// - MizanContext: Root provider with global context',
'// - Named context providers: <UserContext user_id={...}>',
'// - Typed hooks with auto-invalidation',
'',
"import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'",
"import {",
" MizanProvider,",
" useMizan,",
" useMizanContext,",
" useMizanCall,",
" type MizanHydration,",
" type Transport,",
"} from 'mizan'",
...(hasChannels ? [
"import { ChannelProvider, ChannelConnection } from 'mizan/channels'",
] : []),
'',
]
if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
lines.push('')
}
// ============================================================================
// Hydration types (global contexts only)
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Hydration Types')
lines.push('// ============================================================================')
lines.push('')
if (globalContexts.length > 0) {
lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface MizanHydrationData {')
for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
}
lines.push('}')
lines.push('')
lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {')
lines.push(' if (!hydration) return undefined')
lines.push(' const result: MizanHydration = {}')
for (const ctx of globalContexts) {
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
}
lines.push(' return result')
lines.push('}')
lines.push('')
}
// ============================================================================
// Global Context Loader (inner component, fetches GET /ctx/global/)
// ============================================================================
if (globalContexts.length > 0) {
lines.push('// ============================================================================')
lines.push('// Global Context Loader')
lines.push('// ============================================================================')
lines.push('')
lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {')
lines.push(' const mizan = useMizan()')
lines.push(' const loaded = useRef(false)')
lines.push('')
lines.push(' useEffect(() => {')
lines.push(' if (loaded.current) return')
lines.push(' loaded.current = true')
lines.push('')
lines.push(' ;(async () => {')
lines.push(' await mizan.whenReady')
lines.push(' try {')
lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)")
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
lines.push(' for (const [name, data] of Object.entries(result.data)) {')
lines.push(' mizan.setContextData(name, data)')
lines.push(' }')
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[MizanContext] Global context fetch failed:', e)")
lines.push(' }')
lines.push(' })()')
lines.push(' }, [mizan])')
lines.push('')
lines.push(' return <>{children}</>')
lines.push('}')
lines.push('')
}
// ============================================================================
// Root Provider (MizanContext)
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Root Provider')
lines.push('// ============================================================================')
lines.push('')
lines.push('export interface MizanContextProps {')
lines.push(' children: ReactNode')
if (globalContexts.length > 0) {
lines.push(' /** SSR hydration data (global contexts only) */')
lines.push(' hydration?: MizanHydrationData')
}
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
lines.push(' wsUrl?: string')
lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */')
lines.push(' baseUrl?: string')
lines.push('}')
lines.push('')
lines.push('/**')
lines.push(' * Root mizan provider. Mount at your app root.')
lines.push(' *')
lines.push(' * Usage:')
lines.push(' * <MizanContext hydration={hydration}>')
lines.push(' * <App />')
lines.push(' * </MizanContext>')
lines.push(' */')
lines.push('export function MizanContext({')
lines.push(' children,')
if (globalContexts.length > 0) {
lines.push(' hydration,')
}
lines.push(' wsUrl,')
lines.push(' baseUrl,')
lines.push('}: MizanContextProps) {')
if (hasChannels) {
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
lines.push(' if (!connectionRef.current) {')
lines.push(" connectionRef.current = new ChannelConnection({ url: wsUrl || '/ws/' })")
lines.push(' }')
lines.push('')
}
// Build the JSX tree
lines.push(' return (')
lines.push(' <MizanProvider')
if (globalContexts.length > 0) {
lines.push(' hydration={toMizanHydration(hydration)}')
}
lines.push(' wsUrl={wsUrl}')
lines.push(' baseUrl={baseUrl}')
if (hasChannels) {
lines.push(' connection={connectionRef.current}')
}
lines.push(' >')
// Inner content: GlobalContextLoader wraps children if needed
let innerContent = '{children}'
if (globalContexts.length > 0) {
innerContent = `<GlobalContextLoader>{children}</GlobalContextLoader>`
}
if (hasChannels) {
lines.push(` <ChannelProvider connection={connectionRef.current} autoConnect={true}>`)
lines.push(` ${innerContent}`)
lines.push(` </ChannelProvider>`)
} else {
lines.push(` ${innerContent}`)
}
lines.push(' </MizanProvider>')
lines.push(' )')
lines.push('}')
lines.push('')
// Legacy alias
lines.push('/** @deprecated Use MizanContext instead */')
lines.push('export const DjangoContext = MizanContext')
lines.push('/** @deprecated Use MizanContextProps instead */')
lines.push('export type DjangoContextProps = MizanContextProps')
if (globalContexts.length > 0) {
lines.push('/** @deprecated Use MizanHydrationData instead */')
lines.push('export type DjangoHydration = MizanHydrationData')
}
lines.push('')
// ============================================================================
// Global Context Hooks
// ============================================================================
if (globalContexts.length > 0) {
lines.push('// ============================================================================')
lines.push('// Global Context Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName)
lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`)
lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`)
lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` return data`)
lines.push(`}`)
lines.push('')
}
lines.push('/** Refresh functions for global contexts. */')
lines.push('export function useMizanRefresh() {')
lines.push(' const { invalidateContext } = useMizan()')
lines.push(' return {')
for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName)
lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`)
}
lines.push(' }')
lines.push('}')
lines.push('')
// Legacy alias
lines.push('/** @deprecated Use useMizanRefresh instead */')
lines.push('export const useDjangoRefresh = useMizanRefresh')
lines.push('')
}
// ============================================================================
// Named Context Providers
// ============================================================================
if (namedContextEntries.length > 0) {
lines.push('// ============================================================================')
lines.push('// Named Context Providers')
lines.push('// ============================================================================')
lines.push('')
for (const [ctxName, ctxMeta] of namedContextEntries) {
const ctxPascal = toPascalCase(ctxName)
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
const params = ctxMeta.params || {}
const paramEntries = Object.entries(params)
// Internal React context type
lines.push(`const ${ctxPascal}ContextInternal = createContext<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(`} | null>(null)`)
lines.push('')
// Props interface
lines.push(`export interface ${ctxPascal}ContextProps {`)
lines.push(` children: ReactNode`)
for (const [pName, pMeta] of paramEntries) {
const tsType = jsonTypeToTS(pMeta.type)
const optional = pMeta.required ? '' : '?'
lines.push(` ${pName}${optional}: ${tsType}`)
}
lines.push(`}`)
lines.push('')
// Provider component
lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`)
lines.push(` const mizan = useMizan()`)
lines.push(` const [data, setData] = useState<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(` } | null>(null)`)
lines.push('')
lines.push(` const refetch = useCallback(async () => {`)
lines.push(` await mizan.whenReady`)
lines.push(` const qs = new URLSearchParams()`)
for (const [pName] of paramEntries) {
lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`)
}
lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`)
lines.push(` const result = await resp.json()`)
lines.push(` if (!result.error) setData(result.data)`)
// Dependency array: mizan + each param
const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)]
lines.push(` }, [${deps.join(', ')}])`)
lines.push('')
lines.push(` useEffect(() => { refetch() }, [refetch])`)
lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`)
lines.push('')
lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}</${ctxPascal}ContextInternal>`)
lines.push(`}`)
lines.push('')
// Individual data hooks
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(`export function use${hookPascal}(): ${fn.outputType} {`)
lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`)
lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`)
lines.push(` return ctx.${fn.name}`)
lines.push(`}`)
lines.push('')
}
}
}
// ============================================================================
// Mutation Hooks (with auto-invalidation)
// ============================================================================
if (mutationFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Mutation Hooks (auto-invalidate on success)')
lines.push('// ============================================================================')
lines.push('')
for (const fn of mutationFunctions) {
const pascal = pascalCase(fn.camelName)
const transport = fn.transport || 'http'
const affectedContexts = getAffectedContexts(fn.affects)
lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` const mizan = useMizan()`)
if (fn.hasInput) {
lines.push(` return useCallback(async (input: ${fn.inputType}) => {`)
lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`)
} else {
lines.push(` return useCallback(async () => {`)
lines.push(` const result = await mizan.call<void, ${fn.outputType}>('${fn.name}', undefined, '${transport}')`)
}
// Invalidation
if (affectedContexts.length === 1) {
lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`)
} else if (affectedContexts.length > 1) {
lines.push(` await Promise.all([`)
for (const ctx of affectedContexts) {
lines.push(` mizan.invalidateContext('${ctx}'),`)
}
lines.push(` ])`)
}
lines.push(` return result`)
lines.push(` }, [mizan])`)
lines.push(`}`)
lines.push('')
}
}
// ============================================================================
// Plain Function Hooks
// ============================================================================
if (plainFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Function Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const fn of plainFunctions) {
const pascal = pascalCase(fn.camelName)
const transport = fn.transport || 'http'
if (fn.hasInput) {
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`)
} else {
lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` return useMizanCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`)
}
lines.push('')
}
}
// ============================================================================
// Re-exports
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Re-exports from mizan library')
lines.push('// ============================================================================')
lines.push('')
lines.push("export { useMizan, useMizanStatus, usePush, DjangoError } from 'mizan'")
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'")
lines.push('')
return lines.join('\n')
}
/**
* Generate server-side hydration helper (runs in Next.js server components).
* This is separate from the client file because it needs to run on the server.
*/
export function generateMizanServer(schema) {
const functions = schema['x-mizan-functions'] || []
const globalContexts = functions.filter(fn => fn.isContext === 'global')
if (globalContexts.length === 0) {
return null
}
// Collect type imports for global contexts
const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean)
const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'//',
'// Server-side functions for SSR hydration.',
'// These run in Next.js server components/layouts.',
'',
]
if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`)
lines.push('')
}
// Hydration type
lines.push('// ============================================================================')
lines.push('// Hydration Types')
lines.push('// ============================================================================')
lines.push('')
lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface MizanHydrationData {')
for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
}
lines.push('}')
lines.push('')
lines.push('/** @deprecated Use MizanHydrationData instead */')
lines.push('export type DjangoHydration = MizanHydrationData')
lines.push('')
// SSR Hydration Helper — single bundled GET
lines.push('// ============================================================================')
lines.push('// SSR Hydration Helper')
lines.push('// ============================================================================')
lines.push('')
lines.push('/**')
lines.push(' * Fetch hydration data for SSR via bundled context endpoint.')
lines.push(' *')
lines.push(' * Call this in your server component:')
lines.push(' * const hydration = await getMizanHydration(client)')
lines.push(' * return <MizanContext hydration={hydration}>...</MizanContext>')
lines.push(' */')
lines.push('export async function getMizanHydration(')
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
lines.push('): Promise<MizanHydrationData> {')
lines.push(' const hydration: MizanHydrationData = {}')
lines.push('')
lines.push(' try {')
lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
for (const ctx of globalContexts) {
lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`)
}
lines.push(' } else {')
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[getMizanHydration] Request failed:', e)")
lines.push(' }')
lines.push('')
lines.push(' return hydration')
lines.push('}')
lines.push('')
lines.push('/** @deprecated Use getMizanHydration instead */')
lines.push('export const getDjangoHydration = getMizanHydration')
lines.push('')
return lines.join('\n')
}
/**
* Generate all mizan files.
*/
export async function generateMizanFiles(schema, options = {}) {
const types = await generateMizanTypes(schema)
const provider = generateMizanProvider(schema, options)
const server = generateMizanServer(schema)
const forms = generateMizanForms(schema)
return { types, provider, server, forms }
}
/**
* Generate typed form hooks with Zod schemas.
*/
export function generateMizanForms(schema) {
const functions = schema['x-mizan-functions'] || []
// Group form functions by form name
const formFunctions = functions.filter(fn => fn.isForm)
const formGroups = new Map()
for (const fn of formFunctions) {
const formName = fn.formName
if (!formGroups.has(formName)) {
formGroups.set(formName, { schema: null, validate: null, submit: null, formset: {} })
}
const group = formGroups.get(formName)
if (fn.formRole === 'schema') {
group.schema = fn
group.formFields = fn.formFields || []
} else if (fn.formRole === 'validate') {
group.validate = fn
} else if (fn.formRole === 'submit') {
group.submit = fn
} else if (fn.formRole === 'formset_schema') {
group.formset.schema = fn
} else if (fn.formRole === 'formset_validate') {
group.formset.validate = fn
} else if (fn.formRole === 'formset_submit') {
group.formset.submit = fn
}
}
if (formGroups.size === 0) {
return null
}
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by mizan - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// Typed form hooks with Zod validation.',
'// Zod schemas are generated from Django form field definitions.',
'// Client-side validation matches Django constraints (required, max_length, email, etc.)',
'',
"import { z } from 'zod'",
"import {",
" useDjangoFormCore,",
" useDjangoFormsetCore,",
" type DjangoFormState,",
" type DjangoFormsetState,",
" type FormOptions,",
"} from 'mizan'",
'',
'// ============================================================================',
'// Zod Schemas',
'// ============================================================================',
'',
]
// Generate Zod schemas for each form
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
const schemaName = `${pascalName}Schema`
const fields = group.formFields || []
lines.push(`/**`)
lines.push(` * Zod schema for ${formName} form`)
lines.push(` * Generated from Django form field definitions`)
lines.push(` */`)
lines.push(`export const ${schemaName} = z.object({`)
for (const field of fields) {
const zodField = generateZodField(field)
lines.push(` ${field.name}: ${zodField},`)
}
lines.push(`})`)
lines.push('')
}
// Generate TypeScript types from Zod schemas
lines.push('// ============================================================================')
lines.push('// Form Data Types (inferred from Zod schemas)')
lines.push('// ============================================================================')
lines.push('')
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
const schemaName = `${pascalName}Schema`
const typeName = `${pascalName}FormData`
lines.push(`/** Form data type for ${formName}, inferred from Zod schema */`)
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>`)
lines.push('')
}
lines.push('// ============================================================================')
lines.push('// Form Hooks')
lines.push('// ============================================================================')
lines.push('')
// Generate hooks for each form
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
const hookName = `use${pascalName}Form`
const typeName = `${pascalName}FormData`
const schemaName = `${pascalName}Schema`
lines.push(`/**`)
lines.push(` * Typed form hook for ${formName}`)
lines.push(` *`)
lines.push(` * Features:`)
lines.push(` * - Full TypeScript inference for form fields`)
lines.push(` * - Client-side Zod validation (instant feedback)`)
lines.push(` * - Server-side Django validation (authoritative)`)
lines.push(` */`)
lines.push(`export function ${hookName}(`)
lines.push(` options?: FormOptions`)
lines.push(`): DjangoFormState<${typeName}> {`)
lines.push(` return useDjangoFormCore<${typeName}>({`)
lines.push(` name: '${formName}',`)
lines.push(` zodSchema: ${schemaName},`)
lines.push(` options,`)
lines.push(` })`)
lines.push(`}`)
lines.push('')
// Generate formset hook if formset is enabled
if (group.formset.schema) {
const formsetHookName = `use${pascalName}Formset`
lines.push(`/**`)
lines.push(` * Typed formset hook for ${formName}`)
lines.push(` */`)
lines.push(`export function ${formsetHookName}(`)
lines.push(` initialCount?: number,`)
lines.push(` liveValidation?: boolean`)
lines.push(`): DjangoFormsetState<${typeName}> {`)
lines.push(` return useDjangoFormsetCore<${typeName}>({`)
lines.push(` name: '${formName}',`)
lines.push(` zodSchema: ${schemaName},`)
lines.push(` initialCount,`)
lines.push(` liveValidation,`)
lines.push(` })`)
lines.push(`}`)
lines.push('')
}
}
// Export list of form names for reference
lines.push('// ============================================================================')
lines.push('// Form Registry')
lines.push('// ============================================================================')
lines.push('')
lines.push('export const MIZAN_FORMS = {')
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
lines.push(` ${toCamelCase(formName)}: {`)
lines.push(` name: '${formName}',`)
lines.push(` schema: ${pascalName}Schema,`)
lines.push(` hook: 'use${pascalName}Form',`)
lines.push(` hasFormset: ${!!group.formset.schema},`)
lines.push(` },`)
}
lines.push('} as const')
lines.push('')
return lines.join('\n')
}
/**
* Generate a Zod field definition from Django field metadata.
*/
function generateZodField(field) {
const { zodType, required, constraints } = field
let zodCode = ''
// Base type
switch (zodType) {
case 'boolean':
zodCode = 'z.boolean()'
break
case 'number':
zodCode = 'z.number()'
if (constraints.int) {
zodCode += '.int()'
}
break
case 'array':
zodCode = `z.array(z.${constraints.items || 'string'}())`
break
case 'file':
zodCode = 'z.any()'
break
default:
zodCode = 'z.string()'
}
// Add constraints
if (zodType === 'string') {
if (constraints.email) {
zodCode += ".email('Invalid email address')"
} else if (constraints.url) {
zodCode += ".url('Invalid URL')"
}
if (constraints.regex) {
const escapedRegex = constraints.regex.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
const message = constraints.regexMessage || 'Invalid format'
zodCode += `.regex(new RegExp('${escapedRegex}'), '${message}')`
}
if (constraints.min !== undefined) {
zodCode += `.min(${constraints.min})`
}
if (constraints.max !== undefined) {
zodCode += `.max(${constraints.max})`
}
} else if (zodType === 'number') {
if (constraints.min !== undefined) {
zodCode += `.min(${constraints.min})`
}
if (constraints.max !== undefined) {
zodCode += `.max(${constraints.max})`
}
}
// Handle optional fields
if (!required) {
if (zodType === 'boolean') {
zodCode += '.default(false)'
} else {
zodCode += '.optional()'
}
}
return zodCode
}
/**
* Convert form name to PascalCase for type names.
*/
function toPascalCase(str) {
return str
.split(/[.\-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* Convert form name to camelCase for object keys.
*/
function toCamelCase(str) {
const pascal = toPascalCase(str)
return pascal.charAt(0).toLowerCase() + pascal.slice(1)
}
/**
* Convert camelCase to PascalCase.
*/
function pascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}