diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 452b8325..03cb2a28 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index d66a5a9c..2fe4636d 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index b80a7fed..dac6eb0e 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -28,16 +28,18 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.2.79", - "@wrnexus/router": "^0.2.79", - "@wrnexus/csr": "^0.2.79", - "@wrnexus/compiler": "^0.2.79", - "@wrnexus/styles": "^0.2.79", - "@wrnexus/dev-server": "^0.2.79", - "@wrnexus/ui": "^0.2.79", - "@wrnexus/validation": "^0.2.79", - "@wrnexus/i18n": "^0.2.79", - "@wrnexus/db": "^0.2.79" + "@wrnexus/core": "^0.3.0", + "@wrnexus/router": "^0.3.0", + "@wrnexus/csr": "^0.3.0", + "@wrnexus/compiler": "^0.3.0", + "@wrnexus/styles": "^0.3.0", + "@wrnexus/dev-server": "^0.3.0", + "@wrnexus/ui": "^0.3.0", + "@wrnexus/validation": "^0.3.0", + "@wrnexus/i18n": "^0.3.0", + "@wrnexus/db": "^0.3.0", + "@wrnexus/plugin": "^0.3.0", + "@wrnexus/syntax": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index b00f5d5a..a3d2daf1 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -20,6 +20,9 @@ "import": "./dist/index.js" } }, + "dependencies": { + "@wrnexus/syntax": "^0.3.0" + }, "files": [ "dist" ] diff --git a/.publish/core/package.json b/.publish/core/package.json index 542812ec..180a135f 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/csr/README.md b/.publish/csr/README.md index 7383bed4..cb42e418 100644 --- a/.publish/csr/README.md +++ b/.publish/csr/README.md @@ -49,15 +49,16 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works. -| Directive | Purpose | -| -------------------------------------------------- | ----------------------------------------------------------------------- | -| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | -| `data-on-="count++"` | Run a statement in scope on a DOM event | -| `data-text="expr"` | Bind an element's `textContent` to an expression | -| `data-show="expr"` | Toggle visibility (`display`) on truthiness | -| `data-for="item in list"` (opt. `item, i in list`) | Per-item list rendering template | -| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | -| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | +| Directive | Purpose | +| -------------------------------------------------------- | ----------------------------------------------------------------------- | +| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | +| `data-on-="count++"` | Run a statement in scope on a DOM event | +| `data-text="expr"` | Bind an element's `textContent` to an expression | +| `data-show="expr"` | Toggle visibility (`display`) on truthiness | +| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | +| `data-key="item.id"` | Alternative key declaration for `data-for` templates | +| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | +| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it. diff --git a/.publish/csr/package.json b/.publish/csr/package.json index a047a28b..366de983 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.79" + "@wrnexus/core": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index 3445085b..afb4d416 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/db — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index b9be24d3..240a0afe 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -25,19 +25,20 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.79", - "@wrnexus/dev-toolbar": "^0.2.79", - "@wrnexus/router": "^0.2.79", - "@wrnexus/ssr": "^0.2.79", - "@wrnexus/csr": "^0.2.79", - "@wrnexus/compiler": "^0.2.79", - "@wrnexus/styles": "^0.2.79", - "@wrnexus/ui": "^0.2.79", - "@wrnexus/validation": "^0.2.79", - "@wrnexus/i18n": "^0.2.79", - "@wrnexus/db": "^0.2.79", - "@wrnexus/pubsub": "^0.2.79", - "@wrnexus/uploader": "^0.2.79" + "@wrnexus/core": "^0.3.0", + "@wrnexus/dev-toolbar": "^0.3.0", + "@wrnexus/router": "^0.3.0", + "@wrnexus/ssr": "^0.3.0", + "@wrnexus/csr": "^0.3.0", + "@wrnexus/compiler": "^0.3.0", + "@wrnexus/styles": "^0.3.0", + "@wrnexus/ui": "^0.3.0", + "@wrnexus/validation": "^0.3.0", + "@wrnexus/i18n": "^0.3.0", + "@wrnexus/db": "^0.3.0", + "@wrnexus/pubsub": "^0.3.0", + "@wrnexus/uploader": "^0.3.0", + "@wrnexus/plugin": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/dev-toolbar/package.json b/.publish/dev-toolbar/package.json index 35edc0ca..b8e45e86 100644 --- a/.publish/dev-toolbar/package.json +++ b/.publish/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 3476a831..260b91d9 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 6e613285..fcc94b61 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.79" + "@wrnexus/core": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 8e66f01a..ac1f0acf 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.79" + "@wrnexus/core": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index 59eaf94a..4c46b665 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/jwt — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index 4eaa93dd..8794f3b9 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.79" + "@wrnexus/native": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index 5cc21b28..d8dea1ec 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 9f5e423e..a9551fea 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/plugin/README.md b/.publish/plugin/README.md new file mode 100644 index 00000000..910d50a5 --- /dev/null +++ b/.publish/plugin/README.md @@ -0,0 +1,7 @@ +# @wrnexus/plugin + +Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms, +diagnostics, development servers, production builds, and DevToolbar extensions. + +Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters. +Duplicate names and dependency cycles are rejected. diff --git a/.publish/plugin/package.json b/.publish/plugin/package.json new file mode 100644 index 00000000..9de3770f --- /dev/null +++ b/.publish/plugin/package.json @@ -0,0 +1,29 @@ +{ + "name": "@wrnexus/plugin", + "version": "0.3.0", + "type": "module", + "description": "@wrnexus/plugin — part of the WrNexus framework.", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "engines": { + "bun": ">=1.3.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "restricted" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "dependencies": { + "@wrnexus/syntax": "^0.3.0" + }, + "files": [ + "dist" + ] +} diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index 7e91439c..75602e10 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 0968756f..3827c3e3 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index afa62145..689ea8e5 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/router/package.json b/.publish/router/package.json index 863348fe..c043ee8b 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.79", - "@wrnexus/core": "^0.2.79" + "@wrnexus/compiler": "^0.3.0", + "@wrnexus/core": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 78e8c17f..bbbe6c1b 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.79" + "@wrnexus/core": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index d612274c..c8b64dc8 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,9 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.79" + "@wrnexus/uploader": "^0.3.0", + "@wrnexus/core": "^0.3.0", + "@wrnexus/plugin": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/syntax/README.md b/.publish/syntax/README.md new file mode 100644 index 00000000..7039cc05 --- /dev/null +++ b/.publish/syntax/README.md @@ -0,0 +1,7 @@ +# @wrnexus/syntax + +Canonical WRN lexer, parser, AST, language metadata, source positions, and stable +diagnostics. Framework tooling should import this package instead of implementing a +separate `.wrn` parser. + +See `docs/WRN-LANGUAGE-SPEC-1.0.md` in the WRNexusJS repository. diff --git a/.publish/syntax/package.json b/.publish/syntax/package.json new file mode 100644 index 00000000..f4ddac45 --- /dev/null +++ b/.publish/syntax/package.json @@ -0,0 +1,46 @@ +{ + "name": "@wrnexus/syntax", + "version": "0.3.0", + "type": "module", + "description": "@wrnexus/syntax — part of the WrNexus framework.", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "engines": { + "bun": ">=1.3.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "restricted" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./parser": { + "types": "./dist/parser.d.ts", + "import": "./dist/parser.js" + }, + "./tokenizer": { + "types": "./dist/tokenizer.d.ts", + "import": "./dist/tokenizer.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + }, + "./diagnostics": { + "types": "./dist/diagnostics.d.ts", + "import": "./dist/diagnostics.js" + }, + "./spec": { + "types": "./dist/spec.d.ts", + "import": "./dist/spec.js" + } + }, + "files": [ + "dist" + ] +} diff --git a/.publish/test/package.json b/.publish/test/package.json index 79b64e20..b0bdbd1f 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index c12e07bf..5660594f 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/.publish/ui/components/ProductHero.wrn b/.publish/ui/components/ProductHero.wrn index c30428cc..6a2adf22 100644 --- a/.publish/ui/components/ProductHero.wrn +++ b/.publish/ui/components/ProductHero.wrn @@ -9,7 +9,7 @@ component ProductHero { view {
diff --git a/.publish/ui/package.json b/.publish/ui/package.json index b24cd728..de9a0718 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -25,7 +25,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.2.79" + "@wrnexus/core": "^0.3.0" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 03baefdb..4f9ae1bc 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.79" + "@wrnexus/core": "^0.3.0" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 38123b2b..1a1644ee 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/README.md b/README.md index 5b359380..2ac84458 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # WrNexus -An **SSR-first** full-stack web framework MVP with **server-rendered, reactive -components**. Built in TypeScript, **Bun-first**, Node-friendly where possible. +> **WRNexusJS 0.3 architecture:** see [the language specification](docs/WRN-LANGUAGE-SPEC-1.0.md), +> [architecture guide](docs/ARCHITECTURE-0.3.md), [upgrade guide](docs/UPGRADE-0.3.md), and +> [40-point implementation matrix](docs/40-POINT-IMPLEMENTATION-0.3.md). Validate a release with +> the [one-by-one test checklist](docs/TEST-CHECKLIST-0.3.md) and [audit record](docs/AUDIT-0.3.md). +> An **SSR-first** full-stack web framework MVP with **server-rendered, reactive +> components**. Built in TypeScript, **Bun-first**, Node-friendly where possible. It gives you file-based pages and API routes, middleware, realtime WebSocket -routes, and opt-in client hydration — with a small, readable codebase designed -so a custom `.wrn` language/compiler can be layered on later. +routes, opt-in client hydration, and the custom `.wrn` language/compiler through +one shared syntax and runtime model. > 📘 **[The Complete Guide](docs/GUIDE.md)** — one document covering every package, > the whole `.wrn` language, all attributes/config properties, and a step-by-step @@ -28,7 +32,7 @@ so a custom `.wrn` language/compiler can be layered on later. ## Installation -Requires [Bun](https://bun.sh) ≥ 1.1. +Requires [Bun](https://bun.sh) ≥ 1.3.0. ```bash bun install diff --git a/WRNexusJS-0.3.0-test-fixes.patch b/WRNexusJS-0.3.0-test-fixes.patch new file mode 100644 index 00000000..2887e4e2 --- /dev/null +++ b/WRNexusJS-0.3.0-test-fixes.patch @@ -0,0 +1,195 @@ +--- a/packages/compiler/src/codegen.ts ++++ b/packages/compiler/src/codegen.ts +@@ -859,7 +859,11 @@ + out.push(`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`); + } + if (clientLoads.length > 0) { +- out.push(`export const __wrnexusClientLoad = ${JSON.stringify(clientLoads.map((entry) => entry.body))};`); ++ out.push( ++ `export async function __wrnexusClientLoad(ctx: any) { ++${clientLoads.map((entry) => entry.body).join("\n")} ++}`, ++ ); + } + } + +--- a/packages/csr/src/reactive-runtime.ts ++++ b/packages/csr/src/reactive-runtime.ts +@@ -480,18 +480,39 @@ + // signal.subscribe dedupes, so re-subscribing each run is cheap and bounded. + var currentRenderer = null; + var pendingRenderers = new Set(); +- var renderScheduled = false; ++ var batchDepth = 0; ++ var flushingRenderers = false; ++ ++ function flushRenderers() { ++ if (flushingRenderers || batchDepth > 0) return; ++ ++ flushingRenderers = true; ++ ++ try { ++ while (pendingRenderers.size > 0) { ++ var queue = Array.from(pendingRenderers); ++ pendingRenderers.clear(); ++ queue.forEach(function (run) { run(); }); ++ } ++ } finally { ++ flushingRenderers = false; ++ } ++ } + + function scheduleRenderer(renderer) { + pendingRenderers.add(renderer); +- if (renderScheduled) return; +- renderScheduled = true; +- queueMicrotask(function () { +- renderScheduled = false; +- var queue = Array.from(pendingRenderers); +- pendingRenderers.clear(); +- queue.forEach(function (run) { run(); }); +- }); ++ flushRenderers(); ++ } ++ ++ function batchUpdates(callback) { ++ batchDepth++; ++ ++ try { ++ return callback(); ++ } finally { ++ batchDepth--; ++ flushRenderers(); ++ } + } + + function reactive(fn) { +@@ -723,68 +744,70 @@ + source, + locals, + ) { +- var statements = +- splitStatements(source); ++ return batchUpdates(function () { ++ var statements = ++ splitStatements(source); + +- for ( +- var statementIndex = 0; +- statementIndex < +- statements.length; +- statementIndex++ +- ) { +- var result = runStatement( +- statements[statementIndex], +- function (expression) { +- return evalExpr( +- expression, +- locals, +- ); +- }, +- function (name) { +- if ( +- locals && +- Object.prototype +- .hasOwnProperty.call( +- locals, +- name, +- ) +- ) { +- return locals[name]; +- } +- +- return peekScope(name); +- }, +- function (name, value) { +- if ( +- locals && +- Object.prototype +- .hasOwnProperty.call( +- locals, +- name, +- ) +- ) { +- locals[name] = value; +- } else { +- writeScope(name, value); +- } +- }, +- function (body) { +- return runStmt( +- body, +- locals, +- ); +- }, +- ); ++ for ( ++ var statementIndex = 0; ++ statementIndex < ++ statements.length; ++ statementIndex++ ++ ) { ++ var result = runStatement( ++ statements[statementIndex], ++ function (expression) { ++ return evalExpr( ++ expression, ++ locals, ++ ); ++ }, ++ function (name) { ++ if ( ++ locals && ++ Object.prototype ++ .hasOwnProperty.call( ++ locals, ++ name, ++ ) ++ ) { ++ return locals[name]; ++ } ++ ++ return peekScope(name); ++ }, ++ function (name, value) { ++ if ( ++ locals && ++ Object.prototype ++ .hasOwnProperty.call( ++ locals, ++ name, ++ ) ++ ) { ++ locals[name] = value; ++ } else { ++ writeScope(name, value); ++ } ++ }, ++ function (body) { ++ return runStmt( ++ body, ++ locals, ++ ); ++ }, ++ ); + +- if (result.returned) { +- return result; ++ if (result.returned) { ++ return result; ++ } + } +- } + +- return { +- returned: false, +- value: undefined, +- }; ++ return { ++ returned: false, ++ value: undefined, ++ }; ++ }); + } + + function installBehaviorFunctions(source) { diff --git a/bun.lock b/bun.lock index 07d022ed..5a949188 100644 --- a/bun.lock +++ b/bun.lock @@ -2,207 +2,226 @@ "lockfileVersion": 1, "configVersion": 1, "workspaces": { - "": { - "name": "myframework", - "devDependencies": { - "@eslint/js": "latest", - "@types/bun": "latest", - "eslint": "latest", - "happy-dom": "^20.10.6", - "prettier": "latest", - "tsup": "^8.5.1", - "typescript": "^5.5.0", - "typescript-eslint": "latest", + "": { + "name": "wrnexus", + "devDependencies": { + "@eslint/js": "latest", + "@types/bun": "latest", + "eslint": "latest", + "happy-dom": "^20.10.6", + "prettier": "latest", + "tsup": "^8.5.1", + "typescript": "^5.5.0", + "typescript-eslint": "latest" + } }, - }, - "examples/basic-app": { - "name": "basic-app", - "version": "0.1.0", - "dependencies": { - "@wrnexus/core": "workspace:*", - "@wrnexus/db": "workspace:*", - "@wrnexus/validation": "workspace:*", + "packages/ai": { + "name": "@wrnexus/ai", + "version": "0.3.0" }, - "devDependencies": { - "@eslint/js": "latest", - "@tailwindcss/cli": "^4.0.0", - "@wrnexus/test": "workspace:*", - "eslint": "latest", - "prettier": "^3.9.4", - "tailwindcss": "^4.0.0", - "typescript-eslint": "latest", + "packages/authz": { + "name": "@wrnexus/authz", + "version": "0.3.0" }, - }, - "packages/ai": { - "name": "@wrnexus/ai", - "version": "0.2.74", - }, - "packages/authz": { - "name": "@wrnexus/authz", - "version": "0.2.74", - }, - "packages/cli": { - "name": "@wrnexus/cli", - "version": "0.2.74", - "bin": { - "wrnexus": "src/index.ts", + "packages/cli": { + "name": "@wrnexus/cli", + "version": "0.3.0", + "bin": { + "wrnexus": "src/index.ts" + }, + "dependencies": { + "@wrnexus/core": "workspace:*", + "@wrnexus/router": "workspace:*", + "@wrnexus/csr": "workspace:*", + "@wrnexus/compiler": "workspace:*", + "@wrnexus/styles": "workspace:*", + "@wrnexus/dev-server": "workspace:*", + "@wrnexus/ui": "workspace:*", + "@wrnexus/validation": "workspace:*", + "@wrnexus/i18n": "workspace:*", + "@wrnexus/db": "workspace:*", + "@wrnexus/plugin": "workspace:*", + "@wrnexus/syntax": "workspace:*" + } }, - "dependencies": { - "@wrnexus/compiler": "workspace:*", - "@wrnexus/core": "workspace:*", - "@wrnexus/csr": "workspace:*", - "@wrnexus/db": "workspace:*", - "@wrnexus/dev-server": "workspace:*", - "@wrnexus/i18n": "workspace:*", - "@wrnexus/router": "workspace:*", - "@wrnexus/styles": "workspace:*", - "@wrnexus/ui": "workspace:*", - "@wrnexus/validation": "workspace:*", + "packages/compiler": { + "name": "@wrnexus/compiler", + "version": "0.3.0", + "dependencies": { + "@wrnexus/syntax": "workspace:*" + } }, - }, - "packages/compiler": { - "name": "@wrnexus/compiler", - "version": "0.2.74", - }, - "packages/core": { - "name": "@wrnexus/core", - "version": "0.2.74", - }, - "packages/csr": { - "name": "@wrnexus/csr", - "version": "0.2.74", - "dependencies": { - "@wrnexus/core": "workspace:*", + "packages/core": { + "name": "@wrnexus/core", + "version": "0.3.0" }, - }, - "packages/db": { - "name": "@wrnexus/db", - "version": "0.2.74", - }, - "packages/dev-server": { - "name": "@wrnexus/dev-server", - "version": "0.2.74", - "dependencies": { - "@wrnexus/compiler": "workspace:*", - "@wrnexus/core": "workspace:*", - "@wrnexus/csr": "workspace:*", - "@wrnexus/db": "workspace:*", - "@wrnexus/dev-toolbar": "workspace:*", - "@wrnexus/i18n": "workspace:*", - "@wrnexus/pubsub": "workspace:*", - "@wrnexus/router": "workspace:*", - "@wrnexus/ssr": "workspace:*", - "@wrnexus/styles": "workspace:*", - "@wrnexus/ui": "workspace:*", - "@wrnexus/uploader": "workspace:*", - "@wrnexus/validation": "workspace:*", + "packages/csr": { + "name": "@wrnexus/csr", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*" + } }, - }, - "packages/dev-toolbar": { - "name": "@wrnexus/dev-toolbar", - "version": "0.2.58", - "devDependencies": { - "@types/bun": "latest", - "typescript": "^5.9.2", + "packages/db": { + "name": "@wrnexus/db", + "version": "0.3.0" }, - }, - "packages/encryption": { - "name": "@wrnexus/encryption", - "version": "0.2.74", - }, - "packages/helpers": { - "name": "@wrnexus/helpers", - "version": "0.2.74", - "dependencies": { - "@wrnexus/core": "workspace:*", + "packages/dev-server": { + "name": "@wrnexus/dev-server", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*", + "@wrnexus/dev-toolbar": "workspace:*", + "@wrnexus/router": "workspace:*", + "@wrnexus/ssr": "workspace:*", + "@wrnexus/csr": "workspace:*", + "@wrnexus/compiler": "workspace:*", + "@wrnexus/styles": "workspace:*", + "@wrnexus/ui": "workspace:*", + "@wrnexus/validation": "workspace:*", + "@wrnexus/i18n": "workspace:*", + "@wrnexus/db": "workspace:*", + "@wrnexus/pubsub": "workspace:*", + "@wrnexus/uploader": "workspace:*", + "@wrnexus/plugin": "workspace:*" + } }, - }, - "packages/i18n": { - "name": "@wrnexus/i18n", - "version": "0.2.74", - "dependencies": { - "@wrnexus/core": "workspace:*", + "packages/dev-toolbar": { + "name": "@wrnexus/dev-toolbar", + "version": "0.3.0", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.2" + } }, - }, - "packages/jwt": { - "name": "@wrnexus/jwt", - "version": "0.2.74", - }, - "packages/mobile": { - "name": "@wrnexus/mobile", - "version": "0.2.74", - "dependencies": { - "@wrnexus/native": "workspace:*", + "packages/encryption": { + "name": "@wrnexus/encryption", + "version": "0.3.0" }, - }, - "packages/native": { - "name": "@wrnexus/native", - "version": "0.2.74", - }, - "packages/oauth": { - "name": "@wrnexus/oauth", - "version": "0.2.74", - }, - "packages/pubsub": { - "name": "@wrnexus/pubsub", - "version": "0.2.74", - }, - "packages/queue": { - "name": "@wrnexus/queue", - "version": "0.2.74", - }, - "packages/reactive": { - "name": "@wrnexus/reactive", - "version": "0.2.74", - }, - "packages/router": { - "name": "@wrnexus/router", - "version": "0.2.74", - "dependencies": { - "@wrnexus/compiler": "workspace:*", - "@wrnexus/core": "workspace:*", + "packages/helpers": { + "name": "@wrnexus/helpers", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*" + } }, - }, - "packages/ssr": { - "name": "@wrnexus/ssr", - "version": "0.2.74", - "dependencies": { - "@wrnexus/core": "workspace:*", + "packages/i18n": { + "name": "@wrnexus/i18n", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*" + } }, - }, - "packages/styles": { - "name": "@wrnexus/styles", - "version": "0.2.74", - "dependencies": { - "@wrnexus/uploader": "workspace:*", + "packages/jwt": { + "name": "@wrnexus/jwt", + "version": "0.3.0" }, - }, - "packages/test": { - "name": "@wrnexus/test", - "version": "0.2.74", - }, - "packages/tracking": { - "name": "@wrnexus/tracking", - "version": "0.2.74", - }, - "packages/ui": { - "name": "@wrnexus/ui", - "version": "0.2.74", - "dependencies": { - "@wrnexus/core": "workspace:*", + "packages/mobile": { + "name": "@wrnexus/mobile", + "version": "0.3.0", + "dependencies": { + "@wrnexus/native": "workspace:*" + } }, - }, - "packages/uploader": { - "name": "@wrnexus/uploader", - "version": "0.2.74", - "dependencies": { - "@wrnexus/core": "workspace:*", + "packages/native": { + "name": "@wrnexus/native", + "version": "0.3.0" }, - }, - "packages/validation": { - "name": "@wrnexus/validation", - "version": "0.2.74", - }, + "packages/oauth": { + "name": "@wrnexus/oauth", + "version": "0.3.0" + }, + "packages/plugin": { + "name": "@wrnexus/plugin", + "version": "0.3.0", + "dependencies": { + "@wrnexus/syntax": "workspace:*" + } + }, + "packages/pubsub": { + "name": "@wrnexus/pubsub", + "version": "0.3.0" + }, + "packages/queue": { + "name": "@wrnexus/queue", + "version": "0.3.0" + }, + "packages/reactive": { + "name": "@wrnexus/reactive", + "version": "0.3.0" + }, + "packages/router": { + "name": "@wrnexus/router", + "version": "0.3.0", + "dependencies": { + "@wrnexus/compiler": "workspace:*", + "@wrnexus/core": "workspace:*" + } + }, + "packages/ssr": { + "name": "@wrnexus/ssr", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*" + } + }, + "packages/styles": { + "name": "@wrnexus/styles", + "version": "0.3.0", + "dependencies": { + "@wrnexus/uploader": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/plugin": "workspace:*" + } + }, + "packages/syntax": { + "name": "@wrnexus/syntax", + "version": "0.3.0" + }, + "packages/test": { + "name": "@wrnexus/test", + "version": "0.3.0" + }, + "packages/tracking": { + "name": "@wrnexus/tracking", + "version": "0.3.0" + }, + "packages/ui": { + "name": "@wrnexus/ui", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*" + } + }, + "packages/uploader": { + "name": "@wrnexus/uploader", + "version": "0.3.0", + "dependencies": { + "@wrnexus/core": "workspace:*" + } + }, + "packages/validation": { + "name": "@wrnexus/validation", + "version": "0.3.0" + }, + "examples/basic-app": { + "name": "basic-app", + "version": "0.1.0", + "dependencies": { + "@wrnexus/core": "workspace:*", + "@wrnexus/validation": "workspace:*", + "@wrnexus/db": "workspace:*" + }, + "devDependencies": { + "@wrnexus/test": "workspace:*", + "@eslint/js": "latest", + "@tailwindcss/cli": "^4.0.0", + "eslint": "latest", + "prettier": "^3.9.4", + "tailwindcss": "^4.0.0", + "typescript-eslint": "latest" + } + } }, "overrides": { "esbuild": "0.28.1", @@ -470,6 +489,8 @@ "@wrnexus/oauth": ["@wrnexus/oauth@workspace:packages/oauth"], + "@wrnexus/plugin": ["@wrnexus/plugin@workspace:packages/plugin"], + "@wrnexus/pubsub": ["@wrnexus/pubsub@workspace:packages/pubsub"], "@wrnexus/queue": ["@wrnexus/queue@workspace:packages/queue"], @@ -482,6 +503,8 @@ "@wrnexus/styles": ["@wrnexus/styles@workspace:packages/styles"], + "@wrnexus/syntax": ["@wrnexus/syntax@workspace:packages/syntax"], + "@wrnexus/test": ["@wrnexus/test@workspace:packages/test"], "@wrnexus/tracking": ["@wrnexus/tracking@workspace:packages/tracking"], diff --git a/current-test-failure-files.zip b/current-test-failure-files.zip new file mode 100644 index 00000000..e601686b Binary files /dev/null and b/current-test-failure-files.zip differ diff --git a/docs/40-POINT-IMPLEMENTATION-0.3.md b/docs/40-POINT-IMPLEMENTATION-0.3.md new file mode 100644 index 00000000..f9d7d680 --- /dev/null +++ b/docs/40-POINT-IMPLEMENTATION-0.3.md @@ -0,0 +1,68 @@ +# WRNexusJS 0.3 — 40-Point Implementation Matrix + +Legend: + +- **Existing + hardened**: capability already existed and was preserved or extended +- **Implemented**: new usable public API/runtime behavior in 0.3 +- **Foundation / experimental**: contract and integration seam exist; advanced provider-specific + implementations should remain opt-in until they receive production soak testing + +| # | Improvement | 0.3 implementation | Level | +| --: | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| 1 | Formal language specification | Canonical spec constants, stable diagnostics, and `docs/WRN-LANGUAGE-SPEC-1.0.md` | Implemented | +| 2 | Shared parser and AST | New `@wrnexus/syntax`; compiler compatibility re-exports | Implemented | +| 3 | Compile-time/runtime separation | Runtime metadata, server-only suppression, compile-time diagnostics and transforms | Existing + hardened | +| 4 | Fine-grained reactivity | Batched signals, dependency-tracked computed/effects, coalesced renderers | Implemented | +| 5 | Deterministic SSR/hydration | Stable hydration IDs, runtime/strategy metadata, mismatch diagnostics, keyed `data-for` reconciliation and keyed `{#each}` syntax | Implemented | +| 6 | Partial hydration/islands | load/idle/visible/interaction/media/none strategies | Implemented | +| 7 | Server-only execution | `runtime = "server"`, `load server`, browser-interactivity rejection | Implemented | +| 8 | Data-loading model | `defineLoader`, `defineAction`, request-local dedupe, WRN loaders/actions | Foundation / experimental | +| 9 | Streaming SSR | Promise and `AsyncIterable` document streams and response helper | Implemented | +| 10 | Routing | groups, optional and catch-all params, conflict checks, typed builders | Implemented | +| 11 | Middleware | Existing ordered middleware plus tracing and tenancy middleware | Existing + hardened | +| 12 | Typed API/RPC | validated endpoint definitions, typed RPC client, structured errors | Implemented | +| 13 | Security defaults | Existing CSP/CSRF/cookies/CORS/body guards plus WRN security metadata | Existing + hardened | +| 14 | DevToolbar | Runtime diagnostic bridge and existing accessibility/performance/SEO/security rules | Existing + hardened | +| 15 | Optimization reports | `dist/build-report.json`, CLI analyzer, route/assets/budget measurements | Implemented | +| 16 | Error messages | Stable WRN codes, positions, code frames, hints | Implemented | +| 17 | Language server/editor | VS Code diagnostics/completion/grammar/snippets aligned to 0.3 syntax | Existing + hardened | +| 18 | Schemas/validation | Existing shared form/API validation retained; endpoint schema contract added | Existing + hardened | +| 19 | Authentication primitives | Existing auth/session/OAuth/passkey-related packages retained; security metadata seam | Existing + hardened | +| 20 | Multi-tenancy | tenant context, resolver/middleware, subdomain/domain/path runtime config | Implemented | +| 21 | Jobs/cron/workflows | priority, concurrency, idempotency, cancellation, job/workflow/cron helpers | Implemented | +| 22 | Realtime | Existing rooms/pub-sub/Redis bridge retained with shared runtime | Existing + hardened | +| 23 | Plugin system | deterministic lifecycle, AST/code transforms, diagnostics, server/build hooks | Implemented | +| 24 | Infrastructure adapters | portable fetch handler and existing Bun/Node seams; adapter config/reporting | Foundation / experimental | +| 25 | Build caching/monorepo | Existing compile cache and targeted HMR retained; build cache config seam | Existing + hardened | +| 26 | Compatibility/migrations | reversible 0.3 migration, source backup/report, `doctor`, unique versions | Implemented | +| 27 | Testing | new syntax/core/compiler/router/queue/SSR/update/editor regressions | Implemented | +| 28 | Performance budgets | configurable route JS/CSS, HTML, image, hydration, and SSR budgets | Implemented | +| 29 | Observability | tracer/span APIs, request middleware, Server-Timing, console exporter | Implemented | +| 30 | Component architecture | Existing `@wrnexus/ui` inventory retained; syntax metadata supports typed tooling | Existing + hardened | +| 31 | Design tokens | Existing `--wire-*` system and theme resolution retained | Existing + hardened | +| 32 | Accessibility | compile-time missing-alt diagnostic plus existing DevToolbar scanning | Implemented | +| 33 | Motion/transitions | Existing CSR/lifecycle foundation retained; strategy can be plugin/runtime extended | Foundation / experimental | +| 34 | Documentation | language, architecture, upgrade, implementation, test checklist docs | Implemented | +| 35 | AI-friendly framework | machine-readable spec/AST/diagnostics/build report and existing AI package | Implemented | +| 36 | Feature flags/experimental APIs | async context-aware feature flags and typed config gates | Implemented | +| 37 | Public API boundaries | syntax/compiler separation and explicit package exports | Implemented | +| 38 | Configuration | typed validation, source explanation, profile/env visibility | Implemented | +| 39 | Gateway | existing host routing/auth/WebSocket/security gateway preserved; internal contracts unchanged | Existing + hardened | +| 40 | Focused roadmap/release gates | 0.3 stability levels, verification script, audit and test matrix | Implemented | + +## Important release distinction + +This matrix records code present in the 0.3 source tree. “Foundation / experimental” +does not mean absent; it means the API or adapter seam is implemented but should not +be advertised as provider-complete until the relevant deployment, animation, cache, +or server-component adapters receive real production tests. + +## Backward-compatibility gates + +1. Existing compiler imports continue through re-exports. +2. Existing `.wrn` members and directives remain accepted. +3. New runtime behavior is disabled unless syntax/config opts in. +4. Source migration is conservative, backed up, idempotent, and reported. +5. Existing route syntax retains matching precedence. +6. Existing dev and production request runtimes remain shared. +7. Every bug fix receives a regression test or static verification assertion. diff --git a/docs/ARCHITECTURE-0.3.md b/docs/ARCHITECTURE-0.3.md new file mode 100644 index 00000000..35da9099 --- /dev/null +++ b/docs/ARCHITECTURE-0.3.md @@ -0,0 +1,136 @@ +# WRNexusJS 0.3 Architecture + +## Design goals + +WRNexusJS 0.3 is an additive architecture release focused on one invariant: + +> A valid `.wrn` file must be parsed, diagnosed, compiled, rendered, hydrated, +> formatted, migrated, and edited from one shared language model. + +The release keeps existing application contracts while adding extension seams for +full-stack data, plugins, partial hydration, observability, tenancy, advanced +routing, build analysis, jobs, and deployment adapters. + +## Package boundaries + +### Language and compilation + +- `@wrnexus/syntax`: canonical tokens, AST, source positions, diagnostics, and spec +- `@wrnexus/compiler`: SSR/client code generation and deprecated parser re-exports +- `@wrnexus/csr`: browser navigation and fine-grained reactive hydration +- `@wrnexus/reactive`: framework-independent signals, computed values, effects, and batching + +### Request and application runtime + +- `@wrnexus/core`: context, middleware, security, typed endpoints, loaders/actions, + tenant APIs, tracing, feature flags, and performance budgets +- `@wrnexus/router`: route discovery, matching, groups, optional/catch-all params, + conflicts, and typed URL generation +- `@wrnexus/ssr`: document rendering plus string, promise, and async-iterable streaming +- `@wrnexus/dev-server`: shared dev/production request runtime and portable fetch handlers + +### Extension and operations + +- `@wrnexus/plugin`: deterministic plugin ordering and lifecycle hooks +- `@wrnexus/dev-toolbar`: source-linked page diagnostics +- `@wrnexus/cli`: build, doctor, config explanation, analyzer, migrations, and generators +- `@wrnexus/queue`: jobs, priority, concurrency, idempotency, cancellation, cron helpers, + and workflows +- `@wrnexus/pubsub`: realtime scaling adapters + +## Compatibility layers + +The compiler's former parser, tokenizer, and AST modules re-export the canonical +syntax package. No existing public compiler import must be changed immediately. + +The client runtime continues to support legacy hydration scopes, `data-for`, and +`{#each}` output. New hydration metadata is additive: + +```html +
+``` + +The updater creates a full application-source backup before source normalization. +Its migration report lists every changed file. + +## Request flow + +```text +Request + -> security/CORS/body-size boundary + -> optional tracing middleware + -> optional tenant middleware + -> application middleware + -> route matcher + -> loader/API/page/realtime dispatch + -> SSR document assembly + -> response cookies/security/compression +``` + +Development and production use the same `createHandlers` runtime. Production also +exposes `createProductionHandlers`, a portable web-standard fetch seam used by Bun, +Node, and future edge/serverless adapters. + +## Reactive update flow + +```text +signal write + -> dependency invalidation + -> microtask batch + -> computed values refresh on demand + -> only subscribed renderers/effects run + -> DOM bindings update +``` + +Renderer scheduling is coalesced so repeated writes in one task do not cause a full +component rerender for each write. + +## Plugin lifecycle + +Plugins are ordered deterministically with `enforce`, `before`, and `after`: + +```text +configure +configResolved +buildStart / configureServer +transformAst +plugin diagnostics +transformCode +buildEnd +``` + +Duplicate names and ordering cycles fail with stable plugin errors. Plugin hooks +are optional and the absence of plugins has zero behavioral effect. + +## Build outputs + +A production build can emit: + +- compiled route/component modules +- source maps when enabled +- route and asset measurements +- `dist/build-report.json` +- performance-budget violations +- generated production entry with security, observability, tenancy, storage, + databases, realtime, mobile, PWA, and SEO configuration + +Use: + +```bash +wrnexus config . --explain +wrnexus build . +wrnexus analyze . +wrnexus doctor . +``` + +## Stability levels + +- **Stable**: existing behavior, canonical syntax ownership, diagnostics, router + compatibility, typed core primitives, build reports, migration safety +- **Additive stable API**: plugin contracts, tracing, tenancy, loaders/actions, + endpoint definitions, job definitions +- **Experimental runtime behavior**: server components, broader streaming boundaries, + custom adapter implementations, and plugin transforms can be gated in config + +Experimental flags make feature adoption explicit without forcing existing apps to +change their runtime behavior. diff --git a/docs/AUDIT-0.3.md b/docs/AUDIT-0.3.md new file mode 100644 index 00000000..9ee3aae3 --- /dev/null +++ b/docs/AUDIT-0.3.md @@ -0,0 +1,45 @@ +# WRNexusJS 0.3 — Source Audit and Validation Record + +## Audit scope + +The uploaded monorepo was inspected package-by-package before changes. The baseline already contained compiler, SSR/CSR, reactive runtime, routing, gateway/dev-server integration, authentication and authorization packages, validation, database, queue, pub/sub/realtime, upload/storage helpers, UI, DevToolbar, AI tooling, mobile/native support, CLI migrations, and a VS Code extension. + +The 0.3 work therefore extends existing contracts instead of replacing them. The baseline archive was committed locally before edits so every source change remained reviewable and reversible. + +## Compatibility decisions + +- Existing `@wrnexus/compiler` parser/type imports remain available through re-exports from `@wrnexus/syntax`. +- Existing `.wrn` roots, directives, route forms, component mounts, SSR output, and gateway configuration remain accepted. +- New hydration, plugin, tenancy, observability, feature-flag, performance-budget, and build-analysis behavior is opt-in. +- The updater backs up the complete `app/` directory and important project configuration before writing. +- Source normalization only rewrites simple unquoted dynamic attributes and safely parseable one-line props blocks. Nested-brace expressions are left unchanged for manual review. +- The updater writes a machine-readable changed-file report and is source-idempotent. + +## Validation completed in this sandbox + +- TypeScript static validation across all package source files: passed. +- TypeScript emit of all package sources for runtime smoke testing: passed. +- Compiled smoke tests for syntax/compiler, batched reactivity, computed/effects, typed endpoints, tracing, optional/catch-all routing, plugin ordering/transforms, queue priority/idempotency/workflows/cron, and streaming SSR: passed. +- Generated browser reactive runtime JavaScript syntax check: passed. +- All 913 checked-in `.wrn` files compile with the 0.3 compiler: passed. +- All 913 checked-in `.wrn` files remain valid and formatter-idempotent after formatting: passed. +- VS Code Node regression tests: 15 passed, 0 failed. +- VS Code asset/compiler validation: passed; `src/compiler.cjs` was rebuilt from the 0.3 compiler and shared syntax source. +- 0.3 structural release verification: 30 named packages and 66 migration entries passed. +- Git whitespace/error check: passed. +- Synthetic 0.2.70 to 0.3.0 migration: backup, dependency bump, scripts, new packages, conservative source normalization, report, nested-expression preservation, and second-run source idempotency all passed. + +## Environment limitations + +The sandbox did not contain Bun and could not reach the package registry. Therefore these release gates must still be run in the normal WRNexusJS development environment: + +```bash +bun install +bun run check +bun run build +cd editors/vscode && bun run check && bun run package +``` + +Because VSCE dependencies were unavailable, a new 0.3.0 VSIX binary was not packaged here. The compiler bundle, source, grammar, snippets, metadata, Node tests, and extension validation were updated; run `bun run package` before Marketplace publishing. + +The broad provider-specific parts of infrastructure adapters, advanced cache backends, and motion adapters remain deliberately marked as foundation/experimental in the implementation matrix until they receive real deployment and browser soak tests. They are not advertised as provider-complete. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7086f555..058f98a8 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -310,15 +310,15 @@ identifiers, member/index access, calls, arrays/objects, `+ - * / %`, comparison ### Directives (the `data-*` the runtime understands) -| Directive | Syntax | What it does | -| ----------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-scope` | `data-scope="count: 0, name: 'x'"` | Declares reactive state on a subtree. The compiler emits this automatically when a page/component has state. A binding is owned by its **nearest** `data-scope` ancestor (nesting is safe). | -| `data-on-` | `data-on-click="count++"` | Event handler (the compiled form of `@event`). | -| `data-text` | `data-text="count * 2"` | `textContent` follows the expression. Emitted by state interpolation; you can also hand‑write it. | -| `data-show` | `data-show="open"` | Toggles `display` on truthiness. **Hand‑authored** (no `{}` sugar). | -| `data-for` | `data-for="item in items"` or `data-for="item, i in items"` | Repeats the element per list item. Inside, `item`/`i` are locals; per‑item `data-text`, `data-on-*`, attribute mustaches, and text mustaches are filled. **Hand‑authored.** | -| `data-component` | `data-component="counter"` | Mounts a component (server‑rendered, then hydrated). See §12. | -| `data-slot` | `
` | Fills a named `` of a component (see §8). | +| Directive | Syntax | What it does | +| ----------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `data-scope` | `data-scope="count: 0, name: 'x'"` | Declares reactive state on a subtree. The compiler emits this automatically when a page/component has state. A binding is owned by its **nearest** `data-scope` ancestor (nesting is safe). | +| `data-on-` | `data-on-click="count++"` | Event handler (the compiled form of `@event`). | +| `data-text` | `data-text="count * 2"` | `textContent` follows the expression. Emitted by state interpolation; you can also hand‑write it. | +| `data-show` | `data-show="open"` | Toggles `display` on truthiness. **Hand‑authored** (no `{}` sugar). | +| `data-for` | `data-for="item in items"`, optionally `key item.id` or `data-key="item.id"` | Repeats the element per list item. A stable key preserves DOM identity during reorder; unkeyed loops retain legacy full rerendering. Inside, item/index locals work in bindings and handlers. **Hand-authored.** | +| `data-component` | `data-component="counter"` | Mounts a component (server‑rendered, then hydrated). See §12. | +| `data-slot` | `
` | Fills a named `` of a component (see §8). | Example — a reactive list you write by hand: @@ -327,7 +327,7 @@ view {
    -
  • +
} diff --git a/docs/TEST-CHECKLIST-0.3.md b/docs/TEST-CHECKLIST-0.3.md new file mode 100644 index 00000000..890f2105 --- /dev/null +++ b/docs/TEST-CHECKLIST-0.3.md @@ -0,0 +1,102 @@ +# WRNexusJS 0.3 — One-by-One Test Checklist + +Use this checklist on a clean branch before publishing. Run the global gates first, then test the numbered capabilities in order. Keep one of the three existing applications on its current release so you can compare output and behavior during the upgrade. + +## Global release gates + +```bash +bun install +bun run verify:0.3 +bun run typecheck +bun run lint +bun run test +bun run format:check +bun run build +``` + +Expected: every command exits with code 0. The build must create `dist/build-report.json`. + +For the VS Code extension: + +```bash +cd editors/vscode +bun run check +bun run package +``` + +Expected: editor tests and validation pass, `src/compiler.cjs` is rebuilt from the 0.3 compiler, and a 0.3.0 VSIX is produced. + +## Existing application upgrade safety + +Run this separately in each existing application: + +```bash +wrnexus update . --version=0.3.0 --dry-run +wrnexus update . --version=0.3.0 +bun run format +bun run check +bun run build +wrnexus doctor . +wrnexus config . --explain +wrnexus analyze . +``` + +Verify before committing: + +1. `.wrnexus/update-backups/` contains the original `app/` directory and project configuration. +2. `.wrnexus/migrations/0.3.0.json` lists only files that were actually normalized. +3. Dynamic attributes use quoted expressions, for example `items='{items}'`. +4. Existing `data-for`, `{#each}`, component mounts, pages, layouts, APIs, middleware, gateway routes, and authentication flows still work. +5. The package marker is updated only after installation, checks, and build succeed. + +## 40 capability tests + +| # | Capability | Test | Expected result | +| --: | -------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| 1 | Language specification | Open `docs/WRN-LANGUAGE-SPEC-1.0.md`; compile each root member shown there. | Compiler and editor accept the same syntax. | +| 2 | Shared parser/AST | Import `parse` from both `@wrnexus/syntax` and `@wrnexus/compiler`. | Equivalent ASTs; old compiler imports continue working. | +| 3 | Compile/runtime separation | Build a static page with no state/events. | Page sends no reactive runtime unless another feature requires it. | +| 4 | Fine-grained reactivity | Change one signal used by one binding while another signal remains unchanged. | Only dependent renderers run; `batch`, `computed`, and `effect` behave predictably. | +| 5 | Deterministic hydration | SSR a stateful component, then hydrate it. Test keyed reorder with `data-for="item in items key item.id"`. | No mismatch; keyed DOM nodes retain identity and focus on reorder. | +| 6 | Partial hydration | Test `hydrate = "load"`, `idle`, `visible`, `interaction`, `media:...`, and `none`. | Each component hydrates only under its selected condition. | +| 7 | Server-only execution | Set `runtime = "server"` on a static component, then add a browser event deliberately. | Static version ships no client scope; interactive server-only version reports a compile diagnostic. | +| 8 | Data loading | Define a loader, action, request-local `dedupe`, cache metadata, and invalidation tags. | Loader/action types are inferred and duplicate request work runs once. | +| 9 | Streaming SSR | Return an async iterable body with delayed chunks. | Shell/head arrive first and later chunks complete valid HTML. | +| 10 | Routing | Create route groups, `[id?]`, `[[id]]`, `[...slug]`, and `[[...slug]]`. | Groups do not affect URLs; optional/catch-all params match and conflicts are reported. | +| 11 | Middleware | Add global middleware plus tracing and tenancy middleware. | Execution order is deterministic and context values reach the route. | +| 12 | Typed API/RPC | Define an endpoint with input/output schemas and call it through `createRpcClient`. | Invalid input returns a structured error; valid output is typed. | +| 13 | Security defaults | Test CSP, CSRF, secure cookies, CORS, body/upload limits, and a raw HTML boundary. | Unsafe requests/content are rejected and security headers are present. | +| 14 | DevToolbar | Open a page containing missing alt text, low contrast, oversized image, bad SEO, and runtime diagnostic. | Toolbar groups issues by panel and shows source/actionable detail. | +| 15 | Optimization reports | Build and run `wrnexus analyze .`. | Report shows assets, route sizes, measurements, diagnostics, and budget status. | +| 16 | Error messages | Add a malformed prop, invalid runtime, and duplicate symbol. | Error contains stable code, file, line/column, code frame, and hint. | +| 17 | Editor/LSP behavior | Test completion, hover/diagnostics, go-to-definition, formatting, snippets, and multiline props. | Editor behavior matches compiler syntax with no false unknown-member errors. | +| 18 | Schemas/validation | Submit invalid and valid data through existing form/API validation and a typed endpoint schema. | Client/server errors agree and valid types are inferred. | +| 19 | Authentication | Test session login/logout, protected middleware, OAuth/passkey integrations used by your apps, and route security metadata. | Existing SSO flows remain unchanged and protected routes reject anonymous users. | +| 20 | Multi-tenancy | Configure subdomain, domain, and path tenancy separately. | `ctx.tenant` resolves correctly and tenant-scoped operations reject missing tenancy. | +| 21 | Jobs/cron/workflows | Queue priority jobs, duplicate idempotency keys, retries, cancellation, concurrency, workflow steps, and cron aliases. | Priority/order/retries are deterministic; duplicates do not create extra queued work. | +| 22 | Realtime | Connect an authenticated room, publish through the existing bus/Redis bridge, disconnect, and reconnect. | Authorization, delivery, cleanup, and horizontal bridge behavior remain correct. | +| 23 | Plugins | Register pre/normal/post plugins with `before`/`after`, AST/code transforms, diagnostics, server and build hooks. | Order is deterministic; duplicates/cycles fail with stable errors. | +| 24 | Infrastructure adapters | Run the same built app through the existing Bun and Node/fetch adapter paths; test configured deployment adapter metadata. | Request/response semantics and build report remain consistent. | +| 25 | Build caching/HMR | Change CSS, a leaf component, a shared component, server code, and route structure separately. | Only affected work is rebuilt; structural changes trigger the correct reload level. | +| 26 | Compatibility/migrations | Upgrade a copied legacy app twice. | First run changes only required files; second run is source-idempotent; backup/report exist. | +| 27 | Testing | Run the full package suite and the three upgraded app suites. | All old regressions plus new 0.3 tests pass. | +| 28 | Performance budgets | Set deliberately small HTML/JS/CSS/image budgets, then realistic budgets. | Build warns/fails according to policy, then passes with realistic limits. | +| 29 | Observability | Enable tracing and `serverTiming`; make a request containing nested spans. | Trace records include duration/status/attributes and response has `Server-Timing`. | +| 30 | Component architecture | Render primitives, behavior components, layouts, and product components in light/dark/responsive states. | Props/events/slots/accessibility remain consistent and app overrides still win. | +| 31 | Design tokens | Audit `--wire-*` usage and switch themes. | Components use semantic tokens and no required token is undefined. | +| 32 | Accessibility | Compile missing-alt markup and run toolbar keyboard/focus/ARIA checks. | Compile-time and runtime accessibility findings appear without duplicates or false positives. | +| 33 | Motion/transitions | Exercise existing lifecycle/animation behavior with reduced-motion enabled and disabled. | Motion respects user preference and teardown does not leak listeners/timers. | +| 34 | Documentation | Follow the language, architecture, upgrade, and this checklist from a clean clone. | Commands/examples match actual APIs and produce the documented result. | +| 35 | AI-friendly metadata | Inspect language spec exports, structured diagnostics, component metadata, route manifest, and build report. | Tools can consume JSON/typed metadata without parsing human logs. | +| 36 | Feature flags | Resolve boolean/function flags for two users/tenants and across async work. | Context does not leak and experimental features stay disabled by default. | +| 37 | Public APIs | Search applications/plugins for imports containing `/src/` or undocumented internals. | Consumers use only exported package entry points. | +| 38 | Configuration | Layer defaults, profile, environment, project config, and route overrides; run `config --explain`. | Final value and its source are clear; invalid settings report paths. | +| 39 | Gateway | Test host/path routing, forward auth with app + path, WebSockets, timeouts, headers, and development/production origins. | Existing domains work; internal service resolution avoids hardcoded production URLs. | +| 40 | Release gates | Run `verify:0.3`, full checks, app upgrades, builds, editor package, and release prepare. | Version topology, migration uniqueness, lockfile, docs, tests, and artifacts all pass before publish. | + +## Recommended rollout + +1. Upgrade a disposable copy of the least complex application. +2. Compare generated HTML, browser JavaScript, routes, gateway behavior, and screenshots against its current production version. +3. Upgrade the second and third applications only after the first passes this checklist. +4. Publish 0.3.0 as a prerelease first if the three applications exercise materially different subsystems. diff --git a/docs/UPGRADE-0.3.md b/docs/UPGRADE-0.3.md new file mode 100644 index 00000000..10258b6a --- /dev/null +++ b/docs/UPGRADE-0.3.md @@ -0,0 +1,144 @@ +# Upgrade to WRNexusJS 0.3.0 + +## Before upgrading + +Commit the project or create a backup. The updater also creates its own timestamped +backup, including the complete `app` directory. + +Use the same Bun baseline used by the framework: + +```bash +bun --version +# 1.3.0 or newer +``` + +## Preview the migration + +```bash +bunx wrnexus update . --to 0.3.0 --dry-run +``` + +Review every reported `.wrn` file. The source migration is intentionally narrow: + +- compact simple `props { ... }` declarations become multiline +- simple `attribute={expression}` becomes `attribute='{expression}'` +- nested brace values, ambiguous quotes, and complex expressions are not rewritten + +## Apply and verify + +```bash +bunx wrnexus update . --to 0.3.0 +bun install +bun run format +bun run check +bun run build +bun run doctor +bun run analyze +``` + +The update is not marked verified until project verification succeeds. + +## Files added by the migration + +Runnable applications receive these scripts when missing: + +```json +{ + "doctor": "wrnexus doctor .", + "config:explain": "wrnexus config . --explain", + "analyze": "wrnexus analyze .", + "update:preview": "wrnexus update . --dry-run" +} +``` + +They also receive direct dependencies on `@wrnexus/syntax` and `@wrnexus/plugin`. +A report is written to `.wrnexus/migrations/0.3.0.json`. + +## New optional configuration + +```ts +import { defineConfig } from "@wrnexus/styles"; + +export default defineConfig({ + experimental: { + partialHydration: true, + streaming: true, + typedRpc: true, + pluginTransforms: true, + }, + observability: { + enabled: true, + serverTiming: true, + sampleRate: 1, + exporter: "console", + }, + tenancy: { + mode: "subdomain", + rootDomains: ["example.com"], + required: true, + }, + performance: { + enforcement: "warn", + analyze: true, + budgets: { + routeJsBytes: 80_000, + routeCssBytes: 50_000, + imageBytes: 300_000, + }, + }, + build: { + sourceMaps: true, + report: true, + adapter: "bun", + }, +}); +``` + +All new sections are optional. Omitting them preserves previous behavior. + +## Syntax adoption + +Existing `.wrn` files continue working. New declarations can be introduced one at +a time: + +```wrn +component AnalyticsChart { + runtime = "universal" + hydrate = "visible" + + props { + points = [] + } + + computed { + total = points.reduce((sum, point) => sum + point.value, 0) + } + + view { + {total} + } +} +``` + +## Rollback + +Use the updater's backup directory or restore the source-control commit. The +migration report contains the exact normalized files. Because the new APIs and +metadata are additive, an application that did not adopt new syntax can roll back +package versions without source changes. + +## Release verification for framework maintainers + +```bash +bun install +bun run verify:0.3 +bun run typecheck +bun run lint +bun test packages +bun run format:check +node --test editors/vscode/test/*.test.js +cd editors/vscode && bun run validate && bun run package +``` + +Regenerate `bun.lock` and the VS Code `compiler.cjs`/VSIX in a Bun-enabled release +environment before publishing. diff --git a/docs/WRN-LANGUAGE-SPEC-1.0.md b/docs/WRN-LANGUAGE-SPEC-1.0.md new file mode 100644 index 00000000..1e352a17 --- /dev/null +++ b/docs/WRN-LANGUAGE-SPEC-1.0.md @@ -0,0 +1,189 @@ +# WRN Language Specification 1.0 + +This document is the canonical public contract for `.wrn` files in WRNexusJS 0.3.x. +The executable source of truth is `@wrnexus/syntax`; the compiler re-exports its +parser and AST for compatibility. + +## Compatibility promise + +WRNexusJS 0.3 keeps all previously supported declarations, including: + +- `page`, `component`, and `layout` roots +- `layout = "..."`, `types`, `props`, `state`, `view`, `seo`, and `style` +- `functions`, `lifecycle`, and `watch` +- `api`, `ssr`, `client`, and `realtime` +- `data-for`, `{#each}`, conditions, interpolations, events, and `class:*` + +New syntax is additive. Existing projects do not need to adopt it immediately. +Dynamic attribute expressions should be single-quoted so braces remain unambiguous: + +```wrn + +``` + +The 0.3 updater only rewrites simple, unambiguous `prop={expression}` values. Nested +brace expressions are left unchanged and reported for manual review. + +## File structure + +A file may begin with TypeScript imports and must contain one root declaration: + +```wrn +import { appUrl } from "@wrnexus/helpers"; + +page Home { + view { +
Home
+ } +} +``` + +Root names are JavaScript identifiers. The valid root kinds are `page`, `component`, +and `layout`. + +## Execution declarations + +```wrn +runtime = "universal" +hydrate = "visible" +``` + +Runtime targets: + +- `server`: never emits an interactive browser scope +- `client`: intended for browser execution +- `universal`: server-rendered and optionally hydrated + +Hydration strategies: + +- `load` +- `idle` +- `visible` +- `interaction` +- `none` +- `media:` + +`client = "..."` remains an alias for a hydration declaration. The existing +`client { ... }` mode block remains valid. + +## Props and state + +```wrn +props { + title: string + count: number = 0 + enabled: boolean = false +} + +state open: boolean = false +``` + +A prop without an initializer is required. State always requires an initializer. +Typed initializers are validated when their literal type can be determined. + +## Computed values and effects + +```wrn +computed { + total = price * quantity + label = `${quantity} items` +} + +effect { + document.title = label +} +``` + +Computed values are dependency-tracked and cached. Effects rerun after a batched +reactive update when a referenced state or computed value changes. + +## Data loading and actions + +```wrn +load server { + return await repository.list(ctx.tenant?.id) +} + +load client { + return await fetch("/api/live").then((response) => response.json()) +} + +action save(input) { + return await repository.save(input) +} +``` + +Server and client loaders are exported separately by the compiler. Named actions +are exported individually and through `__wrnexusActions` for framework adapters. + +## Security metadata + +```wrn +security { + auth = "required" + csrf = "true" + roles = "admin,editor" + rateLimit = "strict" +} +``` + +The compiler exports this metadata as `__wrnexusSecurity`. Runtime middleware or +plugins can enforce organization-specific policy. Metadata does not replace global +security headers or API validation. + +## View syntax + +```wrn +view { + +} +``` + +Supported view features include: + +- HTML and component tags +- escaped `{expression}` interpolation +- event attributes such as `@click`, `@window:scroll`, and `@document:click` +- conditional `class:*` attributes +- `data-show` +- `data-for="item, index in items"`, optionally keyed with `key item.id` or `data-key="item.id"` +- `{#if}`, `{:else if}`, `{:else}`, and `{/if}` +- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches +- comments and scoped styles + +Output is escaped by default. Explicit raw HTML APIs must be treated as security +boundaries. + +## Stable diagnostics + +Canonical diagnostics use stable identifiers such as: + +- `WRN-PARSE-001` +- `WRN-PARSE-MEMBER` +- `WRN-PROP-INITIALIZER` +- `WRN-SYMBOL-DUPLICATE` +- `WRN-HYDRATE-STRATEGY` +- `WRN-RUNTIME-TARGET` +- `WRN-RUNTIME-SERVER-INTERACTIVE` +- `WRN-A11Y-001` + +Compiler, doctor, build, and future language-server integrations should consume +`@wrnexus/syntax` diagnostics rather than implementing independent parsers. + +## AST ownership + +The canonical packages are: + +```text +@wrnexus/syntax lexer, parser, AST, specification, diagnostics +@wrnexus/compiler code generation and compatibility re-exports +``` + +Direct imports from compiler internals are deprecated. Public imports from +`@wrnexus/compiler` continue to work in 0.3.x. diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 4e69aaf3..12e48944 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.3.0 + +- Added language support for `runtime`, `hydrate`, `computed`, `effect`, `security`, + `load server`, `load client`, and named `action` declarations. +- Added snippets and completion entries for execution boundaries, deferred hydration, + derived state, effects, data loading, actions, and security metadata. +- Updated root-member diagnostics to understand prefixed blocks without reporting + action names, HTTP methods, load targets, or realtime handler names as members. +- Rebuilt the bundled compiler from the shared 0.3 syntax package and added a + reproducible TypeScript-based bundle script. +- Fixed multiline tag formatting so long inline-closing elements remain + idempotent across repeated format-on-save passes. +- Aligned the extension version with the WRNexusJS 0.3.0 framework release. + ## 0.2.14 - Added top-level ES module import highlighting, completion, snippets, formatting, diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index cbf52919..52fbd4b5 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "wrnexus", - "version": "0.2.14", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrnexus", - "version": "0.2.14", + "version": "0.3.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@vscode/vsce": "^3.9.2" diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 91c350c4..b220330a 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "wrnexus", "displayName": "WRNexus Language Support", "description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.", - "version": "0.2.15", + "version": "0.3.0", "publisher": "wrnexus", "private": true, "license": "SEE LICENSE IN LICENSE", @@ -257,7 +257,7 @@ ] }, "scripts": { - "build:compiler": "bun build ../../packages/compiler/src/index.ts --target=node --format=cjs --outfile=src/compiler.cjs", + "build:compiler": "node ../../scripts/build-editor-compiler.mjs", "build": "bun run build:compiler", "test": "node --test test/*.test.js", "validate": "node test/validate.mjs", diff --git a/editors/vscode/snippets/wrn.json b/editors/vscode/snippets/wrn.json index db75eba0..05cd6a38 100644 --- a/editors/vscode/snippets/wrn.json +++ b/editors/vscode/snippets/wrn.json @@ -24,7 +24,6 @@ "}" ] }, - "WRN dynamic page": { "prefix": ["wrn-dynamic-page", "dynamic-page"], "description": "Create a WRN page using a route parameter", @@ -42,7 +41,6 @@ "}" ] }, - "WRN component": { "prefix": ["wrn-component", "component"], "description": "Create a reusable WRN component", @@ -62,7 +60,6 @@ "}" ] }, - "WRN reactive component": { "prefix": ["wrn-reactive-component", "reactive-component"], "description": "Create a component with functions, lifecycle hooks, and a watcher", @@ -96,7 +93,6 @@ "}" ] }, - "WRN layout": { "prefix": ["wrn-layout", "layout"], "description": "Create a reusable WRN layout", @@ -110,31 +106,26 @@ "}" ] }, - "WRN state": { "prefix": ["wrn-state", "state"], "description": "Create reactive state", "body": ["state ${1:name}: ${2:string} = ${3:\"value\"}"] }, - "WRN functions block": { "prefix": ["wrn-functions", "functions"], "description": "Create a browser-side functions block", "body": ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"] }, - "WRN function": { "prefix": ["wrn-function", "function"], "description": "Create a function inside a functions block", "body": ["function ${1:name}(${2}) {", " $0", "}"] }, - "WRN async function": { "prefix": ["wrn-async-function", "async-function"], "description": "Create an asynchronous component function", "body": ["async function ${1:name}(${2}) {", " $0", "}"] }, - "WRN lifecycle": { "prefix": ["wrn-lifecycle", "lifecycle"], "description": "Create mount, update, and unmount lifecycle hooks", @@ -154,37 +145,31 @@ "}" ] }, - "WRN mount hook": { "prefix": ["wrn-mount", "mount"], "description": "Create a component mount lifecycle hook", "body": ["mount {", " $0", "}"] }, - "WRN update hook": { "prefix": ["wrn-update", "update-hook"], "description": "Create a batched component update lifecycle hook", "body": ["update {", " $0", "}"] }, - "WRN unmount hook": { "prefix": ["wrn-unmount", "unmount"], "description": "Create a component unmount lifecycle hook", "body": ["unmount {", " $0", "}"] }, - "WRN watcher": { "prefix": ["wrn-watch", "watch"], "description": "Watch a declared state value", "body": ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"] }, - "WRN watcher with condition": { "prefix": ["wrn-watch-if", "watch-if"], "description": "Watch state and react to a specific value", "body": ["watch ${1:stateName} {", " if (value === ${2:true}) {", " $0", " }", "}"] }, - "WRN SEO block": { "prefix": ["wrn-seo", "seo"], "description": "Create SEO metadata", @@ -195,7 +180,6 @@ "}" ] }, - "WRN form": { "prefix": ["wrn-form", "form"], "description": "Create a POST form", @@ -212,7 +196,6 @@ "" ] }, - "WRN input": { "prefix": ["wrn-input", "input"], "description": "Create a labeled input", @@ -229,61 +212,51 @@ "" ] }, - "WRN conditional class": { "prefix": ["wrn-class-if", "class-if"], "description": "Add a conditional class directive", "body": ["class:${1:border-indigo-500}=\"${2:condition}\""] }, - "WRN conditional class pair": { "prefix": ["wrn-class-toggle", "class-toggle"], "description": "Toggle two classes from one condition", "body": ["class:${1:opacity-100}=\"${3:visible}\"", "class:${2:opacity-0}=\"!${3:visible}\""] }, - "WRN click event": { "prefix": ["wrn-click", "click"], "description": "Add a click event handler", "body": ["@click=\"${1:handler()}\""] }, - "WRN window scroll event": { "prefix": ["wrn-window-scroll", "window-scroll"], "description": "Add a window scroll event handler", "body": ["@window:scroll=\"${1:handler()}\""] }, - "WRN window resize event": { "prefix": ["wrn-window-resize", "window-resize"], "description": "Add a window resize event handler", "body": ["@window:resize=\"${1:handler()}\""] }, - "WRN document event": { "prefix": ["wrn-document-event", "document-event"], "description": "Add a document-level event handler", "body": ["@document:${1:click}=\"${2:handler()}\""] }, - "WRN show directive": { "prefix": ["wrn-show", "show"], "description": "Conditionally show an element", "body": ["data-show=\"${1:condition}\""] }, - "WRN loop": { "prefix": ["wrn-for", "for"], "description": "Create a reactive list loop", "body": ["
", " {${1:item}}", "
"] }, - "WRN dynamic link": { "prefix": ["wrn-route-link", "route-link"], "description": "Create a link using route state", "body": ["", " ${3:Open}", ""] }, - "WRN API GET": { "prefix": ["wrn-api-get", "api-get"], "description": "Create a GET API route", @@ -295,7 +268,6 @@ "}" ] }, - "WRN API POST": { "prefix": ["wrn-api-post", "api-post"], "description": "Create a POST API route", @@ -310,7 +282,6 @@ "}" ] }, - "WRN loading state": { "prefix": ["wrn-loading", "loading-state"], "description": "Create loading state and a reactive button", @@ -327,7 +298,6 @@ "" ] }, - "WRN BackToTop component": { "prefix": ["wrn-back-to-top", "back-to-top"], "description": "Create an optimized BackToTop component using lifecycle cleanup", @@ -385,5 +355,56 @@ " }", "}" ] + }, + "WRN runtime target": { + "prefix": ["wrn-runtime", "runtime"], + "description": "Set server, client, or universal execution", + "body": ["runtime = \"${1|universal,server,client|}\""] + }, + "WRN hydration strategy": { + "prefix": ["wrn-hydrate", "hydrate"], + "description": "Control when a declaration hydrates in the browser", + "body": ["hydrate = \"${1|load,idle,visible,interaction,none|}\""] + }, + "WRN media hydration": { + "prefix": ["wrn-hydrate-media", "hydrate-media"], + "description": "Hydrate only when a media query matches", + "body": ["hydrate = \"media:${1:(min-width: 768px)}\""] + }, + "WRN computed block": { + "prefix": ["wrn-computed", "computed"], + "description": "Declare cached reactive derived values", + "body": ["computed {", " ${1:displayName} = ${2:firstName + \" \" + lastName}", "}"] + }, + "WRN effect block": { + "prefix": ["wrn-effect", "effect"], + "description": "Run a side effect when reactive dependencies change", + "body": ["effect {", " ${1:console.log(value)}", "}"] + }, + "WRN security block": { + "prefix": ["wrn-security", "security"], + "description": "Declare route security policy metadata", + "body": [ + "security {", + " auth = \"${1|required,optional,public|}\"", + " csrf = \"${2:true}\"", + " rateLimit = \"${3:standard}\"", + "}" + ] + }, + "WRN server load": { + "prefix": ["wrn-load-server", "load-server"], + "description": "Load data only on the server", + "body": ["load server {", " ${1:return {}}", "}"] + }, + "WRN client load": { + "prefix": ["wrn-load-client", "load-client"], + "description": "Load data in the browser", + "body": ["load client {", " ${1:return {}}", "}"] + }, + "WRN action": { + "prefix": ["wrn-action", "action"], + "description": "Declare a named server action", + "body": ["action ${1:save}(${2:input}) {", " $0", "}"] } } diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 324ea1ff..bbb04e3d 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,1329 +1,482 @@ -var __defProp = Object.defineProperty; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __hasOwnProp = Object.prototype.hasOwnProperty; -function __accessProp(key) { - return this[key]; -} -var __toCommonJS = (from) => { - var entry = (__moduleCache ??= new WeakMap).get(from), desc; - if (entry) - return entry; - entry = __defProp({}, "__esModule", { value: true }); - if (from && typeof from === "object" || typeof from === "function") { - for (var key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(entry, key)) - __defProp(entry, key, { - get: __accessProp.bind(from, key), - enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable - }); - } - __moduleCache.set(from, entry); - return entry; -}; -var __moduleCache; -var __returnValue = (v) => v; -function __exportSetter(name, newValue) { - this[name] = __returnValue.bind(null, newValue); -} -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { - get: all[name], - enumerable: true, - configurable: true, - set: __exportSetter.bind(all, name) - }); -}; - -// ../../packages/compiler/src/index.ts -var exports_src = {}; -__export(exports_src, { - runtimeTypeOf: () => runtimeTypeOf, - parse: () => parse, - inferredRuntimeType: () => inferredRuntimeType, - generateNative: () => generateNative, - generate: () => generate, - eraseFunctionTypes: () => eraseFunctionTypes, - compileWireFile: () => compileWireFile, - compileNativeWireFile: () => compileNativeWireFile, - compile: () => compile, - ParseError: () => ParseError, - NativeCompileError: () => NativeCompileError, - Lexer: () => Lexer, - LexError: () => LexError -}); -module.exports = __toCommonJS(exports_src); - -// ../../packages/compiler/src/tokenizer.ts -class LexError extends Error { -} -var isWs = (c) => c === " " || c === "\t" || c === ` -` || c === "\r"; -var isIdentStart = (c) => /[A-Za-z_]/.test(c); -var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c); - -class Lexer { - src; - pos = 0; - constructor(src) { - this.src = src; - } - skipTrivia() { - const { src } = this; - while (this.pos < src.length) { - const c = src[this.pos]; - if (isWs(c)) { - this.pos++; - continue; - } - if (c === "/" && src[this.pos + 1] === "/") { - while (this.pos < src.length && src[this.pos] !== ` -`) - this.pos++; - continue; - } - break; - } - } - next() { - this.skipTrivia(); - const { src } = this; - const pos = this.pos; - if (pos >= src.length) - return { type: "eof", value: "", pos }; - const c = src[pos]; - switch (c) { - case "{": - this.pos++; - return { type: "lbrace", value: c, pos }; - case "}": - this.pos++; - return { type: "rbrace", value: c, pos }; - case "(": - this.pos++; - return { type: "lparen", value: c, pos }; - case ")": - this.pos++; - return { type: "rparen", value: c, pos }; - case "@": - this.pos++; - return { type: "at", value: c, pos }; - case "=": - this.pos++; - return { type: "eq", value: c, pos }; - case ":": - this.pos++; - return { type: "colon", value: c, pos }; - case ",": - this.pos++; - return { type: "comma", value: c, pos }; - case '"': - case "'": - return this.readString(c, pos); - } - if (isIdentStart(c)) { - let v = ""; - while (this.pos < src.length && isIdentPart(src[this.pos])) - v += src[this.pos++]; - return { type: "ident", value: v, pos }; - } - throw new LexError(`Unexpected character '${c}' at offset ${pos} (line ${this.lineAt(pos)})`); - } - peek() { - const save = this.pos; - const t = this.next(); - this.pos = save; - return t; - } - readString(quote, pos) { - const { src } = this; - let v = ""; - this.pos++; - while (this.pos < src.length) { - const c = src[this.pos++]; - if (c === "\\") { - const n = src[this.pos++]; - v += n === "n" ? ` -` : n === "t" ? "\t" : n; - continue; - } - if (c === quote) - return { type: "string", value: v, pos }; - v += c; - } - throw new LexError(`Unterminated string at offset ${pos}`); - } - readPath() { - this.skipTrivia(); - const { src } = this; - let v = ""; - while (this.pos < src.length && !isWs(src[this.pos]) && src[this.pos] !== "{") { - v += src[this.pos++]; - } - if (!v) - throw new LexError(`Expected a path at offset ${this.pos}`); - return v; - } - readToLineEnd() { - const { src } = this; - let v = ""; - while (this.pos < src.length && src[this.pos] !== ` -`) - v += src[this.pos++]; - return v.trim(); - } - readPropInitializer() { - const { src } = this; - let value = ""; - let square = 0; - let brace = 0; - let paren = 0; - let quote = null; - const beginsPropDeclaration = (position) => { - let cursor = position; - while (cursor < src.length && (src[cursor] === " " || src[cursor] === "\t")) - cursor++; - if (!isIdentStart(src[cursor] ?? "")) - return false; - cursor++; - while (cursor < src.length && isIdentPart(src[cursor])) - cursor++; - while (cursor < src.length && (src[cursor] === " " || src[cursor] === "\t")) - cursor++; - return src[cursor] === "=" || src[cursor] === ":"; - }; - while (this.pos < src.length) { - const c = src[this.pos]; - if (quote) { - value += c; - this.pos++; - if (c === "\\" && this.pos < src.length) - value += src[this.pos++]; - else if (c === quote) - quote = null; - continue; - } - if (c === '"' || c === "'" || c === "`") { - quote = c; - value += c; - this.pos++; - continue; - } - if (c === "[") - square++; - else if (c === "]" && square > 0) - square--; - else if (c === "{") - brace++; - else if (c === "}" && brace > 0) - brace--; - else if (c === "(") - paren++; - else if (c === ")" && paren > 0) - paren--; - const topLevel = square === 0 && brace === 0 && paren === 0; - if (topLevel) { - if (c === ` -` || c === "\r" || c === "}") - break; - if ((c === " " || c === "\t") && beginsPropDeclaration(this.pos)) - break; - } - value += c; - this.pos++; - } - const result = value.trim(); - if (!result) - throw new LexError(`Expected a prop initializer at offset ${this.pos}`); - return result; - } - readTypeAnnotation() { - const { src } = this; - let value = ""; - let angle = 0; - let square = 0; - let brace = 0; - let paren = 0; - let quote = null; - while (this.pos < src.length) { - const c = src[this.pos]; - if (quote) { - value += c; - this.pos++; - if (c === "\\" && this.pos < src.length) - value += src[this.pos++]; - else if (c === quote) - quote = null; - continue; - } - if (c === '"' || c === "'" || c === "`") { - quote = c; - value += c; - this.pos++; - continue; - } - if (c === "<") - angle++; - else if (c === ">" && angle > 0) - angle--; - else if (c === "[") - square++; - else if (c === "]" && square > 0) - square--; - else if (c === "{") - brace++; - else if (c === "}" && brace > 0) - brace--; - else if (c === "(") - paren++; - else if (c === ")" && paren > 0) - paren--; - if (angle === 0 && square === 0 && brace === 0 && paren === 0) { - if (c === "=") { - this.pos++; - const type2 = value.trim(); - if (!type2) - throw new LexError(`Expected a type annotation at offset ${this.pos}`); - return { type: type2, hasDefault: true }; - } - if (c === ` -` || c === "\r") - break; - } - value += c; - this.pos++; - } - const type = value.trim(); - if (!type) - throw new LexError(`Expected a type annotation at offset ${this.pos}`); - return { type, hasDefault: false }; - } - readBalancedBraces() { - this.skipTrivia(); - const { src } = this; - if (src[this.pos] !== "{") { - throw new LexError(`Expected '{' at offset ${this.pos}`); - } - const start = this.pos + 1; - let depth = 0; - let i = this.pos; - let str = null; - for (;i < src.length; i++) { - const c = src[i]; - if (str) { - if (c === "\\") { - i++; - continue; - } - if (c === str) - str = null; - continue; - } - if (c === '"' || c === "'" || c === "`") { - str = c; - continue; - } - if (c === "{") - depth++; - else if (c === "}") { - depth--; - if (depth === 0) { - this.pos = i + 1; - return src.slice(start, i); - } - } - } - throw new LexError(`Unbalanced braces starting at offset ${this.pos}`); - } - lineAt(pos) { - let line = 1; - for (let i = 0;i < pos && i < this.src.length; i++) { - if (this.src[i] === ` -`) - line++; - } - return line; - } -} - -// ../../packages/compiler/src/types.ts -function runtimeTypeOf(annotation) { - if (!annotation) - return "unknown"; - const type = annotation.trim().replace(/^readonly\s+/, ""); - if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) - return "string"; - if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) - return "number"; - if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) - return "boolean"; - if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type)) - return "bigint"; - if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type)) - return "array"; - if (/^(?:Record\s*<|object\b|\{)/.test(type)) - return "object"; - if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type)) - return "function"; - return "unknown"; -} -function inferredRuntimeType(expression) { - const value = expression.trim(); - if (/^["'`]/.test(value)) - return "string"; - if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) - return "number"; - if (/^(?:true|false)$/.test(value)) - return "boolean"; - if (/^-?\d+n$/.test(value)) - return "bigint"; - if (value.startsWith("[")) - return "array"; - if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value)) - return "object"; - if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) { - return "function"; - } - return "unknown"; -} -function validateTypedInitializer(name, annotation, expression) { - if (!annotation || expression.trim() === "undefined" || expression.trim() === "null") - return null; - const expected = runtimeTypeOf(annotation); - const actual = inferredRuntimeType(expression); - if (expected === "unknown" || actual === "unknown" || expected === actual) - return null; - return `${name} is declared as ${annotation}, but its initializer is ${actual}`; -} -function eraseFunctionTypes(source) { - return source.replace(/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g, (_whole, open, params, close, _returnType, brace) => { - const plainParams = params.split(",").map((param) => param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim()).join(", "); - return `${open}${plainParams}${close}${brace}`; - }); -} - -// ../../packages/compiler/src/parser.ts -var VOID_ELEMENTS = new Set([ - "area", - "base", - "br", - "col", - "embed", - "hr", - "img", - "input", - "link", - "meta", - "param", - "source", - "track", - "wbr" -]); - -class ParseError extends Error { -} -function parseSeoBlock(body) { - const out = {}; - const pair = /([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g; - for (const match of body.matchAll(pair)) { - const key = match[1]; - const rawValue = match[2] ?? match[3] ?? match[4] ?? ""; - out[key] = unescapeSeoValue(rawValue.trim()); - } - return out; -} -function unescapeSeoValue(value) { - return value.replace(/\\(["'\\nrt])/g, (_match, ch) => { - if (ch === "n") - return ` -`; - if (ch === "r") - return "\r"; - if (ch === "t") - return "\t"; - return ch; - }); -} -function parse(source) { - const lx = new Lexer(source); - const imports = []; - const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y; - while (true) { - while (/\s/u.test(source[lx.pos] ?? "")) - lx.pos++; - if (source.startsWith("//", lx.pos)) { - while (lx.pos < source.length && source[lx.pos] !== ` -`) - lx.pos++; - continue; - } - importPattern.lastIndex = lx.pos; - const statement = importPattern.exec(source); - if (!statement) - break; - imports.push(statement[0].trim()); - lx.pos = importPattern.lastIndex; - } - const expect = (type) => { - const t = lx.next(); - if (t.type !== type) { - throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`); - } - return t; - }; - const expectKeyword = (kw) => { - const t = lx.next(); - if (t.type !== "ident" || t.value !== kw) { - throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`); - } - }; - try { - const opener = lx.next(); - if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) { - throw new ParseError(`Expected 'page', 'component', or 'layout' but got '${opener.value || opener.type}' at offset ${opener.pos}`); - } - const kind = opener.value; - const name = expect("ident").value; - expect("lbrace"); - let layout; - const props = []; - const types = []; - const states = []; - const seo = {}; - const view = []; - const styles = []; - const functions = []; - const dataApis = []; - const modeFunctions = []; - const lifecycle = {}; - const watches = []; - const apis = []; - const realtimes = []; - while (lx.peek().type !== "rbrace") { - const kw = lx.peek(); - if (kw.type === "eof") - throw new ParseError(`Unexpected end of input inside ${kind}`); - if (kw.type !== "ident") { - throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`); - } - switch (kw.value) { - case "layout": { - lx.next(); - expect("eq"); - layout = expect("string").value; - break; - } - case "props": { - lx.next(); - expect("lbrace"); - while (lx.peek().type !== "rbrace") { - const t = lx.peek(); - if (t.type === "eof") - throw new ParseError("Unexpected end of input inside props"); - if (t.type !== "ident") { - throw new ParseError(`Expected a prop name at offset ${t.pos}`); - } - const pName = expect("ident").value; - let valueType; - let hasDefault = false; - if (lx.peek().type === "colon") { - lx.next(); - const annotation = lx.readTypeAnnotation(); - valueType = annotation.type; - hasDefault = annotation.hasDefault; - } else { - expect("eq"); - hasDefault = true; - } - const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined"; - props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue }); - } - expect("rbrace"); - break; - } - case "state": { - lx.next(); - const sName = expect("ident").value; - let valueType; - if (lx.peek().type === "colon") { - lx.next(); - const annotation = lx.readTypeAnnotation(); - valueType = annotation.type; - if (!annotation.hasDefault) { - throw new ParseError(`State '${sName}' requires an initializer`); - } - } else { - expect("eq"); - } - states.push({ name: sName, valueType, expr: lx.readToLineEnd() }); - break; - } - case "types": { - lx.next(); - types.push(lx.readBalancedBraces()); - break; - } - case "view": { - lx.next(); - expect("lbrace"); - const { nodes, endPos } = parseHtmlView(lx.src, lx.pos); - view.push(...nodes); - lx.pos = endPos; - expect("rbrace"); - break; - } - case "seo": { - lx.next(); - Object.assign(seo, parseSeoBlock(lx.readBalancedBraces())); - break; - } - case "api": { - lx.next(); - const method = expect("ident").value.toUpperCase(); - const path = lx.readPath(); - const body = lx.readBalancedBraces(); - apis.push({ method, path, body }); - break; - } - case "ssr": - case "client": { - const mode = kw.value === "ssr" ? "ssr" : "client"; - lx.next(); - expect("lbrace"); - while (lx.peek().type !== "rbrace") { - const member = lx.peek(); - if (member.type === "eof") { - throw new ParseError(`Unexpected end of input inside ${mode} block`); - } - if (member.type !== "ident") { - throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`); - } - switch (member.value) { - case "api": { - lx.next(); - const name2 = expect("ident").value; - const method = expect("ident").value.toUpperCase(); - const path = lx.readPath(); - const body = lx.readBalancedBraces(); - dataApis.push({ mode, name: name2, method, path, body }); - break; - } - case "functions": { - lx.next(); - modeFunctions.push({ mode, body: lx.readBalancedBraces() }); - break; - } - default: - throw new ParseError(`Unknown ${mode} member '${member.value}' at offset ${member.pos}`); - } - } - expect("rbrace"); - break; - } - case "realtime": { - lx.next(); - const rName = expect("ident").value; - expect("lbrace"); - const handlers = []; - while (lx.peek().type !== "rbrace") { - expectKeyword("on"); - const event = expect("ident").value; - expect("lparen"); - const args = []; - while (lx.peek().type !== "rparen") { - args.push(expect("ident").value); - if (lx.peek().type === "comma") - lx.next(); - } - expect("rparen"); - handlers.push({ event, args, body: lx.readBalancedBraces() }); - } - expect("rbrace"); - realtimes.push({ name: rName, handlers }); - break; - } - case "style": { - lx.next(); - styles.push(lx.readBalancedBraces()); - break; - } - case "lifecycle": { - lx.next(); - expect("lbrace"); - while (lx.peek().type !== "rbrace") { - const hook = lx.peek(); - if (hook.type === "eof") { - throw new ParseError("Unexpected end of input inside lifecycle block"); - } - if (hook.type !== "ident") { - throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`); - } - if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") { - throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`); - } - const hookName = hook.value; - lx.next(); - if (lifecycle[hookName] !== undefined) { - throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`); - } - lifecycle[hookName] = lx.readBalancedBraces(); - } - expect("rbrace"); - break; - } - case "watch": { - lx.next(); - const stateName = expect("ident").value; - const body = lx.readBalancedBraces(); - watches.push({ - state: stateName, - body - }); - break; - } - case "functions": { - lx.next(); - functions.push(lx.readBalancedBraces()); - break; - } - default: - throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`); - } - } - expect("rbrace"); - const declaredStates = new Set(states.map((state) => state.name)); - for (const watcher of watches) { - if (!declaredStates.has(watcher.state)) { - throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`); - } - } - for (const prop of props) { - const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default); - if (problem) - throw new ParseError(problem); - } - for (const state of states) { - const problem = validateTypedInitializer(`State '${state.name}'`, state.valueType, state.expr); - if (problem) - throw new ParseError(problem); - } - return { - type: "page", - imports, - kind, - name, - layout, - props, - types, - states, - seo, - view, - styles, - functions, - dataApis, - modeFunctions, - lifecycle, - watches, - apis, - realtimes - }; - } catch (err) { - if (err instanceof LexError) - throw new ParseError(err.message); - throw err; - } -} -function parseHtmlView(src, pos) { - let i = pos; - const isNameStart = (c) => /[A-Za-z_]/.test(c); - const isTagNamePart = (c) => /[A-Za-z0-9_$:.-]/.test(c); - const isWs2 = (c) => c === " " || c === "\t" || c === ` -` || c === "\r"; - const fail = (msg) => { - throw new ParseError(`${msg} at offset ${i}`); - }; - const skipWs = () => { - while (i < src.length && isWs2(src[i])) - i++; - }; - const readInterpolation = () => { - const start = i; - let depth = 0; - for (;i < src.length; i++) { - if (src[i] === "{") - depth++; - else if (src[i] === "}" && --depth === 0) { - i++; - return src.slice(start, i); - } - } - return fail("Unterminated `{` interpolation in view"); - }; - const readQuoted = () => { - const quote = src[i]; - if (quote !== '"' && quote !== "'") - return fail("Expected a quoted attribute value"); - i++; - const start = i; - while (i < src.length && src[i] !== quote) - i++; - if (i >= src.length) - return fail("Unterminated attribute value"); - const value = src.slice(start, i); - i++; - return value; - }; - const readTagName = () => { - if (i >= src.length || !isNameStart(src[i])) { - return fail("Expected a tag name"); - } - const start = i++; - while (i < src.length && isTagNamePart(src[i])) { - i++; - } - return src.slice(start, i); - }; - const readAttributeName = () => { - if (i >= src.length) { - return fail("Expected an attribute name"); - } - const start = i; - while (i < src.length) { - const char = src[i]; - const next = src[i + 1]; - if (char === "=" || char === ">" || char === '"' || char === "'" || char === " " || char === "\t" || char === ` -` || char === "\r" || char === "/" && next === ">") { - break; - } - i++; - } - if (i === start) { - return fail("Expected an attribute name"); - } - return src.slice(start, i); - }; - const parseTag = () => { - i++; - const tag = readTagName(); - const attrs = []; - for (;; ) { - skipWs(); - const c = src[i]; - if (c === undefined) - return fail(`Unterminated <${tag}> tag`); - if (c === ">") { - i++; - break; - } - if (c === "/" && src[i + 1] === ">") { - i += 2; - return { type: "element", tag, attrs, children: [] }; - } - if (c === "@") { - i++; - const name2 = readAttributeName(); - skipWs(); - if (src[i] !== "=") - return fail(`Expected '=' after @${name2}`); - i++; - skipWs(); - attrs.push({ name: name2, value: readQuoted(), event: true }); - continue; - } - const name = readAttributeName(); - skipWs(); - if (src[i] === "=") { - i++; - skipWs(); - attrs.push({ name, value: readQuoted(), event: false }); - } else { - attrs.push({ name, value: "", event: false, boolean: true }); - } - } - if (VOID_ELEMENTS.has(tag.toLowerCase())) { - return { type: "element", tag, attrs, children: [] }; - } - const children = parseNodeList("element"); - if (src[i] !== "<" || src[i + 1] !== "/") - return fail(`Expected `); - i += 2; - skipWs(); - const close = readTagName(); - if (close !== tag) - return fail(`Mismatched , expected `); - skipWs(); - if (src[i] !== ">") - return fail(`Expected '>' to close `); - i++; - return { type: "element", tag, attrs, children }; - }; - const EACH_HEADER = /^\{#each\s+([\s\S]+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\}$/; - function parseEach() { - const header = readInterpolation(); - const m = EACH_HEADER.exec(header); - if (!m) - return fail(`Invalid {#each …} header: ${header}`); - const list = m[1].trim(); - const item = m[2]; - const index = m[3]; - const body = parseNodeList("each"); - let empty = []; - if (src.startsWith("{:empty}", i)) { - i += "{:empty}".length; - empty = parseNodeList("each"); - } - if (!src.startsWith("{/each}", i)) - return fail("Expected `{/each}` to close `{#each}`"); - i += "{/each}".length; - return { type: "each", list, item, index, body, empty }; - } - function parseIf() { - const header = readInterpolation(); - const m = /^\{#if\s+([\s\S]+?)\s*\}$/.exec(header); - if (!m) - return fail(`Invalid {#if …} header: ${header}`); - const branches = [ - { cond: m[1].trim(), body: parseNodeList("if") } - ]; - for (;; ) { - if (src.startsWith("{:else if", i)) { - const h = readInterpolation(); - const mm = /^\{:else if\s+([\s\S]+?)\s*\}$/.exec(h); - if (!mm) - return fail(`Invalid {:else if …}: ${h}`); - branches.push({ cond: mm[1].trim(), body: parseNodeList("if") }); - continue; - } - if (src.startsWith("{:else}", i)) { - i += "{:else}".length; - branches.push({ cond: null, body: parseNodeList("if") }); - continue; - } - break; - } - if (!src.startsWith("{/if}", i)) - return fail("Expected `{/if}` to close `{#if}`"); - i += "{/if}".length; - return { type: "if", branches }; - } - function parseNodeList(mode) { - const nodes2 = []; - let text = ""; - const flush = () => { - if (text.length > 0) { - nodes2.push({ type: "text", value: text }); - text = ""; - } - }; - for (;; ) { - if (i >= src.length) { - return mode === "root" ? fail("Unexpected end of view (missing `}`)") : fail("Unclosed block"); - } - const c = src[i]; - if (c === "<") { - const next = src[i + 1]; - if (next === "/") { - flush(); - break; - } - if (src.startsWith("", i + 4); - i = end === -1 ? src.length : end + 3; - continue; - } - if (next !== undefined && (isNameStart(next) || next === "!")) { - flush(); - nodes2.push(parseTag()); - continue; - } - text += c; - i++; - continue; - } - if (c === "{") { - if (src.startsWith("{#each", i)) { - flush(); - nodes2.push(parseEach()); - continue; - } - if (src.startsWith("{#if", i)) { - flush(); - nodes2.push(parseIf()); - continue; - } - if (mode === "each" && (src.startsWith("{:empty}", i) || src.startsWith("{/each}", i))) { - flush(); - break; - } - if (mode === "if" && (src.startsWith("{:else", i) || src.startsWith("{/if}", i))) { - flush(); - break; - } - text += readInterpolation(); - continue; - } - if (c === "}" && mode === "root") { - flush(); - break; - } - text += c; - i++; - } - return nodes2; - } - const nodes = parseNodeList("root"); - return { nodes, endPos: i }; -} - -// ../../packages/compiler/src/codegen.ts -var import_node_buffer = require("node:buffer"); +"use strict"; +// Generated by scripts/build-editor-compiler.mjs. Do not edit directly. +const __nodeRequire = require; +const __path = __nodeRequire("node:path"); +const __modules = { +"packages/compiler/src/codegen.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +/** + * Code generation: lower a `.wrn` AST to TypeScript that targets the framework's + * existing primitives. + * + * state -> a `data-scope` declaration consumed by the runtime + * view -> an HTML string returned by a page component + * @event="..." -> data-on-="..." + * "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime) + * api="" -> SSR/client data binding declared in a mode block + * ssrGet/ssrText -> legacy server-side API fetch + render + * csrGet/csrText -> legacy browser-side API fetch + render + * style -> an inline page stylesheet + * functions -> server-only helpers for API/realtime code + * api M /p {b} -> export const M = async (ctx) => { b } + * realtime {..} -> export const websocket = { evt(ws, ...args) { b } } + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.generate = generate; +exports.parseForExpr = parseForExpr; +const node_buffer_1 = require("node:buffer"); +const parser_ts_1 = require("./parser.js"); +const types_ts_1 = require("./types.js"); function isComponentTag(tag) { - return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); + return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); } +/** Escape a value placed inside a double-quoted HTML attribute. */ function attrEscape(value) { - return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); } +/** Make HTML safe to embed inside a JS template literal. */ function templateEscape(html) { - return html.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); + return html.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); } function styleEscape(css) { - return css.replace(/<\/style/gi, "<\\/style"); + return css.replace(/<\/style/gi, "<\\/style"); } function attrValue(attrs, name) { - return attrs.find((attr) => !attr.event && attr.name === name)?.value; + return attrs.find((attr) => !attr.event && attr.name === name)?.value; } function renderAttr(attr) { - if (attr.event) - return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; - switch (attr.name) { - case "api": - case "ssrGet": - case "ssrText": - case "csrGet": - case "csrText": - return ""; - default: - return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`; - } + if (attr.event) + return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; + switch (attr.name) { + case "api": + case "ssrGet": + case "ssrText": + case "csrGet": + case "csrText": + return ""; + default: + return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`; + } } function eventAttribute(name) { - if (name.startsWith("window:")) { - return `data-on-window-${name.slice("window:".length)}`; - } - if (name.startsWith("document:")) { - return `data-on-document-${name.slice("document:".length)}`; - } - 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}`; + if (name.startsWith("window:")) { + return `data-on-window-${name.slice("window:".length)}`; + } + if (name.startsWith("document:")) { + return `data-on-document-${name.slice("document:".length)}`; + } + 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 reactiveAttrValue(raw, reactive) { - let found = false; - const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner) => { - const expr = inner.trim(); - if (!exprRefsState(expr, reactive.stateNames)) - return whole; - found = true; - try { - const result = new Function("with(this){return (" + expr + ");}").call(reactive.scope); - return result == null ? "" : String(result); - } catch { - return whole; - } - }); - return found ? value : null; + let found = false; + const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner) => { + const expr = inner.trim(); + if (!exprRefsState(expr, reactive.stateNames)) + return whole; + found = true; + try { + const result = new Function("with(this){return (" + expr + ");}").call(reactive.scope); + return result == null ? "" : String(result); + } + catch { + return whole; + } + }); + return found ? value : null; } function renderAttrs(attrs, csrId, reactive = null) { - let bindIndex = 0; - const rendered = attrs.map((attr) => { - const base = renderAttr(attr); - if (!reactive || attr.event || attr.boolean || !base || !attr.value.includes("{")) - return base; - const initial = reactiveAttrValue(attr.value, reactive); - if (initial === null) - return base; - const marker = JSON.stringify([attr.name, attr.value]); - return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`; - }).join(""); - return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered; + let bindIndex = 0; + const rendered = attrs + .map((attr) => { + const base = renderAttr(attr); + if (!reactive || attr.event || attr.boolean || !base || !attr.value.includes("{")) + return base; + const initial = reactiveAttrValue(attr.value, reactive); + if (initial === null) + return base; + const marker = JSON.stringify([attr.name, attr.value]); + return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`; + }) + .join(""); + return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered; } +/** + * Replace i18n text sugar `{t:key}` with a `` marker the + * runtime resolves server-side. Other `{expr}` mustaches are left untouched. + */ function substituteTMarkers(text) { - return text.replace(/\{t:([^{}]+)\}/g, (_m, key) => ``); + return text.replace(/\{t:([^{}]+)\}/g, (_m, key) => ``); } +/** Escape a value for safe embedding in HTML text. */ function htmlTextEscape(value) { - return value.replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">"); + return value.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">")); } +/** + * Evaluate a page's `state` seed expressions at compile time to obtain the + * initial SSR values used to bake `data-text` spans. Seeds may reference + * earlier ones; anything that can't be evaluated becomes `undefined`. + */ function evalStateSeeds(states) { - const scope = {}; - for (const s of states) { - try { - scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope); - } catch { - scope[s.name] = undefined; + const scope = {}; + for (const s of states) { + try { + scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope); + } + catch { + scope[s.name] = undefined; + } } - } - return scope; + return scope; } +/** + * Page text compilation: resolve `{t:key}` i18n markers, then bake state + * interpolations (`{count}`, `{count * 2}`) into `data-text` spans carrying the + * evaluated initial value — so no-JS clients see real content and the reactive + * runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are + * left as literal client mustaches. + */ function substituteReactiveText(raw, reactive) { - const text = substituteTMarkers(raw); - if (!reactive || reactive.stateNames.size === 0) - return text; - return text.replace(/\{([^{}]+)\}/g, (whole, inner) => { - const expr = inner.trim(); - if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) - return whole; - let value; - try { - value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); - } catch { - return whole; - } - const baked = htmlTextEscape(value == null ? "" : String(value)); - return `${baked}`; - }); + const text = substituteTMarkers(raw); + if (!reactive || reactive.stateNames.size === 0) + return text; + return text.replace(/\{([^{}]+)\}/g, (whole, inner) => { + const expr = inner.trim(); + if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) + return whole; + let value; + try { + value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); + } + catch { + return whole; // can't evaluate → keep as a client-only mustache + } + const baked = htmlTextEscape(value == null ? "" : String(value)); + return `${baked}`; + }); } +/** + * Bake a loop-body text run into template-literal source: static text is escaped + * for the literal, `{expr}` becomes `${__wrnexusEscapeHtml(expr)}` (server-rendered, + * escaped), and `{t:key}` becomes a `data-t` marker resolved later by translateHtml. + */ function bakeLoopText(raw) { - let out = ""; - let last = 0; - let m; - const re = /\{([^{}]+)\}/g; - while (m = re.exec(raw)) { - out += escLit(raw.slice(last, m.index)); - const expr = m[1].trim(); - if (expr.startsWith("t:")) { - out += escLit(``); - } else { - out += "${__wrnexusEscapeHtml(" + expr + ")}"; + let out = ""; + let last = 0; + let m; + const re = /\{([^{}]+)\}/g; + while ((m = re.exec(raw))) { + out += escLit(raw.slice(last, m.index)); + const expr = m[1].trim(); + if (expr.startsWith("t:")) { + out += escLit(``); + } + else { + out += "${__wrnexusEscapeHtml(" + expr + ")}"; + } + last = m.index + m[0].length; } - last = m.index + m[0].length; - } - return out + escLit(raw.slice(last)); + return out + escLit(raw.slice(last)); } +/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */ function bakeLoopAttr(raw) { - if (!raw.includes("{")) - return escLit(attrEscape(raw)); - let out = ""; - let last = 0; - let m; - const re = /\{([^{}]+)\}/g; - while (m = re.exec(raw)) { - out += escLit(attrEscape(raw.slice(last, m.index))); - out += "${__wrnexusEscapeHtml(" + m[1].trim() + ")}"; - last = m.index + m[0].length; - } - return out + escLit(attrEscape(raw.slice(last))); + if (!raw.includes("{")) + return escLit(attrEscape(raw)); + let out = ""; + let last = 0; + let m; + const re = /\{([^{}]+)\}/g; + while ((m = re.exec(raw))) { + out += escLit(attrEscape(raw.slice(last, m.index))); + out += "${__wrnexusEscapeHtml(" + m[1].trim() + ")}"; + last = m.index + m[0].length; + } + return out + escLit(attrEscape(raw.slice(last))); } +/** Render one loop-body node to template-literal source (nested loops inline). */ function renderLoopBody(node) { - if (node.type === "text") { - return bakeLoopText(node.value); - } - if (node.type === "each") { - return compileEachExpr(node); - } - if (node.type === "if") { - return compileIfExpr(node); - } - const componentTag = isComponentTag(node.tag); - const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => { - const name = attr.event ? eventAttribute(attr.name) : attr.name; - if (attr.boolean) { - return escLit(` ${name}`); + if (node.type === "text") { + return bakeLoopText(node.value); } - return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`); - }).join(""); - const inner = node.children.map(renderLoopBody).join(""); - if (componentTag) { - return escLit(`
") + inner + escLit("
"); - } - if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { - return escLit(`<${node.tag}`) + attrs + escLit(">"); - } - return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(``); -} -function compileEachExpr(node) { - const item = node.item; - const index = node.index ?? "__wi"; - const body = node.body.map(renderLoopBody).join(""); - const empty = node.empty.map(renderLoopBody).join(""); - return "${(() => { const __wl = Array.isArray(" + node.list + ") ? (" + node.list + ") : []; return __wl.length ? __wl.map((" + item + ", " + index + ") => `" + body + '`).join("") : `' + empty + "`; })()}"; -} -function compileIfExpr(node) { - let expr = "``"; - for (let k = node.branches.length - 1;k >= 0; k--) { - const b = node.branches[k]; - const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`"; - expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr; - } - return "${" + expr + "}"; -} -function collectControlExprs(nodes, out = []) { - for (const node of nodes) { if (node.type === "each") { - out.push(node.list); - collectControlExprs(node.body, out); - collectControlExprs(node.empty, out); - } else if (node.type === "if") { - for (const b of node.branches) { - if (b.cond) - out.push(b.cond); - collectControlExprs(b.body, out); - } - } else if (node.type === "element") { - collectControlExprs(node.children, out); - } - } - return out; -} -function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive = null) { - if (node.type === "text") - return substituteReactiveText(node.value, reactive); - if (node.type === "each" || node.type === "if") { - loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); - return `\x00WRNEACH${loops.length - 1}\x00`; - } - if (isComponentTag(node.tag)) { - return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive); - } - const apiName = attrValue(node.attrs, "api"); - const apiBinding = apiName ? apiBindings.get(apiName) : undefined; - if (apiName && !apiBinding) { - throw new Error(`Unknown .wrn api binding "${apiName}"`); - } - const ssrGet = attrValue(node.attrs, "ssrGet"); - const ssrText = attrValue(node.attrs, "ssrText"); - const csrGet = attrValue(node.attrs, "csrGet"); - const csrText = attrValue(node.attrs, "csrText"); - const csrId = apiBinding?.mode === "client" ? csrMarker(csrBindings, renderBinding(apiBinding)) : csrGet && csrText ? csrMarker(csrBindings, { - method: "GET", - path: apiRoutePath(csrGet), - body: expressionBody(csrText), - helpers: "" - }) : undefined; - if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { - return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>`; - } - const inner = apiBinding?.mode === "ssr" ? ssrMarker(ssrBindings, renderBinding(apiBinding)) : ssrGet && ssrText ? ssrMarker(ssrBindings, { - method: "GET", - path: apiRoutePath(ssrGet), - body: expressionBody(ssrText), - helpers: "" - }) : node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join(""); - return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}`; -} -function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) { - const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => renderPageComponentAttr(attr, loops)).join(""); - const inner = node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join(""); - return `
${inner}
`; -} -function renderNestedComponentInvocation(node, ctx) { - let bindIndex = 0; - const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => { - if (attr.event) { - return escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`); - } - if (attr.boolean) { - return ` ${attr.name}`; - } - const wholeExpression = wholeAttributeExpression(attr.value); - const compiledValue = wholeExpression ? `\${__wireProp(${ctx.resolveExpr(wholeExpression)})}` : compileAttrValue(attr.value, ctx); - const rendered = ` ${attr.name}="${compiledValue}"`; - if (wholeExpression || !attr.value.includes("{") || !exprRefsState(attr.value, ctx.stateNames)) { - return rendered; - } - const marker = attrEscape(JSON.stringify([attr.name, attr.value])); - return rendered + ` data-wrn-bind-${bindIndex++}="${escLit(marker)}"`; - }).join(""); - const loops = loopVarsOf(node); - const childCtx = loops.length > 0 ? { - ...ctx, - loopVars: new Set([...ctx.loopVars ?? [], ...loops]) - } : ctx; - const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join(""); - return `
${inner}
`; -} -function ssrMarker(bindings, binding) { - const marker = ``; - bindings.push({ marker, ...binding }); - return marker; -} -function csrMarker(bindings, binding) { - const id = String(bindings.length); - bindings.push({ id, ...binding }); - return id; -} -function renderBinding(binding) { - return { - method: binding.method, - path: binding.path, - body: binding.body, - helpers: binding.helpers - }; -} -function hasClientBehavior(nodes) { - return nodes.some((node) => { - if (node.type === "text") - return /\{(?!t:)[^{}]+\}/.test(node.value); - if (node.type === "each") { - return hasClientBehavior(node.body) || hasClientBehavior(node.empty); + return compileEachExpr(node); } if (node.type === "if") { - return node.branches.some((branch) => hasClientBehavior(branch.body)); + return compileIfExpr(node); } - return node.attrs.some((attr) => attr.event || attr.name === "csrGet" || attr.name === "csrText") || hasClientBehavior(node.children); - }); + const componentTag = isComponentTag(node.tag); + const attrs = node.attrs + .filter((attr) => attr.name !== "data-component") + .map((attr) => { + const name = attr.event ? eventAttribute(attr.name) : attr.name; + if (attr.boolean) { + return escLit(` ${name}`); + } + return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`); + }) + .join(""); + const inner = node.children.map(renderLoopBody).join(""); + if (componentTag) { + return (escLit(`
") + + inner + + escLit("
")); + } + if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) { + return escLit(`<${node.tag}`) + attrs + escLit(">"); + } + return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(``); +} +/** + * Compile a `{#each list as item}` block to a `${…}` template-literal interpolation + * that iterates the (server-evaluated) list and joins the per-item body. `list` is a + * JS expression evaluated where `ssr` data bindings are in scope as raw named values. + */ +function compileEachExpr(node) { + const item = node.item; + const index = node.index ?? "__wi"; + const body = node.body.map(renderLoopBody).join(""); + const empty = node.empty.map(renderLoopBody).join(""); + return ("${(() => { const __wl = Array.isArray(" + + node.list + + ") ? (" + + node.list + + ") : []; return __wl.length ? __wl.map((" + + item + + ", " + + index + + ") => `" + + body + + '`).join("") : `' + + empty + + "`; })()}"); +} +/** + * Compile a `{#if}` block to a `${…}` template-literal interpolation: a nested ternary + * that renders the first truthy branch's body (or the `{:else}` body, or "" when neither). + * Conditions are JS expressions evaluated in the surrounding server scope. + */ +function compileIfExpr(node) { + let expr = "``"; // no matching branch → empty string + for (let k = node.branches.length - 1; k >= 0; k--) { + const b = node.branches[k]; + const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`"; + expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr; + } + return "${" + expr + "}"; +} +/** + * Collect every server-control expression in a view (recursively): `{#each}` list + * expressions and `{#if}` conditions. Used to wire up raw SSR data consts. + */ +function collectControlExprs(nodes, out = []) { + for (const node of nodes) { + if (node.type === "each") { + out.push(node.list); + collectControlExprs(node.body, out); + collectControlExprs(node.empty, out); + } + else if (node.type === "if") { + for (const b of node.branches) { + if (b.cond) + out.push(b.cond); + collectControlExprs(b.body, out); + } + } + else if (node.type === "element") { + collectControlExprs(node.children, out); + } + } + return out; +} +function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive = null) { + if (node.type === "text") + return substituteReactiveText(node.value, reactive); // {t:key} + state baking + // Server control block (loop / conditional) → a sentinel that survives + // templateEscape, swapped for its real `${…}` code after escaping. + if (node.type === "each" || node.type === "if") { + loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); + return `\x00WRNEACH${loops.length - 1}\x00`; + } + if (isComponentTag(node.tag)) { + return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive); + } + const apiName = attrValue(node.attrs, "api"); + const apiBinding = apiName ? apiBindings.get(apiName) : undefined; + if (apiName && !apiBinding) { + throw new Error(`Unknown .wrn api binding "${apiName}"`); + } + const ssrGet = attrValue(node.attrs, "ssrGet"); + const ssrText = attrValue(node.attrs, "ssrText"); + const csrGet = attrValue(node.attrs, "csrGet"); + const csrText = attrValue(node.attrs, "csrText"); + const csrId = apiBinding?.mode === "client" + ? csrMarker(csrBindings, renderBinding(apiBinding)) + : csrGet && csrText + ? csrMarker(csrBindings, { + method: "GET", + path: apiRoutePath(csrGet), + body: expressionBody(csrText), + helpers: "", + }) + : undefined; + // Void elements (
, , …) have no closing tag and no children. + if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) { + return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>`; + } + const inner = apiBinding?.mode === "ssr" + ? ssrMarker(ssrBindings, renderBinding(apiBinding)) + : ssrGet && ssrText + ? ssrMarker(ssrBindings, { + method: "GET", + path: apiRoutePath(ssrGet), + body: expressionBody(ssrText), + helpers: "", + }) + : node.children + .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}`; +} +function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) { + const attrs = node.attrs + .filter((attr) => attr.name !== "data-component") + .map((attr) => renderPageComponentAttr(attr, loops)) + .join(""); + const inner = node.children + .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + return `
${inner}
`; +} +function renderNestedComponentInvocation(node, ctx) { + let bindIndex = 0; + const attrs = node.attrs + .filter((attr) => attr.name !== "data-component") + .map((attr) => { + if (attr.event) { + return (escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`)); + } + if (attr.boolean) { + return ` ${attr.name}`; + } + const wholeExpression = wholeAttributeExpression(attr.value); + const compiledValue = wholeExpression + ? `\${__wireProp(${ctx.resolveExpr(wholeExpression)})}` + : compileAttrValue(attr.value, ctx); + const rendered = ` ${attr.name}="${compiledValue}"`; + if (wholeExpression || + !attr.value.includes("{") || + !exprRefsState(attr.value, ctx.stateNames)) { + return rendered; + } + const marker = attrEscape(JSON.stringify([attr.name, attr.value])); + return rendered + ` data-wrn-bind-${bindIndex++}="${escLit(marker)}"`; + }) + .join(""); + const loops = loopVarsOf(node); + const childCtx = loops.length > 0 + ? { + ...ctx, + loopVars: new Set([...(ctx.loopVars ?? []), ...loops]), + } + : ctx; + const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join(""); + return `
${inner}
`; +} +function ssrMarker(bindings, binding) { + const marker = ``; + bindings.push({ marker, ...binding }); + return marker; +} +function csrMarker(bindings, binding) { + const id = String(bindings.length); + bindings.push({ id, ...binding }); + return id; +} +function renderBinding(binding) { + return { + method: binding.method, + path: binding.path, + body: binding.body, + helpers: binding.helpers, + }; +} +function hasClientBehavior(nodes) { + return nodes.some((node) => { + // `{t:key}` is i18n sugar resolved server-side — not client reactivity. + if (node.type === "text") + return /\{(?!t:)[^{}]+\}/.test(node.value); + // Server control blocks render on the server; they don't add client reactivity. + if (node.type === "each") { + return hasClientBehavior(node.body) || hasClientBehavior(node.empty); + } + if (node.type === "if") { + return node.branches.some((branch) => hasClientBehavior(branch.body)); + } + return (node.attrs.some((attr) => attr.event || attr.name === "csrGet" || attr.name === "csrText") || + hasClientBehavior(node.children)); + }); } function apiRoutePath(path) { - const trimmed = path.trim(); - if (!trimmed.startsWith("/")) { - throw new Error(`.wrn API paths must start with "/": ${path}`); - } - if (trimmed.includes("\x00") || trimmed.includes("\\") || /(^|\/)\.\.(\/|$)/.test(trimmed)) { - throw new Error(`Unsafe .wrn API path: ${path}`); - } - if (trimmed === "/api" || trimmed.startsWith("/api/")) - return trimmed; - return `/api${trimmed}`; + const trimmed = path.trim(); + if (!trimmed.startsWith("/")) { + throw new Error(`.wrn API paths must start with "/": ${path}`); + } + if (trimmed.includes("\0") || trimmed.includes("\\") || /(^|\/)\.\.(\/|$)/.test(trimmed)) { + throw new Error(`Unsafe .wrn API path: ${path}`); + } + if (trimmed === "/api" || trimmed.startsWith("/api/")) + return trimmed; + return `/api${trimmed}`; } function expressionBody(expr) { - return `return (${expr});`; + return `return (${expr});`; } function dataBody(source) { - const trimmed = source.trim(); - if (!trimmed) - return "return undefined;"; - return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed); + const trimmed = source.trim(); + if (!trimmed) + return "return undefined;"; + return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed); } function modeHelpers(ast, mode, sharedHelpers) { - return [ - sharedHelpers, - ...ast.modeFunctions.filter((block) => block.mode === mode).map((block) => block.body.trim()).filter(Boolean) - ].filter(Boolean).join(` - -`); + return [ + sharedHelpers, + ...ast.modeFunctions + .filter((block) => block.mode === mode) + .map((block) => block.body.trim()) + .filter(Boolean), + ] + .filter(Boolean) + .join("\n\n"); } function apiBindingMap(ast, sharedHelpers) { - const bindings = new Map; - for (const block of ast.dataApis) { - if (bindings.has(block.name)) { - throw new Error(`Duplicate .wrn api binding "${block.name}"`); + const bindings = new Map(); + for (const block of ast.dataApis) { + if (bindings.has(block.name)) { + throw new Error(`Duplicate .wrn api binding "${block.name}"`); + } + bindings.set(block.name, { + mode: block.mode, + method: block.method, + path: apiRoutePath(block.path), + body: dataBody(block.body), + helpers: modeHelpers(ast, block.mode, sharedHelpers), + }); } - bindings.set(block.name, { - mode: block.mode, - method: block.method, - path: apiRoutePath(block.path), - body: dataBody(block.body), - helpers: modeHelpers(ast, block.mode, sharedHelpers) - }); - } - return bindings; + return bindings; } function ssrRuntimeSource() { - return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; + return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; function __wrnexusEscapeHtml(value: unknown): string { return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); } @@ -1383,76 +536,133 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise 0) - out.push(ast.imports.join(` -`)); - const ssrBindings = []; - const csrBindings = []; - const helpers = ast.functions.map((body2) => body2.trim()).filter(Boolean).join(` - -`); - const apiBindings = apiBindingMap(ast, helpers); - const typeSource = ast.types.map((body2) => body2.trim()).filter(Boolean).join(` - -`); - if (typeSource) - out.push(typeSource); - if (helpers) { - out.push(`// --- .wrn functions --- -${helpers}`); - } - out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`); - if (ast.layout) - out.push(`export const layout = ${JSON.stringify(ast.layout)};`); - const reactive = ast.states.length > 0 ? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) } : null; - const loops = []; - let html = ast.view.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive)).join(""); - const styles = ast.styles.map((body2) => body2.trim()).filter(Boolean); - const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view); - if (needsClientRuntime) { - const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__"; - html = `
${html}
`; - } - if (styles.length > 0) { - const css = styles.map(styleEscape).join(` -`); - html = `${html}`; - } - if (csrBindings.length > 0) { - out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`); - } - let body = templateEscape(html); - const dynamicStateScope = ast.states.map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`).join(", "); - const stateType = ast.states.length > 0 ? `{ ${ast.states.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`).join("; ")} }` : "Record"; - loops.forEach((code, idx) => { - body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); - }); - const loopConsts = []; - if (loops.length > 0) { - const lists = collectControlExprs(ast.view); - for (const [name, binding] of apiBindings) { - if (binding.mode !== "ssr") - continue; - if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) - continue; - loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`); +function stableHash(value) { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); } - } - const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || ast.states.some((state) => /\bctx\b/.test(state.expr)); - if (needsSsrRuntime) { - out.push(ssrRuntimeSource()); - out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); - const decls = loopConsts.length > 0 ? loopConsts.join(` -`) + ` -` : ""; - out.push(`export default async function ${ast.name}(ctx: any) { + return (hash >>> 0).toString(36); +} +function hydrationId(ast) { + const shape = JSON.stringify({ + kind: ast.kind, + name: ast.name, + props: ast.props.map((entry) => entry.name), + states: ast.states.map((entry) => entry.name), + computed: ast.computed.map((entry) => entry.name), + view: ast.view, + }); + return `${ast.name}:${stableHash(shape)}`; +} +function hydrationAttribute(ast) { + const strategy = ast.hydrate ?? "load"; + return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"`; +} +function generate(ast) { + if (ast.kind === "component" || ast.kind === "layout") { + return generateComponent(ast); + } + const out = []; + if (ast.imports.length > 0) + out.push(ast.imports.join("\n")); + const ssrBindings = []; + const csrBindings = []; + const helpers = ast.functions + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"); + const apiBindings = apiBindingMap(ast, helpers); + const typeSource = ast.types + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"); + if (typeSource) + out.push(typeSource); + if (helpers) { + out.push(`// --- .wrn functions ---\n${helpers}`); + } + // --- Page metadata / SEO --- + out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`); + if (ast.layout) + out.push(`export const layout = ${JSON.stringify(ast.layout)};`); + out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); + out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`); + out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); + if (Object.keys(ast.security).length > 0) { + out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); + } + // --- View -> default page component --- + const seedScope = evalStateSeeds(ast.states); + for (const entry of ast.computed) { + try { + seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(seedScope); + } + catch { + seedScope[entry.name] = undefined; + } + } + const reactiveNames = [...ast.states.map((entry) => entry.name), ...ast.computed.map((entry) => entry.name)]; + const reactive = reactiveNames.length > 0 + ? { stateNames: new Set(reactiveNames), scope: seedScope } + : null; + const loops = []; + let html = ast.view + .map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + const styles = ast.styles.map((body) => body.trim()).filter(Boolean); + const pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast); + const needsClientRuntime = ast.runtime !== "server" && + (ast.states.length > 0 || ast.computed.length > 0 || hasClientBehavior(ast.view) || pageBehavior !== null); + if (needsClientRuntime) { + const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__"; + html = `
${html}
`; + } + if (styles.length > 0) { + const css = styles.map(styleEscape).join("\n"); + html = `${html}`; + } + if (csrBindings.length > 0) { + out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`); + } + if (pageBehavior) { + out.push(`export const __wrnexusBehavior = ${JSON.stringify(pageBehavior, null, 2)};`); + } + // Escape the static HTML for the template literal, then swap loop sentinels for + // their real `${…}` code (which must NOT be escaped). + let body = templateEscape(html); + const dynamicStateScope = ast.states + .map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`) + .join(", "); + const stateType = ast.states.length > 0 + ? `{ ${ast.states + .map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`) + .join("; ")} }` + : "Record"; + loops.forEach((code, idx) => { + body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); + }); + // Server loops iterate raw SSR data. Declare a named const for every `ssr` data + // binding a loop references, so `{#each as …}` can iterate the real value. + const loopConsts = []; + if (loops.length > 0) { + const lists = collectControlExprs(ast.view); + for (const [name, binding] of apiBindings) { + if (binding.mode !== "ssr") + continue; + if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) + continue; + loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`); + } + } + const needsSsrRuntime = ssrBindings.length > 0 || + loops.length > 0 || + ast.states.some((state) => /\bctx\b/.test(state.expr)); + if (needsSsrRuntime) { + out.push(ssrRuntimeSource()); + out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); + const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : ""; + out.push(`export default async function ${ast.name}(ctx: any) { ${decls} const __state: ${stateType} = { ${dynamicStateScope} }; @@ -1478,8 +688,9 @@ ${css} return await __wrnexusRenderSsrBindings(html, ctx); }`); - } else { - out.push(`export default function ${ast.name}(ctx: any) { + } + else { + out.push(`export default function ${ast.name}(ctx: any) { const __state: ${stateType} = { ${dynamicStateScope} }; const __scopeValue = Object.entries(__state) @@ -1502,395 +713,549 @@ ${css} __scopeValue, ); }`); - } - if (ast.apis.length > 0) { - ast.apis.forEach((api, index) => { - const name = `__wrnexusApi_${api.method}_${index}`; - out.push(`// ${api.method} ${apiRoutePath(api.path)} + } + if (ast.loads.length > 0) { + const serverLoads = ast.loads.filter((entry) => entry.mode === "server"); + const clientLoads = ast.loads.filter((entry) => entry.mode === "client"); + if (serverLoads.length > 0) { + out.push(`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`); + } + if (clientLoads.length > 0) { + out.push(`export const __wrnexusClientLoad = ${JSON.stringify(clientLoads.map((entry) => entry.body))};`); + } + } + if (ast.actions.length > 0) { + for (const action of ast.actions) { + out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`); + } + out.push(`export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`); + } + // --- API blocks -> method handlers --- + if (ast.apis.length > 0) { + ast.apis.forEach((api, index) => { + const name = `__wrnexusApi_${api.method}_${index}`; + out.push(`// ${api.method} ${apiRoutePath(api.path)} const ${name} = async (ctx: any) => {${api.body}};`); - }); - const entries = ast.apis.map((api, index) => ` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`); - out.push(`export const __wrnexusApi = { -${entries.join(` -`)} -};`); - const exported = new Set; - ast.apis.forEach((api, index) => { - if (exported.has(api.method)) - return; - exported.add(api.method); - out.push(`export const ${api.method} = __wrnexusApi_${api.method}_${index};`); - }); - } - if (ast.realtimes.length > 0) { - const handlers = ast.realtimes.flatMap((rt) => rt.handlers.map((h) => { - const params = ["ws", ...h.args].join(", "); - return ` ${h.event}(${params}: any) {${h.body}},`; - })); - out.push(`export const websocket = { -${handlers.join(` -`)} -};`); - } - return out.join(` - -`) + ` -`; + }); + const entries = ast.apis.map((api, index) => ` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`); + out.push(`export const __wrnexusApi = {\n${entries.join("\n")}\n};`); + const exported = new Set(); + ast.apis.forEach((api, index) => { + if (exported.has(api.method)) + return; + exported.add(api.method); + out.push(`export const ${api.method} = __wrnexusApi_${api.method}_${index};`); + }); + } + // --- Realtime blocks -> a websocket export --- + if (ast.realtimes.length > 0) { + const handlers = ast.realtimes.flatMap((rt) => rt.handlers.map((h) => { + const params = ["ws", ...h.args].join(", "); + return ` ${h.event}(${params}: any) {${h.body}},`; + })); + out.push(`export const websocket = {\n${handlers.join("\n")}\n};`); + } + return out.join("\n\n") + "\n"; } +/** Parse a `data-for="item in list [key expr]"` / `"item, i in list [key expr]"` directive. */ function parseForExpr(value) { - const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(value); - if (!m) - return null; - return { item: m[1], index: m[2], list: m[3] }; + const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec(value); + if (!m) + return null; + return { item: m[1], index: m[2], list: m[3].trim(), key: m[4]?.trim() }; } +/** The loop variables a node introduces via `data-for`, if any. */ function loopVarsOf(node) { - if (node.type !== "element") - return []; - const attr = node.attrs.find((a) => !a.event && a.name === "data-for"); - if (!attr) - return []; - const parsed = parseForExpr(attr.value); - return parsed ? [parsed.item, ...parsed.index ? [parsed.index] : []] : []; + if (node.type !== "element") + return []; + const attr = node.attrs.find((a) => !a.event && a.name === "data-for"); + if (!attr) + return []; + const parsed = parseForExpr(attr.value); + return parsed ? [parsed.item, ...(parsed.index ? [parsed.index] : [])] : []; } -var JS_RESERVED = new Set([ - "class", - "for", - "default", - "function", - "return", - "if", - "else", - "new", - "delete", - "typeof", - "in", - "instanceof", - "void", - "do", - "while", - "switch", - "case", - "break", - "continue", - "this", - "super", - "import", - "export", - "extends", - "var", - "let", - "const", - "null", - "true", - "false", - "try", - "catch", - "finally", - "throw", - "yield", - "await", - "enum", - "with", - "debugger" +/** JS reserved words that cannot be used as a plain `const` name. */ +const JS_RESERVED = new Set([ + "class", + "for", + "default", + "function", + "return", + "if", + "else", + "new", + "delete", + "typeof", + "in", + "instanceof", + "void", + "do", + "while", + "switch", + "case", + "break", + "continue", + "this", + "super", + "import", + "export", + "extends", + "var", + "let", + "const", + "null", + "true", + "false", + "try", + "catch", + "finally", + "throw", + "yield", + "await", + "enum", + "with", + "debugger", ]); +/** A JS reference for a prop/state name (reserved words get a `__p_` prefix). */ function safeRef(name) { - return JS_RESERVED.has(name) ? `__p_${name}` : name; + return JS_RESERVED.has(name) ? `__p_${name}` : name; } +/** Escape a literal segment so it is safe inside a JS template literal. */ function escLit(s) { - return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); + return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); } function componentBehavior(ast) { - const functions = eraseFunctionTypes(ast.functions.map((body) => body.trim()).filter(Boolean).join(` - -`)); - const lifecycle = { - ...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}, - ...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {}, - ...ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {} - }; - const watches = ast.watches.map((watch) => ({ - state: watch.state, - body: watch.body.trim() - })); - if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) { - return null; - } - return { - functions, - lifecycle, - watches - }; + const functions = (0, types_ts_1.eraseFunctionTypes)(ast.functions + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n")); + const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() })); + const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean); + const lifecycle = { + ...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}), + ...(ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {}), + ...(ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {}), + }; + const watches = ast.watches.map((watch) => ({ + state: watch.state, + body: watch.body.trim(), + })); + if (!functions && + computed.length === 0 && + effects.length === 0 && + Object.keys(lifecycle).length === 0 && + watches.length === 0) { + return null; + } + return { + functions, + computed, + effects, + lifecycle, + watches, + }; } function behaviorAttribute(behavior) { - if (!behavior) { - return ""; - } - const encoded = import_node_buffer.Buffer.from(JSON.stringify(behavior), "utf8").toString("base64"); - return ` data-wrn-behavior="${encoded}"`; + if (!behavior) { + return ""; + } + const encoded = node_buffer_1.Buffer.from(JSON.stringify(behavior), "utf8").toString("base64"); + return ` data-wrn-behavior="${encoded}"`; } -var INTERP_RE = /\{([^{}]+)\}/g; +const INTERP_RE = /\{([^{}]+)\}/g; function exprRefsState(expr, stateNames) { - for (const name of stateNames) { - if (new RegExp(`\\b${name}\\b`).test(expr)) - return true; - } - return false; + for (const name of stateNames) { + if (new RegExp(`\\b${name}\\b`).test(expr)) + return true; + } + return false; } function viewHasEvents(nodes) { - return nodes.some((node) => { - if (node.type === "text") - return false; - if (node.type === "each") { - return viewHasEvents(node.body) || viewHasEvents(node.empty); - } - if (node.type === "if") { - return node.branches.some((branch) => viewHasEvents(branch.body)); - } - return node.attrs.some((attr) => attr.event) || viewHasEvents(node.children); - }); + return nodes.some((node) => { + if (node.type === "text") + return false; + if (node.type === "each") { + return viewHasEvents(node.body) || viewHasEvents(node.empty); + } + if (node.type === "if") { + return node.branches.some((branch) => viewHasEvents(branch.body)); + } + return node.attrs.some((attr) => attr.event) || viewHasEvents(node.children); + }); } function viewHasServerEach(nodes) { - return nodes.some((node) => { - if (node.type === "text") - return false; - if (node.type === "each") - return true; - if (node.type === "if") { - return node.branches.some((branch) => viewHasServerEach(branch.body)); - } - return viewHasServerEach(node.children); - }); + return nodes.some((node) => { + if (node.type === "text") + return false; + if (node.type === "each") + return true; + if (node.type === "if") { + return node.branches.some((branch) => viewHasServerEach(branch.body)); + } + return viewHasServerEach(node.children); + }); } +/** + * Compile a text node. Interpolations that reference state stay as client + * mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of + * props/constants are baked server-side (`${__wireHtml(expr)}`), so static + * components render correct HTML with zero JavaScript. + */ function compileText(raw, ctx) { - let out = ""; - let last = 0; - let m; - INTERP_RE.lastIndex = 0; - while (m = INTERP_RE.exec(raw)) { - out += escLit(raw.slice(last, m.index)); - const expr = m[1].trim(); - if (expr.startsWith("t:")) { - out += escLit(``); - } else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) { - out += escLit(`{${expr}}`); - } else if (exprRefsState(expr, ctx.stateNames)) { - out += escLit(``) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(``); - } else if (expr === "content") { - out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`; - } else { - out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`; + let out = ""; + let last = 0; + let m; + INTERP_RE.lastIndex = 0; + while ((m = INTERP_RE.exec(raw))) { + out += escLit(raw.slice(last, m.index)); + const expr = m[1].trim(); + if (expr.startsWith("t:")) { + // i18n sugar: {t:key} → a marker resolved server-side by translateHtml. + out += escLit(``); + } + else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) { + // Loop variable (from data-for): leave a literal client mustache — the + // list renderer fills it per item; it has no server-side value. + out += escLit(`{${expr}}`); + } + else if (exprRefsState(expr, ctx.stateNames)) { + // State interpolation: bake the initial value AND keep it reactive via a + // data-text span, so no-JS clients see the real value and hydration + // updates it in place. `count` → `0`. + out += + escLit(``) + + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + + escLit(``); + } + else if (expr === "content") { + out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`; + } + else { + out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`; + } + last = m.index + m[0].length; } - last = m.index + m[0].length; - } - return out + escLit(raw.slice(last)); + return out + escLit(raw.slice(last)); } +/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */ function compileAttrValue(raw, ctx) { - if (!raw.includes("{")) - return escLit(attrEscape(raw)); - let out = ""; - let last = 0; - let m; - INTERP_RE.lastIndex = 0; - while (m = INTERP_RE.exec(raw)) { - out += escLit(attrEscape(raw.slice(last, m.index))); - const expr = m[1].trim(); - if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) { - out += escLit(`{${expr}}`); - } else { - out += `\${__wireAttr(${ctx.resolveExpr(expr)})}`; + if (!raw.includes("{")) + return escLit(attrEscape(raw)); + let out = ""; + let last = 0; + let m; + INTERP_RE.lastIndex = 0; + while ((m = INTERP_RE.exec(raw))) { + out += escLit(attrEscape(raw.slice(last, m.index))); + const expr = m[1].trim(); + if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) { + out += escLit(`{${expr}}`); // hydrated per-item by the list renderer + } + else { + out += `\${__wireAttr(${ctx.resolveExpr(expr)})}`; + } + last = m.index + m[0].length; } - last = m.index + m[0].length; - } - return out + escLit(attrEscape(raw.slice(last))); + return out + escLit(attrEscape(raw.slice(last))); } function renderComponentIfNode(node, ctx) { - let expression = "``"; - for (let index = node.branches.length - 1;index >= 0; index--) { - const branch = node.branches[index]; - const body = branch.body.map((child) => renderComponentNode(child, ctx)).join(""); - const bodyExpression = "`" + body + "`"; - expression = branch.cond === null ? bodyExpression : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; - } - return "${" + expression + "}"; + let expression = "``"; + for (let index = node.branches.length - 1; index >= 0; index--) { + const branch = node.branches[index]; + const body = branch.body.map((child) => renderComponentNode(child, ctx)).join(""); + const bodyExpression = "`" + body + "`"; + expression = + branch.cond === null + ? bodyExpression + : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; + } + return "${" + expression + "}"; } function renderComponentEachNode(node, ctx) { - const item = node.item; - const index = node.index ?? "__wi"; - const list = ctx.resolveExpr(node.list); - const childCtx = { - ...ctx, - serverLocals: new Set([...ctx.serverLocals ?? [], item, index]) - }; - const body = node.body.map((child) => renderComponentNode(child, childCtx)).join(""); - const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join(""); - return "${(() => { const __wl = Array.isArray(" + list + ") ? (" + list + ") : []; return __wl.length ? __wl.map((" + item + ", " + index + ") => `" + body + '`).join("") : `' + empty + "`; })()}"; + const item = node.item; + const index = node.index ?? "__wi"; + const list = ctx.resolveExpr(node.list); + const childCtx = { + ...ctx, + serverLocals: new Set([...(ctx.serverLocals ?? []), item, index]), + }; + const body = node.body.map((child) => renderComponentNode(child, childCtx)).join(""); + const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join(""); + return ("${(() => { const __wl = Array.isArray(" + + list + + ") ? (" + + list + + ") : []; return __wl.length ? __wl.map((" + + item + + ", " + + index + + ") => `" + + body + + '`).join("") : `' + + empty + + "`; })()}"); } function serverLoopLocalsAttribute(ctx) { - const locals = [...ctx.serverLocals ?? []]; - if (locals.length === 0) { - return ""; - } - const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", "); - return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`; + const locals = [...(ctx.serverLocals ?? [])]; + if (locals.length === 0) { + return ""; + } + const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", "); + return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`; } +function unwrapDirectiveExpression(raw) { + const value = raw.trim(); + if (!value.startsWith("{") || !value.endsWith("}")) { + return value; + } + let depth = 0; + let quote = null; + let escaped = false; + for (let index = 0; index < value.length; index++) { + const char = value[index]; + if (escaped) { + escaped = false; + continue; + } + if (quote) { + if (char === "\\") { + escaped = true; + } + else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "{") + depth++; + if (char === "}") + depth--; + if (depth === 0 && index < value.length - 1) { + return value; + } + } + return depth === 0 ? value.slice(1, -1).trim() : value; +} +/** Render a component view node into template-literal-ready source. */ function renderComponentNode(node, ctx) { - if (node.type === "text") - return compileText(node.value, ctx); - if (node.type === "each") { - return renderComponentEachNode(node, ctx); - } - if (node.type === "if") { - return renderComponentIfNode(node, ctx); - } - if (isComponentTag(node.tag)) { - return renderNestedComponentInvocation(node, ctx); - } - const loopVariables = loopVarsOf(node); - const elementContext = loopVariables.length > 0 ? { - ...ctx, - loopVars: new Set([...ctx.loopVars ?? [], ...loopVariables]) - } : ctx; - let bindIndex = 0; - const staticClasses = []; - const conditionalClasses = []; - for (const attr of node.attrs) { - if (!attr.event && attr.name === "class") { - staticClasses.push(attr.value); + if (node.type === "text") + return compileText(node.value, ctx); + if (node.type === "each") { + return renderComponentEachNode(node, ctx); } - if (!attr.event && attr.name.startsWith("class:")) { - conditionalClasses.push({ - className: attr.name.slice("class:".length), - expression: attr.value - }); + if (node.type === "if") { + return renderComponentIfNode(node, ctx); } - } - const attrs = node.attrs.filter((a) => a.name !== "class" && !a.name.startsWith("class:")).map((a) => { - if (a.event) { - return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`; + if (isComponentTag(node.tag)) { + return renderNestedComponentInvocation(node, ctx); } - if (a.boolean) { - return ` ${a.name}`; + const loopVariables = loopVarsOf(node); + const elementContext = loopVariables.length > 0 + ? { + ...ctx, + loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]), + } + : ctx; + let bindIndex = 0; + const staticClasses = []; + const conditionalClasses = []; + for (const attr of node.attrs) { + if (!attr.event && attr.name === "class") { + staticClasses.push(attr.value); + } + if (!attr.event && attr.name.startsWith("class:")) { + conditionalClasses.push({ + className: attr.name.slice("class:".length), + expression: unwrapDirectiveExpression(attr.value), + }); + } } - const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`; - const referencesState = exprRefsState(a.value, ctx.stateNames); - const referencesLoopVariable = elementContext.loopVars ? exprRefsState(a.value, elementContext.loopVars) : false; - const referencesServerLocal = ctx.serverLocals ? exprRefsState(a.value, ctx.serverLocals) : false; - if (!a.value.includes("{") || !referencesState && !referencesLoopVariable && !referencesServerLocal) { - return rendered; + const attrs = node.attrs + .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) + .map((a) => { + if (a.event) { + return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`; + } + if (a.boolean) { + return ` ${a.name}`; + } + const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`; + const referencesState = exprRefsState(a.value, ctx.stateNames); + const referencesLoopVariable = elementContext.loopVars + ? exprRefsState(a.value, elementContext.loopVars) + : false; + const referencesServerLocal = ctx.serverLocals + ? exprRefsState(a.value, ctx.serverLocals) + : false; + if (!a.value.includes("{") || + (!referencesState && !referencesLoopVariable && !referencesServerLocal)) { + return rendered; + } + const marker = attrEscape(JSON.stringify([a.name, a.value])); + return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`; + }) + .join(""); + const initialConditionalClasses = conditionalClasses + .map(({ className, expression }) => { + const referencesLoopVariable = elementContext.loopVars + ? exprRefsState(expression, elementContext.loopVars) + : false; + // data-for variables do not exist during + // initial server rendering. + if (referencesLoopVariable) { + return ""; + } + return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`; + }) + .join(""); + const staticClassValue = staticClasses.join(" "); + const classReferencesState = exprRefsState(staticClassValue, ctx.stateNames); + const classReferencesLoopVariable = elementContext.loopVars + ? exprRefsState(staticClassValue, elementContext.loopVars) + : false; + const classReferencesServerLocal = ctx.serverLocals + ? exprRefsState(staticClassValue, ctx.serverLocals) + : false; + const classHasReactiveExpression = staticClassValue.includes("{") && + (classReferencesState || classReferencesLoopVariable || classReferencesServerLocal); + const classAttribute = staticClasses.length > 0 || conditionalClasses.length > 0 + ? ` class="${compileAttrValue(staticClassValue, elementContext)}${initialConditionalClasses}"` + : ""; + const classReactiveBinding = classHasReactiveExpression + ? ` data-wrn-bind-class="${escLit(attrEscape(JSON.stringify(["class", staticClassValue])))}"` + : ""; + const classBindings = conditionalClasses + .map(({ className, expression }, index) => { + const marker = attrEscape(JSON.stringify([className, expression])); + return ` data-wrn-class-${index}="${escLit(marker)}"`; + }) + .join(""); + const loopLocalsAttribute = serverLoopLocalsAttribute(ctx); + const allAttrs = `${loopLocalsAttribute}` + + `${classAttribute}` + + `${classReactiveBinding}` + + `${classBindings}` + + `${attrs}`; + if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) { + return `<${node.tag}${allAttrs}>`; } - const marker = attrEscape(JSON.stringify([a.name, a.value])); - return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`; - }).join(""); - const initialConditionalClasses = conditionalClasses.map(({ className, expression }) => { - const referencesLoopVariable = elementContext.loopVars ? exprRefsState(expression, elementContext.loopVars) : false; - if (referencesLoopVariable) { - return ""; - } - return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`; - }).join(""); - const staticClassValue = staticClasses.join(" "); - const classReferencesState = exprRefsState(staticClassValue, ctx.stateNames); - const classReferencesLoopVariable = elementContext.loopVars ? exprRefsState(staticClassValue, elementContext.loopVars) : false; - const classReferencesServerLocal = ctx.serverLocals ? exprRefsState(staticClassValue, ctx.serverLocals) : false; - const classHasReactiveExpression = staticClassValue.includes("{") && (classReferencesState || classReferencesLoopVariable || classReferencesServerLocal); - const classAttribute = staticClasses.length > 0 || conditionalClasses.length > 0 ? ` class="${compileAttrValue(staticClassValue, elementContext)}${initialConditionalClasses}"` : ""; - const classReactiveBinding = classHasReactiveExpression ? ` data-wrn-bind-class="${escLit(attrEscape(JSON.stringify(["class", staticClassValue])))}"` : ""; - const classBindings = conditionalClasses.map(({ className, expression }, index) => { - const marker = attrEscape(JSON.stringify([className, expression])); - return ` data-wrn-class-${index}="${escLit(marker)}"`; - }).join(""); - const loopLocalsAttribute = serverLoopLocalsAttribute(ctx); - const allAttrs = `${loopLocalsAttribute}` + `${classAttribute}` + `${classReactiveBinding}` + `${classBindings}` + `${attrs}`; - if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { - return `<${node.tag}${allAttrs}>`; - } - const inner = node.children.map((child) => renderComponentNode(child, elementContext)).join(""); - return `<${node.tag}${allAttrs}>${inner}`; + const inner = node.children.map((child) => renderComponentNode(child, elementContext)).join(""); + return `<${node.tag}${allAttrs}>${inner}`; } function generateComponent(ast) { - const out = []; - if (ast.imports.length > 0) - out.push(ast.imports.join(` -`)); - const hasServerEach = viewHasServerEach(ast.view); - if (hasServerEach) { - out.push(`import { Buffer } from "node:buffer";`); - } - const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [ - { - name: "content", - default: '""', - valueType: "string", - required: false - }, - ...ast.props - ] : ast.props; - const stateNames = new Set(ast.states.map((s) => s.name)); - const nameRefs = new Map; - for (const p of effectiveProps) { - nameRefs.set(p.name, safeRef(p.name)); - } - for (const s of ast.states) - nameRefs.set(s.name, safeRef(s.name)); - const resolveExpr = (expr) => { - let result = expr; - for (const [name, ref] of nameRefs) { - if (name !== ref) - result = result.replace(new RegExp(`\\b${name}\\b`, "g"), ref); + const out = []; + if (ast.imports.length > 0) + out.push(ast.imports.join("\n")); + const hasServerEach = viewHasServerEach(ast.view); + if (hasServerEach) { + out.push(`import { Buffer } from "node:buffer";`); } - return result; - }; - const ctx = { stateNames, resolveExpr }; - const serverFunctions = ast.functions.map((body) => body.trim()).filter(Boolean).join(` - -`); - const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join(""); - const styles = ast.styles.map((body) => body.trim()).filter(Boolean); - const styleTag = styles.length > 0 ? escLit(``) : ""; - const behavior = componentBehavior(ast); - const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null; - const scopeKeys = [ - ...effectiveProps.map((prop) => prop.name), - ...ast.states.map((state) => state.name) - ]; - const behaviorAttr = behaviorAttribute(behavior); - const decls = []; - for (const prop of effectiveProps) { - if (prop.required) { - decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`); + const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") + ? [ + { + name: "content", + default: '""', + valueType: "string", + required: false, + }, + ...ast.props, + ] + : ast.props; + const stateNames = new Set([ + ...ast.states.map((entry) => entry.name), + ...ast.computed.map((entry) => entry.name), + ]); + const nameRefs = new Map(); + for (const p of effectiveProps) { + nameRefs.set(p.name, safeRef(p.name)); } - decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`); - } - for (const state of ast.states) { - decls.push(` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`); - } - const returnExpr = needsScope ? "`" + styleTag + `
` + viewCode + "
`" : "`" + styleTag + viewCode + "`"; - const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`).join(", ")} }); -` : needsScope ? ` const __scope = ""; -` : ""; - if (ast.kind === "layout") { - out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`); - } else { - out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); - } - if (behavior) { - out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`); - } - const typeSource = ast.types.map((body) => body.trim()).filter(Boolean).join(` - -`); - if (typeSource) - out.push(typeSource); - if (effectiveProps.length > 0) { - out.push(`export interface ${ast.name}Props { -${effectiveProps.map((prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`).join(` -`)} -}`); - } - out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any { + for (const s of ast.states) + nameRefs.set(s.name, safeRef(s.name)); + for (const entry of ast.computed) + nameRefs.set(entry.name, safeRef(entry.name)); + const resolveExpr = (expr) => { + let result = expr; + for (const [name, ref] of nameRefs) { + if (name !== ref) + result = result.replace(new RegExp(`\\b${name}\\b`, "g"), ref); + } + return result; + }; + const ctx = { stateNames, resolveExpr }; + const serverFunctions = ast.functions + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"); + const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join(""); + const styles = ast.styles.map((body) => body.trim()).filter(Boolean); + const styleTag = styles.length > 0 + ? escLit(``) + : ""; + // A component needs a reactive scope only when it has state or event handlers. + // Prop-driven text/attributes are baked server-side, so static components ship + // no JavaScript at all. + const behavior = componentBehavior(ast); + const needsScope = ast.runtime !== "server" && + (ast.states.length > 0 || ast.computed.length > 0 || viewHasEvents(ast.view) || behavior !== null); + const scopeKeys = [ + ...effectiveProps.map((prop) => prop.name), + ...ast.states.map((state) => state.name), + ]; + const behaviorAttr = behaviorAttribute(behavior); + const decls = []; + for (const prop of effectiveProps) { + if (prop.required) { + decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`); + } + decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))});`); + } + for (const state of ast.states) { + decls.push(` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`); + } + for (const entry of ast.computed) { + decls.push(` const ${nameRefs.get(entry.name)} = (${resolveExpr(entry.expr)});`); + } + const returnExpr = needsScope + ? "`" + styleTag + `
` + viewCode + "
`" + : "`" + styleTag + viewCode + "`"; + const scopeLine = needsScope && scopeKeys.length > 0 + ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys + .map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`) + .join(", ")} });\n` + : needsScope + ? ` const __scope = "";\n` + : ""; + if (ast.kind === "layout") { + out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`); + } + else { + out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); + } + out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); + out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`); + out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); + if (Object.keys(ast.security).length > 0) { + out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); + } + if (behavior) { + out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`); + } + const typeSource = ast.types + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"); + if (typeSource) + out.push(typeSource); + if (effectiveProps.length > 0) { + out.push(`export interface ${ast.name}Props {\n${effectiveProps + .map((prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`) + .join("\n")}\n}`); + } + out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any { if (v === undefined || v === null) { return def; } @@ -1999,13 +1364,13 @@ function __wireProp(v: any): string { function __wireRaw(v: any): string { return String(v == null ? "" : v); }`); - if (hasServerEach) { - out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { + if (hasServerEach) { + out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); }`); - } - if (needsScope) { - out.push(`function __wrnexusSerializeScopeValue(value: any): string { + } + if (needsScope) { + out.push(`function __wrnexusSerializeScopeValue(value: any): string { if (value === undefined) { return "undefined"; } @@ -2055,274 +1420,1870 @@ function __wireRaw(v: any): string { .replace(//g, ">"); }`); - } - const serverFunctionSource = serverFunctions ? `${serverFunctions} -` : ""; - out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string { -` + ` const __p = props || {}; -` + (decls.length > 0 ? decls.join(` -`) + ` -` : "") + serverFunctionSource + scopeLine + ` return ${returnExpr}; -` + `}`); - return out.join(` - -`) + ` -`; + } + const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : ""; + out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + + ` const __p = props || {};\n` + + (decls.length > 0 ? decls.join("\n") + "\n" : "") + + serverFunctionSource + + scopeLine + + ` return ${returnExpr};\n` + + `}`); + return out.join("\n\n") + "\n"; +} +function __wireRaw(v) { + return String(v == null ? "" : v); } function wholeAttributeExpression(value) { - const match = /^\s*\{([\s\S]+)\}\s*$/.exec(value); - return match?.[1]?.trim() || null; + const match = /^\s*\{([\s\S]+)\}\s*$/.exec(value); + return match?.[1]?.trim() || null; +} +function __wireHtml(v) { + return String(v == null ? "" : v).replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">"); +} +function __wireAttr(v) { + return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """); +} +function __wireProp(v) { + const value = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v); + return __wireAttr(value); } function renderPageComponentAttr(attr, dynamicExpressions) { - if (attr.event) { - return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; - } - if (attr.boolean) { - return ` ${attr.name}`; - } - const expression = wholeAttributeExpression(attr.value); - if (!expression) { - return ` ${attr.name}="${attrEscape(attr.value)}"`; - } - dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`); - const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; - return ` ${attr.name}="${marker}"`; + if (attr.event) { + return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`; + } + if (attr.boolean) { + return ` ${attr.name}`; + } + const expression = wholeAttributeExpression(attr.value); + if (!expression) { + return ` ${attr.name}="${attrEscape(attr.value)}"`; + } + dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`); + const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; + return ` ${attr.name}="${marker}"`; } -// ../../packages/compiler/src/native-codegen.ts -class NativeCompileError extends Error { - constructor(message) { - super(message); - this.name = "NativeCompileError"; - } +}, +"packages/compiler/src/index.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +/** + * @wrnexus/compiler — the `.wrn` language compiler. + * + * Parsing and language diagnostics are provided by the canonical + * `@wrnexus/syntax` package. This package owns platform-specific codegen. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = void 0; +exports.compileNativeWireFile = compileNativeWireFile; +exports.compileWireFile = compileWireFile; +exports.compile = compile; +const syntax_1 = require("@wrnexus/syntax"); +const codegen_ts_1 = require("./codegen.js"); +const native_codegen_ts_1 = require("./native-codegen.js"); +var syntax_2 = require("@wrnexus/syntax"); +Object.defineProperty(exports, "assertValidAst", { enumerable: true, get: function () { return syntax_2.assertValidAst; } }); +Object.defineProperty(exports, "diagnose", { enumerable: true, get: function () { return syntax_2.diagnose; } }); +Object.defineProperty(exports, "diagnosticFromError", { enumerable: true, get: function () { return syntax_2.diagnosticFromError; } }); +Object.defineProperty(exports, "formatDiagnostic", { enumerable: true, get: function () { return syntax_2.formatDiagnostic; } }); +Object.defineProperty(exports, "parse", { enumerable: true, get: function () { return syntax_2.parse; } }); +Object.defineProperty(exports, "ParseError", { enumerable: true, get: function () { return syntax_2.ParseError; } }); +var codegen_ts_2 = require("./codegen.js"); +Object.defineProperty(exports, "generate", { enumerable: true, get: function () { return codegen_ts_2.generate; } }); +var native_codegen_ts_2 = require("./native-codegen.js"); +Object.defineProperty(exports, "generateNative", { enumerable: true, get: function () { return native_codegen_ts_2.generateNative; } }); +Object.defineProperty(exports, "NativeCompileError", { enumerable: true, get: function () { return native_codegen_ts_2.NativeCompileError; } }); +var syntax_3 = require("@wrnexus/syntax"); +Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return syntax_3.Lexer; } }); +Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return syntax_3.LexError; } }); +var syntax_4 = require("@wrnexus/syntax"); +Object.defineProperty(exports, "eraseFunctionTypes", { enumerable: true, get: function () { return syntax_4.eraseFunctionTypes; } }); +Object.defineProperty(exports, "inferredRuntimeType", { enumerable: true, get: function () { return syntax_4.inferredRuntimeType; } }); +Object.defineProperty(exports, "runtimeTypeOf", { enumerable: true, get: function () { return syntax_4.runtimeTypeOf; } }); +/** Compile `.wrn` source into an Expo Router React Native screen. */ +function compileNativeWireFile(source) { + const ast = (0, syntax_1.parse)(source); + (0, syntax_1.assertValidAst)(ast); + return (0, native_codegen_ts_1.generateNative)(ast); } -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" +/** + * Compile `.wrn` source into TypeScript source. Errors include a stable code, + * source location, code frame, and actionable hint whenever available. + */ +function compileWireFile(source, filePath = "") { + try { + const ast = (0, syntax_1.parse)(source); + (0, syntax_1.assertValidAst)(ast, { file: filePath, accessibility: true }); + return `// compiled from .wrn\n${(0, codegen_ts_1.generate)(ast)}`; + } + catch (error) { + const diagnostic = (0, syntax_1.diagnosticFromError)(source, error, { file: filePath }); + throw new Error(`Failed to parse ${filePath}:\n\n${(0, syntax_1.formatDiagnostic)(source, diagnostic)}`, { + cause: error, + }); + } +} +/** Richer entry point returning the AST and structured diagnostics. */ +function compile(source, filePath = "") { + const richDiagnostics = (0, syntax_1.diagnose)(source, { file: filePath, accessibility: true }); + const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error"); + if (errors.length > 0) { + throw new syntax_1.ParseError(errors.map((diagnostic) => diagnostic.message).join("\n"), errors[0].code); + } + const ast = (0, syntax_1.parse)(source); + return { + code: `// compiled from .wrn\n${(0, codegen_ts_1.generate)(ast)}`, + ast, + diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`), + richDiagnostics, + }; +} + +}, +"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NativeCompileError = void 0; +exports.generateNative = generateNative; +class NativeCompileError extends Error { + constructor(message) { + super(message); + this.name = "NativeCompileError"; + } +} +exports.NativeCompileError = NativeCompileError; +const 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" +const 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; + 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 === "<" ? "<" : ">"); + 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} }`; + 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-")) +function renderAttrs(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 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, 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) => 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 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}`; + 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) { - 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(", ")} }`); + 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(`, -`)} });`; + return `const styles = StyleSheet.create({ ${entries.join(",\n")} });`; } +/** Compile a parsed `.wrn` page to an Expo Router React Native screen. */ 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.valueType ? `<${state.valueType}>` : ""}(${state.expr});`; - }).join(` -`); - const body = ast.view.map((node) => renderNode2(node, states)).join(""); - const typeSource = ast.types.map((block) => block.trim()).filter(Boolean).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"; -${ast.imports.join(` -`)} - -${typeSource} - -export default function ${ast.name}() { - const router = useRouter(); -${hooks} - return <>${body}; + 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`; } -${nativeStyles(ast.styles)} -`; -} +}, +"packages/compiler/src/parser.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +/** @deprecated Import parser APIs from @wrnexus/syntax. */ +__exportStar(require("@wrnexus/syntax/parser"), exports); -// ../../packages/compiler/src/index.ts -function compileNativeWireFile(source) { - return generateNative(parse(source)); +}, +"packages/compiler/src/tokenizer.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */ +__exportStar(require("@wrnexus/syntax/tokenizer"), exports); + +}, +"packages/compiler/src/types.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +/** @deprecated Import language type utilities from @wrnexus/syntax. */ +__exportStar(require("@wrnexus/syntax/types"), exports); + +}, +"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.positionAt = positionAt; +exports.classifyParseError = classifyParseError; +exports.diagnosticFromError = diagnosticFromError; +exports.diagnose = diagnose; +exports.assertValidAst = assertValidAst; +exports.isHydrationStrategy = isHydrationStrategy; +exports.isRuntimeTarget = isRuntimeTarget; +exports.formatDiagnostic = formatDiagnostic; +const parser_ts_1 = require("./parser.js"); +const spec_ts_1 = require("./spec.js"); +function positionAt(source, offset) { + const safe = Math.max(0, Math.min(offset, source.length)); + const before = source.slice(0, safe); + const lines = before.split(/\r?\n/); + return { offset: safe, line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 }; } -function compileWireFile(source, filePath = "") { - let ast; - try { - ast = parse(source); - } catch (error) { +function offsetFromMessage(message) { + const match = /offset\s+(\d+)/i.exec(message); + return match ? Number(match[1]) : undefined; +} +function classifyParseError(message) { + if (/Expected 'page', 'component', or 'layout'/.test(message)) + return spec_ts_1.WRN_DIAGNOSTIC_CODES.root; + if (/Unknown (?:page|component|layout|ssr|client) member/.test(message)) { + return spec_ts_1.WRN_DIAGNOSTIC_CODES.member; + } + if (/prop initializer|Expected eq/.test(message)) + return spec_ts_1.WRN_DIAGNOSTIC_CODES.propInitializer; + if (/State '.+' requires an initializer/.test(message)) { + return spec_ts_1.WRN_DIAGNOSTIC_CODES.stateInitializer; + } + if (/Cannot watch undeclared state/.test(message)) + return spec_ts_1.WRN_DIAGNOSTIC_CODES.watchUndeclared; + return spec_ts_1.WRN_DIAGNOSTIC_CODES.parse; +} +function diagnosticFromError(source, error, options = {}) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse ${filePath}: ${message}`, { - cause: error + const offset = error instanceof parser_ts_1.ParseError && error.offset !== undefined + ? error.offset + : offsetFromMessage(message); + return { + code: error instanceof parser_ts_1.ParseError ? error.code : classifyParseError(message), + severity: "error", + message, + file: options.file, + ...(offset === undefined ? {} : { position: positionAt(source, offset) }), + }; +} +function walk(nodes, visit) { + for (const node of nodes) { + visit(node); + if (node.type === "element") + walk(node.children, visit); + else if (node.type === "each") { + walk(node.body, visit); + walk(node.empty, visit); + } + else if (node.type === "if") { + for (const branch of node.branches) + walk(branch.body, visit); + } + } +} +function astDiagnostics(ast, options) { + const diagnostics = []; + const seen = new Map(); + for (const [kind, declarations] of [ + ["prop", ast.props], + ["state", ast.states], + ["computed", ast.computed], + ]) { + for (const declaration of declarations) { + const previous = seen.get(declaration.name); + if (previous) { + diagnostics.push({ + code: spec_ts_1.WRN_DIAGNOSTIC_CODES.duplicateSymbol, + severity: "error", + message: `Duplicate symbol '${declaration.name}' (${previous} and ${kind}).`, + hint: "Rename one declaration so every prop, state, and computed value is unique.", + file: options.file, + }); + } + else { + seen.set(declaration.name, kind); + } + } + } + if (ast.hydrate && !isHydrationStrategy(ast.hydrate)) { + diagnostics.push({ + code: spec_ts_1.WRN_DIAGNOSTIC_CODES.invalidHydration, + severity: "error", + message: `Unknown hydration strategy '${ast.hydrate}'.`, + hint: "Use load, idle, visible, interaction, none, or media:.", + file: options.file, + }); + } + if (ast.runtime && !isRuntimeTarget(ast.runtime)) { + diagnostics.push({ + code: spec_ts_1.WRN_DIAGNOSTIC_CODES.invalidRuntime, + severity: "error", + message: `Unknown runtime target '${ast.runtime}'.`, + hint: "Use server, client, or universal.", + file: options.file, + }); + } + let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0; + walk(ast.view, (node) => { + if (node.type === "element" && node.attrs.some((attribute) => attribute.event)) + interactive = true; + if (!options.accessibility || node.type !== "element") + return; + const tag = node.tag.toLowerCase(); + if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) { + diagnostics.push({ + code: spec_ts_1.WRN_DIAGNOSTIC_CODES.accessibility, + severity: "warning", + message: "Image is missing an alt attribute.", + hint: "Add alt text, or alt=\"\" for a decorative image.", + file: options.file, + }); + } }); - } - return `// compiled from .wrn -${generate(ast)}`; + if (ast.runtime === "server" && interactive) { + diagnostics.push({ + code: spec_ts_1.WRN_DIAGNOSTIC_CODES.serverInteractive, + severity: "error", + message: "A server-only WRN root cannot contain client state, effects, watches, or event handlers.", + hint: "Use runtime = \"universal\" or remove interactive behavior.", + file: options.file, + }); + } + return diagnostics; } -function compile(source) { - const diagnostics = []; - try { - const ast = parse(source); - return { code: `// compiled from .wrn -${generate(ast)}`, ast, diagnostics }; - } catch (err) { - if (err instanceof ParseError) - diagnostics.push(err.message); - throw err; - } +function diagnose(source, options = {}) { + try { + return astDiagnostics((0, parser_ts_1.parse)(source), options); + } + catch (error) { + return [diagnosticFromError(source, error, options)]; + } } +function assertValidAst(ast, options = {}) { + const errors = astDiagnostics(ast, options).filter((diagnostic) => diagnostic.severity === "error"); + if (!errors.length) + return; + const first = errors[0]; + throw new parser_ts_1.ParseError(first.message, first.code); +} +function isHydrationStrategy(value) { + return (spec_ts_1.WRN_HYDRATION_STRATEGIES.includes(value) || + (value.startsWith("media:") && value.length > "media:".length)); +} +function isRuntimeTarget(value) { + return spec_ts_1.WRN_RUNTIME_TARGETS.includes(value); +} +function formatDiagnostic(source, diagnostic) { + const location = diagnostic.position + ? `${diagnostic.file ?? ""}:${diagnostic.position.line}:${diagnostic.position.column}` + : diagnostic.file ?? ""; + const lines = [`${diagnostic.code} ${diagnostic.severity.toUpperCase()}`, "", diagnostic.message, "", location]; + if (diagnostic.position) { + const sourceLine = source.split(/\r?\n/)[diagnostic.position.line - 1] ?? ""; + lines.push("", sourceLine, `${" ".repeat(Math.max(0, diagnostic.position.column - 1))}^`); + } + if (diagnostic.hint) + lines.push("", `Hint: ${diagnostic.hint}`); + return lines.join("\n"); +} + +}, +"packages/syntax/src/index.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.LexError = exports.Lexer = void 0; +var tokenizer_ts_1 = require("./tokenizer.js"); +Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } }); +Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } }); +var parser_ts_1 = require("./parser.js"); +Object.defineProperty(exports, "parse", { enumerable: true, get: function () { return parser_ts_1.parse; } }); +Object.defineProperty(exports, "parseHtmlView", { enumerable: true, get: function () { return parser_ts_1.parseHtmlView; } }); +Object.defineProperty(exports, "ParseError", { enumerable: true, get: function () { return parser_ts_1.ParseError; } }); +Object.defineProperty(exports, "VOID_ELEMENTS", { enumerable: true, get: function () { return parser_ts_1.VOID_ELEMENTS; } }); +var types_ts_1 = require("./types.js"); +Object.defineProperty(exports, "eraseFunctionTypes", { enumerable: true, get: function () { return types_ts_1.eraseFunctionTypes; } }); +Object.defineProperty(exports, "inferredRuntimeType", { enumerable: true, get: function () { return types_ts_1.inferredRuntimeType; } }); +Object.defineProperty(exports, "runtimeTypeOf", { enumerable: true, get: function () { return types_ts_1.runtimeTypeOf; } }); +Object.defineProperty(exports, "validateTypedInitializer", { enumerable: true, get: function () { return types_ts_1.validateTypedInitializer; } }); +var diagnostics_ts_1 = require("./diagnostics.js"); +Object.defineProperty(exports, "assertValidAst", { enumerable: true, get: function () { return diagnostics_ts_1.assertValidAst; } }); +Object.defineProperty(exports, "classifyParseError", { enumerable: true, get: function () { return diagnostics_ts_1.classifyParseError; } }); +Object.defineProperty(exports, "diagnose", { enumerable: true, get: function () { return diagnostics_ts_1.diagnose; } }); +Object.defineProperty(exports, "diagnosticFromError", { enumerable: true, get: function () { return diagnostics_ts_1.diagnosticFromError; } }); +Object.defineProperty(exports, "formatDiagnostic", { enumerable: true, get: function () { return diagnostics_ts_1.formatDiagnostic; } }); +Object.defineProperty(exports, "isHydrationStrategy", { enumerable: true, get: function () { return diagnostics_ts_1.isHydrationStrategy; } }); +Object.defineProperty(exports, "isRuntimeTarget", { enumerable: true, get: function () { return diagnostics_ts_1.isRuntimeTarget; } }); +Object.defineProperty(exports, "positionAt", { enumerable: true, get: function () { return diagnostics_ts_1.positionAt; } }); +__exportStar(require("./spec.js"), exports); + +}, +"packages/syntax/src/parser.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +/** + * Recursive-descent parser for `.wrn`, producing a small AST. + * + * Grammar (subset of the vision, but real): + * + * page { + * types { } + * props { : [= ] } // no default means required + * state : = // type annotation is optional + * view { } // plain HTML (see parseHtmlView) + * seo { title = "Home" description = "..." } + * ssr { api { } functions { } } + * client { api { } functions { } } + * style { } // zero or more, inlined with the page + * functions { } // zero or more, shared helpers + * api { } // zero or more + * realtime { on () { } * } // zero or more + * } + * + * The `view` block is written as ordinary HTML — nothing new to learn. Text may + * contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and + * `@event="..."` declares a client event binding. See `parseHtmlView`. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ParseError = exports.VOID_ELEMENTS = void 0; +exports.parse = parse; +exports.parseHtmlView = parseHtmlView; +const tokenizer_ts_1 = require("./tokenizer.js"); +const types_ts_1 = require("./types.js"); +/** + * HTML void elements: they have no children and no closing tag. + * @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements + */ +exports.VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); +class ParseError extends Error { + code; + offset; + constructor(message, code = "WRN-PARSE-001") { + super(message); + this.name = "ParseError"; + this.code = code; + const match = /offset\s+(\d+)/i.exec(message); + this.offset = match ? Number(match[1]) : undefined; + } +} +exports.ParseError = ParseError; +function parseSeoBlock(body) { + const out = {}; + const pair = /([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g; + for (const match of body.matchAll(pair)) { + const key = match[1]; + const rawValue = match[2] ?? match[3] ?? match[4] ?? ""; + out[key] = unescapeSeoValue(rawValue.trim()); + } + return out; +} +function unescapeSeoValue(value) { + return value.replace(/\\(["'\\nrt])/g, (_match, ch) => { + if (ch === "n") + return "\n"; + if (ch === "r") + return "\r"; + if (ch === "t") + return "\t"; + return ch; + }); +} +function parse(source) { + const lx = new tokenizer_ts_1.Lexer(source); + const imports = []; + const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y; + while (true) { + while (/\s/u.test(source[lx.pos] ?? "")) + lx.pos++; + if (source.startsWith("//", lx.pos)) { + while (lx.pos < source.length && source[lx.pos] !== "\n") + lx.pos++; + continue; + } + importPattern.lastIndex = lx.pos; + const statement = importPattern.exec(source); + if (!statement) + break; + imports.push(statement[0].trim()); + lx.pos = importPattern.lastIndex; + } + const expect = (type) => { + const t = lx.next(); + if (t.type !== type) { + throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`); + } + return t; + }; + const expectKeyword = (kw) => { + const t = lx.next(); + if (t.type !== "ident" || t.value !== kw) { + throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`); + } + }; + try { + // A file may contain a page, reusable component, or reusable layout. + const opener = lx.next(); + if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) { + throw new ParseError(`Expected 'page', 'component', or 'layout' but got '${opener.value || opener.type}' at offset ${opener.pos}`); + } + const kind = opener.value; + const name = expect("ident").value; + expect("lbrace"); + let layout; + let runtime; + let hydrate; + const props = []; + const types = []; + const states = []; + const computed = []; + const effects = []; + const loads = []; + const actions = []; + const security = {}; + const seo = {}; + const view = []; + const styles = []; + const functions = []; + const dataApis = []; + const modeFunctions = []; + const lifecycle = {}; + const watches = []; + const apis = []; + const realtimes = []; + while (lx.peek().type !== "rbrace") { + const kw = lx.peek(); + if (kw.type === "eof") + throw new ParseError(`Unexpected end of input inside ${kind}`); + if (kw.type !== "ident") { + throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`); + } + switch (kw.value) { + case "layout": { + // layout = "public" — selects app/layouts/.wrn for this page. + lx.next(); + expect("eq"); + layout = expect("string").value; + break; + } + case "runtime": { + lx.next(); + expect("eq"); + const value = expect("string").value; + if (value !== "server" && value !== "client" && value !== "universal") { + throw new ParseError(`Unknown runtime target '${value}' at offset ${kw.pos}`, "WRN-RUNTIME-TARGET"); + } + runtime = value; + break; + } + case "hydrate": { + lx.next(); + expect("eq"); + hydrate = expect("string").value; + break; + } + case "props": { + // props { name: Type = } — omit the default for required props. + lx.next(); + expect("lbrace"); + while (lx.peek().type !== "rbrace") { + const t = lx.peek(); + if (t.type === "eof") + throw new ParseError("Unexpected end of input inside props"); + if (t.type !== "ident") { + throw new ParseError(`Expected a prop name at offset ${t.pos}`); + } + const pName = expect("ident").value; + let valueType; + let hasDefault = false; + if (lx.peek().type === "colon") { + lx.next(); + const annotation = lx.readTypeAnnotation(); + valueType = annotation.type; + hasDefault = annotation.hasDefault; + } + else { + expect("eq"); + hasDefault = true; + } + const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined"; + props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue }); + } + expect("rbrace"); + break; + } + case "state": { + lx.next(); + const sName = expect("ident").value; + let valueType; + if (lx.peek().type === "colon") { + lx.next(); + const annotation = lx.readTypeAnnotation(); + valueType = annotation.type; + if (!annotation.hasDefault) { + throw new ParseError(`State '${sName}' requires an initializer`); + } + } + else { + expect("eq"); + } + states.push({ name: sName, valueType, expr: lx.readToLineEnd() }); + break; + } + case "computed": { + lx.next(); + expect("lbrace"); + while (lx.peek().type !== "rbrace") { + const t = lx.peek(); + if (t.type === "eof") + throw new ParseError("Unexpected end of input inside computed"); + const name = expect("ident").value; + expect("eq"); + computed.push({ name, expr: lx.readPropInitializer() }); + } + expect("rbrace"); + break; + } + case "effect": { + lx.next(); + effects.push({ body: lx.readBalancedBraces() }); + break; + } + case "types": { + lx.next(); + types.push(lx.readBalancedBraces()); + break; + } + case "view": { + lx.next(); + expect("lbrace"); + // The view body is plain HTML. Parse it straight off the source + // (the token lexer isn't used for markup), then resume after the + // block's closing `}`. + const { nodes, endPos } = parseHtmlView(lx.src, lx.pos); + view.push(...nodes); + lx.pos = endPos; + expect("rbrace"); + break; + } + case "seo": { + lx.next(); + Object.assign(seo, parseSeoBlock(lx.readBalancedBraces())); + break; + } + case "security": { + lx.next(); + Object.assign(security, parseSeoBlock(lx.readBalancedBraces())); + break; + } + case "load": { + lx.next(); + const modeToken = expect("ident"); + if (modeToken.value !== "server" && modeToken.value !== "client") { + throw new ParseError(`Expected 'server' or 'client' after load at offset ${modeToken.pos}`); + } + loads.push({ mode: modeToken.value, body: lx.readBalancedBraces() }); + break; + } + case "action": { + lx.next(); + const actionName = expect("ident").value; + const args = []; + if (lx.peek().type === "lparen") { + lx.next(); + while (lx.peek().type !== "rparen") { + args.push(expect("ident").value); + if (lx.peek().type === "comma") + lx.next(); + } + expect("rparen"); + } + actions.push({ name: actionName, args, body: lx.readBalancedBraces() }); + break; + } + case "api": { + lx.next(); + const method = expect("ident").value.toUpperCase(); + const path = lx.readPath(); + const body = lx.readBalancedBraces(); + apis.push({ method, path, body }); + break; + } + case "ssr": + case "client": { + const mode = kw.value === "ssr" ? "ssr" : "client"; + lx.next(); + if (mode === "client" && lx.peek().type === "eq") { + lx.next(); + hydrate = expect("string").value; + break; + } + expect("lbrace"); + while (lx.peek().type !== "rbrace") { + const member = lx.peek(); + if (member.type === "eof") { + throw new ParseError(`Unexpected end of input inside ${mode} block`); + } + if (member.type !== "ident") { + throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`); + } + switch (member.value) { + case "api": { + lx.next(); + const name = expect("ident").value; + const method = expect("ident").value.toUpperCase(); + const path = lx.readPath(); + const body = lx.readBalancedBraces(); + dataApis.push({ mode, name, method, path, body }); + break; + } + case "functions": { + lx.next(); + modeFunctions.push({ mode, body: lx.readBalancedBraces() }); + break; + } + default: + throw new ParseError(`Unknown ${mode} member '${member.value}' at offset ${member.pos}`); + } + } + expect("rbrace"); + break; + } + case "realtime": { + lx.next(); + const rName = expect("ident").value; + expect("lbrace"); + const handlers = []; + while (lx.peek().type !== "rbrace") { + expectKeyword("on"); + const event = expect("ident").value; + expect("lparen"); + const args = []; + while (lx.peek().type !== "rparen") { + args.push(expect("ident").value); + if (lx.peek().type === "comma") + lx.next(); + } + expect("rparen"); + handlers.push({ event, args, body: lx.readBalancedBraces() }); + } + expect("rbrace"); + realtimes.push({ name: rName, handlers }); + break; + } + case "style": { + lx.next(); + styles.push(lx.readBalancedBraces()); + break; + } + case "lifecycle": { + lx.next(); + expect("lbrace"); + while (lx.peek().type !== "rbrace") { + const hook = lx.peek(); + if (hook.type === "eof") { + throw new ParseError("Unexpected end of input inside lifecycle block"); + } + if (hook.type !== "ident") { + throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`); + } + if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") { + throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`); + } + const hookName = hook.value; + lx.next(); + if (lifecycle[hookName] !== undefined) { + throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`); + } + lifecycle[hookName] = lx.readBalancedBraces(); + } + expect("rbrace"); + break; + } + case "watch": { + lx.next(); + const stateName = expect("ident").value; + const body = lx.readBalancedBraces(); + watches.push({ + state: stateName, + body, + }); + break; + } + case "functions": { + lx.next(); + functions.push(lx.readBalancedBraces()); + break; + } + default: + throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`); + } + } + expect("rbrace"); + const declaredStates = new Set(states.map((state) => state.name)); + for (const watcher of watches) { + if (!declaredStates.has(watcher.state)) { + throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`); + } + } + for (const prop of props) { + const problem = (0, types_ts_1.validateTypedInitializer)(`Prop '${prop.name}'`, prop.valueType, prop.default); + if (problem) + throw new ParseError(problem); + } + for (const state of states) { + const problem = (0, types_ts_1.validateTypedInitializer)(`State '${state.name}'`, state.valueType, state.expr); + if (problem) + throw new ParseError(problem); + } + const symbols = new Set(); + for (const declaration of [...props, ...states, ...computed]) { + if (symbols.has(declaration.name)) { + throw new ParseError(`Duplicate symbol '${declaration.name}'`, "WRN-SYMBOL-DUPLICATE"); + } + symbols.add(declaration.name); + } + return { + type: "page", + imports, + kind, + name, + layout, + runtime, + hydrate, + props, + types, + states, + computed, + effects, + loads, + actions, + security, + seo, + view, + styles, + functions, + dataApis, + modeFunctions, + lifecycle, + watches, + apis, + realtimes, + }; + } + catch (err) { + if (err instanceof tokenizer_ts_1.LexError) + throw new ParseError(err.message); + throw err; + } +} +/** + * Parse the body of a `view { ... }` block as plain HTML. + * + * `src` is the whole `.wrn` source; `pos` points just past the view block's + * opening `{`. Returns the parsed nodes plus the index of the block's closing + * `}` (left for the caller to consume). It is intentionally lenient — you write + * markup the way you already know: + * + * - `children` — elements with attributes + * - `` and HTML void elements (`
`, ``, …) — no closing tag + * - text may contain `{expr}` interpolation, kept verbatim for the runtime + * - `@event="..."` becomes a client event binding; hyphenated names are fine + * - `` are dropped + * + * `{` and `}` in text are reserved for interpolation; a lone `<` that isn't a + * tag is treated as literal text. + */ +function parseHtmlView(src, pos) { + let i = pos; + const isNameStart = (c) => /[A-Za-z_]/.test(c); + const isTagNamePart = (c) => /[A-Za-z0-9_$:.-]/.test(c); + const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r"; + const fail = (msg) => { + throw new ParseError(`${msg} at offset ${i}`); + }; + const skipWs = () => { + while (i < src.length && isWs(src[i])) + i++; + }; + /** Read a `{...}` interpolation (brace-balanced), braces included. */ + const readInterpolation = () => { + const start = i; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === "{") + depth++; + else if (src[i] === "}" && --depth === 0) { + i++; + return src.slice(start, i); + } + } + return fail("Unterminated `{` interpolation in view"); + }; + const readQuoted = () => { + const quote = src[i]; + if (quote !== '"' && quote !== "'") + return fail("Expected a quoted attribute value"); + i++; + const start = i; + while (i < src.length && src[i] !== quote) + i++; + if (i >= src.length) + return fail("Unterminated attribute value"); + const value = src.slice(start, i); + i++; // closing quote + return value; + }; + const readTagName = () => { + if (i >= src.length || !isNameStart(src[i])) { + return fail("Expected a tag name"); + } + const start = i++; + while (i < src.length && isTagNamePart(src[i])) { + i++; + } + return src.slice(start, i); + }; + const readAttributeName = () => { + if (i >= src.length) { + return fail("Expected an attribute name"); + } + const start = i; + while (i < src.length) { + const char = src[i]; + const next = src[i + 1]; + if (char === "=" || + char === ">" || + char === '"' || + char === "'" || + char === " " || + char === "\t" || + char === "\n" || + char === "\r" || + (char === "/" && next === ">")) { + break; + } + i++; + } + if (i === start) { + return fail("Expected an attribute name"); + } + return src.slice(start, i); + }; + const parseTag = () => { + i++; // consume '<' + const tag = readTagName(); + const attrs = []; + for (;;) { + skipWs(); + const c = src[i]; + if (c === undefined) + return fail(`Unterminated <${tag}> tag`); + if (c === ">") { + i++; + break; + } + if (c === "/" && src[i + 1] === ">") { + i += 2; + return { type: "element", tag, attrs, children: [] }; + } + if (c === "@") { + i++; + const name = readAttributeName(); + skipWs(); + if (src[i] !== "=") + return fail(`Expected '=' after @${name}`); + i++; + skipWs(); + attrs.push({ name, value: readQuoted(), event: true }); + continue; + } + const name = readAttributeName(); + skipWs(); + if (src[i] === "=") { + i++; + skipWs(); + attrs.push({ name, value: readQuoted(), event: false }); + } + else { + attrs.push({ name, value: "", event: false, boolean: true }); + } + } + if (exports.VOID_ELEMENTS.has(tag.toLowerCase())) { + return { type: "element", tag, attrs, children: [] }; + } + const children = parseNodeList("element"); + // parseNodeList stops at the parent's closing tag ``); + i += 2; + skipWs(); + const close = readTagName(); + if (close !== tag) + return fail(`Mismatched , expected `); + skipWs(); + if (src[i] !== ">") + return fail(`Expected '>' to close `); + i++; + return { type: "element", tag, attrs, children }; + }; + const EACH_HEADER = /^\{#each\s+([\s\S]+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?(?:\s+key\s+([\s\S]+?))?\s*\}$/; + /** Parse `{#each as [, ] [key ]} …body… {:empty} …empty… {/each}`. */ + function parseEach() { + const header = readInterpolation(); // reads the full `{#each …}` + const m = EACH_HEADER.exec(header); + if (!m) + return fail(`Invalid {#each …} header: ${header}`); + const list = m[1].trim(); + const item = m[2]; + const index = m[3]; + const key = m[4]?.trim(); + const body = parseNodeList("each"); // stops at {:empty} or {/each} + let empty = []; + if (src.startsWith("{:empty}", i)) { + i += "{:empty}".length; + empty = parseNodeList("each"); // stops at {/each} + } + if (!src.startsWith("{/each}", i)) + return fail("Expected `{/each}` to close `{#each}`"); + i += "{/each}".length; + return { type: "each", list, item, index, key, body, empty }; + } + /** Parse `{#if } … {:else if } … {:else} … {/if}`. */ + function parseIf() { + const header = readInterpolation(); // reads the full `{#if …}` + const m = /^\{#if\s+([\s\S]+?)\s*\}$/.exec(header); + if (!m) + return fail(`Invalid {#if …} header: ${header}`); + const branches = [ + { cond: m[1].trim(), body: parseNodeList("if") }, + ]; + for (;;) { + if (src.startsWith("{:else if", i)) { + const h = readInterpolation(); + const mm = /^\{:else if\s+([\s\S]+?)\s*\}$/.exec(h); + if (!mm) + return fail(`Invalid {:else if …}: ${h}`); + branches.push({ cond: mm[1].trim(), body: parseNodeList("if") }); + continue; + } + if (src.startsWith("{:else}", i)) { + i += "{:else}".length; + branches.push({ cond: null, body: parseNodeList("if") }); + continue; + } + break; + } + if (!src.startsWith("{/if}", i)) + return fail("Expected `{/if}` to close `{#if}`"); + i += "{/if}".length; + return { type: "if", branches }; + } + /** + * Parse a run of nodes. `mode` sets the terminator: + * - "root": stops at the view block's closing `}` + * - "element": stops at the parent element's closing tag (` { + if (text.length > 0) { + nodes.push({ type: "text", value: text }); + text = ""; + } + }; + for (;;) { + if (i >= src.length) { + return mode === "root" + ? fail("Unexpected end of view (missing `}`)") + : fail("Unclosed block"); + } + const c = src[i]; + if (c === "<") { + const next = src[i + 1]; + if (next === "/") { + flush(); + break; // parent's closing tag + } + if (src.startsWith("", i + 4); + i = end === -1 ? src.length : end + 3; + continue; + } + if (next !== undefined && (isNameStart(next) || next === "!")) { + flush(); + nodes.push(parseTag()); + continue; + } + // A lone `<` that doesn't start a tag: treat as literal text. + text += c; + i++; + continue; + } + if (c === "{") { + if (src.startsWith("{#each", i)) { + flush(); + nodes.push(parseEach()); + continue; + } + if (src.startsWith("{#if", i)) { + flush(); + nodes.push(parseIf()); + continue; + } + if (mode === "each" && (src.startsWith("{:empty}", i) || src.startsWith("{/each}", i))) { + flush(); + break; // loop-section terminator; left for parseEach + } + if (mode === "if" && (src.startsWith("{:else", i) || src.startsWith("{/if}", i))) { + flush(); + break; // conditional-section terminator; left for parseIf + } + text += readInterpolation(); + continue; + } + if (c === "}" && mode === "root") { + flush(); + break; // view terminator; leave `}` for the caller + } + text += c; + i++; + } + return nodes; + } + const nodes = parseNodeList("root"); + return { nodes, endPos: i }; +} + +}, +"packages/syntax/src/spec.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WRN_DIAGNOSTIC_CODES = exports.WRN_RUNTIME_TARGETS = exports.WRN_HYDRATION_STRATEGIES = exports.WRN_ROOT_MEMBERS = exports.WRN_ROOT_KINDS = exports.WRN_LANGUAGE_VERSION = void 0; +/** Canonical, machine-readable WRN language capabilities. */ +exports.WRN_LANGUAGE_VERSION = "1.0"; +exports.WRN_ROOT_KINDS = ["page", "component", "layout"]; +exports.WRN_ROOT_MEMBERS = [ + "layout", + "runtime", + "hydrate", + "client", + "types", + "props", + "state", + "computed", + "effect", + "watch", + "lifecycle", + "view", + "seo", + "security", + "load", + "action", + "api", + "ssr", + "realtime", + "style", + "functions", +]; +exports.WRN_HYDRATION_STRATEGIES = [ + "load", + "idle", + "visible", + "interaction", + "none", +]; +exports.WRN_RUNTIME_TARGETS = ["server", "client", "universal"]; +exports.WRN_DIAGNOSTIC_CODES = { + parse: "WRN-PARSE-001", + root: "WRN-PARSE-ROOT", + member: "WRN-PARSE-MEMBER", + propInitializer: "WRN-PROP-INITIALIZER", + stateInitializer: "WRN-STATE-INITIALIZER", + watchUndeclared: "WRN-WATCH-UNDECLARED", + duplicateSymbol: "WRN-SYMBOL-DUPLICATE", + invalidHydration: "WRN-HYDRATE-STRATEGY", + invalidRuntime: "WRN-RUNTIME-TARGET", + serverInteractive: "WRN-RUNTIME-SERVER-INTERACTIVE", + accessibility: "WRN-A11Y-001", +}; + +}, +"packages/syntax/src/tokenizer.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +/** + * Lexer for the `.wrn` language. + * + * `.wrn` mixes a small structural grammar (page/state/view/api/realtime) with + * raw JavaScript bodies. A pure token stream can't represent the raw JS, so the + * lexer is driven on demand by the parser: it yields structural tokens via + * `next()`/`peek()`, and exposes `readBalancedBraces()`, `readPath()` and + * `readToLineEnd()` for the parser to grab raw spans when grammar demands it. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Lexer = exports.LexError = void 0; +class LexError extends Error { +} +exports.LexError = LexError; +const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r"; +const isIdentStart = (c) => /[A-Za-z_]/.test(c); +const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c); +class Lexer { + src; + pos = 0; + constructor(src) { + this.src = src; + } + /** Skip whitespace and `// line comments`. */ + skipTrivia() { + const { src } = this; + while (this.pos < src.length) { + const c = src[this.pos]; + if (isWs(c)) { + this.pos++; + continue; + } + if (c === "/" && src[this.pos + 1] === "/") { + while (this.pos < src.length && src[this.pos] !== "\n") + this.pos++; + continue; + } + break; + } + } + /** Read and consume the next structural token. */ + next() { + this.skipTrivia(); + const { src } = this; + const pos = this.pos; + if (pos >= src.length) + return { type: "eof", value: "", pos }; + const c = src[pos]; + switch (c) { + case "{": + this.pos++; + return { type: "lbrace", value: c, pos }; + case "}": + this.pos++; + return { type: "rbrace", value: c, pos }; + case "(": + this.pos++; + return { type: "lparen", value: c, pos }; + case ")": + this.pos++; + return { type: "rparen", value: c, pos }; + case "@": + this.pos++; + return { type: "at", value: c, pos }; + case "=": + this.pos++; + return { type: "eq", value: c, pos }; + case ":": + this.pos++; + return { type: "colon", value: c, pos }; + case ",": + this.pos++; + return { type: "comma", value: c, pos }; + case '"': + case "'": + return this.readString(c, pos); + } + if (isIdentStart(c)) { + let v = ""; + while (this.pos < src.length && isIdentPart(src[this.pos])) + v += src[this.pos++]; + return { type: "ident", value: v, pos }; + } + throw new LexError(`Unexpected character '${c}' at offset ${pos} (line ${this.lineAt(pos)})`); + } + /** Look at the next token without consuming it. */ + peek() { + const save = this.pos; + const t = this.next(); + this.pos = save; + return t; + } + readString(quote, pos) { + const { src } = this; + let v = ""; + this.pos++; // opening quote + while (this.pos < src.length) { + const c = src[this.pos++]; + if (c === "\\") { + const n = src[this.pos++]; + v += n === "n" ? "\n" : n === "t" ? "\t" : n; + continue; + } + if (c === quote) + return { type: "string", value: v, pos }; + v += c; + } + throw new LexError(`Unterminated string at offset ${pos}`); + } + /** Read a route path like `/users/[id]` up to whitespace or `{`. */ + readPath() { + this.skipTrivia(); + const { src } = this; + let v = ""; + while (this.pos < src.length && !isWs(src[this.pos]) && src[this.pos] !== "{") { + v += src[this.pos++]; + } + if (!v) + throw new LexError(`Expected a path at offset ${this.pos}`); + return v; + } + /** + * Read a prop default initializer. The initializer may contain nested arrays, + * objects, calls, strings, or template literals. At top level it ends at a + * newline, the closing brace of the props block, or the next inline prop + * declaration (`name = ...` / `name: Type = ...`). + */ + readPropInitializer() { + const { src } = this; + while (this.pos < src.length && (src[this.pos] === " " || src[this.pos] === "\t")) { + this.pos++; + } + const start = this.pos; + let square = 0; + let brace = 0; + let paren = 0; + let angle = 0; + let quote = null; + const atTopLevel = () => square === 0 && brace === 0 && paren === 0 && angle === 0; + while (this.pos < src.length) { + const c = src[this.pos]; + if (quote) { + this.pos++; + if (c === "\\" && this.pos < src.length) { + this.pos++; + } + else if (c === quote) { + quote = null; + } + continue; + } + if (c === '"' || c === "'" || c === "`") { + quote = c; + this.pos++; + continue; + } + if (atTopLevel()) { + if (c === "\n" || c === "\r" || c === "}") + break; + if (c === " " || c === "\t") { + let look = this.pos; + while (look < src.length && (src[look] === " " || src[look] === "\t")) + look++; + const rest = src.slice(look); + if (/^[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=\r\n{}]+)?\s*=/.test(rest)) + break; + } + } + if (c === "[") + square++; + else if (c === "]" && square > 0) + square--; + else if (c === "{") + brace++; + else if (c === "}" && brace > 0) + brace--; + else if (c === "(") + paren++; + else if (c === ")" && paren > 0) + paren--; + else if (c === "<") + angle++; + else if (c === ">" && angle > 0) + angle--; + this.pos++; + } + const value = src.slice(start, this.pos).trim(); + if (!value) + throw new LexError(`Expected a prop initializer at offset ${start}`); + return value; + } + /** Read the rest of the current line (used for `state x = `). */ + readToLineEnd() { + const { src } = this; + let v = ""; + while (this.pos < src.length && src[this.pos] !== "\n") + v += src[this.pos++]; + return v.trim(); + } + /** + * Read a TypeScript-style type annotation after `:`. Reading stops at a + * top-level `=` or line ending, while nested object/tuple/generic syntax is + * preserved. The optional `=` is consumed for the caller. + */ + readTypeAnnotation() { + const { src } = this; + let value = ""; + let angle = 0; + let square = 0; + let brace = 0; + let paren = 0; + let quote = null; + while (this.pos < src.length) { + const c = src[this.pos]; + if (quote) { + value += c; + this.pos++; + if (c === "\\" && this.pos < src.length) + value += src[this.pos++]; + else if (c === quote) + quote = null; + continue; + } + if (c === '"' || c === "'" || c === "`") { + quote = c; + value += c; + this.pos++; + continue; + } + if (c === "<") + angle++; + else if (c === ">" && angle > 0) + angle--; + else if (c === "[") + square++; + else if (c === "]" && square > 0) + square--; + else if (c === "{") + brace++; + else if (c === "}" && brace > 0) + brace--; + else if (c === "(") + paren++; + else if (c === ")" && paren > 0) + paren--; + if (angle === 0 && square === 0 && brace === 0 && paren === 0) { + if (c === "=") { + this.pos++; + const type = value.trim(); + if (!type) + throw new LexError(`Expected a type annotation at offset ${this.pos}`); + return { type, hasDefault: true }; + } + if (c === "\n" || c === "\r") + break; + } + value += c; + this.pos++; + } + const type = value.trim(); + if (!type) + throw new LexError(`Expected a type annotation at offset ${this.pos}`); + return { type, hasDefault: false }; + } + /** + * Read a `{ ... }` block and return its INNER text (no outer braces), with + * brace counting that respects string and template literals so a `}` inside a + * string doesn't end the block early. + */ + readBalancedBraces() { + this.skipTrivia(); + const { src } = this; + if (src[this.pos] !== "{") { + throw new LexError(`Expected '{' at offset ${this.pos}`); + } + const start = this.pos + 1; + let depth = 0; + let i = this.pos; + let str = null; + for (; i < src.length; i++) { + const c = src[i]; + if (str) { + if (c === "\\") { + i++; + continue; + } + if (c === str) + str = null; + continue; + } + if (c === '"' || c === "'" || c === "`") { + str = c; + continue; + } + if (c === "{") + depth++; + else if (c === "}") { + depth--; + if (depth === 0) { + this.pos = i + 1; + return src.slice(start, i); + } + } + } + throw new LexError(`Unbalanced braces starting at offset ${this.pos}`); + } + lineAt(pos) { + let line = 1; + for (let i = 0; i < pos && i < this.src.length; i++) { + if (this.src[i] === "\n") + line++; + } + return line; + } +} +exports.Lexer = Lexer; + +}, +"packages/syntax/src/types.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +/** Utilities shared by typed `.wrn` parsing, validation, and code generation. */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runtimeTypeOf = runtimeTypeOf; +exports.inferredRuntimeType = inferredRuntimeType; +exports.validateTypedInitializer = validateTypedInitializer; +exports.eraseFunctionTypes = eraseFunctionTypes; +function runtimeTypeOf(annotation) { + if (!annotation) + return "unknown"; + const type = annotation.trim().replace(/^readonly\s+/, ""); + if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) + return "string"; + if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) + return "number"; + if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) + return "boolean"; + if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type)) + return "bigint"; + if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type)) + return "array"; + if (/^(?:Record\s*<|object\b|\{)/.test(type)) + return "object"; + if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type)) + return "function"; + return "unknown"; +} +function inferredRuntimeType(expression) { + const value = expression.trim(); + if (/^["'`]/.test(value)) + return "string"; + if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) + return "number"; + if (/^(?:true|false)$/.test(value)) + return "boolean"; + if (/^-?\d+n$/.test(value)) + return "bigint"; + if (value.startsWith("[")) + return "array"; + if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value)) + return "object"; + if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) { + return "function"; + } + return "unknown"; +} +function validateTypedInitializer(name, annotation, expression) { + if (!annotation || expression.trim() === "undefined" || expression.trim() === "null") + return null; + const expected = runtimeTypeOf(annotation); + const actual = inferredRuntimeType(expression); + if (expected === "unknown" || actual === "unknown" || expected === actual) + return null; + return `${name} is declared as ${annotation}, but its initializer is ${actual}`; +} +/** + * Browser behavior is evaluated as JavaScript, so erase TypeScript annotations + * from ordinary function declarations before serializing it into HTML. + * Server output retains the original typed source. + */ +function eraseFunctionTypes(source) { + return source.replace(/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g, (_whole, open, params, close, _returnType, brace) => { + const plainParams = params + .split(",") + .map((param) => param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim()) + .join(", "); + return `${open}${plainParams}${close}${brace}`; + }); +} + +} +}; +const __aliases = { + "@wrnexus/syntax": "packages/syntax/src/index.ts", + "@wrnexus/syntax/parser": "packages/syntax/src/parser.ts", + "@wrnexus/syntax/tokenizer": "packages/syntax/src/tokenizer.ts", + "@wrnexus/syntax/types": "packages/syntax/src/types.ts", + "@wrnexus/syntax/diagnostics": "packages/syntax/src/diagnostics.ts", + "@wrnexus/syntax/spec": "packages/syntax/src/spec.ts" +}; +const __cache = Object.create(null); +function __normalize(id) { + const normalized = id.split("\\").join("/"); + return normalized.startsWith("./") ? normalized.slice(2) : normalized; +} +function __resolve(request, parent) { + if (__aliases[request]) return __aliases[request]; + if (!request.startsWith(".")) return null; + const base = __normalize(__path.posix.join(__path.posix.dirname(parent), request)); + const candidates = [ + base, + base.endsWith(".js") ? base.slice(0, -3) + ".ts" : base, + base.endsWith(".ts") ? base : base + ".ts", + (base.endsWith("/") ? base.slice(0, -1) : base) + "/index.ts" + ]; + for (const candidate of candidates) { + if (__modules[candidate]) return candidate; + } + return null; +} +function __load(id) { + if (__cache[id]) return __cache[id].exports; + const factory = __modules[id]; + if (!factory) throw new Error("WRN editor compiler module not found: " + id); + const module = { exports: {} }; + __cache[id] = module; + const localRequire = (request) => { + const resolved = __resolve(request, id); + return resolved ? __load(resolved) : __nodeRequire(request); + }; + factory(module, module.exports, localRequire, id, __path.posix.dirname(id)); + return module.exports; +} +module.exports = __load("packages/compiler/src/index.ts"); diff --git a/editors/vscode/src/completion.js b/editors/vscode/src/completion.js index ad81d82c..39f20a75 100644 --- a/editors/vscode/src/completion.js +++ b/editors/vscode/src/completion.js @@ -106,6 +106,56 @@ const BLOCK_COMPLETIONS = [ documentation: "Declare reactive state owned by the current component, layout, or page.", snippet: 'state ${1:name}: ${2:string} = ${3:"value"}', }, + { + label: "runtime", + detail: "WRN execution target", + documentation: "Choose whether this declaration executes on the server, client, or both.", + snippet: 'runtime = "${1|universal,server,client|}"', + }, + { + label: "hydrate", + detail: "WRN hydration strategy", + documentation: "Choose when client interactivity is initialized for this declaration.", + snippet: 'hydrate = "${1|load,idle,visible,interaction,none|}"', + }, + { + label: "computed", + detail: "Derived reactive values block", + documentation: + "Declare values that are recomputed only when their reactive dependencies change.", + snippet: ["computed {", " ${1:displayName} = ${2:firstName + ' ' + lastName}", "}"].join( + "\n", + ), + }, + { + label: "effect", + detail: "Reactive side-effect block", + documentation: "Run browser-side code whenever its referenced reactive values change.", + snippet: ["effect {", " ${1:console.log(value)}", "}"].join("\n"), + }, + { + label: "security", + detail: "Route security metadata block", + documentation: "Declare authentication, authorization, CSRF, and rate-limit policy metadata.", + snippet: [ + "security {", + ' auth = "${1|required,optional,public|}"', + ' csrf = "${2:true}"', + "}", + ].join("\n"), + }, + { + label: "load", + detail: "Typed data-loading block", + documentation: "Load data on the server or client with an explicit execution boundary.", + snippet: ["load ${1|server,client|} {", " ${2:return {}}", "}"].join("\n"), + }, + { + label: "action", + detail: "Named server action", + documentation: "Declare a callable mutation with an explicit name and arguments.", + snippet: ["action ${1:save}(${2:input}) {", " $0", "}"].join("\n"), + }, { label: "functions", detail: "Browser component functions block", @@ -215,6 +265,8 @@ const CONTEXT_COMPLETIONS = [ ["ctx.user", "Authenticated user"], ["ctx.session", "Current session"], ["ctx.locals", "Request-local data"], + ["ctx.tenant", "Resolved tenant context"], + ["ctx.tracer", "Request tracing interface"], ]; const WATCH_VALUE_COMPLETIONS = [ diff --git a/editors/vscode/src/diagnostics.js b/editors/vscode/src/diagnostics.js index c67f0ef5..054bc262 100644 --- a/editors/vscode/src/diagnostics.js +++ b/editors/vscode/src/diagnostics.js @@ -11,33 +11,34 @@ const VALID_TOP_LEVEL_KINDS = new Set(["page", "component", "layout"]); const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]); +const ROOT_MEMBER_NAMES = [ + "layout", + "runtime", + "hydrate", + "client", + "types", + "props", + "state", + "computed", + "effect", + "watch", + "lifecycle", + "view", + "seo", + "security", + "load", + "action", + "api", + "ssr", + "realtime", + "style", + "functions", +]; + const VALID_MEMBERS = { - page: new Set([ - "layout", - "state", - "types", - "view", - "seo", - "style", - "functions", - "api", - "ssr", - "client", - "realtime", - ]), - - component: new Set([ - "types", - "props", - "state", - "view", - "style", - "functions", - "lifecycle", - "watch", - ]), - - layout: new Set(["types", "props", "state", "view", "style", "functions", "lifecycle", "watch"]), + page: new Set(ROOT_MEMBER_NAMES), + component: new Set(ROOT_MEMBER_NAMES), + layout: new Set(ROOT_MEMBER_NAMES), }; function createDiagnostic( @@ -544,21 +545,87 @@ function findRootMembers(source, bodyStart, bodyEnd) { end: identifier.end, }); - if (identifier.name === "state" || identifier.name === "layout") { + const assignmentMembers = new Set(["layout", "runtime", "hydrate", "state"]); + + if (assignmentMembers.has(identifier.name)) { skipLine(); continue; } - if (identifier.name === "watch") { - index = skipWhitespace(source, index, bodyEnd); + if (identifier.name === "client") { + const next = skipWhitespace(source, index, bodyEnd); - const watchedState = readIdentifier(source, index, bodyEnd); - - if (watchedState) { - index = watchedState.end; + if (source[next] === "=") { + skipLine(); + continue; } } + const blockMembers = new Set([ + "types", + "props", + "computed", + "effect", + "watch", + "lifecycle", + "view", + "seo", + "security", + "load", + "action", + "api", + "ssr", + "client", + "realtime", + "style", + "functions", + ]); + + if (blockMembers.has(identifier.name)) { + let cursor = index; + let parenthesisDepth = 0; + let bracketDepth = 0; + let memberQuote = null; + let memberEscaped = false; + + while (cursor < bodyEnd) { + const current = source[cursor]; + + if (memberQuote !== null) { + if (memberEscaped) { + memberEscaped = false; + } else if (current === "\\") { + memberEscaped = true; + } else if (current === memberQuote) { + memberQuote = null; + } + + cursor += 1; + continue; + } + + if (current === '"' || current === "'") { + memberQuote = current; + cursor += 1; + continue; + } + + if (current === "(") parenthesisDepth += 1; + if (current === ")") parenthesisDepth = Math.max(0, parenthesisDepth - 1); + if (current === "[") bracketDepth += 1; + if (current === "]") bracketDepth = Math.max(0, bracketDepth - 1); + + if (current === "{" && parenthesisDepth === 0 && bracketDepth === 0) { + index = cursor; + break; + } + + cursor += 1; + } + + if (cursor >= bodyEnd) index = bodyEnd; + } + continue; } diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js index 70cda245..15c85e8c 100644 --- a/editors/vscode/src/formatter.js +++ b/editors/vscode/src/formatter.js @@ -179,11 +179,14 @@ function parseOpeningTag(value) { const inlineClosing = remainder === ``; + const closesInRemainder = remainder.startsWith(``); + return { tagName, attributes, selfClosing, inlineClosing, + closesInRemainder, remainder, }; } @@ -202,7 +205,11 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) { const attributeIndent = unit.repeat(depth + 1); - const normalizedSingleLine = value.replace(/\s+/g, " ").trim(); + const normalizedOpening = `<${parsed.tagName}${ + parsed.attributes.length ? ` ${parsed.attributes.join(" ")}` : "" + }${parsed.selfClosing ? " /" : ""}>`; + + const normalizedSingleLine = `${normalizedOpening}${parsed.remainder}`; const shouldBreak = parsed.attributes.length > 1 || @@ -212,6 +219,7 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) { const opensElement = !parsed.selfClosing && !parsed.inlineClosing && + !parsed.closesInRemainder && !VOID_ELEMENTS.has(parsed.tagName.toLowerCase()); if (!shouldBreak) { @@ -375,7 +383,9 @@ function collectOpeningTag(inputLines, startIndex) { } return { - value: collected.join(" "), + // Preserve the fact that the opening tag was already multiline so a + // second formatter pass cannot collapse it back to one line. + value: collected.join("\n"), endIndex: index, }; } diff --git a/editors/vscode/syntaxes/wrn.tmLanguage.json b/editors/vscode/syntaxes/wrn.tmLanguage.json index 6a593e83..3d7dd6b2 100644 --- a/editors/vscode/syntaxes/wrn.tmLanguage.json +++ b/editors/vscode/syntaxes/wrn.tmLanguage.json @@ -82,6 +82,24 @@ { "include": "#realtime-block" }, + { + "include": "#execution-decl" + }, + { + "include": "#computed-block" + }, + { + "include": "#effect-block" + }, + { + "include": "#security-block" + }, + { + "include": "#load-block" + }, + { + "include": "#action-block" + }, { "include": "#mode-block" }, @@ -143,14 +161,24 @@ "types-block": { "begin": "\\b(types)\\b\\s*(\\{)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } }, "end": "\\}", "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } + "0": { + "name": "punctuation.definition.block.end.wrn" + } }, - "patterns": [{ "include": "source.ts" }] + "patterns": [ + { + "include": "source.ts" + } + ] }, "props-block": { "begin": "\\b(props)\\b\\s*(\\{)", @@ -757,6 +785,182 @@ ] } ] + }, + "execution-decl": { + "begin": "\\b(runtime|hydrate|client)\\b\\s*(=)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "keyword.operator.assignment.wrn" + } + }, + "end": "$", + "patterns": [ + { + "include": "#strings" + } + ] + }, + "computed-block": { + "begin": "\\b(computed)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "patterns": [ + { + "include": "#comments" + }, + { + "match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)", + "captures": { + "1": { + "name": "variable.other.readwrite.declaration.wrn" + }, + "2": { + "name": "keyword.operator.assignment.wrn" + } + } + }, + { + "include": "source.ts" + } + ] + }, + "effect-block": { + "begin": "\\b(effect)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + }, + "security-block": { + "begin": "\\b(security)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "patterns": [ + { + "include": "#comments" + }, + { + "match": "\\b([A-Za-z_$][A-Za-z0-9_$-]*)\\s*(=)", + "captures": { + "1": { + "name": "support.type.property-name.wrn" + }, + "2": { + "name": "keyword.operator.assignment.wrn" + } + } + }, + { + "include": "#strings" + } + ] + }, + "load-block": { + "begin": "\\b(load)\\b\\s+(server|client)\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "storage.modifier.wrn" + }, + "3": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + }, + "action-block": { + "begin": "\\b(action)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\([^)]*\\))\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "entity.name.function.wrn" + }, + "3": { + "name": "variable.parameter.wrn" + }, + "4": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] } } } diff --git a/editors/vscode/test/diagnostics.test.js b/editors/vscode/test/diagnostics.test.js index 184b5db0..5f7ea1f3 100644 --- a/editors/vscode/test/diagnostics.test.js +++ b/editors/vscode/test/diagnostics.test.js @@ -30,6 +30,7 @@ const { findTopLevelDeclaration, maskLeadingTrivia, validateBalancedCharacters, + validateRootMembers, } = require("../src/diagnostics"); Module._load = originalLoad; @@ -92,3 +93,25 @@ component ThemeToggle { assert.deepEqual(diagnostics, []); }); + +test("accepts all WRN 0.3 root members without false unknown-member errors", () => { + const source = `page Dashboard { + runtime = "universal" + hydrate = "visible" + computed { doubled = count * 2 } + effect { console.log(doubled) } + security { auth = "required" } + load server { return {} } + action save(input) { return input } + client { functions { function ready() {} } } + view {
{doubled}
} + }`; + const declaration = findTopLevelDeclaration({}, source); + const document = { + positionAt(offset) { + return offset; + }, + }; + + assert.deepEqual(validateRootMembers(document, source, declaration.kind, declaration.match), []); +}); diff --git a/editors/vscode/test/formatter.test.js b/editors/vscode/test/formatter.test.js index 1342c4b1..bdfa2a96 100644 --- a/editors/vscode/test/formatter.test.js +++ b/editors/vscode/test/formatter.test.js @@ -19,3 +19,21 @@ test("preserves nested prop defaults while formatting", () => { assert.match(formatted, /options = \{ gap: 4, dense: false \}/); assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted); }); + +test("keeps long inline-closing tags idempotent after multiline expansion", () => { + const source = `component Status { + view { +
+ + Loading + +
+ } +} +`; + const options = { insertSpaces: true, tabSize: 2, printWidth: 100 }; + const formatted = formatWrn(source, options); + + assert.equal(formatWrn(formatted, options), formatted); + assert.match(formatted, /<\/span>Loading/); +}); diff --git a/package.json b/package.json index 1486eba1..258537b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wrnexus", - "version": "0.1.0", + "version": "0.3.0", "private": true, "type": "module", "description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.", @@ -24,7 +24,8 @@ "lint:fix": "eslint . --fix", "format": "prettier . --write", "format:check": "prettier . --check", - "check": "bun run typecheck && bun run lint && bun run test && bun run format:check" + "check": "bun run typecheck && bun run lint && bun run test && bun run format:check", + "verify:0.3": "node scripts/verify-0.3.mjs" }, "devDependencies": { "@eslint/js": "latest", @@ -37,7 +38,7 @@ "typescript-eslint": "latest" }, "engines": { - "bun": ">=1.1.0" + "bun": ">=1.3.0" }, "overrides": { "esbuild": "0.28.1" diff --git a/packages/ai/package.json b/packages/ai/package.json index 1e566465..9ea81b1f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.79", + "version": "0.3.0", "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 09036d63..f1f54f65 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ab8607a..96593592 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { @@ -20,6 +20,8 @@ "@wrnexus/ui": "workspace:*", "@wrnexus/validation": "workspace:*", "@wrnexus/i18n": "workspace:*", - "@wrnexus/db": "workspace:*" + "@wrnexus/db": "workspace:*", + "@wrnexus/plugin": "workspace:*", + "@wrnexus/syntax": "workspace:*" } } diff --git a/packages/cli/src/analyze.ts b/packages/cli/src/analyze.ts new file mode 100644 index 00000000..12710f85 --- /dev/null +++ b/packages/cli/src/analyze.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +interface BuildReport { + frameworkVersion: string; + generatedAt: string; + adapter: string; + routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>; + assets: Array<{ file: string; bytes: number }>; + measurements: Record; + budgetViolations: Array<{ metric: string; budget: number; actual: number; overBy: number }>; +} + +function bytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(2)} MB`; +} + +export function runAnalyze(appRoot: string, args: string[]): boolean { + const root = resolve(appRoot); + const path = join(root, "dist", "build-report.json"); + if (!existsSync(path)) { + console.error("WRN-BUILD-REPORT-MISSING: run `wrnexus build` before `wrnexus analyze`."); + return false; + } + const report = JSON.parse(readFileSync(path, "utf8")) as BuildReport; + if (args.includes("--json")) { + console.log(JSON.stringify(report, null, 2)); + return report.budgetViolations.length === 0; + } + + console.log(`WRNexus build analysis (${report.frameworkVersion})`); + console.log(` Generated: ${report.generatedAt}`); + console.log(` Adapter: ${report.adapter}`); + console.log(` Routes: ${report.routes.length}`); + console.log("\nMeasurements:"); + for (const [metric, value] of Object.entries(report.measurements)) { + console.log(` ${metric.padEnd(18)} ${bytes(value)}`); + } + console.log("\nLargest assets:"); + for (const asset of report.assets.slice(0, 12)) { + console.log(` ${bytes(asset.bytes).padStart(10)} ${asset.file}`); + } + if (report.budgetViolations.length) { + console.log("\nBudget violations:"); + for (const violation of report.budgetViolations) { + console.log( + ` ✗ ${violation.metric}: ${bytes(violation.actual)} > ${bytes(violation.budget)}`, + ); + } + } else { + console.log("\n ✓ No configured performance budget violations."); + } + return report.budgetViolations.length === 0; +} diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index a5006e5b..6950565e 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -11,11 +11,20 @@ */ import { createHash } from "node:crypto"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { join, resolve } from "node:path"; import { buildRouter, type Route } from "@wrnexus/router"; import { getReactiveRuntime } from "@wrnexus/csr"; -import { compileWireFile } from "@wrnexus/compiler"; +import { assertValidAst, generate, parse } from "@wrnexus/compiler"; import { loadAppConfig, headToString, @@ -30,6 +39,8 @@ import { uiComponentsDir, uiCss } from "@wrnexus/ui"; import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation"; import { loadLocales, resolveI18n } from "@wrnexus/i18n"; import { pathToFileURL } from "node:url"; +import { checkPerformanceBudgets } from "@wrnexus/core"; +import { createPluginRunner } from "@wrnexus/plugin"; // Import the production server from the package specifier (not a source path) so // the generated entry resolves whether @wrnexus/dev-server is a workspace or an @@ -58,23 +69,47 @@ export async function runBuild(appRoot: string): Promise { console.log(`✓ Public: ${distPublicDir}`); } - // `.wrn` route files are compiled to `.ts` so Bun.build can bundle them. - let compiledCount = 0; - const importPathFor = (file: string): string => { - if (!file.endsWith(".wrn")) { - return fwd(file); - } - - const ts = compileWireFile(readFileSync(file, "utf8"), file); - - const out = join(compiledDir, `route${compiledCount++}.ts`); - - writeFileSync(out, ts, "utf8"); - - return fwd(out); - }; - const config = await loadAppConfig(root); + const pluginRunner = createPluginRunner(config.plugins, { + root, + mode: "production", + command: "build", + profile: process.env.WRNEXUS_PROFILE, + metadata: new Map(), + warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), + }); + await pluginRunner.configure(config as Record); + await pluginRunner.configResolved(config as Readonly>); + await pluginRunner.hook("buildStart"); + + // `.wrn` route files are compiled once into deterministic intermediate modules. + // Plugin AST/code transforms run only when configured, so existing applications + // keep the exact compiler path and output contract by default. + let compiledCount = 0; + const compiledFiles = new Map(); + const compileWrn = async (file: string): Promise => { + if (!file.endsWith(".wrn") || compiledFiles.has(file)) return; + const source = readFileSync(file, "utf8"); + let ast = parse(source); + assertValidAst(ast, { file, accessibility: true }); + ast = await pluginRunner.transformAst(ast, file); + const pluginDiagnostics = await pluginRunner.diagnostics(ast, file); + const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error"); + for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) { + console.warn(`[${diagnostic.code}] ${file}: ${diagnostic.message}`); + } + if (errors.length) { + throw new Error( + errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"), + ); + } + let code = `// compiled from .wrn\n${generate(ast)}`; + code = await pluginRunner.transformCode(code, file); + const out = join(compiledDir, `route${compiledCount++}.ts`); + writeFileSync(out, code, "utf8"); + compiledFiles.set(file, out); + }; + const importPathFor = (file: string): string => fwd(compiledFiles.get(file) ?? file); // Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so // any page/API importing them is built against the current SQL. const { regenerateQueries } = await import("./db.ts"); @@ -105,6 +140,14 @@ export async function runBuild(appRoot: string): Promise { } const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] }); + const wrnFiles = new Set([ + ...router.pages.map((route) => route.file), + ...router.api.map((route) => route.file), + ...router.realtime.map((route) => route.file), + ...router.components.map((component) => component.file), + ...router.layouts.map((layout) => layout.file), + ]); + for (const file of wrnFiles) await compileWrn(file); const assetHash = createHash("sha256"); // 1) Components are `.wrn` modules rendered server-side — no browser chunks. @@ -271,6 +314,8 @@ await createProductionServer( mobile: ${JSON.stringify(config.mobile ?? {})}, pwa: ${JSON.stringify(config.pwa ?? {})}, security: ${JSON.stringify(config.security ?? {})}, + observability: ${JSON.stringify(config.observability ?? {})}, + tenancy: ${JSON.stringify(config.tenancy ?? {})}, }, ); `; @@ -285,19 +330,118 @@ await createProductionServer( target: "bun", format: "esm", minify: true, + sourcemap: config.build?.sourceMaps ? "inline" : "none", }); if (!result.success) { throw new Error("Server build failed:\n" + result.logs.map(String).join("\n")); } writeFileSync(join(distDir, "server.js"), await result.outputs[0]!.text(), "utf8"); + const report = createBuildReport({ + root, + distDir, + publicDir: distPublicDir, + adapter: config.build?.adapter ?? "bun", + routes: router.pages, + runtimeFile: reactivePath, + cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"), + }); + const violations = checkPerformanceBudgets( + config.performance?.budgets ?? {}, + report.measurements, + ); + report.budgetViolations = violations; + writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8"); + await pluginRunner.hook("buildEnd", report); + console.log(`✓ Server: ${join(distDir, "server.js")}`); console.log( `✓ Routes: ${router.pages.length} pages, ${router.api.length} api, ${router.realtime.length} realtime, ${mwVars.length} middleware`, ); + console.log(`✓ Report: ${join(distDir, "build-report.json")}`); + if (violations.length) { + for (const violation of violations) { + console.warn( + `⚠ Budget ${violation.metric}: ${violation.actual} > ${violation.budget} (+${violation.overBy})`, + ); + } + if (config.performance?.enforcement === "error") { + throw new Error( + `WRN-PERFORMANCE-BUDGET: ${violations.length} production budget(s) exceeded.`, + ); + } + } console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`); } +interface BuildReport { + frameworkVersion: string; + generatedAt: string; + root: string; + adapter: string; + routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>; + assets: Array<{ file: string; bytes: number }>; + measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number }; + budgetViolations: ReturnType; +} + +function fileBytes(file: string): number { + return existsSync(file) && statSync(file).isFile() ? statSync(file).size : 0; +} + +function walkFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) files.push(...walkFiles(path)); + else if (stat.isFile()) files.push(path); + } + return files; +} + +function createBuildReport(input: { + root: string; + distDir: string; + publicDir: string; + adapter: string; + routes: Route[]; + runtimeFile: string; + cssFile: string; +}): BuildReport { + const assets = walkFiles(input.distDir) + .filter((file) => !file.endsWith("build-report.json") && !fwd(file).includes("/compiled/")) + .map((file) => ({ file: fwd(file.slice(input.distDir.length + 1)), bytes: fileBytes(file) })) + .sort((a, b) => b.bytes - a.bytes); + const imageExtensions = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i; + const imageBytes = Math.max( + 0, + ...walkFiles(input.publicDir) + .filter((file) => imageExtensions.test(file)) + .map(fileBytes), + ); + return { + frameworkVersion: "0.3.0", + generatedAt: new Date().toISOString(), + root: input.root, + adapter: input.adapter, + routes: input.routes.map((route) => ({ + path: route.raw, + source: fwd(route.file.replace(input.root, "").replace(/^\//, "")), + sourceBytes: fileBytes(route.file), + dynamicParams: route.paramNames, + })), + assets, + measurements: { + routeJsBytes: fileBytes(input.runtimeFile), + routeCssBytes: fileBytes(input.cssFile), + imageBytes, + }, + budgetViolations: [], + }; +} + async function buildBrowserRuntime( source: string, outFile: string, diff --git a/packages/cli/src/config-command.ts b/packages/cli/src/config-command.ts new file mode 100644 index 00000000..8c8a8447 --- /dev/null +++ b/packages/cli/src/config-command.ts @@ -0,0 +1,29 @@ +import { resolve } from "node:path"; +import { explainAppConfig } from "@wrnexus/styles"; + +function jsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`; + return value; +} + +export async function runConfigCommand(appRoot: string, args: string[]): Promise { + const root = resolve(appRoot); + const profile = args.find((arg) => arg.startsWith("--profile="))?.split("=")[1]; + const explained = await explainAppConfig(root, profile); + if (args.includes("--json")) { + console.log(JSON.stringify(explained, jsonReplacer, 2)); + return; + } + console.log(`WRNexus resolved configuration\n`); + console.log(` Root: ${root}`); + console.log(` Profile: ${explained.profile}`); + console.log(` Sources: ${explained.sources.join(", ") || "framework defaults"}`); + if (explained.issues.length) { + console.log("\nIssues:"); + for (const issue of explained.issues) { + console.log(` ${issue.severity === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`); + } + } + console.log("\nResolved value:\n"); + console.log(JSON.stringify(explained.config, jsonReplacer, 2)); +} diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 40a103d4..401ecb36 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -1,26 +1,99 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { diagnose } from "@wrnexus/syntax"; +import { buildRouter, findRouteConflicts } from "@wrnexus/router"; +import { loadAppConfig, validateAppConfig } from "@wrnexus/styles"; export interface DoctorCheck { name: string; ok: boolean; detail: string; + level?: "error" | "warning"; +} + +function parseVersion(value: string): [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.replace(/^[^\d]*/, "")); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0]; +} + +function versionAtLeast(value: string, minimum: string): boolean { + const left = parseVersion(value); + const right = parseVersion(minimum); + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return left[i]! > right[i]!; + } + return true; +} + +function walk(dir: string, extension: string): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir)) { + if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue; + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) files.push(...walk(path, extension)); + else if (stat.isFile() && extname(path) === extension) files.push(path); + } + return files; +} + +function frameworkRanges(pkg: Record): Map { + const ranges = new Map(); + for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { + const deps = pkg[field] as Record | undefined; + for (const [name, range] of Object.entries(deps ?? {})) { + if (!name.startsWith("@wrnexus/")) continue; + const values = ranges.get(range) ?? []; + values.push(name); + ranges.set(range, values); + } + } + return ranges; } export function inspectProject(appRoot: string): DoctorCheck[] { const root = resolve(appRoot); const checks: DoctorCheck[] = []; const pkgPath = join(root, "package.json"); + const bunVersion = typeof Bun !== "undefined" ? String(Bun.version) : ""; checks.push({ name: "Bun runtime", - ok: typeof Bun !== "undefined", - detail: typeof Bun !== "undefined" ? `v${Bun.version}` : "Bun is required", + ok: !!bunVersion && versionAtLeast(bunVersion, "1.3.0"), + detail: bunVersion ? `v${bunVersion} (minimum 1.3.0)` : "Bun 1.3.0 or newer is required", }); checks.push({ name: "package.json", ok: existsSync(pkgPath), - detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WrNexus project root", + detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WRNexus project root", }); + + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record; + const ranges = frameworkRanges(pkg); + checks.push({ + name: "framework package versions", + ok: ranges.size <= 1, + detail: + ranges.size <= 1 + ? ([...ranges.keys()][0] ?? "No @wrnexus packages declared") + : `version skew: ${[...ranges.entries()] + .map(([range, names]) => `${range} (${names.join(", ")})`) + .join("; ")}`, + }); + const marker = (pkg.wrnexus as { version?: string } | undefined)?.version; + checks.push({ + name: "update marker", + ok: !marker || versionAtLeast(marker, "0.3.0"), + detail: marker ? `project last migrated to ${marker}` : "missing; run `wrnexus update`", + level: "warning", + }); + } catch { + checks.push({ name: "package JSON", ok: false, detail: "package.json is invalid JSON" }); + } + } + const app = join(root, "app"); checks.push({ name: "app/pages", @@ -34,13 +107,51 @@ export function inspectProject(appRoot: string): DoctorCheck[] { name: "configuration", ok: !!config, detail: config ?? "No wrnexus.config file; framework defaults will be used", + level: "warning", }); + + const wrnFiles = walk(app, ".wrn"); + let syntaxErrors = 0; + let syntaxWarnings = 0; + for (const file of wrnFiles) { + const diagnostics = diagnose(readFileSync(file, "utf8"), { file, accessibility: true }); + syntaxErrors += diagnostics.filter((item) => item.severity === "error").length; + syntaxWarnings += diagnostics.filter((item) => item.severity !== "error").length; + } + checks.push({ + name: "WRN language", + ok: syntaxErrors === 0, + detail: `${wrnFiles.length} files, ${syntaxErrors} errors, ${syntaxWarnings} warnings`, + }); + + if (existsSync(join(app, "pages"))) { + try { + const router = buildRouter(app); + const conflicts = [ + ...findRouteConflicts(router.pages), + ...findRouteConflicts(router.api), + ...findRouteConflicts(router.realtime), + ]; + checks.push({ + name: "route manifest", + ok: conflicts.length === 0, + detail: conflicts.length + ? conflicts.map((conflict) => conflict.raw).join(", ") + : `${router.pages.length} pages, ${router.api.length} API, ${router.realtime.length} realtime`, + }); + } catch (error) { + checks.push({ + name: "route manifest", + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } + } + const mobilePkg = join(root, "mobile", "package.json"); if (existsSync(mobilePkg)) { try { - const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as { - wrnexus?: { mode?: string }; - }; + const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as { wrnexus?: { mode?: string } }; checks.push({ name: "mobile project", ok: mobile.wrnexus?.mode === "webview" || mobile.wrnexus?.mode === "native", @@ -57,12 +168,34 @@ export function inspectProject(appRoot: string): DoctorCheck[] { return checks; } -export function runDoctor(appRoot: string): boolean { - const checks = inspectProject(appRoot); - console.log("WrNexus doctor\n"); - for (const check of checks) - console.log(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`); +export async function runDoctor(appRoot: string): Promise { + const root = resolve(appRoot); + const checks = inspectProject(root); + try { + const config = await loadAppConfig(root); + const issues = validateAppConfig(config); + checks.push({ + name: "resolved configuration", + ok: issues.every((issue) => issue.severity !== "error"), + detail: issues.length + ? issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ") + : "valid", + level: issues.some((issue) => issue.severity === "error") ? "error" : "warning", + }); + } catch (error) { + checks.push({ + name: "resolved configuration", + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } + + console.log("WRNexus doctor\n"); + for (const check of checks) { + const optional = check.level === "warning"; + console.log(` ${check.ok ? "✓" : optional ? "⚠" : "✗"} ${check.name}: ${check.detail}`); + } console.log("\n Security dependencies: run `bun audit`"); console.log(" Complete verification: run `bun run check`"); - return checks.every((check) => check.ok || check.name === "configuration"); + return checks.every((check) => check.ok || check.level === "warning"); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3a3b1820..8ef4ee6f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -60,7 +60,9 @@ Usage: wrnexus db Migrations: migrate | rollback | status | seed | generate | new wrnexus test [app-dir] [--watch] Run the app's tests (bun test, 'test' profile) wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files - wrnexus doctor [app-dir] Check project structure, runtime, mobile config, and next fixes + wrnexus doctor [app-dir] Check project structure, versions, syntax, routes, and config + wrnexus config [app-dir] --explain Print the fully resolved profile configuration + wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets Profiles: pass --profile= to dev/build/db (or set WRNEXUS_PROFILE) to load that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat @@ -181,7 +183,18 @@ async function main(): Promise { } case "doctor": { const { runDoctor } = await import("./doctor.ts"); - const healthy = runDoctor(rest.find((a) => !a.startsWith("--")) ?? "."); + const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? "."); + if (!healthy) process.exitCode = 1; + break; + } + case "config": { + const { runConfigCommand } = await import("./config-command.ts"); + await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest); + break; + } + case "analyze": { + const { runAnalyze } = await import("./analyze.ts"); + const healthy = runAnalyze(rest.find((a) => !a.startsWith("--")) ?? ".", rest); if (!healthy) process.exitCode = 1; break; } diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index a7a39aef..bebeb94a 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -12,12 +12,20 @@ * the running CLI's own version (the default — pair with `bunx @wrnexus/cli@latest * update` to jump to the newest release with no network guesswork). * - * Migrations are CONSERVATIVE: they only add/refresh framework-owned things and - * never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS` + * Migrations are CONSERVATIVE: source rewrites are syntax-aware, idempotent, and + * protected by a complete pre-update backup. User-owned files are never replaced wholesale. Add new ones to `MIGRATIONS` * as the framework evolves — that is how "new things" reach existing apps. */ -import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; import { spawnSync } from "node:child_process"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -86,9 +94,95 @@ const LEGACY_VSCODE_EXTENSIONS = 2, ) + "\n"; +function walkProjectFiles(dir: string, extension: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue; + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) out.push(...walkProjectFiles(path, extension)); + else if (stat.isFile() && path.endsWith(extension)) out.push(path); + } + return out; +} + +function formatInlineProps(source: string): string { + return source.replace( + /(^[ \t]*)props\s*\{([^{}\n]*)\}/gm, + (whole, indent: string, body: string) => { + const declaration = + /([A-Za-z_$][\w$]*)(\s*:\s*[^=]+?)?\s*=\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[\]|\{\}|true|false|null|undefined|-?\d+(?:\.\d+)?)/gy; + const values: string[] = []; + let offset = 0; + while (offset < body.length) { + while (/\s/.test(body[offset] ?? "")) offset++; + if (offset >= body.length) break; + declaration.lastIndex = offset; + const match = declaration.exec(body); + if (!match || match.index !== offset) return whole; + values.push(`${match[1]}${match[2] ?? ""} = ${match[3]}`); + offset = declaration.lastIndex; + } + if (values.length < 2) return whole; + return `${indent}props {\n${values.map((value) => `${indent} ${value}`).join("\n")}\n${indent}}`; + }, + ); +} + +function quoteLegacyDynamicAttributes(source: string): string { + let output = ""; + let index = 0; + while (index < source.length) { + const start = source.indexOf("<", index); + if (start < 0) return output + source.slice(index); + output += source.slice(index, start); + if (source.startsWith("", start + 4); + if (end < 0) return output + source.slice(start); + output += source.slice(start, end + 3); + index = end + 3; + continue; + } + let end = start + 1; + let quote = ""; + let braceDepth = 0; + for (; end < source.length; end++) { + const char = source[end]!; + if (quote) { + if (char === "\\") end++; + else if (char === quote) quote = ""; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (char === "{") braceDepth++; + else if (char === "}") braceDepth = Math.max(0, braceDepth - 1); + else if (char === ">" && braceDepth === 0) break; + } + if (end >= source.length) return output + source.slice(start); + let tag = source.slice(start, end + 1); + if (!/^<\/?[A-Za-z]/.test(tag) || /^<\//.test(tag)) { + output += tag; + index = end + 1; + continue; + } + tag = tag.replace( + /(\s[@:#A-Za-z_$][\w$:.-]*)\s*=\s*\{([^{}']+)\}/g, + (_all, name: string, expr: string) => `${name}='{${expr.trim()}}'`, + ); + output += tag; + index = end + 1; + } + return output; +} + +export function migrateWrnSource(source: string): string { + return formatInlineProps(quoteLegacyDynamicAttributes(source)); +} + /** - * Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT - * overwrite user code. Append new entries with the version that ships them. + * Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source + * migrations must preserve semantics and are protected by update backups. */ const MIGRATIONS: Migration[] = [ { @@ -359,7 +453,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.32", - id: "fix-runtime-bug", + id: "fix-runtime-bug-0-2-32", description: "Fixes safe encoding and browser parsing of multiline component behavior metadata.", apply() { @@ -368,7 +462,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.33", - id: "fix-runtime-bug", + id: "fix-runtime-bug-0-2-33", description: "Fixes safe encoding and browser parsing of multiline component behavior metadata.", apply() { @@ -431,7 +525,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.40", - id: "conditions-bug", + id: "conditions-bug-0-2-40", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -440,7 +534,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.41", - id: "conditions-bug", + id: "conditions-bug-0-2-41", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -449,7 +543,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.42", - id: "conditions-bug", + id: "conditions-bug-0-2-42", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -458,7 +552,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.43", - id: "conditions-bug", + id: "conditions-bug-0-2-43", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -467,7 +561,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.44", - id: "conditions-bug", + id: "conditions-bug-0-2-44", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -476,7 +570,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.45", - id: "conditions-bug", + id: "conditions-bug-0-2-45", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -485,7 +579,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.46", - id: "conditions-bug", + id: "conditions-bug-0-2-46", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -494,7 +588,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.47", - id: "conditions-bug", + id: "conditions-bug-0-2-47", description: "Adds server-rendered component each/if blocks and fixes dynamic component rendering.", apply() { @@ -503,7 +597,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.48", - id: "components-update", + id: "components-update-0-2-48", description: "Adds reactive hydration support for server-rendered each loop locals.", apply() { // No generated application files require automatic migration. @@ -511,7 +605,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.49", - id: "components-update", + id: "components-update-0-2-49", description: "Adds reactive hydration support for server-rendered each loop locals.", apply() { // No generated application files require automatic migration. @@ -519,7 +613,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.50", - id: "components-update", + id: "components-update-0-2-50", description: "Adds reactive hydration support for server-rendered each loop locals.", apply() { // No generated application files require automatic migration. @@ -769,7 +863,7 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.78", - id: "ui-components-fix", + id: "ui-components-fix-2", description: "Dev toolbar for optimization and developer.", apply() { // Language and shared UI behavior improve automatically after updating. @@ -777,12 +871,87 @@ const MIGRATIONS: Migration[] = [ }, { version: "0.2.79", - id: "ui-components-fix", + id: "ui-components-fix-3", description: "Dev toolbar for optimization and developer.", apply() { // Language and shared UI behavior improve automatically after updating. }, }, + { + version: "0.3.0", + id: "language-foundation-and-compatible-platform-upgrade", + description: + "Adds the shared syntax/AST package, stable diagnostics, plugins, partial hydration, advanced routing, build analysis, tracing, typed data APIs, and safe WRN source normalization.", + apply(ctx) { + const runnable = existsSync(join(ctx.appRoot, "app", "pages")); + const packageFile = join(ctx.appRoot, "package.json"); + const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record; + const scripts = (pkg.scripts ??= {}); + const dependencies = (pkg.dependencies ??= {}); + const addedScripts: string[] = []; + const addedDependencies: string[] = []; + if (runnable) { + for (const [name, command] of Object.entries({ + doctor: "wrnexus doctor .", + "config:explain": "wrnexus config . --explain", + analyze: "wrnexus analyze .", + "update:preview": "wrnexus update . --dry-run", + })) { + if (!scripts[name]) { + scripts[name] = command; + addedScripts.push(name); + } + } + for (const name of ["@wrnexus/syntax", "@wrnexus/plugin"]) { + if (!dependencies[name]) { + dependencies[name] = `^${ctx.to}`; + addedDependencies.push(name); + } + } + } + if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`); + if (addedDependencies.length) ctx.log(`+ dependencies: ${addedDependencies.join(", ")}`); + if (!ctx.dryRun && (addedScripts.length || addedDependencies.length)) { + writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8"); + } + + const changedFiles: string[] = []; + for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) { + const before = readFileSync(file, "utf8"); + const after = migrateWrnSource(before); + if (after === before) continue; + changedFiles.push(file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/")); + ctx.log(`~ normalized ${changedFiles[changedFiles.length - 1]}`); + if (!ctx.dryRun) writeFileSync(file, after, "utf8"); + } + + const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.3.0.json"); + ctx.log(`+ .wrnexus/migrations/0.3.0.json (${changedFiles.length} source files normalized)`); + if (!ctx.dryRun) { + mkdirSync(dirname(reportFile), { recursive: true }); + writeFileSync( + reportFile, + JSON.stringify( + { + version: "0.3.0", + from: ctx.from, + appliedAt: new Date().toISOString(), + changedFiles, + compatibility: { + legacyDataFor: true, + legacyEachBlocks: true, + legacyComponentMounts: true, + quotedDynamicAttributes: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + } + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ @@ -862,12 +1031,13 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri ".vscode/extensions.json", "CLAUDE.md", "public/llms.txt", + "app", ]) { const source = join(appRoot, name); if (existsSync(source)) { const destination = join(backup, name); mkdirSync(dirname(destination), { recursive: true }); - cpSync(source, destination); + cpSync(source, destination, { recursive: statSync(source).isDirectory() }); } } return backup; diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts index 5468055a..b0f17522 100644 --- a/packages/cli/test/update.test.ts +++ b/packages/cli/test/update.test.ts @@ -168,3 +168,23 @@ test("update verification formats before checking and building", () => { test("update verification skips unavailable scripts", () => { expect(verificationCommands({ check: "bun run lint" })).toEqual([["run", "check"]]); }); + +test("0.3 WRN source normalization is conservative and idempotent", async () => { + const { migrateWrnSource } = await import("../src/update.ts"); + const source = `component Card { + props { eyebrow = "" title = "" description = "" align = "start" class = "" } + view { + + + } + }`; + + const migrated = migrateWrnSource(source); + expect(migrated).toContain("props {\n"); + expect(migrated).toContain(' eyebrow = ""'); + expect(migrated).toContain("items='{items}'"); + expect(migrated).toContain("@click='{save()}'"); + // Nested brace expressions are deliberately left untouched for manual review. + expect(migrated).toContain("data-config={{ nested: true }}"); + expect(migrateWrnSource(migrated)).toBe(migrated); +}); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 6c041a7d..b5c8685b 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,9 +1,12 @@ { "name": "@wrnexus/compiler", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" + }, + "dependencies": { + "@wrnexus/syntax": "workspace:*" } } diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 110e892c..0fb5d366 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -654,6 +654,32 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise>> 0).toString(36); +} + +function hydrationId(ast: PageAst): string { + const shape = JSON.stringify({ + kind: ast.kind, + name: ast.name, + props: ast.props.map((entry) => entry.name), + states: ast.states.map((entry) => entry.name), + computed: ast.computed.map((entry) => entry.name), + view: ast.view, + }); + return `${ast.name}:${stableHash(shape)}`; +} + +function hydrationAttribute(ast: PageAst): string { + const strategy = ast.hydrate ?? "load"; + return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"`; +} + export function generate(ast: PageAst): string { if (ast.kind === "component" || ast.kind === "layout") { return generateComponent(ast); @@ -682,22 +708,46 @@ export function generate(ast: PageAst): string { // --- Page metadata / SEO --- out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`); if (ast.layout) out.push(`export const layout = ${JSON.stringify(ast.layout)};`); + out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); + out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`); + out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); + if (Object.keys(ast.security).length > 0) { + out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); + } // --- View -> default page component --- + const seedScope = evalStateSeeds(ast.states); + for (const entry of ast.computed) { + try { + seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call( + seedScope, + ); + } catch { + seedScope[entry.name] = undefined; + } + } + const reactiveNames = [ + ...ast.states.map((entry) => entry.name), + ...ast.computed.map((entry) => entry.name), + ]; const reactive: PageReactive | null = - ast.states.length > 0 - ? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) } - : null; + reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), scope: seedScope } : null; const loops: string[] = []; let html = ast.view .map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive)) .join(""); const styles = ast.styles.map((body) => body.trim()).filter(Boolean); - const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view); + const pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast); + const needsClientRuntime = + ast.runtime !== "server" && + (ast.states.length > 0 || + ast.computed.length > 0 || + hasClientBehavior(ast.view) || + pageBehavior !== null); if (needsClientRuntime) { const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__"; - html = `
${html}
`; + html = `
${html}
`; } if (styles.length > 0) { @@ -707,6 +757,9 @@ export function generate(ast: PageAst): string { if (csrBindings.length > 0) { out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`); } + if (pageBehavior) { + out.push(`export const __wrnexusBehavior = ${JSON.stringify(pageBehavior, null, 2)};`); + } // Escape the static HTML for the template literal, then swap loop sentinels for // their real `${…}` code (which must NOT be escaped). @@ -805,6 +858,32 @@ export function generate(ast: PageAst): string { ); } + if (ast.loads.length > 0) { + const serverLoads = ast.loads.filter((entry) => entry.mode === "server"); + const clientLoads = ast.loads.filter((entry) => entry.mode === "client"); + if (serverLoads.length > 0) { + out.push( + `export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`, + ); + } + if (clientLoads.length > 0) { + out.push( + `export async function __wrnexusClientLoad(ctx: any) { +${clientLoads.map((entry) => entry.body).join("\n")} +}`, + ); + } + } + + if (ast.actions.length > 0) { + for (const action of ast.actions) { + out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`); + } + out.push( + `export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`, + ); + } + // --- API blocks -> method handlers --- if (ast.apis.length > 0) { ast.apis.forEach((api, index) => { @@ -863,6 +942,8 @@ interface CompCtx { interface ComponentBehavior { functions: string; + computed: Array<{ name: string; expr: string }>; + effects: string[]; lifecycle: { mount?: string; update?: string; @@ -874,13 +955,16 @@ interface ComponentBehavior { }>; } -/** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */ -export function parseForExpr(value: string): { item: string; index?: string; list: string } | null { - const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec( - value, - ); +/** Parse a `data-for="item in list [key expr]"` / `"item, i in list [key expr]"` directive. */ +export function parseForExpr( + value: string, +): { item: string; index?: string; list: string; key?: string } | null { + const m = + /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec( + value, + ); if (!m) return null; - return { item: m[1]!, index: m[2], list: m[3]! }; + return { item: m[1]!, index: m[2], list: m[3]!.trim(), key: m[4]?.trim() }; } /** The loop variables a node introduces via `data-for`, if any. */ @@ -953,6 +1037,9 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null { .join("\n\n"), ); + const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() })); + const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean); + const lifecycle = { ...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}), @@ -966,12 +1053,20 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null { body: watch.body.trim(), })); - if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) { + if ( + !functions && + computed.length === 0 && + effects.length === 0 && + Object.keys(lifecycle).length === 0 && + watches.length === 0 + ) { return null; } return { functions, + computed, + effects, lifecycle, watches, }; @@ -1357,12 +1452,16 @@ function generateComponent(ast: PageAst): string { ] : ast.props; - const stateNames = new Set(ast.states.map((s) => s.name)); + const stateNames = new Set([ + ...ast.states.map((entry) => entry.name), + ...ast.computed.map((entry) => entry.name), + ]); const nameRefs = new Map(); for (const p of effectiveProps) { nameRefs.set(p.name, safeRef(p.name)); } for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name)); + for (const entry of ast.computed) nameRefs.set(entry.name, safeRef(entry.name)); const resolveExpr = (expr: string): string => { let result = expr; for (const [name, ref] of nameRefs) { @@ -1391,7 +1490,12 @@ function generateComponent(ast: PageAst): string { // no JavaScript at all. const behavior = componentBehavior(ast); - const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null; + const needsScope = + ast.runtime !== "server" && + (ast.states.length > 0 || + ast.computed.length > 0 || + viewHasEvents(ast.view) || + behavior !== null); const scopeKeys = [ ...effectiveProps.map((prop) => prop.name), @@ -1418,9 +1522,16 @@ function generateComponent(ast: PageAst): string { ` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`, ); } + for (const entry of ast.computed) { + decls.push(` const ${nameRefs.get(entry.name)} = (${resolveExpr(entry.expr)});`); + } const returnExpr = needsScope - ? "`" + styleTag + `
` + viewCode + "
`" + ? "`" + + styleTag + + `
` + + viewCode + + "
`" : "`" + styleTag + viewCode + "`"; const scopeLine = @@ -1437,6 +1548,12 @@ function generateComponent(ast: PageAst): string { } else { out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); } + out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); + out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`); + out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); + if (Object.keys(ast.security).length > 0) { + out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); + } if (behavior) { out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 604b4a0f..4b0ccf3c 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,74 +1,102 @@ /** * @wrnexus/compiler — the `.wrn` language compiler. * - * Pipeline: source ──▶ Lexer ──▶ parse() ──▶ AST ──▶ generate() ──▶ TypeScript - * - * See VISION.md for the language design. The MVP supports `page` with `state`, - * `view`, `api`, and `realtime` blocks, lowering to the framework's primitives. + * Parsing and language diagnostics are provided by the canonical + * `@wrnexus/syntax` package. This package owns platform-specific codegen. */ -import { parse, ParseError, type PageAst } from "./parser.ts"; +import { + assertValidAst, + diagnose, + diagnosticFromError, + formatDiagnostic, + parse, + ParseError, + type PageAst, + type WrnDiagnostic, +} from "@wrnexus/syntax"; import { generate } from "./codegen.ts"; import { generateNative } from "./native-codegen.ts"; -export { parse, ParseError } from "./parser.ts"; +export { + assertValidAst, + diagnose, + diagnosticFromError, + formatDiagnostic, + parse, + ParseError, +} from "@wrnexus/syntax"; export { generate } from "./codegen.ts"; export { generateNative, NativeCompileError } from "./native-codegen.ts"; -export { Lexer, LexError } from "./tokenizer.ts"; -export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "./types.ts"; +export { Lexer, LexError } from "@wrnexus/syntax"; +export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax"; export type { - PageAst, - SeoBlock, - ViewNode, - Attr, - StateDecl, - PropDecl, + ActionBlock, ApiBlock, + Attr, + ComputedDecl, DataApiBlock, DataMode, + EffectBlock, + LoadBlock, ModeFunctionsBlock, + PageAst, + PropDecl, RealtimeBlock, -} from "./parser.ts"; + SeoBlock, + StateDecl, + ViewNode, + WrnDiagnostic, +} from "@wrnexus/syntax"; export interface CompileResult { code: string; ast: PageAst; + /** Backward-compatible plain diagnostic messages. */ diagnostics: string[]; + /** Structured diagnostics for editors, CI, and the DevToolbar. */ + richDiagnostics: WrnDiagnostic[]; } /** Compile `.wrn` source into an Expo Router React Native screen. */ export function compileNativeWireFile(source: string): string { - return generateNative(parse(source)); + const ast = parse(source); + assertValidAst(ast); + return generateNative(ast); } /** - * Compile `.wrn` source into TypeScript source. Throws `ParseError` on invalid - * input (the dev loader surfaces this as a readable error page). + * Compile `.wrn` source into TypeScript source. Errors include a stable code, + * source location, code frame, and actionable hint whenever available. */ export function compileWireFile(source: string, filePath = ""): string { - let ast; - try { - ast = parse(source); + const ast = parse(source); + assertValidAst(ast, { file: filePath, accessibility: true }); + return `// compiled from .wrn\n${generate(ast)}`; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - - throw new Error(`Failed to parse ${filePath}: ${message}`, { + const diagnostic = diagnosticFromError(source, error, { file: filePath }); + throw new Error(`Failed to parse ${filePath}:\n\n${formatDiagnostic(source, diagnostic)}`, { cause: error, }); } - - return `// compiled from .wrn\n${generate(ast)}`; } -/** Richer entry point returning the AST and diagnostics alongside the code. */ -export function compile(source: string): CompileResult { - const diagnostics: string[] = []; - try { - const ast = parse(source); - return { code: `// compiled from .wrn\n${generate(ast)}`, ast, diagnostics }; - } catch (err) { - if (err instanceof ParseError) diagnostics.push(err.message); - throw err; +/** Richer entry point returning the AST and structured diagnostics. */ +export function compile(source: string, filePath = ""): CompileResult { + const richDiagnostics = diagnose(source, { file: filePath, accessibility: true }); + const errors = richDiagnostics.filter((diagnostic) => diagnostic.severity === "error"); + if (errors.length > 0) { + throw new ParseError( + errors.map((diagnostic) => diagnostic.message).join("\n"), + errors[0]!.code, + ); } + const ast = parse(source); + return { + code: `// compiled from .wrn\n${generate(ast)}`, + ast, + diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`), + richDiagnostics, + }; } diff --git a/packages/compiler/src/parser.ts b/packages/compiler/src/parser.ts index 0dbdb6a7..aa708b46 100644 --- a/packages/compiler/src/parser.ts +++ b/packages/compiler/src/parser.ts @@ -1,816 +1,2 @@ -/** - * Recursive-descent parser for `.wrn`, producing a small AST. - * - * Grammar (subset of the vision, but real): - * - * page { - * types { } - * props { : [= ] } // no default means required - * state : = // type annotation is optional - * view { } // plain HTML (see parseHtmlView) - * seo { title = "Home" description = "..." } - * ssr { api { } functions { } } - * client { api { } functions { } } - * style { } // zero or more, inlined with the page - * functions { } // zero or more, shared helpers - * api { } // zero or more - * realtime { on () { } * } // zero or more - * } - * - * The `view` block is written as ordinary HTML — nothing new to learn. Text may - * contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and - * `@event="..."` declares a client event binding. See `parseHtmlView`. - */ - -import { Lexer, LexError, type Token } from "./tokenizer.ts"; -import { validateTypedInitializer } from "./types.ts"; - -export interface StateDecl { - name: string; - /** Explicit TypeScript-style type annotation, when supplied. */ - valueType?: string; - /** Raw JS initializer expression, e.g. `0` or `'x'`. */ - expr: string; -} - -export interface Attr { - name: string; - value: string; - /** True for `@event` bindings (vs. plain HTML attributes). */ - event: boolean; - /** True for a valueless boolean attribute, e.g. ` } + }`; + const output = compileWireFile(source); + + expect(output).toContain('export const __wrnexusRuntime = "universal"'); + expect(output).toContain('export const __wrnexusHydrate = "idle"'); + expect(output).toContain("export async function __wrnexusLoad"); + expect(output).toContain("export async function __wrnexusClientLoad"); + expect(output).toContain("export async function save(input)"); + expect(output).toContain("export const __wrnexusActions"); + expect(output).toContain('data-wrn-hydrate="idle"'); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 4aabdf3a..706fc930 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index d3e41923..f2484e86 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -6,6 +6,9 @@ * it can later be reused by the `.wrn` compiler output. */ +import type { Tenant } from "./tenant.ts"; +import type { Tracer } from "./observability.ts"; + import { applyCookieHeaders, createCookieStore, @@ -40,6 +43,10 @@ export type Context = { * by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`. */ user?: unknown; + /** Active tenant/workspace resolved by tenant middleware. */ + tenant?: Tenant; + /** Request tracer installed by observability middleware. */ + tracer?: Tracer; /** * The direct socket peer IP, set by the server from `server.requestIP`. This * is NOT spoofable by request headers — prefer it over `x-forwarded-for` for diff --git a/packages/core/src/data.ts b/packages/core/src/data.ts new file mode 100644 index 00000000..8c2eb836 --- /dev/null +++ b/packages/core/src/data.ts @@ -0,0 +1,56 @@ +import type { Context } from "./context.ts"; + +export interface CachePolicy { + ttlMs?: number; + staleWhileRevalidateMs?: number; + tags?: string[] | ((ctx: Context) => string[]); +} + +export interface LoaderDefinition { + cache?: CachePolicy; + load(ctx: Context): T | Promise; +} + +export interface ActionDefinition { + csrf?: boolean; + run(input: I, ctx: Context): O | Promise; + invalidate?: string[] | ((output: O, ctx: Context) => string[]); +} + +export interface DefinedLoader { + readonly definition: LoaderDefinition; + (ctx: Context): Promise; +} + +export interface DefinedAction { + readonly definition: ActionDefinition; + (input: I, ctx: Context): Promise; +} + +export function defineLoader(definition: LoaderDefinition): DefinedLoader { + return Object.assign(async (ctx: Context) => definition.load(ctx), { definition }); +} + +export function defineAction(definition: ActionDefinition): DefinedAction { + return Object.assign(async (input: I, ctx: Context) => definition.run(input, ctx), { + definition, + }); +} + +/** Request-local fetch deduplication keyed by a stable string. */ +export async function dedupe(ctx: Context, key: string, load: () => T | Promise): Promise { + const bucket = (ctx.locals.__wrnexusData ??= new Map>()) as Map< + string, + Promise + >; + const existing = bucket.get(key); + if (existing) return existing as Promise; + const pending = Promise.resolve().then(load); + bucket.set(key, pending); + try { + return await pending; + } catch (error) { + bucket.delete(key); + throw error; + } +} diff --git a/packages/core/src/endpoint.ts b/packages/core/src/endpoint.ts new file mode 100644 index 00000000..ce29346f --- /dev/null +++ b/packages/core/src/endpoint.ts @@ -0,0 +1,108 @@ +import type { Context } from "./context.ts"; + +export interface SchemaLike { + parse(input: unknown): T; +} + +export interface EndpointErrorBody { + code: string; + message: string; + details?: unknown; +} + +export class EndpointError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = "EndpointError"; + } +} + +export interface EndpointDefinition { + input?: SchemaLike; + output?: SchemaLike; + auth?: "optional" | "required"; + description?: string; + tags?: string[]; + handler(input: I, ctx: Context): O | Promise; +} + +export interface DefinedEndpoint { + readonly definition: EndpointDefinition; + (ctx: Context, input?: unknown): Promise; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +/** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */ +export function defineEndpoint( + definition: EndpointDefinition, +): DefinedEndpoint { + const endpoint = async (ctx: Context, rawInput?: unknown): Promise => { + try { + if (definition.auth === "required" && !ctx.user) { + throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required."); + } + const input = definition.input ? definition.input.parse(rawInput) : (rawInput as I); + const rawOutput = await definition.handler(input, ctx); + const output = definition.output ? definition.output.parse(rawOutput) : rawOutput; + return output instanceof Response ? output : json({ data: output }); + } catch (error) { + if (error instanceof EndpointError) { + return json( + { error: { code: error.code, message: error.message, details: error.details } }, + error.status, + ); + } + return json( + { + error: { + code: "INTERNAL_ERROR", + message: "The endpoint failed unexpectedly.", + } satisfies EndpointErrorBody, + }, + 500, + ); + } + }; + return Object.assign(endpoint, { definition }); +} + +export interface RpcClientOptions { + baseUrl?: string; + fetch?: typeof globalThis.fetch; + headers?: HeadersInit | (() => HeadersInit | Promise); +} + +/** Create a tiny typed RPC caller for endpoints exposed by a WrNexus app. */ +export function createRpcClient(options: RpcClientOptions = {}) { + const request = options.fetch ?? globalThis.fetch; + return async function call(path: string, input: I): Promise { + const headers = + typeof options.headers === "function" ? await options.headers() : (options.headers ?? {}); + const response = await request(new URL(path, options.baseUrl ?? globalThis.location?.origin), { + method: "POST", + headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) }, + body: JSON.stringify(input), + }); + const body = (await response.json()) as { data?: O; error?: EndpointErrorBody }; + if (!response.ok || body.error) { + throw new EndpointError( + response.status, + body.error?.code ?? "RPC_ERROR", + body.error?.message ?? `RPC request failed with ${response.status}`, + body.error?.details, + ); + } + return body.data as O; + }; +} diff --git a/packages/core/src/features.ts b/packages/core/src/features.ts new file mode 100644 index 00000000..958d50c9 --- /dev/null +++ b/packages/core/src/features.ts @@ -0,0 +1,21 @@ +import type { Context } from "./context.ts"; + +export type FeatureValue = boolean | string | number; +export type FeatureRule = FeatureValue | ((ctx: Context) => FeatureValue | Promise); + +export interface FeatureFlags { + get(name: string, ctx: Context): Promise; + enabled(name: string, ctx: Context): Promise; +} + +export function defineFeatureFlags(rules: Record): FeatureFlags { + return { + async get(name, ctx) { + const rule = rules[name]; + return typeof rule === "function" ? rule(ctx) : rule; + }, + async enabled(name, ctx) { + return (await this.get(name, ctx)) === true; + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 689e0e44..515eaf8a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,3 +104,33 @@ export { setSessionBackend, loadSession } from "./storage.ts"; export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts"; export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts"; + +export { defineEndpoint, createRpcClient, EndpointError } from "./endpoint.ts"; +export type { + DefinedEndpoint, + EndpointDefinition, + EndpointErrorBody, + RpcClientOptions, + SchemaLike, +} from "./endpoint.ts"; + +export { defineAction, defineLoader, dedupe } from "./data.ts"; +export type { + ActionDefinition, + CachePolicy, + DefinedAction, + DefinedLoader, + LoaderDefinition, +} from "./data.ts"; + +export { requireTenant, tenantFromSubdomain, tenantMiddleware, tenantScope } from "./tenant.ts"; +export type { Tenant, TenantMiddlewareOptions, TenantResolver } from "./tenant.ts"; + +export { createTracer, tracingMiddleware, withSpan } from "./observability.ts"; +export type { Span, SpanRecord, Tracer } from "./observability.ts"; + +export { defineFeatureFlags } from "./features.ts"; +export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts"; + +export { checkPerformanceBudgets } from "./performance.ts"; +export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts"; diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts new file mode 100644 index 00000000..b371d659 --- /dev/null +++ b/packages/core/src/observability.ts @@ -0,0 +1,109 @@ +import type { Context, Middleware } from "./context.ts"; + +export interface SpanRecord { + name: string; + startTime: number; + endTime?: number; + durationMs?: number; + status?: "ok" | "error"; + attributes: Record; + error?: unknown; +} + +export interface Tracer { + startSpan(name: string, attributes?: SpanRecord["attributes"]): Span; + records(): readonly SpanRecord[]; +} + +export interface Span { + setAttribute(name: string, value: string | number | boolean): void; + end(status?: "ok" | "error", error?: unknown): SpanRecord; +} + +export function createTracer(clock: () => number = () => performance.now()): Tracer { + const spans: SpanRecord[] = []; + return { + startSpan(name, attributes = {}) { + const record: SpanRecord = { name, startTime: clock(), attributes: { ...attributes } }; + spans.push(record); + let ended = false; + return { + setAttribute(key, value) { + record.attributes[key] = value; + }, + end(status = "ok", error) { + if (!ended) { + ended = true; + record.endTime = clock(); + record.durationMs = record.endTime - record.startTime; + record.status = status; + record.error = error; + } + return record; + }, + }; + }, + records: () => spans, + }; +} + +export async function withSpan( + tracer: Tracer, + name: string, + run: (span: Span) => T | Promise, + attributes?: SpanRecord["attributes"], +): Promise { + const span = tracer.startSpan(name, attributes); + try { + const result = await run(span); + span.end("ok"); + return result; + } catch (error) { + span.end("error", error); + throw error; + } +} + +export interface TracingMiddlewareOptions { + /** Include W3C Server-Timing response headers. Defaults to true. */ + serverTiming?: boolean; + /** Fraction of requests to trace, from 0 to 1. Defaults to 1. */ + sampleRate?: number; + /** Called after a traced response completes. */ + onComplete?: (ctx: Context, records: readonly SpanRecord[]) => void | Promise; +} + +export function tracingMiddleware( + tracerFactory: (ctx: Context) => Tracer = () => createTracer(), + options: TracingMiddlewareOptions = {}, +): Middleware { + const sampleRate = Math.max(0, Math.min(1, options.sampleRate ?? 1)); + return async (ctx, next) => { + if (sampleRate === 0 || (sampleRate < 1 && Math.random() > sampleRate)) return next(); + + const tracer = tracerFactory(ctx); + ctx.tracer = tracer; + const response = await withSpan(tracer, "http.request", () => next(), { + method: ctx.req.method, + path: ctx.url.pathname, + }); + const records = tracer.records(); + await options.onComplete?.(ctx, records); + + if (options.serverTiming === false) return response; + + const headers = new Headers(response.headers); + const timings = records + .filter((record) => record.durationMs !== undefined) + .map( + (record, index) => + `wrn${index};dur=${record.durationMs!.toFixed(2)};desc="${record.name.replace(/"/g, "")}"`, + ); + if (timings.length) headers.set("server-timing", timings.join(", ")); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }; +} diff --git a/packages/core/src/performance.ts b/packages/core/src/performance.ts new file mode 100644 index 00000000..a3edf091 --- /dev/null +++ b/packages/core/src/performance.ts @@ -0,0 +1,38 @@ +export interface PerformanceBudgets { + routeJsBytes?: number; + routeCssBytes?: number; + htmlBytes?: number; + imageBytes?: number; + hydrationMs?: number; + serverRenderMs?: number; +} + +export interface PerformanceMeasurement { + routeJsBytes?: number; + routeCssBytes?: number; + htmlBytes?: number; + imageBytes?: number; + hydrationMs?: number; + serverRenderMs?: number; +} + +export interface BudgetViolation { + metric: keyof PerformanceBudgets; + budget: number; + actual: number; + overBy: number; +} + +export function checkPerformanceBudgets( + budgets: PerformanceBudgets, + measurement: PerformanceMeasurement, +): BudgetViolation[] { + const violations: BudgetViolation[] = []; + for (const metric of Object.keys(budgets) as Array) { + const budget = budgets[metric]; + const actual = measurement[metric]; + if (budget === undefined || actual === undefined || actual <= budget) continue; + violations.push({ metric, budget, actual, overBy: actual - budget }); + } + return violations; +} diff --git a/packages/core/src/tenant.ts b/packages/core/src/tenant.ts new file mode 100644 index 00000000..ab6d42c0 --- /dev/null +++ b/packages/core/src/tenant.ts @@ -0,0 +1,56 @@ +import type { Context, Middleware } from "./context.ts"; + +export interface Tenant { + id: string; + slug?: string; + name?: string; + metadata?: Record; +} + +export type TenantResolver = (ctx: Context) => Tenant | null | Promise; + +export interface TenantMiddlewareOptions { + required?: boolean; + status?: number; +} + +export function tenantMiddleware( + resolveTenant: TenantResolver, + options: TenantMiddlewareOptions = {}, +): Middleware { + return async (ctx, next) => { + const tenant = await resolveTenant(ctx); + ctx.tenant = tenant ?? undefined; + if (!tenant && options.required !== false) { + return new Response("Tenant not found", { status: options.status ?? 404 }); + } + return next(); + }; +} + +export function tenantFromSubdomain( + lookup: (slug: string, ctx: Context) => Tenant | null | Promise, + rootDomains: string[] = [], +): TenantResolver { + return async (ctx) => { + const host = ctx.url.hostname.toLowerCase(); + const root = rootDomains.find((domain) => host === domain || host.endsWith(`.${domain}`)); + const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0]; + if (!slug || slug === host || slug === "www") return null; + return lookup(slug, ctx); + }; +} + +export function requireTenant(ctx: Context): Tenant { + if (!ctx.tenant) + throw new Error("WRN-TENANT-REQUIRED: tenant middleware has not resolved a tenant."); + return ctx.tenant; +} + +/** Wrap a repository so every operation receives the current tenant id. */ +export function tenantScope( + tenant: Tenant, + repository: T, +): T & { tenantId: string } { + return Object.assign(Object.create(repository), { tenantId: tenant.id }); +} diff --git a/packages/core/test/fullstack-primitives.test.ts b/packages/core/test/fullstack-primitives.test.ts new file mode 100644 index 00000000..5cd99015 --- /dev/null +++ b/packages/core/test/fullstack-primitives.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { + checkPerformanceBudgets, + createContext, + createTracer, + dedupe, + defineAction, + defineEndpoint, + defineFeatureFlags, + defineLoader, + tenantFromSubdomain, + tracingMiddleware, +} from "../src/index.ts"; + +function context(url = "https://acme.example.com/dashboard") { + return createContext(new Request(url), new URL(url)); +} + +test("typed endpoints validate authentication and preserve a stable JSON envelope", async () => { + const endpoint = defineEndpoint<{ value: number }, { doubled: number }>({ + auth: "required", + input: { + parse(input) { + const value = Number((input as { value?: unknown })?.value); + if (!Number.isFinite(value)) throw new Error("invalid"); + return { value }; + }, + }, + handler: ({ value }) => ({ doubled: value * 2 }), + }); + + expect((await endpoint(context(), { value: 4 })).status).toBe(401); + const authenticated = context(); + authenticated.user = { id: "user-1" }; + expect(await (await endpoint(authenticated, { value: 4 })).json()).toEqual({ + data: { doubled: 8 }, + }); +}); + +test("loaders, actions, and request-local dedupe remain framework-agnostic", async () => { + let calls = 0; + const loader = defineLoader({ load: async () => ({ ready: true }) }); + const action = defineAction<{ name: string }, string>({ run: async (input) => input.name }); + const ctx = context(); + const first = dedupe(ctx, "profile", async () => ++calls); + const second = dedupe(ctx, "profile", async () => ++calls); + + expect(await loader(ctx)).toEqual({ ready: true }); + expect(await action({ name: "Ajay" }, ctx)).toBe("Ajay"); + expect(await Promise.all([first, second])).toEqual([1, 1]); + expect(calls).toBe(1); +}); + +test("feature flags, tenant resolution, budgets, and tracing compose", async () => { + const ctx = context(); + const resolveTenant = tenantFromSubdomain(async (slug) => ({ id: slug, slug }), ["example.com"]); + expect(await resolveTenant(ctx)).toEqual({ id: "acme", slug: "acme" }); + + const flags = defineFeatureFlags({ dashboardV2: true, seats: 25 }); + expect(await flags.enabled("dashboardV2", ctx)).toBe(true); + expect(await flags.get("seats", ctx)).toBe(25); + + expect(checkPerformanceBudgets({ routeJsBytes: 100 }, { routeJsBytes: 130 })).toEqual([ + { metric: "routeJsBytes", budget: 100, actual: 130, overBy: 30 }, + ]); + + const tracer = createTracer(() => 10); + const middleware = tracingMiddleware(() => tracer, { serverTiming: true }); + const response = await middleware(ctx, () => new Response("ok")); + expect(response.headers.get("server-timing")).toContain("http.request"); +}); diff --git a/packages/csr/README.md b/packages/csr/README.md index 7383bed4..cb42e418 100644 --- a/packages/csr/README.md +++ b/packages/csr/README.md @@ -49,15 +49,16 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works. -| Directive | Purpose | -| -------------------------------------------------- | ----------------------------------------------------------------------- | -| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | -| `data-on-="count++"` | Run a statement in scope on a DOM event | -| `data-text="expr"` | Bind an element's `textContent` to an expression | -| `data-show="expr"` | Toggle visibility (`display`) on truthiness | -| `data-for="item in list"` (opt. `item, i in list`) | Per-item list rendering template | -| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | -| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | +| Directive | Purpose | +| -------------------------------------------------------- | ----------------------------------------------------------------------- | +| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | +| `data-on-="count++"` | Run a statement in scope on a DOM event | +| `data-text="expr"` | Bind an element's `textContent` to an expression | +| `data-show="expr"` | Toggle visibility (`display`) on truthiness | +| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | +| `data-key="item.id"` | Alternative key declaration for `data-for` templates | +| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | +| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it. diff --git a/packages/csr/package.json b/packages/csr/package.json index fb31490f..3605c647 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 7079b5b7..08781b95 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -25,6 +25,21 @@ export const REACTIVE_RUNTIME = String.raw` var updateHooksScheduled = false; var behaviorObserver; + function reportDiagnostic(code, message, element, detail) { + var payload = { + code: code, + message: message, + hydrationId: element && element.getAttribute ? element.getAttribute("data-wrn-hydration") : null, + detail: detail || null, + }; + console.error("[wrnexus:" + code + "] " + message, detail || ""); + try { + window.dispatchEvent(new CustomEvent("wrnexus:diagnostic", { detail: payload })); + } catch (_) { + // CustomEvent can be unavailable in minimal DOM test environments. + } + } + function scheduleUpdateHook(element, callback) { pendingUpdateHooks.set(element, callback); if (updateHooksScheduled) return; @@ -424,8 +439,10 @@ export const REACTIVE_RUNTIME = String.raw` encodedScope, ); } catch (error) { - console.error( - "[wrnexus] failed to decode scope payload", + reportDiagnostic( + "WRN-HYDRATE-SCOPE", + "Failed to decode the server-rendered scope payload.", + el, error, ); @@ -445,6 +462,16 @@ export const REACTIVE_RUNTIME = String.raw` var signals = {}; Object.keys(initial).forEach(function (k) { signals[k] = signal(initial[k]); }); + var behavior = parseBehavior(el); + var computedDefinitions = {}; + var computing = new Set(); + if (behavior && Array.isArray(behavior.computed)) { + behavior.computed.forEach(function (entry) { + if (entry && typeof entry.name === "string" && typeof entry.expr === "string") { + computedDefinitions[entry.name] = entry.expr; + } + }); + } var renderers = []; // Dependency-tracked rendering: while a renderer runs, every signal it reads @@ -452,7 +479,41 @@ export const REACTIVE_RUNTIME = String.raw` // change then re-runs only the renderers that actually read it. The Set in // signal.subscribe dedupes, so re-subscribing each run is cheap and bounded. var currentRenderer = null; - var currentRenderer = null; + var pendingRenderers = new Set(); + var batchDepth = 0; + var flushingRenderers = false; + + function flushRenderers() { + if (flushingRenderers || batchDepth > 0) return; + + flushingRenderers = true; + + try { + while (pendingRenderers.size > 0) { + var queue = Array.from(pendingRenderers); + pendingRenderers.clear(); + queue.forEach(function (run) { run(); }); + } + } finally { + flushingRenderers = false; + } + } + + function scheduleRenderer(renderer) { + pendingRenderers.add(renderer); + flushRenderers(); + } + + function batchUpdates(callback) { + batchDepth++; + + try { + return callback(); + } finally { + batchDepth--; + flushRenderers(); + } + } function reactive(fn) { var running = false; @@ -467,7 +528,7 @@ export const REACTIVE_RUNTIME = String.raw` var previousRenderer = currentRenderer; - currentRenderer = run; + currentRenderer = schedule; try { fn(); @@ -479,6 +540,10 @@ export const REACTIVE_RUNTIME = String.raw` } } + function schedule() { + scheduleRenderer(run); + } + renderers.push(run); return run; @@ -577,6 +642,18 @@ export const REACTIVE_RUNTIME = String.raw` } function readScope(name) { + if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) { + if (computing.has(name)) { + reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el); + return undefined; + } + computing.add(name); + try { + return evalExpr(computedDefinitions[name]); + } finally { + computing.delete(name); + } + } var sig = signals[name]; if (sig) { if (currentRenderer) sig.subscribe(currentRenderer); @@ -589,6 +666,9 @@ export const REACTIVE_RUNTIME = String.raw` } function peekScope(name) { + if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) { + return readScope(name); + } if (signals[name]) return signals[name].get(); if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) { return behaviorFunctions[name]; @@ -664,68 +744,70 @@ export const REACTIVE_RUNTIME = String.raw` source, locals, ) { - var statements = - splitStatements(source); + return batchUpdates(function () { + var statements = + splitStatements(source); - for ( - var statementIndex = 0; - statementIndex < - statements.length; - statementIndex++ - ) { - var result = runStatement( - statements[statementIndex], - function (expression) { - return evalExpr( - expression, - locals, - ); - }, - function (name) { - if ( - locals && - Object.prototype - .hasOwnProperty.call( - locals, - name, - ) - ) { - return locals[name]; - } + for ( + var statementIndex = 0; + statementIndex < + statements.length; + statementIndex++ + ) { + var result = runStatement( + statements[statementIndex], + function (expression) { + return evalExpr( + expression, + locals, + ); + }, + function (name) { + if ( + locals && + Object.prototype + .hasOwnProperty.call( + locals, + name, + ) + ) { + return locals[name]; + } - return peekScope(name); - }, - function (name, value) { - if ( - locals && - Object.prototype - .hasOwnProperty.call( - locals, - name, - ) - ) { - locals[name] = value; - } else { - writeScope(name, value); - } - }, - function (body) { - return runStmt( - body, - locals, - ); - }, - ); + return peekScope(name); + }, + function (name, value) { + if ( + locals && + Object.prototype + .hasOwnProperty.call( + locals, + name, + ) + ) { + locals[name] = value; + } else { + writeScope(name, value); + } + }, + function (body) { + return runStmt( + body, + locals, + ); + }, + ); - if (result.returned) { - return result; + if (result.returned) { + return result; + } } - } - return { - returned: false, - value: undefined, - }; + return { + returned: false, + value: undefined, + }; + }); } function installBehaviorFunctions(source) { @@ -771,13 +853,31 @@ export const REACTIVE_RUNTIME = String.raw` } // --- data-for list rendering ------------------------------------------- - // Each [data-for="item in list"] element is a per-item template. On any - // change to the list (or a dependency an item reads), the list re-renders. + // Each [data-for="item in list"] element is a per-item template. Add + // data-key="item.id" or a key item.id suffix preserves DOM nodes when + // a list is reordered. Unkeyed loops retain the legacy full-rerender path. function parseFor(value) { - var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec( + var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec( value || "", ); - return m ? { item: m[1], index: m[2], list: m[3] } : null; + return m ? { item: m[1], index: m[2], list: m[3].trim(), key: m[4] && m[4].trim() } : null; + } + + function unwrapForKey(value) { + var expression = String(value || "").trim(); + if (expression.charAt(0) === "{" && expression.charAt(expression.length - 1) === "}") { + expression = expression.slice(1, -1).trim(); + } + return expression; + } + + function stableForKey(value) { + if (value === null) return "null:"; + var type = typeof value; + if (type === "object") { + try { return "object:" + JSON.stringify(value); } catch (_) { return "object:" + String(value); } + } + return type + ":" + String(value); } function fillMustache(str, itemEval) { return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) { @@ -1152,6 +1252,18 @@ export const REACTIVE_RUNTIME = String.raw` "data-for", ); + var keyExpression = + spec.key || + unwrapForKey( + tpl.getAttribute( + "data-key", + ), + ); + + template.removeAttribute( + "data-key", + ); + var parent = tpl.parentNode; @@ -1168,6 +1280,7 @@ export const REACTIVE_RUNTIME = String.raw` parent.removeChild(tpl); var clones = []; + var keyedRecords = new Map(); reactive(function () { var list = @@ -1184,64 +1297,155 @@ export const REACTIVE_RUNTIME = String.raw` list = []; } - for ( - var cloneIndex = 0; - cloneIndex < - clones.length; - cloneIndex++ - ) { - var existingClone = - clones[cloneIndex]; - - if ( - existingClone.parentNode + if (!keyExpression) { + for ( + var cloneIndex = 0; + cloneIndex < + clones.length; + cloneIndex++ ) { - existingClone.parentNode - .removeChild( - existingClone, - ); + var existingClone = + clones[cloneIndex]; + + if ( + existingClone.parentNode + ) { + existingClone.parentNode + .removeChild( + existingClone, + ); + } } + + clones = []; + + var fragment = + document.createDocumentFragment(); + + for ( + var itemIndex = 0; + itemIndex < list.length; + itemIndex++ + ) { + var clone = + template.cloneNode(true); + + var locals = {}; + + locals[spec.item] = + list[itemIndex]; + + if (spec.index) { + locals[spec.index] = + itemIndex; + } + + hydrateItem( + clone, + locals, + ); + + fragment.appendChild( + clone, + ); + + clones.push(clone); + } + + parent.insertBefore( + fragment, + marker.nextSibling, + ); + + return; } - clones = []; - - var fragment = - document.createDocumentFragment(); + var nextRecords = new Map(); + var orderedNodes = []; for ( - var itemIndex = 0; - itemIndex < list.length; - itemIndex++ + var keyedIndex = 0; + keyedIndex < list.length; + keyedIndex++ ) { - var clone = - template.cloneNode(true); + var keyedItem = list[keyedIndex]; + var keyedLocals = {}; - var locals = {}; + keyedLocals[spec.item] = keyedItem; + if (spec.index) keyedLocals[spec.index] = keyedIndex; - locals[spec.item] = - list[itemIndex]; - - if (spec.index) { - locals[spec.index] = - itemIndex; + var rawKey; + try { + rawKey = evaluateExpression( + keyExpression, + function (name) { + return Object.prototype.hasOwnProperty.call(keyedLocals, name) + ? keyedLocals[name] + : readScope(name); + }, + ); + } catch (error) { + reportDiagnostic( + "WRN-HYDRATE-KEY-001", + "Unable to evaluate data-for key '" + keyExpression + "'.", + el, + error, + ); + rawKey = keyedIndex; } - hydrateItem( - clone, - locals, - ); + var normalizedKey = stableForKey(rawKey); + if (nextRecords.has(normalizedKey)) { + reportDiagnostic( + "WRN-HYDRATE-KEY-002", + "Duplicate data-for key '" + String(rawKey) + "'; falling back to its index.", + el, + { key: rawKey, index: keyedIndex }, + ); + normalizedKey += ":index:" + keyedIndex; + } - fragment.appendChild( - clone, - ); + var record = keyedRecords.get(normalizedKey); + if ( + !record || + record.item !== keyedItem || + (spec.index && record.index !== keyedIndex) + ) { + if (record && record.node.parentNode) { + record.node.parentNode.removeChild(record.node); + } - clones.push(clone); + var keyedClone = template.cloneNode(true); + hydrateItem(keyedClone, keyedLocals); + record = { + node: keyedClone, + item: keyedItem, + index: keyedIndex, + }; + } + + nextRecords.set(normalizedKey, record); + orderedNodes.push(record.node); } + keyedRecords.forEach(function (record, key) { + if (!nextRecords.has(key) && record.node.parentNode) { + record.node.parentNode.removeChild(record.node); + } + }); + + var keyedFragment = document.createDocumentFragment(); + orderedNodes.forEach(function (node) { + keyedFragment.appendChild(node); + }); + parent.insertBefore( - fragment, + keyedFragment, marker.nextSibling, ); + + keyedRecords = nextRecords; + clones = orderedNodes; }); }); @@ -1512,10 +1716,18 @@ export const REACTIVE_RUNTIME = String.raw` }); }); - var behavior = parseBehavior(el); if (behavior) { installBehaviorFunctions(behavior.functions); + (behavior.effects || []).forEach(function (source) { + if (typeof source !== "string" || !source.trim()) return; + reactive(function () { + try { runStmt(source); } catch (error) { + reportDiagnostic("WRN-EFFECT-ERROR", "Reactive effect failed.", el, error); + } + }); + }); + (behavior.watches || []).forEach(function (watch) { if (!watch || typeof watch.state !== "string" || typeof watch.body !== "string") return; if (!stateWatchers[watch.state]) stateWatchers[watch.state] = []; @@ -1609,6 +1821,73 @@ export const REACTIVE_RUNTIME = String.raw` behaviorObserver.observe(document.documentElement, { childList: true, subtree: true }); } + function queueScopeHydration(element) { + if (!element || element.__wrnexusScope || element.__wrnexusHydrationQueued) return; + var runtime = element.getAttribute("data-wrn-runtime") || "universal"; + var strategy = element.getAttribute("data-wrn-hydrate") || "load"; + if (runtime === "server" || strategy === "none") return; + element.__wrnexusHydrationQueued = true; + + function hydrate() { + if (element.__wrnexusScope || !element.isConnected) return; + setupScope(element); + } + + if (strategy === "load") { + hydrate(); + return; + } + if (strategy === "idle") { + var idle = window.requestIdleCallback || function (callback) { return window.setTimeout(callback, 1); }; + idle(hydrate); + return; + } + if (strategy === "visible" && typeof IntersectionObserver !== "undefined") { + var observer = new IntersectionObserver(function (entries) { + if (!entries.some(function (entry) { return entry.isIntersecting; })) return; + observer.disconnect(); + hydrate(); + }); + observer.observe(element); + return; + } + if (strategy === "interaction") { + var activate = function () { + element.removeEventListener("pointerdown", activate, true); + element.removeEventListener("keydown", activate, true); + element.removeEventListener("focusin", activate, true); + hydrate(); + }; + element.addEventListener("pointerdown", activate, true); + element.addEventListener("keydown", activate, true); + element.addEventListener("focusin", activate, true); + return; + } + if (strategy.indexOf("media:") === 0 && typeof window.matchMedia === "function") { + var query = strategy.slice(6); + var media = window.matchMedia(query); + if (media.matches) hydrate(); + else { + var onChange = function (event) { + if (!event.matches) return; + if (media.removeEventListener) media.removeEventListener("change", onChange); + else media.removeListener(onChange); + hydrate(); + }; + if (media.addEventListener) media.addEventListener("change", onChange); + else media.addListener(onChange); + } + return; + } + + reportDiagnostic( + "WRN-HYDRATE-STRATEGY", + "Unknown hydration strategy '" + strategy + "'. Falling back to load.", + element, + ); + hydrate(); + } + function hydrateScopes(root) { var host = root || document; @@ -1620,13 +1899,13 @@ export const REACTIVE_RUNTIME = String.raw` host.matches && host.matches(scopeSelector) ) { - setupScope(host); + queueScopeHydration(host); } if (host.querySelectorAll) { host .querySelectorAll(scopeSelector) - .forEach(setupScope); + .forEach(queueScopeHydration); } ensureBehaviorObserver(); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 6fdfc9b6..0f14014a 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -99,6 +99,25 @@ test("data-for exposes item + index, mustaches and member access", () => { ]); }); +test("keyed data-for preserves DOM identity when items reorder", () => { + const win = mount( + `
+
  • {row.name}
+ +
`, + ); + + const before = Array.from(win.document.querySelectorAll("li")); + expect(before.map((node) => node.textContent)).toEqual(["a", "b"]); + + (win.document.querySelector("button") as unknown as HTMLElement).click(); + + const after = Array.from(win.document.querySelectorAll("li")); + expect(after.map((node) => node.textContent)).toEqual(["b", "a"]); + expect(after[0]).toBe(before[1]); + expect(after[1]).toBe(before[0]); +}); + test("expression evaluator: member access, ternary, comparison, calls", () => { const win = mount( `
diff --git a/packages/db/package.json b/packages/db/package.json index a32339a6..f90bc510 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index c1716df9..38e58ac3 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { @@ -20,6 +20,7 @@ "@wrnexus/i18n": "workspace:*", "@wrnexus/db": "workspace:*", "@wrnexus/pubsub": "workspace:*", - "@wrnexus/uploader": "workspace:*" + "@wrnexus/uploader": "workspace:*", + "@wrnexus/plugin": "workspace:*" } } diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 4dc70043..39fb35e3 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -32,6 +32,8 @@ export { RESTART_EXIT_CODE } from "./restart.ts"; import { resetDevCache } from "./cache.ts"; import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; +import { createPluginRunner, type PluginInput } from "@wrnexus/plugin"; +import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles"; import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server"; @@ -67,6 +69,9 @@ export interface ServeOptions { mobile?: MobileConfig; pwa?: PwaConfig | false; devToolbar?: boolean | DevToolbarConfig; + plugins?: PluginInput; + observability?: ObservabilityConfig; + tenancy?: TenancyConfig; } export interface RunningServer { @@ -153,7 +158,20 @@ function resolveDevToolbarConfig( export async function startServer(opts: ServeOptions): Promise { const appDir = resolve(opts.appDir); + const appRoot = dirname(appDir); const mode: Mode = opts.mode ?? "development"; + const pluginRunner = createPluginRunner(opts.plugins, { + root: appRoot, + mode, + command: "dev", + metadata: new Map(), + warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), + }); + await pluginRunner.configure(opts as unknown as Record); + await pluginRunner.configResolved( + Object.freeze({ ...opts }) as Readonly>, + ); + const hmr = opts.hmr ?? mode === "development"; const port = opts.port ?? 3000; const hostname = opts.hostname ?? "::"; @@ -161,7 +179,6 @@ export async function startServer(opts: ServeOptions): Promise { const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] }); const styleEntry = opts.styleEntry ?? null; - const appRoot = dirname(appDir); const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar); @@ -256,6 +273,8 @@ export async function startServer(opts: ServeOptions): Promise { mobile: opts.mobile, pwa: opts.pwa, security: opts.security, + observability: opts.observability, + tenancy: opts.tenancy, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), devToolbar: @@ -278,6 +297,19 @@ export async function startServer(opts: ServeOptions): Promise { websocket: handlers.websocket, }); + try { + await pluginRunner.hook("configureServer", { + server, + router, + handlers, + assets, + devToolbarCollector, + }); + } catch (error) { + server.stop(); + throw error; + } + let watcher: ReturnType; // In-process HMR: keep the server and socket alive, invalidate only changed diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 20d93258..8a7487d7 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -23,6 +23,8 @@ import { type ResolvedTheme, type MobileConfig, type PwaConfig, + type ObservabilityConfig, + type TenancyConfig, } from "@wrnexus/styles"; import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n"; @@ -116,6 +118,10 @@ export interface ProdOptions { pwa?: PwaConfig | false; /** Framework security headers and CORS policy. */ security?: SecurityConfig; + /** Built-in request tracing and Server-Timing policy. */ + observability?: ObservabilityConfig; + /** Built-in tenant identity resolution. */ + tenancy?: TenancyConfig; port?: number; hostname?: string; maxBodyBytes?: number; @@ -305,6 +311,8 @@ export function createProductionHandlers( mobile: opts.mobile, pwa: opts.pwa, security: opts.security, + observability: opts.observability, + tenancy: opts.tenancy, maxBodyBytes: opts.maxBodyBytes, realtimeBus: realtimeBusFromConfig(opts.realtime), }); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 8659c8d0..794129c7 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -23,6 +23,8 @@ import { withContextHeaders, withSecurityHeaders, resolveRequestUrl, + tenantMiddleware, + tracingMiddleware, type Context, type Middleware, type Mode, @@ -43,6 +45,8 @@ import { type ResolvedTheme, type MobileConfig, type PwaConfig, + type ObservabilityConfig, + type TenancyConfig, } from "@wrnexus/styles"; import { LANG_COOKIE, @@ -129,6 +133,10 @@ export interface RuntimeDeps { pwa?: PwaConfig | false; /** Framework security headers and CORS policy. */ security?: SecurityConfig; + /** Built-in request tracing and Server-Timing policy. */ + observability?: ObservabilityConfig; + /** Built-in tenant identity resolution. */ + tenancy?: TenancyConfig; /** Max request body size in bytes (413 above this). Default 10 MB. */ maxBodyBytes?: number; /** HMR hub for browser live-update sockets (dev only). */ @@ -158,6 +166,65 @@ function shouldEnableDevToolbar(mode: string, deps: RuntimeDeps): boolean { ); } +function tenantIdentityFromConfig( + config: TenancyConfig, +): (ctx: Context) => Promise<{ id: string; slug?: string } | null> { + return async (ctx) => { + const host = ctx.url.hostname.toLowerCase(); + + if (config.mode === "domain") return host ? { id: host, slug: host } : null; + + if (config.mode === "path") { + const segments = ctx.url.pathname.split("/").filter(Boolean); + const prefix = config.pathPrefix?.replace(/^\/+|\/+$/g, ""); + const slug = prefix ? (segments[0] === prefix ? segments[1] : undefined) : segments[0]; + return slug ? { id: slug, slug } : null; + } + + if (config.mode === "subdomain" || config.mode === undefined) { + const roots = config.rootDomains?.map((domain) => domain.toLowerCase()) ?? []; + const root = roots.find((domain) => host === domain || host.endsWith(`.${domain}`)); + const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0]; + if (!slug || slug === host || slug === "www" || slug === "localhost") return null; + return { id: slug, slug }; + } + + return null; + }; +} + +function frameworkMiddleware(deps: RuntimeDeps): Middleware[] { + const middleware: Middleware[] = []; + + if (deps.observability && deps.observability.enabled !== false) { + middleware.push( + tracingMiddleware(undefined, { + sampleRate: deps.observability.sampleRate, + serverTiming: deps.observability.serverTiming, + onComplete: + deps.observability.exporter === "console" + ? (ctx, records) => { + const total = records.find((record) => record.name === "http.request")?.durationMs; + console.log( + `[wrnexus:trace] ${ctx.req.method} ${ctx.url.pathname} ${total?.toFixed(2) ?? "0.00"}ms`, + ); + } + : undefined, + }), + ); + } + + if (deps.tenancy && deps.tenancy.mode !== "custom") { + middleware.push( + tenantMiddleware(tenantIdentityFromConfig(deps.tenancy), { + required: deps.tenancy.required, + }), + ); + } + + return middleware; +} + function shouldReportNotFound(pathname: string): boolean { return !( pathname.startsWith("/__wrnexus/") || @@ -597,6 +664,11 @@ export interface Handlers { /** Build the fetch + websocket handlers from a set of dependencies. */ export function createHandlers(deps: RuntimeDeps): Handlers { const { mode, hmr, router, loadModule, getMiddleware, assets } = deps; + const builtInMiddleware = frameworkMiddleware(deps); + const resolveMiddleware = async (): Promise => [ + ...builtInMiddleware, + ...(await getMiddleware()), + ]; const maxBodyBytes = deps.maxBodyBytes ?? 10 * 1024 * 1024; // 10 MB default // Server-side realtime room manager (shared by every `defineRoom` connection). @@ -832,7 +904,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ); ctx.t = makeT(deps.i18n, ctx.lang); } - const mws = await getMiddleware(); + const mws = await resolveMiddleware(); const res = secure( withContextHeaders(ctx, await runMiddleware(mws, ctx, () => dispatch(ctx))), ); @@ -1271,7 +1343,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { } const res = withContextHeaders( ctx, - await runMiddleware(await getMiddleware(), ctx, () => dispatch(ctx)), + await runMiddleware(await resolveMiddleware(), ctx, () => dispatch(ctx)), ); const html = await res.text(); ws.send(JSON.stringify({ type: "html", html })); diff --git a/packages/dev-server/src/serve-entry.ts b/packages/dev-server/src/serve-entry.ts index 62c99656..b2ab6c2a 100644 --- a/packages/dev-server/src/serve-entry.ts +++ b/packages/dev-server/src/serve-entry.ts @@ -43,6 +43,9 @@ const server = await startServer({ mobile: config.mobile, pwa: config.pwa, devToolbar: config.devToolbar, + plugins: config.plugins, + observability: config.observability, + tenancy: config.tenancy, }); const r = server.router; diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json index e657a898..c2cf283d 100644 --- a/packages/dev-toolbar/package.json +++ b/packages/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/dev-toolbar/src/client/runtime.ts b/packages/dev-toolbar/src/client/runtime.ts index c81b3c19..1488f181 100644 --- a/packages/dev-toolbar/src/client/runtime.ts +++ b/packages/dev-toolbar/src/client/runtime.ts @@ -50,6 +50,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => { root.addEventListener("click",event=>{const button=event.target.closest("button");if(!button)return;if(button.matches("[data-toggle]")){state.open=!state.open;state.severity="all";panel.classList.toggle("open",state.open);render()}if(button.matches("[data-close]")){state.open=false;panel.classList.remove("open")}if(button.matches("[data-scan]"))scan();if(button.matches("[data-clear]")){state.issues=[];state.severity="all";state.search="";search.value="";render()}if(button.dataset.filter){state.severity=state.severity===button.dataset.filter?"all":button.dataset.filter;state.open=true;panel.classList.add("open");render()}if(button.dataset.highlight)highlight(button.dataset.highlight)}); search.addEventListener("input",()=>{state.search=search.value.toLowerCase();render()}); addEventListener("wrnexus:navigated",()=>{if(state.config.scanOnNavigation)setTimeout(scan,50)});addEventListener("wrnexus:hmr",()=>{if(state.config.scanOnHmr)setTimeout(scan,100)});addEventListener("wrnexus:runtime-error",event=>addRuntime("error","WRNexus runtime error",event.detail?.message||"Runtime failure",event.detail||{})); + addEventListener("wrnexus:diagnostic",event=>{const detail=event.detail||{};const code=detail.code||"WRN-RUNTIME";const category=String(code).includes("HYDRATE")?"runtime":String(code).includes("ROUTE")?"routing":"compiler";const title=String(code).includes("HYDRATE")?"Hydration diagnostic":"WRNexus diagnostic";const x=issue(String(code),category,"error",title,detail.message||"Framework diagnostic",null,"Open the source location and resolve the reported framework contract.","high",detail);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();}); window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}}; const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]}); setTimeout(scan,100); diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 7405418f..06b4f8b0 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 9ec2c225..5f1bfa76 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.79", + "version": "0.3.0", "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 d7e2e9c9..6858f657 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 82803827..34aa0ce3 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 099c3c09..5dee7ac1 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 0356216e..1246a911 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index dd84e9e6..6afd06ff 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 00000000..910d50a5 --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,7 @@ +# @wrnexus/plugin + +Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms, +diagnostics, development servers, production builds, and DevToolbar extensions. + +Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters. +Duplicate names and dependency cycles are rejected. diff --git a/packages/plugin/package.json b/packages/plugin/package.json new file mode 100644 index 00000000..850d9970 --- /dev/null +++ b/packages/plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "@wrnexus/plugin", + "version": "0.3.0", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@wrnexus/syntax": "workspace:*" + } +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts new file mode 100644 index 00000000..117a012b --- /dev/null +++ b/packages/plugin/src/index.ts @@ -0,0 +1,164 @@ +import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax"; + +export type PluginOrder = "pre" | "normal" | "post"; + +export interface PluginContext { + root: string; + mode: "development" | "production"; + command: "dev" | "build" | "test"; + profile?: string; + metadata: Map; + warn(message: string): void; +} + +export interface TransformContext extends PluginContext { + file: string; +} + +export interface WrnexusPlugin { + name: string; + version?: string; + enforce?: PluginOrder; + /** Plugin names that must execute first. */ + after?: string[]; + /** Plugin names that must execute later. */ + before?: string[]; + configure?(config: Record, context: PluginContext): void | Promise; + configResolved?( + config: Readonly>, + context: PluginContext, + ): void | Promise; + transformAst?(ast: PageAst, context: TransformContext): PageAst | void | Promise; + transformCode?(code: string, context: TransformContext): string | void | Promise; + diagnostics?(ast: PageAst, context: TransformContext): WrnDiagnostic[] | Promise; + routes?(routes: unknown[], context: PluginContext): unknown[] | void | Promise; + configureServer?(server: unknown, context: PluginContext): void | Promise; + buildStart?(context: PluginContext): void | Promise; + buildEnd?(result: unknown, context: PluginContext): void | Promise; + devToolbarPanels?(context: PluginContext): unknown[] | Promise; +} + +export type PluginInput = WrnexusPlugin | false | null | undefined | PluginInput[]; + +export function definePlugin(plugin: WrnexusPlugin): WrnexusPlugin { + if (!plugin.name || !/^[a-z0-9@][a-z0-9@/._-]*$/i.test(plugin.name)) { + throw new Error("WRN-PLUGIN-NAME: plugins require a stable package-style name."); + } + return plugin; +} + +function flatten(input: PluginInput, output: WrnexusPlugin[]): void { + if (!input) return; + if (Array.isArray(input)) { + for (const entry of input) flatten(entry, output); + } else { + output.push(definePlugin(input)); + } +} + +function rank(plugin: WrnexusPlugin): number { + return plugin.enforce === "pre" ? 0 : plugin.enforce === "post" ? 2 : 1; +} + +/** Resolve plugin order deterministically and reject duplicates/cycles. */ +export function resolvePlugins(input: PluginInput): WrnexusPlugin[] { + const plugins: WrnexusPlugin[] = []; + flatten(input, plugins); + const byName = new Map(); + for (const plugin of plugins) { + if (byName.has(plugin.name)) throw new Error(`WRN-PLUGIN-DUPLICATE: ${plugin.name}`); + byName.set(plugin.name, plugin); + } + + const edges = new Map>(); + for (const plugin of plugins) edges.set(plugin.name, new Set()); + for (const plugin of plugins) { + for (const dependency of plugin.after ?? []) { + if (byName.has(dependency)) edges.get(dependency)!.add(plugin.name); + } + for (const dependent of plugin.before ?? []) { + if (byName.has(dependent)) edges.get(plugin.name)!.add(dependent); + } + } + for (const left of plugins) { + for (const right of plugins) { + if (rank(left) < rank(right)) edges.get(left.name)!.add(right.name); + } + } + + const indegree = new Map(plugins.map((plugin) => [plugin.name, 0])); + for (const targets of edges.values()) { + for (const target of targets) indegree.set(target, (indegree.get(target) ?? 0) + 1); + } + const ready = plugins + .filter((plugin) => indegree.get(plugin.name) === 0) + .sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)); + const resolved: WrnexusPlugin[] = []; + while (ready.length) { + const plugin = ready.shift()!; + resolved.push(plugin); + for (const target of edges.get(plugin.name) ?? []) { + indegree.set(target, indegree.get(target)! - 1); + if (indegree.get(target) === 0) { + ready.push(byName.get(target)!); + ready.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)); + } + } + } + if (resolved.length !== plugins.length) { + const cyclic = plugins + .filter((plugin) => !resolved.includes(plugin)) + .map((plugin) => plugin.name); + throw new Error(`WRN-PLUGIN-CYCLE: ${cyclic.join(", ")}`); + } + return resolved; +} + +export interface PluginRunner { + readonly plugins: readonly WrnexusPlugin[]; + configure(config: Record): Promise; + configResolved(config: Readonly>): Promise; + transformAst(ast: PageAst, file: string): Promise; + transformCode(code: string, file: string): Promise; + diagnostics(ast: PageAst, file: string): Promise; + hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise; +} + +export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner { + const plugins = resolvePlugins(input); + const transformContext = (file: string): TransformContext => ({ ...context, file }); + return { + plugins, + async configure(config) { + for (const plugin of plugins) await plugin.configure?.(config, context); + }, + async configResolved(config) { + for (const plugin of plugins) await plugin.configResolved?.(config, context); + }, + async transformAst(ast, file) { + let current = ast; + for (const plugin of plugins) + current = (await plugin.transformAst?.(current, transformContext(file))) ?? current; + return current; + }, + async transformCode(code, file) { + let current = code; + for (const plugin of plugins) + current = (await plugin.transformCode?.(current, transformContext(file))) ?? current; + return current; + }, + async diagnostics(ast, file) { + const all: WrnDiagnostic[] = []; + for (const plugin of plugins) + all.push(...((await plugin.diagnostics?.(ast, transformContext(file))) ?? [])); + return all; + }, + async hook(name, value) { + for (const plugin of plugins) { + if (name === "buildStart") await plugin.buildStart?.(context); + else if (name === "buildEnd") await plugin.buildEnd?.(value, context); + else await plugin.configureServer?.(value, context); + } + }, + }; +} diff --git a/packages/plugin/test/plugin.test.ts b/packages/plugin/test/plugin.test.ts new file mode 100644 index 00000000..23cbae88 --- /dev/null +++ b/packages/plugin/test/plugin.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { resolvePlugins } from "../src/index.ts"; + +describe("plugin ordering", () => { + test("orders pre, normal, and post plugins", () => { + expect( + resolvePlugins([ + { name: "post", enforce: "post" }, + { name: "normal" }, + { name: "pre", enforce: "pre" }, + ]).map((plugin) => plugin.name), + ).toEqual(["pre", "normal", "post"]); + }); + + test("respects explicit dependencies", () => { + expect( + resolvePlugins([{ name: "b", after: ["a"] }, { name: "a" }]).map((plugin) => plugin.name), + ).toEqual(["a", "b"]); + }); +}); diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index b8b10796..a63086ec 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 754d8b44..09bb86b2 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index dbd10ece..deaa4eb9 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -20,6 +20,9 @@ export interface Job { runAt: number; /** If set, re-enqueue this job this many ms after each successful run. */ repeat?: number; + priority: number; + idempotencyKey?: string; + createdAt: number; } export type JobHandler = (job: Job) => void | Promise; @@ -31,6 +34,10 @@ export interface AddOptions { maxAttempts?: number; /** Re-enqueue this job this many ms after each successful run (recurring). */ repeat?: number; + /** Higher-priority jobs run first when multiple jobs are due. */ + priority?: number; + /** Prevent duplicate queued work with the same stable key. */ + idempotencyKey?: string; } export interface QueueOptions { @@ -42,6 +49,8 @@ export interface QueueOptions { pollMs?: number; /** Called when a job exhausts its attempts. */ onFailed?: (job: Job, error: unknown) => void; + /** Maximum jobs executed in one drain. Default: unlimited. */ + concurrency?: number; /** Clock injection (tests). Default Date.now. */ now?: () => number; } @@ -54,18 +63,66 @@ export interface Queue { start(): void; stop(): void; size(): number; + get(id: string): Job | undefined; + list(name?: string): Job[]; + cancel(id: string): boolean; +} + +export interface JobDefinition { + name: string; + options?: Omit; + run: JobHandler; +} + +export function defineJob(definition: JobDefinition): JobDefinition { + return definition; +} + +export interface WorkflowStep { + name: string; + run(input: I): O | Promise; +} + +export function defineWorkflow(name: string, steps: Array>) { + return { + name, + steps, + async run(input: T): Promise { + let value: unknown = input; + for (const step of steps) value = await step.run(value); + return value; + }, + }; +} + +export function cronToInterval(cron: string): number { + const aliases: Record = { + "@hourly": 60 * 60 * 1000, + "@daily": 24 * 60 * 60 * 1000, + "@weekly": 7 * 24 * 60 * 60 * 1000, + }; + if (aliases[cron]) return aliases[cron]; + const everyMinutes = /^\*\/(\d+)\s+\*\s+\*\s+\*\s+\*$/.exec(cron.trim()); + if (everyMinutes) return Number(everyMinutes[1]) * 60 * 1000; + throw new Error(`WRN-CRON-UNSUPPORTED: '${cron}'. Use @hourly, @daily, @weekly, or */N * * * *.`); } export function createQueue(options: QueueOptions = {}): Queue { const defaultMax = options.maxAttempts ?? 3; const backoffMs = options.backoffMs ?? 1000; const pollMs = options.pollMs ?? 250; + const concurrency = options.concurrency ?? Number.POSITIVE_INFINITY; if (!Number.isInteger(defaultMax) || defaultMax < 1) throw new RangeError("queue maxAttempts must be a positive integer"); if (!Number.isFinite(backoffMs) || backoffMs < 0) throw new RangeError("queue backoffMs must be a non-negative number"); if (!Number.isFinite(pollMs) || pollMs < 1) throw new RangeError("queue pollMs must be at least 1ms"); + if (!( + concurrency === Number.POSITIVE_INFINITY || + (Number.isInteger(concurrency) && concurrency > 0) + )) + throw new RangeError("queue concurrency must be a positive integer"); const now = options.now ?? Date.now; const jobs: Job[] = []; @@ -100,7 +157,10 @@ export function createQueue(options: QueueOptions = {}): Queue { draining = true; try { const cutoff = at ?? now(); - const due = jobs.filter((j) => j.runAt <= cutoff && handlers.has(j.name)); + const due = jobs + .filter((j) => j.runAt <= cutoff && handlers.has(j.name)) + .sort((a, b) => b.priority - a.priority || a.runAt - b.runAt || a.createdAt - b.createdAt) + .slice(0, concurrency); await Promise.all(due.map(runJob)); return due.length; } finally { @@ -120,14 +180,24 @@ export function createQueue(options: QueueOptions = {}): Queue { throw new RangeError("job delayMs must be a non-negative number"); if (opts.repeat !== undefined && (!Number.isFinite(opts.repeat) || opts.repeat <= 0)) throw new RangeError("job repeat must be a positive number"); + if (opts.priority !== undefined && !Number.isFinite(opts.priority)) + throw new RangeError("job priority must be a finite number"); + if (opts.idempotencyKey) { + const existing = jobs.find((job) => job.idempotencyKey === opts.idempotencyKey); + if (existing) return existing as Job; + } + const createdAt = now(); const job: Job = { id: `job_${++seq}`, name, data, attempts: 0, maxAttempts: opts.maxAttempts ?? defaultMax, - runAt: now() + (opts.delayMs ?? 0), + runAt: createdAt + (opts.delayMs ?? 0), repeat: opts.repeat, + priority: opts.priority ?? 0, + idempotencyKey: opts.idempotencyKey, + createdAt, }; jobs.push(job); return job as Job; @@ -145,5 +215,13 @@ export function createQueue(options: QueueOptions = {}): Queue { timer = null; }, size: () => jobs.length, + get: (id) => jobs.find((job) => job.id === id), + list: (name) => jobs.filter((job) => !name || job.name === name).map((job) => ({ ...job })), + cancel(id) { + const index = jobs.findIndex((job) => job.id === id); + if (index < 0) return false; + jobs.splice(index, 1); + return true; + }, }; } diff --git a/packages/queue/test/queue.test.ts b/packages/queue/test/queue.test.ts index 12c81c57..95788c6b 100644 --- a/packages/queue/test/queue.test.ts +++ b/packages/queue/test/queue.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { createQueue } from "../src/index.ts"; +import { createQueue, cronToInterval, defineWorkflow } from "../src/index.ts"; test("processes a job", async () => { const queue = createQueue(); @@ -104,3 +104,37 @@ test("drains independent due jobs concurrently", async () => { releases.forEach((release) => release()); expect(await draining).toBe(2); }); + +test("prioritizes due jobs and respects concurrency", async () => { + const queue = createQueue({ concurrency: 1 }); + const order: string[] = []; + queue.process("work", (job) => { + order.push(job.data); + }); + await queue.add("work", "low", { priority: 1 }); + await queue.add("work", "high", { priority: 10 }); + expect(await queue.drain()).toBe(1); + expect(order).toEqual(["high"]); + expect(queue.size()).toBe(1); +}); + +test("deduplicates, lists and cancels queued work", async () => { + const queue = createQueue(); + const first = await queue.add("sync", { id: 1 }, { idempotencyKey: "customer:1" }); + const second = await queue.add("sync", { id: 2 }, { idempotencyKey: "customer:1" }); + expect(second.id).toBe(first.id); + expect(queue.list("sync")).toHaveLength(1); + expect(queue.get(first.id)?.data).toEqual({ id: 1 }); + expect(queue.cancel(first.id)).toBe(true); + expect(queue.cancel(first.id)).toBe(false); +}); + +test("runs typed workflows and parses supported cron expressions", async () => { + const workflow = defineWorkflow("double-and-label", [ + { name: "double", run: (value: number) => value * 2 }, + { name: "label", run: (value: number) => `value:${value}` }, + ]); + expect(await workflow.run(5)).toBe("value:10"); + expect(cronToInterval("@hourly")).toBe(3_600_000); + expect(cronToInterval("*/5 * * * *")).toBe(300_000); +}); diff --git a/packages/reactive/package.json b/packages/reactive/package.json index c42dd4e4..b356f707 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/reactive/src/index.ts b/packages/reactive/src/index.ts index f04727da..9ac91cca 100644 --- a/packages/reactive/src/index.ts +++ b/packages/reactive/src/index.ts @@ -1,5 +1,3 @@ -/** - * @wrnexus/reactive — tiny reactive primitives. - */ -export type { Signal, Subscriber, Unsubscribe } from "./signal.ts"; -export { signal } from "./signal.ts"; +/** @wrnexus/reactive — fine-grained reactive primitives. */ +export type { Cleanup, ReadonlySignal, Signal, Subscriber, Unsubscribe } from "./signal.ts"; +export { batch, computed, effect, signal, untrack } from "./signal.ts"; diff --git a/packages/reactive/src/signal.ts b/packages/reactive/src/signal.ts index f972db32..9624de06 100644 --- a/packages/reactive/src/signal.ts +++ b/packages/reactive/src/signal.ts @@ -1,53 +1,158 @@ /** - * A minimal, type-safe reactive signal with zero dependencies. - * - * This is the seed of the framework's reactivity. Today it powers nothing on - * its own, but it is shaped so client islands (and later the `.wrn` compiler's - * `state` blocks) can build reactive bindings on top of it. - * - * const count = signal(0) - * count.get() // 0 - * count.set(1) // notifies subscribers - * const off = count.subscribe(v => console.log(v)) - * off() // unsubscribe + * Fine-grained reactive primitives shared by server utilities and client code. + * Updates are synchronous by default and coalesced inside `batch()`. */ -export type Subscriber = (value: T) => void; +export type Subscriber = (value: T, previous?: T) => void; export type Unsubscribe = () => void; +export type Cleanup = () => void; export interface Signal { - /** Read the current value. */ get(): T; - /** Write a new value; subscribers run only when the value actually changes. */ set(next: T): void; - /** Apply a function to the current value. */ update(fn: (current: T) => T): void; - /** Subscribe to changes; returns an unsubscribe function. */ subscribe(fn: Subscriber): Unsubscribe; } +export interface ReadonlySignal { + get(): T; + subscribe(fn: Subscriber): Unsubscribe; +} + +type DependencyCollector = (subscribe: (subscriber: Subscriber) => Unsubscribe) => void; + +let activeCollector: DependencyCollector | null = null; +let batchDepth = 0; +const pending = new Set<() => void>(); + +function enqueue(job: () => void): void { + if (batchDepth > 0) pending.add(job); + else job(); +} + +function flush(): void { + while (pending.size > 0) { + const jobs = [...pending]; + pending.clear(); + for (const job of jobs) job(); + } +} + +/** Coalesce every signal notification made by `fn` into one flush. */ +export function batch(fn: () => T): T { + batchDepth++; + try { + return fn(); + } finally { + batchDepth--; + if (batchDepth === 0) flush(); + } +} + +/** Read reactive values without recording dependencies. */ +export function untrack(fn: () => T): T { + const previous = activeCollector; + activeCollector = null; + try { + return fn(); + } finally { + activeCollector = previous; + } +} + export function signal(initial: T): Signal { let value = initial; + let pendingPrevious: T | undefined; + let queued = false; const subscribers = new Set>(); - return { + const notify = (): void => { + queued = false; + const previous = pendingPrevious; + pendingPrevious = undefined; + for (const fn of [...subscribers]) fn(value, previous); + }; + + const api: Signal = { get(): T { + if (activeCollector) { + activeCollector((subscriber) => api.subscribe(subscriber as Subscriber)); + } return value; }, set(next: T): void { - if (Object.is(next, value)) return; // skip no-op updates + if (Object.is(next, value)) return; + const previous = value; value = next; - // Iterate a copy so a subscriber may unsubscribe during notification. - for (const fn of [...subscribers]) fn(value); + if (!queued) { + queued = true; + pendingPrevious = previous; + enqueue(notify); + } }, update(fn: (current: T) => T): void { - this.set(fn(value)); + api.set(fn(value)); }, subscribe(fn: Subscriber): Unsubscribe { subscribers.add(fn); - return () => { - subscribers.delete(fn); - }; + return () => subscribers.delete(fn); }, }; + + return api; +} + +/** + * Run a dependency-tracked side effect. Dependencies are rebuilt after every + * execution, preventing stale subscriptions when conditional reads change. + */ +export function effect(run: () => void | Cleanup): Cleanup { + let disposed = false; + let cleanup: void | Cleanup; + let subscriptions: Cleanup[] = []; + let scheduled = false; + + const execute = (): void => { + scheduled = false; + if (disposed) return; + if (typeof cleanup === "function") cleanup(); + for (const unsubscribe of subscriptions) unsubscribe(); + subscriptions = []; + + const previous = activeCollector; + activeCollector = (subscribe) => { + subscriptions.push( + subscribe(() => { + if (scheduled || disposed) return; + scheduled = true; + enqueue(execute); + }), + ); + }; + try { + const nextCleanup = run(); + cleanup = typeof nextCleanup === "function" ? nextCleanup : undefined; + } finally { + activeCollector = previous; + } + }; + + execute(); + return () => { + if (disposed) return; + disposed = true; + if (typeof cleanup === "function") cleanup(); + for (const unsubscribe of subscriptions) unsubscribe(); + subscriptions = []; + }; +} + +/** Create a lazily readable derived signal with automatic dependency tracking. */ +export function computed(read: () => T): ReadonlySignal { + const output = signal(undefined as T); + effect(() => output.set(read())); + return { + get: output.get, + subscribe: output.subscribe, + }; } diff --git a/packages/reactive/test/signal.test.ts b/packages/reactive/test/signal.test.ts index 95076363..9dc636b2 100644 --- a/packages/reactive/test/signal.test.ts +++ b/packages/reactive/test/signal.test.ts @@ -1,17 +1,47 @@ -import { expect, test } from "bun:test"; -import { signal } from "../src/index.ts"; +import { describe, expect, test } from "bun:test"; +import { batch, computed, effect, signal } from "../src/index.ts"; -test("signal skips equal writes and supports safe unsubscribe during notification", () => { - const count = signal(0); - const seen: number[] = []; - let off = () => {}; - off = count.subscribe((value) => { - seen.push(value); - off(); +describe("reactive primitives", () => { + test("signals skip no-op updates", () => { + const value = signal(1); + const seen: number[] = []; + value.subscribe((next) => seen.push(next)); + value.set(1); + value.set(2); + expect(seen).toEqual([2]); + }); + + test("batch coalesces notifications", () => { + const value = signal(0); + const seen: number[] = []; + value.subscribe((next) => seen.push(next)); + batch(() => { + value.set(1); + value.set(2); + value.set(3); + }); + expect(seen).toEqual([3]); + }); + + test("computed values and effects track dependencies", () => { + const count = signal(2); + const doubled = computed(() => count.get() * 2); + const seen: number[] = []; + const dispose = effect(() => { + seen.push(doubled.get()); + }); + count.set(3); + dispose(); + count.set(4); + expect(seen).toEqual([4, 6]); + }); + + test("effects ignore accidental non-function return values", () => { + const value = signal(0); + const seen: number[] = []; + const dispose = effect(() => seen.push(value.get()) as unknown as void); + value.set(1); + dispose(); + expect(seen).toEqual([0, 1]); }); - count.set(0); - count.update((value) => value + 1); - count.set(2); - expect(seen).toEqual([1]); - expect(count.get()).toBe(2); }); diff --git a/packages/router/package.json b/packages/router/package.json index 72796860..482b9307 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 7acb0ff9..6a0527a3 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -23,12 +23,19 @@ import { compileRoutePattern, matchRoute, sortRoutes, + findRouteConflicts, type Route, type RouteMatch, } from "./match.ts"; export type { Route, RouteMatch } from "./match.ts"; -export { compileRoutePattern, matchRoute, sortRoutes } from "./match.ts"; +export { + compileRoutePattern, + getRouteParams, + matchRoute, + sortRoutes, + findRouteConflicts, +} from "./match.ts"; export { generateRoutesFile } from "./routes-gen.ts"; export interface ComponentRef { @@ -69,10 +76,13 @@ export interface RouterOptions { * - drops a trailing `index` segment * - prefixes with `prefix` (e.g. "/api") */ -function fileToRoute(rel: string, prefix: string): string { +export function fileToRoute(rel: string, prefix = ""): string { const withoutExt = rel.replace(/\.(tsx|ts|wrn)$/, ""); - const segments = withoutExt.split("/").filter(Boolean); - if (segments[segments.length - 1] === "index") segments.pop(); + const segments = withoutExt + .split("/") + .filter(Boolean) + .filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); + if (["index", "page"].includes(segments[segments.length - 1] ?? "")) segments.pop(); const tail = segments.join("/"); const route = prefix + (tail ? "/" + tail : ""); return route === "" ? "/" : route; @@ -81,8 +91,8 @@ function fileToRoute(rel: string, prefix: string): string { function buildRoutes(files: ScannedFile[], prefix: string): Route[] { const routes = files.map((f): Route => { const raw = fileToRoute(f.rel, prefix); - const { regex, paramNames } = compileRoutePattern(raw); - return { raw, file: f.file, regex, paramNames }; + const { regex, paramNames, paramMeta } = compileRoutePattern(raw); + return { raw, file: f.file, regex, paramNames, paramMeta }; }); return sortRoutes(routes); } @@ -93,8 +103,8 @@ function normalizeEmbeddedApiPath(path: string): string { } function routeFromRaw(raw: string, file: string): Route { - const { regex, paramNames } = compileRoutePattern(raw); - return { raw, file, regex, paramNames }; + const { regex, paramNames, paramMeta } = compileRoutePattern(raw); + return { raw, file, regex, paramNames, paramMeta }; } function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick { @@ -121,6 +131,14 @@ function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick f.file) diff --git a/packages/router/src/match.ts b/packages/router/src/match.ts index 70d12784..575db6fe 100644 --- a/packages/router/src/match.ts +++ b/packages/router/src/match.ts @@ -1,10 +1,20 @@ /** * Route compilation + matching. * - * A "route" is a URL pattern compiled to a RegExp. We support static segments - * and dynamic `[param]` segments, e.g. `/users/[id]` -> `{ id }`. + * Supported segments: + * [id] required parameter + * [id?] optional parameter + * [[id]] optional parameter (directory-friendly form) + * [...slug] required catch-all + * [[...slug]] optional catch-all */ +export interface RouteParam { + name: string; + optional: boolean; + catchAll: boolean; +} + export interface Route { /** The human-readable route pattern, e.g. `/users/[id]`. */ raw: string; @@ -14,6 +24,8 @@ export interface Route { regex: RegExp; /** Ordered names of dynamic params captured by `regex`. */ paramNames: string[]; + /** Rich parameter metadata. Optional for compatibility with old manifests. */ + paramMeta?: RouteParam[]; } export interface RouteMatch { @@ -23,52 +35,138 @@ export interface RouteMatch { const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; -/** Compile a `/users/[id]` style pattern into a RegExp + param names. */ -export function compileRoutePattern(raw: string): Pick { - if (raw === "/") { - return { regex: /^\/$/, paramNames: [] }; +function parseParamSegment(segment: string): RouteParam | null { + let inner: string | null = null; + let optional = false; + + if (segment.startsWith("[[") && segment.endsWith("]]")) { + inner = segment.slice(2, -2); + optional = true; + } else if (segment.startsWith("[") && segment.endsWith("]")) { + inner = segment.slice(1, -1); + if (inner.endsWith("?")) { + optional = true; + inner = inner.slice(0, -1); + } } - const paramNames: string[] = []; - const parts = raw + if (inner === null) return null; + const catchAll = inner.startsWith("..."); + const name = catchAll ? inner.slice(3) : inner; + if (!/^[A-Za-z_$][\w$-]*$/.test(name)) { + throw new Error(`WRN-ROUTE-PARAM: Invalid route parameter '${segment}'.`); + } + return { name, optional, catchAll }; +} + +/** Return parameter metadata without requiring callers to inspect the regex. */ +export function getRouteParams(raw: string): RouteParam[] { + return raw .split("/") .filter(Boolean) - .map((segment) => { - const dynamic = segment.match(/^\[(.+)\]$/); - if (dynamic) { - paramNames.push(dynamic[1]!); - return "([^/]+)"; - } - return segment.replace(ESCAPE_RE, "\\$&"); - }); + .map(parseParamSegment) + .filter((value): value is RouteParam => value !== null); +} - // Allow an optional trailing slash. - const regex = new RegExp("^/" + parts.join("/") + "/?$"); - return { regex, paramNames }; +/** Compile a WRNexus route pattern into a RegExp + parameter metadata. */ +export function compileRoutePattern( + raw: string, +): Pick { + if (raw === "/") { + return { regex: /^\/$/, paramNames: [], paramMeta: [] }; + } + + const paramMeta: RouteParam[] = []; + let source = "^"; + const segments = raw.split("/").filter(Boolean); + + for (const segment of segments) { + const param = parseParamSegment(segment); + if (!param) { + source += `/${segment.replace(ESCAPE_RE, "\\$&")}`; + continue; + } + + if (paramMeta.some((existing) => existing.name === param.name)) { + throw new Error( + `WRN-ROUTE-DUPLICATE-PARAM: Parameter '${param.name}' appears more than once in '${raw}'.`, + ); + } + paramMeta.push(param); + + const capture = param.catchAll ? "(.+?)" : "([^/]+)"; + source += param.optional ? `(?:/${capture})?` : `/${capture}`; + } + + source += "/?$"; + return { + regex: new RegExp(source), + paramNames: paramMeta.map((param) => param.name), + paramMeta, + }; +} + +function routeSpecificity(route: Route): number[] { + const segments = route.raw.split("/").filter(Boolean); + let staticCount = 0; + let requiredCount = 0; + let optionalCount = 0; + let catchAllCount = 0; + for (const segment of segments) { + const param = parseParamSegment(segment); + if (!param) staticCount++; + else if (param.catchAll) catchAllCount++; + else if (param.optional) optionalCount++; + else requiredCount++; + } + return [ + staticCount, + requiredCount, + -optionalCount, + -catchAllCount, + segments.length, + route.raw.length, + ]; } /** - * Order routes so that static routes win over dynamic ones, and longer/more - * specific routes win over shorter ones. Sorting once keeps matching simple. + * Order routes so static and constrained routes win over optional/catch-all + * routes. The ordering remains deterministic for identical specificity. */ export function sortRoutes(routes: Route[]): Route[] { return [...routes].sort((a, b) => { - if (a.paramNames.length !== b.paramNames.length) { - return a.paramNames.length - b.paramNames.length; // fewer params first + const as = routeSpecificity(a); + const bs = routeSpecificity(b); + for (let i = 0; i < as.length; i++) { + if (as[i] !== bs[i]) return bs[i]! - as[i]!; } - return b.raw.length - a.raw.length; // longer/more specific first + return a.raw.localeCompare(b.raw) || a.file.localeCompare(b.file); }); } +/** Find duplicate URL patterns before request handling starts. */ +export function findRouteConflicts(routes: Route[]): Array<{ raw: string; files: string[] }> { + const grouped = new Map(); + for (const route of routes) { + const files = grouped.get(route.raw) ?? []; + files.push(route.file); + grouped.set(route.raw, files); + } + return [...grouped] + .filter(([, files]) => files.length > 1) + .map(([raw, files]) => ({ raw, files })); +} + /** Find the first route whose pattern matches `pathname`. */ export function matchRoute(routes: Route[], pathname: string): RouteMatch | null { for (const route of routes) { - const m = route.regex.exec(pathname); - if (!m) continue; + const match = route.regex.exec(pathname); + if (!match) continue; const params: Record = {}; try { - route.paramNames.forEach((name, i) => { - params[name] = decodeURIComponent(m[i + 1]!); + route.paramNames.forEach((name, index) => { + const value = match[index + 1]; + if (value !== undefined) params[name] = decodeURIComponent(value); }); } catch { // A malformed percent-encoded path is not a valid route match. Treat it diff --git a/packages/router/src/routes-gen.ts b/packages/router/src/routes-gen.ts index 404d5a3a..80ad4fb0 100644 --- a/packages/router/src/routes-gen.ts +++ b/packages/router/src/routes-gen.ts @@ -1,10 +1,9 @@ /** * Typed-routes codegen. From the scanned page routes, emit `app/routes.gen.ts` - * with a `Routes` map (path → param types) and an `href()` builder — so links - * are checked at compile time (unknown path or missing param = type error). + * with a `Routes` map (path -> param types) and an `href()` builder. */ -import type { Route } from "./match.ts"; +import { getRouteParams, type Route } from "./match.ts"; export function generateRoutesFile(pages: Route[]): string { const seen = new Set(); @@ -12,35 +11,73 @@ export function generateRoutesFile(pages: Route[]): string { for (const page of [...pages].sort((a, b) => a.raw.localeCompare(b.raw))) { if (seen.has(page.raw)) continue; seen.add(page.raw); - const type = page.paramNames.length - ? `{ ${page.paramNames.map((n) => `${JSON.stringify(n)}: string`).join("; ")} }` + const params = page.paramMeta ?? getRouteParams(page.raw); + const type = params.length + ? `{ ${params + .map((param) => { + const key = `${JSON.stringify(param.name)}${param.optional ? "?" : ""}`; + const value = param.catchAll ? "string | readonly string[]" : "string"; + return `${key}: ${value}`; + }) + .join("; ")} }` : "Record"; entries.push(` ${JSON.stringify(page.raw)}: ${type};`); } - return `// AUTO-GENERATED by \`wrnexus dev\` — do not edit. -// Typed routes: a compile-time map of every page path to its [param] types, -// plus an href() builder that fills params and rejects unknown paths. + return `// AUTO-GENERATED by \`wrnexus dev\` - do not edit. +// Typed routes support required, optional, and catch-all parameters. export interface Routes { ${entries.join("\n") || " [path: string]: Record;"} } export type RoutePath = keyof Routes; +type RouteValue = string | readonly string[] | undefined; + +function encodeRouteValue(value: RouteValue, catchAll: boolean): string { + if (value === undefined) return ""; + const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)]; + return values.map((part) => encodeURIComponent(part)).join("/"); +} export function href

( path: P, - ...args: Routes[P] extends Record ? [] : [params: Routes[P]] + ...args: keyof Routes[P] extends never + ? [] + : Record extends Routes[P] + ? [params?: Routes[P]] + : [params: Routes[P]] ): string { - const params = (args[0] ?? {}) as Record; - return String(path) - .split("/") - .map((seg) => - seg.startsWith("[") && seg.endsWith("]") - ? encodeURIComponent(params[seg.slice(1, -1)] ?? "") - : seg, - ) - .join("/"); + const params = (args[0] ?? {}) as Record; + const output: string[] = []; + for (const segment of String(path).split("/").filter(Boolean)) { + let name: string | undefined; + let optional = false; + let catchAll = false; + if (segment.startsWith("[[") && segment.endsWith("]]")) { + optional = true; + name = segment.slice(2, -2); + } else if (segment.startsWith("[") && segment.endsWith("]")) { + name = segment.slice(1, -1); + if (name.endsWith("?")) { + optional = true; + name = name.slice(0, -1); + } + } + if (!name) { + output.push(segment); + continue; + } + if (name.startsWith("...")) { + catchAll = true; + name = name.slice(3); + } + const value = params[name]; + if (value === undefined && optional) continue; + if (value === undefined) throw new Error(\`WRN-ROUTE-MISSING-PARAM: Missing route parameter '\${name}'.\`); + output.push(encodeRouteValue(value, catchAll)); + } + return "/" + output.filter(Boolean).join("/"); } `; } diff --git a/packages/router/test/routes-gen.test.ts b/packages/router/test/routes-gen.test.ts index 12e9f473..c549063f 100644 --- a/packages/router/test/routes-gen.test.ts +++ b/packages/router/test/routes-gen.test.ts @@ -1,42 +1,77 @@ import { test, expect } from "bun:test"; -import { compileRoutePattern, generateRoutesFile, matchRoute, type Route } from "../src/index.ts"; +import { + compileRoutePattern, + fileToRoute, + generateRoutesFile, + matchRoute, + sortRoutes, + type Route, +} from "../src/index.ts"; function route(raw: string): Route { return { raw, file: raw, ...compileRoutePattern(raw) }; } test("generateRoutesFile emits a Routes map with param types", () => { - const code = generateRoutesFile([route("/"), route("/about"), route("/users/[id]")]); + const code = generateRoutesFile([ + route("/"), + route("/about"), + route("/users/[id]"), + route("/docs/[[...slug]]"), + ]); expect(code).toContain('"/": Record;'); expect(code).toContain('"/about": Record;'); expect(code).toContain('"/users/[id]": { "id": string };'); + expect(code).toContain('"/docs/[[...slug]]": { "slug"?: string | readonly string[] };'); expect(code).toContain("export function href"); }); -test("generated href() fills params and leaves static paths intact", async () => { - // Compile + import the generated module to exercise href() for real. +test("generated href() fills required, optional and catch-all params", async () => { const code = generateRoutesFile([ route("/"), route("/users/[id]"), route("/org/[org]/team/[team]"), + route("/blog/[[page]]"), + route("/docs/[...slug]"), ]); const { tmpdir } = await import("node:os"); const { writeFileSync, mkdirSync } = await import("node:fs"); const { join } = await import("node:path"); const { pathToFileURL } = await import("node:url"); - const dir = join(tmpdir(), "wire-routes-test"); + const dir = join(tmpdir(), "wrnexus-routes-test"); mkdirSync(dir, { recursive: true }); const file = join(dir, `r${Date.now()}.ts`); writeFileSync(file, code); const mod = (await import(pathToFileURL(file).href)) as { - href: (p: string, params?: Record) => string; + href: (p: string, params?: Record) => string; }; expect(mod.href("/")).toBe("/"); expect(mod.href("/users/[id]", { id: "42" })).toBe("/users/42"); expect(mod.href("/org/[org]/team/[team]", { org: "acme", team: "core" })).toBe( "/org/acme/team/core", ); - expect(mod.href("/users/[id]", { id: "a b" })).toBe("/users/a%20b"); // encoded + expect(mod.href("/users/[id]", { id: "a b" })).toBe("/users/a%20b"); + expect(mod.href("/blog/[[page]]")).toBe("/blog"); + expect(mod.href("/docs/[...slug]", { slug: ["guide", "start here"] })).toBe( + "/docs/guide/start%20here", + ); +}); + +test("matches optional and catch-all routes", () => { + expect(matchRoute([route("/blog/[[page]]")], "/blog")?.params).toEqual({}); + expect(matchRoute([route("/blog/[[page]]")], "/blog/2")?.params).toEqual({ page: "2" }); + expect(matchRoute([route("/docs/[...slug]")], "/docs/a/b")?.params).toEqual({ slug: "a/b" }); + expect(matchRoute([route("/docs/[[...slug]]")], "/docs")?.params).toEqual({}); +}); + +test("static routes sort ahead of dynamic and catch-all routes", () => { + const sorted = sortRoutes([route("/docs/[...slug]"), route("/docs/[id]"), route("/docs/new")]); + expect(sorted.map((item) => item.raw)).toEqual(["/docs/new", "/docs/[id]", "/docs/[...slug]"]); +}); + +test("route groups and page/index filenames do not affect URLs", () => { + expect(fileToRoute("(marketing)/pricing/page.wrn")).toBe("/pricing"); + expect(fileToRoute("(dashboard)/index.wrn")).toBe("/"); }); test("malformed encoded route params return no match instead of throwing", () => { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 2bf1b0d8..90d4b903 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/src/index.ts b/packages/ssr/src/index.ts index 5509e515..f55944bd 100644 --- a/packages/ssr/src/index.ts +++ b/packages/ssr/src/index.ts @@ -177,3 +177,62 @@ function resolveSeoUrl( return value; } } + +export interface StreamRenderOptions extends Omit { + body: string | Promise | AsyncIterable; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in value && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" + ); +} + +async function* bodyChunks(body: StreamRenderOptions["body"]): AsyncIterable { + if (typeof body === "string") { + yield body; + return; + } + if (isAsyncIterable(body)) { + yield* body; + return; + } + yield await body; +} + +/** + * Stream a complete document while preserving the exact head/body contract of + * `renderDocument`. Async iterables can flush a shell, primary content, and + * slower fragments without buffering the entire route. + */ +export function renderDocumentStream(opts: StreamRenderOptions): ReadableStream { + const marker = ""; + const document = renderDocument({ ...opts, body: marker }); + const [prefix, suffix] = document.split(marker); + const encoder = new TextEncoder(); + + return new ReadableStream({ + async start(controller) { + try { + controller.enqueue(encoder.encode(prefix ?? "")); + for await (const chunk of bodyChunks(opts.body)) controller.enqueue(encoder.encode(chunk)); + controller.enqueue(encoder.encode(suffix ?? "")); + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); +} + +export function streamDocumentResponse( + opts: StreamRenderOptions, + init: ResponseInit = {}, +): Response { + const headers = new Headers(init.headers); + if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8"); + return new Response(renderDocumentStream(opts), { ...init, headers }); +} diff --git a/packages/ssr/test/ssr.test.ts b/packages/ssr/test/ssr.test.ts index a70919c5..bf48b96f 100644 --- a/packages/ssr/test/ssr.test.ts +++ b/packages/ssr/test/ssr.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { renderDocument } from "../src/index.ts"; +import { renderDocument, renderDocumentStream } from "../src/index.ts"; test("escapes metadata and script URLs while preserving trusted rendered body", () => { const html = renderDocument({ @@ -35,3 +35,14 @@ test("always emits a document language and preserves an explicit language", () = expect(explicit).toContain(''); expect(explicit).not.toContain('lang="en"'); }); + +test("streams async body chunks inside the document shell", async () => { + async function* body() { + yield "

Shell

"; + yield "

Later

"; + } + const html = await new Response( + renderDocumentStream({ meta: { title: "Stream" }, body: body() }), + ).text(); + expect(html).toContain('

Shell

Later

'); +}); diff --git a/packages/styles/package.json b/packages/styles/package.json index 34b687d2..2d8493b9 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,12 +1,14 @@ { "name": "@wrnexus/styles", - "version": "0.2.79", + "version": "0.3.0", "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" }, "dependencies": { - "@wrnexus/uploader": "workspace:*" + "@wrnexus/uploader": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/plugin": "workspace:*" } } diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index 0577de42..802c9a74 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -10,7 +10,8 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import type { SecurityConfig, SeoConfig } from "@wrnexus/core"; +import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core"; +import type { PluginInput } from "@wrnexus/plugin"; import type { StorageConfig } from "@wrnexus/uploader"; import type { ThemeConfig } from "./theme.ts"; import type { FontConfig } from "./fonts.ts"; @@ -131,7 +132,59 @@ export interface DevToolbarConfig { veryLargeImageBytes?: number; } +export interface ExperimentalConfig { + serverComponents?: boolean; + streaming?: boolean; + partialHydration?: boolean; + typedRpc?: boolean; + pluginTransforms?: boolean; + [feature: string]: boolean | undefined; +} + +export interface PerformanceConfig { + budgets?: PerformanceBudgets; + /** `warn` reports budget violations; `error` fails production builds. */ + enforcement?: "off" | "warn" | "error"; + analyze?: boolean; +} + +export interface ObservabilityConfig { + enabled?: boolean; + serviceName?: string; + serverTiming?: boolean; + sampleRate?: number; + exporter?: "console" | "otlp" | "none"; + endpoint?: string; +} + +export interface TenancyConfig { + mode?: "subdomain" | "domain" | "path" | "custom"; + required?: boolean; + rootDomains?: string[]; + pathPrefix?: string; +} + +export interface BuildConfig { + cache?: boolean; + cacheDir?: string; + sourceMaps?: boolean; + report?: boolean; + adapter?: "bun" | "node" | "static" | "serverless" | "edge" | string; +} + export interface AppConfig { + /** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */ + plugins?: PluginInput; + /** Opt-in APIs that are not yet covered by stable compatibility guarantees. */ + experimental?: ExperimentalConfig; + /** Route and asset budgets plus build analyzer behavior. */ + performance?: PerformanceConfig; + /** Request tracing, Server-Timing, and exporter configuration. */ + observability?: ObservabilityConfig; + /** First-class tenant resolution defaults. */ + tenancy?: TenancyConfig; + /** Build cache, source map, report, and deployment adapter settings. */ + build?: BuildConfig; /** Development-only page diagnostics toolbar. Enabled by default in development. */ devToolbar?: boolean | DevToolbarConfig; /** Raw HTML appended to every page's `` (e.g. CDN stylesheet links). */ @@ -237,6 +290,13 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise< const merged: AppConfig = override ? deepMerge(base, override) : { ...base }; delete merged.profiles; applyFontCsp(merged); + const issues = validateAppConfig(merged); + const errors = issues.filter((issue) => issue.severity === "error"); + if (errors.length) { + throw new Error( + `Invalid wrnexus.config: ${errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`, + ); + } return merged; } @@ -306,6 +366,73 @@ function parseDotenv(content: string): Record { return out; } +export interface ConfigIssue { + path: string; + severity: "error" | "warning"; + message: string; +} + +export function defineConfig(config: AppConfig): AppConfig { + return config; +} + +export function validateAppConfig(config: AppConfig): ConfigIssue[] { + const issues: ConfigIssue[] = []; + const sampleRate = config.observability?.sampleRate; + if (sampleRate !== undefined && (sampleRate < 0 || sampleRate > 1)) { + issues.push({ + path: "observability.sampleRate", + severity: "error", + message: "must be between 0 and 1", + }); + } + const budgets = config.performance?.budgets; + if (budgets) { + for (const [name, value] of Object.entries(budgets)) { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + issues.push({ + path: `performance.budgets.${name}`, + severity: "error", + message: "must be a non-negative finite number", + }); + } + } + } + if (config.tenancy?.mode === "path" && !config.tenancy.pathPrefix) { + issues.push({ + path: "tenancy.pathPrefix", + severity: "warning", + message: "is recommended when tenancy.mode is path", + }); + } + return issues; +} + +export interface ExplainedConfig { + profile: string; + config: AppConfig; + issues: ConfigIssue[]; + sources: string[]; +} + +export async function explainAppConfig( + appRoot: string, + profile?: string, +): Promise { + const active = profile ?? resolveProfile(); + const config = await loadAppConfig(appRoot, active); + const sources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name))); + const envSources = [".env", ".env.local", `.env.${active}`, `.env.${active}.local`].filter( + (name) => existsSync(join(appRoot, name)), + ); + return { + profile: active, + config, + issues: validateAppConfig(config), + sources: [...sources, ...envSources], + }; +} + /** Flatten a head config into a single HTML string. */ export function headToString(head?: string | string[]): string { if (!head) return ""; diff --git a/packages/styles/src/index.ts b/packages/styles/src/index.ts index bc89a3d5..7f5548e7 100644 --- a/packages/styles/src/index.ts +++ b/packages/styles/src/index.ts @@ -9,13 +9,30 @@ export type { AppConfig, + BuildConfig, + ConfigIssue, + DevToolbarConfig, + ExplainedConfig, + ExperimentalConfig, MobileConfig, + ObservabilityConfig, + PerformanceConfig, + TenancyConfig, PwaConfig, StylesConfig, StyleProcessContext, Mode, } from "./config.ts"; -export { loadAppConfig, loadRawConfig, headToString, resolveProfile, loadEnv } from "./config.ts"; +export { + defineConfig, + explainAppConfig, + headToString, + loadAppConfig, + loadEnv, + loadRawConfig, + resolveProfile, + validateAppConfig, +} from "./config.ts"; export { findStyleEntry, bundleCss } from "./styles.ts"; export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts"; export { renderFontHead, renderProductionFontHead, fontCspSources } from "./fonts.ts"; diff --git a/packages/syntax/README.md b/packages/syntax/README.md new file mode 100644 index 00000000..7039cc05 --- /dev/null +++ b/packages/syntax/README.md @@ -0,0 +1,7 @@ +# @wrnexus/syntax + +Canonical WRN lexer, parser, AST, language metadata, source positions, and stable +diagnostics. Framework tooling should import this package instead of implementing a +separate `.wrn` parser. + +See `docs/WRN-LANGUAGE-SPEC-1.0.md` in the WRNexusJS repository. diff --git a/packages/syntax/package.json b/packages/syntax/package.json new file mode 100644 index 00000000..d5af1819 --- /dev/null +++ b/packages/syntax/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wrnexus/syntax", + "version": "0.3.0", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./parser": "./src/parser.ts", + "./tokenizer": "./src/tokenizer.ts", + "./types": "./src/types.ts", + "./diagnostics": "./src/diagnostics.ts", + "./spec": "./src/spec.ts" + } +} diff --git a/packages/syntax/src/diagnostics.ts b/packages/syntax/src/diagnostics.ts new file mode 100644 index 00000000..8b9d3051 --- /dev/null +++ b/packages/syntax/src/diagnostics.ts @@ -0,0 +1,206 @@ +import { parse, ParseError, type PageAst, type ViewNode } from "./parser.ts"; +import { + WRN_DIAGNOSTIC_CODES, + WRN_HYDRATION_STRATEGIES, + WRN_RUNTIME_TARGETS, + type WrnHydrationStrategy, + type WrnRuntimeTarget, +} from "./spec.ts"; + +export type WrnDiagnosticSeverity = "error" | "warning" | "info"; + +export interface WrnSourcePosition { + offset: number; + line: number; + column: number; +} + +export interface WrnDiagnostic { + code: string; + severity: WrnDiagnosticSeverity; + message: string; + hint?: string; + file?: string; + position?: WrnSourcePosition; +} + +export interface DiagnoseOptions { + file?: string; + accessibility?: boolean; +} + +export function positionAt(source: string, offset: number): WrnSourcePosition { + const safe = Math.max(0, Math.min(offset, source.length)); + const before = source.slice(0, safe); + const lines = before.split(/\r?\n/); + return { offset: safe, line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 }; +} + +function offsetFromMessage(message: string): number | undefined { + const match = /offset\s+(\d+)/i.exec(message); + return match ? Number(match[1]) : undefined; +} + +export function classifyParseError(message: string): string { + if (/Expected 'page', 'component', or 'layout'/.test(message)) return WRN_DIAGNOSTIC_CODES.root; + if (/Unknown (?:page|component|layout|ssr|client) member/.test(message)) { + return WRN_DIAGNOSTIC_CODES.member; + } + if (/prop initializer|Expected eq/.test(message)) return WRN_DIAGNOSTIC_CODES.propInitializer; + if (/State '.+' requires an initializer/.test(message)) { + return WRN_DIAGNOSTIC_CODES.stateInitializer; + } + if (/Cannot watch undeclared state/.test(message)) return WRN_DIAGNOSTIC_CODES.watchUndeclared; + return WRN_DIAGNOSTIC_CODES.parse; +} + +export function diagnosticFromError( + source: string, + error: unknown, + options: DiagnoseOptions = {}, +): WrnDiagnostic { + const message = error instanceof Error ? error.message : String(error); + const offset = + error instanceof ParseError && error.offset !== undefined + ? error.offset + : offsetFromMessage(message); + return { + code: error instanceof ParseError ? error.code : classifyParseError(message), + severity: "error", + message, + file: options.file, + ...(offset === undefined ? {} : { position: positionAt(source, offset) }), + }; +} + +function walk(nodes: ViewNode[], visit: (node: ViewNode) => void): void { + for (const node of nodes) { + visit(node); + if (node.type === "element") walk(node.children, visit); + else if (node.type === "each") { + walk(node.body, visit); + walk(node.empty, visit); + } else if (node.type === "if") { + for (const branch of node.branches) walk(branch.body, visit); + } + } +} + +function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[] { + const diagnostics: WrnDiagnostic[] = []; + const seen = new Map(); + for (const [kind, declarations] of [ + ["prop", ast.props], + ["state", ast.states], + ["computed", ast.computed], + ] as const) { + for (const declaration of declarations) { + const previous = seen.get(declaration.name); + if (previous) { + diagnostics.push({ + code: WRN_DIAGNOSTIC_CODES.duplicateSymbol, + severity: "error", + message: `Duplicate symbol '${declaration.name}' (${previous} and ${kind}).`, + hint: "Rename one declaration so every prop, state, and computed value is unique.", + file: options.file, + }); + } else { + seen.set(declaration.name, kind); + } + } + } + + if (ast.hydrate && !isHydrationStrategy(ast.hydrate)) { + diagnostics.push({ + code: WRN_DIAGNOSTIC_CODES.invalidHydration, + severity: "error", + message: `Unknown hydration strategy '${ast.hydrate}'.`, + hint: "Use load, idle, visible, interaction, none, or media:.", + file: options.file, + }); + } + if (ast.runtime && !isRuntimeTarget(ast.runtime)) { + diagnostics.push({ + code: WRN_DIAGNOSTIC_CODES.invalidRuntime, + severity: "error", + message: `Unknown runtime target '${ast.runtime}'.`, + hint: "Use server, client, or universal.", + file: options.file, + }); + } + + let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0; + walk(ast.view, (node) => { + if (node.type === "element" && node.attrs.some((attribute) => attribute.event)) + interactive = true; + if (!options.accessibility || node.type !== "element") return; + const tag = node.tag.toLowerCase(); + if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) { + diagnostics.push({ + code: WRN_DIAGNOSTIC_CODES.accessibility, + severity: "warning", + message: "Image is missing an alt attribute.", + hint: 'Add alt text, or alt="" for a decorative image.', + file: options.file, + }); + } + }); + if (ast.runtime === "server" && interactive) { + diagnostics.push({ + code: WRN_DIAGNOSTIC_CODES.serverInteractive, + severity: "error", + message: + "A server-only WRN root cannot contain client state, effects, watches, or event handlers.", + hint: 'Use runtime = "universal" or remove interactive behavior.', + file: options.file, + }); + } + return diagnostics; +} + +export function diagnose(source: string, options: DiagnoseOptions = {}): WrnDiagnostic[] { + try { + return astDiagnostics(parse(source), options); + } catch (error) { + return [diagnosticFromError(source, error, options)]; + } +} + +export function assertValidAst(ast: PageAst, options: DiagnoseOptions = {}): void { + const errors = astDiagnostics(ast, options).filter( + (diagnostic) => diagnostic.severity === "error", + ); + if (!errors.length) return; + const first = errors[0]!; + throw new ParseError(first.message, first.code); +} + +export function isHydrationStrategy(value: string): value is WrnHydrationStrategy { + return ( + (WRN_HYDRATION_STRATEGIES as readonly string[]).includes(value) || + (value.startsWith("media:") && value.length > "media:".length) + ); +} + +export function isRuntimeTarget(value: string): value is WrnRuntimeTarget { + return (WRN_RUNTIME_TARGETS as readonly string[]).includes(value); +} + +export function formatDiagnostic(source: string, diagnostic: WrnDiagnostic): string { + const location = diagnostic.position + ? `${diagnostic.file ?? ""}:${diagnostic.position.line}:${diagnostic.position.column}` + : (diagnostic.file ?? ""); + const lines = [ + `${diagnostic.code} ${diagnostic.severity.toUpperCase()}`, + "", + diagnostic.message, + "", + location, + ]; + if (diagnostic.position) { + const sourceLine = source.split(/\r?\n/)[diagnostic.position.line - 1] ?? ""; + lines.push("", sourceLine, `${" ".repeat(Math.max(0, diagnostic.position.column - 1))}^`); + } + if (diagnostic.hint) lines.push("", `Hint: ${diagnostic.hint}`); + return lines.join("\n"); +} diff --git a/packages/syntax/src/index.ts b/packages/syntax/src/index.ts new file mode 100644 index 00000000..5c7ccaee --- /dev/null +++ b/packages/syntax/src/index.ts @@ -0,0 +1,46 @@ +export { Lexer, LexError } from "./tokenizer.ts"; +export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts"; +export type { + ActionBlock, + ApiBlock, + Attr, + ComputedDecl, + DataApiBlock, + DataMode, + EffectBlock, + LifecycleBlock, + LoadBlock, + ModeFunctionsBlock, + PageAst, + PropDecl, + RealtimeBlock, + RealtimeHandler, + SeoBlock, + StateDecl, + ViewNode, + WatchBlock, +} from "./parser.ts"; +export { + eraseFunctionTypes, + inferredRuntimeType, + runtimeTypeOf, + validateTypedInitializer, +} from "./types.ts"; +export type { RuntimeType } from "./types.ts"; +export { + assertValidAst, + classifyParseError, + diagnose, + diagnosticFromError, + formatDiagnostic, + isHydrationStrategy, + isRuntimeTarget, + positionAt, +} from "./diagnostics.ts"; +export type { + DiagnoseOptions, + WrnDiagnostic, + WrnDiagnosticSeverity, + WrnSourcePosition, +} from "./diagnostics.ts"; +export * from "./spec.ts"; diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts new file mode 100644 index 00000000..453b7f63 --- /dev/null +++ b/packages/syntax/src/parser.ts @@ -0,0 +1,953 @@ +/** + * Recursive-descent parser for `.wrn`, producing a small AST. + * + * Grammar (subset of the vision, but real): + * + * page { + * types { } + * props { : [= ] } // no default means required + * state : = // type annotation is optional + * view { } // plain HTML (see parseHtmlView) + * seo { title = "Home" description = "..." } + * ssr { api { } functions { } } + * client { api { } functions { } } + * style { } // zero or more, inlined with the page + * functions { } // zero or more, shared helpers + * api { } // zero or more + * realtime { on () { } * } // zero or more + * } + * + * The `view` block is written as ordinary HTML — nothing new to learn. Text may + * contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and + * `@event="..."` declares a client event binding. See `parseHtmlView`. + */ + +import { Lexer, LexError, type Token } from "./tokenizer.ts"; +import { validateTypedInitializer } from "./types.ts"; + +export interface StateDecl { + name: string; + /** Explicit TypeScript-style type annotation, when supplied. */ + valueType?: string; + /** Raw JS initializer expression, e.g. `0` or `'x'`. */ + expr: string; +} + +export interface ComputedDecl { + name: string; + expr: string; +} + +export interface EffectBlock { + body: string; +} + +export interface LoadBlock { + mode: "server" | "client"; + body: string; +} + +export interface ActionBlock { + name: string; + args: string[]; + body: string; +} + +export interface Attr { + name: string; + value: string; + /** True for `@event` bindings (vs. plain HTML attributes). */ + event: boolean; + /** True for a valueless boolean attribute, e.g. ` } + }`); + + expect(ast.runtime).toBe("universal"); + expect(ast.hydrate).toBe("visible"); + expect(ast.computed).toEqual([{ name: "doubled", expr: "count * 2" }]); + expect(ast.effects).toHaveLength(1); + expect(ast.security).toEqual({ auth: "required", csrf: "true" }); + expect(ast.loads.map((load) => load.mode)).toEqual(["server", "client"]); + expect(ast.actions).toEqual([expect.objectContaining({ name: "save", args: ["input"] })]); +}); + +test("diagnoses server-only interactive roots and accessibility issues", () => { + const diagnostics = diagnose( + `component AvatarButton { + runtime = "server" + state open = false + view { + + } + }`, + { file: "AvatarButton.wrn", accessibility: true }, + ); + + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "WRN-RUNTIME-SERVER-INTERACTIVE", + ); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("WRN-A11Y-001"); +}); + +test("formats parser diagnostics with stable codes and source locations", () => { + const source = `page Broken { runtime = "worker"\n view {
} }`; + const [diagnostic] = diagnose(source, { file: "Broken.wrn" }); + + expect(diagnostic?.code).toBe("WRN-RUNTIME-TARGET"); + expect(formatDiagnostic(source, diagnostic!)).toContain("Broken.wrn"); +}); + +test("parses keyed each blocks without changing legacy loop syntax", () => { + const keyed = parse(`component Rows { + props { rows = [] } + view { + {#each rows as row, index key row.id} +

{index}: {row.name}

+ {/each} + } + }`); + + const loop = keyed.view.find((node) => node.type === "each"); + expect(loop).toEqual( + expect.objectContaining({ + type: "each", + list: "rows", + item: "row", + index: "index", + key: "row.id", + }), + ); + + const legacy = parse(`component Rows { + props { rows = [] } + view { {#each rows as row}

{row.name}

{/each} } + }`); + expect(legacy.view.find((node) => node.type === "each")).toEqual( + expect.objectContaining({ type: "each", key: undefined }), + ); +}); diff --git a/packages/test/package.json b/packages/test/package.json index 63d03100..d4083c7e 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index bdeb116f..0e2526ce 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/components/ProductHero.wrn b/packages/ui/components/ProductHero.wrn index c30428cc..6a2adf22 100644 --- a/packages/ui/components/ProductHero.wrn +++ b/packages/ui/components/ProductHero.wrn @@ -9,7 +9,7 @@ component ProductHero { view {
diff --git a/packages/ui/package.json b/packages/ui/package.json index 70f3ccdd..36547a85 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 7b906e7a..cb34d543 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index 38fb6afa..d97b0d1b 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.79", + "version": "0.3.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/scripts/build-editor-compiler.mjs b/scripts/build-editor-compiler.mjs new file mode 100644 index 00000000..134058fa --- /dev/null +++ b/scripts/build-editor-compiler.mjs @@ -0,0 +1,134 @@ +import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); + +function loadTypeScript() { + const candidates = [process.env.TYPESCRIPT_PATH, "typescript"].filter(Boolean); + for (const candidate of candidates) { + try { + return require(candidate); + } catch { + // Try the next local or explicitly supplied compiler path. + } + } + throw new Error( + "TypeScript is required to build the editor compiler. Run `bun install` or set TYPESCRIPT_PATH.", + ); +} + +const ts = loadTypeScript(); + +function walk(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) files.push(...walk(path)); + else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path); + } + return files; +} + +function moduleId(file) { + return relative(root, file).split(sep).join("/"); +} + +const sourceFiles = [ + ...walk(join(root, "packages", "syntax", "src")), + ...walk(join(root, "packages", "compiler", "src")), +].sort(); + +const modules = []; +for (const file of sourceFiles) { + const source = readFileSync(file, "utf8"); + const result = ts.transpileModule(source, { + fileName: file, + reportDiagnostics: true, + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + esModuleInterop: true, + skipLibCheck: true, + sourceMap: false, + inlineSourceMap: false, + removeComments: false, + rewriteRelativeImportExtensions: true, + }, + }); + const diagnostics = result.diagnostics ?? []; + if (diagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) { + const message = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (name) => name, + getCurrentDirectory: () => root, + getNewLine: () => "\n", + }); + throw new Error(message); + } + modules.push( + `${JSON.stringify(moduleId(file))}: function (module, exports, require, __filename, __dirname) {\n${result.outputText}\n}`, + ); +} + +const output = `"use strict"; +// Generated by scripts/build-editor-compiler.mjs. Do not edit directly. +const __nodeRequire = require; +const __path = __nodeRequire("node:path"); +const __modules = { +${modules.join(",\n")} +}; +const __aliases = { + "@wrnexus/syntax": "packages/syntax/src/index.ts", + "@wrnexus/syntax/parser": "packages/syntax/src/parser.ts", + "@wrnexus/syntax/tokenizer": "packages/syntax/src/tokenizer.ts", + "@wrnexus/syntax/types": "packages/syntax/src/types.ts", + "@wrnexus/syntax/diagnostics": "packages/syntax/src/diagnostics.ts", + "@wrnexus/syntax/spec": "packages/syntax/src/spec.ts" +}; +const __cache = Object.create(null); +function __normalize(id) { + const normalized = id.split("\\\\").join("/"); + return normalized.startsWith("./") ? normalized.slice(2) : normalized; +} +function __resolve(request, parent) { + if (__aliases[request]) return __aliases[request]; + if (!request.startsWith(".")) return null; + const base = __normalize(__path.posix.join(__path.posix.dirname(parent), request)); + const candidates = [ + base, + base.endsWith(".js") ? base.slice(0, -3) + ".ts" : base, + base.endsWith(".ts") ? base : base + ".ts", + (base.endsWith("/") ? base.slice(0, -1) : base) + "/index.ts" + ]; + for (const candidate of candidates) { + if (__modules[candidate]) return candidate; + } + return null; +} +function __load(id) { + if (__cache[id]) return __cache[id].exports; + const factory = __modules[id]; + if (!factory) throw new Error("WRN editor compiler module not found: " + id); + const module = { exports: {} }; + __cache[id] = module; + const localRequire = (request) => { + const resolved = __resolve(request, id); + return resolved ? __load(resolved) : __nodeRequire(request); + }; + factory(module, module.exports, localRequire, id, __path.posix.dirname(id)); + return module.exports; +} +module.exports = __load("packages/compiler/src/index.ts"); +`; + +const destination = join(root, "editors", "vscode", "src", "compiler.cjs"); +writeFileSync(destination, output, "utf8"); +process.stdout.write( + `Built ${relative(root, destination)} from ${sourceFiles.length} TypeScript modules.\n`, +); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index af5cc1fb..0f6abeda 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -142,7 +142,7 @@ function publishManifest(m: Record, version: string): Record=1.1.0" }, + engines: { bun: ">=1.3.0" }, publishConfig: { registry: REGISTRY, access: ACCESS }, }; diff --git a/scripts/verify-0.3.mjs b/scripts/verify-0.3.mjs new file mode 100644 index 00000000..88abcca9 --- /dev/null +++ b/scripts/verify-0.3.mjs @@ -0,0 +1,134 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const VERSION = "0.3.0"; +const errors = []; +const notes = []; +const readJson = (file) => JSON.parse(readFileSync(file, "utf8")); + +function walk(dir, name = "package.json") { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === ".git" || entry === "dist") continue; + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) out.push(...walk(path, name)); + else if (entry === name) out.push(path); + } + return out; +} + +const packageFiles = [join(root, "package.json"), ...walk(join(root, "packages"))]; +const packages = new Map(); +for (const file of packageFiles) { + const pkg = readJson(file); + if (pkg.name) packages.set(pkg.name, { file, pkg }); + if (file.includes(`${join(root, "packages")}/`) && pkg.version !== VERSION) { + errors.push(`${relative(root, file)} has version ${pkg.version ?? ""}`); + } +} +if (readJson(join(root, "package.json")).version !== VERSION) { + errors.push(`root package version is not ${VERSION}`); +} + +for (const { file, pkg } of packages.values()) { + for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { + for (const [name, range] of Object.entries(pkg[field] ?? {})) { + if (String(range).startsWith("workspace:") && !packages.has(name)) { + errors.push(`${relative(root, file)} references missing workspace ${name}`); + } + } + } +} + +for (const required of [ + "packages/syntax/src/index.ts", + "packages/plugin/src/index.ts", + "scripts/build-editor-compiler.mjs", + "docs/WRN-LANGUAGE-SPEC-1.0.md", + "docs/ARCHITECTURE-0.3.md", + "docs/UPGRADE-0.3.md", + "docs/40-POINT-IMPLEMENTATION-0.3.md", + "docs/TEST-CHECKLIST-0.3.md", + "docs/AUDIT-0.3.md", +]) { + if (!existsSync(join(root, required))) errors.push(`missing ${required}`); +} + +const updateSource = readFileSync(join(root, "packages/cli/src/update.ts"), "utf8"); +const migrationPairs = [ + ...updateSource.matchAll(/version:\s*"([^"]+)"[\s\S]*?id:\s*"([^"]+)"/g), +].map((match) => ({ version: match[1], id: match[2] })); +const ids = new Set(); +for (const migration of migrationPairs) { + if (ids.has(migration.id)) errors.push(`duplicate migration id ${migration.id}`); + ids.add(migration.id); +} +if (!migrationPairs.some((migration) => migration.version === VERSION)) { + errors.push(`missing ${VERSION} updater migration`); +} + +for (const jsonFile of [ + "editors/vscode/package.json", + "editors/vscode/package-lock.json", + "editors/vscode/snippets/wrn.json", + "editors/vscode/syntaxes/wrn.tmLanguage.json", +]) { + try { + readJson(join(root, jsonFile)); + } catch (error) { + errors.push(`${jsonFile} is invalid JSON: ${error instanceof Error ? error.message : error}`); + } +} + +try { + const lockText = readFileSync(join(root, "bun.lock"), "utf8").replace(/,\s*([}\]])/g, "$1"); + const lock = JSON.parse(lockText); + for (const workspace of ["packages/syntax", "packages/plugin"]) { + if (!lock.workspaces?.[workspace]) errors.push(`bun.lock missing ${workspace}`); + } + for (const name of ["@wrnexus/syntax", "@wrnexus/plugin"]) { + if (!lock.packages?.[name]) errors.push(`bun.lock missing workspace link ${name}`); + } +} catch (error) { + errors.push(`bun.lock structure is invalid: ${error instanceof Error ? error.message : error}`); +} + +const compilerParser = readFileSync(join(root, "packages/compiler/src/parser.ts"), "utf8"); +if (!compilerParser.includes("@wrnexus/syntax")) { + errors.push("compiler parser is not a compatibility re-export of @wrnexus/syntax"); +} + +const tsconfig = readJson(join(root, "tsconfig.json")); +for (const name of packages.keys()) { + if (name.startsWith("@wrnexus/") && !tsconfig.compilerOptions?.paths?.[name]) { + errors.push(`tsconfig paths missing ${name}`); + } +} + +notes.push(`${packages.size} named packages checked`); +notes.push(`${migrationPairs.length} migration entries checked`); +notes.push("editor JSON and Bun workspace lock structure checked"); + +if (errors.length) { + process.stderr.write( + [ + `WRNexusJS ${VERSION} verification failed:`, + ...errors.map((error) => ` - ${error}`), + "", + ].join("\n"), + ); + + process.exitCode = 1; +} else { + process.stdout.write( + [ + `WRNexusJS ${VERSION} structural verification passed.`, + ...notes.map((note) => ` ✓ ${note}`), + "", + ].join("\n"), + ); +} diff --git a/tsconfig.json b/tsconfig.json index dcf0ede8..6aae28d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,7 +43,62 @@ "@wrnexus/dev-toolbar/client": ["./packages/dev-toolbar/src/client/index.ts"], "@wrnexus/dev-toolbar/server": ["./packages/dev-toolbar/src/server/index.ts"], "@wrnexus/dev-toolbar/types": ["./packages/dev-toolbar/src/types.ts"], - "@wrnexus/dev-toolbar/rules": ["./packages/dev-toolbar/src/rules/index.ts"] + "@wrnexus/dev-toolbar/rules": ["./packages/dev-toolbar/src/rules/index.ts"], + "@wrnexus/syntax": ["./packages/syntax/src/index.ts"], + "@wrnexus/syntax/*": ["./packages/syntax/src/*.ts"], + "@wrnexus/plugin": ["./packages/plugin/src/index.ts"], + "@wrnexus/cli": ["./packages/cli/src/index.ts"], + "@wrnexus/cli/workspace": ["./packages/cli/src/workspace.ts"], + "@wrnexus/db": ["./packages/db/src/index.ts"], + "@wrnexus/db/connect": ["./packages/db/src/connect.ts"], + "@wrnexus/db/session": ["./packages/db/src/session-store.ts"], + "@wrnexus/db/sqlite": ["./packages/db/src/adapters/sqlite.ts"], + "@wrnexus/db/postgres": ["./packages/db/src/adapters/postgres.ts"], + "@wrnexus/db/mysql": ["./packages/db/src/adapters/mysql.ts"], + "@wrnexus/db/mongo": ["./packages/db/src/adapters/mongo.ts"], + "@wrnexus/dev-server/serve-entry": ["./packages/dev-server/src/serve-entry.ts"], + "@wrnexus/i18n": ["./packages/i18n/src/index.ts"], + "@wrnexus/native/browser": ["./packages/native/src/browser.ts"], + "@wrnexus/native/mobile": ["./packages/native/src/mobile.ts"], + "@wrnexus/pubsub/redis": ["./packages/pubsub/src/redis.ts"], + "@wrnexus/syntax/parser": ["./packages/syntax/src/parser.ts"], + "@wrnexus/syntax/tokenizer": ["./packages/syntax/src/tokenizer.ts"], + "@wrnexus/syntax/types": ["./packages/syntax/src/types.ts"], + "@wrnexus/syntax/diagnostics": ["./packages/syntax/src/diagnostics.ts"], + "@wrnexus/syntax/spec": ["./packages/syntax/src/spec.ts"], + "@wrnexus/ui": ["./packages/ui/src/index.ts"], + "@wrnexus/ui/components/*": ["./packages/ui/components/*"], + "@wrnexus/ui/component-catalog.json": ["./packages/ui/component-catalog.json"], + "@wrnexus/ui/component-reference.json": ["./packages/ui/component-reference.json"], + "@wrnexus/ui/ui.css": ["./packages/ui/ui.css"], + "@wrnexus/validation": ["./packages/validation/src/index.ts"], + "@wrnexus/ai/*": ["./packages/ai/src/*.ts"], + "@wrnexus/authz/*": ["./packages/authz/src/*.ts"], + "@wrnexus/cli/*": ["./packages/cli/src/*.ts"], + "@wrnexus/compiler/*": ["./packages/compiler/src/*.ts"], + "@wrnexus/core/*": ["./packages/core/src/*.ts"], + "@wrnexus/csr/*": ["./packages/csr/src/*.ts"], + "@wrnexus/db/*": ["./packages/db/src/*.ts"], + "@wrnexus/dev-server/*": ["./packages/dev-server/src/*.ts"], + "@wrnexus/dev-toolbar/*": ["./packages/dev-toolbar/src/*.ts"], + "@wrnexus/encryption/*": ["./packages/encryption/src/*.ts"], + "@wrnexus/helpers/*": ["./packages/helpers/src/*.ts"], + "@wrnexus/i18n/*": ["./packages/i18n/src/*.ts"], + "@wrnexus/jwt/*": ["./packages/jwt/src/*.ts"], + "@wrnexus/mobile/*": ["./packages/mobile/src/*.ts"], + "@wrnexus/oauth/*": ["./packages/oauth/src/*.ts"], + "@wrnexus/plugin/*": ["./packages/plugin/src/*.ts"], + "@wrnexus/pubsub/*": ["./packages/pubsub/src/*.ts"], + "@wrnexus/queue/*": ["./packages/queue/src/*.ts"], + "@wrnexus/reactive/*": ["./packages/reactive/src/*.ts"], + "@wrnexus/router/*": ["./packages/router/src/*.ts"], + "@wrnexus/ssr/*": ["./packages/ssr/src/*.ts"], + "@wrnexus/styles/*": ["./packages/styles/src/*.ts"], + "@wrnexus/test/*": ["./packages/test/src/*.ts"], + "@wrnexus/tracking/*": ["./packages/tracking/src/*.ts"], + "@wrnexus/ui/*": ["./packages/ui/src/*.ts"], + "@wrnexus/uploader/*": ["./packages/uploader/src/*.ts"], + "@wrnexus/validation/*": ["./packages/validation/src/*.ts"] } }, "exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"] diff --git a/update-package-versions.mjs b/update-package-versions.mjs index 6c22a495..182ff409 100644 --- a/update-package-versions.mjs +++ b/update-package-versions.mjs @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const version = "0.2.79"; +const version = "0.3.0"; const dependencyGroups = [ "dependencies",