Two-stage codegen: React + Vue + Svelte from one schema

Stage 1 (framework-agnostic):
  types.ts           — OpenAPI interfaces
  contexts/<name>.ts — fetchXxxContext(params) using mizanFetch
  mutations/<name>.ts — callXxx(args) using mizanCall
  functions/<name>.ts — callXxx(args) using mizanCall
  index.ts           — re-exports

Stage 2 (per framework):
  react.tsx  — hooks + context providers + SSR hydration
  vue.ts     — composables with provide/inject + ref/computed
  svelte.ts  — writable/derived store factories

New packages:
  mizan-runtime — the kernel (~200 lines, zero framework deps)
    configure(), initSession(), registerContext(), invalidate(),
    mizanFetch(), mizanCall(), MizanError
  mizan-vue     — Vue adapter (package.json, codegen template)
  mizan-svelte  — Svelte adapter (package.json, codegen template)

CLI: mizan-generate --target react,vue,svelte
Config: target: 'react' (default) in django.config.mjs

Verified: codegen produces 33 functions across 2 contexts,
14 plain functions, 0 mutations, generating all three Stage 2
outputs from one schema fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 12:09:35 -04:00
parent 6108845d99
commit c20de182e1
35 changed files with 6009 additions and 817 deletions

View File

@@ -2,282 +2,222 @@
/**
* mizan Code Generator CLI
*
* Generate TypeScript types, React provider, and hooks from Django schemas.
* 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 # Run once
* npx mizan-generate --watch # Watch mode
* 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 { generateMizanFiles } from './lib/mizan.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'
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 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
}
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')
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('')
}
/**
* Run schema generation.
*/
async function generate(config, options = {}) {
const { output } = 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 schema generation...')
console.log(`[mizan] Starting generation (targets: ${targets.join(', ')})...`)
const outputPath = output || config.output || 'src/api/generated.ts'
const fullOutputDir = path.resolve(frontendDir, outputDir)
let mizanSchema = null
let channelsSchema = null
let channelsSchema = null
let mizanSchema = null
// ── Channels (React-only for now) ───────────────────────────────────
// 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)
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.error('[mizan] Generation failed:', err.message)
} finally {
running = false
console.log(`[mizan] Channels not available: ${err.message}`)
}
}
await runGenerate()
// ── Mizan functions ─────────────────────────────────────────────────
console.log('[mizan] Watching for changes (press Ctrl+C to stop)...')
try {
console.log('[mizan] Fetching mizan schema...')
mizanSchema = await fetchMizanSchema(config.source, frontendDir)
if (config.source.django) {
const { watch: chokidarWatch } = await import('chokidar')
const djangoDir = path.resolve(frontendDir, path.dirname(config.source.django.managePath))
const functions = mizanSchema['x-mizan-functions'] || []
const contextGroups = mizanSchema['x-mizan-contexts'] || {}
const watcher = chokidarWatch([
path.join(djangoDir, '**/*.py'),
], {
ignored: [
'**/node_modules/**',
'**/__pycache__/**',
'**/migrations/**',
'**/.venv/**',
],
ignoreInitial: true,
})
if (functions.length === 0) {
console.log('[mizan] No functions registered')
return
}
watcher.on('change', (filePath) => {
console.log(`[mizan] Detected change: ${path.relative(djangoDir, filePath)}`)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(runGenerate, debounce)
})
}
console.log(`[mizan] Found ${functions.length} functions`)
process.on('SIGINT', () => {
console.log('\n[mizan] Stopping watch mode...')
process.exit(0)
})
// ── 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!')
}
/**
* Main entry point.
*/
async function main() {
const args = process.argv.slice(2)
const args = process.argv.slice(2)
let configPath = 'django.config.mjs'
let watchMode = false
let output = null
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] === '--help' || args[i] === '-h') {
console.log(`
mizan Code Generator - Generate TypeScript from Django schemas
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 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)
-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 }
const config = await loadConfig(configPath)
const options = { output, target }
if (watchMode) {
await watch(config, options)
} else {
await generate(config, options)
}
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)
console.error('[mizan] Error:', err.message)
process.exit(1)
})

View File

@@ -0,0 +1,160 @@
/**
* React Stage 2 — Generates hooks + context providers from Stage 1 output.
*/
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, type ReactNode } from 'react'",
"import { registerContext, mizanCall, mizanFetch } 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('')
}
// ── Global context hooks ────────────────────────────────────────────
if (globalContexts.length > 0) {
const p = pascalCase('global')
lines.push(`// Global context — fetched once at app init`)
lines.push(`const GlobalCtx = createContext<${p}ContextData | null>(null)`)
lines.push('')
lines.push(`export function GlobalContextProvider({ children }: { children: ReactNode }) {`)
lines.push(` const [data, setData] = useState<${p}ContextData | null>(() => {`)
lines.push(` if (typeof window === 'undefined') return null`)
lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`)
lines.push(` return ssr ?? null`)
lines.push(` })`)
lines.push('')
lines.push(` const refetch = useCallback(async () => {`)
lines.push(` const result = await fetch${p}Context({} as any)`)
lines.push(` setData(result)`)
lines.push(` }, [])`)
lines.push('')
lines.push(` useEffect(() => { if (!data) refetch() }, [data, refetch])`)
lines.push(` useEffect(() => registerContext('global', {}, refetch), [refetch])`)
lines.push('')
lines.push(` return <GlobalCtx.Provider value={data}>{children}</GlobalCtx.Provider>`)
lines.push('}')
lines.push('')
for (const fn of globalContexts) {
const hookPascal = pascalCase(fn.camelName)
lines.push(`export function use${hookPascal}(): ${fn.outputType} {`)
lines.push(` const ctx = useContext(GlobalCtx)`)
lines.push(` if (!ctx) throw new Error('use${hookPascal} requires GlobalContextProvider')`)
lines.push(` return ctx.${fn.name}`)
lines.push('}')
lines.push('')
}
}
// ── Named context providers ─────────────────────────────────────────
for (const [ctxName, ctxMeta] of namedContexts) {
const p = pascalCase(ctxName)
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
const paramEntries = Object.entries(ctxMeta.params || {})
lines.push(`// ${p} context`)
lines.push(`const ${p}Ctx = createContext<${p}ContextData | null>(null)`)
lines.push('')
// Provider
lines.push(`export function ${p}Context({ children, ...params }: ${p}ContextParams & { children: ReactNode }) {`)
lines.push(` const [data, setData] = useState<${p}ContextData | null>(() => {`)
lines.push(` if (typeof window === 'undefined') return null`)
lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`)
if (ctxFunctions.length > 0) {
lines.push(` if (ssr?.${ctxFunctions[0].name} !== undefined) return ssr`)
}
lines.push(` return null`)
lines.push(` })`)
lines.push('')
lines.push(` const refetch = useCallback(async () => {`)
lines.push(` const result = await fetch${p}Context(params)`)
lines.push(` setData(result)`)
const deps = paramEntries.map(([pName]) => `params.${pName}`)
lines.push(` }, [${deps.join(', ')}])`)
lines.push('')
lines.push(` useEffect(() => { refetch() }, [refetch])`)
lines.push(` useEffect(() => registerContext('${ctxName}', params, refetch), [${deps.join(', ')}, refetch])`)
lines.push('')
lines.push(` return <${p}Ctx.Provider value={data}>{children}</${p}Ctx.Provider>`)
lines.push('}')
lines.push('')
// Hooks
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(`export function use${hookPascal}(): ${fn.outputType} | null {`)
lines.push(` const ctx = useContext(${p}Ctx)`)
lines.push(` return ctx?.${fn.name} ?? null`)
lines.push('}')
lines.push('')
}
}
// ── Mutation hooks ──────────────────────────────────────────────────
for (const fn of mutations) {
const p = pascalCase(fn.camelName)
if (fn.hasInput) {
lines.push(`export function use${p}() {`)
lines.push(` return useCallback((args: Parameters<typeof call${p}>[0]) => call${p}(args), [])`)
lines.push('}')
} else {
lines.push(`export function use${p}() {`)
lines.push(` return useCallback(() => call${p}(), [])`)
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 useCallback((args: Parameters<typeof call${p}>[0]) => call${p}(args), [])`)
lines.push('}')
} else {
lines.push(`export function use${p}() {`)
lines.push(` return useCallback(() => call${p}(), [])`)
lines.push('}')
}
lines.push('')
}
return lines.join('\n')
}

