Rewrite codegen for named contexts, mutation hooks, and Mizan naming

Generator (react/src/generator/lib/mizan.mjs):
- Rename djarea.mjs → mizan.mjs
- MizanContext replaces DjangoContext (legacy alias kept)
- Global contexts fetched via bundled GET /ctx/global/ through
  inner GlobalContextLoader component
- Named context providers generated per context group with param
  elevation (required/optional props from x-mizan-contexts)
- Mutation hooks auto-invalidate affected contexts on success
- SSR hydration uses single GET /ctx/global/ instead of N POSTs
- Output files: generated.provider.tsx, generated.server.ts

Runtime (react/src/context.tsx):
- Add setContextData() for bundle splitting without refetch
- Add request() for auth-transparent HTTP from generated code

Index generator updated for new export names and named contexts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 20:59:53 -04:00
parent 3523f2e3fe
commit af7e22ffc1
4 changed files with 397 additions and 152 deletions

View File

@@ -165,6 +165,19 @@ export interface MizanContextValue {
* Base URL for HTTP calls (for use by generated context providers). * Base URL for HTTP calls (for use by generated context providers).
*/ */
baseUrl: string baseUrl: string
/**
* Set context data directly without triggering a network request.
* Used by generated providers that fetch bundled responses.
*/
setContextData: (name: string, data: unknown) => void
/**
* Make an authenticated HTTP request.
* Handles JWT Bearer or session cookie auth automatically.
* Waits for session init before making the request.
*/
request: (method: string, path: string, data?: unknown) => Promise<Response>
} }
export interface MizanProviderProps { export interface MizanProviderProps {
@@ -539,6 +552,36 @@ export function MizanProvider({
[invalidateContext] [invalidateContext]
) )
// Set context data directly (used by generated providers that fetch bundles)
const setContextData = useCallback(
(name: string, data: unknown) => {
setContextStore(prev => {
const next = { ...prev, [name]: data }
const listeners = contextListenersRef.current.get(name)
if (listeners) {
listeners.forEach(listener => {
try {
listener(data)
} catch (e) {
console.error(`[mizan] Context listener error for '${name}':`, e)
}
})
}
return next
})
},
[]
)
// Auth-transparent HTTP request (used by generated context providers)
const request = useCallback(
async (method: string, path: string, data?: unknown): Promise<Response> => {
await sessionRef.current!.promise
return httpClient.request(method, path, data)
},
[httpClient]
)
const value = useMemo<MizanContextValue>( const value = useMemo<MizanContextValue>(
() => ({ () => ({
call, call,
@@ -554,8 +597,10 @@ export function MizanProvider({
invalidateFunctions, invalidateFunctions,
registerContextProvider, registerContextProvider,
baseUrl, baseUrl,
setContextData,
request,
}), }),
[call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl] [call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl, setContextData, request]
) )
return ( return (

View File

@@ -123,9 +123,9 @@ async function generate(config, options = {}) {
const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts') const mizanTypesPath = outputPath.replace(/\.ts$/, '.mizan.ts')
const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath) const fullMizanTypesPath = path.resolve(frontendDir, mizanTypesPath)
const mizanProviderPath = outputPath.replace(/\.ts$/, '.django.tsx') const mizanProviderPath = outputPath.replace(/\.ts$/, '.provider.tsx')
const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath) const fullMizanProviderPath = path.resolve(frontendDir, mizanProviderPath)
const mizanServerPath = outputPath.replace(/\.ts$/, '.django.server.ts') const mizanServerPath = outputPath.replace(/\.ts$/, '.server.ts')
const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath) const fullMizanServerPath = path.resolve(frontendDir, mizanServerPath)
const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts') const mizanFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath) const fullMizanFormsPath = path.resolve(frontendDir, mizanFormsPath)

View File

@@ -5,27 +5,19 @@
* from the generated files for clean imports. * from the generated files for clean imports.
*/ */
/** function pascalCase(str) {
* Extract context hooks from mizan schema. return str.charAt(0).toUpperCase() + str.slice(1)
* Returns hook names in PascalCase (e.g., useAuthStatus, useUser). }
*/
function extractContextHooks(mizanSchema) {
const functions = mizanSchema?.['x-mizan-functions'] || []
const contexts = functions.filter(fn => fn.isContext)
return contexts.map(ctx => { function toPascalCase(str) {
const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1) return str
return `use${pascal}` .split(/[.\-_]/)
}).sort() .map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
} }
/** /**
* Generate the consolidated index.ts file. * Generate the consolidated index.ts file.
*
* @param {Object} options - Generation options
* @param {Object} options.channelsSchema - Channels schema (optional)
* @param {Object} options.mizanSchema - mizan schema (optional)
* @returns {string} Generated index.ts content
*/ */
export function generateIndex({ channelsSchema, mizanSchema }) { export function generateIndex({ channelsSchema, mizanSchema }) {
const lines = [ const lines = [
@@ -37,11 +29,10 @@ export function generateIndex({ channelsSchema, mizanSchema }) {
' * @example', ' * @example',
' * ```tsx', ' * ```tsx',
' * import {', ' * import {',
' * DjangoContext,', ' * MizanContext,',
' * useUser,', ' * useCurrentUser,',
' * useEcho,', ' * useEcho,',
' * useChatChannel,', ' * useChatChannel,',
' * DjangoError,',
' * } from \'@/api\'', ' * } from \'@/api\'',
' * ```', ' * ```',
' */', ' */',
@@ -51,54 +42,75 @@ export function generateIndex({ channelsSchema, mizanSchema }) {
'', '',
] ]
// ==========================================================================
// mizan Provider & Hooks (from generated.django.tsx)
// ==========================================================================
const functions = mizanSchema?.['x-mizan-functions'] || [] const functions = mizanSchema?.['x-mizan-functions'] || []
const contextGroups = mizanSchema?.['x-mizan-contexts'] || {}
const hasMizan = functions.length > 0 const hasMizan = functions.length > 0
if (hasMizan) { if (hasMizan) {
const contextHooks = extractContextHooks(mizanSchema) const globalContexts = functions.filter(fn => fn.isContext === 'global')
const contexts = functions.filter(fn => fn.isContext)
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global')
lines.push('// =============================================================================') lines.push('// =============================================================================')
lines.push('// mizan Provider & Hooks') lines.push('// mizan Provider & Hooks')
lines.push('// =============================================================================') lines.push('// =============================================================================')
lines.push('') lines.push('')
// Server exports (getDjangoHydration runs in server components) // Server exports
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push('export {') lines.push('export {')
lines.push(' getMizanHydration,')
lines.push(' getDjangoHydration,') lines.push(' getDjangoHydration,')
lines.push(' type MizanHydrationData,')
lines.push(' type DjangoHydration,') lines.push(' type DjangoHydration,')
lines.push("} from './generated.django.server'") lines.push("} from './generated.server'")
lines.push('') lines.push('')
} }
// Client exports // Client exports
lines.push('export {') lines.push('export {')
lines.push(' // Provider') lines.push(' // Provider')
lines.push(' MizanContext,')
lines.push(' type MizanContextProps,')
lines.push(' DjangoContext,') lines.push(' DjangoContext,')
lines.push(' type DjangoContextProps,') lines.push(' type DjangoContextProps,')
if (contexts.length > 0) { // Global context hooks
if (globalContexts.length > 0) {
lines.push('') lines.push('')
lines.push(' // Context hooks') lines.push(' // Global context hooks')
for (const hookName of contextHooks) { for (const ctx of globalContexts) {
lines.push(` ${hookName},`) const hookPascal = pascalCase(ctx.camelName)
lines.push(` use${hookPascal},`)
} }
lines.push('') lines.push('')
lines.push(' // Refresh hooks') lines.push(' // Refresh hooks')
lines.push(' useMizanRefresh,')
lines.push(' useDjangoRefresh,') 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) { if (regularFunctions.length > 0) {
lines.push('') lines.push('')
lines.push(' // Function hooks') lines.push(' // Function hooks')
for (const fn of regularFunctions) { for (const fn of regularFunctions) {
const pascal = fn.camelName.charAt(0).toUpperCase() + fn.camelName.slice(1) const pascal = pascalCase(fn.camelName)
lines.push(` use${pascal},`) lines.push(` use${pascal},`)
} }
} }
@@ -112,12 +124,12 @@ export function generateIndex({ channelsSchema, mizanSchema }) {
lines.push(' type ConnectionStatus,') lines.push(' type ConnectionStatus,')
lines.push(' type PushMessage,') lines.push(' type PushMessage,')
lines.push(' type PushListener,') lines.push(' type PushListener,')
lines.push("} from './generated.django'") lines.push("} from './generated.provider'")
lines.push('') lines.push('')
} }
// ========================================================================== // ==========================================================================
// Channel Hooks (from generated.channels.hooks.tsx) // Channel Hooks
// ========================================================================== // ==========================================================================
const channels = channelsSchema?.['x-mizan-channels'] || [] const channels = channelsSchema?.['x-mizan-channels'] || []
@@ -134,7 +146,6 @@ export function generateIndex({ channelsSchema, mizanSchema }) {
lines.push("} from './generated.channels.hooks'") lines.push("} from './generated.channels.hooks'")
lines.push('') lines.push('')
// Channel types
lines.push('// =============================================================================') lines.push('// =============================================================================')
lines.push('// Channel Types') lines.push('// Channel Types')
lines.push('// =============================================================================') lines.push('// =============================================================================')

View File

@@ -108,7 +108,7 @@ export async function generateMizanTypes(schema) {
const functions = schema['x-mizan-functions'] || [] const functions = schema['x-mizan-functions'] || []
if (functions.length > 0) { if (functions.length > 0) {
lines.push('export const DJANGO_FUNCTIONS = {') lines.push('export const MIZAN_FUNCTIONS = {')
for (const fn of functions) { for (const fn of functions) {
lines.push(` ${fn.camelName}: {`) lines.push(` ${fn.camelName}: {`)
lines.push(` name: '${fn.name}',`) lines.push(` name: '${fn.name}',`)
@@ -119,7 +119,7 @@ export async function generateMizanTypes(schema) {
} }
lines.push('} as const') lines.push('} as const')
} else { } else {
lines.push('export const DJANGO_FUNCTIONS = {} as const') lines.push('export const MIZAN_FUNCTIONS = {} as const')
} }
lines.push('') lines.push('')
@@ -127,25 +127,57 @@ export async function generateMizanTypes(schema) {
return lines.join('\n') 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. * Generate the React provider that wraps MizanProvider with typed hooks.
* *
* The generated provider: * The generated provider:
* - Wraps MizanProvider (from mizan library) * - MizanContext: Root provider with global context bundled fetch
* - Passes context names for auto-fetch * - Named context providers: <UserContext user_id={...}>
* - Provides typed hooks for contexts and functions * - Mutation hooks with auto-invalidation
* - Plain function hooks
*/ */
export function generateMizanProvider(schema, options = {}) { export function generateMizanProvider(schema, options = {}) {
const { hasChannels = false } = options const { hasChannels = false } = options
const functions = schema['x-mizan-functions'] || [] const functions = schema['x-mizan-functions'] || []
const contextGroups = schema['x-mizan-contexts'] || {}
if (functions.length === 0) { if (functions.length === 0) {
return null return null
} }
// Separate contexts from regular functions // Partition functions
const contexts = functions.filter(fn => fn.isContext) const globalContexts = functions.filter(fn => fn.isContext === 'global')
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) 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 // Collect type imports
const typeImports = [] const typeImports = []
@@ -166,10 +198,11 @@ export function generateMizanProvider(schema, options = {}) {
'// Regenerate with: npm run schemas', '// Regenerate with: npm run schemas',
'', '',
'// This file provides typed wrappers around the mizan library.', '// This file provides typed wrappers around the mizan library.',
'// - DjangoContext: Typed provider wrapping MizanProvider', '// - MizanContext: Root provider with global context',
'// - Typed hooks: useAuthStatus(), useUser(), etc.', '// - Named context providers: <UserContext user_id={...}>',
'// - Typed hooks with auto-invalidation',
'', '',
"import { type ReactNode, useCallback } from 'react'", "import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'",
"import {", "import {",
" MizanProvider,", " MizanProvider,",
" useMizan,", " useMizan,",
@@ -180,7 +213,6 @@ export function generateMizanProvider(schema, options = {}) {
"} from 'mizan'", "} from 'mizan'",
...(hasChannels ? [ ...(hasChannels ? [
"import { ChannelProvider, ChannelConnection } from 'mizan/channels'", "import { ChannelProvider, ChannelConnection } from 'mizan/channels'",
"import { useRef } from 'react'",
] : []), ] : []),
'', '',
] ]
@@ -191,7 +223,7 @@ export function generateMizanProvider(schema, options = {}) {
} }
// ============================================================================ // ============================================================================
// Hydration types // Hydration types (global contexts only)
// ============================================================================ // ============================================================================
lines.push('// ============================================================================') lines.push('// ============================================================================')
@@ -199,20 +231,19 @@ export function generateMizanProvider(schema, options = {}) {
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push('/** Typed hydration data for SSR */') lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface DjangoHydration {') lines.push('export interface MizanHydrationData {')
for (const ctx of contexts) { for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
} }
lines.push('}') lines.push('}')
lines.push('') lines.push('')
lines.push('/** Convert typed hydration to mizan format */') lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {')
lines.push('function toMizanHydration(hydration?: DjangoHydration): MizanHydration | undefined {')
lines.push(' if (!hydration) return undefined') lines.push(' if (!hydration) return undefined')
lines.push(' const result: MizanHydration = {}') lines.push(' const result: MizanHydration = {}')
for (const ctx of contexts) { for (const ctx of globalContexts) {
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`) lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
} }
lines.push(' return result') lines.push(' return result')
@@ -221,50 +252,81 @@ export function generateMizanProvider(schema, options = {}) {
} }
// ============================================================================ // ============================================================================
// Provider // Global Context Loader (inner component, fetches GET /ctx/global/)
// ============================================================================
if (globalContexts.length > 0) {
lines.push('// ============================================================================')
lines.push('// Global Context Loader')
lines.push('// ============================================================================')
lines.push('')
lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {')
lines.push(' const mizan = useMizan()')
lines.push(' const loaded = useRef(false)')
lines.push('')
lines.push(' useEffect(() => {')
lines.push(' if (loaded.current) return')
lines.push(' loaded.current = true')
lines.push('')
lines.push(' ;(async () => {')
lines.push(' await mizan.whenReady')
lines.push(' try {')
lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)")
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
lines.push(' for (const [name, data] of Object.entries(result.data)) {')
lines.push(' mizan.setContextData(name, data)')
lines.push(' }')
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[MizanContext] Global context fetch failed:', e)")
lines.push(' }')
lines.push(' })()')
lines.push(' }, [mizan])')
lines.push('')
lines.push(' return <>{children}</>')
lines.push('}')
lines.push('')
}
// ============================================================================
// Root Provider (MizanContext)
// ============================================================================ // ============================================================================
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('// Provider') lines.push('// Root Provider')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('export interface DjangoContextProps {') lines.push('export interface MizanContextProps {')
lines.push(' children: ReactNode') lines.push(' children: ReactNode')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push(' /** SSR hydration data */') lines.push(' /** SSR hydration data (global contexts only) */')
lines.push(' hydration?: DjangoHydration') lines.push(' hydration?: MizanHydrationData')
} }
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */') lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
lines.push(' wsUrl?: string') lines.push(' wsUrl?: string')
lines.push(' /** Base URL for HTTP fallback (default: /api/mizan) */') lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */')
lines.push(' baseUrl?: string') lines.push(' baseUrl?: string')
lines.push('}') lines.push('}')
lines.push('') lines.push('')
// Context names array for MizanProvider
const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ')
lines.push('/**') lines.push('/**')
lines.push(' * Typed Django context provider.') lines.push(' * Root mizan provider. Mount at your app root.')
lines.push(' *')
lines.push(' * Wraps MizanProvider with:')
lines.push(' * - Typed hydration')
lines.push(' * - Auto-fetch for registered contexts')
lines.push(' *') lines.push(' *')
lines.push(' * Usage:') lines.push(' * Usage:')
lines.push(' * <DjangoContext hydration={hydration}>') lines.push(' * <MizanContext hydration={hydration}>')
lines.push(' * <App />') lines.push(' * <App />')
lines.push(' * </DjangoContext>') lines.push(' * </MizanContext>')
lines.push(' */') lines.push(' */')
lines.push('export function DjangoContext({') lines.push('export function MizanContext({')
lines.push(' children,') lines.push(' children,')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push(' hydration,') lines.push(' hydration,')
} }
lines.push(' wsUrl,') lines.push(' wsUrl,')
lines.push(' baseUrl,') lines.push(' baseUrl,')
lines.push('}: DjangoContextProps) {') lines.push('}: MizanContextProps) {')
if (hasChannels) { if (hasChannels) {
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)') lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
@@ -274,11 +336,11 @@ export function generateMizanProvider(schema, options = {}) {
lines.push('') lines.push('')
} }
// Build the JSX tree
lines.push(' return (') lines.push(' return (')
lines.push(' <MizanProvider') lines.push(' <MizanProvider')
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push(' hydration={toMizanHydration(hydration)}') lines.push(' hydration={toMizanHydration(hydration)}')
lines.push(` contexts={[${contextNames}]}`)
} }
lines.push(' wsUrl={wsUrl}') lines.push(' wsUrl={wsUrl}')
lines.push(' baseUrl={baseUrl}') lines.push(' baseUrl={baseUrl}')
@@ -287,12 +349,18 @@ export function generateMizanProvider(schema, options = {}) {
} }
lines.push(' >') lines.push(' >')
// Inner content: GlobalContextLoader wraps children if needed
let innerContent = '{children}'
if (globalContexts.length > 0) {
innerContent = `<GlobalContextLoader>{children}</GlobalContextLoader>`
}
if (hasChannels) { if (hasChannels) {
lines.push(' <ChannelProvider connection={connectionRef.current} autoConnect={true}>') lines.push(` <ChannelProvider connection={connectionRef.current} autoConnect={true}>`)
lines.push(' {children}') lines.push(` ${innerContent}`)
lines.push(' </ChannelProvider>') lines.push(` </ChannelProvider>`)
} else { } else {
lines.push(' {children}') lines.push(` ${innerContent}`)
} }
lines.push(' </MizanProvider>') lines.push(' </MizanProvider>')
@@ -300,78 +368,200 @@ export function generateMizanProvider(schema, options = {}) {
lines.push('}') 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('')
// ============================================================================ // ============================================================================
// Context Hooks // Global Context Hooks
// ============================================================================ // ============================================================================
if (contexts.length > 0) { if (globalContexts.length > 0) {
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('// Context Hooks (typed wrappers)') lines.push('// Global Context Hooks')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
for (const ctx of contexts) { for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName) const pascal = pascalCase(ctx.camelName)
lines.push(`/**`) lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`)
lines.push(` * Get ${ctx.name} context data.`)
lines.push(` * @throws if context not loaded yet`)
lines.push(` */`)
lines.push(`export function use${pascal}(): ${ctx.outputType} {`) lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`) lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`)
lines.push(` if (data === undefined) {`) lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` }`)
lines.push(` return data`) lines.push(` return data`)
lines.push(`}`) lines.push(`}`)
lines.push('') lines.push('')
} }
// Refresh hooks lines.push('/** Refresh functions for global contexts. */')
lines.push('/**') lines.push('export function useMizanRefresh() {')
lines.push(' * Get context refresh functions without subscribing to data changes.') lines.push(' const { invalidateContext } = useMizan()')
lines.push(' * Use this in components that only need to trigger refreshes.')
lines.push(' */')
lines.push('export function useDjangoRefresh() {')
lines.push(' const { refreshContext, refreshAllContexts } = useMizan()')
lines.push(' return {') lines.push(' return {')
for (const ctx of contexts) { for (const ctx of globalContexts) {
const pascal = pascalCase(ctx.camelName) const pascal = pascalCase(ctx.camelName)
lines.push(` refresh${pascal}: () => refreshContext('${ctx.name}'),`) lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`)
} }
lines.push(' refreshAll: refreshAllContexts,')
lines.push(' }') lines.push(' }')
lines.push('}') lines.push('}')
lines.push('') lines.push('')
// Legacy alias
lines.push('/** @deprecated Use useMizanRefresh instead */')
lines.push('export const useDjangoRefresh = useMizanRefresh')
lines.push('')
} }
// ============================================================================ // ============================================================================
// Function Hooks // Named Context Providers
// ============================================================================ // ============================================================================
if (regularFunctions.length > 0) { if (namedContextEntries.length > 0) {
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('// Function Hooks (typed wrappers)') lines.push('// Named Context Providers')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
for (const fn of regularFunctions) { for (const [ctxName, ctxMeta] of namedContextEntries) {
const ctxPascal = toPascalCase(ctxName)
const ctxFunctions = functions.filter(fn => fn.isContext === ctxName)
const params = ctxMeta.params || {}
const paramEntries = Object.entries(params)
// Internal React context type
lines.push(`const ${ctxPascal}ContextInternal = createContext<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(`} | null>(null)`)
lines.push('')
// Props interface
lines.push(`export interface ${ctxPascal}ContextProps {`)
lines.push(` children: ReactNode`)
for (const [pName, pMeta] of paramEntries) {
const tsType = jsonTypeToTS(pMeta.type)
const optional = pMeta.required ? '' : '?'
lines.push(` ${pName}${optional}: ${tsType}`)
}
lines.push(`}`)
lines.push('')
// Provider component
lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`)
lines.push(` const mizan = useMizan()`)
lines.push(` const [data, setData] = useState<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(` } | null>(null)`)
lines.push('')
lines.push(` const refetch = useCallback(async () => {`)
lines.push(` await mizan.whenReady`)
lines.push(` const qs = new URLSearchParams()`)
for (const [pName] of paramEntries) {
lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`)
}
lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`)
lines.push(` const result = await resp.json()`)
lines.push(` if (!result.error) setData(result.data)`)
// Dependency array: mizan + each param
const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)]
lines.push(` }, [${deps.join(', ')}])`)
lines.push('')
lines.push(` useEffect(() => { refetch() }, [refetch])`)
lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`)
lines.push('')
lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}</${ctxPascal}ContextInternal>`)
lines.push(`}`)
lines.push('')
// Individual data hooks
for (const fn of ctxFunctions) {
const hookPascal = pascalCase(fn.camelName)
lines.push(`export function use${hookPascal}(): ${fn.outputType} {`)
lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`)
lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`)
lines.push(` return ctx.${fn.name}`)
lines.push(`}`)
lines.push('')
}
}
}
// ============================================================================
// Mutation Hooks (with auto-invalidation)
// ============================================================================
if (mutationFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Mutation Hooks (auto-invalidate on success)')
lines.push('// ============================================================================')
lines.push('')
for (const fn of mutationFunctions) {
const pascal = pascalCase(fn.camelName)
const transport = fn.transport || 'http'
const affectedContexts = getAffectedContexts(fn.affects)
lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`)
lines.push(`export function use${pascal}() {`)
lines.push(` const mizan = useMizan()`)
if (fn.hasInput) {
lines.push(` return useCallback(async (input: ${fn.inputType}) => {`)
lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`)
} else {
lines.push(` return useCallback(async () => {`)
lines.push(` const result = await mizan.call<void, ${fn.outputType}>('${fn.name}', undefined, '${transport}')`)
}
// Invalidation
if (affectedContexts.length === 1) {
lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`)
} else if (affectedContexts.length > 1) {
lines.push(` await Promise.all([`)
for (const ctx of affectedContexts) {
lines.push(` mizan.invalidateContext('${ctx}'),`)
}
lines.push(` ])`)
}
lines.push(` return result`)
lines.push(` }, [mizan])`)
lines.push(`}`)
lines.push('')
}
}
// ============================================================================
// Plain Function Hooks
// ============================================================================
if (plainFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Function Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const fn of plainFunctions) {
const pascal = pascalCase(fn.camelName) const pascal = pascalCase(fn.camelName)
// Transport is known at generation time - pass it directly
const transport = fn.transport || 'http' const transport = fn.transport || 'http'
if (fn.hasInput) { if (fn.hasInput) {
lines.push(`/**`) lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`export function use${pascal}() {`) lines.push(`export function use${pascal}() {`)
lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`) lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`) lines.push(`}`)
} else { } else {
lines.push(`/**`) lines.push(`/** Call ${fn.name}. Transport: ${transport} */`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`export function use${pascal}() {`) lines.push(`export function use${pascal}() {`)
lines.push(` return useMizanCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`) lines.push(` return useMizanCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`) lines.push(`}`)
@@ -401,14 +591,14 @@ export function generateMizanProvider(schema, options = {}) {
*/ */
export function generateMizanServer(schema) { export function generateMizanServer(schema) {
const functions = schema['x-mizan-functions'] || [] const functions = schema['x-mizan-functions'] || []
const contexts = functions.filter(fn => fn.isContext) const globalContexts = functions.filter(fn => fn.isContext === 'global')
if (contexts.length === 0) { if (globalContexts.length === 0) {
return null return null
} }
// Collect type imports for contexts // Collect type imports for global contexts
const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean) const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean)
const uniqueTypeImports = [...new Set(typeImports)].sort() const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [ const lines = [
@@ -430,55 +620,54 @@ export function generateMizanServer(schema) {
lines.push('// Hydration Types') lines.push('// Hydration Types')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('/** Typed hydration data for SSR */') lines.push('/** Typed hydration data for SSR (global contexts only) */')
lines.push('export interface DjangoHydration {') lines.push('export interface MizanHydrationData {')
for (const ctx of contexts) { for (const ctx of globalContexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
} }
lines.push('}') lines.push('}')
lines.push('') lines.push('')
lines.push('/** @deprecated Use MizanHydrationData instead */')
lines.push('export type DjangoHydration = MizanHydrationData')
lines.push('')
// SSR Hydration Helper // SSR Hydration Helper — single bundled GET
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('// SSR Hydration Helper') lines.push('// SSR Hydration Helper')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('/**') lines.push('/**')
lines.push(' * Fetch hydration data for SSR.') lines.push(' * Fetch hydration data for SSR via bundled context endpoint.')
lines.push(' *') lines.push(' *')
lines.push(' * Call this in your server component:') lines.push(' * Call this in your server component:')
lines.push(' * const hydration = await getDjangoHydration(client)') lines.push(' * const hydration = await getMizanHydration(client)')
lines.push(' * return <DjangoContext hydration={hydration}>...</DjangoContext>') lines.push(' * return <MizanContext hydration={hydration}>...</MizanContext>')
lines.push(' */') lines.push(' */')
lines.push('export async function getDjangoHydration(') lines.push('export async function getMizanHydration(')
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }") lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
lines.push('): Promise<DjangoHydration> {') lines.push('): Promise<MizanHydrationData> {')
lines.push(' const hydration: DjangoHydration = {}') lines.push(' const hydration: MizanHydrationData = {}')
lines.push('') lines.push('')
lines.push(' const results = await Promise.allSettled([') lines.push(' try {')
for (const ctx of contexts) { lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
lines.push(` client.request('POST', '/api/mizan/call/', { fn: '${ctx.name}', args: {} }),`) lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
for (const ctx of globalContexts) {
lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`)
} }
lines.push(' ])') lines.push(' } else {')
lines.push('') lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
lines.push(' }')
contexts.forEach((ctx, i) => { lines.push(' } catch (e) {')
lines.push(` if (results[${i}].status === 'fulfilled') {`) lines.push(" console.error('[getMizanHydration] Request failed:', e)")
lines.push(` const data = await (results[${i}] as PromiseFulfilledResult<Response>).value.json()`)
lines.push(` if (data.error) {`)
lines.push(` console.error('[getDjangoHydration] ${ctx.name} failed:', data.code, data.message)`)
lines.push(` } else {`)
lines.push(` hydration.${ctx.camelName} = data.data`)
lines.push(` }`)
lines.push(` } else {`)
lines.push(` console.error('[getDjangoHydration] ${ctx.name} request failed:', (results[${i}] as PromiseRejectedResult).reason)`)
lines.push(' }') lines.push(' }')
})
lines.push('') lines.push('')
lines.push(' return hydration') lines.push(' return hydration')
lines.push('}') lines.push('}')
lines.push('') lines.push('')
lines.push('/** @deprecated Use getMizanHydration instead */')
lines.push('export const getDjangoHydration = getMizanHydration')
lines.push('')
return lines.join('\n') return lines.join('\n')
} }
@@ -658,7 +847,7 @@ export function generateMizanForms(schema) {
lines.push('// Form Registry') lines.push('// Form Registry')
lines.push('// ============================================================================') lines.push('// ============================================================================')
lines.push('') lines.push('')
lines.push('export const DJANGO_FORMS = {') lines.push('export const MIZAN_FORMS = {')
for (const [formName, group] of formGroups) { for (const [formName, group] of formGroups) {
if (!group.schema) continue if (!group.schema) continue
const pascalName = toPascalCase(formName) const pascalName = toPascalCase(formName)