Restructure tree by role; rename mizan-runtime → mizan-base
packages/ flattens into: backends/ server protocol adapters (mizan-django, mizan-ts) frontends/ client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte) workers/ runtime workers (mizan-ssr) cores/ shared language-level primitives (empty for now; mizan-python forthcoming) The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is renamed to reflect its role — it's the shared base that frontend adapters depend on directly. Reflects the substrate position that per-framework adapters wrap a single shared kernel; codegen targets the adapter, not the raw kernel. Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four example/harness config files, .claude/settings.local.json, four docs (CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 + react/vue/svelte adapters), and three package.jsons (the mizan-base rename plus mizan-vue/svelte peerDeps). Generated files under examples/django-react-site/harness/src/api/ still reference @mizan/runtime — left as-is; they're regenerated artifacts and the harness is non-functional pending the React wrapper-layer codegen. Also folded in a pre-existing fix: the Gitea workflows had working-directory: react / django pointing at a layout that predates packages/, never updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
223
backends/mizan-django/generate/generator/cli.mjs
Executable file
223
backends/mizan-django/generate/generator/cli.mjs
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* mizan Code Generator CLI
|
||||
*
|
||||
* Two-stage codegen:
|
||||
* Stage 1: Framework-agnostic types + fetch/mutation functions
|
||||
* Stage 2: Framework-specific wrappers (React hooks, Vue composables, Svelte stores)
|
||||
*
|
||||
* Usage:
|
||||
* npx mizan-generate # React (default)
|
||||
* npx mizan-generate --target vue # Vue
|
||||
* npx mizan-generate --target react,vue,svelte # All three
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs'
|
||||
import { generateTypes, generateContextFile, generateMutationFile, generateFunctionFile, generateStage1Index } from './lib/stage1.mjs'
|
||||
import { generateReactAdapter } from './lib/adapters/react.mjs'
|
||||
import { generateVueAdapter } from './lib/adapters/vue.mjs'
|
||||
import { generateSvelteAdapter } from './lib/adapters/svelte.mjs'
|
||||
import { generateChannelsFiles } from './lib/channels.mjs'
|
||||
|
||||
const frontendDir = process.cwd()
|
||||
|
||||
async function loadConfig(configPath) {
|
||||
const fullPath = path.resolve(frontendDir, configPath)
|
||||
try { await fs.access(fullPath) } catch { throw new Error(`Config not found: ${fullPath}`) }
|
||||
const fileUrl = new URL(`file://${fullPath.replace(/\\/g, '/')}`)
|
||||
const module = await import(fileUrl)
|
||||
return module.default
|
||||
}
|
||||
|
||||
async function writeOutput(filePath, content) {
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(filePath, content, 'utf8')
|
||||
}
|
||||
|
||||
function pascalCase(str) {
|
||||
return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
async function generate(config, options = {}) {
|
||||
const { output, target: targetFlag } = options
|
||||
const outputDir = output || config.output || 'src/api'
|
||||
const targets = (targetFlag || config.target || 'react').split(',').map(t => t.trim())
|
||||
|
||||
console.log(`[mizan] Starting generation (targets: ${targets.join(', ')})...`)
|
||||
|
||||
const fullOutputDir = path.resolve(frontendDir, outputDir)
|
||||
let mizanSchema = null
|
||||
let channelsSchema = null
|
||||
|
||||
// ── Channels (React-only for now) ───────────────────────────────────
|
||||
|
||||
try {
|
||||
console.log('[mizan] Fetching channels schema...')
|
||||
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
|
||||
const channelCount = channelsSchema['x-mizan-channels']?.length || 0
|
||||
if (channelCount > 0 && targets.includes('react')) {
|
||||
console.log(`[mizan] Found ${channelCount} channels`)
|
||||
const { types, hooks } = await generateChannelsFiles(channelsSchema)
|
||||
await writeOutput(path.join(fullOutputDir, 'channels.ts'), types)
|
||||
if (hooks) await writeOutput(path.join(fullOutputDir, 'channels.hooks.tsx'), hooks)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[mizan] Channels not available: ${err.message}`)
|
||||
}
|
||||
|
||||
// ── Mizan functions ─────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
console.log('[mizan] Fetching mizan schema...')
|
||||
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
|
||||
|
||||
const functions = mizanSchema['x-mizan-functions'] || []
|
||||
const contextGroups = mizanSchema['x-mizan-contexts'] || {}
|
||||
|
||||
if (functions.length === 0) {
|
||||
console.log('[mizan] No functions registered')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[mizan] Found ${functions.length} functions`)
|
||||
|
||||
// ── Stage 1: Framework-agnostic ─────────────────────────────────
|
||||
|
||||
// Types
|
||||
const types = await generateTypes(mizanSchema)
|
||||
await writeOutput(path.join(fullOutputDir, 'types.ts'), types)
|
||||
console.log('[mizan] Stage 1 -> types.ts')
|
||||
|
||||
// Context files
|
||||
await fs.mkdir(path.join(fullOutputDir, 'contexts'), { recursive: true })
|
||||
for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) {
|
||||
const content = generateContextFile(ctxName, ctxMeta, functions)
|
||||
await writeOutput(path.join(fullOutputDir, 'contexts', `${ctxName}.ts`), content)
|
||||
console.log(`[mizan] Stage 1 -> contexts/${ctxName}.ts`)
|
||||
}
|
||||
|
||||
// Mutation + function files
|
||||
const regularFns = functions.filter(fn => !fn.isContext && !fn.isForm)
|
||||
if (regularFns.length > 0) {
|
||||
await fs.mkdir(path.join(fullOutputDir, 'mutations'), { recursive: true })
|
||||
await fs.mkdir(path.join(fullOutputDir, 'functions'), { recursive: true })
|
||||
|
||||
for (const fn of regularFns) {
|
||||
const dir = fn.affects ? 'mutations' : 'functions'
|
||||
const content = fn.affects ? generateMutationFile(fn) : generateFunctionFile(fn)
|
||||
await writeOutput(path.join(fullOutputDir, dir, `${fn.camelName}.ts`), content)
|
||||
console.log(`[mizan] Stage 1 -> ${dir}/${fn.camelName}.ts`)
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1 index
|
||||
const stage1Index = generateStage1Index(mizanSchema)
|
||||
await writeOutput(path.join(fullOutputDir, 'index.ts'), stage1Index)
|
||||
console.log('[mizan] Stage 1 -> index.ts')
|
||||
|
||||
// ── Stage 2: Framework-specific ─────────────────────────────────
|
||||
|
||||
for (const target of targets) {
|
||||
let content
|
||||
let filename
|
||||
|
||||
switch (target) {
|
||||
case 'react':
|
||||
content = generateReactAdapter(mizanSchema)
|
||||
filename = 'react.tsx'
|
||||
break
|
||||
case 'vue':
|
||||
content = generateVueAdapter(mizanSchema)
|
||||
filename = 'vue.ts'
|
||||
break
|
||||
case 'svelte':
|
||||
content = generateSvelteAdapter(mizanSchema)
|
||||
filename = 'svelte.ts'
|
||||
break
|
||||
default:
|
||||
console.warn(`[mizan] Unknown target: ${target}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (content) {
|
||||
await writeOutput(path.join(fullOutputDir, filename), content)
|
||||
console.log(`[mizan] Stage 2 -> ${filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Schema JSON
|
||||
await writeOutput(
|
||||
path.join(fullOutputDir, 'schema.json'),
|
||||
JSON.stringify(mizanSchema, null, 2),
|
||||
)
|
||||
|
||||
} catch (err) {
|
||||
console.log(`[mizan] Schema not available: ${err.message}`)
|
||||
}
|
||||
|
||||
console.log('[mizan] Generation complete!')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
let configPath = 'django.config.mjs'
|
||||
let watchMode = false
|
||||
let output = null
|
||||
let target = 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] === '--target' || args[i] === '-t') target = args[++i]
|
||||
else if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(`
|
||||
mizan Code Generator
|
||||
|
||||
Usage:
|
||||
npx mizan-generate [options]
|
||||
|
||||
Options:
|
||||
-c, --config <path> Config file (default: django.config.mjs)
|
||||
-t, --target <targets> Comma-separated: react,vue,svelte (default: react)
|
||||
-o, --output <dir> Output directory (default: src/api)
|
||||
-w, --watch Watch mode
|
||||
-h, --help Show help
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
const config = await loadConfig(configPath)
|
||||
const options = { output, target }
|
||||
|
||||
if (watchMode) {
|
||||
await generate(config, options)
|
||||
console.log('[mizan] Watching for changes...')
|
||||
const { watch: chokidarWatch } = await import('chokidar')
|
||||
if (config.source.django) {
|
||||
const djangoDir = path.resolve(frontendDir, path.dirname(config.source.django.managePath))
|
||||
let timeout = null
|
||||
const watcher = chokidarWatch([path.join(djangoDir, '**/*.py')], {
|
||||
ignored: ['**/node_modules/**', '**/__pycache__/**', '**/migrations/**'],
|
||||
ignoreInitial: true,
|
||||
})
|
||||
watcher.on('change', () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => generate(config, options), 1000)
|
||||
})
|
||||
}
|
||||
process.on('SIGINT', () => process.exit(0))
|
||||
} else {
|
||||
await generate(config, options)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[mizan] Error:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
180
backends/mizan-django/generate/generator/lib/adapters/react.mjs
Normal file
180
backends/mizan-django/generate/generator/lib/adapters/react.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* React Stage 2 — Generates hooks + context providers from Stage 1 output.
|
||||
*
|
||||
* Generated providers subscribe to the runtime kernel for state.
|
||||
* The kernel owns data, status, and error. React just renders.
|
||||
*/
|
||||
|
||||
function pascalCase(str) {
|
||||
return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
export function generateReactAdapter(schema) {
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
const contextGroups = schema['x-mizan-contexts'] || {}
|
||||
const namedContexts = Object.entries(contextGroups).filter(([n]) => n !== 'global')
|
||||
const globalContexts = functions.filter(fn => fn.isContext === 'global')
|
||||
const mutations = functions.filter(fn => !fn.isContext && !fn.isForm && fn.affects)
|
||||
const plainFns = functions.filter(fn => !fn.isContext && !fn.isForm && !fn.affects)
|
||||
|
||||
const lines = [
|
||||
"'use client'",
|
||||
'',
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
"import { createContext, useContext, useState, useEffect, useCallback, useRef, useSyncExternalStore, type ReactNode } from 'react'",
|
||||
"import { registerContext, mizanFetch, mizanCall, type ContextState } from '@mizan/runtime'",
|
||||
'',
|
||||
]
|
||||
|
||||
// Import from Stage 1
|
||||
const stage1Imports = []
|
||||
for (const [ctxName] of Object.entries(contextGroups)) {
|
||||
const p = pascalCase(ctxName)
|
||||
stage1Imports.push(`fetch${p}Context`, `type ${p}ContextData`, `type ${p}ContextParams`)
|
||||
}
|
||||
for (const fn of [...mutations, ...plainFns]) {
|
||||
stage1Imports.push(`call${pascalCase(fn.camelName)}`)
|
||||
}
|
||||
if (stage1Imports.length > 0) {
|
||||
lines.push(`import { ${stage1Imports.join(', ')} } from '../index'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ── Helper hook: subscribe to kernel state ──────────────────────────
|
||||
|
||||
lines.push('// Subscribe to kernel state via useSyncExternalStore')
|
||||
lines.push('function useContextState<T>(')
|
||||
lines.push(' name: string,')
|
||||
lines.push(' params: Record<string, any>,')
|
||||
lines.push(' fetchFn: () => Promise<T>,')
|
||||
lines.push(' initialData?: T,')
|
||||
lines.push('): ContextState<T> {')
|
||||
lines.push(' const ref = useRef<ReturnType<typeof registerContext> | null>(null)')
|
||||
lines.push('')
|
||||
lines.push(' if (!ref.current) {')
|
||||
lines.push(' ref.current = registerContext(name, params, fetchFn, initialData)')
|
||||
lines.push(' }')
|
||||
lines.push('')
|
||||
lines.push(' const handle = ref.current')
|
||||
lines.push('')
|
||||
lines.push(' // Fetch on mount if no data')
|
||||
lines.push(' useEffect(() => {')
|
||||
lines.push(" if (handle.getState().status === 'idle') handle.refetch()")
|
||||
lines.push(' return () => handle.unregister()')
|
||||
lines.push(' }, [handle])')
|
||||
lines.push('')
|
||||
lines.push(' return useSyncExternalStore(')
|
||||
lines.push(' handle.subscribe,')
|
||||
lines.push(' handle.getState,')
|
||||
lines.push(' handle.getState,')
|
||||
lines.push(' )')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// ── Mutation hook helper ────────────────────────────────────────────
|
||||
|
||||
lines.push('// Mutation hook with loading/error state')
|
||||
lines.push('function useMutation<TArgs, TResult>(')
|
||||
lines.push(' callFn: (args: TArgs) => Promise<TResult>,')
|
||||
lines.push('): { mutate: (args: TArgs) => Promise<TResult>; isPending: boolean; error: Error | null } {')
|
||||
lines.push(' const [isPending, setIsPending] = useState(false)')
|
||||
lines.push(' const [error, setError] = useState<Error | null>(null)')
|
||||
lines.push('')
|
||||
lines.push(' const mutate = useCallback(async (args: TArgs) => {')
|
||||
lines.push(' setIsPending(true)')
|
||||
lines.push(' setError(null)')
|
||||
lines.push(' try {')
|
||||
lines.push(' const result = await callFn(args)')
|
||||
lines.push(' return result')
|
||||
lines.push(' } catch (e) {')
|
||||
lines.push(' setError(e as Error)')
|
||||
lines.push(' throw e')
|
||||
lines.push(' } finally {')
|
||||
lines.push(' setIsPending(false)')
|
||||
lines.push(' }')
|
||||
lines.push(' }, [callFn])')
|
||||
lines.push('')
|
||||
lines.push(' return { mutate, isPending, error }')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// ── Context hooks ───────────────────────────────────────────────────
|
||||
|
||||
for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) {
|
||||
const p = pascalCase(ctxName)
|
||||
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||
const paramEntries = Object.entries(ctxMeta.params || {})
|
||||
|
||||
lines.push(`// ── ${p} Context ──`)
|
||||
lines.push('')
|
||||
|
||||
// Hook that returns the full kernel state
|
||||
if (paramEntries.length > 0) {
|
||||
lines.push(`export function use${p}Context(params: ${p}ContextParams): ContextState<${p}ContextData> {`)
|
||||
lines.push(` const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : null`)
|
||||
lines.push(` return useContextState('${ctxName}', params, () => fetch${p}Context(params), ssrData)`)
|
||||
} else {
|
||||
lines.push(`export function use${p}Context(): ContextState<${p}ContextData> {`)
|
||||
lines.push(` const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : null`)
|
||||
lines.push(` return useContextState('${ctxName}', {}, () => fetch${p}Context({} as any), ssrData)`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// Convenience hooks for individual data fields
|
||||
for (const fn of ctxFunctions) {
|
||||
const hookPascal = pascalCase(fn.camelName)
|
||||
if (paramEntries.length > 0) {
|
||||
lines.push(`export function use${hookPascal}(params: ${p}ContextParams): ${fn.outputType} | null {`)
|
||||
lines.push(` const state = use${p}Context(params)`)
|
||||
} else {
|
||||
lines.push(`export function use${hookPascal}(): ${fn.outputType} | null {`)
|
||||
lines.push(` const state = use${p}Context()`)
|
||||
}
|
||||
lines.push(` return state.data?.${fn.name} ?? null`)
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mutation hooks (with loading/error) ──────────────────────────────
|
||||
|
||||
for (const fn of mutations) {
|
||||
const p = pascalCase(fn.camelName)
|
||||
if (fn.hasInput) {
|
||||
lines.push(`export function use${p}() {`)
|
||||
lines.push(` return useMutation<Parameters<typeof call${p}>[0], Awaited<ReturnType<typeof call${p}>>>(call${p})`)
|
||||
lines.push('}')
|
||||
} else {
|
||||
lines.push(`export function use${p}() {`)
|
||||
lines.push(` return useMutation<void, Awaited<ReturnType<typeof call${p}>>>(() => call${p}() as any)`)
|
||||
lines.push('}')
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ── Plain function hooks ────────────────────────────────────────────
|
||||
|
||||
for (const fn of plainFns) {
|
||||
const p = pascalCase(fn.camelName)
|
||||
if (fn.hasInput) {
|
||||
lines.push(`export function use${p}() {`)
|
||||
lines.push(` return useMutation<Parameters<typeof call${p}>[0], Awaited<ReturnType<typeof call${p}>>>(call${p})`)
|
||||
lines.push('}')
|
||||
} else {
|
||||
lines.push(`export function use${p}() {`)
|
||||
lines.push(` return useMutation<void, Awaited<ReturnType<typeof call${p}>>>(() => call${p}() as any)`)
|
||||
lines.push('}')
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// ── Re-export runtime types ─────────────────────────────────────────
|
||||
|
||||
lines.push("export type { ContextState } from '@mizan/runtime'")
|
||||
lines.push("export { configure, initSession, MizanError } from '@mizan/runtime'")
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Svelte Stage 2 — Generates stores from Stage 1 output.
|
||||
*
|
||||
* Subscribes to the kernel for state. Returns readable stores.
|
||||
*/
|
||||
|
||||
function pascalCase(str) {
|
||||
return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
export function generateSvelteAdapter(schema) {
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
const contextGroups = schema['x-mizan-contexts'] || {}
|
||||
const mutations = functions.filter(fn => !fn.isContext && !fn.isForm && fn.affects)
|
||||
const plainFns = functions.filter(fn => !fn.isContext && !fn.isForm && !fn.affects)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
"import { readable, type Readable } from 'svelte/store'",
|
||||
"import { registerContext, type ContextState } from '@mizan/runtime'",
|
||||
'',
|
||||
]
|
||||
|
||||
const stage1Imports = []
|
||||
for (const [ctxName] of Object.entries(contextGroups)) {
|
||||
const p = pascalCase(ctxName)
|
||||
stage1Imports.push(`fetch${p}Context`, `type ${p}ContextData`, `type ${p}ContextParams`)
|
||||
}
|
||||
for (const fn of [...mutations, ...plainFns]) {
|
||||
stage1Imports.push(`call${pascalCase(fn.camelName)}`)
|
||||
}
|
||||
if (stage1Imports.length > 0) {
|
||||
lines.push(`import { ${stage1Imports.join(', ')} } from '../index'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) {
|
||||
const p = pascalCase(ctxName)
|
||||
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||
const paramEntries = Object.entries(ctxMeta.params || {})
|
||||
const paramsArg = paramEntries.length > 0 ? 'params' : '{} as any'
|
||||
|
||||
if (paramEntries.length > 0) {
|
||||
lines.push(`export function create${p}Context(params: ${p}ContextParams) {`)
|
||||
} else {
|
||||
lines.push(`export function create${p}Context() {`)
|
||||
}
|
||||
|
||||
// Use readable store backed by kernel subscription
|
||||
lines.push(` const store = readable<ContextState<${p}ContextData>>(`)
|
||||
lines.push(` { data: null, status: 'idle', error: null },`)
|
||||
lines.push(` (set) => {`)
|
||||
lines.push(` const handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`)
|
||||
lines.push(` const unsub = handle.subscribe(() => set(handle.getState()))`)
|
||||
lines.push(` handle.refetch()`)
|
||||
lines.push(` return () => { unsub(); handle.unregister() }`)
|
||||
lines.push(` },`)
|
||||
lines.push(` )`)
|
||||
lines.push('')
|
||||
lines.push(` return store`)
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Re-export mutations as-is from Stage 1
|
||||
for (const fn of [...mutations, ...plainFns]) {
|
||||
const p = pascalCase(fn.camelName)
|
||||
lines.push(`export { call${p} } from '../index'`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
lines.push("export type { ContextState } from '@mizan/runtime'")
|
||||
lines.push("export { configure, initSession, MizanError } from '@mizan/runtime'")
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
104
backends/mizan-django/generate/generator/lib/adapters/vue.mjs
Normal file
104
backends/mizan-django/generate/generator/lib/adapters/vue.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Vue Stage 2 — Generates composables from Stage 1 output.
|
||||
*
|
||||
* Subscribes to the kernel for state. Vue reactivity wraps kernel notifications.
|
||||
*/
|
||||
|
||||
function pascalCase(str) {
|
||||
return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
export function generateVueAdapter(schema) {
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
const contextGroups = schema['x-mizan-contexts'] || {}
|
||||
const mutations = functions.filter(fn => !fn.isContext && !fn.isForm && fn.affects)
|
||||
const plainFns = functions.filter(fn => !fn.isContext && !fn.isForm && !fn.affects)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
"import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue'",
|
||||
"import { registerContext, type ContextState } from '@mizan/runtime'",
|
||||
'',
|
||||
]
|
||||
|
||||
const stage1Imports = []
|
||||
for (const [ctxName] of Object.entries(contextGroups)) {
|
||||
const p = pascalCase(ctxName)
|
||||
stage1Imports.push(`fetch${p}Context`, `type ${p}ContextData`, `type ${p}ContextParams`)
|
||||
}
|
||||
for (const fn of [...mutations, ...plainFns]) {
|
||||
stage1Imports.push(`call${pascalCase(fn.camelName)}`)
|
||||
}
|
||||
if (stage1Imports.length > 0) {
|
||||
lines.push(`import { ${stage1Imports.join(', ')} } from '../index'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) {
|
||||
const p = pascalCase(ctxName)
|
||||
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||
const paramEntries = Object.entries(ctxMeta.params || {})
|
||||
const paramsArg = paramEntries.length > 0 ? 'params' : '{} as any'
|
||||
|
||||
if (paramEntries.length > 0) {
|
||||
lines.push(`export function use${p}Context(params: ${p}ContextParams) {`)
|
||||
} else {
|
||||
lines.push(`export function use${p}Context() {`)
|
||||
}
|
||||
|
||||
lines.push(` const state = ref<ContextState<${p}ContextData>>({ data: null, status: 'idle', error: null })`)
|
||||
lines.push(` let handle: ReturnType<typeof registerContext> | null = null`)
|
||||
lines.push('')
|
||||
lines.push(` onMounted(() => {`)
|
||||
lines.push(` handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`)
|
||||
lines.push(` handle.subscribe(() => { state.value = handle!.getState() })`)
|
||||
lines.push(` handle.refetch()`)
|
||||
lines.push(` })`)
|
||||
lines.push('')
|
||||
lines.push(` onServerPrefetch(async () => {`)
|
||||
lines.push(` handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`)
|
||||
lines.push(` await handle.refetch()`)
|
||||
lines.push(` state.value = handle.getState()`)
|
||||
lines.push(` })`)
|
||||
lines.push('')
|
||||
lines.push(` onUnmounted(() => { handle?.unregister() })`)
|
||||
lines.push('')
|
||||
lines.push(` return {`)
|
||||
lines.push(` state,`)
|
||||
for (const fn of ctxFunctions) {
|
||||
lines.push(` ${fn.camelName}: computed(() => state.value.data?.${fn.name} ?? null) as ComputedRef<${fn.outputType} | null>,`)
|
||||
}
|
||||
lines.push(` loading: computed(() => state.value.status === 'loading'),`)
|
||||
lines.push(` error: computed(() => state.value.error),`)
|
||||
lines.push(` }`)
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
for (const fn of [...mutations, ...plainFns]) {
|
||||
const p = pascalCase(fn.camelName)
|
||||
lines.push(`export function use${p}() {`)
|
||||
lines.push(` const isPending = ref(false)`)
|
||||
lines.push(` const error = ref<Error | null>(null)`)
|
||||
if (fn.hasInput) {
|
||||
lines.push(` async function mutate(args: Parameters<typeof call${p}>[0]) {`)
|
||||
} else {
|
||||
lines.push(` async function mutate() {`)
|
||||
}
|
||||
lines.push(` isPending.value = true; error.value = null`)
|
||||
lines.push(` try { return await call${p}(${fn.hasInput ? 'args' : ''}) }`)
|
||||
lines.push(` catch (e) { error.value = e as Error; throw e }`)
|
||||
lines.push(` finally { isPending.value = false }`)
|
||||
lines.push(` }`)
|
||||
lines.push(` return { mutate, isPending, error }`)
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push("export type { ContextState } from '@mizan/runtime'")
|
||||
lines.push("export { configure, initSession, MizanError } from '@mizan/runtime'")
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
155
backends/mizan-django/generate/generator/lib/channels.mjs
Normal file
155
backends/mizan-django/generate/generator/lib/channels.mjs
Normal 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 }
|
||||
}
|
||||
88
backends/mizan-django/generate/generator/lib/fetch.mjs
Normal file
88
backends/mizan-django/generate/generator/lib/fetch.mjs
Normal 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')
|
||||
}
|
||||
164
backends/mizan-django/generate/generator/lib/index.mjs
Normal file
164
backends/mizan-django/generate/generator/lib/index.mjs
Normal 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')
|
||||
}
|
||||
980
backends/mizan-django/generate/generator/lib/mizan.mjs
Normal file
980
backends/mizan-django/generate/generator/lib/mizan.mjs
Normal file
@@ -0,0 +1,980 @@
|
||||
/**
|
||||
* 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(' // Check for SSR hydration data first')
|
||||
lines.push(" const ssr = typeof window !== 'undefined' && (window as any).__MIZAN_SSR_DATA__")
|
||||
lines.push(' if (ssr) {')
|
||||
lines.push(' for (const [name, data] of Object.entries(ssr)) {')
|
||||
lines.push(' mizan.setContextData(name, data)')
|
||||
lines.push(' }')
|
||||
lines.push(' return')
|
||||
lines.push(' }')
|
||||
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(' for (const [name, data] of Object.entries(result)) {')
|
||||
lines.push(' mizan.setContextData(name, data)')
|
||||
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()`)
|
||||
|
||||
// SSR hydration check — initialize from __MIZAN_SSR_DATA__ if available
|
||||
lines.push(` const [data, setData] = useState<{`)
|
||||
for (const fn of ctxFunctions) {
|
||||
lines.push(` ${fn.name}: ${fn.outputType}`)
|
||||
}
|
||||
lines.push(` } | null>(() => {`)
|
||||
lines.push(` if (typeof window === 'undefined') return null`)
|
||||
lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`)
|
||||
lines.push(` if (!ssr) return null`)
|
||||
// Check if all functions for this context have SSR data
|
||||
const firstFn = ctxFunctions[0]
|
||||
lines.push(` if (ssr.${firstFn.name} === undefined) return null`)
|
||||
lines.push(` return {`)
|
||||
for (const fn of ctxFunctions) {
|
||||
lines.push(` ${fn.name}: ssr.${fn.name},`)
|
||||
}
|
||||
lines.push(` }`)
|
||||
lines.push(` })`)
|
||||
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(` setData(result)`)
|
||||
|
||||
// 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(' if (response.ok) {')
|
||||
lines.push(' const result = await response.json()')
|
||||
for (const ctx of globalContexts) {
|
||||
lines.push(` if (result?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.${ctx.name}`)
|
||||
}
|
||||
lines.push(' } else {')
|
||||
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', response.status)")
|
||||
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)
|
||||
}
|
||||
198
backends/mizan-django/generate/generator/lib/stage1.mjs
Normal file
198
backends/mizan-django/generate/generator/lib/stage1.mjs
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Stage 1 Codegen — Framework-agnostic TypeScript output.
|
||||
*
|
||||
* Produces:
|
||||
* types.ts — interfaces from OpenAPI schema
|
||||
* contexts/<name>.ts — fetchXxxContext(params) per context group
|
||||
* mutations/<name>.ts — callXxx(args) per mutation
|
||||
* functions/<name>.ts — callXxx(args) per plain function
|
||||
* index.ts — re-exports
|
||||
*/
|
||||
|
||||
import openapiTS, { astToString } from 'openapi-typescript'
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function pascalCase(str) {
|
||||
return str
|
||||
.split(/[.\-_]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function camelCase(str) {
|
||||
const p = pascalCase(str)
|
||||
return p.charAt(0).toLowerCase() + p.slice(1)
|
||||
}
|
||||
|
||||
// TypeScript SyntaxKind values for openapi-typescript AST
|
||||
const SyntaxKind = {
|
||||
InterfaceDeclaration: 265,
|
||||
PropertySignature: 172,
|
||||
Identifier: 80,
|
||||
}
|
||||
|
||||
function idName(node) {
|
||||
return node?.kind === SyntaxKind.Identifier ? node.escapedText : undefined
|
||||
}
|
||||
|
||||
function getSchemaNamesFromAst(ast) {
|
||||
if (!Array.isArray(ast)) return []
|
||||
const componentsNode = ast.find(
|
||||
n => n?.kind === SyntaxKind.InterfaceDeclaration && idName(n?.name) === 'components'
|
||||
)
|
||||
if (!componentsNode?.members) return []
|
||||
const schemasProp = componentsNode.members.find(
|
||||
m => m?.kind === SyntaxKind.PropertySignature && idName(m?.name) === 'schemas' && Array.isArray(m?.type?.members)
|
||||
)
|
||||
if (!schemasProp) return []
|
||||
return schemasProp.type.members
|
||||
.map(m => m?.kind === SyntaxKind.PropertySignature ? idName(m.name) : undefined)
|
||||
.filter(n => typeof n === 'string')
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function generateTypes(schema) {
|
||||
const ast = await openapiTS(schema)
|
||||
const schemaNames = getSchemaNamesFromAst(ast)
|
||||
const typesCode = astToString(ast)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
typesCode,
|
||||
'',
|
||||
'// Convenience type exports',
|
||||
...schemaNames.map(name => `export type ${name} = components["schemas"]["${name}"]`),
|
||||
'',
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─── Context Files ──────────────────────────────────────────────────────────
|
||||
|
||||
export function generateContextFile(ctxName, ctxMeta, functions) {
|
||||
const pascal = pascalCase(ctxName)
|
||||
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
"import { mizanFetch } from '@mizan/runtime'",
|
||||
'',
|
||||
]
|
||||
|
||||
// Import output types
|
||||
const typeImports = ctxFunctions.map(fn => fn.outputType).filter(Boolean)
|
||||
if (typeImports.length > 0) {
|
||||
lines.push(`import type { ${[...new Set(typeImports)].join(', ')} } from '../types'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Data interface
|
||||
lines.push(`export interface ${pascal}ContextData {`)
|
||||
for (const fn of ctxFunctions) {
|
||||
lines.push(` ${fn.name}: ${fn.outputType}`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
// Params interface (from x-mizan-contexts)
|
||||
const params = ctxMeta?.params || {}
|
||||
const paramEntries = Object.entries(params)
|
||||
|
||||
if (paramEntries.length > 0) {
|
||||
lines.push(`export interface ${pascal}ContextParams {`)
|
||||
for (const [pName, pMeta] of paramEntries) {
|
||||
const tsType = pMeta.type === 'integer' || pMeta.type === 'number' ? 'number' : pMeta.type === 'boolean' ? 'boolean' : 'string'
|
||||
const optional = pMeta.required ? '' : '?'
|
||||
lines.push(` ${pName}${optional}: ${tsType}`)
|
||||
}
|
||||
lines.push('}')
|
||||
} else {
|
||||
lines.push(`export type ${pascal}ContextParams = Record<string, never>`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
// Fetch function
|
||||
lines.push(`export function fetch${pascal}Context(params: ${pascal}ContextParams): Promise<${pascal}ContextData> {`)
|
||||
lines.push(` return mizanFetch('${ctxName}', params)`)
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─── Mutation Files ─────────────────────────────────────────────────────────
|
||||
|
||||
export function generateMutationFile(fn) {
|
||||
const pascal = pascalCase(fn.camelName)
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
"import { mizanCall } from '@mizan/runtime'",
|
||||
'',
|
||||
]
|
||||
|
||||
// Import types
|
||||
const typeImports = []
|
||||
if (fn.hasInput && fn.inputType) typeImports.push(fn.inputType)
|
||||
if (fn.outputType) typeImports.push(fn.outputType)
|
||||
if (typeImports.length > 0) {
|
||||
lines.push(`import type { ${[...new Set(typeImports)].join(', ')} } from '../types'`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Call function
|
||||
if (fn.hasInput) {
|
||||
lines.push(`export function call${pascal}(args: ${fn.inputType}): Promise<${fn.outputType}> {`)
|
||||
} else {
|
||||
lines.push(`export function call${pascal}(): Promise<${fn.outputType}> {`)
|
||||
}
|
||||
lines.push(` return mizanCall('${fn.name}', ${fn.hasInput ? 'args' : '{}'})`)
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─── Function Files (plain, no context, no affects) ─────────────────────────
|
||||
|
||||
export function generateFunctionFile(fn) {
|
||||
// Same shape as mutation, just different semantics
|
||||
return generateMutationFile(fn)
|
||||
}
|
||||
|
||||
// ─── Index ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function generateStage1Index(schema) {
|
||||
const functions = schema['x-mizan-functions'] || []
|
||||
const contextGroups = schema['x-mizan-contexts'] || {}
|
||||
|
||||
const lines = [
|
||||
'// AUTO-GENERATED by mizan — do not edit',
|
||||
'',
|
||||
"export * from './types'",
|
||||
'',
|
||||
]
|
||||
|
||||
// Context exports
|
||||
for (const ctxName of Object.keys(contextGroups)) {
|
||||
const pascal = pascalCase(ctxName)
|
||||
lines.push(`export { fetch${pascal}Context, type ${pascal}ContextData, type ${pascal}ContextParams } from './contexts/${ctxName}'`)
|
||||
}
|
||||
if (Object.keys(contextGroups).length > 0) lines.push('')
|
||||
|
||||
// Mutation + function exports
|
||||
const regularFns = functions.filter(fn => !fn.isContext && !fn.isForm)
|
||||
for (const fn of regularFns) {
|
||||
const pascal = pascalCase(fn.camelName)
|
||||
lines.push(`export { call${pascal} } from './${fn.affects ? 'mutations' : 'functions'}/${fn.camelName}'`)
|
||||
}
|
||||
if (regularFns.length > 0) lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user