feat: add typed WRN declarations
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ai",
|
"name": "@wrnexus/ai",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/authz",
|
"name": "@wrnexus/authz",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/authz — part of the WrNexus framework.",
|
"description": "@wrnexus/authz — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/cli — part of the WrNexus framework.",
|
"description": "@wrnexus/cli — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -28,16 +28,16 @@
|
|||||||
"wrnexus": "./dist/index.js"
|
"wrnexus": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58",
|
"@wrnexus/core": "^0.2.59",
|
||||||
"@wrnexus/router": "^0.2.58",
|
"@wrnexus/router": "^0.2.59",
|
||||||
"@wrnexus/csr": "^0.2.58",
|
"@wrnexus/csr": "^0.2.59",
|
||||||
"@wrnexus/compiler": "^0.2.58",
|
"@wrnexus/compiler": "^0.2.59",
|
||||||
"@wrnexus/styles": "^0.2.58",
|
"@wrnexus/styles": "^0.2.59",
|
||||||
"@wrnexus/dev-server": "^0.2.58",
|
"@wrnexus/dev-server": "^0.2.59",
|
||||||
"@wrnexus/ui": "^0.2.58",
|
"@wrnexus/ui": "^0.2.59",
|
||||||
"@wrnexus/validation": "^0.2.58",
|
"@wrnexus/validation": "^0.2.59",
|
||||||
"@wrnexus/i18n": "^0.2.58",
|
"@wrnexus/i18n": "^0.2.59",
|
||||||
"@wrnexus/db": "^0.2.58"
|
"@wrnexus/db": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
+17
-15
@@ -76,18 +76,19 @@ class Lexer {
|
|||||||
|
|
||||||
Exported type-only symbols describing the parsed tree:
|
Exported type-only symbols describing the parsed tree:
|
||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. |
|
| `PageAst` | Root node including `kind`, `name`, `types`, typed `props`, typed `states`, `view`, styles, functions, data APIs, lifecycle, and routes. |
|
||||||
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
||||||
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
||||||
| `StateDecl` | `{ name; expr }` — a `state x = <expr>` declaration. |
|
| `StateDecl` | `{ name; valueType?; expr }` — a typed `state x: Type = <expr>` declaration. |
|
||||||
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
| `PropDecl` | `{ name; valueType?; required; default }` — a typed prop declaration. |
|
||||||
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
||||||
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
||||||
| `DataMode` | `"ssr" \| "client"`. |
|
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
||||||
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
| `DataMode` | `"ssr" \| "client"`. |
|
||||||
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
||||||
|
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -147,12 +148,13 @@ lx.next(); // { type: "lbrace", value: "{", pos: 10 }
|
|||||||
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
|
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
|
||||||
|
|
||||||
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
||||||
- `props { name = <default> ... }` — component props; each default's type drives coercion.
|
- `types { <TypeScript declarations> }` — reusable interfaces and aliases for the current file.
|
||||||
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
|
- `props { name: Type = <default> ... }` — typed component props. Omit `= <default>` to make a prop required. Legacy inferred props remain supported.
|
||||||
|
- `state <ident>: Type = <expr>` — typed reactive state seeded from a raw JS expression. The annotation is optional for backward compatibility.
|
||||||
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
||||||
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
||||||
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
||||||
- `functions { <raw js> }` — shared server-side helpers (repeatable).
|
- `functions { <TypeScript> }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code.
|
||||||
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
|
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
|
||||||
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
|
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
|
||||||
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
|
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/compiler — part of the WrNexus framework.",
|
"description": "@wrnexus/compiler — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/core",
|
"name": "@wrnexus/core",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/core — part of the WrNexus framework.",
|
"description": "@wrnexus/core — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/csr — part of the WrNexus framework.",
|
"description": "@wrnexus/csr — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/db — part of the WrNexus framework.",
|
"description": "@wrnexus/db — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
|
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -25,18 +25,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58",
|
"@wrnexus/core": "^0.2.59",
|
||||||
"@wrnexus/router": "^0.2.58",
|
"@wrnexus/router": "^0.2.59",
|
||||||
"@wrnexus/ssr": "^0.2.58",
|
"@wrnexus/ssr": "^0.2.59",
|
||||||
"@wrnexus/csr": "^0.2.58",
|
"@wrnexus/csr": "^0.2.59",
|
||||||
"@wrnexus/compiler": "^0.2.58",
|
"@wrnexus/compiler": "^0.2.59",
|
||||||
"@wrnexus/styles": "^0.2.58",
|
"@wrnexus/styles": "^0.2.59",
|
||||||
"@wrnexus/ui": "^0.2.58",
|
"@wrnexus/ui": "^0.2.59",
|
||||||
"@wrnexus/validation": "^0.2.58",
|
"@wrnexus/validation": "^0.2.59",
|
||||||
"@wrnexus/i18n": "^0.2.58",
|
"@wrnexus/i18n": "^0.2.59",
|
||||||
"@wrnexus/db": "^0.2.58",
|
"@wrnexus/db": "^0.2.59",
|
||||||
"@wrnexus/pubsub": "^0.2.58",
|
"@wrnexus/pubsub": "^0.2.59",
|
||||||
"@wrnexus/uploader": "^0.2.58"
|
"@wrnexus/uploader": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/encryption",
|
"name": "@wrnexus/encryption",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/encryption — part of the WrNexus framework.",
|
"description": "@wrnexus/encryption — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/helpers",
|
"name": "@wrnexus/helpers",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/i18n — part of the WrNexus framework.",
|
"description": "@wrnexus/i18n — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/jwt",
|
"name": "@wrnexus/jwt",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/jwt — part of the WrNexus framework.",
|
"description": "@wrnexus/jwt — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/mobile",
|
"name": "@wrnexus/mobile",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/mobile — part of the WrNexus framework.",
|
"description": "@wrnexus/mobile — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/native": "^0.2.58"
|
"@wrnexus/native": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/native",
|
"name": "@wrnexus/native",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/native — part of the WrNexus framework.",
|
"description": "@wrnexus/native — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/oauth",
|
"name": "@wrnexus/oauth",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/oauth — part of the WrNexus framework.",
|
"description": "@wrnexus/oauth — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/pubsub",
|
"name": "@wrnexus/pubsub",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
|
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/queue",
|
"name": "@wrnexus/queue",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/queue — part of the WrNexus framework.",
|
"description": "@wrnexus/queue — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/reactive",
|
"name": "@wrnexus/reactive",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/reactive — part of the WrNexus framework.",
|
"description": "@wrnexus/reactive — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/router",
|
"name": "@wrnexus/router",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/router — part of the WrNexus framework.",
|
"description": "@wrnexus/router — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/compiler": "^0.2.58",
|
"@wrnexus/compiler": "^0.2.59",
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ssr",
|
"name": "@wrnexus/ssr",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/ssr — part of the WrNexus framework.",
|
"description": "@wrnexus/ssr — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/styles",
|
"name": "@wrnexus/styles",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/styles — part of the WrNexus framework.",
|
"description": "@wrnexus/styles — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/uploader": "^0.2.58"
|
"@wrnexus/uploader": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/test",
|
"name": "@wrnexus/test",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/test — part of the WrNexus framework.",
|
"description": "@wrnexus/test — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/tracking",
|
"name": "@wrnexus/tracking",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/tracking — part of the WrNexus framework.",
|
"description": "@wrnexus/tracking — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
component DateInput {
|
component DateInput {
|
||||||
props {
|
props {
|
||||||
id = ""
|
id: string = ""
|
||||||
name = ""
|
name: string = ""
|
||||||
label = "Date"
|
label: string = "Date"
|
||||||
value = ""
|
value: string = ""
|
||||||
placeholder = ""
|
placeholder: string = ""
|
||||||
help = ""
|
help: string = ""
|
||||||
error = ""
|
error: string = ""
|
||||||
required = false
|
required: boolean = false
|
||||||
disabled = false
|
disabled: boolean = false
|
||||||
readonly = false
|
readonly: boolean = false
|
||||||
autocomplete = ""
|
autocomplete: string = ""
|
||||||
}
|
}
|
||||||
view {
|
view {
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
component DatePicker {
|
component DatePicker {
|
||||||
props {
|
props {
|
||||||
id = "date"
|
id: string = "date"
|
||||||
name = "date"
|
name: string = "date"
|
||||||
label = "Date"
|
label: string = "Date"
|
||||||
value = ""
|
value: string = ""
|
||||||
min = ""
|
min: string = ""
|
||||||
max = ""
|
max: string = ""
|
||||||
required = false
|
required: boolean = false
|
||||||
}
|
}
|
||||||
view {
|
view {
|
||||||
<DateInput id="{id}" name="{name}" label="{label}" value="{value}" required="{required}" />
|
<DateInput id="{id}" name="{name}" label="{label}" value="{value}" required="{required}" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ui",
|
"name": "@wrnexus/ui",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/ui — part of the WrNexus framework.",
|
"description": "@wrnexus/ui — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"./ui.css": "./ui.css"
|
"./ui.css": "./ui.css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/uploader",
|
"name": "@wrnexus/uploader",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/uploader — part of the WrNexus framework.",
|
"description": "@wrnexus/uploader — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "^0.2.58"
|
"@wrnexus/core": "^0.2.59"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/validation",
|
"name": "@wrnexus/validation",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "@wrnexus/validation — part of the WrNexus framework.",
|
"description": "@wrnexus/validation — part of the WrNexus framework.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+26
-8
@@ -151,17 +151,32 @@ layout = "public"
|
|||||||
|
|
||||||
```
|
```
|
||||||
props {
|
props {
|
||||||
start = 0 // default's TYPE drives coercion: start="5" arrives as number 5
|
title: string // required: no default
|
||||||
label = "Count" // string
|
start: number = 0 // start="5" arrives as number 5
|
||||||
disabled = false // boolean
|
disabled: boolean = false
|
||||||
|
items: Array<Item> = []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Type annotations use TypeScript syntax. Legacy declarations without an annotation still infer their
|
||||||
|
runtime type from the default value.
|
||||||
|
|
||||||
|
### `types { }` — local TypeScript models
|
||||||
|
|
||||||
|
```ts
|
||||||
|
types {
|
||||||
|
interface Item { id: string; label: string }
|
||||||
|
type Status = "idle" | "loading" | "ready"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These declarations can be referenced by props, state, and function signatures in the same file.
|
||||||
|
|
||||||
### `state` — ⚠️ one per line
|
### `state` — ⚠️ one per line
|
||||||
|
|
||||||
```
|
```
|
||||||
state count = start // may reference a prop or earlier state
|
state count: number = start // may reference a prop or earlier state
|
||||||
state total = 0
|
state status: Status = "idle"
|
||||||
```
|
```
|
||||||
|
|
||||||
State seeds the reactive scope. On pages, seeds are also evaluated at compile time for SSR baking.
|
State seeds the reactive scope. On pages, seeds are also evaluated at compile time for SSR baking.
|
||||||
@@ -198,15 +213,18 @@ style {
|
|||||||
Multiple `style` blocks accumulate. Use theme tokens (`var(--wire-*)`) so styles restyle on theme
|
Multiple `style` blocks accumulate. Use theme tokens (`var(--wire-*)`) so styles restyle on theme
|
||||||
change.
|
change.
|
||||||
|
|
||||||
### `functions { }` — shared **server‑only** helpers
|
### `functions { }` — typed helpers
|
||||||
|
|
||||||
```
|
```
|
||||||
functions {
|
functions {
|
||||||
function slug(s) { return s.toLowerCase().replace(/\s+/g, "-"); }
|
function slug(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Available to `api`, `ssr`/`client`, and `realtime` bodies.
|
Component functions are available to events, lifecycle hooks, and watchers in the browser as well as
|
||||||
|
server rendering. Page helpers are available to `api`, `ssr`/`client`, and `realtime` bodies.
|
||||||
|
|
||||||
### `api <METHOD> <path> { }` — colocated API route
|
### `api <METHOD> <path> { }` — colocated API route
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.13
|
||||||
|
|
||||||
|
- Added explicit prop and state types, local `types` blocks, and typed function
|
||||||
|
parameter/return highlighting and completion snippets.
|
||||||
|
- Nested component diagnostics now resolve typed prop and state expressions.
|
||||||
|
|
||||||
## 0.2.12
|
## 0.2.12
|
||||||
|
|
||||||
- Added component-aware prop completions, hover tables, required-prop checks,
|
- Added component-aware prop completions, hover tables, required-prop checks,
|
||||||
|
|||||||
@@ -4,6 +4,29 @@ Complete VS Code language support for [WRNexus](https://wrnexusjs.dev) `.wrn` fi
|
|||||||
|
|
||||||
WRNexus is an SSR-first, Bun-powered full-stack framework with reactive components, layouts, colocated APIs, browser functions, lifecycle hooks, state watchers, client-side navigation, and reusable UI components.
|
WRNexus is an SSR-first, Bun-powered full-stack framework with reactive components, layouts, colocated APIs, browser functions, lifecycle hooks, state watchers, client-side navigation, and reusable UI components.
|
||||||
|
|
||||||
|
Typed declarations work across props, state, and functions:
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component UserCard {
|
||||||
|
types {
|
||||||
|
interface User { id: string; name: string }
|
||||||
|
}
|
||||||
|
props {
|
||||||
|
user: User
|
||||||
|
compact: boolean = false
|
||||||
|
}
|
||||||
|
state selected: boolean = false
|
||||||
|
functions {
|
||||||
|
function select(user: User): void {
|
||||||
|
selected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view { <article>{user.name}</article> }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The extension shows declared prop types and reports missing required props and incompatible values. Typed prop and state expressions passed into nested components retain their declared type.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Search for **WRNexus Language Support** in the VS Code Extensions view or run:
|
Search for **WRNexus Language Support** in the VS Code Extensions view or run:
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"folding": {
|
"folding": {
|
||||||
"offSide": false,
|
"offSide": false,
|
||||||
"markers": {
|
"markers": {
|
||||||
"start": "^\\s*(?:page|component|layout|api|realtime|view|seo|props|style|functions|lifecycle|watch|ssr|client)\\b.*\\{\\s*$",
|
"start": "^\\s*(?:page|component|layout|api|realtime|view|seo|types|props|style|functions|lifecycle|watch|ssr|client)\\b.*\\{\\s*$",
|
||||||
"end": "^\\s*\\}\\s*$"
|
"end": "^\\s*\\}\\s*$"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wrnexus",
|
"name": "wrnexus",
|
||||||
"version": "0.2.12",
|
"version": "0.2.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wrnexus",
|
"name": "wrnexus",
|
||||||
"version": "0.2.12",
|
"version": "0.2.13",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vscode/vsce": "^3.9.2"
|
"@vscode/vsce": "^3.9.2"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "wrnexus",
|
"name": "wrnexus",
|
||||||
"displayName": "WRNexus Language Support",
|
"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.",
|
"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.12",
|
"version": "0.2.13",
|
||||||
"publisher": "wrnexus",
|
"publisher": "wrnexus",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"WRN state": {
|
"WRN state": {
|
||||||
"prefix": ["wrn-state", "state"],
|
"prefix": ["wrn-state", "state"],
|
||||||
"description": "Create reactive state",
|
"description": "Create reactive state",
|
||||||
"body": ["state ${1:name} = ${2:\"value\"}"]
|
"body": ["state ${1:name}: ${2:string} = ${3:\"value\"}"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"WRN functions block": {
|
"WRN functions block": {
|
||||||
|
|||||||
+219
-21
@@ -39,9 +39,12 @@ var __export = (target, all) => {
|
|||||||
// ../../packages/compiler/src/index.ts
|
// ../../packages/compiler/src/index.ts
|
||||||
var exports_src = {};
|
var exports_src = {};
|
||||||
__export(exports_src, {
|
__export(exports_src, {
|
||||||
|
runtimeTypeOf: () => runtimeTypeOf,
|
||||||
parse: () => parse,
|
parse: () => parse,
|
||||||
|
inferredRuntimeType: () => inferredRuntimeType,
|
||||||
generateNative: () => generateNative,
|
generateNative: () => generateNative,
|
||||||
generate: () => generate,
|
generate: () => generate,
|
||||||
|
eraseFunctionTypes: () => eraseFunctionTypes,
|
||||||
compileWireFile: () => compileWireFile,
|
compileWireFile: () => compileWireFile,
|
||||||
compileNativeWireFile: () => compileNativeWireFile,
|
compileNativeWireFile: () => compileNativeWireFile,
|
||||||
compile: () => compile,
|
compile: () => compile,
|
||||||
@@ -109,6 +112,9 @@ class Lexer {
|
|||||||
case "=":
|
case "=":
|
||||||
this.pos++;
|
this.pos++;
|
||||||
return { type: "eq", value: c, pos };
|
return { type: "eq", value: c, pos };
|
||||||
|
case ":":
|
||||||
|
this.pos++;
|
||||||
|
return { type: "colon", value: c, pos };
|
||||||
case ",":
|
case ",":
|
||||||
this.pos++;
|
this.pos++;
|
||||||
return { type: "comma", value: c, pos };
|
return { type: "comma", value: c, pos };
|
||||||
@@ -167,6 +173,67 @@ class Lexer {
|
|||||||
v += src[this.pos++];
|
v += src[this.pos++];
|
||||||
return v.trim();
|
return v.trim();
|
||||||
}
|
}
|
||||||
|
readTypeAnnotation() {
|
||||||
|
const { src } = this;
|
||||||
|
let value = "";
|
||||||
|
let angle = 0;
|
||||||
|
let square = 0;
|
||||||
|
let brace = 0;
|
||||||
|
let paren = 0;
|
||||||
|
let quote = null;
|
||||||
|
while (this.pos < src.length) {
|
||||||
|
const c = src[this.pos];
|
||||||
|
if (quote) {
|
||||||
|
value += c;
|
||||||
|
this.pos++;
|
||||||
|
if (c === "\\" && this.pos < src.length)
|
||||||
|
value += src[this.pos++];
|
||||||
|
else if (c === quote)
|
||||||
|
quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === "`") {
|
||||||
|
quote = c;
|
||||||
|
value += c;
|
||||||
|
this.pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "<")
|
||||||
|
angle++;
|
||||||
|
else if (c === ">" && angle > 0)
|
||||||
|
angle--;
|
||||||
|
else if (c === "[")
|
||||||
|
square++;
|
||||||
|
else if (c === "]" && square > 0)
|
||||||
|
square--;
|
||||||
|
else if (c === "{")
|
||||||
|
brace++;
|
||||||
|
else if (c === "}" && brace > 0)
|
||||||
|
brace--;
|
||||||
|
else if (c === "(")
|
||||||
|
paren++;
|
||||||
|
else if (c === ")" && paren > 0)
|
||||||
|
paren--;
|
||||||
|
if (angle === 0 && square === 0 && brace === 0 && paren === 0) {
|
||||||
|
if (c === "=") {
|
||||||
|
this.pos++;
|
||||||
|
const type2 = value.trim();
|
||||||
|
if (!type2)
|
||||||
|
throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||||
|
return { type: type2, hasDefault: true };
|
||||||
|
}
|
||||||
|
if (c === `
|
||||||
|
` || c === "\r")
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value += c;
|
||||||
|
this.pos++;
|
||||||
|
}
|
||||||
|
const type = value.trim();
|
||||||
|
if (!type)
|
||||||
|
throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||||
|
return { type, hasDefault: false };
|
||||||
|
}
|
||||||
readBalancedBraces() {
|
readBalancedBraces() {
|
||||||
this.skipTrivia();
|
this.skipTrivia();
|
||||||
const { src } = this;
|
const { src } = this;
|
||||||
@@ -215,6 +282,62 @@ class Lexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ../../packages/compiler/src/types.ts
|
||||||
|
function runtimeTypeOf(annotation) {
|
||||||
|
if (!annotation)
|
||||||
|
return "unknown";
|
||||||
|
const type = annotation.trim().replace(/^readonly\s+/, "");
|
||||||
|
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||||
|
return "string";
|
||||||
|
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||||
|
return "number";
|
||||||
|
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||||
|
return "boolean";
|
||||||
|
if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||||
|
return "bigint";
|
||||||
|
if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type))
|
||||||
|
return "array";
|
||||||
|
if (/^(?:Record\s*<|object\b|\{)/.test(type))
|
||||||
|
return "object";
|
||||||
|
if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type))
|
||||||
|
return "function";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
function inferredRuntimeType(expression) {
|
||||||
|
const value = expression.trim();
|
||||||
|
if (/^["'`]/.test(value))
|
||||||
|
return "string";
|
||||||
|
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value))
|
||||||
|
return "number";
|
||||||
|
if (/^(?:true|false)$/.test(value))
|
||||||
|
return "boolean";
|
||||||
|
if (/^-?\d+n$/.test(value))
|
||||||
|
return "bigint";
|
||||||
|
if (value.startsWith("["))
|
||||||
|
return "array";
|
||||||
|
if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value))
|
||||||
|
return "object";
|
||||||
|
if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) {
|
||||||
|
return "function";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
function validateTypedInitializer(name, annotation, expression) {
|
||||||
|
if (!annotation || expression.trim() === "undefined" || expression.trim() === "null")
|
||||||
|
return null;
|
||||||
|
const expected = runtimeTypeOf(annotation);
|
||||||
|
const actual = inferredRuntimeType(expression);
|
||||||
|
if (expected === "unknown" || actual === "unknown" || expected === actual)
|
||||||
|
return null;
|
||||||
|
return `${name} is declared as ${annotation}, but its initializer is ${actual}`;
|
||||||
|
}
|
||||||
|
function eraseFunctionTypes(source) {
|
||||||
|
return source.replace(/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g, (_whole, open, params, close, _returnType, brace) => {
|
||||||
|
const plainParams = params.split(",").map((param) => param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim()).join(", ");
|
||||||
|
return `${open}${plainParams}${close}${brace}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ../../packages/compiler/src/parser.ts
|
// ../../packages/compiler/src/parser.ts
|
||||||
var VOID_ELEMENTS = new Set([
|
var VOID_ELEMENTS = new Set([
|
||||||
"area",
|
"area",
|
||||||
@@ -282,6 +405,7 @@ function parse(source) {
|
|||||||
expect("lbrace");
|
expect("lbrace");
|
||||||
let layout;
|
let layout;
|
||||||
const props = [];
|
const props = [];
|
||||||
|
const types = [];
|
||||||
const states = [];
|
const states = [];
|
||||||
const seo = {};
|
const seo = {};
|
||||||
const view = [];
|
const view = [];
|
||||||
@@ -318,8 +442,19 @@ function parse(source) {
|
|||||||
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
||||||
}
|
}
|
||||||
const pName = expect("ident").value;
|
const pName = expect("ident").value;
|
||||||
expect("eq");
|
let valueType;
|
||||||
props.push({ name: pName, default: lx.readToLineEnd() });
|
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.readToLineEnd() : "undefined";
|
||||||
|
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||||
}
|
}
|
||||||
expect("rbrace");
|
expect("rbrace");
|
||||||
break;
|
break;
|
||||||
@@ -327,8 +462,23 @@ function parse(source) {
|
|||||||
case "state": {
|
case "state": {
|
||||||
lx.next();
|
lx.next();
|
||||||
const sName = expect("ident").value;
|
const sName = expect("ident").value;
|
||||||
expect("eq");
|
let valueType;
|
||||||
states.push({ name: sName, expr: lx.readToLineEnd() });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "view": {
|
case "view": {
|
||||||
@@ -465,12 +615,23 @@ function parse(source) {
|
|||||||
throw new ParseError(`Cannot watch undeclared state '${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 {
|
return {
|
||||||
type: "page",
|
type: "page",
|
||||||
kind,
|
kind,
|
||||||
name,
|
name,
|
||||||
layout,
|
layout,
|
||||||
props,
|
props,
|
||||||
|
types,
|
||||||
states,
|
states,
|
||||||
seo,
|
seo,
|
||||||
view,
|
view,
|
||||||
@@ -1149,6 +1310,11 @@ function generate(ast) {
|
|||||||
|
|
||||||
`);
|
`);
|
||||||
const apiBindings = apiBindingMap(ast, helpers);
|
const apiBindings = apiBindingMap(ast, helpers);
|
||||||
|
const typeSource = ast.types.map((body2) => body2.trim()).filter(Boolean).join(`
|
||||||
|
|
||||||
|
`);
|
||||||
|
if (typeSource)
|
||||||
|
out.push(typeSource);
|
||||||
if (helpers) {
|
if (helpers) {
|
||||||
out.push(`// --- .wrn functions ---
|
out.push(`// --- .wrn functions ---
|
||||||
${helpers}`);
|
${helpers}`);
|
||||||
@@ -1177,6 +1343,7 @@ ${css}
|
|||||||
}
|
}
|
||||||
let body = templateEscape(html);
|
let body = templateEscape(html);
|
||||||
const dynamicStateScope = ast.states.map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`).join(", ");
|
const dynamicStateScope = ast.states.map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`).join(", ");
|
||||||
|
const stateType = ast.states.length > 0 ? `{ ${ast.states.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`).join("; ")} }` : "Record<string, never>";
|
||||||
loops.forEach((code, idx) => {
|
loops.forEach((code, idx) => {
|
||||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||||
});
|
});
|
||||||
@@ -1200,7 +1367,7 @@ ${css}
|
|||||||
` : "";
|
` : "";
|
||||||
out.push(`export default async function ${ast.name}(ctx: any) {
|
out.push(`export default async function ${ast.name}(ctx: any) {
|
||||||
${decls}
|
${decls}
|
||||||
const __state = { ${dynamicStateScope} };
|
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||||
|
|
||||||
const __scopeValue = Object.entries(__state)
|
const __scopeValue = Object.entries(__state)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
@@ -1226,7 +1393,7 @@ ${css}
|
|||||||
}`);
|
}`);
|
||||||
} else {
|
} else {
|
||||||
out.push(`export default function ${ast.name}(ctx: any) {
|
out.push(`export default function ${ast.name}(ctx: any) {
|
||||||
const __state = { ${dynamicStateScope} };
|
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||||
|
|
||||||
const __scopeValue = Object.entries(__state)
|
const __scopeValue = Object.entries(__state)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
@@ -1346,9 +1513,9 @@ function escLit(s) {
|
|||||||
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||||
}
|
}
|
||||||
function componentBehavior(ast) {
|
function componentBehavior(ast) {
|
||||||
const functions = ast.functions.map((body) => body.trim()).filter(Boolean).join(`
|
const functions = eraseFunctionTypes(ast.functions.map((body) => body.trim()).filter(Boolean).join(`
|
||||||
|
|
||||||
`);
|
`));
|
||||||
const lifecycle = {
|
const lifecycle = {
|
||||||
...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {},
|
...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {},
|
||||||
...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {},
|
...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {},
|
||||||
@@ -1562,7 +1729,9 @@ function generateComponent(ast) {
|
|||||||
const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [
|
const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [
|
||||||
{
|
{
|
||||||
name: "content",
|
name: "content",
|
||||||
default: '""'
|
default: '""',
|
||||||
|
valueType: "string",
|
||||||
|
required: false
|
||||||
},
|
},
|
||||||
...ast.props
|
...ast.props
|
||||||
] : ast.props;
|
] : ast.props;
|
||||||
@@ -1600,10 +1769,13 @@ ${styles.map(styleEscape).join(`
|
|||||||
const behaviorAttr = behaviorAttribute(behavior);
|
const behaviorAttr = behaviorAttribute(behavior);
|
||||||
const decls = [];
|
const decls = [];
|
||||||
for (const prop of effectiveProps) {
|
for (const prop of effectiveProps) {
|
||||||
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
|
if (prop.required) {
|
||||||
|
decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`);
|
||||||
|
}
|
||||||
|
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`);
|
||||||
}
|
}
|
||||||
for (const state of ast.states) {
|
for (const state of ast.states) {
|
||||||
decls.push(` let ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
|
decls.push(` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`);
|
||||||
}
|
}
|
||||||
const returnExpr = needsScope ? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
|
const returnExpr = needsScope ? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
|
||||||
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`).join(", ")} });
|
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`).join(", ")} });
|
||||||
@@ -1617,20 +1789,35 @@ ${styles.map(styleEscape).join(`
|
|||||||
if (behavior) {
|
if (behavior) {
|
||||||
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
||||||
}
|
}
|
||||||
out.push(`function __coerce(v: any, def: any): any {
|
const typeSource = ast.types.map((body) => body.trim()).filter(Boolean).join(`
|
||||||
|
|
||||||
|
`);
|
||||||
|
if (typeSource)
|
||||||
|
out.push(typeSource);
|
||||||
|
if (effectiveProps.length > 0) {
|
||||||
|
out.push(`export interface ${ast.name}Props {
|
||||||
|
${effectiveProps.map((prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`).join(`
|
||||||
|
`)}
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any {
|
||||||
if (v === undefined || v === null) {
|
if (v === undefined || v === null) {
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof def === "number") {
|
if (declared === "number" || typeof def === "number") {
|
||||||
return Number(v);
|
const parsed = Number(v);
|
||||||
|
if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop");
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof def === "boolean") {
|
if (declared === "boolean" || typeof def === "boolean") {
|
||||||
return v === true || v === "" || v === "true";
|
if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true;
|
||||||
|
if (v === false || v === "false" || v === 0 || v === "0") return false;
|
||||||
|
throw new TypeError("Expected a boolean prop");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(def)) {
|
if (declared === "array" || Array.isArray(def)) {
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -1640,6 +1827,7 @@ ${styles.map(styleEscape).join(`
|
|||||||
const parsed = JSON.parse(v);
|
const parsed = JSON.parse(v);
|
||||||
return Array.isArray(parsed) ? parsed : def;
|
return Array.isArray(parsed) ? parsed : def;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (declared === "array") throw new TypeError("Expected an array prop");
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1647,7 +1835,7 @@ ${styles.map(styleEscape).join(`
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (def !== null && typeof def === "object") {
|
if (declared === "object" || (def !== null && typeof def === "object")) {
|
||||||
if (
|
if (
|
||||||
v !== null &&
|
v !== null &&
|
||||||
typeof v === "object" &&
|
typeof v === "object" &&
|
||||||
@@ -1668,6 +1856,7 @@ ${styles.map(styleEscape).join(`
|
|||||||
? parsed
|
? parsed
|
||||||
: def;
|
: def;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (declared === "object") throw new TypeError("Expected an object prop");
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1675,7 +1864,11 @@ ${styles.map(styleEscape).join(`
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(v);
|
if (declared === "bigint") return BigInt(v);
|
||||||
|
if (declared === "function" && typeof v !== "function") {
|
||||||
|
throw new TypeError("Expected a function prop");
|
||||||
|
}
|
||||||
|
return declared === "unknown" && def === undefined ? v : String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wireHtml(v: any): string {
|
function __wireHtml(v: any): string {
|
||||||
@@ -1775,7 +1968,7 @@ function __wireRaw(v: any): string {
|
|||||||
}
|
}
|
||||||
const serverFunctionSource = serverFunctions ? `${serverFunctions}
|
const serverFunctionSource = serverFunctions ? `${serverFunctions}
|
||||||
` : "";
|
` : "";
|
||||||
out.push(`export function render(props: Record<string, any> = {}): string {
|
out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"}): string {
|
||||||
` + ` const __p = props || {};
|
` + ` const __p = props || {};
|
||||||
` + (decls.length > 0 ? decls.join(`
|
` + (decls.length > 0 ? decls.join(`
|
||||||
`) + `
|
`) + `
|
||||||
@@ -1988,15 +2181,20 @@ function generateNative(ast) {
|
|||||||
const states = new Set(ast.states.map((state) => state.name));
|
const states = new Set(ast.states.map((state) => state.name));
|
||||||
const hooks = ast.states.map((state) => {
|
const hooks = ast.states.map((state) => {
|
||||||
const cap = state.name[0].toUpperCase() + state.name.slice(1);
|
const cap = state.name[0].toUpperCase() + state.name.slice(1);
|
||||||
return ` const [${state.name}, set${cap}] = useState(${state.expr});`;
|
return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`;
|
||||||
}).join(`
|
}).join(`
|
||||||
`);
|
`);
|
||||||
const body = ast.view.map((node) => renderNode2(node, states)).join("");
|
const body = ast.view.map((node) => renderNode2(node, states)).join("");
|
||||||
|
const typeSource = ast.types.map((block) => block.trim()).filter(Boolean).join(`
|
||||||
|
|
||||||
|
`);
|
||||||
return `// generated from .wrn for Expo/React Native
|
return `// generated from .wrn for Expo/React Native
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
${typeSource}
|
||||||
|
|
||||||
export default function ${ast.name}() {
|
export default function ${ast.name}() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
${hooks}
|
${hooks}
|
||||||
|
|||||||
@@ -58,11 +58,23 @@ const BLOCK_COMPLETIONS = [
|
|||||||
"}",
|
"}",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "types",
|
||||||
|
detail: "TypeScript type declarations block",
|
||||||
|
documentation: "Declare interfaces and type aliases used by props, state, and functions.",
|
||||||
|
snippet: [
|
||||||
|
"types {",
|
||||||
|
" interface ${1:Item} {",
|
||||||
|
" ${2:id}: ${3:string}",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "props",
|
label: "props",
|
||||||
detail: "Component or layout props block",
|
detail: "Component or layout props block",
|
||||||
documentation: "Declare values accepted by a component or layout.",
|
documentation: "Declare values accepted by a component or layout.",
|
||||||
snippet: ["props {", ' ${1:title} = "${2:Title}"', "}"].join("\n"),
|
snippet: ["props {", ' ${1:title}: ${2:string} = "${3:Title}"', "}"].join("\n"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "seo",
|
label: "seo",
|
||||||
@@ -85,22 +97,28 @@ const BLOCK_COMPLETIONS = [
|
|||||||
label: "state",
|
label: "state",
|
||||||
detail: "Reactive state declaration",
|
detail: "Reactive state declaration",
|
||||||
documentation: "Declare reactive state owned by the current component, layout, or page.",
|
documentation: "Declare reactive state owned by the current component, layout, or page.",
|
||||||
snippet: 'state ${1:name} = ${2:"value"}',
|
snippet: 'state ${1:name}: ${2:string} = ${3:"value"}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "functions",
|
label: "functions",
|
||||||
detail: "Browser component functions block",
|
detail: "Browser component functions block",
|
||||||
documentation:
|
documentation:
|
||||||
"Declare functions that can be called from events, lifecycle hooks, and watchers.",
|
"Declare functions that can be called from events, lifecycle hooks, and watchers.",
|
||||||
snippet: ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"].join(
|
snippet: [
|
||||||
"\n",
|
"functions {",
|
||||||
),
|
" function ${1:handler}(${2:value}: ${3:unknown}): ${4:void} {",
|
||||||
|
" $0",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "function",
|
label: "function",
|
||||||
detail: "WRN component function",
|
detail: "WRN component function",
|
||||||
documentation: "Declare a browser-side function inside a functions block.",
|
documentation: "Declare a browser-side function inside a functions block.",
|
||||||
snippet: ["function ${1:name}(${2}) {", " $0", "}"].join("\n"),
|
snippet: ["function ${1:name}(${2:value}: ${3:unknown}): ${4:void} {", " $0", "}"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "lifecycle",
|
label: "lifecycle",
|
||||||
@@ -278,7 +296,7 @@ function extractRouteParams(document) {
|
|||||||
function extractStates(document) {
|
function extractStates(document) {
|
||||||
const source = document.getText();
|
const source = document.getText();
|
||||||
|
|
||||||
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/gm)];
|
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)(?:\s*:\s*[^=\r\n]+)?\s*=/gm)];
|
||||||
|
|
||||||
return [...new Set(matches.map((match) => match[1]))];
|
return [...new Set(matches.map((match) => match[1]))];
|
||||||
}
|
}
|
||||||
@@ -293,7 +311,9 @@ function extractProps(document) {
|
|||||||
while ((blockMatch = propsBlockPattern.exec(source)) !== null) {
|
while ((blockMatch = propsBlockPattern.exec(source)) !== null) {
|
||||||
const body = blockMatch[1];
|
const body = blockMatch[1];
|
||||||
|
|
||||||
for (const match of body.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*=/gm)) {
|
for (const match of body.matchAll(
|
||||||
|
/^\s*([A-Za-z_$][\w$]*)(?:\s*:\s*[^=\r\n]+)?(?:\s*=|\s*$)/gm,
|
||||||
|
)) {
|
||||||
props.add(match[1]);
|
props.add(match[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +343,12 @@ function extractFunctions(document) {
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((parameter) => parameter.trim())
|
.map((parameter) => parameter.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((parameter) => parameter.replace(/=.*$/, "").trim());
|
.map((parameter) =>
|
||||||
|
parameter
|
||||||
|
.replace(/=.*$/, "")
|
||||||
|
.replace(/\??\s*:\s*[\s\S]+$/, "")
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
functions.push({
|
functions.push({
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ function inferType(defaultValue) {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeType(type) {
|
||||||
|
const value = String(type || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^readonly\s+/, "");
|
||||||
|
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "string";
|
||||||
|
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "number";
|
||||||
|
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "boolean";
|
||||||
|
if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "bigint";
|
||||||
|
if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(value) || /^\[/.test(value)) return "array";
|
||||||
|
if (/^(?:Record\s*<|object\b|\{)/.test(value)) return "object";
|
||||||
|
if (/=>|^Function$/.test(value)) return "function";
|
||||||
|
return value || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function stringLiteral(value) {
|
function stringLiteral(value) {
|
||||||
const match = /^(?:"([\s\S]*)"|'([\s\S]*)'|`([\s\S]*)`)$/.exec(value.trim());
|
const match = /^(?:"([\s\S]*)"|'([\s\S]*)'|`([\s\S]*)`)$/.exec(value.trim());
|
||||||
return match ? (match[1] ?? match[2] ?? match[3]) : null;
|
return match ? (match[1] ?? match[2] ?? match[3]) : null;
|
||||||
@@ -61,16 +75,22 @@ function parseComponentMetadata(source, uri = null) {
|
|||||||
const closingBrace = findMatchingBrace(source, openingBrace);
|
const closingBrace = findMatchingBrace(source, openingBrace);
|
||||||
const bodyEnd = closingBrace === -1 ? source.length : closingBrace;
|
const bodyEnd = closingBrace === -1 ? source.length : closingBrace;
|
||||||
const body = source.slice(openingBrace + 1, bodyEnd);
|
const body = source.slice(openingBrace + 1, bodyEnd);
|
||||||
const linePattern = /^(?:\s*\/\/\s*@required\s*\r?\n)?\s*([A-Za-z_$][\w$]*)\s*=\s*(.*?)\s*$/gm;
|
const linePattern =
|
||||||
|
/^(?:\s*\/\/\s*@required\s*\r?\n)?\s*([A-Za-z_$][\w$]*)(?:\s*:\s*([^=\r\n]+?))?(?:\s*=\s*(.*?))?\s*$/gm;
|
||||||
let propMatch;
|
let propMatch;
|
||||||
while ((propMatch = linePattern.exec(body)) !== null) {
|
while ((propMatch = linePattern.exec(body)) !== null) {
|
||||||
const defaultValue = propMatch[2];
|
const annotation = propMatch[2] && propMatch[2].trim();
|
||||||
|
const hasDefault = propMatch[3] !== undefined;
|
||||||
|
const defaultValue = hasDefault ? propMatch[3] : "undefined";
|
||||||
const name = propMatch[1];
|
const name = propMatch[1];
|
||||||
props.push({
|
props.push({
|
||||||
name,
|
name,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
required: defaultValue.trim() === "undefined" || /^\s*\/\/\s*@required/m.test(propMatch[0]),
|
required:
|
||||||
type: inferType(defaultValue),
|
!hasDefault ||
|
||||||
|
defaultValue.trim() === "undefined" ||
|
||||||
|
/^\s*\/\/\s*@required/m.test(propMatch[0]),
|
||||||
|
type: annotation || inferType(defaultValue),
|
||||||
options: inferOptions(source, name),
|
options: inferOptions(source, name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -85,8 +105,11 @@ function unwrapAttributeValue(value) {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attributeValueType(value) {
|
function attributeValueType(value, symbols = new Map()) {
|
||||||
const unwrapped = unwrapAttributeValue(value);
|
const unwrapped = unwrapAttributeValue(value);
|
||||||
|
if (/^[A-Za-z_$][\w$]*$/.test(unwrapped) && symbols.has(unwrapped)) {
|
||||||
|
return symbols.get(unwrapped);
|
||||||
|
}
|
||||||
if (/^(?:true|false)$/.test(unwrapped)) return "boolean";
|
if (/^(?:true|false)$/.test(unwrapped)) return "boolean";
|
||||||
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(unwrapped)) return "number";
|
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(unwrapped)) return "number";
|
||||||
if (unwrapped.startsWith("[")) return "array";
|
if (unwrapped.startsWith("[")) return "array";
|
||||||
@@ -94,12 +117,13 @@ function attributeValueType(value) {
|
|||||||
return "string";
|
return "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTypeCompatible(prop, value) {
|
function isTypeCompatible(prop, value, symbols = new Map()) {
|
||||||
if (prop.type === "unknown" || prop.type === "null") return true;
|
const expected = runtimeType(prop.type);
|
||||||
const actual = attributeValueType(value);
|
if (expected === "unknown" || expected === "null") return true;
|
||||||
if (prop.type === actual) return true;
|
const actual = runtimeType(attributeValueType(value, symbols));
|
||||||
if (prop.type === "number" && actual === "string") return Number.isFinite(Number(value));
|
if (expected === actual) return true;
|
||||||
if (prop.type === "boolean" && actual === "string")
|
if (expected === "number" && actual === "string") return Number.isFinite(Number(value));
|
||||||
|
if (expected === "boolean" && actual === "string")
|
||||||
return /^(?:true|false|1|0|yes|no|on|off)?$/i.test(value);
|
return /^(?:true|false|1|0|yes|no|on|off)?$/i.test(value);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -137,6 +161,13 @@ function parseComponentTags(source) {
|
|||||||
|
|
||||||
function validateComponentTags(source, components) {
|
function validateComponentTags(source, components) {
|
||||||
const diagnostics = [];
|
const diagnostics = [];
|
||||||
|
const own = parseComponentMetadata(source);
|
||||||
|
const symbols = new Map((own?.props || []).map((prop) => [prop.name, prop.type]));
|
||||||
|
const statePattern = /^\s*state\s+([A-Za-z_$][\w$]*)(?:\s*:\s*([^=\r\n]+?))?\s*=\s*(.*?)\s*$/gm;
|
||||||
|
let stateMatch;
|
||||||
|
while ((stateMatch = statePattern.exec(source)) !== null) {
|
||||||
|
symbols.set(stateMatch[1], stateMatch[2]?.trim() || inferType(stateMatch[3]));
|
||||||
|
}
|
||||||
for (const tag of parseComponentTags(source)) {
|
for (const tag of parseComponentTags(source)) {
|
||||||
const component = components.get(tag.name);
|
const component = components.get(tag.name);
|
||||||
if (!component) continue;
|
if (!component) continue;
|
||||||
@@ -167,11 +198,12 @@ function validateComponentTags(source, components) {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isTypeCompatible(prop, attribute.value)) {
|
const actualType = attributeValueType(attribute.value, symbols);
|
||||||
|
if (!isTypeCompatible(prop, attribute.value, symbols)) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
severity: "error",
|
severity: "error",
|
||||||
code: "wrn-component-prop-type",
|
code: "wrn-component-prop-type",
|
||||||
message: `Prop \`${attribute.name}\` on <${tag.name}> expects ${prop.type}, but received ${attributeValueType(attribute.value)}.`,
|
message: `Prop \`${attribute.name}\` on <${tag.name}> expects ${prop.type}, but received ${runtimeType(actualType)}.`,
|
||||||
start: attribute.nameStart,
|
start: attribute.nameStart,
|
||||||
end: attribute.nameEnd,
|
end: attribute.nameEnd,
|
||||||
});
|
});
|
||||||
@@ -194,6 +226,7 @@ function validateComponentTags(source, components) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
attributeValueType,
|
attributeValueType,
|
||||||
inferType,
|
inferType,
|
||||||
|
runtimeType,
|
||||||
isTypeCompatible,
|
isTypeCompatible,
|
||||||
parseComponentMetadata,
|
parseComponentMetadata,
|
||||||
parseComponentTags,
|
parseComponentTags,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const VALID_MEMBERS = {
|
|||||||
page: new Set([
|
page: new Set([
|
||||||
"layout",
|
"layout",
|
||||||
"state",
|
"state",
|
||||||
|
"types",
|
||||||
"view",
|
"view",
|
||||||
"seo",
|
"seo",
|
||||||
"style",
|
"style",
|
||||||
@@ -25,9 +26,18 @@ const VALID_MEMBERS = {
|
|||||||
"realtime",
|
"realtime",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
component: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
|
component: new Set([
|
||||||
|
"types",
|
||||||
|
"props",
|
||||||
|
"state",
|
||||||
|
"view",
|
||||||
|
"style",
|
||||||
|
"functions",
|
||||||
|
"lifecycle",
|
||||||
|
"watch",
|
||||||
|
]),
|
||||||
|
|
||||||
layout: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
|
layout: new Set(["types", "props", "state", "view", "style", "functions", "lifecycle", "watch"]),
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDiagnostic(
|
function createDiagnostic(
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
{
|
{
|
||||||
"include": "#seo-block"
|
"include": "#seo-block"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"include": "#types-block"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"include": "#props-block"
|
"include": "#props-block"
|
||||||
},
|
},
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state-decl": {
|
"state-decl": {
|
||||||
"begin": "\\b(state)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
|
"begin": "\\b(state)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=)",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
"1": {
|
"1": {
|
||||||
"name": "keyword.control.wrn"
|
"name": "keyword.control.wrn"
|
||||||
@@ -102,6 +105,12 @@
|
|||||||
"name": "variable.other.wrn"
|
"name": "variable.other.wrn"
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
|
"name": "punctuation.separator.type.wrn"
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"name": "storage.type.wrn"
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
"name": "keyword.operator.assignment.wrn"
|
"name": "keyword.operator.assignment.wrn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,6 +122,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"types-block": {
|
||||||
|
"begin": "\\b(types)\\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": "source.ts" }]
|
||||||
|
},
|
||||||
"props-block": {
|
"props-block": {
|
||||||
"begin": "\\b(props)\\b\\s*(\\{)",
|
"begin": "\\b(props)\\b\\s*(\\{)",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
@@ -134,12 +155,18 @@
|
|||||||
"include": "#comments"
|
"include": "#comments"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
|
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=|$)",
|
||||||
"captures": {
|
"captures": {
|
||||||
"1": {
|
"1": {
|
||||||
"name": "variable.parameter.wrn"
|
"name": "variable.parameter.wrn"
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
|
"name": "punctuation.separator.type.wrn"
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"name": "storage.type.wrn"
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
"name": "keyword.operator.assignment.wrn"
|
"name": "keyword.operator.assignment.wrn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,3 +80,31 @@ test("recognizes literal attribute value types", () => {
|
|||||||
assert.equal(attributeValueType("{[1, 2]}"), "array");
|
assert.equal(attributeValueType("{[1, 2]}"), "array");
|
||||||
assert.equal(attributeValueType("plain"), "string");
|
assert.equal(attributeValueType("plain"), "string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extracts explicit prop types and resolves typed prop/state expressions", () => {
|
||||||
|
const input = `component DatePicker {
|
||||||
|
props {
|
||||||
|
required: boolean = false
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
state count: number = 0
|
||||||
|
view { <DateInput required="{required}" value="{value}" count="{count}" /> }
|
||||||
|
}`;
|
||||||
|
const target = parseComponentMetadata(`component DateInput {
|
||||||
|
props {
|
||||||
|
required: boolean = false
|
||||||
|
value: string
|
||||||
|
count: number = 0
|
||||||
|
}
|
||||||
|
view { <input/> }
|
||||||
|
}`);
|
||||||
|
const own = parseComponentMetadata(input);
|
||||||
|
assert.deepEqual(
|
||||||
|
own.props.map(({ name, type, required }) => ({ name, type, required })),
|
||||||
|
[
|
||||||
|
{ name: "required", type: "boolean", required: false },
|
||||||
|
{ name: "value", type: "string", required: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(validateComponentTags(input, new Map([["DateInput", target]])), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ai",
|
"name": "@wrnexus/ai",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/authz",
|
"name": "@wrnexus/authz",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -593,6 +593,15 @@ const MIGRATIONS: Migration[] = [
|
|||||||
// UI components remain auto-discovered; no application files require migration.
|
// UI components remain auto-discovered; no application files require migration.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: "0.2.59",
|
||||||
|
id: "typed-wrn-declarations",
|
||||||
|
description:
|
||||||
|
"Adds explicit prop and state types, local type declarations, and typed component runtime validation.",
|
||||||
|
apply() {
|
||||||
|
// Existing inferred declarations remain compatible; typed syntax is opt-in.
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||||
|
|||||||
+17
-15
@@ -76,18 +76,19 @@ class Lexer {
|
|||||||
|
|
||||||
Exported type-only symbols describing the parsed tree:
|
Exported type-only symbols describing the parsed tree:
|
||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. |
|
| `PageAst` | Root node including `kind`, `name`, `types`, typed `props`, typed `states`, `view`, styles, functions, data APIs, lifecycle, and routes. |
|
||||||
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
||||||
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
||||||
| `StateDecl` | `{ name; expr }` — a `state x = <expr>` declaration. |
|
| `StateDecl` | `{ name; valueType?; expr }` — a typed `state x: Type = <expr>` declaration. |
|
||||||
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
| `PropDecl` | `{ name; valueType?; required; default }` — a typed prop declaration. |
|
||||||
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
||||||
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
||||||
| `DataMode` | `"ssr" \| "client"`. |
|
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
||||||
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
| `DataMode` | `"ssr" \| "client"`. |
|
||||||
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
||||||
|
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -147,12 +148,13 @@ lx.next(); // { type: "lbrace", value: "{", pos: 10 }
|
|||||||
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
|
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
|
||||||
|
|
||||||
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
||||||
- `props { name = <default> ... }` — component props; each default's type drives coercion.
|
- `types { <TypeScript declarations> }` — reusable interfaces and aliases for the current file.
|
||||||
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
|
- `props { name: Type = <default> ... }` — typed component props. Omit `= <default>` to make a prop required. Legacy inferred props remain supported.
|
||||||
|
- `state <ident>: Type = <expr>` — typed reactive state seeded from a raw JS expression. The annotation is optional for backward compatibility.
|
||||||
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
||||||
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
||||||
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
||||||
- `functions { <raw js> }` — shared server-side helpers (repeatable).
|
- `functions { <TypeScript> }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code.
|
||||||
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
|
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
|
||||||
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
|
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
|
||||||
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
|
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||||
|
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||||
|
|
||||||
interface RenderBinding {
|
interface RenderBinding {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -667,6 +668,12 @@ export function generate(ast: PageAst): string {
|
|||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
const apiBindings = apiBindingMap(ast, helpers);
|
const apiBindings = apiBindingMap(ast, helpers);
|
||||||
|
|
||||||
|
const typeSource = ast.types
|
||||||
|
.map((body) => body.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
if (typeSource) out.push(typeSource);
|
||||||
|
|
||||||
if (helpers) {
|
if (helpers) {
|
||||||
out.push(`// --- .wrn functions ---\n${helpers}`);
|
out.push(`// --- .wrn functions ---\n${helpers}`);
|
||||||
}
|
}
|
||||||
@@ -709,6 +716,12 @@ export function generate(ast: PageAst): string {
|
|||||||
`${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`,
|
`${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`,
|
||||||
)
|
)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
const stateType =
|
||||||
|
ast.states.length > 0
|
||||||
|
? `{ ${ast.states
|
||||||
|
.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`)
|
||||||
|
.join("; ")} }`
|
||||||
|
: "Record<string, never>";
|
||||||
loops.forEach((code, idx) => {
|
loops.forEach((code, idx) => {
|
||||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||||
});
|
});
|
||||||
@@ -738,7 +751,7 @@ export function generate(ast: PageAst): string {
|
|||||||
out.push(
|
out.push(
|
||||||
`export default async function ${ast.name}(ctx: any) {
|
`export default async function ${ast.name}(ctx: any) {
|
||||||
${decls}
|
${decls}
|
||||||
const __state = { ${dynamicStateScope} };
|
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||||
|
|
||||||
const __scopeValue = Object.entries(__state)
|
const __scopeValue = Object.entries(__state)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
@@ -766,7 +779,7 @@ export function generate(ast: PageAst): string {
|
|||||||
} else {
|
} else {
|
||||||
out.push(
|
out.push(
|
||||||
`export default function ${ast.name}(ctx: any) {
|
`export default function ${ast.name}(ctx: any) {
|
||||||
const __state = { ${dynamicStateScope} };
|
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||||
|
|
||||||
const __scopeValue = Object.entries(__state)
|
const __scopeValue = Object.entries(__state)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
@@ -932,10 +945,12 @@ function escLit(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||||
const functions = ast.functions
|
const functions = eraseFunctionTypes(
|
||||||
.map((body) => body.trim())
|
ast.functions
|
||||||
.filter(Boolean)
|
.map((body) => body.trim())
|
||||||
.join("\n\n");
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
);
|
||||||
|
|
||||||
const lifecycle = {
|
const lifecycle = {
|
||||||
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
|
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
|
||||||
@@ -1289,6 +1304,8 @@ function generateComponent(ast: PageAst): string {
|
|||||||
{
|
{
|
||||||
name: "content",
|
name: "content",
|
||||||
default: '""',
|
default: '""',
|
||||||
|
valueType: "string",
|
||||||
|
required: false,
|
||||||
},
|
},
|
||||||
...ast.props,
|
...ast.props,
|
||||||
]
|
]
|
||||||
@@ -1339,12 +1356,21 @@ function generateComponent(ast: PageAst): string {
|
|||||||
|
|
||||||
const decls: string[] = [];
|
const decls: string[] = [];
|
||||||
for (const prop of effectiveProps) {
|
for (const prop of effectiveProps) {
|
||||||
|
if (prop.required) {
|
||||||
|
decls.push(
|
||||||
|
` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(
|
||||||
|
`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`,
|
||||||
|
)});`,
|
||||||
|
);
|
||||||
|
}
|
||||||
decls.push(
|
decls.push(
|
||||||
` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`,
|
` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const state of ast.states) {
|
for (const state of ast.states) {
|
||||||
decls.push(` let ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
|
decls.push(
|
||||||
|
` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnExpr = needsScope
|
const returnExpr = needsScope
|
||||||
@@ -1370,20 +1396,41 @@ function generateComponent(ast: PageAst): string {
|
|||||||
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(`function __coerce(v: any, def: any): any {
|
const typeSource = ast.types
|
||||||
|
.map((body) => body.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
if (typeSource) out.push(typeSource);
|
||||||
|
|
||||||
|
if (effectiveProps.length > 0) {
|
||||||
|
out.push(
|
||||||
|
`export interface ${ast.name}Props {\n${effectiveProps
|
||||||
|
.map(
|
||||||
|
(prop) =>
|
||||||
|
` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
|
||||||
|
)
|
||||||
|
.join("\n")}\n}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any {
|
||||||
if (v === undefined || v === null) {
|
if (v === undefined || v === null) {
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof def === "number") {
|
if (declared === "number" || typeof def === "number") {
|
||||||
return Number(v);
|
const parsed = Number(v);
|
||||||
|
if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop");
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof def === "boolean") {
|
if (declared === "boolean" || typeof def === "boolean") {
|
||||||
return v === true || v === "" || v === "true";
|
if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true;
|
||||||
|
if (v === false || v === "false" || v === 0 || v === "0") return false;
|
||||||
|
throw new TypeError("Expected a boolean prop");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(def)) {
|
if (declared === "array" || Array.isArray(def)) {
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -1393,6 +1440,7 @@ function generateComponent(ast: PageAst): string {
|
|||||||
const parsed = JSON.parse(v);
|
const parsed = JSON.parse(v);
|
||||||
return Array.isArray(parsed) ? parsed : def;
|
return Array.isArray(parsed) ? parsed : def;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (declared === "array") throw new TypeError("Expected an array prop");
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1400,7 +1448,7 @@ function generateComponent(ast: PageAst): string {
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (def !== null && typeof def === "object") {
|
if (declared === "object" || (def !== null && typeof def === "object")) {
|
||||||
if (
|
if (
|
||||||
v !== null &&
|
v !== null &&
|
||||||
typeof v === "object" &&
|
typeof v === "object" &&
|
||||||
@@ -1421,6 +1469,7 @@ function generateComponent(ast: PageAst): string {
|
|||||||
? parsed
|
? parsed
|
||||||
: def;
|
: def;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (declared === "object") throw new TypeError("Expected an object prop");
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1428,7 +1477,11 @@ function generateComponent(ast: PageAst): string {
|
|||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(v);
|
if (declared === "bigint") return BigInt(v);
|
||||||
|
if (declared === "function" && typeof v !== "function") {
|
||||||
|
throw new TypeError("Expected a function prop");
|
||||||
|
}
|
||||||
|
return declared === "unknown" && def === undefined ? v : String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wireHtml(v: any): string {
|
function __wireHtml(v: any): string {
|
||||||
@@ -1532,7 +1585,7 @@ function __wireRaw(v: any): string {
|
|||||||
const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : "";
|
const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : "";
|
||||||
|
|
||||||
out.push(
|
out.push(
|
||||||
`export function render(props: Record<string, any> = {}): string {\n` +
|
`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"}): string {\n` +
|
||||||
` const __p = props || {};\n` +
|
` const __p = props || {};\n` +
|
||||||
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
|
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
|
||||||
serverFunctionSource +
|
serverFunctionSource +
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ export { parse, ParseError } from "./parser.ts";
|
|||||||
export { generate } from "./codegen.ts";
|
export { generate } from "./codegen.ts";
|
||||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||||
export { Lexer, LexError } from "./tokenizer.ts";
|
export { Lexer, LexError } from "./tokenizer.ts";
|
||||||
|
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "./types.ts";
|
||||||
export type {
|
export type {
|
||||||
PageAst,
|
PageAst,
|
||||||
SeoBlock,
|
SeoBlock,
|
||||||
ViewNode,
|
ViewNode,
|
||||||
Attr,
|
Attr,
|
||||||
StateDecl,
|
StateDecl,
|
||||||
|
PropDecl,
|
||||||
ApiBlock,
|
ApiBlock,
|
||||||
DataApiBlock,
|
DataApiBlock,
|
||||||
DataMode,
|
DataMode,
|
||||||
|
|||||||
@@ -225,9 +225,13 @@ export function generateNative(ast: PageAst): string {
|
|||||||
const hooks = ast.states
|
const hooks = ast.states
|
||||||
.map((state) => {
|
.map((state) => {
|
||||||
const cap = state.name[0]!.toUpperCase() + state.name.slice(1);
|
const cap = state.name[0]!.toUpperCase() + state.name.slice(1);
|
||||||
return ` const [${state.name}, set${cap}] = useState(${state.expr});`;
|
return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const body = ast.view.map((node) => renderNode(node, states)).join("");
|
const body = ast.view.map((node) => renderNode(node, states)).join("");
|
||||||
return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
|
const typeSource = ast.types
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n\n${typeSource}\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* Grammar (subset of the vision, but real):
|
* Grammar (subset of the vision, but real):
|
||||||
*
|
*
|
||||||
* page <Name> {
|
* page <Name> {
|
||||||
* state <ident> = <expr> // zero or more
|
* 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)
|
* view { <html> } // plain HTML (see parseHtmlView)
|
||||||
* seo { title = "Home" description = "..." }
|
* seo { title = "Home" description = "..." }
|
||||||
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
||||||
@@ -21,9 +23,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Lexer, LexError, type Token } from "./tokenizer.ts";
|
import { Lexer, LexError, type Token } from "./tokenizer.ts";
|
||||||
|
import { validateTypedInitializer } from "./types.ts";
|
||||||
|
|
||||||
export interface StateDecl {
|
export interface StateDecl {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Explicit TypeScript-style type annotation, when supplied. */
|
||||||
|
valueType?: string;
|
||||||
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
||||||
expr: string;
|
expr: string;
|
||||||
}
|
}
|
||||||
@@ -131,6 +136,10 @@ export interface RealtimeBlock {
|
|||||||
|
|
||||||
export interface PropDecl {
|
export interface PropDecl {
|
||||||
name: string;
|
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. */
|
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
|
||||||
default: string;
|
default: string;
|
||||||
}
|
}
|
||||||
@@ -147,6 +156,8 @@ export interface PageAst {
|
|||||||
layout?: string;
|
layout?: string;
|
||||||
/** Declared component props (empty for pages). */
|
/** Declared component props (empty for pages). */
|
||||||
props: PropDecl[];
|
props: PropDecl[];
|
||||||
|
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
|
||||||
|
types: string[];
|
||||||
states: StateDecl[];
|
states: StateDecl[];
|
||||||
seo: SeoBlock;
|
seo: SeoBlock;
|
||||||
view: ViewNode[];
|
view: ViewNode[];
|
||||||
@@ -218,6 +229,7 @@ export function parse(source: string): PageAst {
|
|||||||
|
|
||||||
let layout: string | undefined;
|
let layout: string | undefined;
|
||||||
const props: PropDecl[] = [];
|
const props: PropDecl[] = [];
|
||||||
|
const types: string[] = [];
|
||||||
const states: StateDecl[] = [];
|
const states: StateDecl[] = [];
|
||||||
const seo: SeoBlock = {};
|
const seo: SeoBlock = {};
|
||||||
const view: ViewNode[] = [];
|
const view: ViewNode[] = [];
|
||||||
@@ -245,7 +257,7 @@ export function parse(source: string): PageAst {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "props": {
|
case "props": {
|
||||||
// props { name = <default> ... } — one declaration per line.
|
// props { name: Type = <default> } — omit the default for required props.
|
||||||
lx.next();
|
lx.next();
|
||||||
expect("lbrace");
|
expect("lbrace");
|
||||||
while (lx.peek().type !== "rbrace") {
|
while (lx.peek().type !== "rbrace") {
|
||||||
@@ -255,8 +267,19 @@ export function parse(source: string): PageAst {
|
|||||||
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
||||||
}
|
}
|
||||||
const pName = expect("ident").value;
|
const pName = expect("ident").value;
|
||||||
expect("eq");
|
let valueType: string | undefined;
|
||||||
props.push({ name: pName, default: lx.readToLineEnd() });
|
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.readToLineEnd() : "undefined";
|
||||||
|
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||||
}
|
}
|
||||||
expect("rbrace");
|
expect("rbrace");
|
||||||
break;
|
break;
|
||||||
@@ -264,8 +287,23 @@ export function parse(source: string): PageAst {
|
|||||||
case "state": {
|
case "state": {
|
||||||
lx.next();
|
lx.next();
|
||||||
const sName = expect("ident").value;
|
const sName = expect("ident").value;
|
||||||
expect("eq");
|
let valueType: string | undefined;
|
||||||
states.push({ name: sName, expr: lx.readToLineEnd() });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "view": {
|
case "view": {
|
||||||
@@ -421,12 +459,26 @@ export function parse(source: string): PageAst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
type: "page",
|
type: "page",
|
||||||
kind,
|
kind,
|
||||||
name,
|
name,
|
||||||
layout,
|
layout,
|
||||||
props,
|
props,
|
||||||
|
types,
|
||||||
states,
|
states,
|
||||||
seo,
|
seo,
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -9,7 +9,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type TokenType =
|
export type TokenType =
|
||||||
"ident" | "string" | "lbrace" | "rbrace" | "lparen" | "rparen" | "at" | "eq" | "comma" | "eof";
|
| "ident"
|
||||||
|
| "string"
|
||||||
|
| "lbrace"
|
||||||
|
| "rbrace"
|
||||||
|
| "lparen"
|
||||||
|
| "rparen"
|
||||||
|
| "at"
|
||||||
|
| "eq"
|
||||||
|
| "colon"
|
||||||
|
| "comma"
|
||||||
|
| "eof";
|
||||||
|
|
||||||
export interface Token {
|
export interface Token {
|
||||||
type: TokenType;
|
type: TokenType;
|
||||||
@@ -71,6 +81,9 @@ export class Lexer {
|
|||||||
case "=":
|
case "=":
|
||||||
this.pos++;
|
this.pos++;
|
||||||
return { type: "eq", value: c, pos };
|
return { type: "eq", value: c, pos };
|
||||||
|
case ":":
|
||||||
|
this.pos++;
|
||||||
|
return { type: "colon", value: c, pos };
|
||||||
case ",":
|
case ",":
|
||||||
this.pos++;
|
this.pos++;
|
||||||
return { type: "comma", value: c, pos };
|
return { type: "comma", value: c, pos };
|
||||||
@@ -133,6 +146,62 @@ export class Lexer {
|
|||||||
return v.trim();
|
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
|
* Read a `{ ... }` block and return its INNER text (no outer braces), with
|
||||||
* brace counting that respects string and template literals so a `}` inside a
|
* brace counting that respects string and template literals so a `}` inside a
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/** 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}`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,80 @@ test("parses a component with props and state", () => {
|
|||||||
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
|
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("typed props, required props, state, custom types, and function parameters compile", () => {
|
||||||
|
const source = `component TypedPicker {
|
||||||
|
types {
|
||||||
|
interface DateOption { value: string; label: string }
|
||||||
|
type ChangeHandler = (value: string) => void
|
||||||
|
}
|
||||||
|
props {
|
||||||
|
value: string = ""
|
||||||
|
options: DateOption[] = []
|
||||||
|
required: boolean = false
|
||||||
|
onChange: ChangeHandler
|
||||||
|
}
|
||||||
|
state open: boolean = false
|
||||||
|
functions {
|
||||||
|
function choose(option: DateOption, index: number): void {
|
||||||
|
open = false
|
||||||
|
onChange(option.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view { <button @click="choose(options[0], 0)">{value}</button> }
|
||||||
|
}`;
|
||||||
|
const ast = parse(source);
|
||||||
|
expect(ast.props.map(({ name, valueType, required }) => ({ name, valueType, required }))).toEqual(
|
||||||
|
[
|
||||||
|
{ name: "value", valueType: "string", required: false },
|
||||||
|
{ name: "options", valueType: "DateOption[]", required: false },
|
||||||
|
{ name: "required", valueType: "boolean", required: false },
|
||||||
|
{ name: "onChange", valueType: "ChangeHandler", required: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(ast.states[0]).toMatchObject({ name: "open", valueType: "boolean", expr: "false" });
|
||||||
|
const output = generate(ast);
|
||||||
|
expect(output).toContain("interface DateOption");
|
||||||
|
expect(output).toContain("export interface TypedPickerProps");
|
||||||
|
expect(output).toContain('"onChange": ChangeHandler;');
|
||||||
|
expect(output).toContain("let open: boolean = (false)");
|
||||||
|
expect(output).toContain("function choose(option: DateOption, index: number): void");
|
||||||
|
const behavior = extractCompiledBehavior(output);
|
||||||
|
expect(behavior.functions).toContain("function choose(option, index)");
|
||||||
|
expect(behavior.functions).not.toContain("option: DateOption");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("typed declarations reject obvious initializer mismatches", () => {
|
||||||
|
expect(() =>
|
||||||
|
parse(`component Invalid {
|
||||||
|
props {
|
||||||
|
required: boolean = "yes"
|
||||||
|
}
|
||||||
|
view { <div></div> }
|
||||||
|
}`),
|
||||||
|
).toThrow("Prop 'required' is declared as boolean, but its initializer is string");
|
||||||
|
expect(() => parse(`page Invalid { state count: number = false\n view { <p></p> } }`)).toThrow(
|
||||||
|
"State 'count' is declared as number, but its initializer is boolean",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("typed props enforce required values and runtime-compatible input", async () => {
|
||||||
|
const component = await compileAndImport(`component TypedInput {
|
||||||
|
props {
|
||||||
|
label: string
|
||||||
|
count: number = 0
|
||||||
|
enabled: boolean = false
|
||||||
|
}
|
||||||
|
view { <span>{label}:{count}:{enabled}</span> }
|
||||||
|
}`);
|
||||||
|
const render = component.render as (props?: Record<string, unknown>) => string;
|
||||||
|
expect(() => render()).toThrow("TypedInput requires prop 'label' (string)");
|
||||||
|
expect(render({ label: "Total", count: "4", enabled: "true" })).toContain(
|
||||||
|
"<span>Total:4:true</span>",
|
||||||
|
);
|
||||||
|
expect(() => render({ label: "Total", count: "many" })).toThrow("Expected a finite number prop");
|
||||||
|
expect(() => render({ label: "Total", enabled: "sometimes" })).toThrow("Expected a boolean prop");
|
||||||
|
});
|
||||||
|
|
||||||
test("HTML view: void elements, boolean attrs, comments, lone <", () => {
|
test("HTML view: void elements, boolean attrs, comments, lone <", () => {
|
||||||
const ast = parse(
|
const ast = parse(
|
||||||
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
|
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/core",
|
"name": "@wrnexus/core",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/encryption",
|
"name": "@wrnexus/encryption",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/helpers",
|
"name": "@wrnexus/helpers",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/jwt",
|
"name": "@wrnexus/jwt",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/mobile",
|
"name": "@wrnexus/mobile",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/native",
|
"name": "@wrnexus/native",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/oauth",
|
"name": "@wrnexus/oauth",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/pubsub",
|
"name": "@wrnexus/pubsub",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/queue",
|
"name": "@wrnexus/queue",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/reactive",
|
"name": "@wrnexus/reactive",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/router",
|
"name": "@wrnexus/router",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ssr",
|
"name": "@wrnexus/ssr",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/styles",
|
"name": "@wrnexus/styles",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/test",
|
"name": "@wrnexus/test",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/tracking",
|
"name": "@wrnexus/tracking",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
component DateInput {
|
component DateInput {
|
||||||
props {
|
props {
|
||||||
id = ""
|
id: string = ""
|
||||||
name = ""
|
name: string = ""
|
||||||
label = "Date"
|
label: string = "Date"
|
||||||
value = ""
|
value: string = ""
|
||||||
placeholder = ""
|
placeholder: string = ""
|
||||||
help = ""
|
help: string = ""
|
||||||
error = ""
|
error: string = ""
|
||||||
required = false
|
required: boolean = false
|
||||||
disabled = false
|
disabled: boolean = false
|
||||||
readonly = false
|
readonly: boolean = false
|
||||||
autocomplete = ""
|
autocomplete: string = ""
|
||||||
}
|
}
|
||||||
view {
|
view {
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
component DatePicker {
|
component DatePicker {
|
||||||
props {
|
props {
|
||||||
id = "date"
|
id: string = "date"
|
||||||
name = "date"
|
name: string = "date"
|
||||||
label = "Date"
|
label: string = "Date"
|
||||||
value = ""
|
value: string = ""
|
||||||
min = ""
|
min: string = ""
|
||||||
max = ""
|
max: string = ""
|
||||||
required = false
|
required: boolean = false
|
||||||
}
|
}
|
||||||
view {
|
view {
|
||||||
<DateInput id="{id}" name="{name}" label="{label}" value="{value}" required="{required}" />
|
<DateInput id="{id}" name="{name}" label="{label}" value="{value}" required="{required}" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/ui",
|
"name": "@wrnexus/ui",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/uploader",
|
"name": "@wrnexus/uploader",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/validation",
|
"name": "@wrnexus/validation",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -21,16 +21,19 @@ function block(source, keyword) {
|
|||||||
function propsOf(source) {
|
function propsOf(source) {
|
||||||
const props = [];
|
const props = [];
|
||||||
for (const line of block(source, "props").split(/\r?\n/)) {
|
for (const line of block(source, "props").split(/\r?\n/)) {
|
||||||
const match = line.trim().match(/^([A-Za-z][A-Za-z0-9_]*)\s*(?:=\s*(.+))?$/);
|
const match = line
|
||||||
|
.trim()
|
||||||
|
.match(/^([A-Za-z][A-Za-z0-9_]*)(?:\s*:\s*([^=]+?))?\s*(?:=\s*(.+))?$/);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const [, name, rawDefault] = match;
|
const [, name, annotation, rawDefault] = match;
|
||||||
const value = rawDefault?.trim();
|
const value = rawDefault?.trim();
|
||||||
const type =
|
const type =
|
||||||
value === "true" || value === "false"
|
annotation?.trim() ||
|
||||||
|
(value === "true" || value === "false"
|
||||||
? "boolean"
|
? "boolean"
|
||||||
: /^-?\d+(?:\.\d+)?$/.test(value ?? "")
|
: /^-?\d+(?:\.\d+)?$/.test(value ?? "")
|
||||||
? "number"
|
? "number"
|
||||||
: "string";
|
: "string");
|
||||||
props.push({ name, type, required: value === undefined, default: value ?? null });
|
props.push({ name, type, required: value === undefined, default: value ?? null });
|
||||||
}
|
}
|
||||||
return props;
|
return props;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const version = "0.2.58";
|
const version = "0.2.59";
|
||||||
|
|
||||||
const dependencyGroups = [
|
const dependencyGroups = [
|
||||||
"dependencies",
|
"dependencies",
|
||||||
|
|||||||
Reference in New Issue
Block a user