feat: restore gateway HMR and add WRN formatting

This commit is contained in:
2026-07-13 14:54:03 +05:30
parent 3b46c69601
commit cff420a29e
71 changed files with 727 additions and 119 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0
- Added native Format Document and format-on-save support for `.wrn` files.
- Added configurable formatter enablement through `wrnexus.format.enable`.
## 0.1.0
- Initial Marketplace package for WrNexus `.wrn` language support.
+6 -3
View File
@@ -21,6 +21,8 @@ Bun-powered full-stack framework.
`entity.other.attribute-name.wrn.directive` and `…wire.event`).
- **Diagnostics.** Parse errors from the real `@wrnexus/compiler` are shown inline
as you type, anchored to the exact offset.
- **Formatting.** Format Document and format-on-save normalize WRN block, HTML,
CSS, and function indentation without rewriting application expressions.
- **Snippets.** `page`, `component`, `view`, `state`, `props`, `seo`, `api`,
`ssr`, `client`, `realtime`, `functions`, `style`, plus view helpers `mount`,
`for`, `show`, `t`.
@@ -29,9 +31,10 @@ Bun-powered full-stack framework.
## Settings
| Setting | Default | Description |
| ---------------------------- | ------- | ----------------------------------- |
| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. |
| Setting | Default | Description |
| ---------------------------- | ------- | ----------------------------------------- |
| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. |
| `wrnexus.format.enable` | `true` | Enable the built-in formatter for `.wrn`. |
## Building the diagnostics engine
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wrnexus",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wrnexus",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"devDependencies": {
"@vscode/vsce": "^3.9.2"
+9 -3
View File
@@ -1,8 +1,8 @@
{
"name": "wrnexus",
"displayName": "WrNexus (.wrn)",
"description": "Syntax highlighting, diagnostics, snippets and completions for WrNexus .wrn files.",
"version": "0.1.0",
"description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.",
"version": "0.2.0",
"publisher": "wrnexus",
"license": "MIT",
"repository": {
@@ -25,7 +25,8 @@
"categories": [
"Programming Languages",
"Snippets",
"Linters"
"Linters",
"Formatters"
],
"keywords": [
"wire",
@@ -90,6 +91,11 @@
"type": "boolean",
"default": true,
"description": "Show parse errors for .wrn files as you type."
},
"wrnexus.format.enable": {
"type": "boolean",
"default": true,
"description": "Enable the built-in formatter for .wrn files."
}
}
},
+217 -3
View File
@@ -40,10 +40,13 @@ var __export = (target, all) => {
var exports_src = {};
__export(exports_src, {
parse: () => parse,
generateNative: () => generateNative,
generate: () => generate,
compileWireFile: () => compileWireFile,
compileNativeWireFile: () => compileNativeWireFile,
compile: () => compile,
ParseError: () => ParseError,
NativeCompileError: () => NativeCompileError,
Lexer: () => Lexer,
LexError: () => LexError
});
@@ -678,7 +681,7 @@ function attrValue(attrs, name) {
}
function renderAttr(attr) {
if (attr.event)
return ` data-on-${attr.name}="${attrEscape(attr.value)}"`;
return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
switch (attr.name) {
case "api":
case "ssrGet":
@@ -690,6 +693,13 @@ function renderAttr(attr) {
return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`;
}
}
function eventAttribute(name) {
if (name.startsWith("browser-"))
return `data-on-wrnexus-browser-${name.slice(8)}`;
if (name.startsWith("mobile-"))
return `data-on-wrnexus-mobile-${name.slice(7)}`;
return `data-on-${name}`;
}
function renderAttrs(attrs, csrId) {
const rendered = attrs.map(renderAttr).join("");
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
@@ -768,7 +778,7 @@ function renderLoopBody(node) {
if (node.type === "if")
return compileIfExpr(node);
const attrs = node.attrs.map((a) => {
const name = a.event ? `data-on-${a.name}` : a.name;
const name = a.event ? eventAttribute(a.name) : a.name;
if (a.boolean)
return escLit(` ${name}`);
return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`);
@@ -1176,7 +1186,7 @@ function renderComponentNode(node, ctx) {
if (node.type === "each" || node.type === "if") {
throw new Error("Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).");
}
const attrs = node.attrs.map((a) => a.event ? ` data-on-${a.name}="${compileAttrValue(a.value, ctx)}"` : a.boolean ? ` ${a.name}` : ` ${a.name}="${compileAttrValue(a.value, ctx)}"`).join("");
const attrs = node.attrs.map((a) => a.event ? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"` : a.boolean ? ` ${a.name}` : ` ${a.name}="${compileAttrValue(a.value, ctx)}"`).join("");
const loops = loopVarsOf(node);
const childCtx = loops.length > 0 ? { ...ctx, loopVars: new Set([...ctx.loopVars ?? [], ...loops]) } : ctx;
if (VOID_ELEMENTS.has(node.tag.toLowerCase()))
@@ -1262,7 +1272,211 @@ function __wireAttr(v: any): string {
`;
}
// ../../packages/compiler/src/native-codegen.ts
class NativeCompileError extends Error {
constructor(message) {
super(message);
this.name = "NativeCompileError";
}
}
var tagMap = {
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"
};
var attrMap = {
class: "style",
className: "style",
src: "source",
alt: "accessibilityLabel",
placeholder: "placeholder",
disabled: "disabled",
value: "value",
href: "__href",
"aria-label": "accessibilityLabel"
};
function expression(value) {
const exact = /^\{([\s\S]+)\}$/.exec(value.trim());
return exact?.[1]?.trim() ?? null;
}
function textJsx(value) {
const pieces = [];
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 === "<" ? "&lt;" : "&gt;");
}
function eventBody(value, states) {
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 renderAttrs2(attrs, states) {
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 expr2 = expression(attr.value);
return ` source={${expr2 ? `{ uri: ${expr2} }` : `{ 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 renderNode2(node, states, key) {
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) => renderNode2(child, states, index === 0 ? node.index ?? "__index" : undefined)).join("");
const empty = node.empty.map((child) => renderNode2(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) => renderNode2(child, states)).join("")}</>` : `(${branch.cond}) ? <>${branch.body.map((child) => renderNode2(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 = renderAttrs2(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 renderNode2(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) {
const entries = [];
for (const block of blocks) {
for (const match of block.matchAll(/\.([A-Za-z_][\w-]*)\s*\{([^}]*)\}/g)) {
const props = [];
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) => c.toUpperCase());
let value = 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(`,
`)} });`;
}
function generateNative(ast) {
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.expr});`;
}).join(`
`);
const body = ast.view.map((node) => renderNode2(node, states)).join("");
return `// generated from .wrn for Expo/React Native
import React, { useState } from "react";
import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
import { useRouter } from "expo-router";
export default function ${ast.name}() {
const router = useRouter();
${hooks}
return <>${body}</>;
}
${nativeStyles(ast.styles)}
`;
}
// ../../packages/compiler/src/index.ts
function compileNativeWireFile(source) {
return generateNative(parse(source));
}
function compileWireFile(source) {
const ast = parse(source);
return `// compiled from .wrn
+12
View File
@@ -2,6 +2,7 @@
"use strict";
const vscode = require("vscode");
const { formatWrn } = require("./formatter.js");
// The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded
// defensively so the rest of the extension (highlighting, snippets, completion)
@@ -112,6 +113,17 @@ function activate(context) {
// Completions.
context.subscriptions.push(
vscode.languages.registerDocumentFormattingEditProvider("wrn", {
provideDocumentFormattingEdits(document, options) {
if (!vscode.workspace.getConfiguration("wrnexus").get("format.enable", true)) return [];
const formatted = formatWrn(document.getText(), options);
if (formatted === document.getText()) return [];
const end = document.positionAt(document.getText().length);
return [
vscode.TextEdit.replace(new vscode.Range(new vscode.Position(0, 0), end), formatted),
];
},
}),
vscode.languages.registerCompletionItemProvider(
"wrn",
{ provideCompletionItems: provideCompletions },
+117
View File
@@ -0,0 +1,117 @@
"use strict";
const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
/** Count structural curly braces while ignoring quoted strings and line comments. */
function curlyDelta(line, state) {
let delta = 0;
let quote = null;
let escaped = false;
for (let index = 0; index < line.length; index++) {
const char = line[index];
const next = line[index + 1];
if (state.blockComment) {
if (char === "*" && next === "/") {
state.blockComment = false;
index++;
}
continue;
}
if (state.template) {
if (!escaped && char === "`") state.template = false;
escaped = !escaped && char === "\\";
if (char !== "\\") escaped = false;
continue;
}
if (quote) {
if (!escaped && char === quote) quote = null;
escaped = !escaped && char === "\\";
if (char !== "\\") escaped = false;
continue;
}
if (char === "/" && next === "*") {
state.blockComment = true;
index++;
} else if (char === "/" && next === "/") {
break;
} else if (char === "`") {
state.template = true;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === "{") {
delta++;
} else if (char === "}") {
delta--;
}
}
return delta;
}
/** Return the net nesting introduced by HTML-like tags on this line. */
function htmlDelta(line) {
let delta = 0;
const tags = line.matchAll(/<\s*(\/?)\s*([A-Za-z][\w:-]*)\b[^>]*>/g);
for (const match of tags) {
const full = match[0];
const closing = match[1] === "/";
const name = match[2].toLowerCase();
if (closing) delta--;
else if (!VOID_ELEMENTS.has(name) && !/\/\s*>$/.test(full)) delta++;
}
return delta;
}
function leadingClosers(line) {
let count = /^\s*}/.test(line) ? 1 : 0;
const tags = line.match(/^\s*((?:<\/\s*[A-Za-z][\w:-]*\s*>\s*)+)/);
if (tags) count += [...tags[1].matchAll(/<\//g)].length;
return count;
}
/**
* Format WRN source conservatively: normalize structural indentation and
* trailing whitespace without rewriting expressions, HTML, CSS, or JS.
*/
function formatWrn(text, options = {}) {
const tabSize = Math.max(1, Number(options.tabSize) || 2);
const unit = options.insertSpaces === false ? "\t" : " ".repeat(tabSize);
const hadFinalNewline = /\r?\n$/.test(text);
const lines = text.replace(/\r\n/g, "\n").split("\n");
if (hadFinalNewline) lines.pop();
let depth = 0;
const state = { blockComment: false, template: false };
const formatted = lines.map((original) => {
const line = original.trimEnd();
if (!line.trim()) return "";
// Preserve multiline template-string content because its whitespace can be data.
const wasTemplate = state.template;
const trimmed = line.trimStart();
const indent = Math.max(0, depth - leadingClosers(trimmed));
const output = wasTemplate ? line : unit.repeat(indent) + trimmed;
depth = Math.max(0, depth + curlyDelta(trimmed, state) + htmlDelta(trimmed));
return output;
});
return formatted.join("\n") + (hadFinalNewline ? "\n" : "");
}
module.exports = { formatWrn };
+14
View File
@@ -58,5 +58,19 @@ try {
bad("compiler bundle loads", e.message);
}
// 4. Formatter normalizes nested WRN blocks and is idempotent.
try {
const { formatWrn } = require(join(root, "src/formatter.js"));
const source = `page Home {\nview {\n<main>\n<h1>Hello</h1>\n</main>\n}\n}\n`;
const expected = `page Home {\n view {\n <main>\n <h1>Hello</h1>\n </main>\n }\n}\n`;
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2 });
formatted === expected ? ok("formatter indents WRN blocks") : bad("formatter indents WRN blocks");
formatWrn(formatted, { insertSpaces: true, tabSize: 2 }) === formatted
? ok("formatter is idempotent")
: bad("formatter is idempotent");
} catch (e) {
bad("formatter loads", e.message);
}
console.log(failures === 0 ? "\nAll extension checks passed." : `\n${failures} check(s) failed.`);
process.exit(failures === 0 ? 0 : 1);
Binary file not shown.