View File

@@ -0,0 +1,97 @@
/**
* Svelte Stage 2 — Generates stores from Stage 1 output.
*/
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 { writable, derived, type Readable } from 'svelte/store'",
"import { registerContext } from '@mizan/runtime'",
'',
]
// Stage 1 imports
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('')
}
// ── Context stores ──────────────────────────────────────────────────
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`)
if (paramEntries.length > 0) {
lines.push(`export function create${p}Context(params: ${p}ContextParams) {`)
} else {
lines.push(`export function create${p}Context() {`)
}
lines.push(` const data = writable<${p}ContextData | null>(null)`)
lines.push(` const loading = writable(true)`)
lines.push('')
lines.push(` const refetch = async () => {`)
lines.push(` loading.set(true)`)
if (paramEntries.length > 0) {
lines.push(` const result = await fetch${p}Context(params)`)
} else {
lines.push(` const result = await fetch${p}Context({} as any)`)
}
lines.push(` data.set(result)`)
lines.push(` loading.set(false)`)
lines.push(` }`)
lines.push('')
lines.push(` refetch()`)
if (paramEntries.length > 0) {
lines.push(` const unregister = registerContext('${ctxName}', params, refetch)`)
} else {
lines.push(` const unregister = registerContext('${ctxName}', {}, refetch)`)
}
lines.push('')
// Derived stores for each function
lines.push(` return {`)
lines.push(` data,`)
lines.push(` loading,`)
for (const fn of ctxFunctions) {
const camel = fn.camelName
lines.push(` ${camel}: derived(data, $d => $d?.${fn.name} ?? null) as Readable<${fn.outputType} | null>,`)
}
lines.push(` destroy: unregister,`)
lines.push(` }`)
lines.push('}')
lines.push('')
}
// ── Mutation + function exports ─────────────────────────────────────
for (const fn of [...mutations, ...plainFns]) {
const p = pascalCase(fn.camelName)
lines.push(`export { call${p} } from '../${fn.affects ? 'mutations' : 'functions'}/${fn.camelName}'`)
}
lines.push('')
return lines.join('\n')
}

View File

@@ -0,0 +1,105 @@
/**
* Vue Stage 2 — Generates composables from Stage 1 output.
*/
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, watch, onMounted, onUnmounted, provide, inject, type Ref, type ComputedRef, type InjectionKey } from 'vue'",
"import { registerContext } from '@mizan/runtime'",
'',
]
// Stage 1 imports
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('')
}
// ── Context composables ─────────────────────────────────────────────
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(`const ${p}Key: InjectionKey<{ data: Ref<${p}ContextData | null>, loading: Ref<boolean> }> = Symbol('${ctxName}')`)
lines.push('')
// Provider composable
if (paramEntries.length > 0) {
lines.push(`export function provide${p}Context(params: { ${paramEntries.map(([k, v]) => `${k}: ${v.type === 'integer' || v.type === 'number' ? 'number' : 'string'}`).join(', ')} }) {`)
} else {
lines.push(`export function provide${p}Context() {`)
}
lines.push(` const data = ref<${p}ContextData | null>(null)`)
lines.push(` const loading = ref(true)`)
lines.push('')
lines.push(` const refetch = async () => {`)
lines.push(` loading.value = true`)
lines.push(` try {`)
if (paramEntries.length > 0) {
lines.push(` data.value = await fetch${p}Context(params as any)`)
} else {
lines.push(` data.value = await fetch${p}Context({} as any)`)
}
lines.push(` } catch (e) { console.error('[mizan] ${ctxName} fetch failed:', e) }`)
lines.push(` loading.value = false`)
lines.push(` }`)
lines.push('')
lines.push(` let unregister: (() => void) | null = null`)
lines.push(` onMounted(() => {`)
lines.push(` refetch()`)
if (paramEntries.length > 0) {
lines.push(` unregister = registerContext('${ctxName}', params, refetch)`)
} else {
lines.push(` unregister = registerContext('${ctxName}', {}, refetch)`)
}
lines.push(` })`)
lines.push(` onUnmounted(() => { unregister?.() })`)
lines.push('')
lines.push(` provide(${p}Key, { data, loading })`)
lines.push('}')
lines.push('')
// Consumer composables
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(`export function use${hookPascal}(): ComputedRef<${fn.outputType} | null> {`)
lines.push(` const ctx = inject(${p}Key)`)
lines.push(` if (!ctx) throw new Error('use${hookPascal} requires provide${p}Context in a parent')`)
lines.push(` return computed(() => ctx.data.value?.${fn.name} ?? null)`)
lines.push('}')
lines.push('')
}
}
// ── Mutation composables ────────────────────────────────────────────
for (const fn of [...mutations, ...plainFns]) {
const p = pascalCase(fn.camelName)
lines.push(`export const use${p} = call${p}`)
lines.push('')
}
return lines.join('\n')
}

View 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')
}