diff --git a/.publish/ai/package.json b/.publish/ai/package.json
index 78887f5b..3d29a755 100644
--- a/.publish/ai/package.json
+++ b/.publish/ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
diff --git a/.publish/authz/package.json b/.publish/authz/package.json
index 895695b8..21bdce31 100644
--- a/.publish/authz/package.json
+++ b/.publish/authz/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/cli/package.json b/.publish/cli/package.json
index 140a16ac..88a5b48b 100644
--- a/.publish/cli/package.json
+++ b/.publish/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
- "@wrnexus/core": "^0.2.18",
- "@wrnexus/router": "^0.2.18",
- "@wrnexus/csr": "^0.2.18",
- "@wrnexus/compiler": "^0.2.18",
- "@wrnexus/styles": "^0.2.18",
- "@wrnexus/dev-server": "^0.2.18",
- "@wrnexus/ui": "^0.2.18",
- "@wrnexus/validation": "^0.2.18",
- "@wrnexus/i18n": "^0.2.18",
- "@wrnexus/db": "^0.2.18"
+ "@wrnexus/core": "^0.2.19",
+ "@wrnexus/router": "^0.2.19",
+ "@wrnexus/csr": "^0.2.19",
+ "@wrnexus/compiler": "^0.2.19",
+ "@wrnexus/styles": "^0.2.19",
+ "@wrnexus/dev-server": "^0.2.19",
+ "@wrnexus/ui": "^0.2.19",
+ "@wrnexus/validation": "^0.2.19",
+ "@wrnexus/i18n": "^0.2.19",
+ "@wrnexus/db": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json
index f22ab6f5..b259433c 100644
--- a/.publish/compiler/package.json
+++ b/.publish/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/core/package.json b/.publish/core/package.json
index c7a20a3a..85e6645a 100644
--- a/.publish/core/package.json
+++ b/.publish/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/csr/package.json b/.publish/csr/package.json
index 2b5ef12d..3e7ec4a0 100644
--- a/.publish/csr/package.json
+++ b/.publish/csr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/db/package.json b/.publish/db/package.json
index 505b2809..39d6693d 100644
--- a/.publish/db/package.json
+++ b/.publish/db/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/dev-server/README.md b/.publish/dev-server/README.md
index 105e193f..6e45c204 100644
--- a/.publish/dev-server/README.md
+++ b/.publish/dev-server/README.md
@@ -142,7 +142,7 @@ interface ProdOptions {
### `startGateway(opts)` — multi-app gateway
-Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
+Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, the gateway supervises every child: editing a page, component, API, or middleware restarts only that app and the HMR client reconnects to display the latest page. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
```ts
interface GatewayOptions {
diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json
index 198608d8..706d7fc1 100644
--- a/.publish/dev-server/package.json
+++ b/.publish/dev-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,18 +25,18 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.2.18",
- "@wrnexus/router": "^0.2.18",
- "@wrnexus/ssr": "^0.2.18",
- "@wrnexus/csr": "^0.2.18",
- "@wrnexus/compiler": "^0.2.18",
- "@wrnexus/styles": "^0.2.18",
- "@wrnexus/ui": "^0.2.18",
- "@wrnexus/validation": "^0.2.18",
- "@wrnexus/i18n": "^0.2.18",
- "@wrnexus/db": "^0.2.18",
- "@wrnexus/pubsub": "^0.2.18",
- "@wrnexus/uploader": "^0.2.18"
+ "@wrnexus/core": "^0.2.19",
+ "@wrnexus/router": "^0.2.19",
+ "@wrnexus/ssr": "^0.2.19",
+ "@wrnexus/csr": "^0.2.19",
+ "@wrnexus/compiler": "^0.2.19",
+ "@wrnexus/styles": "^0.2.19",
+ "@wrnexus/ui": "^0.2.19",
+ "@wrnexus/validation": "^0.2.19",
+ "@wrnexus/i18n": "^0.2.19",
+ "@wrnexus/db": "^0.2.19",
+ "@wrnexus/pubsub": "^0.2.19",
+ "@wrnexus/uploader": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json
index dbbb5487..011c8d61 100644
--- a/.publish/encryption/package.json
+++ b/.publish/encryption/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json
index 04f8ef0f..5e674bad 100644
--- a/.publish/helpers/package.json
+++ b/.publish/helpers/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json
index b7c77cca..efccad28 100644
--- a/.publish/i18n/package.json
+++ b/.publish/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json
index d25533f4..c639446e 100644
--- a/.publish/jwt/package.json
+++ b/.publish/jwt/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json
index fa454978..226d4109 100644
--- a/.publish/mobile/package.json
+++ b/.publish/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/native": "^0.2.18"
+ "@wrnexus/native": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/native/package.json b/.publish/native/package.json
index b203cf6a..a1c40fac 100644
--- a/.publish/native/package.json
+++ b/.publish/native/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json
index e93ef3a0..b797e882 100644
--- a/.publish/oauth/package.json
+++ b/.publish/oauth/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json
index fe75fe97..c095f509 100644
--- a/.publish/pubsub/package.json
+++ b/.publish/pubsub/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/queue/package.json b/.publish/queue/package.json
index 732142e5..2f30fc68 100644
--- a/.publish/queue/package.json
+++ b/.publish/queue/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json
index e9fce405..874523db 100644
--- a/.publish/reactive/package.json
+++ b/.publish/reactive/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/router/package.json b/.publish/router/package.json
index 886d3de8..9871430e 100644
--- a/.publish/router/package.json
+++ b/.publish/router/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
- "@wrnexus/compiler": "^0.2.18",
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/compiler": "^0.2.19",
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json
index 99a48ac3..36357c00 100644
--- a/.publish/ssr/package.json
+++ b/.publish/ssr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/styles/package.json b/.publish/styles/package.json
index 61fac9aa..f1b25262 100644
--- a/.publish/styles/package.json
+++ b/.publish/styles/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/uploader": "^0.2.18"
+ "@wrnexus/uploader": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/test/package.json b/.publish/test/package.json
index da25dc1d..9540d59b 100644
--- a/.publish/test/package.json
+++ b/.publish/test/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json
index 2901320c..4918e6d0 100644
--- a/.publish/tracking/package.json
+++ b/.publish/tracking/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/ui/package.json b/.publish/ui/package.json
index 2a6abc59..5326e5df 100644
--- a/.publish/ui/package.json
+++ b/.publish/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist",
diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json
index 62966d87..1a2f44e0 100644
--- a/.publish/uploader/package.json
+++ b/.publish/uploader/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.2.18"
+ "@wrnexus/core": "^0.2.19"
},
"files": [
"dist"
diff --git a/.publish/validation/package.json b/.publish/validation/package.json
index cca2e0a0..ff67ddd5 100644
--- a/.publish/validation/package.json
+++ b/.publish/validation/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md
index 0a734c54..ac623fbf 100644
--- a/editors/vscode/CHANGELOG.md
+++ b/editors/vscode/CHANGELOG.md
@@ -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.
diff --git a/editors/vscode/README.md b/editors/vscode/README.md
index 5dfafe80..031f6357 100644
--- a/editors/vscode/README.md
+++ b/editors/vscode/README.md
@@ -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
diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json
index 9ce29756..463fb864 100644
--- a/editors/vscode/package-lock.json
+++ b/editors/vscode/package-lock.json
@@ -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"
diff --git a/editors/vscode/package.json b/editors/vscode/package.json
index f7c6905a..5c10b7fd 100644
--- a/editors/vscode/package.json
+++ b/editors/vscode/package.json
@@ -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."
}
}
},
diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs
index f32379e2..dd1e430b 100644
--- a/editors/vscode/src/compiler.cjs
+++ b/editors/vscode/src/compiler.cjs
@@ -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 === "<" ? "<" : ">");
+}
+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}`;
+ }).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
diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js
index 0a3331e7..a85b0af3 100644
--- a/editors/vscode/src/extension.js
+++ b/editors/vscode/src/extension.js
@@ -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 },
diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js
new file mode 100644
index 00000000..a8eba438
--- /dev/null
+++ b/editors/vscode/src/formatter.js
@@ -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 };
diff --git a/editors/vscode/test/validate.mjs b/editors/vscode/test/validate.mjs
index 1166c41d..01e26e41 100644
--- a/editors/vscode/test/validate.mjs
+++ b/editors/vscode/test/validate.mjs
@@ -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\nHello
\n\n}\n}\n`;
+ const expected = `page Home {\n view {\n \n Hello
\n \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);
diff --git a/editors/vscode/wrnexus-0.2.0.vsix b/editors/vscode/wrnexus-0.2.0.vsix
new file mode 100644
index 00000000..e34cf5d5
Binary files /dev/null and b/editors/vscode/wrnexus-0.2.0.vsix differ
diff --git a/packages/ai/package.json b/packages/ai/package.json
index 8e4fed92..c3c9fbe7 100644
--- a/packages/ai/package.json
+++ b/packages/ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
diff --git a/packages/authz/package.json b/packages/authz/package.json
index 2e57a6cc..e25ee529 100644
--- a/packages/authz/package.json
+++ b/packages/authz/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 709abc86..3c98b707 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/cli/src/create.ts b/packages/cli/src/create.ts
index ba81269e..258aa29a 100644
--- a/packages/cli/src/create.ts
+++ b/packages/cli/src/create.ts
@@ -51,7 +51,9 @@ mobile/.expo/
# Editors and operating systems
.idea/
-.vscode/
+.vscode/*
+!.vscode/settings.json
+!.vscode/extensions.json
*.swp
*.swo
.DS_Store
@@ -132,7 +134,14 @@ const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
export default tseslint.config(
{
- ignores: ["node_modules/**", "dist/**", ".wrnexus/**", "**/.wrnexus/**", "mobile/android/**", "mobile/ios/**"],
+ ignores: [
+ "node_modules/**",
+ "dist/**",
+ ".wrnexus/**",
+ "**/.wrnexus/**",
+ "mobile/android/**",
+ "mobile/ios/**",
+ ],
},
{
languageOptions: {
@@ -187,6 +196,23 @@ indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
+`,
+ ".vscode/settings.json": `{
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ },
+ "prettier.requireConfig": true,
+ "[wrn]": {
+ "editor.defaultFormatter": "wrnexus.wrnexus",
+ "editor.formatOnSave": true
+ }
+}
+`,
+ ".vscode/extensions.json": `{
+ "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
+}
`,
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
@@ -274,7 +300,13 @@ Allow: /
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
body {
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
}
`,
"app/pages/index.wrn": `// Home page (route: /). SSR-first: the view is server-rendered, then components
diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts
index a5662f7e..0ee51c17 100644
--- a/packages/cli/src/update.ts
+++ b/packages/cli/src/update.ts
@@ -172,6 +172,87 @@ const MIGRATIONS: Migration[] = [
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
},
},
+ {
+ version: "0.2.19",
+ id: "vscode-formatting",
+ description: "Add safe VS Code format-on-save defaults and extension recommendations",
+ apply(ctx) {
+ const vscode = join(ctx.appRoot, ".vscode");
+ const settings = join(vscode, "settings.json");
+ const extensions = join(vscode, "extensions.json");
+ const prettierIgnore = join(ctx.appRoot, ".prettierignore");
+ const gitignore = join(ctx.appRoot, ".gitignore");
+
+ const files: Array<[string, string, string]> = [
+ [
+ settings,
+ JSON.stringify(
+ {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
+ "prettier.requireConfig": true,
+ "[wrn]": {
+ "editor.defaultFormatter": "wrnexus.wrnexus",
+ "editor.formatOnSave": true,
+ },
+ },
+ null,
+ 2,
+ ) + "\n",
+ ".vscode/settings.json",
+ ],
+ [
+ extensions,
+ JSON.stringify(
+ {
+ recommendations: [
+ "wrnexus.wrnexus",
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint",
+ ],
+ },
+ null,
+ 2,
+ ) + "\n",
+ ".vscode/extensions.json",
+ ],
+ ];
+
+ for (const [file, contents, label] of files) {
+ if (existsSync(file)) continue;
+ ctx.log(`+ ${label}`);
+ if (!ctx.dryRun) {
+ mkdirSync(vscode, { recursive: true });
+ writeFileSync(file, contents, "utf8");
+ }
+ }
+
+ const prettier = existsSync(prettierIgnore) ? readFileSync(prettierIgnore, "utf8") : "";
+ if (!new Set(prettier.split(/\r?\n/).map((line) => line.trim())).has("CLAUDE.md")) {
+ ctx.log("+ .prettierignore: CLAUDE.md");
+ if (!ctx.dryRun) {
+ writeFileSync(prettierIgnore, `${prettier.replace(/\s*$/, "")}\nCLAUDE.md\n`, "utf8");
+ }
+ }
+
+ if (existsSync(gitignore)) {
+ const current = readFileSync(gitignore, "utf8");
+ const lines = current.split(/\r?\n/);
+ const vscodeIgnore = lines.findIndex((line) => line.trim() === ".vscode/");
+ const needed = [".vscode/*", "!.vscode/settings.json", "!.vscode/extensions.json"];
+ if (vscodeIgnore >= 0 || needed.some((entry) => !lines.includes(entry))) {
+ ctx.log("~ .gitignore: track shared VS Code configuration");
+ if (!ctx.dryRun) {
+ const filtered = lines.filter((line) => line.trim() !== ".vscode/");
+ const have = new Set(filtered.map((line) => line.trim()));
+ for (const entry of needed) if (!have.has(entry)) filtered.push(entry);
+ writeFileSync(gitignore, filtered.join("\n").replace(/\n*$/, "\n"), "utf8");
+ }
+ }
+ }
+ },
+ },
];
/** Release tooling uses this to require an explicit migration entry per version. */
@@ -246,6 +327,9 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
"wrnexus.config.mjs",
"tsconfig.json",
".gitignore",
+ ".prettierignore",
+ ".vscode/settings.json",
+ ".vscode/extensions.json",
"CLAUDE.md",
"public/llms.txt",
]) {
diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts
index 4f3fffe7..bead6d81 100644
--- a/packages/cli/test/create.test.ts
+++ b/packages/cli/test/create.test.ts
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { format } from "prettier";
import { scaffoldApp } from "../src/create.ts";
import { currentCliVersion } from "../src/update-notifier.ts";
@@ -69,6 +70,15 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
expect(existsSync(join(root, "llms.txt"))).toBe(false);
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md");
+ expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toContain(
+ '"editor.formatOnSave": true',
+ );
+ expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toContain(
+ '"editor.defaultFormatter": "wrnexus.wrnexus"',
+ );
+ expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toContain(
+ "wrnexus.wrnexus",
+ );
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toContain(
'href="/api/hello"',
);
@@ -77,3 +87,34 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
rmSync(parent, { recursive: true, force: true });
}
});
+
+test("scaffoldApp emits Prettier-compliant editor and framework files", async () => {
+ const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
+ const root = join(parent, "formatted-app");
+
+ try {
+ scaffoldApp(root, "formatted-app");
+ for (const relative of [
+ "app/styles/global.css",
+ "eslint.config.js",
+ ".vscode/settings.json",
+ ".vscode/extensions.json",
+ ]) {
+ const file = join(root, relative);
+ const source = readFileSync(file, "utf8");
+ const formatted = await format(source, {
+ filepath: file,
+ printWidth: 100,
+ tabWidth: 2,
+ useTabs: false,
+ semi: true,
+ singleQuote: false,
+ trailingComma: "all",
+ endOfLine: "lf",
+ });
+ expect(formatted).toBe(source);
+ }
+ } finally {
+ rmSync(parent, { recursive: true, force: true });
+ }
+});
diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts
index e9e9bc3b..2274db00 100644
--- a/packages/cli/test/update.test.ts
+++ b/packages/cli/test/update.test.ts
@@ -61,3 +61,35 @@ test("0.2.18 migration adds helpers to existing apps and is idempotent", () => {
rmSync(root, { recursive: true, force: true });
}
});
+
+test("0.2.19 migration adds VS Code formatting defaults without overwriting settings", () => {
+ const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
+ mkdirSync(join(root, "app", "pages"), { recursive: true });
+ mkdirSync(join(root, ".vscode"), { recursive: true });
+ writeFileSync(
+ join(root, "package.json"),
+ JSON.stringify({ name: "editor-app", wrnexus: { version: "0.2.18" } }),
+ );
+ writeFileSync(join(root, ".gitignore"), ".vscode/\n");
+ writeFileSync(join(root, ".prettierignore"), "node_modules/\n");
+ writeFileSync(join(root, ".vscode", "settings.json"), '{"editor.tabSize":4}\n');
+
+ try {
+ updateApp(root, "0.2.19", false);
+ updateApp(root, "0.2.19", false);
+
+ expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toBe(
+ '{"editor.tabSize":4}\n',
+ );
+ expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toContain(
+ "wrnexus.wrnexus",
+ );
+ expect(readFileSync(join(root, ".prettierignore"), "utf8")).toBe("node_modules/\nCLAUDE.md\n");
+ const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
+ expect(gitignore).not.toContain(".vscode/\n");
+ expect(gitignore).toContain("!.vscode/settings.json");
+ expect(gitignore).toContain("!.vscode/extensions.json");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index 1eb26f61..f1fff46f 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/core/package.json b/packages/core/package.json
index 79ca1ee0..b0a0758d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/csr/package.json b/packages/csr/package.json
index 444d96d3..3e17a63d 100644
--- a/packages/csr/package.json
+++ b/packages/csr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/db/package.json b/packages/db/package.json
index 541fc3ee..747d1341 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/dev-server/README.md b/packages/dev-server/README.md
index 105e193f..6e45c204 100644
--- a/packages/dev-server/README.md
+++ b/packages/dev-server/README.md
@@ -142,7 +142,7 @@ interface ProdOptions {
### `startGateway(opts)` — multi-app gateway
-Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
+Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, the gateway supervises every child: editing a page, component, API, or middleware restarts only that app and the HMR client reconnects to display the latest page. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
```ts
interface GatewayOptions {
diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json
index 42017e87..0ed95623 100644
--- a/packages/dev-server/package.json
+++ b/packages/dev-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts
index a40f4bf1..3bd20258 100644
--- a/packages/dev-server/src/gateway.ts
+++ b/packages/dev-server/src/gateway.ts
@@ -12,6 +12,7 @@
import { spawn, type ChildProcess } from "node:child_process";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
+import { RESTART_EXIT_CODE } from "./restart.ts";
/** Per-app access control, enforced at the gateway before proxying. */
export interface GatewayAuth {
@@ -71,7 +72,7 @@ export interface RunningGateway {
interface Target extends GatewayApp {
port: number;
origin: string;
- child: ChildProcess;
+ child?: ChildProcess;
}
interface WsBridge {
@@ -81,6 +82,18 @@ interface WsBridge {
queue: (string | ArrayBufferLike | ArrayBufferView)[];
}
+/** Decide whether a gateway child should be relaunched after it exits. */
+export function gatewayRestartDelay(
+ mode: "development" | "production",
+ code: number | null,
+ signal: NodeJS.Signals | null,
+): number | null {
+ if (mode !== "development" || signal) return null;
+ if (code === RESTART_EXIT_CODE) return 0;
+ if (code && code !== 0) return 1200;
+ return null;
+}
+
/** Fixed-window rate limiter keyed by client IP. */
function makeRateLimiter(max: number, windowMs: number) {
const hits = new Map();
@@ -196,9 +209,9 @@ function applyEdgeHeaders(res: Response): Response {
async function waitReady(target: Target, timeoutMs = 15000): Promise {
const deadline = Date.now() + timeoutMs;
for (;;) {
- if (target.child.exitCode !== null) {
+ if (!target.child || target.child.exitCode !== null) {
throw new Error(
- `app '${target.name}' exited with code ${target.child.exitCode} during startup`,
+ `app '${target.name}' exited with code ${target.child?.exitCode ?? "unknown"} during startup`,
);
}
try {
@@ -245,28 +258,52 @@ export async function startGateway(opts: GatewayOptions): Promise {
const appPort = app.port ?? port + 1 + i;
const dir = resolve(app.dir);
- const child =
- mode === "production"
- ? spawn(process.execPath, [join(dir, "dist", "server.js")], {
- stdio: "inherit",
- env: { ...process.env, PORT: String(appPort) },
- })
- : spawn(
- process.execPath,
- [serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
- {
+ const target: Target = {
+ ...app,
+ port: appPort,
+ origin: `http://127.0.0.1:${appPort}`,
+ };
+
+ const launch = (): void => {
+ if (stopping) return;
+ const child =
+ mode === "production"
+ ? spawn(process.execPath, [join(dir, "dist", "server.js")], {
stdio: "inherit",
- },
- );
- return { ...app, port: appPort, origin: `http://127.0.0.1:${appPort}`, child };
+ env: { ...process.env, PORT: String(appPort) },
+ })
+ : spawn(
+ process.execPath,
+ [serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
+ { stdio: "inherit" },
+ );
+ target.child = child;
+
+ child.once("exit", (code, signal) => {
+ if (stopping || target.child !== child) return;
+ const delay = gatewayRestartDelay(mode, code, signal);
+ if (delay === null) return;
+ if (delay > 0) {
+ console.error(`[wrnexus] app '${target.name}' exited (code ${code}); retrying in 1.2s…`);
+ setTimeout(launch, delay);
+ } else {
+ launch();
+ }
+ });
+ };
+
+ launch();
+ return target;
});
const stopChildren = () => {
+ stopping = true;
for (const target of targets) {
- if (target.child.exitCode === null) target.child.kill();
+ if (target.child?.exitCode === null) target.child.kill();
}
};
diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts
index c1ededc6..00855b2f 100644
--- a/packages/dev-server/src/index.ts
+++ b/packages/dev-server/src/index.ts
@@ -28,9 +28,9 @@ import { createHandlers, type WsData } from "./runtime.ts";
import { createDevAssetServer } from "./assets.ts";
import { HmrHub } from "./hmr.ts";
import { startWatcher } from "./watch.ts";
+import { RESTART_EXIT_CODE } from "./restart.ts";
-/** Exit code the child uses to ask the dev supervisor for a fresh process. */
-export const RESTART_EXIT_CODE = 97;
+export { RESTART_EXIT_CODE } from "./restart.ts";
export interface ServeOptions {
appDir: string;
diff --git a/packages/dev-server/src/restart.ts b/packages/dev-server/src/restart.ts
new file mode 100644
index 00000000..5ac73cdc
--- /dev/null
+++ b/packages/dev-server/src/restart.ts
@@ -0,0 +1,2 @@
+/** Exit code a dev-server child uses to request a clean supervisor restart. */
+export const RESTART_EXIT_CODE = 97;
diff --git a/packages/dev-server/test/gateway.test.ts b/packages/dev-server/test/gateway.test.ts
index 81983401..d8b09f93 100644
--- a/packages/dev-server/test/gateway.test.ts
+++ b/packages/dev-server/test/gateway.test.ts
@@ -4,6 +4,7 @@ import {
forwardAuthFailure,
forwardAuthHeaders,
gatewayProxyHeaders,
+ gatewayRestartDelay,
} from "../src/gateway.ts";
test("gateway uses platform-safe hostname defaults", () => {
@@ -56,3 +57,11 @@ test("forward auth describes the original gateway request", () => {
expect(headers.get("cookie")).toBe("session=abc");
expect(headers.get("authorization")).toBe("Bearer token");
});
+
+test("gateway respawns development apps after an HMR restart exit", () => {
+ expect(gatewayRestartDelay("development", 97, null)).toBe(0);
+ expect(gatewayRestartDelay("development", 1, null)).toBe(1200);
+ expect(gatewayRestartDelay("development", 0, null)).toBeNull();
+ expect(gatewayRestartDelay("development", 97, "SIGTERM")).toBeNull();
+ expect(gatewayRestartDelay("production", 97, null)).toBeNull();
+});
diff --git a/packages/encryption/package.json b/packages/encryption/package.json
index 5189e521..061a518d 100644
--- a/packages/encryption/package.json
+++ b/packages/encryption/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/helpers/package.json b/packages/helpers/package.json
index f549cc34..e0695618 100644
--- a/packages/helpers/package.json
+++ b/packages/helpers/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index b56bda94..d4334a2f 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/jwt/package.json b/packages/jwt/package.json
index 5996535e..85802952 100644
--- a/packages/jwt/package.json
+++ b/packages/jwt/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/mobile/package.json b/packages/mobile/package.json
index 9d134e45..422a7ba3 100644
--- a/packages/mobile/package.json
+++ b/packages/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/native/package.json b/packages/native/package.json
index 01188db0..e26e722e 100644
--- a/packages/native/package.json
+++ b/packages/native/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/oauth/package.json b/packages/oauth/package.json
index c10437c5..f6be6bbc 100644
--- a/packages/oauth/package.json
+++ b/packages/oauth/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json
index 341def4e..d5f13b71 100644
--- a/packages/pubsub/package.json
+++ b/packages/pubsub/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/queue/package.json b/packages/queue/package.json
index 5f793572..3ce85456 100644
--- a/packages/queue/package.json
+++ b/packages/queue/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/reactive/package.json b/packages/reactive/package.json
index f9cfa726..fe5bd702 100644
--- a/packages/reactive/package.json
+++ b/packages/reactive/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/router/package.json b/packages/router/package.json
index c2c4a0f6..7636c0bc 100644
--- a/packages/router/package.json
+++ b/packages/router/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/ssr/package.json b/packages/ssr/package.json
index a4222f1e..f51678b3 100644
--- a/packages/ssr/package.json
+++ b/packages/ssr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/styles/package.json b/packages/styles/package.json
index e93c06bd..28a655e2 100644
--- a/packages/styles/package.json
+++ b/packages/styles/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
- "version": "0.2.18",
+ "version": "0.2.19",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/test/package.json b/packages/test/package.json
index a4d9f814..30ac988c 100644
--- a/packages/test/package.json
+++ b/packages/test/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/tracking/package.json b/packages/tracking/package.json
index 8e02a8b7..675299ac 100644
--- a/packages/tracking/package.json
+++ b/packages/tracking/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 9458e237..ad859b63 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/uploader/package.json b/packages/uploader/package.json
index 6a6e4d2f..6952029c 100644
--- a/packages/uploader/package.json
+++ b/packages/uploader/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/validation/package.json b/packages/validation/package.json
index ba991f34..e4dca774 100644
--- a/packages/validation/package.json
+++ b/packages/validation/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
- "version": "0.2.18",
+ "version": "0.2.19",
"private": true,
"type": "module",
"main": "src/index.ts",