import type { Attr, PageAst, ViewNode } from "./parser.ts"; export class NativeCompileError extends Error { constructor(message: string) { super(message); this.name = "NativeCompileError"; } } const tagMap: Record = { div: "View", main: "View", section: "View", article: "View", nav: "View", header: "View", footer: "View", aside: "View", form: "View", ul: "View", ol: "View", li: "View", p: "Text", span: "Text", strong: "Text", em: "Text", small: "Text", label: "Text", h1: "Text", h2: "Text", h3: "Text", h4: "Text", h5: "Text", h6: "Text", button: "Pressable", a: "Pressable", input: "TextInput", textarea: "TextInput", img: "Image", view: "View", text: "Text", pressable: "Pressable", textinput: "TextInput", image: "Image", scrollview: "ScrollView", safeareaview: "SafeAreaView", flatlist: "FlatList", activityindicator: "ActivityIndicator", }; const attrMap: Record = { class: "style", className: "style", src: "source", alt: "accessibilityLabel", placeholder: "placeholder", disabled: "disabled", value: "value", href: "__href", "aria-label": "accessibilityLabel", }; function expression(value: string): string | null { const exact = /^\{([\s\S]+)\}$/.exec(value.trim()); return exact?.[1]?.trim() ?? null; } function textJsx(value: string): string { const pieces: string[] = []; let last = 0; for (const match of value.matchAll(/\{([^{}]+)\}/g)) { if (match.index! > last) pieces.push(value.slice(last, match.index)); const expr = match[1]!.trim(); pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`); last = match.index! + match[0].length; } pieces.push(value.slice(last)); return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "<" : ">")); } function eventBody(value: string, states: Set): string { let body = expression(value) ?? value; for (const state of states) { const cap = state[0]!.toUpperCase() + state.slice(1); body = body .replace(new RegExp(`\\b${state}\\+\\+`, "g"), `set${cap}(value => value + 1)`) .replace(new RegExp(`\\b${state}--`, "g"), `set${cap}(value => value - 1)`) .replace(new RegExp(`\\b${state}\\s*=\\s*([^;]+)`, "g"), `set${cap}($1)`); } return `() => { ${body} }`; } function renderAttrs(attrs: Attr[], states: Set): string { return attrs .map((attr) => { if (attr.event) { if (attr.name.startsWith("browser-")) return ""; const eventName = attr.name.startsWith("mobile-") ? attr.name.slice(7) : attr.name; const event = eventName === "click" || eventName === "press" ? "onPress" : eventName === "input" || eventName === "change" ? "onChangeText" : `on${eventName[0]!.toUpperCase()}${eventName.slice(1)}`; return ` ${event}={${eventBody(attr.value, states)}}`; } if (attr.name === "data-native-browser" || attr.name.startsWith("data-native-on-browser-")) return ""; if ( attr.name === "data-native-options" || attr.name === "data-native-only" || attr.name === "data-native-requires" || attr.name === "data-native-unsupported" ) return ""; if (attr.name === "data-native-mobile") { throw new NativeCompileError( `Declarative native capability "${attr.value}" currently targets browser/Capacitor pages. In Expo output, call the installed Expo package from an @mobile-event handler.`, ); } const name = attrMap[attr.name] ?? attr.name; if (name === "__href") return ` onPress={() => router.push(${JSON.stringify(attr.value)})}`; if (name === "source") { const expr = expression(attr.value); return ` source={${expr ? `{ uri: ${expr} }` : `{ uri: ${JSON.stringify(attr.value)} }`}}`; } if (name === "style" && attr.name !== "style") { return ` style={[${attr.value .split(/\s+/) .filter(Boolean) .map((value) => `styles[${JSON.stringify(value)}]`) .join(", ")} ]}`; } if (name === "style") { const inlineExpression = expression(attr.value); if (inlineExpression) return ` style={${inlineExpression}}`; throw new NativeCompileError( 'Inline CSS strings are not portable to native; use class="name" and a page style block', ); } if (attr.boolean) return ` ${name}`; const expr = expression(attr.value); return expr ? ` ${name}={${expr}}` : ` ${name}=${JSON.stringify(attr.value)}`; }) .join(""); } function renderNode(node: ViewNode, states: Set, key?: string): string { if (node.type === "text") return textJsx(node.value); if (node.type === "each") { const params = node.index ? `${node.item}, ${node.index}` : `${node.item}, __index`; const body = node.body .map((child, index) => renderNode(child, states, index === 0 ? (node.index ?? "__index") : undefined), ) .join(""); const empty = node.empty.map((child) => renderNode(child, states)).join(""); return `{(${node.list})?.length ? (${node.list}).map((${params}) => <>${body}) : <>${empty}}`; } if (node.type === "if") { const result = node.branches.reduceRight( (fallback, branch) => branch.cond === null ? `<>${branch.body.map((child) => renderNode(child, states)).join("")}` : `(${branch.cond}) ? <>${branch.body.map((child) => renderNode(child, states)).join("")} : ${fallback}`, "null", ); return `{${result}}`; } const nativeOnly = node.attrs.find( (attr) => !attr.event && attr.name === "data-native-only", )?.value; if (nativeOnly === "browser" || nativeOnly === "web") return ""; const nativeTag = tagMap[node.tag.toLowerCase()] ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined); if (!nativeTag) throw new NativeCompileError(`HTML element <${node.tag}> has no native equivalent`); const attrs = renderAttrs(node.attrs, states) + (key ? ` key={${key}}` : ""); if (nativeTag === "TextInput" || nativeTag === "Image" || nativeTag === "ActivityIndicator") return `<${nativeTag}${attrs} />`; const children = node.children .map((child) => { if (child.type !== "text") return renderNode(child, states); if (!child.value.trim()) return ""; const text = textJsx(child.value); return nativeTag === "Text" ? text : `${text}`; }) .join(""); return `<${nativeTag}${attrs}>${children}`; } function nativeStyles(blocks: string[]): string { const entries: string[] = []; for (const block of blocks) { for (const match of block.matchAll(/\.([A-Za-z_][\w-]*)\s*\{([^}]*)\}/g)) { const props: string[] = []; for (const declaration of match[2]!.split(";")) { const colon = declaration.indexOf(":"); if (colon < 0) continue; const name = declaration .slice(0, colon) .trim() .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); let value: string | number = declaration.slice(colon + 1).trim(); if (/^-?\d+(?:\.\d+)?px$/.test(value)) value = Number(value.slice(0, -2)); props.push( `${JSON.stringify(name)}: ${typeof value === "number" ? value : JSON.stringify(value)}`, ); } entries.push(`${JSON.stringify(match[1])}: { ${props.join(", ")} }`); } } return `const styles = StyleSheet.create({ ${entries.join(",\n")} });`; } /** Compile a parsed `.wrn` page to an Expo Router React Native screen. */ export function generateNative(ast: PageAst): string { if (ast.kind !== "page") throw new NativeCompileError("Native route compilation currently accepts page files only"); if (ast.dataApis.length) throw new NativeCompileError( "Data API blocks are not yet portable to native screens; fetch through the generated native backend helper", ); const states = new Set(ast.states.map((state) => state.name)); const hooks = ast.states .map((state) => { const cap = state.name[0]!.toUpperCase() + state.name.slice(1); return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`; }) .join("\n"); const body = ast.view.map((node) => renderNode(node, states)).join(""); const typeSource = ast.types .map((block) => block.trim()) .filter(Boolean) .join("\n\n"); return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n${ast.imports.join("\n")}\n\n${typeSource}\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body};\n}\n\n${nativeStyles(ast.styles)}\n`; }