import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl, type StructuredImportDecl, type ViewNode, } from "@wrnexus/syntax"; const RESERVED_BINDINGS = new Set([ "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface", "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield", ]); const RUNTIME_BINDINGS = new Set([ "context", "state", "output", "server", "props", "refs", "event", "payload", ]); function safeIdentifier(name: string): boolean { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name); } function identifierReferenced(source: string, name: string): boolean { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source); } function viewReferenceSource(nodes: ViewNode[]): string[] { return nodes.flatMap((node): string[] => { if (node.type === "text") return [node.value]; if (node.type === "each") { return [ node.list, node.key ?? "", ...viewReferenceSource(node.body), ...viewReferenceSource(node.empty), ]; } if (node.type === "if") { return node.branches.flatMap((branch) => [ branch.cond ?? "", ...viewReferenceSource(branch.body), ]); } return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)]; }); } function browserReferenceSource(ast: PageAst, functions: RuntimeFunctionDecl[]): string { return [ ...functions.flatMap((fn) => [fn.source, fn.body]), ...ast.computed.map((entry) => entry.expr), ...ast.effects, ast.lifecycle.mount ?? "", ast.lifecycle.update ?? "", ast.lifecycle.unmount ?? "", ...viewReferenceSource(ast.view), ].join("\n"); } function renderSelectedImport( entry: StructuredImportDecl, source: string, ): { code: string; bindings: string[] } | null { if (entry.typeOnly) return null; const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source); if (entry.source.endsWith(".wrn") && !isStore) return null; const defaultImport = entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport)) ? entry.defaultImport : undefined; const namespaceImport = entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport)) ? entry.namespaceImport : undefined; const namedImports = entry.namedImports.filter( (item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local)), ); const bindings = [ ...(defaultImport ? [defaultImport] : []), ...(namespaceImport ? [namespaceImport] : []), ...namedImports.map((item) => item.local), ].filter(safeIdentifier); const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length); const sideEffectOnly = !entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0; if (!hasBindings && !sideEffectOnly) return null; if (sideEffectOnly) return { code: entry.raw, bindings: [] }; const clauses: string[] = []; if (defaultImport) clauses.push(defaultImport); if (namespaceImport) clauses.push(`* as ${namespaceImport}`); if (namedImports.length) { clauses.push( `{ ${namedImports .map((item) => item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`, ) .join(", ")} }`, ); } return { code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`, bindings, }; } function selectedBrowserImports( ast: PageAst, functions: RuntimeFunctionDecl[], ): { code: string; bindings: string[] }[] { const referenceSource = browserReferenceSource(ast, functions); return ast.structuredImports .map((entry) => renderSelectedImport(entry, referenceSource)) .filter((entry): entry is { code: string; bindings: string[] } => entry !== null); } export function browserModuleRequired(ast: PageAst): boolean { const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime), ); return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0; } function functionEntry( ast: PageAst, fn: RuntimeFunctionDecl, availableFunctions: string[], ): string { const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name)); /* * Names the function body declares for itself. * * Props and state are destructured into the SAME scope as the body, so a * body that declares `var size` when `size` is also a prop produced * "Identifier 'size' has already been declared" and the entire module * failed to parse -- taking every function in the component down with it, * with nothing to point at the one line responsible. Skipping the alias for * a shadowed name is also what plain JavaScript does: inside that function * the local wins. */ const declaredLocals = new Set(); for (const match of fn.body.matchAll( /\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)|\bfunction\s+([A-Za-z_$][\w$]*)/g, )) { const name = match[1] ?? match[2]; if (name) declaredLocals.add(name); } const stateNames = ast.states .filter( (state) => state.runtime !== "server" && safeIdentifier(state.name) && !RUNTIME_BINDINGS.has(state.name) && !parameterNames.has(state.name) && !declaredLocals.has(state.name), ) .map((state) => state.name); const stateSet = new Set(stateNames); const propNames = ast.props .filter( (prop) => safeIdentifier(prop.name) && !RUNTIME_BINDINGS.has(prop.name) && !parameterNames.has(prop.name) && !stateSet.has(prop.name) && !declaredLocals.has(prop.name), ) .map((prop) => prop.name); const functionAliases = availableFunctions.filter( (name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !parameterNames.has(name) && !stateSet.has(name) && !propNames.includes(name) && !declaredLocals.has(name), ); const parameters = fn.parameters.map((parameter) => parameter.name).join(", "); const initialStateSnapshot = stateNames.length ? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };` : ""; const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : ""; const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : ""; const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" "); const syncStateFromContext = stateNames .map((name) => `${name} = context.state.${name};`) .join(" "); const peerAliases = functionAliases .map((name) => { const call = `context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs)`; if (!stateNames.length) { return `const ${name} = (...__wrnexusPeerArgs) => ${call};`; } return `const ${name} = (...__wrnexusPeerArgs) => { ${syncStateToContext} let __wrnexusPeerResult; try { __wrnexusPeerResult = ${call}; } catch (__wrnexusPeerError) { ${syncStateFromContext} throw __wrnexusPeerError; } if (__wrnexusPeerResult && typeof __wrnexusPeerResult.then === "function") { return Promise.resolve(__wrnexusPeerResult).finally(() => { ${syncStateFromContext} }); } ${syncStateFromContext} return __wrnexusPeerResult; };`; }) .join("\n"); const copyBack = stateNames .map( (name) => `if (!Object.is(${name}, __wrnexusInitialState[${JSON.stringify(name)}])) context.state.${name} = ${name};`, ) .join("\n"); const body = eraseFunctionTypes(fn.body); const runtimeBindings = [ !parameterNames.has("output") ? "const output = context.output;" : "", !parameterNames.has("server") ? "const server = context.server;" : "", !parameterNames.has("props") ? "const props = context.props;" : "", !parameterNames.has("refs") ? "const refs = context.refs;" : "", ] .filter(Boolean) .join("\n "); return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(context${parameters ? `, ${parameters}` : ""}) { ${initialStateSnapshot} ${stateAliases} ${propAliases} ${peerAliases} ${runtimeBindings} try { ${body} } finally { ${copyBack} } }`; } export function generateBrowserModule(ast: PageAst): string { const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime), ); const functionNames = functions.map((fn) => fn.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); const selectedImports = selectedBrowserImports(ast, functions); const imports = selectedImports.map((entry) => entry.code).join("\n"); const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; return `// generated WRNexusJS browser module for ${ast.name} ${imports} export const __wrnexusClientFunctions = { ${functions.map((fn) => ` ${functionEntry(ast, fn, functionNames)}`).join(",\n")} }; export const __wrnexusClientState = ${JSON.stringify(state)}; export const __wrnexusOutputs = ${JSON.stringify(ast.outputs)}; export const __wrnexusImportedBindings = { ${importedBindings.join(", ")} }; export function bindClientScope(context) { const functions = {}; const scopedContext = { ...context, functions }; for (const [name, handler] of Object.entries(__wrnexusClientFunctions)) { functions[name] = (...args) => handler(scopedContext, ...args); } return functions; } `; }