feat: add typed WRN declarations

This commit is contained in:
2026-07-19 18:44:27 +05:30
parent 0d3ec79ee4
commit 94ae40d8bd
81 changed files with 942 additions and 241 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+13 -13
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+3 -3
View File
@@ -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"
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+11 -11
View File
@@ -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">
+7 -7
View File
@@ -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}" />
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 **serveronly** 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
+6
View File
@@ -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,
+23
View File
@@ -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:
+1 -1
View File
@@ -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*$"
} }
} }
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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}
+34 -9
View File
@@ -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,
+46 -13
View File
@@ -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,
+12 -2
View File
@@ -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(
+29 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+9
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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": {
+70 -17
View File
@@ -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 +
+2
View File
@@ -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,
+6 -2
View File
@@ -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`;
} }
+58 -6
View File
@@ -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,
+70 -1
View File
@@ -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
+63
View File
@@ -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}`;
},
);
}
+74
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+11 -11
View File
@@ -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">
+7 -7
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+7 -4
View File
@@ -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 -1
View File
@@ -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",