release: WRNexusJS 0.3.0
This commit is contained in:
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
+14
-12
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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/",
|
||||
|
||||
+10
-9
@@ -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-<event>="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-<event>="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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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/",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -9,7 +9,7 @@ component ProductHero {
|
||||
|
||||
view {
|
||||
<section
|
||||
class=" relative isolate overflow-hidden border-b border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-bg,#ffffff)] px-4 py-14 sm:px-6 sm:py-18 lg:px-8 lg:py-20 {class} "
|
||||
class=" relative isolate overflow-hidden bg-[var(--wire-color-bg,#ffffff)] px-4 py-14 sm:px-6 sm:py-18 lg:px-8 lg:py-20 {class} "
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0 -z-10 opacity-80" style="background: radial-gradient(circle at 16% 0%, color-mix(in srgb, var(--wire-color-primary, #059669) 16%, transparent), transparent 36%);" aria-hidden="true" ></div>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
@@ -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"],
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
@@ -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
|
||||
<div data-wrn-hydration="stable-id" data-wrn-hydrate="visible" data-wrn-runtime="universal"></div>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
+10
-10
@@ -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-<event>` | `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` | `<div data-slot="header">…</div>` | Fills a named `<slot name="header">` 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-<event>` | `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` | `<div data-slot="header">…</div>` | Fills a named `<slot name="header">` of a component (see §8). |
|
||||
|
||||
Example — a reactive list you write by hand:
|
||||
|
||||
@@ -327,7 +327,7 @@ view {
|
||||
<div data-scope="items: [{t:'a'},{t:'b'}], open: true">
|
||||
<button @click="open = !open">toggle</button>
|
||||
<ul data-show="open">
|
||||
<li data-for="i in items" data-text="i.t"></li>
|
||||
<li data-for="i in items key i.id" data-text="i.t"></li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
<output>{total}</output>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
<FeatureList items='{items}' primaryAction='{{ label: "Open" }}' />
|
||||
```
|
||||
|
||||
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 {
|
||||
<main>Home</main>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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:<media-query>`
|
||||
|
||||
`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 {
|
||||
<button
|
||||
type="button"
|
||||
class:opacity-50='loading'
|
||||
@click='save()'
|
||||
>
|
||||
{loading ? "Saving..." : title}
|
||||
</button>
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
"</form>"
|
||||
]
|
||||
},
|
||||
|
||||
"WRN input": {
|
||||
"prefix": ["wrn-input", "input"],
|
||||
"description": "Create a labeled input",
|
||||
@@ -229,61 +212,51 @@
|
||||
"</label>"
|
||||
]
|
||||
},
|
||||
|
||||
"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": ["<div data-for=\"${1:item} in ${2:items}\">", " <span>{${1:item}}</span>", "</div>"]
|
||||
},
|
||||
|
||||
"WRN dynamic link": {
|
||||
"prefix": ["wrn-route-link", "route-link"],
|
||||
"description": "Create a link using route state",
|
||||
"body": ["<a href=\"/${1:resource}/{${2:id}}\">", " ${3:Open}", "</a>"]
|
||||
},
|
||||
|
||||
"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 @@
|
||||
"</button>"
|
||||
]
|
||||
},
|
||||
|
||||
"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", "}"]
|
||||
}
|
||||
}
|
||||
|
||||
+2900
-1939
File diff suppressed because it is too large
Load Diff
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -179,11 +179,14 @@ function parseOpeningTag(value) {
|
||||
|
||||
const inlineClosing = remainder === `</${tagName}>`;
|
||||
|
||||
const closesInRemainder = remainder.startsWith(`</${tagName}>`);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { <main>{doubled}</main> }
|
||||
}`;
|
||||
const declaration = findTopLevelDeclaration({}, source);
|
||||
const document = {
|
||||
positionAt(offset) {
|
||||
return offset;
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(validateRootMembers(document, source, declaration.kind, declaration.match), []);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
<section class="overflow-hidden rounded-3xl border border-[var(--wire-color-border,#e2e8f0)] bg-[var(--wire-color-surface,#ffffff)]">
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-[var(--wire-color-surface-2,#f8fafc)] px-3 py-1.5 text-xs font-semibold">
|
||||
<span class="h-2 w-2 animate-pulse rounded-full bg-[var(--wire-color-primary,#059669)]"></span>Loading
|
||||
</span>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
`;
|
||||
const options = { insertSpaces: true, tabSize: 2, printWidth: 100 };
|
||||
const formatted = formatWrn(source, options);
|
||||
|
||||
assert.equal(formatWrn(formatted, options), formatted);
|
||||
assert.match(formatted, /<\/span>Loading/);
|
||||
});
|
||||
|
||||
+4
-3
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>;
|
||||
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;
|
||||
}
|
||||
+162
-18
@@ -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<void> {
|
||||
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<string, unknown>);
|
||||
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
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<string, string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<typeof checkPerformanceBudgets>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
+147
-14
@@ -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<string, unknown>): Map<string, string[]> {
|
||||
const ranges = new Map<string, string[]>();
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const deps = pkg[field] as Record<string, string> | 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<string, unknown>;
|
||||
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<boolean> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ Usage:
|
||||
wrnexus db <cmd> 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=<name> 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<void> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
+191
-21
@@ -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)) {
|
||||
const end = source.indexOf("-->", 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<string, any>;
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
<FeatureList items={items} emptyLabel="No items" />
|
||||
<button @click={save()} data-config={{ nested: true }}>Save</button>
|
||||
}
|
||||
}`;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +654,32 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<strin
|
||||
}`;
|
||||
}
|
||||
|
||||
function stableHash(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 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 = `<div data-scope="${scopePlaceholder}">${html}</div>`;
|
||||
html = `<div data-scope="${scopePlaceholder}"${behaviorAttribute(pageBehavior)}${hydrationAttribute(ast)}>${html}</div>`;
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
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 + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`"
|
||||
? "`" +
|
||||
styleTag +
|
||||
`<div data-scope="\${__scope}"${behaviorAttr}${hydrationAttribute(ast)}>` +
|
||||
viewCode +
|
||||
"</div>`"
|
||||
: "`" + 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)};`);
|
||||
|
||||
@@ -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 = "<inline .wrn>"): 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 = "<inline .wrn>"): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,816 +1,2 @@
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
*
|
||||
* Grammar (subset of the vision, but real):
|
||||
*
|
||||
* page <Name> {
|
||||
* types { <TypeScript declarations> }
|
||||
* props { <ident>: <type> [= <expr>] } // no default means required
|
||||
* state <ident>: <type> = <expr> // type annotation is optional
|
||||
* view { <html> } // plain HTML (see parseHtmlView)
|
||||
* seo { title = "Home" description = "..." }
|
||||
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
||||
* client { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
||||
* style { <raw css> } // zero or more, inlined with the page
|
||||
* functions { <raw js> } // zero or more, shared helpers
|
||||
* api <METHOD> <path> { <raw js> } // zero or more
|
||||
* realtime <name> { on <evt>(<args>) { <raw js> } * } // 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. `<button disabled>`. */
|
||||
boolean?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML void elements: they have no children and no closing tag.
|
||||
* @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements
|
||||
*/
|
||||
export const VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
export type ViewNode =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "element"; tag: string; attrs: Attr[]; children: ViewNode[] }
|
||||
/**
|
||||
* A server-side loop: `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`.
|
||||
* `list` is a JS expression (evaluated on the server, may reference an `ssr` data
|
||||
* binding). The `body` is rendered once per item with `{item.field}` interpolation;
|
||||
* `empty` renders when the list is empty. See codegen `compileEach`.
|
||||
*/
|
||||
| {
|
||||
type: "each";
|
||||
list: string;
|
||||
item: string;
|
||||
index?: string;
|
||||
body: ViewNode[];
|
||||
empty: ViewNode[];
|
||||
}
|
||||
/**
|
||||
* A server-side conditional: `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`.
|
||||
* Rendered branches are chosen on the server. Each branch's `cond` is a JS expression
|
||||
* (`null` for the final `{:else}`); the first truthy branch renders. See `compileIfExpr`.
|
||||
*/
|
||||
| { type: "if"; branches: { cond: string | null; body: ViewNode[] }[] };
|
||||
|
||||
export interface ApiBlock {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export type SeoBlock = Record<string, string>;
|
||||
|
||||
export type DataMode = "ssr" | "client";
|
||||
|
||||
export interface DataApiBlock {
|
||||
mode: DataMode;
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ModeFunctionsBlock {
|
||||
mode: DataMode;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export type LifecycleHookName = "mount" | "update" | "unmount";
|
||||
|
||||
export interface LifecycleBlock {
|
||||
mount?: string;
|
||||
update?: string;
|
||||
unmount?: string;
|
||||
}
|
||||
|
||||
export interface WatchBlock {
|
||||
state: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface RealtimeHandler {
|
||||
event: string;
|
||||
args: string[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface RealtimeBlock {
|
||||
name: string;
|
||||
handlers: RealtimeHandler[];
|
||||
}
|
||||
|
||||
export interface PropDecl {
|
||||
name: string;
|
||||
/** Explicit TypeScript-style type annotation, when supplied. */
|
||||
valueType?: string;
|
||||
/** Props without a default are required. */
|
||||
required: boolean;
|
||||
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
|
||||
default: string;
|
||||
}
|
||||
|
||||
export interface PageAst {
|
||||
type: "page";
|
||||
/** Static ES module imports declared before the WRN root declaration. */
|
||||
imports: string[];
|
||||
/**
|
||||
* `page` is a route, `component` is a reusable fragment,
|
||||
* and `layout` is a reusable page wrapper.
|
||||
*/
|
||||
kind: "page" | "component" | "layout";
|
||||
name: string;
|
||||
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
|
||||
layout?: string;
|
||||
/** Declared component props (empty for pages). */
|
||||
props: PropDecl[];
|
||||
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
|
||||
types: string[];
|
||||
states: StateDecl[];
|
||||
seo: SeoBlock;
|
||||
view: ViewNode[];
|
||||
styles: string[];
|
||||
functions: string[];
|
||||
dataApis: DataApiBlock[];
|
||||
modeFunctions: ModeFunctionsBlock[];
|
||||
lifecycle: LifecycleBlock;
|
||||
watches: WatchBlock[];
|
||||
apis: ApiBlock[];
|
||||
realtimes: RealtimeBlock[];
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
function parseSeoBlock(body: string): SeoBlock {
|
||||
const out: SeoBlock = {};
|
||||
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: string): string {
|
||||
return value.replace(/\\(["'\\nrt])/g, (_match, ch: string) => {
|
||||
if (ch === "n") return "\n";
|
||||
if (ch === "r") return "\r";
|
||||
if (ch === "t") return "\t";
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
|
||||
export function parse(source: string): PageAst {
|
||||
const lx = new Lexer(source);
|
||||
|
||||
const imports: string[] = [];
|
||||
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: Token["type"]): Token => {
|
||||
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: string): void => {
|
||||
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 as "page" | "component" | "layout";
|
||||
const name = expect("ident").value;
|
||||
expect("lbrace");
|
||||
|
||||
let layout: string | undefined;
|
||||
const props: PropDecl[] = [];
|
||||
const types: string[] = [];
|
||||
const states: StateDecl[] = [];
|
||||
const seo: SeoBlock = {};
|
||||
const view: ViewNode[] = [];
|
||||
const styles: string[] = [];
|
||||
const functions: string[] = [];
|
||||
const dataApis: DataApiBlock[] = [];
|
||||
const modeFunctions: ModeFunctionsBlock[] = [];
|
||||
const lifecycle: LifecycleBlock = {};
|
||||
const watches: WatchBlock[] = [];
|
||||
const apis: ApiBlock[] = [];
|
||||
const realtimes: RealtimeBlock[] = [];
|
||||
|
||||
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/<name>.wrn for this page.
|
||||
lx.next();
|
||||
expect("eq");
|
||||
layout = expect("string").value;
|
||||
break;
|
||||
}
|
||||
case "props": {
|
||||
// props { name: Type = <default> } — 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: string | undefined;
|
||||
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: string | undefined;
|
||||
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");
|
||||
// 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 "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: DataMode = 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 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: RealtimeHandler[] = [];
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
expectKeyword("on");
|
||||
const event = expect("ident").value;
|
||||
expect("lparen");
|
||||
const args: string[] = [];
|
||||
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 as LifecycleHookName;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* - `<tag attr="v" @event="expr">children</tag>` — elements with attributes
|
||||
* - `<tag/>` and HTML void elements (`<br>`, `<img>`, …) — no closing tag
|
||||
* - text may contain `{expr}` interpolation, kept verbatim for the runtime
|
||||
* - `@event="..."` becomes a client event binding; hyphenated names are fine
|
||||
* - `<!-- comments -->` are dropped
|
||||
*
|
||||
* `{` and `}` in text are reserved for interpolation; a lone `<` that isn't a
|
||||
* tag is treated as literal text.
|
||||
*/
|
||||
export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; endPos: number } {
|
||||
let i = pos;
|
||||
|
||||
const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c);
|
||||
|
||||
const isTagNamePart = (c: string): boolean => /[A-Za-z0-9_$:.-]/.test(c);
|
||||
|
||||
const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
|
||||
const fail = (msg: string): never => {
|
||||
throw new ParseError(`${msg} at offset ${i}`);
|
||||
};
|
||||
const skipWs = (): void => {
|
||||
while (i < src.length && isWs(src[i]!)) i++;
|
||||
};
|
||||
|
||||
/** Read a `{...}` interpolation (brace-balanced), braces included. */
|
||||
const readInterpolation = (): string => {
|
||||
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 = (): string => {
|
||||
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 = (): string => {
|
||||
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 = (): string => {
|
||||
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 = (): ViewNode => {
|
||||
i++; // consume '<'
|
||||
const tag = readTagName();
|
||||
const attrs: Attr[] = [];
|
||||
|
||||
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 (VOID_ELEMENTS.has(tag.toLowerCase())) {
|
||||
return { type: "element", tag, attrs, children: [] };
|
||||
}
|
||||
|
||||
const children = parseNodeList("element");
|
||||
// parseNodeList stops at the parent's closing tag `</`.
|
||||
if (src[i] !== "<" || src[i + 1] !== "/") return fail(`Expected </${tag}>`);
|
||||
i += 2;
|
||||
skipWs();
|
||||
const close = readTagName();
|
||||
if (close !== tag) return fail(`Mismatched </${close}>, expected </${tag}>`);
|
||||
skipWs();
|
||||
if (src[i] !== ">") return fail(`Expected '>' to close </${tag}>`);
|
||||
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*)?\}$/;
|
||||
|
||||
/** Parse `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`. */
|
||||
function parseEach(): ViewNode {
|
||||
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 body = parseNodeList("each"); // stops at {:empty} or {/each}
|
||||
let empty: ViewNode[] = [];
|
||||
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, body, empty };
|
||||
}
|
||||
|
||||
/** Parse `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`. */
|
||||
function parseIf(): ViewNode {
|
||||
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: string | null; body: ViewNode[] }[] = [
|
||||
{ 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 (`</`)
|
||||
* - "each": stops (without consuming) at `{:empty}` or `{/each}`
|
||||
* - "if": stops (without consuming) at `{:else …}` or `{/if}`
|
||||
* `{#each …}` and `{#if …}` start nested blocks in any mode.
|
||||
*/
|
||||
function parseNodeList(mode: "root" | "element" | "each" | "if"): ViewNode[] {
|
||||
const nodes: ViewNode[] = [];
|
||||
let text = "";
|
||||
const flush = (): void => {
|
||||
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)) {
|
||||
const end = src.indexOf("-->", 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 };
|
||||
}
|
||||
/** @deprecated Import parser APIs from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/parser";
|
||||
|
||||
@@ -1,321 +1,2 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type TokenType =
|
||||
| "ident"
|
||||
| "string"
|
||||
| "lbrace"
|
||||
| "rbrace"
|
||||
| "lparen"
|
||||
| "rparen"
|
||||
| "at"
|
||||
| "eq"
|
||||
| "colon"
|
||||
| "comma"
|
||||
| "eof";
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
||||
|
||||
export class Lexer {
|
||||
pos = 0;
|
||||
constructor(public readonly src: string) {}
|
||||
|
||||
/** Skip whitespace and `// line comments`. */
|
||||
private skipTrivia(): void {
|
||||
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(): Token {
|
||||
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(): Token {
|
||||
const save = this.pos;
|
||||
const t = this.next();
|
||||
this.pos = save;
|
||||
return t;
|
||||
}
|
||||
|
||||
private readString(quote: string, pos: number): Token {
|
||||
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(): string {
|
||||
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(): string {
|
||||
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: string | null = 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 = <expr>`). */
|
||||
readToLineEnd(): string {
|
||||
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(): { type: string; hasDefault: boolean } {
|
||||
const { src } = this;
|
||||
let value = "";
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let quote: string | null = 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(): string {
|
||||
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: string | null = 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}`);
|
||||
}
|
||||
|
||||
private lineAt(pos: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < pos && i < this.src.length; i++) {
|
||||
if (this.src[i] === "\n") line++;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/tokenizer";
|
||||
|
||||
@@ -1,63 +1,2 @@
|
||||
/** Utilities shared by typed `.wrn` parsing, validation, and code generation. */
|
||||
|
||||
export type RuntimeType =
|
||||
"string" | "number" | "boolean" | "bigint" | "array" | "object" | "function" | "unknown";
|
||||
|
||||
export function runtimeTypeOf(annotation: string | undefined): RuntimeType {
|
||||
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";
|
||||
}
|
||||
|
||||
export function inferredRuntimeType(expression: string): RuntimeType {
|
||||
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";
|
||||
}
|
||||
|
||||
export function validateTypedInitializer(
|
||||
name: string,
|
||||
annotation: string | undefined,
|
||||
expression: string,
|
||||
): string | null {
|
||||
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.
|
||||
*/
|
||||
export function eraseFunctionTypes(source: string): string {
|
||||
return source.replace(
|
||||
/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g,
|
||||
(_whole, open: string, params: string, close: string, _returnType: string, brace: string) => {
|
||||
const plainParams = params
|
||||
.split(",")
|
||||
.map((param) =>
|
||||
param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim(),
|
||||
)
|
||||
.join(", ");
|
||||
return `${open}${plainParams}${close}${brace}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/types";
|
||||
|
||||
@@ -996,6 +996,24 @@ component Accordion {
|
||||
|
||||
expect(output).not.toContain('${(isOpen(index)) ? " open" : ""}');
|
||||
});
|
||||
|
||||
test("data-for parser supports optional stable key expressions", async () => {
|
||||
const { parseForExpr } = await import("../src/codegen.ts");
|
||||
|
||||
expect(parseForExpr("item, index in items key item.id")).toEqual({
|
||||
item: "item",
|
||||
index: "index",
|
||||
list: "items",
|
||||
key: "item.id",
|
||||
});
|
||||
|
||||
expect(parseForExpr("item in items")).toEqual({
|
||||
item: "item",
|
||||
index: undefined,
|
||||
list: "items",
|
||||
key: undefined,
|
||||
});
|
||||
});
|
||||
test("server-rendered each locals work in reactive handlers", async () => {
|
||||
const firstLocals = Buffer.from(
|
||||
JSON.stringify({
|
||||
@@ -1063,3 +1081,27 @@ test("server-rendered each locals work in reactive handlers", async () => {
|
||||
|
||||
expect(articles[1]!.querySelector("span")?.classList.contains("open")).toBe(true);
|
||||
});
|
||||
|
||||
test("WRN 0.3 metadata, computed values, loaders, and actions compile additively", () => {
|
||||
const source = `page Dashboard {
|
||||
runtime = "universal"
|
||||
hydrate = "idle"
|
||||
state count = 2
|
||||
computed { doubled = count * 2 }
|
||||
effect { console.log(doubled) }
|
||||
security { auth = "required" }
|
||||
load server { return { count: 2 } }
|
||||
load client { return { refreshed: true } }
|
||||
action save(input) { return input }
|
||||
view { <button @click="count++">{doubled}</button> }
|
||||
}`;
|
||||
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"');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T> {
|
||||
cache?: CachePolicy;
|
||||
load(ctx: Context): T | Promise<T>;
|
||||
}
|
||||
|
||||
export interface ActionDefinition<I, O> {
|
||||
csrf?: boolean;
|
||||
run(input: I, ctx: Context): O | Promise<O>;
|
||||
invalidate?: string[] | ((output: O, ctx: Context) => string[]);
|
||||
}
|
||||
|
||||
export interface DefinedLoader<T> {
|
||||
readonly definition: LoaderDefinition<T>;
|
||||
(ctx: Context): Promise<T>;
|
||||
}
|
||||
|
||||
export interface DefinedAction<I, O> {
|
||||
readonly definition: ActionDefinition<I, O>;
|
||||
(input: I, ctx: Context): Promise<O>;
|
||||
}
|
||||
|
||||
export function defineLoader<T>(definition: LoaderDefinition<T>): DefinedLoader<T> {
|
||||
return Object.assign(async (ctx: Context) => definition.load(ctx), { definition });
|
||||
}
|
||||
|
||||
export function defineAction<I, O>(definition: ActionDefinition<I, O>): DefinedAction<I, O> {
|
||||
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<T>(ctx: Context, key: string, load: () => T | Promise<T>): Promise<T> {
|
||||
const bucket = (ctx.locals.__wrnexusData ??= new Map<string, Promise<unknown>>()) as Map<
|
||||
string,
|
||||
Promise<unknown>
|
||||
>;
|
||||
const existing = bucket.get(key);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const pending = Promise.resolve().then(load);
|
||||
bucket.set(key, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
bucket.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
export interface SchemaLike<T> {
|
||||
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<I, O> {
|
||||
input?: SchemaLike<I>;
|
||||
output?: SchemaLike<O>;
|
||||
auth?: "optional" | "required";
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
handler(input: I, ctx: Context): O | Promise<O>;
|
||||
}
|
||||
|
||||
export interface DefinedEndpoint<I, O> {
|
||||
readonly definition: EndpointDefinition<I, O>;
|
||||
(ctx: Context, input?: unknown): Promise<Response>;
|
||||
}
|
||||
|
||||
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<I = unknown, O = unknown>(
|
||||
definition: EndpointDefinition<I, O>,
|
||||
): DefinedEndpoint<I, O> {
|
||||
const endpoint = async (ctx: Context, rawInput?: unknown): Promise<Response> => {
|
||||
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<HeadersInit>);
|
||||
}
|
||||
|
||||
/** 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<I, O>(path: string, input: I): Promise<O> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
export type FeatureValue = boolean | string | number;
|
||||
export type FeatureRule = FeatureValue | ((ctx: Context) => FeatureValue | Promise<FeatureValue>);
|
||||
|
||||
export interface FeatureFlags {
|
||||
get(name: string, ctx: Context): Promise<FeatureValue | undefined>;
|
||||
enabled(name: string, ctx: Context): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function defineFeatureFlags(rules: Record<string, FeatureRule>): 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, string | number | boolean>;
|
||||
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<T>(
|
||||
tracer: Tracer,
|
||||
name: string,
|
||||
run: (span: Span) => T | Promise<T>,
|
||||
attributes?: SpanRecord["attributes"],
|
||||
): Promise<T> {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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<keyof PerformanceBudgets>) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TenantResolver = (ctx: Context) => Tenant | null | Promise<Tenant | null>;
|
||||
|
||||
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<Tenant | null>,
|
||||
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<T extends object>(
|
||||
tenant: Tenant,
|
||||
repository: T,
|
||||
): T & { tenantId: string } {
|
||||
return Object.assign(Object.create(repository), { tenantId: tenant.id });
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
+10
-9
@@ -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-<event>="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-<event>="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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
|
||||
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
|
||||
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
|
||||
</div>`,
|
||||
);
|
||||
|
||||
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(
|
||||
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RunningServer> {
|
||||
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<string, unknown>);
|
||||
await pluginRunner.configResolved(
|
||||
Object.freeze({ ...opts }) as Readonly<Record<string, unknown>>,
|
||||
);
|
||||
|
||||
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<RunningServer> {
|
||||
|
||||
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<RunningServer> {
|
||||
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<RunningServer> {
|
||||
websocket: handlers.websocket,
|
||||
});
|
||||
|
||||
try {
|
||||
await pluginRunner.hook("configureServer", {
|
||||
server,
|
||||
router,
|
||||
handlers,
|
||||
assets,
|
||||
devToolbarCollector,
|
||||
});
|
||||
} catch (error) {
|
||||
server.stop();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let watcher: ReturnType<typeof startWatcher>;
|
||||
|
||||
// In-process HMR: keep the server and socket alive, invalidate only changed
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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<Middleware[]> => [
|
||||
...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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user