/** * React Stage 2 — Generates idiomatic React providers + hooks on top of the kernel. * * The kernel (@mizan/base) owns data, status, error. This adapter wraps each * registered context in a React Provider component so kernel subscription happens * once per provider mount, and consumer hooks read from React Context. * * Output shape: * root — calls configure(), auto-mounts global * per-named-context provider * * * * * useGlobalContext() full ContextState * useCurrentUser() convenience: data field * useUserContext() full ContextState * useUserProfile() convenience: data field * useEcho() mutation/plain — { mutate, isPending, error } * useMizan() escape hatch — { call, fetch } */ 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 hasGlobal = !!contextGroups.global const globalFns = 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 = [] // ── Header + imports ───────────────────────────────────────────────── lines.push( "'use client'", '', '// AUTO-GENERATED by mizan — do not edit', '', "import {", " createContext,", " useCallback,", " useContext,", " useEffect,", " useRef,", " useState,", " useSyncExternalStore,", " type ReactNode,", "} from 'react'", "import {", " configure,", " initSession,", " mizanCall,", " mizanFetch,", " MizanError,", " registerContext,", " type ContextState,", "} from '@mizan/base'", '', ) 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'`, '') } // ── Internal helper: subscribe to kernel state from a Provider ────── lines.push( '// Internal — runs inside a Provider, registers with the kernel exactly once.', 'function useContextSubscription(', ' name: string,', ' params: Record,', ' fetchFn: () => Promise,', ' initialData?: T,', '): ContextState {', ' const ref = useRef | null>(null)', ' if (!ref.current) {', ' ref.current = registerContext(name, params, fetchFn, initialData)', ' }', ' const handle = ref.current', '', ' useEffect(() => {', " if (handle.getState().status === 'idle') handle.refetch()", ' return () => handle.unregister()', ' }, [handle])', '', ' return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)', '}', '', ) // ── Internal helper: mutation wrapper ─────────────────────────────── lines.push( '// Internal — wraps an imperative call() with isPending / error state.', 'interface MutationHook {', ' mutate: (args: TArgs) => Promise', ' isPending: boolean', ' error: Error | null', '}', '', 'function useMutation(', ' callFn: (args: TArgs) => Promise,', '): MutationHook {', ' const [isPending, setIsPending] = useState(false)', ' const [error, setError] = useState(null)', '', ' const mutate = useCallback(async (args: TArgs) => {', ' setIsPending(true)', ' setError(null)', ' try {', ' return await callFn(args)', ' } catch (e) {', ' setError(e as Error)', ' throw e', ' } finally {', ' setIsPending(false)', ' }', ' }, [callFn])', '', ' return { mutate, isPending, error }', '}', '', ) // ── Global context provider + hooks ───────────────────────────────── if (hasGlobal) { lines.push( '// ── Global Context ──', '', 'const GlobalCtx = createContext | null>(null)', '', 'export function GlobalContextProvider({ children }: { children: ReactNode }) {', " const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : undefined", " const state = useContextSubscription('global', {}, () => fetchGlobalContext({} as any), ssrData)", ' return {children}', '}', '', 'export function useGlobalContext(): ContextState {', ' const ctx = useContext(GlobalCtx)', " if (!ctx) throw new Error('useGlobalContext requires or ')", ' return ctx', '}', '', ) for (const fn of globalFns) { const p = pascalCase(fn.camelName) lines.push( `export function use${p}(): ${fn.outputType} | null {`, ` return useGlobalContext().data?.${fn.name} ?? null`, '}', '', ) } } // ── Named context providers + hooks ───────────────────────────────── for (const [ctxName, ctxMeta] of namedContexts) { const p = pascalCase(ctxName) const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) const paramKeys = Object.keys(ctxMeta.params || {}) const hasParams = paramKeys.length > 0 lines.push( `// ── ${p} Context ──`, '', `const ${p}Ctx = createContext | null>(null)`, '', ) if (hasParams) { lines.push( `export function ${p}Context({ children, ...params }: ${p}ContextParams & { children: ReactNode }) {`, ` const state = useContextSubscription('${ctxName}', params, () => fetch${p}Context(params))`, ` return <${p}Ctx.Provider value={state}>{children}`, '}', ) } else { lines.push( `export function ${p}Context({ children }: { children: ReactNode }) {`, ` const state = useContextSubscription('${ctxName}', {}, () => fetch${p}Context({} as any))`, ` return <${p}Ctx.Provider value={state}>{children}`, '}', ) } lines.push('') lines.push( `export function use${p}Context(): ContextState<${p}ContextData> {`, ` const ctx = useContext(${p}Ctx)`, ` if (!ctx) throw new Error('use${p}Context requires <${p}Context>')`, ' return ctx', '}', '', ) for (const fn of ctxFunctions) { const fnPascal = pascalCase(fn.camelName) lines.push( `export function use${fnPascal}(): ${fn.outputType} | null {`, ` return use${p}Context().data?.${fn.name} ?? null`, '}', '', ) } } // ── Mutation + plain function hooks ───────────────────────────────── for (const fn of [...mutations, ...plainFns]) { const p = pascalCase(fn.camelName) if (fn.hasInput) { lines.push( `export function use${p}() {`, ` return useMutation[0], Awaited>>(call${p})`, '}', '', ) } else { lines.push( `export function use${p}() {`, ` return useMutation>>(() => call${p}() as any)`, '}', '', ) } } // ── Root MizanContext provider ────────────────────────────────────── lines.push( '// ── MizanContext root provider ──', '', 'export interface MizanContextProps {', ' /** Base URL for protocol endpoints. Defaults to "/api/mizan". */', ' baseUrl?: string', ' children: ReactNode', '}', '', '/**', " * Root provider — calls configure() once and mounts the global context (if defined).", ' * Must wrap any component using Mizan-generated hooks.', ' */', 'export function MizanContext({ baseUrl, children }: MizanContextProps) {', ' const configured = useRef(false)', ' if (!configured.current) {', ' if (baseUrl) configure({ baseUrl })', ' configured.current = true', ' }', ) if (hasGlobal) { lines.push(' return {children}') } else { lines.push(' return <>{children}') } lines.push('}', '') // ── Escape hatch: useMizan ────────────────────────────────────────── lines.push( '// ── Imperative escape hatch ──', '', '/**', ' * Returns the imperative kernel API. For test harnesses or rare cases where', ' * a typed generated hook does not fit. Most app code should use the typed hooks.', ' */', 'export function useMizan() {', ' return { call: mizanCall, fetch: mizanFetch }', '}', '', ) // ── Re-exports ────────────────────────────────────────────────────── lines.push( "export type { ContextState } from '@mizan/base'", "export { configure, initSession, MizanError } from '@mizan/base'", '', ) return lines.join('\n') }