diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 8c12fc31..f5508aec 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.31", + "version": "0.2.32", "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 c32c98b4..3caffcb9 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.31", + "version": "0.2.32", "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 bc09ae28..e7abfd3a 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.31", + "version": "0.2.32", "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.31", - "@wrnexus/router": "^0.2.31", - "@wrnexus/csr": "^0.2.31", - "@wrnexus/compiler": "^0.2.31", - "@wrnexus/styles": "^0.2.31", - "@wrnexus/dev-server": "^0.2.31", - "@wrnexus/ui": "^0.2.31", - "@wrnexus/validation": "^0.2.31", - "@wrnexus/i18n": "^0.2.31", - "@wrnexus/db": "^0.2.31" + "@wrnexus/core": "^0.2.32", + "@wrnexus/router": "^0.2.32", + "@wrnexus/csr": "^0.2.32", + "@wrnexus/compiler": "^0.2.32", + "@wrnexus/styles": "^0.2.32", + "@wrnexus/dev-server": "^0.2.32", + "@wrnexus/ui": "^0.2.32", + "@wrnexus/validation": "^0.2.32", + "@wrnexus/i18n": "^0.2.32", + "@wrnexus/db": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index e5a5c01c..455aab8a 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.31", + "version": "0.2.32", "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 99291dae..da0c67b4 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.31", + "version": "0.2.32", "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 1d740a6e..8b0c485a 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.31" + "@wrnexus/core": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index d2bb4a36..2b800e80 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.31", + "version": "0.2.32", "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 ea690999..d86956d3 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,18 +25,18 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.31", - "@wrnexus/router": "^0.2.31", - "@wrnexus/ssr": "^0.2.31", - "@wrnexus/csr": "^0.2.31", - "@wrnexus/compiler": "^0.2.31", - "@wrnexus/styles": "^0.2.31", - "@wrnexus/ui": "^0.2.31", - "@wrnexus/validation": "^0.2.31", - "@wrnexus/i18n": "^0.2.31", - "@wrnexus/db": "^0.2.31", - "@wrnexus/pubsub": "^0.2.31", - "@wrnexus/uploader": "^0.2.31" + "@wrnexus/core": "^0.2.32", + "@wrnexus/router": "^0.2.32", + "@wrnexus/ssr": "^0.2.32", + "@wrnexus/csr": "^0.2.32", + "@wrnexus/compiler": "^0.2.32", + "@wrnexus/styles": "^0.2.32", + "@wrnexus/ui": "^0.2.32", + "@wrnexus/validation": "^0.2.32", + "@wrnexus/i18n": "^0.2.32", + "@wrnexus/db": "^0.2.32", + "@wrnexus/pubsub": "^0.2.32", + "@wrnexus/uploader": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index ddf393c2..de67047a 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.31", + "version": "0.2.32", "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 b5d5912b..7e19edc7 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.31", + "version": "0.2.32", "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.31" + "@wrnexus/core": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 96ebfa1d..e0e0a116 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.31" + "@wrnexus/core": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index fe9e1f9c..8eb3a37e 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.31", + "version": "0.2.32", "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 00c92765..aa83797b 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.31" + "@wrnexus/native": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index b29abc42..7bbc1466 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.31", + "version": "0.2.32", "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 e4368bb9..f1b662cc 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.31", + "version": "0.2.32", "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 62def059..95173413 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.31", + "version": "0.2.32", "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 8cb6b62d..c36090b4 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.31", + "version": "0.2.32", "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 11cb802b..755aa01c 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.31", + "version": "0.2.32", "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 51cdc407..a6b83545 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.31", - "@wrnexus/core": "^0.2.31" + "@wrnexus/compiler": "^0.2.32", + "@wrnexus/core": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 68c399b5..1c41c05e 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.31" + "@wrnexus/core": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index ad8391d8..0e9e9c45 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.31" + "@wrnexus/uploader": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/test/package.json b/.publish/test/package.json index b3a55130..7db15391 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.31", + "version": "0.2.32", "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 59d3de43..d86a763b 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 0861f921..fba5bbc8 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -22,7 +22,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.2.31" + "@wrnexus/core": "^0.2.32" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 1a6414b5..be788875 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.31" + "@wrnexus/core": "^0.2.32" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 6e2ef57d..33f4a40a 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 4bd94aa6..706c1b54 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,54 +1,614 @@ # WRNexus Language Support -Language support for [WRNexus](https://wrnexusjs.dev) `.wrn` files — the SSR-first, -Bun-powered full-stack framework. +Complete VS Code language support for [WRNexus](https://wrnexusjs.dev) `.wrn` files. + +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. ## Installation -After the first Marketplace release, search for **WRNexus Language Support** in -VS Code or run: +Search for **WRNexus Language Support** in the VS Code Extensions view or run: ```text ext install wrnexus.wrnexus ``` -For a local package, open the Command Palette, choose **Extensions: Install from -VSIX**, and select the generated `wrnexus-.vsix` file. +For a local build: + +1. Package the extension: + +```bash +bun run package +``` + +2. Open the VS Code Command Palette. +3. Select **Extensions: Install from VSIX**. +4. Choose the generated `wrnexus-.vsix` file. + +You can also install it from the terminal: + +```bash +code --install-extension ./wrnexus-0.2.7.vsix --force +``` + +Reload VS Code after installation: + +```text +Developer: Reload Window +``` ## Features -- **Syntax highlighting** ("color-full code"). Keywords (`page`, `component`, - `state`, `view`, `api`, `realtime`, …) plus **embedded highlighting** for the - languages inside each block: - - `view { … }` → HTML, with `{expr}` interpolation and `{t:key}` translations - and `@event="…"` bindings picked out. - - `style { … }` → CSS. - - `functions { … }`, `api … { … }`, `ssr`/`client`, `realtime on(…) { … }` → TypeScript. -- **WrNexus attributes in distinct colors.** A grammar injection recolors the - framework's own attributes so they pop even inside plain HTML: `@event` - bindings and `{t:…}` in one accent, runtime directives (`data-component`, - `data-for`, `data-show`, `data-text`, `data-scope`, `data-slot`, `data-on-*`, - `data-wire-*`) in another. Default colors ship with the extension; change them - under `editor.tokenColorCustomizations` (scopes - `entity.other.attribute-name.wrn.directive` and `…wire.event`). -- **Diagnostics.** Parse errors from the real `@wrnexus/compiler` are shown inline - as you type, anchored to the exact offset. -- **Formatting.** Format Document and format-on-save normalize WRN block, HTML, - CSS, and function indentation without rewriting application expressions. -- **Snippets.** `page`, `component`, `view`, `state`, `props`, `seo`, `api`, - `ssr`, `client`, `realtime`, `functions`, `style`, plus view helpers `mount`, - `for`, `show`, `t`. -- **Completions.** Block keywords at file scope, `data-*` runtime attributes and - `@event` bindings inside a `view`, and HTTP methods after `api`. +### Syntax highlighting -## Settings +The extension provides highlighting for WRNexus declarations and embedded languages. -| Setting | Default | Description | -| ---------------------------- | ------- | ----------------------------------------- | -| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. | -| `wrnexus.format.enable` | `true` | Enable the built-in formatter for `.wrn`. | +Supported top-level declarations: -To format automatically when saving, add this to VS Code settings: +```wrn +page HomePage { +} + +component UserCard { +} + +layout PublicLayout { +} +``` + +Supported WRNexus blocks include: + +```wrn +props { +} + +state count = 0 + +seo { +} + +functions { +} + +lifecycle { +} + +watch count { +} + +view { +} + +style { +} + +api GET /api/users { +} + +ssr { +} + +client { +} + +realtime chat { +} +``` + +Embedded language highlighting: + +- `view { ... }` uses HTML highlighting. +- `style { ... }` uses CSS highlighting. +- `functions { ... }` uses TypeScript highlighting. +- Lifecycle hook bodies use TypeScript highlighting. +- Watcher bodies use TypeScript highlighting. +- API, SSR, client, and realtime bodies use TypeScript highlighting. +- `{expression}` interpolation uses TypeScript expression highlighting. + +### Pages + +Create route pages with layouts, SEO metadata, state, APIs, and views: + +```wrn +page UserPage { + layout = "public" + + state id = ctx.params.id + + seo { + title = "User profile" + description = "View user information" + } + + view { +
+

User {id}

+
+ } +} +``` + +Dynamic route parameters are available through: + +```wrn +state id = ctx.params.id +``` + +For a route such as: + +```text +pages/users/[id].wrn +``` + +autocomplete suggests: + +```text +id +ctx.params.id +``` + +### Components + +Create reusable reactive components: + +```wrn +component Counter { + state count = 0 + + view { + + } +} +``` + +Components support: + +- props +- reactive state +- browser functions +- lifecycle hooks +- state watchers +- event handlers +- conditional classes +- reactive attributes +- reactive loops +- conditional visibility + +### Layouts + +Create reusable layouts: + +```wrn +layout PublicLayout { + view { +
+ +
+ +
+ {content} +
+ } +} +``` + +Layouts can also use state, props, functions, lifecycle hooks, and watchers. + +## Browser functions + +Functions declared inside a component or layout are available to browser events, lifecycle hooks, and watchers. + +```wrn +component Counter { + state count = 0 + + functions { + function increment() { + count += 1 + } + + function reset() { + count = 0 + } + } + + view { + + + + +

{count}

+ } +} +``` + +Functions may use browser APIs: + +```wrn +functions { + function scrollToTop() { + window.scrollTo({ + top: 0, + behavior: "smooth" + }) + } +} +``` + +Asynchronous functions are also supported: + +```wrn +functions { + async function loadUsers() { + const response = await fetch("/api/users") + const users = await response.json() + } +} +``` + +## Lifecycle hooks + +Components and layouts support three lifecycle hooks: + +```wrn +lifecycle { + mount { + } + + update { + } + + unmount { + } +} +``` + +### `mount` + +Runs once after the component is connected to the page and hydrated. + +```wrn +lifecycle { + mount { + console.log("Component mounted") + } +} +``` + +Use it to: + +- read browser state +- initialize third-party libraries +- register global event listeners +- start timers +- perform initial browser-only work + +### `update` + +Runs after reactive state changes. + +Multiple synchronous state changes are batched into one update cycle. + +```wrn +lifecycle { + update { + console.log("Component state updated") + } +} +``` + +### `unmount` + +Runs before the component is removed. + +```wrn +lifecycle { + unmount { + console.log("Component removed") + } +} +``` + +Use it to: + +- remove global event listeners +- clear timers +- disconnect observers +- release browser resources +- clean up third-party libraries + +Lifecycle cleanup also runs during WRNexus client-side navigation. + +## State watchers + +Watch one declared state value: + +```wrn +component Menu { + state open = false + + watch open { + console.log("Current value:", value) + console.log("Previous value:", previous) + } + + view { + + } +} +``` + +Watcher variables: + +| Variable | Description | +| ---------- | -------------------- | +| `value` | Current state value | +| `previous` | Previous state value | + +A watcher runs only when its declared state changes. + +Invalid watcher declarations are reported: + +```wrn +watch missingState { +} +``` + +The editor reports that `missingState` has not been declared. + +## Window and document events + +WRNexus supports events attached directly to `window` and `document`. + +### Window events + +```wrn +
+
+``` + +```wrn +
+
+``` + +```wrn +
+
+``` + +### Document events + +```wrn +
+
+``` + +```wrn +
+
+``` + +Global event listeners created manually inside `mount` should be removed inside `unmount`. + +## Complete BackToTop example + +```wrn +component BackToTop { + state visible = false + + functions { + function updateBackToTopVisibility() { + visible = window.scrollY > 500 + } + + function scrollToTop() { + window.scrollTo({ + top: 0, + behavior: "smooth" + }) + } + } + + lifecycle { + mount { + updateBackToTopVisibility() + + window.addEventListener( + "scroll", + updateBackToTopVisibility, + { passive: true } + ) + } + + unmount { + window.removeEventListener( + "scroll", + updateBackToTopVisibility + ) + } + } + + watch visible { + console.log( + "BackToTop visibility changed:", + value, + previous + ) + } + + view { + + } +} +``` + +## Conditional classes + +Use `class:` to toggle CSS classes reactively: + +```wrn + +``` + +The expression is evaluated whenever its reactive dependencies change. + +## Event bindings + +Standard element events: + +```wrn + +``` + +```wrn + +``` + +```wrn +
+
+``` + +Supported completion suggestions include: + +- `@click` +- `@input` +- `@change` +- `@submit` +- `@focus` +- `@blur` +- `@keydown` +- `@keyup` +- `@mouseenter` +- `@mouseleave` +- `@window:scroll` +- `@window:resize` +- `@window:keydown` +- `@document:click` +- `@document:visibilitychange` + +## Reactive directives + +### Conditional visibility + +```wrn +
+ Visible when open is true +
+``` + +### Reactive loops + +```wrn +
    +
  • + {user.name} +
  • +
