diff --git a/.publish/ai/package.json b/.publish/ai/package.json index d7f6002d..19157bdd 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index 7f532aa3..7833f371 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index bb0e186b..d069c03b 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", @@ -28,16 +28,16 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.2.58", - "@wrnexus/router": "^0.2.58", - "@wrnexus/csr": "^0.2.58", - "@wrnexus/compiler": "^0.2.58", - "@wrnexus/styles": "^0.2.58", - "@wrnexus/dev-server": "^0.2.58", - "@wrnexus/ui": "^0.2.58", - "@wrnexus/validation": "^0.2.58", - "@wrnexus/i18n": "^0.2.58", - "@wrnexus/db": "^0.2.58" + "@wrnexus/core": "^0.2.59", + "@wrnexus/router": "^0.2.59", + "@wrnexus/csr": "^0.2.59", + "@wrnexus/compiler": "^0.2.59", + "@wrnexus/styles": "^0.2.59", + "@wrnexus/dev-server": "^0.2.59", + "@wrnexus/ui": "^0.2.59", + "@wrnexus/validation": "^0.2.59", + "@wrnexus/i18n": "^0.2.59", + "@wrnexus/db": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/compiler/README.md b/.publish/compiler/README.md index e997cf08..d543e5b9 100644 --- a/.publish/compiler/README.md +++ b/.publish/compiler/README.md @@ -76,18 +76,19 @@ class Lexer { Exported type-only symbols describing the parsed tree: -| Type | Description | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. | -| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. | -| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. | -| `StateDecl` | `{ name; expr }` — a `state x = ` declaration. | -| `SeoBlock` | `Record` from the `seo { ... }` block. | -| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. | -| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. | -| `DataMode` | `"ssr" \| "client"`. | -| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. | -| `RealtimeBlock` | `{ name; handlers }` — a `realtime { on evt(args) { ... } }` block. | +| Type | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `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 }`. | +| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. | +| `StateDecl` | `{ name; valueType?; expr }` — a typed `state x: Type = ` declaration. | +| `PropDecl` | `{ name; valueType?; required; default }` — a typed prop declaration. | +| `SeoBlock` | `Record` from the `seo { ... }` block. | +| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. | +| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. | +| `DataMode` | `"ssr" \| "client"`. | +| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. | +| `RealtimeBlock` | `{ name; handlers }` — a `realtime { on evt(args) { ... } }` block. | ## Usage @@ -147,12 +148,13 @@ lx.next(); // { type: "lbrace", value: "{", pos: 10 } A file opens with `page ` or `component ` followed by a `{ ... }` body containing zero or more members: - `layout = ""` — selects `app/layouts/.wrn` (pages only). -- `props { name = ... }` — component props; each default's type drives coercion. -- `state = ` — reactive state seeded from a raw JS expression. +- `types { }` — reusable interfaces and aliases for the current file. +- `props { name: Type = ... }` — typed component props. Omit `= ` to make a prop required. Legacy inferred props remain supported. +- `state : Type = ` — typed reactive state seeded from a raw JS expression. The annotation is optional for backward compatibility. - `view { }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and ``. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser. - `seo { key = "value" ... }` — metadata merged into the generated `meta`. - `style { }` — inlined page/component stylesheet (repeatable). -- `functions { }` — shared server-side helpers (repeatable). +- `functions { }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code. - `api { }` — route handler, lowered to a `METHOD` export (repeatable). - `ssr { ... }` / `client { ... }` — data blocks holding `api { ... }` bindings and their own `functions { ... }`. - `realtime { on () { } ... }` — websocket handlers, lowered to a `websocket` export. diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 9407087f..a29066a7 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/core/package.json b/.publish/core/package.json index 80c33fb0..c98b208a 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 42825a8d..3314b067 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.58" + "@wrnexus/core": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index 9e66921d..ef8908d8 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/db — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index d73f7c46..1333775f 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,18 +25,18 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.58", - "@wrnexus/router": "^0.2.58", - "@wrnexus/ssr": "^0.2.58", - "@wrnexus/csr": "^0.2.58", - "@wrnexus/compiler": "^0.2.58", - "@wrnexus/styles": "^0.2.58", - "@wrnexus/ui": "^0.2.58", - "@wrnexus/validation": "^0.2.58", - "@wrnexus/i18n": "^0.2.58", - "@wrnexus/db": "^0.2.58", - "@wrnexus/pubsub": "^0.2.58", - "@wrnexus/uploader": "^0.2.58" + "@wrnexus/core": "^0.2.59", + "@wrnexus/router": "^0.2.59", + "@wrnexus/ssr": "^0.2.59", + "@wrnexus/csr": "^0.2.59", + "@wrnexus/compiler": "^0.2.59", + "@wrnexus/styles": "^0.2.59", + "@wrnexus/ui": "^0.2.59", + "@wrnexus/validation": "^0.2.59", + "@wrnexus/i18n": "^0.2.59", + "@wrnexus/db": "^0.2.59", + "@wrnexus/pubsub": "^0.2.59", + "@wrnexus/uploader": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index edbff800..36456300 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 84f5b138..f5d99969 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.58" + "@wrnexus/core": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 40173ec1..dc64a41e 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.58" + "@wrnexus/core": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index af1e5804..69d76a92 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/jwt — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index d2e08c4e..90d3babb 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.58" + "@wrnexus/native": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index 44abaf08..42b646f6 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 7e92b200..f67b5dee 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index d9754057..2a4a2c46 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index b9250b1e..7a7e4622 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index ae41eaa1..7203aee2 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/router/package.json b/.publish/router/package.json index 0f7278f8..9fa94846 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.58", - "@wrnexus/core": "^0.2.58" + "@wrnexus/compiler": "^0.2.59", + "@wrnexus/core": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 5642870a..7a9dd12b 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.58" + "@wrnexus/core": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 7a029464..4f92465c 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.58" + "@wrnexus/uploader": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/test/package.json b/.publish/test/package.json index 99a12af5..4c0bf295 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index 8305c282..37d1e55c 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/components/DateInput.wrn b/.publish/ui/components/DateInput.wrn index 30dfdcb7..cf3d5acd 100644 --- a/.publish/ui/components/DateInput.wrn +++ b/.publish/ui/components/DateInput.wrn @@ -1,16 +1,16 @@ component DateInput { props { - id = "" - name = "" - label = "Date" - value = "" - placeholder = "" - help = "" - error = "" - required = false - disabled = false - readonly = false - autocomplete = "" + id: string = "" + name: string = "" + label: string = "Date" + value: string = "" + placeholder: string = "" + help: string = "" + error: string = "" + required: boolean = false + disabled: boolean = false + readonly: boolean = false + autocomplete: string = "" } view {
diff --git a/.publish/ui/components/DatePicker.wrn b/.publish/ui/components/DatePicker.wrn index cc21325b..dc13ae55 100644 --- a/.publish/ui/components/DatePicker.wrn +++ b/.publish/ui/components/DatePicker.wrn @@ -1,12 +1,12 @@ component DatePicker { props { - id = "date" - name = "date" - label = "Date" - value = "" - min = "" - max = "" - required = false + id: string = "date" + name: string = "date" + label: string = "Date" + value: string = "" + min: string = "" + max: string = "" + required: boolean = false } view { diff --git a/.publish/ui/package.json b/.publish/ui/package.json index f247242c..f6a35840 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -25,7 +25,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.2.58" + "@wrnexus/core": "^0.2.59" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 20647421..5cfa38a6 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.58" + "@wrnexus/core": "^0.2.59" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 25e95bf6..9ca069fb 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0cc0672c..7086f555 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -151,17 +151,32 @@ layout = "public" ``` props { - start = 0 // default's TYPE drives coercion: start="5" arrives as number 5 - label = "Count" // string - disabled = false // boolean + title: string // required: no default + start: number = 0 // start="5" arrives as number 5 + disabled: boolean = false + items: Array = [] } ``` +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 count = start // may reference a prop or earlier state -state total = 0 +state count: number = start // may reference a prop or earlier state +state status: Status = "idle" ``` 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 change. -### `functions { }` — shared **server‑only** helpers +### `functions { }` — typed helpers ``` 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 { }` — colocated API route diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 0b7b4d83..bf6ec7af 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 - Added component-aware prop completions, hover tables, required-prop checks, diff --git a/editors/vscode/README.md b/editors/vscode/README.md index f649b511..bf919025 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -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. +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 {
{user.name}
} +} +``` + +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 Search for **WRNexus Language Support** in the VS Code Extensions view or run: diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json index db8587a8..cd0c93a9 100644 --- a/editors/vscode/language-configuration.json +++ b/editors/vscode/language-configuration.json @@ -47,7 +47,7 @@ "folding": { "offSide": false, "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*$" } } diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index b2070c71..95e3cd11 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "wrnexus", - "version": "0.2.12", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrnexus", - "version": "0.2.12", + "version": "0.2.13", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@vscode/vsce": "^3.9.2" diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 4d9fdf09..54f146e5 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "wrnexus", "displayName": "WRNexus Language Support", "description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.", - "version": "0.2.12", + "version": "0.2.13", "publisher": "wrnexus", "private": true, "license": "SEE LICENSE IN LICENSE", diff --git a/editors/vscode/snippets/wrn.json b/editors/vscode/snippets/wrn.json index cd12f832..c3720a0d 100644 --- a/editors/vscode/snippets/wrn.json +++ b/editors/vscode/snippets/wrn.json @@ -109,7 +109,7 @@ "WRN state": { "prefix": ["wrn-state", "state"], "description": "Create reactive state", - "body": ["state ${1:name} = ${2:\"value\"}"] + "body": ["state ${1:name}: ${2:string} = ${3:\"value\"}"] }, "WRN functions block": { diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index b1c90383..ea2e3309 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -39,9 +39,12 @@ var __export = (target, all) => { // ../../packages/compiler/src/index.ts var exports_src = {}; __export(exports_src, { + runtimeTypeOf: () => runtimeTypeOf, parse: () => parse, + inferredRuntimeType: () => inferredRuntimeType, generateNative: () => generateNative, generate: () => generate, + eraseFunctionTypes: () => eraseFunctionTypes, compileWireFile: () => compileWireFile, compileNativeWireFile: () => compileNativeWireFile, compile: () => compile, @@ -109,6 +112,9 @@ class Lexer { case "=": this.pos++; return { type: "eq", value: c, pos }; + case ":": + this.pos++; + return { type: "colon", value: c, pos }; case ",": this.pos++; return { type: "comma", value: c, pos }; @@ -167,6 +173,67 @@ class Lexer { v += src[this.pos++]; 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() { this.skipTrivia(); 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 var VOID_ELEMENTS = new Set([ "area", @@ -282,6 +405,7 @@ function parse(source) { expect("lbrace"); let layout; const props = []; + const types = []; const states = []; const seo = {}; const view = []; @@ -318,8 +442,19 @@ function parse(source) { throw new ParseError(`Expected a prop name at offset ${t.pos}`); } const pName = expect("ident").value; - expect("eq"); - props.push({ name: pName, default: lx.readToLineEnd() }); + let valueType; + let hasDefault = false; + if (lx.peek().type === "colon") { + lx.next(); + const annotation = lx.readTypeAnnotation(); + valueType = annotation.type; + hasDefault = annotation.hasDefault; + } else { + expect("eq"); + hasDefault = true; + } + const defaultValue = hasDefault ? lx.readToLineEnd() : "undefined"; + props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue }); } expect("rbrace"); break; @@ -327,8 +462,23 @@ function parse(source) { case "state": { lx.next(); const sName = expect("ident").value; - expect("eq"); - states.push({ name: sName, expr: lx.readToLineEnd() }); + let valueType; + if (lx.peek().type === "colon") { + lx.next(); + const annotation = lx.readTypeAnnotation(); + valueType = annotation.type; + if (!annotation.hasDefault) { + throw new ParseError(`State '${sName}' requires an initializer`); + } + } else { + expect("eq"); + } + states.push({ name: sName, valueType, expr: lx.readToLineEnd() }); + break; + } + case "types": { + lx.next(); + types.push(lx.readBalancedBraces()); break; } case "view": { @@ -465,12 +615,23 @@ function parse(source) { throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`); } } + for (const prop of props) { + const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default); + if (problem) + throw new ParseError(problem); + } + for (const state of states) { + const problem = validateTypedInitializer(`State '${state.name}'`, state.valueType, state.expr); + if (problem) + throw new ParseError(problem); + } return { type: "page", kind, name, layout, props, + types, states, seo, view, @@ -1149,6 +1310,11 @@ function generate(ast) { `); const apiBindings = apiBindingMap(ast, helpers); + const typeSource = ast.types.map((body2) => body2.trim()).filter(Boolean).join(` + +`); + if (typeSource) + out.push(typeSource); if (helpers) { out.push(`// --- .wrn functions --- ${helpers}`); @@ -1177,6 +1343,7 @@ ${css} } let body = templateEscape(html); const dynamicStateScope = ast.states.map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`).join(", "); + const stateType = ast.states.length > 0 ? `{ ${ast.states.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`).join("; ")} }` : "Record"; loops.forEach((code, idx) => { body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); }); @@ -1200,7 +1367,7 @@ ${css} ` : ""; out.push(`export default async function ${ast.name}(ctx: any) { ${decls} - const __state = { ${dynamicStateScope} }; + const __state: ${stateType} = { ${dynamicStateScope} }; const __scopeValue = Object.entries(__state) .map(([key, value]) => { @@ -1226,7 +1393,7 @@ ${css} }`); } else { out.push(`export default function ${ast.name}(ctx: any) { - const __state = { ${dynamicStateScope} }; + const __state: ${stateType} = { ${dynamicStateScope} }; const __scopeValue = Object.entries(__state) .map(([key, value]) => { @@ -1346,9 +1513,9 @@ function escLit(s) { return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); } 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 = { ...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.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") ? [ { name: "content", - default: '""' + default: '""', + valueType: "string", + required: false }, ...ast.props ] : ast.props; @@ -1600,10 +1769,13 @@ ${styles.map(styleEscape).join(` const behaviorAttr = behaviorAttribute(behavior); const decls = []; 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) { - 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 + `
` + viewCode + "
`" : "`" + styleTag + viewCode + "`"; 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) { 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) { return def; } - if (typeof def === "number") { - return Number(v); + if (declared === "number" || typeof def === "number") { + const parsed = Number(v); + if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop"); + return parsed; } - if (typeof def === "boolean") { - return v === true || v === "" || v === "true"; + if (declared === "boolean" || typeof def === "boolean") { + 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)) { return v; } @@ -1640,6 +1827,7 @@ ${styles.map(styleEscape).join(` const parsed = JSON.parse(v); return Array.isArray(parsed) ? parsed : def; } catch { + if (declared === "array") throw new TypeError("Expected an array prop"); return def; } } @@ -1647,7 +1835,7 @@ ${styles.map(styleEscape).join(` return def; } - if (def !== null && typeof def === "object") { + if (declared === "object" || (def !== null && typeof def === "object")) { if ( v !== null && typeof v === "object" && @@ -1668,6 +1856,7 @@ ${styles.map(styleEscape).join(` ? parsed : def; } catch { + if (declared === "object") throw new TypeError("Expected an object prop"); return def; } } @@ -1675,7 +1864,11 @@ ${styles.map(styleEscape).join(` 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 { @@ -1775,7 +1968,7 @@ function __wireRaw(v: any): string { } const serverFunctionSource = serverFunctions ? `${serverFunctions} ` : ""; - out.push(`export function render(props: Record = {}): string { + out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string { ` + ` const __p = props || {}; ` + (decls.length > 0 ? decls.join(` `) + ` @@ -1988,15 +2181,20 @@ function generateNative(ast) { const states = new Set(ast.states.map((state) => state.name)); const hooks = ast.states.map((state) => { const cap = state.name[0].toUpperCase() + state.name.slice(1); - return ` const [${state.name}, set${cap}] = useState(${state.expr});`; + return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`; }).join(` `); const body = ast.view.map((node) => renderNode2(node, states)).join(""); + const typeSource = ast.types.map((block) => block.trim()).filter(Boolean).join(` + +`); return `// generated from .wrn for Expo/React Native import React, { useState } from "react"; import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; import { useRouter } from "expo-router"; +${typeSource} + export default function ${ast.name}() { const router = useRouter(); ${hooks} diff --git a/editors/vscode/src/completion.js b/editors/vscode/src/completion.js index f6995982..44617a62 100644 --- a/editors/vscode/src/completion.js +++ b/editors/vscode/src/completion.js @@ -58,11 +58,23 @@ const BLOCK_COMPLETIONS = [ "}", ].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", detail: "Component or layout props block", 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", @@ -85,22 +97,28 @@ const BLOCK_COMPLETIONS = [ label: "state", detail: "Reactive state declaration", 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", detail: "Browser component functions block", documentation: "Declare functions that can be called from events, lifecycle hooks, and watchers.", - snippet: ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"].join( - "\n", - ), + snippet: [ + "functions {", + " function ${1:handler}(${2:value}: ${3:unknown}): ${4:void} {", + " $0", + " }", + "}", + ].join("\n"), }, { label: "function", detail: "WRN component function", 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", @@ -278,7 +296,7 @@ function extractRouteParams(document) { function extractStates(document) { 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]))]; } @@ -293,7 +311,9 @@ function extractProps(document) { while ((blockMatch = propsBlockPattern.exec(source)) !== null) { 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]); } } @@ -323,7 +343,12 @@ function extractFunctions(document) { .split(",") .map((parameter) => parameter.trim()) .filter(Boolean) - .map((parameter) => parameter.replace(/=.*$/, "").trim()); + .map((parameter) => + parameter + .replace(/=.*$/, "") + .replace(/\??\s*:\s*[\s\S]+$/, "") + .trim(), + ); functions.push({ name, diff --git a/editors/vscode/src/component-metadata.js b/editors/vscode/src/component-metadata.js index 7fae5a0a..6509dd04 100644 --- a/editors/vscode/src/component-metadata.js +++ b/editors/vscode/src/component-metadata.js @@ -35,6 +35,20 @@ function inferType(defaultValue) { 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) { const match = /^(?:"([\s\S]*)"|'([\s\S]*)'|`([\s\S]*)`)$/.exec(value.trim()); return match ? (match[1] ?? match[2] ?? match[3]) : null; @@ -61,16 +75,22 @@ function parseComponentMetadata(source, uri = null) { const closingBrace = findMatchingBrace(source, openingBrace); const bodyEnd = closingBrace === -1 ? source.length : closingBrace; 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; 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]; props.push({ name, defaultValue, - required: defaultValue.trim() === "undefined" || /^\s*\/\/\s*@required/m.test(propMatch[0]), - type: inferType(defaultValue), + required: + !hasDefault || + defaultValue.trim() === "undefined" || + /^\s*\/\/\s*@required/m.test(propMatch[0]), + type: annotation || inferType(defaultValue), options: inferOptions(source, name), }); } @@ -85,8 +105,11 @@ function unwrapAttributeValue(value) { return trimmed; } -function attributeValueType(value) { +function attributeValueType(value, symbols = new Map()) { 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 (/^-?(?:\d+\.?\d*|\.\d+)$/.test(unwrapped)) return "number"; if (unwrapped.startsWith("[")) return "array"; @@ -94,12 +117,13 @@ function attributeValueType(value) { return "string"; } -function isTypeCompatible(prop, value) { - if (prop.type === "unknown" || prop.type === "null") return true; - const actual = attributeValueType(value); - if (prop.type === actual) return true; - if (prop.type === "number" && actual === "string") return Number.isFinite(Number(value)); - if (prop.type === "boolean" && actual === "string") +function isTypeCompatible(prop, value, symbols = new Map()) { + const expected = runtimeType(prop.type); + if (expected === "unknown" || expected === "null") return true; + const actual = runtimeType(attributeValueType(value, symbols)); + if (expected === actual) return true; + 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 false; } @@ -137,6 +161,13 @@ function parseComponentTags(source) { function validateComponentTags(source, components) { 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)) { const component = components.get(tag.name); if (!component) continue; @@ -167,11 +198,12 @@ function validateComponentTags(source, components) { }); continue; } - if (!isTypeCompatible(prop, attribute.value)) { + const actualType = attributeValueType(attribute.value, symbols); + if (!isTypeCompatible(prop, attribute.value, symbols)) { diagnostics.push({ severity: "error", 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, end: attribute.nameEnd, }); @@ -194,6 +226,7 @@ function validateComponentTags(source, components) { module.exports = { attributeValueType, inferType, + runtimeType, isTypeCompatible, parseComponentMetadata, parseComponentTags, diff --git a/editors/vscode/src/diagnostics.js b/editors/vscode/src/diagnostics.js index 6647975e..2f05852b 100644 --- a/editors/vscode/src/diagnostics.js +++ b/editors/vscode/src/diagnostics.js @@ -15,6 +15,7 @@ const VALID_MEMBERS = { page: new Set([ "layout", "state", + "types", "view", "seo", "style", @@ -25,9 +26,18 @@ const VALID_MEMBERS = { "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( diff --git a/editors/vscode/syntaxes/wrn.tmLanguage.json b/editors/vscode/syntaxes/wrn.tmLanguage.json index 334c0ea8..3cf9452c 100644 --- a/editors/vscode/syntaxes/wrn.tmLanguage.json +++ b/editors/vscode/syntaxes/wrn.tmLanguage.json @@ -49,6 +49,9 @@ { "include": "#seo-block" }, + { + "include": "#types-block" + }, { "include": "#props-block" }, @@ -93,7 +96,7 @@ } }, "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": { "1": { "name": "keyword.control.wrn" @@ -102,6 +105,12 @@ "name": "variable.other.wrn" }, "3": { + "name": "punctuation.separator.type.wrn" + }, + "4": { + "name": "storage.type.wrn" + }, + "5": { "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": { "begin": "\\b(props)\\b\\s*(\\{)", "beginCaptures": { @@ -134,12 +155,18 @@ "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": { "1": { "name": "variable.parameter.wrn" }, "2": { + "name": "punctuation.separator.type.wrn" + }, + "3": { + "name": "storage.type.wrn" + }, + "4": { "name": "keyword.operator.assignment.wrn" } } diff --git a/editors/vscode/test/component-metadata.test.js b/editors/vscode/test/component-metadata.test.js index 3c729553..15ea5eeb 100644 --- a/editors/vscode/test/component-metadata.test.js +++ b/editors/vscode/test/component-metadata.test.js @@ -80,3 +80,31 @@ test("recognizes literal attribute value types", () => { assert.equal(attributeValueType("{[1, 2]}"), "array"); 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 { } + }`; + const target = parseComponentMetadata(`component DateInput { + props { + required: boolean = false + value: string + count: number = 0 + } + view { } + }`); + 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]])), []); +}); diff --git a/packages/ai/package.json b/packages/ai/package.json index af755df6..5deaf02e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", diff --git a/packages/authz/package.json b/packages/authz/package.json index f63e7dc6..c0c78975 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 070f3a67..db14848e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 1a2755a2..1d379b6c 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -593,6 +593,15 @@ const MIGRATIONS: 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. */ diff --git a/packages/compiler/README.md b/packages/compiler/README.md index e997cf08..d543e5b9 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -76,18 +76,19 @@ class Lexer { Exported type-only symbols describing the parsed tree: -| Type | Description | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. | -| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. | -| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. | -| `StateDecl` | `{ name; expr }` — a `state x = ` declaration. | -| `SeoBlock` | `Record` from the `seo { ... }` block. | -| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. | -| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. | -| `DataMode` | `"ssr" \| "client"`. | -| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. | -| `RealtimeBlock` | `{ name; handlers }` — a `realtime { on evt(args) { ... } }` block. | +| Type | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `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 }`. | +| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. | +| `StateDecl` | `{ name; valueType?; expr }` — a typed `state x: Type = ` declaration. | +| `PropDecl` | `{ name; valueType?; required; default }` — a typed prop declaration. | +| `SeoBlock` | `Record` from the `seo { ... }` block. | +| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. | +| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. | +| `DataMode` | `"ssr" \| "client"`. | +| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. | +| `RealtimeBlock` | `{ name; handlers }` — a `realtime { on evt(args) { ... } }` block. | ## Usage @@ -147,12 +148,13 @@ lx.next(); // { type: "lbrace", value: "{", pos: 10 } A file opens with `page ` or `component ` followed by a `{ ... }` body containing zero or more members: - `layout = ""` — selects `app/layouts/.wrn` (pages only). -- `props { name = ... }` — component props; each default's type drives coercion. -- `state = ` — reactive state seeded from a raw JS expression. +- `types { }` — reusable interfaces and aliases for the current file. +- `props { name: Type = ... }` — typed component props. Omit `= ` to make a prop required. Legacy inferred props remain supported. +- `state : Type = ` — typed reactive state seeded from a raw JS expression. The annotation is optional for backward compatibility. - `view { }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and ``. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser. - `seo { key = "value" ... }` — metadata merged into the generated `meta`. - `style { }` — inlined page/component stylesheet (repeatable). -- `functions { }` — shared server-side helpers (repeatable). +- `functions { }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code. - `api { }` — route handler, lowered to a `METHOD` export (repeatable). - `ssr { ... }` / `client { ... }` — data blocks holding `api { ... }` bindings and their own `functions { ... }`. - `realtime { on () { } ... }` — websocket handlers, lowered to a `websocket` export. diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 75026f0f..c9873c2c 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 1c420f9f..6344acbb 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -18,6 +18,7 @@ import { Buffer } from "node:buffer"; import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts"; +import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts"; interface RenderBinding { method: string; @@ -667,6 +668,12 @@ export function generate(ast: PageAst): string { .join("\n\n"); const apiBindings = apiBindingMap(ast, helpers); + const typeSource = ast.types + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"); + if (typeSource) out.push(typeSource); + if (helpers) { out.push(`// --- .wrn functions ---\n${helpers}`); } @@ -709,6 +716,12 @@ export function generate(ast: PageAst): string { `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`, ) .join(", "); + const stateType = + ast.states.length > 0 + ? `{ ${ast.states + .map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`) + .join("; ")} }` + : "Record"; loops.forEach((code, idx) => { body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); }); @@ -738,7 +751,7 @@ export function generate(ast: PageAst): string { out.push( `export default async function ${ast.name}(ctx: any) { ${decls} - const __state = { ${dynamicStateScope} }; + const __state: ${stateType} = { ${dynamicStateScope} }; const __scopeValue = Object.entries(__state) .map(([key, value]) => { @@ -766,7 +779,7 @@ export function generate(ast: PageAst): string { } else { out.push( `export default function ${ast.name}(ctx: any) { - const __state = { ${dynamicStateScope} }; + const __state: ${stateType} = { ${dynamicStateScope} }; const __scopeValue = Object.entries(__state) .map(([key, value]) => { @@ -932,10 +945,12 @@ function escLit(s: string): string { } function componentBehavior(ast: PageAst): ComponentBehavior | null { - const functions = ast.functions - .map((body) => body.trim()) - .filter(Boolean) - .join("\n\n"); + const functions = eraseFunctionTypes( + ast.functions + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"), + ); const lifecycle = { ...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}), @@ -1289,6 +1304,8 @@ function generateComponent(ast: PageAst): string { { name: "content", default: '""', + valueType: "string", + required: false, }, ...ast.props, ] @@ -1339,12 +1356,21 @@ function generateComponent(ast: PageAst): string { const decls: string[] = []; for (const prop of effectiveProps) { + if (prop.required) { + decls.push( + ` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify( + `${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`, + )});`, + ); + } decls.push( - ` const ${nameRefs.get(prop.name)} = __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) { - 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 @@ -1370,20 +1396,41 @@ function generateComponent(ast: PageAst): string { 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) { return def; } - if (typeof def === "number") { - return Number(v); + if (declared === "number" || typeof def === "number") { + const parsed = Number(v); + if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop"); + return parsed; } - if (typeof def === "boolean") { - return v === true || v === "" || v === "true"; + if (declared === "boolean" || typeof def === "boolean") { + 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)) { return v; } @@ -1393,6 +1440,7 @@ function generateComponent(ast: PageAst): string { const parsed = JSON.parse(v); return Array.isArray(parsed) ? parsed : def; } catch { + if (declared === "array") throw new TypeError("Expected an array prop"); return def; } } @@ -1400,7 +1448,7 @@ function generateComponent(ast: PageAst): string { return def; } - if (def !== null && typeof def === "object") { + if (declared === "object" || (def !== null && typeof def === "object")) { if ( v !== null && typeof v === "object" && @@ -1421,6 +1469,7 @@ function generateComponent(ast: PageAst): string { ? parsed : def; } catch { + if (declared === "object") throw new TypeError("Expected an object prop"); return def; } } @@ -1428,7 +1477,11 @@ function generateComponent(ast: PageAst): string { 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 { @@ -1532,7 +1585,7 @@ function __wireRaw(v: any): string { const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : ""; out.push( - `export function render(props: Record = {}): string {\n` + + `export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + ` const __p = props || {};\n` + (decls.length > 0 ? decls.join("\n") + "\n" : "") + serverFunctionSource + diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c5620230..604b4a0f 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -15,12 +15,14 @@ export { parse, ParseError } from "./parser.ts"; export { generate } from "./codegen.ts"; export { generateNative, NativeCompileError } from "./native-codegen.ts"; export { Lexer, LexError } from "./tokenizer.ts"; +export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "./types.ts"; export type { PageAst, SeoBlock, ViewNode, Attr, StateDecl, + PropDecl, ApiBlock, DataApiBlock, DataMode, diff --git a/packages/compiler/src/native-codegen.ts b/packages/compiler/src/native-codegen.ts index 8f13d694..2ea1ba67 100644 --- a/packages/compiler/src/native-codegen.ts +++ b/packages/compiler/src/native-codegen.ts @@ -225,9 +225,13 @@ export function generateNative(ast: PageAst): string { const hooks = ast.states .map((state) => { const cap = state.name[0]!.toUpperCase() + state.name.slice(1); - return ` const [${state.name}, set${cap}] = useState(${state.expr});`; + return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`; }) .join("\n"); const body = ast.view.map((node) => renderNode(node, states)).join(""); - 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`; } diff --git a/packages/compiler/src/parser.ts b/packages/compiler/src/parser.ts index 0fda6b93..1bf4cfe2 100644 --- a/packages/compiler/src/parser.ts +++ b/packages/compiler/src/parser.ts @@ -4,7 +4,9 @@ * Grammar (subset of the vision, but real): * * page { - * state = // zero or more + * types { } + * props { : [= ] } // no default means required + * state : = // type annotation is optional * view { } // plain HTML (see parseHtmlView) * seo { title = "Home" description = "..." } * ssr { api { } functions { } } @@ -21,9 +23,12 @@ */ import { Lexer, LexError, type Token } from "./tokenizer.ts"; +import { validateTypedInitializer } from "./types.ts"; export interface StateDecl { name: string; + /** Explicit TypeScript-style type annotation, when supplied. */ + valueType?: string; /** Raw JS initializer expression, e.g. `0` or `'x'`. */ expr: string; } @@ -131,6 +136,10 @@ export interface RealtimeBlock { export interface PropDecl { name: string; + /** Explicit TypeScript-style type annotation, when supplied. */ + valueType?: string; + /** Props without a default are required. */ + required: boolean; /** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */ default: string; } @@ -147,6 +156,8 @@ export interface PageAst { layout?: string; /** Declared component props (empty for pages). */ props: PropDecl[]; + /** Raw declarations from `types { ... }`, emitted as TypeScript. */ + types: string[]; states: StateDecl[]; seo: SeoBlock; view: ViewNode[]; @@ -218,6 +229,7 @@ export function parse(source: string): PageAst { let layout: string | undefined; const props: PropDecl[] = []; + const types: string[] = []; const states: StateDecl[] = []; const seo: SeoBlock = {}; const view: ViewNode[] = []; @@ -245,7 +257,7 @@ export function parse(source: string): PageAst { break; } case "props": { - // props { name = ... } — one declaration per line. + // props { name: Type = } — omit the default for required props. lx.next(); expect("lbrace"); 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}`); } const pName = expect("ident").value; - expect("eq"); - props.push({ name: pName, default: lx.readToLineEnd() }); + let valueType: string | undefined; + let hasDefault = false; + if (lx.peek().type === "colon") { + lx.next(); + const annotation = lx.readTypeAnnotation(); + valueType = annotation.type; + hasDefault = annotation.hasDefault; + } else { + expect("eq"); + hasDefault = true; + } + const defaultValue = hasDefault ? lx.readToLineEnd() : "undefined"; + props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue }); } expect("rbrace"); break; @@ -264,8 +287,23 @@ export function parse(source: string): PageAst { case "state": { lx.next(); const sName = expect("ident").value; - expect("eq"); - states.push({ name: sName, expr: lx.readToLineEnd() }); + let valueType: string | undefined; + if (lx.peek().type === "colon") { + lx.next(); + const annotation = lx.readTypeAnnotation(); + valueType = annotation.type; + if (!annotation.hasDefault) { + throw new ParseError(`State '${sName}' requires an initializer`); + } + } else { + expect("eq"); + } + states.push({ name: sName, valueType, expr: lx.readToLineEnd() }); + break; + } + case "types": { + lx.next(); + types.push(lx.readBalancedBraces()); break; } case "view": { @@ -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 { type: "page", kind, name, layout, props, + types, states, seo, view, diff --git a/packages/compiler/src/tokenizer.ts b/packages/compiler/src/tokenizer.ts index 07e12f2b..73f1bbd3 100644 --- a/packages/compiler/src/tokenizer.ts +++ b/packages/compiler/src/tokenizer.ts @@ -9,7 +9,17 @@ */ 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 { type: TokenType; @@ -71,6 +81,9 @@ export class Lexer { case "=": this.pos++; return { type: "eq", value: c, pos }; + case ":": + this.pos++; + return { type: "colon", value: c, pos }; case ",": this.pos++; return { type: "comma", value: c, pos }; @@ -133,6 +146,62 @@ export class Lexer { return v.trim(); } + /** + * Read a TypeScript-style type annotation after `:`. Reading stops at a + * top-level `=` or line ending, while nested object/tuple/generic syntax is + * preserved. The optional `=` is consumed for the caller. + */ + readTypeAnnotation(): { type: string; hasDefault: boolean } { + const { src } = this; + let value = ""; + let angle = 0; + let square = 0; + let brace = 0; + let paren = 0; + let quote: string | null = null; + + while (this.pos < src.length) { + const c = src[this.pos]!; + if (quote) { + value += c; + this.pos++; + if (c === "\\" && this.pos < src.length) value += src[this.pos++]!; + else if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'" || c === "`") { + quote = c; + value += c; + this.pos++; + continue; + } + if (c === "<") angle++; + else if (c === ">" && angle > 0) angle--; + else if (c === "[") square++; + else if (c === "]" && square > 0) square--; + else if (c === "{") brace++; + else if (c === "}" && brace > 0) brace--; + else if (c === "(") paren++; + else if (c === ")" && paren > 0) paren--; + + if (angle === 0 && square === 0 && brace === 0 && paren === 0) { + if (c === "=") { + this.pos++; + const type = value.trim(); + if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`); + return { type, hasDefault: true }; + } + if (c === "\n" || c === "\r") break; + } + value += c; + this.pos++; + } + + const type = value.trim(); + if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`); + return { type, hasDefault: false }; + } + /** * Read a `{ ... }` block and return its INNER text (no outer braces), with * brace counting that respects string and template literals so a `}` inside a diff --git a/packages/compiler/src/types.ts b/packages/compiler/src/types.ts new file mode 100644 index 00000000..4c4cc80a --- /dev/null +++ b/packages/compiler/src/types.ts @@ -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}`; + }, + ); +} diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 41b68de6..15a50c1f 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -66,6 +66,80 @@ test("parses a component with props and state", () => { 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 { } +}`; + 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 {
} + }`), + ).toThrow("Prop 'required' is declared as boolean, but its initializer is string"); + expect(() => parse(`page Invalid { state count: number = false\n view {

} }`)).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 { {label}:{count}:{enabled} } + }`); + const render = component.render as (props?: Record) => string; + expect(() => render()).toThrow("TypedInput requires prop 'label' (string)"); + expect(render({ label: "Total", count: "4", enabled: "true" })).toContain( + "Total:4:true", + ); + 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 <", () => { const ast = parse( `component T {\n view {\n \n
\n \n

a < b

\n }\n}`, diff --git a/packages/core/package.json b/packages/core/package.json index 82c663c5..c53cefc9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index 6fd269e1..14119c41 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 11ec6078..fd9c9ae4 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 90b959be..677d419b 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 571ffb3a..64bace5b 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 9ad00a18..5a330821 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index d6339e3d..6baa7b2a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index f22506d2..a0828ba1 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 92f622f1..1aa5f9a6 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index f967e671..51b5254a 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index d9b7343d..6433ad78 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index fb4616b3..89e5cc92 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 6b222975..d5c6fca7 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index e0b008bc..40180b0b 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index 335a9713..54084ef3 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 735e52f3..6f8ea0e0 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 7c7cb1dd..ad30de67 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.58", + "version": "0.2.59", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index 7bc3f35a..1f9a6d49 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 77f02e72..3accca18 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/components/DateInput.wrn b/packages/ui/components/DateInput.wrn index 30dfdcb7..cf3d5acd 100644 --- a/packages/ui/components/DateInput.wrn +++ b/packages/ui/components/DateInput.wrn @@ -1,16 +1,16 @@ component DateInput { props { - id = "" - name = "" - label = "Date" - value = "" - placeholder = "" - help = "" - error = "" - required = false - disabled = false - readonly = false - autocomplete = "" + id: string = "" + name: string = "" + label: string = "Date" + value: string = "" + placeholder: string = "" + help: string = "" + error: string = "" + required: boolean = false + disabled: boolean = false + readonly: boolean = false + autocomplete: string = "" } view {
diff --git a/packages/ui/components/DatePicker.wrn b/packages/ui/components/DatePicker.wrn index cc21325b..dc13ae55 100644 --- a/packages/ui/components/DatePicker.wrn +++ b/packages/ui/components/DatePicker.wrn @@ -1,12 +1,12 @@ component DatePicker { props { - id = "date" - name = "date" - label = "Date" - value = "" - min = "" - max = "" - required = false + id: string = "date" + name: string = "date" + label: string = "Date" + value: string = "" + min: string = "" + max: string = "" + required: boolean = false } view { diff --git a/packages/ui/package.json b/packages/ui/package.json index 94afe5d5..157e1997 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index bc1f8452..99ff295f 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index 04e8e381..8b9af5fb 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.58", + "version": "0.2.59", "private": true, "type": "module", "main": "src/index.ts", diff --git a/scripts/generate-ui-component-reference.mjs b/scripts/generate-ui-component-reference.mjs index e62507a8..e603d9e6 100644 --- a/scripts/generate-ui-component-reference.mjs +++ b/scripts/generate-ui-component-reference.mjs @@ -21,16 +21,19 @@ function block(source, keyword) { function propsOf(source) { const props = []; 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; - const [, name, rawDefault] = match; + const [, name, annotation, rawDefault] = match; const value = rawDefault?.trim(); const type = - value === "true" || value === "false" + annotation?.trim() || + (value === "true" || value === "false" ? "boolean" : /^-?\d+(?:\.\d+)?$/.test(value ?? "") ? "number" - : "string"; + : "string"); props.push({ name, type, required: value === undefined, default: value ?? null }); } return props; diff --git a/update-package-versions.mjs b/update-package-versions.mjs index 9a65d14c..6b5396aa 100644 --- a/update-package-versions.mjs +++ b/update-package-versions.mjs @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const version = "0.2.58"; +const version = "0.2.59"; const dependencyGroups = [ "dependencies",