238 lines
8.9 KiB
TypeScript
238 lines
8.9 KiB
TypeScript
import type { Attr, PageAst, ViewNode } from "@wrnexus/syntax";
|
|
|
|
export class NativeCompileError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "NativeCompileError";
|
|
}
|
|
}
|
|
|
|
const tagMap: Record<string, string> = {
|
|
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<string, string> = {
|
|
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>): 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>): 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<string>, 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>${text}</Text>`;
|
|
})
|
|
.join("");
|
|
return `<${nativeTag}${attrs}>${children}</${nativeTag}>`;
|
|
}
|
|
|
|
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`;
|
|
}
|