+``` + +### Reactive text + +```wrn + +``` + +### Dynamic attributes + +```wrn + +``` + +## Diagnostics + +The extension provides two diagnostic layers. + +### Editor diagnostics + +Fast local validation checks: + +- invalid root declarations +- unknown page, component, or layout members +- unbalanced braces, brackets, and parentheses +- unclosed strings +- invalid HTML closing tags +- missing HTML closing tags +- invalid lifecycle hooks +- duplicate lifecycle hooks +- duplicate lifecycle blocks +- undeclared watched states +- missing watcher bodies +- invalid layout usage +- missing view blocks + +### Compiler diagnostics + +The extension bundles the real WRNexus compiler and validates `.wrn` files as you type. + +Compiler errors are attached to the reported source offset whenever available. + +Disable diagnostics with: + +```json +"wrnexus.diagnostics.enable": false +``` + +## Formatting + +The built-in formatter supports: + +- page, component, and layout indentation +- functions and nested function bodies +- lifecycle blocks +- watcher blocks +- multiline function calls +- object literals +- HTML elements +- multiline HTML attributes +- conditional class directives +- window and document events +- CSS blocks +- API blocks + +Format a document using: + +```text +Shift+Alt+F +``` + +Enable format-on-save: ```json "[wrn]": { @@ -57,29 +617,178 @@ To format automatically when saving, add this to VS Code settings: } ``` +Disable the formatter: + +```json +"wrnexus.format.enable": false +``` + +Configure preferred line width: + +```json +"wrnexus.formatting.printWidth": 100 +``` + +## Autocomplete + +Autocomplete is available for: + +- pages +- components +- layouts +- state declarations +- props +- views +- SEO metadata +- functions +- lifecycle hooks +- watchers +- declared state names +- declared props +- declared functions +- watcher variables +- route parameters +- context values +- standard events +- window events +- document events +- reactive attributes +- conditional classes + +After typing: + +```wrn +watch +``` + +the extension suggests declared state names. + +Inside a watcher, it suggests: + +```text +value +previous +``` + +Declared component functions are suggested as callable snippets: + +```text +updateVisibility() +scrollToTop() +``` + +## Go to definition + +Use Ctrl+Click or **Go to Definition** for supported WRNexus symbols. + +The extension can navigate to component declarations and locally declared functions when supported by the definition provider. + +## Snippets + +Useful snippet prefixes include: + +| Prefix | Purpose | +| ------------------------ | ------------------------------------- | +| `wrn-page` | Create a page | +| `wrn-dynamic-page` | Create a dynamic route page | +| `wrn-component` | Create a component | +| `wrn-reactive-component` | Create a reactive component | +| `wrn-layout` | Create a layout | +| `wrn-state` | Declare state | +| `wrn-functions` | Create a functions block | +| `wrn-function` | Create a function | +| `wrn-async-function` | Create an async function | +| `wrn-lifecycle` | Create all lifecycle hooks | +| `wrn-mount` | Create a mount hook | +| `wrn-update` | Create an update hook | +| `wrn-unmount` | Create an unmount hook | +| `wrn-watch` | Create a watcher | +| `wrn-window-scroll` | Add a window scroll event | +| `wrn-window-resize` | Add a window resize event | +| `wrn-document-event` | Add a document event | +| `wrn-class-if` | Add a conditional class | +| `wrn-class-toggle` | Toggle two classes | +| `wrn-back-to-top` | Create a complete BackToTop component | + +## Settings + +| Setting | Default | Description | +| ------------------------------- | ------- | -------------------------------------- | +| `wrnexus.diagnostics.enable` | `true` | Enable editor and compiler diagnostics | +| `wrnexus.format.enable` | `true` | Enable the built-in formatter | +| `wrnexus.formatting.printWidth` | `100` | Preferred formatter line width | + +Recommended VS Code configuration: + +```json +{ + "[wrn]": { + "editor.defaultFormatter": "wrnexus.wrnexus", + "editor.formatOnSave": true, + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.snippetSuggestions": "top" + }, + "wrnexus.diagnostics.enable": true, + "wrnexus.format.enable": true, + "wrnexus.formatting.printWidth": 100 +} +``` + ## Privacy -The extension does not collect telemetry and does not send source code or usage -data over the network. Formatting, completion, and diagnostics run locally. +The extension does not collect telemetry. + +It does not send source code, diagnostics, formatting data, or usage information over the network. + +Syntax highlighting, formatting, autocomplete, snippets, definition navigation, and diagnostics run locally. ## Development -Diagnostics use a bundled copy of the compiler. Build and validate everything -after changing the extension or `@wrnexus/compiler`: +Install dependencies: ```bash bun install +``` + +Rebuild the bundled compiler: + +```bash +bun run build:compiler +``` + +Validate the extension: + +```bash bun run check +``` + +Package a VSIX: + +```bash bun run package ``` -`bun run package` creates `wrnexus-.vsix`. The extension still -highlights, provides snippets, and completes without the compiler bundle; only -inline error reporting requires it. +This creates: -Open this folder in VS Code and press F5 to launch an Extension -Development Host, then open any `.wrn` file. +```text +wrnexus-.vsix +``` -See the -[Marketplace publishing guide](https://git.workroot.in/WorkRoot/WRNexusJS/src/branch/main/editors/vscode/PUBLISHING.md) -for the release procedure. +Install the local package: + +```bash +code --install-extension ./wrnexus-.vsix --force +``` + +Open the extension folder in VS Code and press F5 to launch an Extension Development Host. + +After changing the parser or compiler, always rebuild: + +```bash +bun run build:compiler +``` + +Without the compiler bundle, syntax highlighting, snippets, formatting, and autocomplete continue to work, but compiler diagnostics are unavailable. + +See the [Marketplace publishing guide](https://git.workroot.in/WorkRoot/WRNexusJS/src/branch/main/editors/vscode/PUBLISHING.md) for publishing instructions. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 7c39087f..1b9d47fe 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -1,8 +1,8 @@ { "name": "wrnexus", "displayName": "WRNexus Language Support", - "description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.", - "version": "0.2.6", + "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.7", "publisher": "wrnexus", "private": true, "license": "SEE LICENSE IN LICENSE", @@ -32,15 +32,22 @@ "Formatters" ], "keywords": [ - "wire", "wrnexus", "wrn", + "wire", "wirejs", "language support", "formatter", + "diagnostics", "syntax highlighting", + "autocomplete", + "snippets", + "lifecycle", + "state watcher", + "components", "full-stack", "ssr", + "csr", "bun" ], "main": "./src/extension.js", @@ -52,7 +59,8 @@ { "id": "wrn", "aliases": [ - "WrNexus", + "WRNexus", + "WRN", "wrn" ], "extensions": [ @@ -92,23 +100,82 @@ } ], "configuration": { - "title": "WrNexus", + "title": "WRNexus", "properties": { "wrnexus.diagnostics.enable": { "type": "boolean", "default": true, - "description": "Show parse errors for .wrn files as you type." + "scope": "resource", + "description": "Enable WRNexus editor and compiler diagnostics for .wrn files." }, "wrnexus.format.enable": { "type": "boolean", "default": true, - "description": "Enable the built-in formatter for .wrn files." + "scope": "resource", + "description": "Enable the built-in WRNexus formatter for .wrn files." + }, + "wrnexus.formatting.printWidth": { + "type": "number", + "default": 100, + "minimum": 60, + "maximum": 240, + "scope": "resource", + "description": "Preferred maximum line width used by the WRNexus formatter." } } }, "configurationDefaults": { + "[wrn]": { + "editor.defaultFormatter": "wrnexus.wrnexus", + "editor.formatOnSave": false, + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.autoClosingBrackets": "always", + "editor.autoClosingQuotes": "always", + "editor.suggest.showSnippets": true, + "editor.snippetSuggestions": "top" + }, "editor.tokenColorCustomizations": { "textMateRules": [ + { + "scope": "storage.type.wrn", + "settings": { + "foreground": "#c084fc", + "fontStyle": "bold" + } + }, + { + "scope": "entity.name.type.wrn", + "settings": { + "foreground": "#67e8f9" + } + }, + { + "scope": "entity.name.function.wrn", + "settings": { + "foreground": "#a7f3d0" + } + }, + { + "scope": "entity.name.function.lifecycle.wrn", + "settings": { + "foreground": "#fbbf24", + "fontStyle": "bold" + } + }, + { + "scope": "variable.other.readwrite.wrn", + "settings": { + "foreground": "#93c5fd" + } + }, + { + "scope": "variable.language.wrn", + "settings": { + "foreground": "#f0abfc", + "fontStyle": "italic" + } + }, { "scope": "entity.other.attribute-name.wrn.directive", "settings": { @@ -123,6 +190,13 @@ "fontStyle": "bold" } }, + { + "scope": "entity.other.attribute-name.event.wrn", + "settings": { + "foreground": "#f472b6", + "fontStyle": "bold" + } + }, { "scope": "punctuation.definition.keyword.wrn", "settings": { diff --git a/editors/vscode/snippets/wrn.json b/editors/vscode/snippets/wrn.json index 12952f52..cd12f832 100644 --- a/editors/vscode/snippets/wrn.json +++ b/editors/vscode/snippets/wrn.json @@ -22,7 +22,7 @@ "WRN dynamic page": { "prefix": ["wrn-dynamic-page", "dynamic-page"], - "description": "Create a WRN page with a route parameter", + "description": "Create a WRN page using a route parameter", "body": [ "page ${1:DynamicPage} {", " layout = \"${2:default}\"", @@ -40,13 +40,15 @@ "WRN component": { "prefix": ["wrn-component", "component"], - "description": "Create a WRN component", + "description": "Create a reusable WRN component", "body": [ "component ${1:ComponentName} {", " props {", " ${2:title} = \"${3:Title}\"", " }", "", + " state ${4:ready} = false", + "", " view {", "
", "

{${2:title}}

", @@ -56,9 +58,43 @@ ] }, + "WRN reactive component": { + "prefix": ["wrn-reactive-component", "reactive-component"], + "description": "Create a component with functions, lifecycle hooks, and a watcher", + "body": [ + "component ${1:ComponentName} {", + " state ${2:active} = false", + "", + " functions {", + " function ${3:updateState}() {", + " ${2:active} = true", + " }", + " }", + "", + " lifecycle {", + " mount {", + " ${3:updateState}()", + " }", + "", + " unmount {", + " $0", + " }", + " }", + "", + " watch ${2:active} {", + " console.log(value, previous)", + " }", + "", + " view {", + "
{${2:active}}
", + " }", + "}" + ] + }, + "WRN layout": { "prefix": ["wrn-layout", "layout"], - "description": "Create a WRN layout", + "description": "Create a reusable WRN layout", "body": [ "layout ${1:LayoutName} {", " view {", @@ -76,6 +112,74 @@ "body": ["state ${1:name} = ${2:\"value\"}"] }, + "WRN functions block": { + "prefix": ["wrn-functions", "functions"], + "description": "Create a browser-side functions block", + "body": ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"] + }, + + "WRN function": { + "prefix": ["wrn-function", "function"], + "description": "Create a function inside a functions block", + "body": ["function ${1:name}(${2}) {", " $0", "}"] + }, + + "WRN async function": { + "prefix": ["wrn-async-function", "async-function"], + "description": "Create an asynchronous component function", + "body": ["async function ${1:name}(${2}) {", " $0", "}"] + }, + + "WRN lifecycle": { + "prefix": ["wrn-lifecycle", "lifecycle"], + "description": "Create mount, update, and unmount lifecycle hooks", + "body": [ + "lifecycle {", + " mount {", + " ${1}", + " }", + "", + " update {", + " ${2}", + " }", + "", + " unmount {", + " $0", + " }", + "}" + ] + }, + + "WRN mount hook": { + "prefix": ["wrn-mount", "mount"], + "description": "Create a component mount lifecycle hook", + "body": ["mount {", " $0", "}"] + }, + + "WRN update hook": { + "prefix": ["wrn-update", "update-hook"], + "description": "Create a batched component update lifecycle hook", + "body": ["update {", " $0", "}"] + }, + + "WRN unmount hook": { + "prefix": ["wrn-unmount", "unmount"], + "description": "Create a component unmount lifecycle hook", + "body": ["unmount {", " $0", "}"] + }, + + "WRN watcher": { + "prefix": ["wrn-watch", "watch"], + "description": "Watch a declared state value", + "body": ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"] + }, + + "WRN watcher with condition": { + "prefix": ["wrn-watch-if", "watch-if"], + "description": "Watch state and react to a specific value", + "body": ["watch ${1:stateName} {", " if (value === ${2:true}) {", " $0", " }", "}"] + }, + "WRN SEO block": { "prefix": ["wrn-seo", "seo"], "description": "Create SEO metadata", @@ -127,12 +231,36 @@ "body": ["class:${1:border-indigo-500}=\"${2:condition}\""] }, + "WRN conditional class pair": { + "prefix": ["wrn-class-toggle", "class-toggle"], + "description": "Toggle two classes from one condition", + "body": ["class:${1:opacity-100}=\"${3:visible}\"", "class:${2:opacity-0}=\"!${3:visible}\""] + }, + "WRN click event": { "prefix": ["wrn-click", "click"], - "description": "Add a click handler", + "description": "Add a click event handler", "body": ["@click=\"${1:handler()}\""] }, + "WRN window scroll event": { + "prefix": ["wrn-window-scroll", "window-scroll"], + "description": "Add a window scroll event handler", + "body": ["@window:scroll=\"${1:handler()}\""] + }, + + "WRN window resize event": { + "prefix": ["wrn-window-resize", "window-resize"], + "description": "Add a window resize event handler", + "body": ["@window:resize=\"${1:handler()}\""] + }, + + "WRN document event": { + "prefix": ["wrn-document-event", "document-event"], + "description": "Add a document-level event handler", + "body": ["@document:${1:click}=\"${2:handler()}\""] + }, + "WRN show directive": { "prefix": ["wrn-show", "show"], "description": "Conditionally show an element", @@ -158,7 +286,7 @@ "api GET ${1:/api/resource} {", " return Response.json({", " ${2:success}: true", - " });", + " })", "}" ] }, @@ -168,32 +296,19 @@ "description": "Create a POST API route", "body": [ "api POST ${1:/api/resource} {", - " const body = await ctx.request.json();", + " const body = await ctx.request.json()", "", " return Response.json({", " success: true,", " data: body", - " });", - "}" - ] - }, - - "WRN middleware": { - "prefix": ["wrn-middleware", "middleware"], - "description": "Create middleware", - "body": [ - "middleware ${1:auth} {", - " async function handle(ctx, next) {", - " ${2}", - " return await next();", - " }", + " })", "}" ] }, "WRN loading state": { "prefix": ["wrn-loading", "loading-state"], - "description": "Create loading state and button", + "description": "Create loading state and a reactive button", "body": [ "state loading = false", "", @@ -206,5 +321,64 @@ " {loading ? \"Loading...\" : \"${1:Submit}\"}", "" ] + }, + + "WRN BackToTop component": { + "prefix": ["wrn-back-to-top", "back-to-top"], + "description": "Create an optimized BackToTop component using lifecycle cleanup", + "body": [ + "component BackToTop {", + " state visible = false", + "", + " functions {", + " function updateBackToTopVisibility() {", + " visible = window.scrollY > ${1:500}", + " }", + "", + " function scrollToTop() {", + " window.scrollTo({", + " top: 0,", + " behavior: \"smooth\"", + " })", + " }", + " }", + "", + " lifecycle {", + " mount {", + " updateBackToTopVisibility()", + "", + " window.addEventListener(", + " \"scroll\",", + " updateBackToTopVisibility,", + " { passive: true }", + " )", + " }", + "", + " unmount {", + " window.removeEventListener(", + " \"scroll\",", + " updateBackToTopVisibility", + " )", + " }", + " }", + "", + " view {", + " ", + " ${2:Back to top}", + " ", + " }", + "}" + ] } } diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index efb59e19..ff6acee3 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -289,6 +289,8 @@ function parse(source) { const functions = []; const dataApis = []; const modeFunctions = []; + const lifecycle = {}; + const watches = []; const apis = []; const realtimes = []; while (lx.peek().type !== "rbrace") { @@ -413,6 +415,40 @@ function parse(source) { styles.push(lx.readBalancedBraces()); break; } + case "lifecycle": { + lx.next(); + expect("lbrace"); + while (lx.peek().type !== "rbrace") { + const hook = lx.peek(); + if (hook.type === "eof") { + throw new ParseError("Unexpected end of input inside lifecycle block"); + } + if (hook.type !== "ident") { + throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`); + } + if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") { + throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`); + } + const hookName = hook.value; + lx.next(); + if (lifecycle[hookName] !== undefined) { + throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`); + } + lifecycle[hookName] = lx.readBalancedBraces(); + } + expect("rbrace"); + break; + } + case "watch": { + lx.next(); + const stateName = expect("ident").value; + const body = lx.readBalancedBraces(); + watches.push({ + state: stateName, + body + }); + break; + } case "functions": { lx.next(); functions.push(lx.readBalancedBraces()); @@ -423,6 +459,12 @@ function parse(source) { } } expect("rbrace"); + const declaredStates = new Set(states.map((state) => state.name)); + for (const watcher of watches) { + if (!declaredStates.has(watcher.state)) { + throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`); + } + } return { type: "page", kind, @@ -436,6 +478,8 @@ function parse(source) { functions, dataApis, modeFunctions, + lifecycle, + watches, apis, realtimes }; @@ -716,10 +760,18 @@ function renderAttr(attr) { } } function eventAttribute(name) { - if (name.startsWith("browser-")) + if (name.startsWith("window:")) { + return `data-on-window-${name.slice("window:".length)}`; + } + if (name.startsWith("document:")) { + return `data-on-document-${name.slice("document:".length)}`; + } + if (name.startsWith("browser-")) { return `data-on-wrnexus-browser-${name.slice(8)}`; - if (name.startsWith("mobile-")) + } + if (name.startsWith("mobile-")) { return `data-on-wrnexus-mobile-${name.slice(7)}`; + } return `data-on-${name}`; } function reactiveAttrValue(raw, reactive) { @@ -924,7 +976,7 @@ function renderNestedComponentInvocation(node, ctx) { let bindIndex = 0; const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => { if (attr.event) { - return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`; + return escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`); } if (attr.boolean) { return ` ${attr.name}`; @@ -1262,6 +1314,34 @@ function safeRef(name) { 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 lifecycle = { + ...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}, + ...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {}, + ...ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {} + }; + const watches = ast.watches.map((watch) => ({ + state: watch.state, + body: watch.body.trim() + })); + if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) { + return null; + } + return { + functions, + lifecycle, + watches + }; +} +function behaviorAttribute(behavior) { + if (!behavior) { + return ""; + } + return ` data-wrn-behavior="${attrEscape(JSON.stringify(behavior))}"`; +} var INTERP_RE = /\{([^{}]+)\}/g; function exprRefsState(expr, stateNames) { for (const name of stateNames) { @@ -1340,7 +1420,7 @@ function renderComponentNode(node, ctx) { } const attrs = node.attrs.filter((a) => a.name !== "class" && !a.name.startsWith("class:")).map((a) => { if (a.event) { - return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`; + return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`; } if (a.boolean) { return ` ${a.name}`; @@ -1400,11 +1480,13 @@ function generateComponent(ast) { ${styles.map(styleEscape).join(` `)} `) : ""; - const needsScope = ast.states.length > 0 || viewHasEvents(ast.view); + const behavior = componentBehavior(ast); + const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null; const scopeKeys = [ ...effectiveProps.map((prop) => prop.name), ...ast.states.map((state) => state.name) ]; + const behaviorAttr = behaviorAttribute(behavior); const decls = []; for (const prop of effectiveProps) { decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`); @@ -1412,7 +1494,7 @@ ${styles.map(styleEscape).join(` for (const state of ast.states) { decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`); } - const returnExpr = needsScope ? "`" + styleTag + '
' + viewCode + "
`" : "`" + styleTag + viewCode + "`"; + const returnExpr = needsScope ? "`" + styleTag + `
` + viewCode + "
`" : "`" + styleTag + viewCode + "`"; const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} }); ` : needsScope ? ` const __scope = ""; ` : ""; @@ -1421,6 +1503,9 @@ ${styles.map(styleEscape).join(` } else { out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); } + if (behavior) { + out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`); + } out.push(`function __coerce(v: any, def: any): any { if (v === undefined || v === null) return def; if (typeof def === "number") return Number(v); diff --git a/editors/vscode/src/completion.js b/editors/vscode/src/completion.js index 8020fb00..8f87760d 100644 --- a/editors/vscode/src/completion.js +++ b/editors/vscode/src/completion.js @@ -20,15 +20,26 @@ const BLOCK_COMPLETIONS = [ { label: "component", detail: "WRN component", - documentation: "Create a reusable WRN component.", + documentation: + "Create a reusable WRN component with state, functions, lifecycle hooks, watchers, and a view.", snippet: [ "component ${1:ComponentName} {", - " props {", - ' ${2:title} = "${3:Title}"', + " state ${2:ready} = false", + "", + " functions {", + " function ${3:initialize}() {", + " $0", + " }", + " }", + "", + " lifecycle {", + " mount {", + " ${3:initialize}()", + " }", " }", "", " view {", - " $0", + "
", " }", "}", ].join("\n"), @@ -47,9 +58,16 @@ const BLOCK_COMPLETIONS = [ "}", ].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"), + }, { label: "seo", detail: "SEO metadata block", + documentation: "Declare title, description, and other page metadata.", snippet: [ "seo {", ' title = "${1:Page title}"', @@ -60,13 +78,84 @@ const BLOCK_COMPLETIONS = [ { label: "view", detail: "WRN view block", + documentation: "Declare the HTML view rendered by the page, component, or layout.", snippet: ["view {", " $0", "}"].join("\n"), }, { label: "state", detail: "Reactive state declaration", + documentation: "Declare reactive state owned by the current component, layout, or page.", snippet: 'state ${1:name} = ${2:"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", + ), + }, + { + label: "function", + detail: "WRN component function", + documentation: "Declare a browser-side function inside a functions block.", + snippet: ["function ${1:name}(${2}) {", " $0", "}"].join("\n"), + }, + { + label: "lifecycle", + detail: "Component lifecycle block", + documentation: "Declare mount, update, and unmount hooks for a component or layout.", + snippet: [ + "lifecycle {", + " mount {", + " $1", + " }", + "", + " update {", + " $2", + " }", + "", + " unmount {", + " $0", + " }", + "}", + ].join("\n"), + }, + { + label: "mount", + detail: "Lifecycle mount hook", + documentation: "Runs once after the component is connected and hydrated.", + snippet: ["mount {", " $0", "}"].join("\n"), + }, + { + label: "update", + detail: "Lifecycle update hook", + documentation: "Runs once after a batch of reactive state changes.", + snippet: ["update {", " $0", "}"].join("\n"), + }, + { + label: "unmount", + detail: "Lifecycle unmount hook", + documentation: + "Runs before the component is removed. Use it to remove global listeners and release resources.", + snippet: ["unmount {", " $0", "}"].join("\n"), + }, + { + label: "watch", + detail: "Reactive state watcher", + documentation: + "Run code when one declared state value changes. The watcher receives `value` and `previous`.", + snippet: ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"].join( + "\n", + ), + }, + { + label: "style", + detail: "Scoped style block", + documentation: "Declare styles for the current WRN declaration.", + snippet: ["style {", " $0", "}"].join("\n"), + }, ]; const ATTRIBUTE_COMPLETIONS = [ @@ -76,6 +165,19 @@ const ATTRIBUTE_COMPLETIONS = [ ["@submit", "Submit event handler", '@submit="${1:handler()}"'], ["@focus", "Focus event handler", '@focus="${1:handler()}"'], ["@blur", "Blur event handler", '@blur="${1:handler()}"'], + ["@keydown", "Keyboard key-down event handler", '@keydown="${1:handler()}"'], + ["@keyup", "Keyboard key-up event handler", '@keyup="${1:handler()}"'], + ["@mouseenter", "Pointer enter event handler", '@mouseenter="${1:handler()}"'], + ["@mouseleave", "Pointer leave event handler", '@mouseleave="${1:handler()}"'], + ["@window:scroll", "Window scroll event handler", '@window:scroll="${1:handler()}"'], + ["@window:resize", "Window resize event handler", '@window:resize="${1:handler()}"'], + ["@window:keydown", "Window key-down event handler", '@window:keydown="${1:handler()}"'], + ["@document:click", "Document click event handler", '@document:click="${1:handler()}"'], + [ + "@document:visibilitychange", + "Document visibility-change event handler", + '@document:visibilitychange="${1:handler()}"', + ], ["data-show", "Conditional visibility", 'data-show="${1:condition}"'], ["data-for", "Reactive loop", 'data-for="${1:item} in ${2:items}"'], ["class:", "Conditional CSS class", 'class:${1:border-indigo-500}="${2:condition}"'], @@ -90,6 +192,11 @@ const CONTEXT_COMPLETIONS = [ ["ctx.locals", "Request-local data"], ]; +const WATCH_VALUE_COMPLETIONS = [ + ["value", "Current watcher state value"], + ["previous", "Previous watcher state value"], +]; + function completionKindFor(label) { if (label.startsWith("@")) { return vscode.CompletionItemKind.Event; @@ -99,15 +206,47 @@ function completionKindFor(label) { return vscode.CompletionItemKind.Property; } + if (label === "function" || label === "functions") { + return vscode.CompletionItemKind.Function; + } + return vscode.CompletionItemKind.Keyword; } -function createCompletion(label, detail, snippet) { +function createCompletion(label, detail, snippet, documentation) { const item = new vscode.CompletionItem(label, completionKindFor(label)); item.detail = detail; item.insertText = new vscode.SnippetString(snippet || label); - item.documentation = new vscode.MarkdownString(detail); + + item.documentation = new vscode.MarkdownString(documentation || detail); + + return item; +} + +function createVariableCompletion(label, detail, insertion = label) { + const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Variable); + + item.detail = detail; + item.insertText = insertion; + + return item; +} + +function createFunctionCompletion(name, parameters = []) { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function); + + item.detail = `WRN component function${ + parameters.length > 0 ? ` (${parameters.join(", ")})` : "" + }`; + + item.documentation = new vscode.MarkdownString( + `Call the browser function \`${name}\` declared in the current \`functions { ... }\` block.`, + ); + + const placeholders = parameters.map((parameter, index) => `\${${index + 1}:${parameter}}`); + + item.insertText = new vscode.SnippetString(`${name}(${placeholders.join(", ")})`); return item; } @@ -118,6 +257,7 @@ function getCurrentOpeningTag(document, position) { ); const lastOpen = textBeforeCursor.lastIndexOf("<"); + const lastClose = textBeforeCursor.lastIndexOf(">"); if (lastOpen > lastClose) { @@ -129,6 +269,7 @@ function getCurrentOpeningTag(document, position) { function extractRouteParams(document) { const fileName = document.fileName.replace(/\\/g, "/"); + const matches = [...fileName.matchAll(/\[([A-Za-z_$][\w$]*)\]/g)]; return matches.map((match) => match[1]); @@ -136,13 +277,195 @@ function extractRouteParams(document) { function extractStates(document) { const source = document.getText(); + const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/gm)]; - return matches.map((match) => match[1]); + return [...new Set(matches.map((match) => match[1]))]; +} + +function extractProps(document) { + const source = document.getText(); + const propsBlockPattern = /\bprops\s*\{([\s\S]*?)\}/g; + + const props = new Set(); + let blockMatch; + + while ((blockMatch = propsBlockPattern.exec(source)) !== null) { + const body = blockMatch[1]; + + for (const match of body.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*=/gm)) { + props.add(match[1]); + } + } + + return [...props]; +} + +function extractFunctions(document) { + const source = document.getText(); + const functions = []; + const seen = new Set(); + + const pattern = /(?:^|\s)(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/g; + + let match; + + while ((match = pattern.exec(source)) !== null) { + const name = match[1]; + + if (seen.has(name)) { + continue; + } + + seen.add(name); + + const parameters = match[2] + .split(",") + .map((parameter) => parameter.trim()) + .filter(Boolean) + .map((parameter) => parameter.replace(/=.*$/, "").trim()); + + functions.push({ + name, + parameters, + }); + } + + return functions; +} + +function sourceBeforePosition(document, position) { + return document.getText(new vscode.Range(new vscode.Position(0, 0), position)); +} + +function isInsideNamedBlock(document, position, blockName) { + const source = sourceBeforePosition(document, position); + + const tokenPattern = new RegExp(`\\b${blockName}\\s*\\{|\\{|\\}`, "g"); + + const stack = []; + let match; + + while ((match = tokenPattern.exec(source)) !== null) { + const token = match[0]; + + if (token.startsWith(blockName)) { + stack.push(blockName); + continue; + } + + if (token === "{") { + stack.push(null); + continue; + } + + if (token === "}") { + stack.pop(); + } + } + + return stack.includes(blockName); +} + +function isInsideLifecycle(document, position) { + return isInsideNamedBlock(document, position, "lifecycle"); +} + +function isInsideFunctions(document, position) { + return isInsideNamedBlock(document, position, "functions"); +} + +function isInsideWatch(document, position) { + const source = sourceBeforePosition(document, position); + + const watchMatches = [...source.matchAll(/\bwatch\s+[A-Za-z_$][\w$]*\s*\{/g)]; + + if (watchMatches.length === 0) { + return false; + } + + const latest = watchMatches[watchMatches.length - 1]; + + const tail = source.slice(latest.index); + let depth = 0; + + for (const character of tail) { + if (character === "{") { + depth += 1; + } else if (character === "}") { + depth -= 1; + } + } + + return depth > 0; +} + +function isAfterWatchKeyword(document, position) { + const linePrefix = document.lineAt(position.line).text.slice(0, position.character); + + return /^\s*watch\s+[A-Za-z0-9_$]*$/.test(linePrefix); +} + +function addBlockCompletions(items, document, position) { + const insideLifecycle = isInsideLifecycle(document, position); + + const insideFunctions = isInsideFunctions(document, position); + + for (const completion of BLOCK_COMPLETIONS) { + if (insideLifecycle && !["mount", "update", "unmount"].includes(completion.label)) { + continue; + } + + if (insideFunctions && completion.label !== "function") { + continue; + } + + if (!insideLifecycle && ["mount", "update", "unmount"].includes(completion.label)) { + continue; + } + + if (!insideFunctions && completion.label === "function") { + continue; + } + + items.push( + createCompletion( + completion.label, + completion.detail, + completion.snippet, + completion.documentation, + ), + ); + } +} + +function addStateCompletions(items, document, position) { + const states = extractStates(document); + const afterWatch = isAfterWatchKeyword(document, position); + + for (const state of states) { + const item = createVariableCompletion( + state, + afterWatch ? "Declared WRN state available for watching" : "WRN reactive state", + ); + + if (afterWatch) { + item.sortText = `0-${state}`; + } + + items.push(item); + } +} + +function addFunctionCompletions(items, document) { + for (const fn of extractFunctions(document)) { + items.push(createFunctionCompletion(fn.name, fn.parameters)); + } } function provideCompletionItems(document, position) { const items = []; + const linePrefix = document.lineAt(position.line).text.slice(0, position.character); const openingTag = getCurrentOpeningTag(document, position); @@ -151,10 +474,10 @@ function provideCompletionItems(document, position) { for (const [label, detail, snippet] of ATTRIBUTE_COMPLETIONS) { items.push(createCompletion(label, detail, snippet)); } + } else if (isAfterWatchKeyword(document, position)) { + addStateCompletions(items, document, position); } else { - for (const completion of BLOCK_COMPLETIONS) { - items.push(createCompletion(completion.label, completion.detail, completion.snippet)); - } + addBlockCompletions(items, document, position); } if (linePrefix.includes("ctx.") || linePrefix.includes("ctx.params.")) { @@ -164,31 +487,27 @@ function provideCompletionItems(document, position) { } for (const param of extractRouteParams(document)) { - const item = new vscode.CompletionItem(param, vscode.CompletionItemKind.Variable); + items.push(createVariableCompletion(param, `Route parameter from [${param}].wrn`)); - item.detail = `Route parameter from [${param}].wrn`; - item.insertText = param; - - items.push(item); - - const fullItem = new vscode.CompletionItem( - `ctx.params.${param}`, - vscode.CompletionItemKind.Variable, + items.push( + createVariableCompletion(`ctx.params.${param}`, `Route parameter from [${param}].wrn`), ); - - fullItem.detail = `Route parameter from [${param}].wrn`; - fullItem.insertText = `ctx.params.${param}`; - - items.push(fullItem); } - for (const state of extractStates(document)) { - const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.Variable); + if (!isAfterWatchKeyword(document, position)) { + addStateCompletions(items, document, position); + } - item.detail = "WRN state"; - item.insertText = state; + for (const prop of extractProps(document)) { + items.push(createVariableCompletion(prop, "WRN component or layout prop")); + } - items.push(item); + addFunctionCompletions(items, document); + + if (isInsideWatch(document, position)) { + for (const [label, detail] of WATCH_VALUE_COMPLETIONS) { + items.push(createVariableCompletion(label, detail)); + } } return items; @@ -196,7 +515,10 @@ function provideCompletionItems(document, position) { function registerCompletionProvider(context) { const provider = vscode.languages.registerCompletionItemProvider( - { language: "wrn", scheme: "file" }, + { + language: "wrn", + scheme: "file", + }, { provideCompletionItems, }, @@ -205,13 +527,17 @@ function registerCompletionProvider(context) { ".", "<", " ", + "(", ); context.subscriptions.push(provider); } module.exports = { + extractFunctions, + extractProps, extractRouteParams, extractStates, + provideCompletionItems, registerCompletionProvider, }; diff --git a/editors/vscode/src/diagnostics.js b/editors/vscode/src/diagnostics.js index ee27b798..02e054dd 100644 --- a/editors/vscode/src/diagnostics.js +++ b/editors/vscode/src/diagnostics.js @@ -9,6 +9,8 @@ const TOP_LEVEL_PATTERN = /^\s*(page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{ const VALID_TOP_LEVEL_KINDS = new Set(["page", "component", "layout"]); +const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]); + const VALID_MEMBERS = { page: new Set([ "layout", @@ -23,9 +25,9 @@ const VALID_MEMBERS = { "realtime", ]), - component: new Set(["props", "state", "view", "style", "functions"]), + component: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]), - layout: new Set(["props", "state", "view", "style", "functions"]), + layout: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]), }; function createDiagnostic( @@ -59,7 +61,6 @@ function lineDiagnostic( code, ) { const line = document.lineAt(lineNumber); - const diagnostic = new vscode.Diagnostic(line.range, message, severity); diagnostic.source = "WRNexus"; @@ -316,6 +317,15 @@ function validateHtmlTags(document, source) { function getRootBodyRange(source, rootMatch) { const openingBrace = rootMatch.index + rootMatch[0].lastIndexOf("{"); + const closingBrace = findMatchingBrace(source, openingBrace); + + return { + start: openingBrace + 1, + end: closingBrace === -1 ? source.length : closingBrace, + }; +} + +function findMatchingBrace(source, openingBrace) { let depth = 0; let quote = null; let escaped = false; @@ -346,6 +356,17 @@ function getRootBodyRange(source, rootMatch) { continue; } + if (source.startsWith("", index + 4); + + if (commentEnd === -1) { + return -1; + } + + index = commentEnd + 2; + continue; + } + if (character === "{") { depth += 1; continue; @@ -355,22 +376,44 @@ function getRootBodyRange(source, rootMatch) { depth -= 1; if (depth === 0) { - return { - start: openingBrace + 1, - end: index, - }; + return index; } } } + return -1; +} + +function skipWhitespace(source, index, end) { + while (index < end && /\s/.test(source[index])) { + index += 1; + } + + return index; +} + +function readIdentifier(source, index, end) { + if (index >= end || !/[A-Za-z_$]/.test(source[index])) { + return null; + } + + const start = index; + index += 1; + + while (index < end && /[A-Za-z0-9_$-]/.test(source[index])) { + index += 1; + } + return { - start: openingBrace + 1, - end: source.length, + name: source.slice(start, index), + start, + end: index, }; } function findRootMembers(source, bodyStart, bodyEnd) { const members = []; + let index = bodyStart; let depth = 0; let quote = null; @@ -414,7 +457,9 @@ function findRootMembers(source, bodyStart, bodyEnd) { if (source.startsWith("", index + 4); + index = end === -1 ? bodyEnd : end + 3; + continue; } @@ -430,25 +475,35 @@ function findRootMembers(source, bodyStart, bodyEnd) { continue; } - if (depth === 0 && /[A-Za-z_]/.test(character)) { - const start = index; - index += 1; + if (depth === 0 && /[A-Za-z_$]/.test(character)) { + const identifier = readIdentifier(source, index, bodyEnd); - while (index < bodyEnd && /[A-Za-z0-9_-]/.test(source[index])) { + if (!identifier) { index += 1; + continue; } - const name = source.slice(start, index); + index = identifier.end; members.push({ - name, - start, - end: index, + name: identifier.name, + start: identifier.start, + end: identifier.end, }); - // Single-line declarations must skip the rest of the line. - if (name === "state" || name === "layout") { + if (identifier.name === "state" || identifier.name === "layout") { skipLine(); + continue; + } + + if (identifier.name === "watch") { + index = skipWhitespace(source, index, bodyEnd); + + const watchedState = readIdentifier(source, index, bodyEnd); + + if (watchedState) { + index = watchedState.end; + } } continue; @@ -460,6 +515,234 @@ function findRootMembers(source, bodyStart, bodyEnd) { return members; } +function findNamedBlocks(source, bodyStart, bodyEnd, blockName) { + const blocks = []; + let index = bodyStart; + + while (index < bodyEnd) { + index = skipWhitespace(source, index, bodyEnd); + + const identifier = readIdentifier(source, index, bodyEnd); + + if (!identifier) { + index += 1; + continue; + } + + index = identifier.end; + + if (identifier.name !== blockName) { + const possibleBrace = skipWhitespace(source, index, bodyEnd); + + if (source[possibleBrace] === "{") { + const end = findMatchingBrace(source, possibleBrace); + + index = end === -1 ? bodyEnd : end + 1; + } + + continue; + } + + const openingBrace = skipWhitespace(source, index, bodyEnd); + + if (source[openingBrace] !== "{") { + blocks.push({ + name: blockName, + nameStart: identifier.start, + nameEnd: identifier.end, + openingBrace: -1, + closingBrace: -1, + }); + + continue; + } + + const closingBrace = findMatchingBrace(source, openingBrace); + + blocks.push({ + name: blockName, + nameStart: identifier.start, + nameEnd: identifier.end, + openingBrace, + closingBrace, + }); + + index = closingBrace === -1 ? bodyEnd : closingBrace + 1; + } + + return blocks; +} + +function findStateDeclarations(source, bodyStart, bodyEnd) { + const states = new Map(); + let index = bodyStart; + let depth = 0; + let quote = null; + let escaped = false; + + while (index < bodyEnd) { + const character = source[index]; + + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === quote) { + quote = null; + } + + index += 1; + continue; + } + + if (character === '"' || character === "'") { + quote = character; + index += 1; + continue; + } + + if (source.startsWith("", index + 4); + + index = end === -1 ? bodyEnd : end + 3; + + continue; + } + + if (character === "{") { + depth += 1; + index += 1; + continue; + } + + if (character === "}") { + depth = Math.max(0, depth - 1); + index += 1; + continue; + } + + if ( + depth === 0 && + source.startsWith("state", index) && + !/[A-Za-z0-9_$]/.test(source[index - 1] || "") && + !/[A-Za-z0-9_$]/.test(source[index + 5] || "") + ) { + let cursor = skipWhitespace(source, index + 5, bodyEnd); + + const state = readIdentifier(source, cursor, bodyEnd); + + if (state) { + states.set(state.name, state); + index = state.end; + continue; + } + } + + index += 1; + } + + return states; +} + +function findWatchDeclarations(source, bodyStart, bodyEnd) { + const watches = []; + let index = bodyStart; + let depth = 0; + let quote = null; + let escaped = false; + + while (index < bodyEnd) { + const character = source[index]; + + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === quote) { + quote = null; + } + + index += 1; + continue; + } + + if (character === '"' || character === "'") { + quote = character; + index += 1; + continue; + } + + if (source.startsWith("", index + 4); + + index = end === -1 ? bodyEnd : end + 3; + + continue; + } + + if (character === "{") { + depth += 1; + index += 1; + continue; + } + + if (character === "}") { + depth = Math.max(0, depth - 1); + index += 1; + continue; + } + + if ( + depth === 0 && + source.startsWith("watch", index) && + !/[A-Za-z0-9_$]/.test(source[index - 1] || "") && + !/[A-Za-z0-9_$]/.test(source[index + 5] || "") + ) { + const watchStart = index; + let cursor = skipWhitespace(source, index + 5, bodyEnd); + + const watchedState = readIdentifier(source, cursor, bodyEnd); + + if (!watchedState) { + watches.push({ + watchStart, + watchEnd: index + 5, + state: null, + openingBrace: -1, + closingBrace: -1, + }); + + index += 5; + continue; + } + + cursor = skipWhitespace(source, watchedState.end, bodyEnd); + + const openingBrace = source[cursor] === "{" ? cursor : -1; + + const closingBrace = openingBrace === -1 ? -1 : findMatchingBrace(source, openingBrace); + + watches.push({ + watchStart, + watchEnd: index + 5, + state: watchedState, + openingBrace, + closingBrace, + }); + + index = closingBrace === -1 ? watchedState.end : closingBrace + 1; + + continue; + } + + index += 1; + } + + return watches; +} + function validateRootMembers(document, source, rootKind, rootMatch) { const diagnostics = []; const allowed = VALID_MEMBERS[rootKind]; @@ -469,6 +752,7 @@ function validateRootMembers(document, source, rootKind, rootMatch) { } const bodyRange = getRootBodyRange(source, rootMatch); + const members = findRootMembers(source, bodyRange.start, bodyRange.end); for (const member of members) { @@ -491,8 +775,182 @@ function validateRootMembers(document, source, rootKind, rootMatch) { return diagnostics; } +function validateLifecycleBlocks(document, source, rootKind, rootMatch) { + if (rootKind !== "component" && rootKind !== "layout") { + return []; + } + + const diagnostics = []; + const bodyRange = getRootBodyRange(source, rootMatch); + + const blocks = findNamedBlocks(source, bodyRange.start, bodyRange.end, "lifecycle"); + + if (blocks.length > 1) { + for (const block of blocks.slice(1)) { + diagnostics.push( + createDiagnostic( + document, + block.nameStart, + block.nameEnd, + "Only one `lifecycle { ... }` block is allowed.", + vscode.DiagnosticSeverity.Error, + "wrn-duplicate-lifecycle", + ), + ); + } + } + + for (const block of blocks) { + if (block.openingBrace === -1) { + diagnostics.push( + createDiagnostic( + document, + block.nameStart, + block.nameEnd, + "`lifecycle` must be followed by a block.", + vscode.DiagnosticSeverity.Error, + "wrn-invalid-lifecycle", + ), + ); + + continue; + } + + const lifecycleEnd = block.closingBrace === -1 ? bodyRange.end : block.closingBrace; + + let index = block.openingBrace + 1; + const seenHooks = new Set(); + + while (index < lifecycleEnd) { + index = skipWhitespace(source, index, lifecycleEnd); + + if (index >= lifecycleEnd) { + break; + } + + const hook = readIdentifier(source, index, lifecycleEnd); + + if (!hook) { + index += 1; + continue; + } + + index = hook.end; + + if (!VALID_LIFECYCLE_HOOKS.has(hook.name)) { + diagnostics.push( + createDiagnostic( + document, + hook.start, + hook.end, + `Unknown lifecycle hook \`${hook.name}\`. Use \`mount\`, \`update\`, or \`unmount\`.`, + vscode.DiagnosticSeverity.Error, + "wrn-invalid-lifecycle-hook", + ), + ); + } else if (seenHooks.has(hook.name)) { + diagnostics.push( + createDiagnostic( + document, + hook.start, + hook.end, + `Duplicate lifecycle hook \`${hook.name}\`.`, + vscode.DiagnosticSeverity.Error, + "wrn-duplicate-lifecycle-hook", + ), + ); + } else { + seenHooks.add(hook.name); + } + + const openingBrace = skipWhitespace(source, index, lifecycleEnd); + + if (source[openingBrace] !== "{") { + diagnostics.push( + createDiagnostic( + document, + hook.start, + hook.end, + `Lifecycle hook \`${hook.name}\` must be followed by a block.`, + vscode.DiagnosticSeverity.Error, + "wrn-invalid-lifecycle-hook", + ), + ); + + index = hook.end; + continue; + } + + const closingBrace = findMatchingBrace(source, openingBrace); + + index = closingBrace === -1 ? lifecycleEnd : closingBrace + 1; + } + } + + return diagnostics; +} + +function validateWatchBlocks(document, source, rootKind, rootMatch) { + if (rootKind !== "component" && rootKind !== "layout") { + return []; + } + + const diagnostics = []; + const bodyRange = getRootBodyRange(source, rootMatch); + + const states = findStateDeclarations(source, bodyRange.start, bodyRange.end); + + const watches = findWatchDeclarations(source, bodyRange.start, bodyRange.end); + + for (const watch of watches) { + if (!watch.state) { + diagnostics.push( + createDiagnostic( + document, + watch.watchStart, + watch.watchEnd, + "`watch` must be followed by a declared state name.", + vscode.DiagnosticSeverity.Error, + "wrn-invalid-watch", + ), + ); + + continue; + } + + if (!states.has(watch.state.name)) { + diagnostics.push( + createDiagnostic( + document, + watch.state.start, + watch.state.end, + `Cannot watch undeclared state \`${watch.state.name}\`.`, + vscode.DiagnosticSeverity.Error, + "wrn-unknown-watch-state", + ), + ); + } + + if (watch.openingBrace === -1) { + diagnostics.push( + createDiagnostic( + document, + watch.state.start, + watch.state.end, + `Watcher for \`${watch.state.name}\` must be followed by a block.`, + vscode.DiagnosticSeverity.Error, + "wrn-invalid-watch", + ), + ); + } + } + + return diagnostics; +} + function validateRequiredView(document, source, rootKind, rootMatch) { const bodyRange = getRootBodyRange(source, rootMatch); + const body = source.slice(bodyRange.start, bodyRange.end); if (/\bview\s*\{/.test(body)) { @@ -550,6 +1008,7 @@ function validateDocument(document) { if (declaration.diagnostic) { diagnostics.push(declaration.diagnostic); + diagnostics.push(...validateBalancedCharacters(document, source)); return diagnostics; @@ -576,6 +1035,12 @@ function validateDocument(document) { diagnostics.push(...validateRootMembers(document, source, declaration.kind, declaration.match)); + diagnostics.push( + ...validateLifecycleBlocks(document, source, declaration.kind, declaration.match), + ); + + diagnostics.push(...validateWatchBlocks(document, source, declaration.kind, declaration.match)); + diagnostics.push(...validateRequiredView(document, source, declaration.kind, declaration.match)); diagnostics.push(...validateLayoutUsage(document, source, declaration.kind)); @@ -593,19 +1058,20 @@ function registerDiagnostics(context) { return; } - const previousTimer = timers.get(document.uri.toString()); + const key = document.uri.toString(); + const previousTimer = timers.get(key); if (previousTimer) { clearTimeout(previousTimer); } const timer = setTimeout(() => { - timers.delete(document.uri.toString()); + timers.delete(key); collection.set(document.uri, validateDocument(document)); }, 150); - timers.set(document.uri.toString(), timer); + timers.set(key, timer); }; for (const document of vscode.workspace.textDocuments) { @@ -652,5 +1118,7 @@ module.exports = { validateBalancedCharacters, validateDocument, validateHtmlTags, + validateLifecycleBlocks, validateRootMembers, + validateWatchBlocks, }; diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js index 435f4d1c..10442c1d 100644 --- a/editors/vscode/src/extension.js +++ b/editors/vscode/src/extension.js @@ -2,257 +2,283 @@ "use strict"; const vscode = require("vscode"); + const { formatWrn } = require("./formatter"); + const { registerCompletionProvider } = require("./completion"); + const { registerDefinitionProvider } = require("./definition"); + const { registerDiagnostics } = require("./diagnostics"); -// The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded -// defensively so the rest of the extension (highlighting, snippets, completion) -// still works even if the bundle is missing. +/** + * The WRN compiler is bundled to CommonJS using: + * + * bun run build:compiler + * + * Loading is optional so highlighting, formatting, snippets, + * autocomplete and navigation continue working when the compiler + * bundle is temporarily unavailable. + */ let compiler = null; + try { compiler = require("./compiler.cjs"); -} catch (err) { - console.warn("[wrnexus] compiler bundle not found; diagnostics disabled.", err && err.message); +} catch (error) { + console.warn( + "[wrnexus] compiler bundle not found; compiler diagnostics disabled.", + error instanceof Error ? error.message : String(error), + ); } -/** Top-level block keywords offered at file scope. */ -const BLOCK_KEYWORDS = [ - ["page", "page ${1:Name} {\n\t$0\n}", "A route page"], - ["component", "component ${1:Name} {\n\t$0\n}", "A reusable, prop-driven component"], - ["layout", 'layout = "${1:public}"', "Select a layout for this page"], - ["state", "state ${1:count} = ${2:0}", "Reactive state seeded into the scope"], - ["props", 'props {\n\t${1:label} = ${2:"Label"}\n}', "Component props with typed defaults"], - ["view", "view {\n\t$0\n}", "Server-rendered HTML"], - ["seo", 'seo {\n\ttitle = "$1"\n\tdescription = "$2"\n}', "Per-page SEO metadata"], - ["api", "api ${1|GET,POST,PUT,PATCH,DELETE|} ${2:/path} {\n\t$0\n}", "Colocated API route"], - ["ssr", "ssr {\n\tapi ${1:load} GET ${2:/api/data} {\n\t\t$0\n\t}\n}", "Server-side data block"], - [ - "client", - "client {\n\tapi ${1:load} GET ${2:/api/data} {\n\t\t$0\n\t}\n}", - "Client-side data block", - ], - [ - "realtime", - "realtime ${1:chat} {\n\ton ${2:message}(${3:data}) {\n\t\t$0\n\t}\n}", - "Realtime channel", - ], - ["functions", "functions {\n\t$0\n}", "Shared helper functions"], - ["style", "style {\n\t$0\n}", "Scoped CSS"], -]; - -/** `data-*` attributes understood by the reactive runtime. */ -const DATA_ATTRS = [ - ["data-component", 'data-component="$1"', "Mount a component by name"], - ["data-for", 'data-for="${1:item} in ${2:items}"', "Repeat this element per item"], - ["data-show", 'data-show="${1:condition}"', "Toggle visibility reactively"], - ["data-text", 'data-text="${1:expr}"', "Bind text content to an expression"], - ["data-scope", 'data-scope="${1:key}: ${2:value}"', "Declare a local reactive scope"], - ["data-slot", 'data-slot="${1:name}"', "Fill a named on a component"], -]; - -/** Client event bindings. */ -const EVENTS = [ - "click", - "input", - "change", - "submit", - "keydown", - "keyup", - "focus", - "blur", - "mouseenter", - "mouseleave", -]; +const WRN_LANGUAGE_ID = "wrn"; +const COMPILER_DIAGNOSTIC_COLLECTION = "wrnexus-compiler"; /** + * Register the WRN document formatter. + * * @param {vscode.ExtensionContext} context */ -function activate(context) { - registerDiagnostics(context); - registerCompletionProvider(context); - registerDefinitionProvider(context); - - const diagnostics = vscode.languages.createDiagnosticCollection("wrn"); - context.subscriptions.push(diagnostics); - - const timers = new Map(); - const runDiagnostics = (doc) => { - if (doc.languageId !== "wrn") return; - if (!vscode.workspace.getConfiguration("wrnexus").get("diagnostics.enable", true)) { - diagnostics.delete(doc.uri); - return; - } - if (!compiler || typeof compiler.compileWireFile !== "function") return; - - const text = doc.getText(); - /** @type {vscode.Diagnostic[]} */ - const found = []; - try { - compiler.compileWireFile(text); - } catch (err) { - found.push(toDiagnostic(doc, err)); - } - diagnostics.set(doc.uri, found); - }; - - const schedule = (doc) => { - const key = doc.uri.toString(); - clearTimeout(timers.get(key)); - timers.set( - key, - setTimeout(() => { - timers.delete(key); - runDiagnostics(doc); - }, 250), - ); - }; - - // Lint on open, edit and save; clear on close. - vscode.workspace.textDocuments.forEach(runDiagnostics); - context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument(runDiagnostics), - vscode.workspace.onDidChangeTextDocument((e) => schedule(e.document)), - vscode.workspace.onDidSaveTextDocument(runDiagnostics), - vscode.workspace.onDidCloseTextDocument((doc) => diagnostics.delete(doc.uri)), - ); - - // Completions. - context.subscriptions.push( - vscode.languages.registerDocumentFormattingEditProvider("wrn", { +function registerFormatter(context) { + const provider = vscode.languages.registerDocumentFormattingEditProvider( + { + language: WRN_LANGUAGE_ID, + scheme: "file", + }, + { provideDocumentFormattingEdits(document, options) { + const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri); + + const enabled = configuration.get("format.enable", true); + + if (!enabled) { + return []; + } + const source = document.getText(); + const printWidth = configuration.get("formatting.printWidth", 100); + const formatted = formatWrn(source, { tabSize: options.tabSize, insertSpaces: options.insertSpaces, - printWidth: 100, + printWidth, }); + if (formatted === source) { + return []; + } + const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length)); return [vscode.TextEdit.replace(range, formatted)]; }, + }, + ); + + context.subscriptions.push(provider); +} + +/** + * Convert a compiler ParseError into a VS Code diagnostic. + * + * Compiler errors normally contain: + * + * at offset 123 + * + * When no offset exists, the diagnostic is attached to the last + * line of the document. + * + * @param {vscode.TextDocument} document + * @param {unknown} error + * @returns {vscode.Diagnostic} + */ +function toCompilerDiagnostic(document, error) { + const message = + error && typeof error === "object" && "message" in error + ? String(error.message) + : "Failed to compile .wrn file."; + + const offsetMatch = /offset\s+(\d+)/i.exec(message); + + let range; + + if (offsetMatch) { + const requestedOffset = Number(offsetMatch[1]); + + const safeOffset = Math.max(0, Math.min(requestedOffset, document.getText().length)); + + const start = document.positionAt(safeOffset); + + const wordRange = document.getWordRangeAtPosition(start); + + range = + wordRange || + new vscode.Range( + start, + document.positionAt(Math.min(safeOffset + 1, document.getText().length)), + ); + } else { + const lastLine = document.lineAt(Math.max(0, document.lineCount - 1)); + + range = lastLine.range; + } + + const diagnostic = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error); + + diagnostic.source = "WRNexus Compiler"; + + diagnostic.code = "wrn-compiler-error"; + + return diagnostic; +} + +/** + * Register diagnostics produced by the actual WRN compiler. + * + * The lightweight diagnostics in diagnostics.js provide immediate + * editor feedback. Compiler diagnostics verify that the document + * can also be parsed and generated by the real framework compiler. + * + * @param {vscode.ExtensionContext} context + */ +function registerCompilerDiagnostics(context) { + const collection = vscode.languages.createDiagnosticCollection(COMPILER_DIAGNOSTIC_COLLECTION); + + const timers = new Map(); + + /** + * @param {vscode.TextDocument} document + */ + const run = (document) => { + if (document.languageId !== WRN_LANGUAGE_ID) { + return; + } + + const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri); + + const enabled = configuration.get("diagnostics.enable", true); + + if (!enabled) { + collection.delete(document.uri); + return; + } + + if (!compiler || typeof compiler.compileWireFile !== "function") { + collection.delete(document.uri); + return; + } + + const source = document.getText(); + + if (!source.trim()) { + collection.delete(document.uri); + return; + } + + try { + compiler.compileWireFile(source); + + collection.set(document.uri, []); + } catch (error) { + collection.set(document.uri, [toCompilerDiagnostic(document, error)]); + } + }; + + /** + * @param {vscode.TextDocument} document + */ + const schedule = (document) => { + if (document.languageId !== WRN_LANGUAGE_ID) { + return; + } + + const key = document.uri.toString(); + + const existing = timers.get(key); + + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(() => { + timers.delete(key); + run(document); + }, 250); + + timers.set(key, timer); + }; + + for (const document of vscode.workspace.textDocuments) { + run(document); + } + + context.subscriptions.push( + collection, + + vscode.workspace.onDidOpenTextDocument(run), + + vscode.workspace.onDidChangeTextDocument((event) => { + schedule(event.document); }), - vscode.languages.registerCompletionItemProvider( - "wrn", - { provideCompletionItems: provideCompletions }, - "-", - "@", - "{", - ":", - ), + + vscode.workspace.onDidSaveTextDocument(run), + + vscode.workspace.onDidCloseTextDocument((document) => { + const key = document.uri.toString(); + + const timer = timers.get(key); + + if (timer) { + clearTimeout(timer); + timers.delete(key); + } + + collection.delete(document.uri); + }), + + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("wrnexus.diagnostics.enable")) { + return; + } + + for (const document of vscode.workspace.textDocuments) { + run(document); + } + }), + + { + dispose() { + for (const timer of timers.values()) { + clearTimeout(timer); + } + + timers.clear(); + }, + }, ); } /** - * Map a thrown ParseError to a VS Code diagnostic. The compiler encodes the - * failure position as `... at offset ` in the message; we resolve it to a - * range. Errors without an offset (e.g. "Unexpected end of input") anchor to the - * end of the document. - * @param {vscode.TextDocument} doc - * @param {unknown} err - * @returns {vscode.Diagnostic} + * Activate the WRNexus VS Code extension. + * + * @param {vscode.ExtensionContext} context */ -function toDiagnostic(doc, err) { - const message = err && err.message ? String(err.message) : "Failed to parse .wrn file"; - const match = /offset\s+(\d+)/.exec(message); - let range; - if (match) { - const offset = Number(match[1]); - const start = doc.positionAt(offset); - const wordRange = doc.getWordRangeAtPosition(start); - range = wordRange || new vscode.Range(start, doc.positionAt(offset + 1)); - } else { - const last = doc.lineAt(Math.max(0, doc.lineCount - 1)); - range = new vscode.Range(last.range.start, last.range.end); - } - const diag = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error); - diag.source = "wrn"; - return diag; -} +function activate(context) { + registerDiagnostics(context); + registerCompilerDiagnostics(context); -/** - * @param {vscode.TextDocument} document - * @param {vscode.Position} position - * @returns {vscode.CompletionItem[]} - */ -function provideCompletions(document, position) { - const line = document.lineAt(position).text; - const before = line.slice(0, position.character); - const full = document.getText(); - const upto = document.offsetAt(position); - const inView = isInsideBlock(full, upto, "view"); - - // `{t:` — offer nothing structured, just let the user type the key. - // `@` inside a view — event bindings. - if (inView && /@[A-Za-z-]*$/.test(before)) { - return EVENTS.map((ev) => { - const item = new vscode.CompletionItem(ev, vscode.CompletionItemKind.Event); - item.insertText = new vscode.SnippetString(`${ev}="$0"`); - item.detail = "wrn event binding"; - // Replace the `@`-less part already typed. - return item; - }); - } - - // `data-` inside a view — reactive attributes. - if (inView && /(^|\s)data-[A-Za-z-]*$/.test(before)) { - return DATA_ATTRS.map(([label, snippet, doc]) => { - const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Property); - item.insertText = new vscode.SnippetString(String(snippet)); - item.documentation = new vscode.MarkdownString(String(doc)); - item.detail = "wrn runtime attribute"; - const dashIdx = before.lastIndexOf("data-"); - item.range = new vscode.Range(position.line, dashIdx, position.line, position.character); - return item; - }); - } - - // Top-level block keywords, when not inside a view/style/functions body. - if (!inView && !isInsideBlock(full, upto, "style") && !isInsideBlock(full, upto, "functions")) { - return BLOCK_KEYWORDS.map(([label, snippet, doc]) => { - const item = new vscode.CompletionItem(String(label), vscode.CompletionItemKind.Keyword); - item.insertText = new vscode.SnippetString(String(snippet)); - item.documentation = new vscode.MarkdownString(String(doc)); - item.detail = "wrn block"; - return item; - }); - } - - return []; -} - -/** - * Very small heuristic: is `offset` inside a ` { ... }` block? Finds the - * nearest preceding `name {` opener and checks braces don't balance before the - * cursor. Good enough to gate view/style/functions-aware completions. - * @param {string} text - * @param {number} offset - * @param {string} name - * @returns {boolean} - */ -function isInsideBlock(text, offset, name) { - const re = new RegExp("\\b" + name + "\\b\\s*(?:[^\\n{]*)\\{", "g"); - let opener = -1; - let m; - while ((m = re.exec(text)) !== null) { - const bracePos = m.index + m[0].length - 1; - if (bracePos >= offset) break; - opener = bracePos; - } - if (opener === -1) return false; - // Count braces between the opener and the cursor; still open => inside. - let depth = 0; - for (let i = opener; i < offset && i < text.length; i++) { - const c = text[i]; - if (c === "{") depth++; - else if (c === "}") depth--; - } - return depth > 0; + registerCompletionProvider(context); + registerDefinitionProvider(context); + registerFormatter(context); } function deactivate() {} -module.exports = { activate, deactivate }; +module.exports = { + activate, + deactivate, + registerCompilerDiagnostics, + registerFormatter, + toCompilerDiagnostic, +}; diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js index 4fd1d8f4..e2890f03 100644 --- a/editors/vscode/src/formatter.js +++ b/editors/vscode/src/formatter.js @@ -19,24 +19,35 @@ const VOID_ELEMENTS = new Set([ function findOpeningTagEnd(value) { let quote = null; + let escaped = false; for (let index = 0; index < value.length; index += 1) { - const char = value[index]; + const character = value[index]; if (quote !== null) { - if (char === quote && value[index - 1] !== "\\") { + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === quote) { quote = null; } continue; } - if (char === '"' || char === "'") { - quote = char; + if (character === '"' || character === "'") { + quote = character; continue; } - if (char === ">") { + if (character === ">") { return index; } } @@ -46,7 +57,8 @@ function findOpeningTagEnd(value) { function parseAttributes(value) { const attributes = []; - const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g; + + const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s"'=<>`]+))?/g; let match; @@ -65,17 +77,21 @@ function parseOpeningTag(value) { } const openingPart = value.slice(0, endIndex + 1); + const remainder = value.slice(endIndex + 1).trim(); - const match = /^<([A-Za-z][\w:-]*)([\s\S]*?)(\/?)>$/.exec(openingPart); + const match = /^<([A-Za-z][\w$:.-]*)([\s\S]*?)(\/?)>$/.exec(openingPart); if (!match) { return null; } const tagName = match[1]; + const attributes = parseAttributes(match[2].trim()); + const selfClosing = match[3] === "/"; + const inlineClosing = remainder === ``; return { @@ -98,7 +114,9 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) { } const baseIndent = unit.repeat(depth); + const attributeIndent = unit.repeat(depth + 1); + const normalizedSingleLine = value.replace(/\s+/g, " ").trim(); const shouldBreak = @@ -159,42 +177,144 @@ function isMultilineOpeningTagStart(value) { } function isClosingTag(value) { - return /^<\/[A-Za-z][\w:-]*\s*>/.test(value); + return /^<\/[A-Za-z][\w$:.-]*\s*>/.test(value); } function isInlineElement(value) { - return /^<([A-Za-z][\w:-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value); + return /^<([A-Za-z][\w$:.-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value); } -function isWrnBlockClosing(value) { - return value === "}" || value.startsWith("} "); -} +function countLeadingClosingBraces(value) { + let index = 0; + let count = 0; -function isWrnBlockOpening(value) { - if (!value.endsWith("{")) { - return false; + while (index < value.length) { + while (index < value.length && /\s/.test(value[index])) { + index += 1; + } + + if (value[index] !== "}") { + break; + } + + count += 1; + index += 1; } - return !value.startsWith("{"); + return count; +} + +/** + * Count braces outside strings and HTML comments. + * + * This supports WRN blocks, function bodies, lifecycle hooks, + * watcher bodies and multiline JavaScript object literals. + */ +function countStructuralBraces(value) { + let openings = 0; + let closings = 0; + + let quote = null; + let escaped = false; + let htmlComment = false; + + for (let index = 0; index < value.length; index += 1) { + if (!quote && !htmlComment && value.startsWith("", index)) { + htmlComment = false; + index += 2; + continue; + } + + if (htmlComment) { + continue; + } + + const character = value[index]; + + if (quote !== null) { + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === quote) { + quote = null; + } + + continue; + } + + if (character === '"' || character === "'" || character === "`") { + quote = character; + continue; + } + + if (character === "{") { + openings += 1; + } else if (character === "}") { + closings += 1; + } + } + + return { + openings, + closings, + }; +} + +function collectOpeningTag(inputLines, startIndex) { + const collected = [inputLines[startIndex].trim()]; + + let index = startIndex; + + while (index + 1 < inputLines.length) { + const joined = collected.join(" "); + + if (findOpeningTagEnd(joined) !== -1) { + break; + } + + index += 1; + collected.push(inputLines[index].trim()); + } + + return { + value: collected.join(" "), + endIndex: index, + }; } function formatWrn(source, options = {}) { const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize || 4); const printWidth = options.printWidth || 100; + const inputLines = source.replace(/\r\n/g, "\n").split("\n"); + const output = []; - let wrnDepth = 0; + let codeDepth = 0; let htmlDepth = 0; let index = 0; let previousWasBlank = false; while (index < inputLines.length) { const originalLine = inputLines[index]; - const trimmed = originalLine.trim(); - if (trimmed === "") { + let value = originalLine.trim(); + + if (value === "") { if (!previousWasBlank && output.length > 0) { output.push(""); } @@ -206,43 +326,31 @@ function formatWrn(source, options = {}) { previousWasBlank = false; - let value = trimmed; - if (isMultilineOpeningTagStart(value)) { - const collected = [value]; - let cursor = index + 1; + const collected = collectOpeningTag(inputLines, index); - while (cursor < inputLines.length) { - const nextPart = inputLines[cursor].trim(); - collected.push(nextPart); - - const joined = collected.join(" "); - - if (findOpeningTagEnd(joined) !== -1) { - break; - } - - cursor += 1; - } - - value = collected.join(" "); - index = cursor; + value = collected.value; + index = collected.endIndex; } - if (isWrnBlockClosing(value)) { - wrnDepth = Math.max(0, wrnDepth - 1); - } + const leadingClosingBraces = countLeadingClosingBraces(value); + + const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces); + + let lineHtmlDepth = htmlDepth; if (isClosingTag(value)) { - htmlDepth = Math.max(0, htmlDepth - 1); + lineHtmlDepth = Math.max(0, htmlDepth - 1); } - const depth = wrnDepth + htmlDepth; + const depth = lineCodeDepth + lineHtmlDepth; if ( value.startsWith("<") && !value.startsWith("" } ] }, - "declaration": { - "match": "\\b(page|component|layout)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)?", + "match": "\\b(page|component|layout)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)?", "captures": { - "1": { "name": "storage.type.wrn keyword.control.wrn" }, - "2": { "name": "entity.name.type.wrn" } + "1": { + "name": "storage.type.wrn keyword.control.wrn" + }, + "2": { + "name": "entity.name.type.wrn" + } } }, - "blocks": { "patterns": [ - { "include": "#view-block" }, - { "include": "#style-block" }, - { "include": "#seo-block" }, - { "include": "#props-block" }, - { "include": "#realtime-block" }, - { "include": "#mode-block" }, - { "include": "#api-block" }, - { "include": "#functions-block" }, - { "include": "#state-decl" }, - { "include": "#layout-decl" }, - { "include": "#strings" } + { + "include": "#view-block" + }, + { + "include": "#style-block" + }, + { + "include": "#seo-block" + }, + { + "include": "#props-block" + }, + { + "include": "#lifecycle-block" + }, + { + "include": "#watch-block" + }, + { + "include": "#realtime-block" + }, + { + "include": "#mode-block" + }, + { + "include": "#api-block" + }, + { + "include": "#functions-block" + }, + { + "include": "#state-decl" + }, + { + "include": "#layout-decl" + }, + { + "include": "#strings" + } ] }, - "layout-decl": { "match": "\\b(layout)\\b\\s*(=)", "captures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "keyword.operator.assignment.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "keyword.operator.assignment.wrn" + } } }, - "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*(=)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "variable.other.wrn" }, - "3": { "name": "keyword.operator.assignment.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "variable.other.wrn" + }, + "3": { + "name": "keyword.operator.assignment.wrn" + } }, "end": "$", "contentName": "meta.embedded.expr.wrn", - "patterns": [{ "include": "source.ts" }] + "patterns": [ + { + "include": "source.ts" + } + ] }, - "props-block": { "begin": "\\b(props)\\b\\s*(\\{)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } }, "end": "\\}", "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } + "0": { + "name": "punctuation.definition.block.end.wrn" + } }, "patterns": [ - { "include": "#comments" }, { - "match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(=)", + "include": "#comments" + }, + { + "match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)", "captures": { - "1": { "name": "variable.parameter.wrn" }, - "2": { "name": "keyword.operator.assignment.wrn" } + "1": { + "name": "variable.parameter.wrn" + }, + "2": { + "name": "keyword.operator.assignment.wrn" + } } }, - { "include": "#strings" } + { + "include": "#strings" + } ] }, - "seo-block": { "begin": "\\b(seo)\\b\\s*(\\{)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } }, "end": "\\}", "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } + "0": { + "name": "punctuation.definition.block.end.wrn" + } }, "patterns": [ - { "include": "#comments" }, { - "match": "\\b([A-Za-z_][A-Za-z0-9_-]*)\\s*(=)", + "include": "#comments" + }, + { + "match": "\\b([A-Za-z_$][A-Za-z0-9_$-]*)\\s*(=)", "captures": { - "1": { "name": "support.type.property-name.wrn" }, - "2": { "name": "keyword.operator.assignment.wrn" } + "1": { + "name": "support.type.property-name.wrn" + }, + "2": { + "name": "keyword.operator.assignment.wrn" + } } }, - { "include": "#strings" } + { + "include": "#strings" + } ] }, - "style-block": { "begin": "\\b(style)\\b\\s*(\\{)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } }, "end": "\\}", "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } + "0": { + "name": "punctuation.definition.block.end.wrn" + } }, "contentName": "meta.embedded.block.css", - "patterns": [{ "include": "#css-braces" }, { "include": "source.css" }] + "patterns": [ + { + "include": "#css-braces" + }, + { + "include": "source.css" + } + ] }, - "css-braces": { "begin": "\\{", "end": "\\}", "contentName": "meta.embedded.block.css", - "patterns": [{ "include": "#css-braces" }, { "include": "source.css" }] + "patterns": [ + { + "include": "#css-braces" + }, + { + "include": "source.css" + } + ] }, - "functions-block": { "begin": "\\b(functions)\\b\\s*(\\{)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } }, "end": "\\}", "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } + "0": { + "name": "punctuation.definition.block.end.wrn" + } }, "contentName": "meta.embedded.block.ts", - "patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }] + "patterns": [ + { + "match": "\\b(async\\s+)?(function)\\s+([A-Za-z_$][A-Za-z0-9_$]*)", + "captures": { + "1": { + "name": "storage.modifier.async.ts" + }, + "2": { + "name": "storage.type.function.ts" + }, + "3": { + "name": "entity.name.function.wrn" + } + } + }, + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + }, + "lifecycle-block": { + "begin": "\\b(lifecycle)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "patterns": [ + { + "include": "#comments" + }, + { + "begin": "\\b(mount|update|unmount)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "entity.name.function.lifecycle.wrn keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + } + ] + }, + "watch-block": { + "begin": "\\b(watch)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "variable.other.readwrite.wrn" + }, + "3": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "match": "\\b(value|previous)\\b", + "name": "variable.language.wrn" + }, + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] }, - "ts-braces": { "begin": "\\{", "end": "\\}", "contentName": "meta.embedded.block.ts", - "patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }] - }, - - "api-block": { - "begin": "\\b(api)\\b\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)", - "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "constant.language.http-method.wrn" }, - "3": { "name": "string.unquoted.route.wrn" }, - "4": { "name": "punctuation.definition.block.begin.wrn" } - }, - "end": "\\}", - "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } - }, - "contentName": "meta.embedded.block.ts", - "patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }] - }, - - "mode-block": { - "begin": "\\b(ssr|client)\\b\\s*(\\{)", - "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } - }, - "end": "\\}", - "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } - }, "patterns": [ - { "include": "#comments" }, { - "begin": "\\b(api)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)", - "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "entity.name.function.wrn" }, - "3": { "name": "constant.language.http-method.wrn" }, - "4": { "name": "string.unquoted.route.wrn" }, - "5": { "name": "punctuation.definition.block.begin.wrn" } - }, - "end": "\\}", - "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } - }, - "contentName": "meta.embedded.block.ts", - "patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }] + "include": "#ts-braces" }, - { "include": "#functions-block" } - ] - }, - - "realtime-block": { - "begin": "\\b(realtime)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\{)", - "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "entity.name.function.wrn" }, - "3": { "name": "punctuation.definition.block.begin.wrn" } - }, - "end": "\\}", - "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } - }, - "patterns": [ - { "include": "#comments" }, { - "begin": "\\b(on)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\([^)]*\\))\\s*(\\{)", - "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "entity.name.function.event.wrn" }, - "3": { "name": "variable.parameter.wrn" }, - "4": { "name": "punctuation.definition.block.begin.wrn" } - }, - "end": "\\}", - "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } - }, - "contentName": "meta.embedded.block.ts", - "patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }] + "include": "source.ts" } ] }, - - "view-block": { - "begin": "\\b(view)\\b\\s*(\\{)", + "api-block": { + "begin": "\\b(api)\\b\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)", "beginCaptures": { - "1": { "name": "keyword.control.wrn" }, - "2": { "name": "punctuation.definition.block.begin.wrn" } + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "constant.language.http-method.wrn" + }, + "3": { + "name": "string.unquoted.route.wrn" + }, + "4": { + "name": "punctuation.definition.block.begin.wrn" + } }, "end": "\\}", "endCaptures": { - "0": { "name": "punctuation.definition.block.end.wrn" } + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + }, + "mode-block": { + "begin": "\\b(ssr|client)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "patterns": [ + { + "include": "#comments" + }, + { + "begin": "\\b(api)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "entity.name.function.wrn" + }, + "3": { + "name": "constant.language.http-method.wrn" + }, + "4": { + "name": "string.unquoted.route.wrn" + }, + "5": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + }, + { + "include": "#functions-block" + } + ] + }, + "realtime-block": { + "begin": "\\b(realtime)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "entity.name.function.wrn" + }, + "3": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "patterns": [ + { + "include": "#comments" + }, + { + "begin": "\\b(on)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\([^)]*\\))\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "entity.name.function.event.wrn" + }, + "3": { + "name": "variable.parameter.wrn" + }, + "4": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + } + ] + }, + "view-block": { + "begin": "\\b(view)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } }, "contentName": "meta.embedded.block.html", "patterns": [ - { "include": "#interpolation" }, - { "include": "#wire-event" }, - { "include": "text.html.basic" } + { + "include": "#interpolation" + }, + { + "include": "#wire-event" + }, + { + "include": "#conditional-class" + }, + { + "include": "text.html.basic" + } ] }, - "wire-event": { - "match": "(@)([A-Za-z][A-Za-z0-9_-]*)(=)", + "match": "(@)(?:(window|document)(:))?([A-Za-z][A-Za-z0-9_-]*)(\\s*=)", "captures": { - "1": { "name": "punctuation.definition.keyword.wrn" }, - "2": { "name": "entity.other.attribute-name.event.wrn" }, - "3": { "name": "keyword.operator.assignment.wrn" } + "1": { + "name": "punctuation.definition.keyword.wrn" + }, + "2": { + "name": "support.type.object.wrn" + }, + "3": { + "name": "punctuation.separator.key-value.wrn" + }, + "4": { + "name": "entity.other.attribute-name.event.wrn" + }, + "5": { + "name": "keyword.operator.assignment.wrn" + } + } + }, + "conditional-class": { + "match": "\\b(class)(:)([^\\s=]+)(\\s*=)", + "captures": { + "1": { + "name": "entity.other.attribute-name.class.wrn" + }, + "2": { + "name": "punctuation.separator.key-value.wrn" + }, + "3": { + "name": "string.unquoted.class-name.wrn" + }, + "4": { + "name": "keyword.operator.assignment.wrn" + } } }, - "interpolation": { "patterns": [ { "name": "meta.interpolation.i18n.wrn", "match": "(\\{)(t:)([^}]*)(\\})", "captures": { - "1": { "name": "punctuation.section.interpolation.begin.wrn" }, - "2": { "name": "keyword.control.i18n.wrn" }, - "3": { "name": "string.other.i18n-key.wrn" }, - "4": { "name": "punctuation.section.interpolation.end.wrn" } + "1": { + "name": "punctuation.section.interpolation.begin.wrn" + }, + "2": { + "name": "keyword.control.i18n.wrn" + }, + "3": { + "name": "string.other.i18n-key.wrn" + }, + "4": { + "name": "punctuation.section.interpolation.end.wrn" + } } }, { @@ -268,37 +601,63 @@ "begin": "\\{", "end": "\\}", "beginCaptures": { - "0": { "name": "punctuation.section.interpolation.begin.wrn" } + "0": { + "name": "punctuation.section.interpolation.begin.wrn" + } }, "endCaptures": { - "0": { "name": "punctuation.section.interpolation.end.wrn" } + "0": { + "name": "punctuation.section.interpolation.end.wrn" + } }, "contentName": "meta.embedded.expr.wrn", - "patterns": [{ "include": "#interp-braces" }, { "include": "source.ts" }] + "patterns": [ + { + "include": "#interp-braces" + }, + { + "include": "source.ts" + } + ] } ] }, - "interp-braces": { "begin": "\\{", "end": "\\}", "contentName": "meta.embedded.expr.wrn", - "patterns": [{ "include": "#interp-braces" }, { "include": "source.ts" }] + "patterns": [ + { + "include": "#interp-braces" + }, + { + "include": "source.ts" + } + ] }, - "strings": { "patterns": [ { "name": "string.quoted.double.wrn", "begin": "\"", "end": "\"", - "patterns": [{ "name": "constant.character.escape.wrn", "match": "\\\\." }] + "patterns": [ + { + "name": "constant.character.escape.wrn", + "match": "\\\\." + } + ] }, { "name": "string.quoted.single.wrn", "begin": "'", "end": "'", - "patterns": [{ "name": "constant.character.escape.wrn", "match": "\\\\." }] + "patterns": [ + { + "name": "constant.character.escape.wrn", + "match": "\\\\." + } + ] } ] } diff --git a/editors/vscode/wrnexus-0.2.7.vsix b/editors/vscode/wrnexus-0.2.7.vsix new file mode 100644 index 00000000..8312e3e9 Binary files /dev/null and b/editors/vscode/wrnexus-0.2.7.vsix differ diff --git a/packages/ai/package.json b/packages/ai/package.json index 182e5687..bb0b66b2 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.31", + "version": "0.2.32", "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 8ca553a8..bc7ede0d 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 47e61186..f8f096e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 47ccb7be..04ce7f66 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -357,6 +357,15 @@ const MIGRATIONS: Migration[] = [ // No generated application files require automatic migration. }, }, + { + version: "0.2.32", + id: "fix-runtime-bug", + description: + "Fixes safe encoding and browser parsing of multiline component behavior metadata.", + apply() { + // No generated application files require automatic migration. + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/compiler/package.json b/packages/compiler/package.json index c99b5de1..9c13b16c 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index ddc9da00..64177722 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -925,7 +925,9 @@ function behaviorAttribute(behavior: ComponentBehavior | null): string { return ""; } - return ` data-wrn-behavior="${attrEscape(JSON.stringify(behavior))}"`; + const encoded = encodeURIComponent(JSON.stringify(behavior)); + + return ` data-wrn-behavior="${encoded}"`; } const INTERP_RE = /\{([^{}]+)\}/g; diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 03fee9de..fa51d935 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -382,11 +382,11 @@ component LifecycleTest { `), ); - expect(output).toContain(""mount""); + expect(output).toContain("%22mount%22"); - expect(output).toContain(""update""); + expect(output).toContain("%22update%22"); - expect(output).toContain(""unmount""); + expect(output).toContain("%22unmount%22"); }); test("component watchers compile into behavior metadata", () => { const output = generate( @@ -405,7 +405,7 @@ component WatchTest { `), ); - expect(output).toContain(""state":"visible""); + expect(output).toContain("%22state%22%3A%22visible%22"); expect(output).toContain("console.log"); }); @@ -436,3 +436,121 @@ component BehaviorOnly { expect(output).toContain("data-wrn-behavior"); }); +test("component behavior metadata safely encodes multiline code", () => { + const output = generate( + parse(` +component BackToTop { + state visible = false + + functions { + function updateVisibility() { + visible = window.scrollY > 500 + } + + function scrollToTop() { + window.scrollTo({ + top: 0, + behavior: "smooth" + }) + } + } + + lifecycle { + mount { + updateVisibility() + } + } + + watch visible { + console.log( + value, + previous + ) + } + + view { + + } +} +`), + ); + + expect(output).toContain("data-wrn-behavior="); + + expect(output).toContain("%7B"); + expect(output).toContain("%22functions%22"); + + expect(output).not.toContain('data-wrn-behavior="{'); +}); + +test("component watchers compile into behavior metadata", () => { + const output = generate( + parse(` +component WatchTest { + state visible = false + + watch visible { + console.log("changed", visible) + } + + view { +
{visible}
+ } +} +`), + ); + + const match = /data-wrn-behavior="([^"]+)"/.exec(output); + + expect(match).not.toBeNull(); + + const behavior = JSON.parse(decodeURIComponent(match![1])); + + expect(behavior.watches).toEqual([ + { + state: "visible", + body: expect.stringContaining('console.log("changed", visible)'), + }, + ]); +}); +test("component lifecycle hooks compile into behavior metadata", () => { + const output = generate( + parse(` +component LifecycleTest { + state ready = false + + lifecycle { + mount { + ready = true + } + + update { + console.log(ready) + } + + unmount { + ready = false + } + } + + view { +
{ready}
+ } +} +`), + ); + + const match = /data-wrn-behavior="([^"]+)"/.exec(output); + + expect(match).not.toBeNull(); + + const behavior = JSON.parse(decodeURIComponent(match![1])); + + expect(behavior.lifecycle.mount).toContain("ready = true"); + + expect(behavior.lifecycle.update).toContain("console.log(ready)"); + + expect(behavior.lifecycle.unmount).toContain("ready = false"); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 4abf7cb0..74c2243e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index c113a266..e3fc0984 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 872e47de..1b129d46 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -44,13 +44,32 @@ export const REACTIVE_RUNTIME = String.raw` } function parseBehavior(element) { - var raw = element.getAttribute("data-wrn-behavior"); - if (!raw) return null; + var raw = element.getAttribute( + "data-wrn-behavior", + ); + + if (!raw) { + return null; + } + try { - var parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : null; + var decoded = decodeURIComponent(raw); + var parsed = JSON.parse(decoded); + + if ( + !parsed || + typeof parsed !== "object" + ) { + return null; + } + + return parsed; } catch (error) { - console.error("[wrnexus] failed to parse component behavior", error); + console.error( + "[wrnexus] failed to parse component behavior", + error, + ); + return null; } } diff --git a/packages/db/package.json b/packages/db/package.json index 5f7945ef..b6f1ee1c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 3079b3c3..11de4324 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 277e1ca2..d723b969 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 1be148c7..19869f3b 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.31", + "version": "0.2.32", "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 96e2d25a..367f8e92 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index c9976710..85e77084 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 5987d781..5fb8eb79 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index e94dc4e2..e5eb35e6 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 5246c971..e2af2548 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 2666ed0e..9dde2fe9 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index e1f30a7e..239e67ec 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index 80fbc1fb..49cf2b8e 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index cd0c372b..3ade68c0 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 84293f68..7492720a 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index b7b8059d..7a019e39 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.31", + "version": "0.2.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index 08b19609..a0072d6f 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 4ec18f8b..17393a6b 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 30706e92..73118372 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 7ed7f3e4..7f6ec631 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index b713242c..0b7d6b7a 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.31", + "version": "0.2.32", "private": true, "type": "module", "main": "src/index.ts",