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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user