Compare commits

...
2 Commits
Author SHA1 Message Date
Clintchiz 936488ac06 release: WRNexusJS 0.2.33 2026-07-14 23:13:46 +05:30
Clintchiz 959ed24009 release: WRNexusJS 0.2.32 2026-07-14 22:39:50 +05:30
66 changed files with 3214 additions and 649 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.", "description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+11 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.", "description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js" "wrnexus": "./dist/index.js"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31", "@wrnexus/core": "^0.2.33",
"@wrnexus/router": "^0.2.31", "@wrnexus/router": "^0.2.33",
"@wrnexus/csr": "^0.2.31", "@wrnexus/csr": "^0.2.33",
"@wrnexus/compiler": "^0.2.31", "@wrnexus/compiler": "^0.2.33",
"@wrnexus/styles": "^0.2.31", "@wrnexus/styles": "^0.2.33",
"@wrnexus/dev-server": "^0.2.31", "@wrnexus/dev-server": "^0.2.33",
"@wrnexus/ui": "^0.2.31", "@wrnexus/ui": "^0.2.33",
"@wrnexus/validation": "^0.2.31", "@wrnexus/validation": "^0.2.33",
"@wrnexus/i18n": "^0.2.31", "@wrnexus/i18n": "^0.2.33",
"@wrnexus/db": "^0.2.31" "@wrnexus/db": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.", "description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.", "description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.", "description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.", "description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+13 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.", "description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -25,18 +25,18 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31", "@wrnexus/core": "^0.2.33",
"@wrnexus/router": "^0.2.31", "@wrnexus/router": "^0.2.33",
"@wrnexus/ssr": "^0.2.31", "@wrnexus/ssr": "^0.2.33",
"@wrnexus/csr": "^0.2.31", "@wrnexus/csr": "^0.2.33",
"@wrnexus/compiler": "^0.2.31", "@wrnexus/compiler": "^0.2.33",
"@wrnexus/styles": "^0.2.31", "@wrnexus/styles": "^0.2.33",
"@wrnexus/ui": "^0.2.31", "@wrnexus/ui": "^0.2.33",
"@wrnexus/validation": "^0.2.31", "@wrnexus/validation": "^0.2.33",
"@wrnexus/i18n": "^0.2.31", "@wrnexus/i18n": "^0.2.33",
"@wrnexus/db": "^0.2.31", "@wrnexus/db": "^0.2.33",
"@wrnexus/pubsub": "^0.2.31", "@wrnexus/pubsub": "^0.2.33",
"@wrnexus/uploader": "^0.2.31" "@wrnexus/uploader": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.", "description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.", "description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.", "description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.", "description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/native": "^0.2.31" "@wrnexus/native": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.", "description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.", "description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.", "description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.", "description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.", "description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.", "description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,8 +21,8 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/compiler": "^0.2.31", "@wrnexus/compiler": "^0.2.33",
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.", "description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.", "description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/uploader": "^0.2.31" "@wrnexus/uploader": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.", "description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.", "description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.", "description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css" "./ui.css": "./ui.css"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist", "dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.", "description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
@@ -21,7 +21,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.33"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.", "description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+756 -47
View File
@@ -1,54 +1,614 @@
# WRNexus Language Support # WRNexus Language Support
Language support for [WRNexus](https://wrnexusjs.dev) `.wrn` files — the SSR-first, Complete VS Code language support for [WRNexus](https://wrnexusjs.dev) `.wrn` files.
Bun-powered full-stack framework.
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 ## Installation
After the first Marketplace release, search for **WRNexus Language Support** in Search for **WRNexus Language Support** in the VS Code Extensions view or run:
VS Code or run:
```text ```text
ext install wrnexus.wrnexus ext install wrnexus.wrnexus
``` ```
For a local package, open the Command Palette, choose **Extensions: Install from For a local build:
VSIX**, and select the generated `wrnexus-<version>.vsix` file.
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-<version>.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 ## Features
- **Syntax highlighting** ("color-full code"). Keywords (`page`, `component`, ### Syntax highlighting
`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`.
## Settings The extension provides highlighting for WRNexus declarations and embedded languages.
| Setting | Default | Description | Supported top-level declarations:
| ---------------------------- | ------- | ----------------------------------------- |
| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. |
| `wrnexus.format.enable` | `true` | Enable the built-in formatter for `.wrn`. |
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 {
<main>
<h1>User {id}</h1>
</main>
}
}
```
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 {
<button @click="count++">
Count: {count}
</button>
}
}
```
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 {
<header>
<nav>Navigation</nav>
</header>
<main>
{content}
</main>
}
}
```
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 {
<button @click="increment()">
Increment
</button>
<button @click="reset()">
Reset
</button>
<p>{count}</p>
}
}
```
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 {
<button @click="open = !open">
Toggle menu
</button>
}
}
```
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
<div @window:scroll="updateScrollPosition()">
</div>
```
```wrn
<div @window:resize="updateViewportSize()">
</div>
```
```wrn
<div @window:keydown="handleKeyboardShortcut()">
</div>
```
### Document events
```wrn
<div @document:click="closeMenu()">
</div>
```
```wrn
<div @document:visibilitychange="handleVisibilityChange()">
</div>
```
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 {
<button
type="button"
@click="scrollToTop()"
aria-label="Back to top"
class="fixed bottom-5 right-5"
class:pointer-events-none="!visible"
class:translate-y-4="!visible"
class:opacity-0="!visible"
class:pointer-events-auto="visible"
class:translate-y-0="visible"
class:opacity-100="visible"
>
Back to top
</button>
}
}
```
## Conditional classes
Use `class:<class-name>` to toggle CSS classes reactively:
```wrn
<button
class:opacity-100="visible"
class:pointer-events-auto="visible"
class:opacity-0="!visible"
class:pointer-events-none="!visible"
>
Action
</button>
```
The expression is evaluated whenever its reactive dependencies change.
## Event bindings
Standard element events:
```wrn
<button @click="submitForm()">
Submit
</button>
```
```wrn
<input @input="query = event.target.value" />
```
```wrn
<form @submit="handleSubmit()">
</form>
```
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
<div data-show="open">
Visible when open is true
</div>
```
### Reactive loops
```wrn
<ul>
<li data-for="user in users">
{user.name}
</li>
</ul>
```
### Reactive text
```wrn
<span data-text="message"></span>
```
### Dynamic attributes
```wrn
<button disabled="{loading}">
Save
</button>
```
## 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 ```json
"[wrn]": { "[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 ## Privacy
The extension does not collect telemetry and does not send source code or usage The extension does not collect telemetry.
data over the network. Formatting, completion, and diagnostics run locally.
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 ## Development
Diagnostics use a bundled copy of the compiler. Build and validate everything Install dependencies:
after changing the extension or `@wrnexus/compiler`:
```bash ```bash
bun install bun install
```
Rebuild the bundled compiler:
```bash
bun run build:compiler
```
Validate the extension:
```bash
bun run check bun run check
```
Package a VSIX:
```bash
bun run package bun run package
``` ```
`bun run package` creates `wrnexus-<version>.vsix`. The extension still This creates:
highlights, provides snippets, and completes without the compiler bundle; only
inline error reporting requires it.
Open this folder in VS Code and press <kbd>F5</kbd> to launch an Extension ```text
Development Host, then open any `.wrn` file. wrnexus-<version>.vsix
```
See the Install the local package:
[Marketplace publishing guide](https://git.workroot.in/WorkRoot/WRNexusJS/src/branch/main/editors/vscode/PUBLISHING.md)
for the release procedure. ```bash
code --install-extension ./wrnexus-<version>.vsix --force
```
Open the extension folder in VS Code and press <kbd>F5</kbd> 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.
+81 -7
View File
@@ -1,8 +1,8 @@
{ {
"name": "wrnexus", "name": "wrnexus",
"displayName": "WRNexus Language Support", "displayName": "WRNexus Language Support",
"description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.", "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.6", "version": "0.2.7",
"publisher": "wrnexus", "publisher": "wrnexus",
"private": true, "private": true,
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
@@ -32,15 +32,22 @@
"Formatters" "Formatters"
], ],
"keywords": [ "keywords": [
"wire",
"wrnexus", "wrnexus",
"wrn", "wrn",
"wire",
"wirejs", "wirejs",
"language support", "language support",
"formatter", "formatter",
"diagnostics",
"syntax highlighting", "syntax highlighting",
"autocomplete",
"snippets",
"lifecycle",
"state watcher",
"components",
"full-stack", "full-stack",
"ssr", "ssr",
"csr",
"bun" "bun"
], ],
"main": "./src/extension.js", "main": "./src/extension.js",
@@ -52,7 +59,8 @@
{ {
"id": "wrn", "id": "wrn",
"aliases": [ "aliases": [
"WrNexus", "WRNexus",
"WRN",
"wrn" "wrn"
], ],
"extensions": [ "extensions": [
@@ -92,23 +100,82 @@
} }
], ],
"configuration": { "configuration": {
"title": "WrNexus", "title": "WRNexus",
"properties": { "properties": {
"wrnexus.diagnostics.enable": { "wrnexus.diagnostics.enable": {
"type": "boolean", "type": "boolean",
"default": true, "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": { "wrnexus.format.enable": {
"type": "boolean", "type": "boolean",
"default": true, "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": { "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": { "editor.tokenColorCustomizations": {
"textMateRules": [ "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", "scope": "entity.other.attribute-name.wrn.directive",
"settings": { "settings": {
@@ -123,6 +190,13 @@
"fontStyle": "bold" "fontStyle": "bold"
} }
}, },
{
"scope": "entity.other.attribute-name.event.wrn",
"settings": {
"foreground": "#f472b6",
"fontStyle": "bold"
}
},
{ {
"scope": "punctuation.definition.keyword.wrn", "scope": "punctuation.definition.keyword.wrn",
"settings": { "settings": {
+195 -21
View File
@@ -22,7 +22,7 @@
"WRN dynamic page": { "WRN dynamic page": {
"prefix": ["wrn-dynamic-page", "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": [ "body": [
"page ${1:DynamicPage} {", "page ${1:DynamicPage} {",
" layout = \"${2:default}\"", " layout = \"${2:default}\"",
@@ -40,13 +40,15 @@
"WRN component": { "WRN component": {
"prefix": ["wrn-component", "component"], "prefix": ["wrn-component", "component"],
"description": "Create a WRN component", "description": "Create a reusable WRN component",
"body": [ "body": [
"component ${1:ComponentName} {", "component ${1:ComponentName} {",
" props {", " props {",
" ${2:title} = \"${3:Title}\"", " ${2:title} = \"${3:Title}\"",
" }", " }",
"", "",
" state ${4:ready} = false",
"",
" view {", " view {",
" <div>", " <div>",
" <h2>{${2:title}}</h2>", " <h2>{${2:title}}</h2>",
@@ -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 {",
" <div>{${2:active}}</div>",
" }",
"}"
]
},
"WRN layout": { "WRN layout": {
"prefix": ["wrn-layout", "layout"], "prefix": ["wrn-layout", "layout"],
"description": "Create a WRN layout", "description": "Create a reusable WRN layout",
"body": [ "body": [
"layout ${1:LayoutName} {", "layout ${1:LayoutName} {",
" view {", " view {",
@@ -76,6 +112,74 @@
"body": ["state ${1:name} = ${2:\"value\"}"] "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": { "WRN SEO block": {
"prefix": ["wrn-seo", "seo"], "prefix": ["wrn-seo", "seo"],
"description": "Create SEO metadata", "description": "Create SEO metadata",
@@ -127,12 +231,36 @@
"body": ["class:${1:border-indigo-500}=\"${2:condition}\""] "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": { "WRN click event": {
"prefix": ["wrn-click", "click"], "prefix": ["wrn-click", "click"],
"description": "Add a click handler", "description": "Add a click event handler",
"body": ["@click=\"${1: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": { "WRN show directive": {
"prefix": ["wrn-show", "show"], "prefix": ["wrn-show", "show"],
"description": "Conditionally show an element", "description": "Conditionally show an element",
@@ -158,7 +286,7 @@
"api GET ${1:/api/resource} {", "api GET ${1:/api/resource} {",
" return Response.json({", " return Response.json({",
" ${2:success}: true", " ${2:success}: true",
" });", " })",
"}" "}"
] ]
}, },
@@ -168,32 +296,19 @@
"description": "Create a POST API route", "description": "Create a POST API route",
"body": [ "body": [
"api POST ${1:/api/resource} {", "api POST ${1:/api/resource} {",
" const body = await ctx.request.json();", " const body = await ctx.request.json()",
"", "",
" return Response.json({", " return Response.json({",
" success: true,", " success: true,",
" data: body", " 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": { "WRN loading state": {
"prefix": ["wrn-loading", "loading-state"], "prefix": ["wrn-loading", "loading-state"],
"description": "Create loading state and button", "description": "Create loading state and a reactive button",
"body": [ "body": [
"state loading = false", "state loading = false",
"", "",
@@ -206,5 +321,64 @@
" {loading ? \"Loading...\" : \"${1:Submit}\"}", " {loading ? \"Loading...\" : \"${1:Submit}\"}",
"</button>" "</button>"
] ]
},
"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 {",
" <button",
" type=\"button\"",
" @click=\"scrollToTop()\"",
" aria-label=\"Back to top\"",
" class=\"fixed bottom-5 right-5\"",
" class:pointer-events-none=\"!visible\"",
" class:translate-y-4=\"!visible\"",
" class:opacity-0=\"!visible\"",
" class:pointer-events-auto=\"visible\"",
" class:translate-y-0=\"visible\"",
" class:opacity-100=\"visible\"",
" >",
" ${2:Back to top}",
" </button>",
" }",
"}"
]
} }
} }
+91 -6
View File
@@ -289,6 +289,8 @@ function parse(source) {
const functions = []; const functions = [];
const dataApis = []; const dataApis = [];
const modeFunctions = []; const modeFunctions = [];
const lifecycle = {};
const watches = [];
const apis = []; const apis = [];
const realtimes = []; const realtimes = [];
while (lx.peek().type !== "rbrace") { while (lx.peek().type !== "rbrace") {
@@ -413,6 +415,40 @@ function parse(source) {
styles.push(lx.readBalancedBraces()); styles.push(lx.readBalancedBraces());
break; 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": { case "functions": {
lx.next(); lx.next();
functions.push(lx.readBalancedBraces()); functions.push(lx.readBalancedBraces());
@@ -423,6 +459,12 @@ function parse(source) {
} }
} }
expect("rbrace"); 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 { return {
type: "page", type: "page",
kind, kind,
@@ -436,6 +478,8 @@ function parse(source) {
functions, functions,
dataApis, dataApis,
modeFunctions, modeFunctions,
lifecycle,
watches,
apis, apis,
realtimes realtimes
}; };
@@ -716,10 +760,18 @@ function renderAttr(attr) {
} }
} }
function eventAttribute(name) { 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)}`; 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-wrnexus-mobile-${name.slice(7)}`;
}
return `data-on-${name}`; return `data-on-${name}`;
} }
function reactiveAttrValue(raw, reactive) { function reactiveAttrValue(raw, reactive) {
@@ -924,7 +976,7 @@ function renderNestedComponentInvocation(node, ctx) {
let bindIndex = 0; let bindIndex = 0;
const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => { const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
if (attr.event) { if (attr.event) {
return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`; return escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`);
} }
if (attr.boolean) { if (attr.boolean) {
return ` ${attr.name}`; return ` ${attr.name}`;
@@ -1262,6 +1314,34 @@ function safeRef(name) {
function escLit(s) { function escLit(s) {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); 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; var INTERP_RE = /\{([^{}]+)\}/g;
function exprRefsState(expr, stateNames) { function exprRefsState(expr, stateNames) {
for (const name of 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) => { const attrs = node.attrs.filter((a) => a.name !== "class" && !a.name.startsWith("class:")).map((a) => {
if (a.event) { if (a.event) {
return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`; return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
} }
if (a.boolean) { if (a.boolean) {
return ` ${a.name}`; return ` ${a.name}`;
@@ -1400,11 +1480,13 @@ function generateComponent(ast) {
${styles.map(styleEscape).join(` ${styles.map(styleEscape).join(`
`)} `)}
</style>`) : ""; </style>`) : "";
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 = [ const scopeKeys = [
...effectiveProps.map((prop) => prop.name), ...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name) ...ast.states.map((state) => state.name)
]; ];
const behaviorAttr = behaviorAttribute(behavior);
const decls = []; const decls = [];
for (const prop of effectiveProps) { for (const prop of effectiveProps) {
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`); 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) { for (const state of ast.states) {
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`); decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
} }
const returnExpr = needsScope ? "`" + styleTag + '<div data-scope="${__scope}">' + viewCode + "</div>`" : "`" + styleTag + viewCode + "`"; const returnExpr = needsScope ? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} }); const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });
` : needsScope ? ` const __scope = ""; ` : needsScope ? ` const __scope = "";
` : ""; ` : "";
@@ -1421,6 +1503,9 @@ ${styles.map(styleEscape).join(`
} else { } else {
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); 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 { out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def; if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v); if (typeof def === "number") return Number(v);
+356 -30
View File
@@ -20,15 +20,26 @@ const BLOCK_COMPLETIONS = [
{ {
label: "component", label: "component",
detail: "WRN 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: [ snippet: [
"component ${1:ComponentName} {", "component ${1:ComponentName} {",
" props {", " state ${2:ready} = false",
' ${2:title} = "${3:Title}"', "",
" functions {",
" function ${3:initialize}() {",
" $0",
" }",
" }",
"",
" lifecycle {",
" mount {",
" ${3:initialize}()",
" }",
" }", " }",
"", "",
" view {", " view {",
" $0", " <div></div>",
" }", " }",
"}", "}",
].join("\n"), ].join("\n"),
@@ -47,9 +58,16 @@ const BLOCK_COMPLETIONS = [
"}", "}",
].join("\n"), ].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", label: "seo",
detail: "SEO metadata block", detail: "SEO metadata block",
documentation: "Declare title, description, and other page metadata.",
snippet: [ snippet: [
"seo {", "seo {",
' title = "${1:Page title}"', ' title = "${1:Page title}"',
@@ -60,13 +78,84 @@ const BLOCK_COMPLETIONS = [
{ {
label: "view", label: "view",
detail: "WRN view block", detail: "WRN view block",
documentation: "Declare the HTML view rendered by the page, component, or layout.",
snippet: ["view {", " $0", "}"].join("\n"), snippet: ["view {", " $0", "}"].join("\n"),
}, },
{ {
label: "state", label: "state",
detail: "Reactive state declaration", detail: "Reactive state declaration",
documentation: "Declare reactive state owned by the current component, layout, or page.",
snippet: 'state ${1:name} = ${2:"value"}', snippet: 'state ${1:name} = ${2:"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 = [ const ATTRIBUTE_COMPLETIONS = [
@@ -76,6 +165,19 @@ const ATTRIBUTE_COMPLETIONS = [
["@submit", "Submit event handler", '@submit="${1:handler()}"'], ["@submit", "Submit event handler", '@submit="${1:handler()}"'],
["@focus", "Focus event handler", '@focus="${1:handler()}"'], ["@focus", "Focus event handler", '@focus="${1:handler()}"'],
["@blur", "Blur event handler", '@blur="${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-show", "Conditional visibility", 'data-show="${1:condition}"'],
["data-for", "Reactive loop", 'data-for="${1:item} in ${2:items}"'], ["data-for", "Reactive loop", 'data-for="${1:item} in ${2:items}"'],
["class:", "Conditional CSS class", 'class:${1:border-indigo-500}="${2:condition}"'], ["class:", "Conditional CSS class", 'class:${1:border-indigo-500}="${2:condition}"'],
@@ -90,6 +192,11 @@ const CONTEXT_COMPLETIONS = [
["ctx.locals", "Request-local data"], ["ctx.locals", "Request-local data"],
]; ];
const WATCH_VALUE_COMPLETIONS = [
["value", "Current watcher state value"],
["previous", "Previous watcher state value"],
];
function completionKindFor(label) { function completionKindFor(label) {
if (label.startsWith("@")) { if (label.startsWith("@")) {
return vscode.CompletionItemKind.Event; return vscode.CompletionItemKind.Event;
@@ -99,15 +206,47 @@ function completionKindFor(label) {
return vscode.CompletionItemKind.Property; return vscode.CompletionItemKind.Property;
} }
if (label === "function" || label === "functions") {
return vscode.CompletionItemKind.Function;
}
return vscode.CompletionItemKind.Keyword; return vscode.CompletionItemKind.Keyword;
} }
function createCompletion(label, detail, snippet) { function createCompletion(label, detail, snippet, documentation) {
const item = new vscode.CompletionItem(label, completionKindFor(label)); const item = new vscode.CompletionItem(label, completionKindFor(label));
item.detail = detail; item.detail = detail;
item.insertText = new vscode.SnippetString(snippet || label); 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; return item;
} }
@@ -118,6 +257,7 @@ function getCurrentOpeningTag(document, position) {
); );
const lastOpen = textBeforeCursor.lastIndexOf("<"); const lastOpen = textBeforeCursor.lastIndexOf("<");
const lastClose = textBeforeCursor.lastIndexOf(">"); const lastClose = textBeforeCursor.lastIndexOf(">");
if (lastOpen > lastClose) { if (lastOpen > lastClose) {
@@ -129,6 +269,7 @@ function getCurrentOpeningTag(document, position) {
function extractRouteParams(document) { function extractRouteParams(document) {
const fileName = document.fileName.replace(/\\/g, "/"); const fileName = document.fileName.replace(/\\/g, "/");
const matches = [...fileName.matchAll(/\[([A-Za-z_$][\w$]*)\]/g)]; const matches = [...fileName.matchAll(/\[([A-Za-z_$][\w$]*)\]/g)];
return matches.map((match) => match[1]); return matches.map((match) => match[1]);
@@ -136,13 +277,195 @@ function extractRouteParams(document) {
function extractStates(document) { function extractStates(document) {
const source = document.getText(); const source = document.getText();
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/gm)]; const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/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) { function provideCompletionItems(document, position) {
const items = []; const items = [];
const linePrefix = document.lineAt(position.line).text.slice(0, position.character); const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
const openingTag = getCurrentOpeningTag(document, position); const openingTag = getCurrentOpeningTag(document, position);
@@ -151,10 +474,10 @@ function provideCompletionItems(document, position) {
for (const [label, detail, snippet] of ATTRIBUTE_COMPLETIONS) { for (const [label, detail, snippet] of ATTRIBUTE_COMPLETIONS) {
items.push(createCompletion(label, detail, snippet)); items.push(createCompletion(label, detail, snippet));
} }
} else if (isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
} else { } else {
for (const completion of BLOCK_COMPLETIONS) { addBlockCompletions(items, document, position);
items.push(createCompletion(completion.label, completion.detail, completion.snippet));
}
} }
if (linePrefix.includes("ctx.") || linePrefix.includes("ctx.params.")) { if (linePrefix.includes("ctx.") || linePrefix.includes("ctx.params.")) {
@@ -164,31 +487,27 @@ function provideCompletionItems(document, position) {
} }
for (const param of extractRouteParams(document)) { 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`; items.push(
item.insertText = param; createVariableCompletion(`ctx.params.${param}`, `Route parameter from [${param}].wrn`),
items.push(item);
const fullItem = new vscode.CompletionItem(
`ctx.params.${param}`,
vscode.CompletionItemKind.Variable,
); );
fullItem.detail = `Route parameter from [${param}].wrn`;
fullItem.insertText = `ctx.params.${param}`;
items.push(fullItem);
} }
for (const state of extractStates(document)) { if (!isAfterWatchKeyword(document, position)) {
const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.Variable); addStateCompletions(items, document, position);
}
item.detail = "WRN state"; for (const prop of extractProps(document)) {
item.insertText = state; 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; return items;
@@ -196,7 +515,10 @@ function provideCompletionItems(document, position) {
function registerCompletionProvider(context) { function registerCompletionProvider(context) {
const provider = vscode.languages.registerCompletionItemProvider( const provider = vscode.languages.registerCompletionItemProvider(
{ language: "wrn", scheme: "file" }, {
language: "wrn",
scheme: "file",
},
{ {
provideCompletionItems, provideCompletionItems,
}, },
@@ -205,13 +527,17 @@ function registerCompletionProvider(context) {
".", ".",
"<", "<",
" ", " ",
"(",
); );
context.subscriptions.push(provider); context.subscriptions.push(provider);
} }
module.exports = { module.exports = {
extractFunctions,
extractProps,
extractRouteParams, extractRouteParams,
extractStates, extractStates,
provideCompletionItems,
registerCompletionProvider, registerCompletionProvider,
}; };
+490 -22
View File
@@ -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_TOP_LEVEL_KINDS = new Set(["page", "component", "layout"]);
const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]);
const VALID_MEMBERS = { const VALID_MEMBERS = {
page: new Set([ page: new Set([
"layout", "layout",
@@ -23,9 +25,9 @@ const VALID_MEMBERS = {
"realtime", "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( function createDiagnostic(
@@ -59,7 +61,6 @@ function lineDiagnostic(
code, code,
) { ) {
const line = document.lineAt(lineNumber); const line = document.lineAt(lineNumber);
const diagnostic = new vscode.Diagnostic(line.range, message, severity); const diagnostic = new vscode.Diagnostic(line.range, message, severity);
diagnostic.source = "WRNexus"; diagnostic.source = "WRNexus";
@@ -316,6 +317,15 @@ function validateHtmlTags(document, source) {
function getRootBodyRange(source, rootMatch) { function getRootBodyRange(source, rootMatch) {
const openingBrace = rootMatch.index + rootMatch[0].lastIndexOf("{"); 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 depth = 0;
let quote = null; let quote = null;
let escaped = false; let escaped = false;
@@ -346,6 +356,17 @@ function getRootBodyRange(source, rootMatch) {
continue; continue;
} }
if (source.startsWith("<!--", index)) {
const commentEnd = source.indexOf("-->", index + 4);
if (commentEnd === -1) {
return -1;
}
index = commentEnd + 2;
continue;
}
if (character === "{") { if (character === "{") {
depth += 1; depth += 1;
continue; continue;
@@ -355,22 +376,44 @@ function getRootBodyRange(source, rootMatch) {
depth -= 1; depth -= 1;
if (depth === 0) { if (depth === 0) {
return { return index;
start: openingBrace + 1,
end: 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 { return {
start: openingBrace + 1, name: source.slice(start, index),
end: source.length, start,
end: index,
}; };
} }
function findRootMembers(source, bodyStart, bodyEnd) { function findRootMembers(source, bodyStart, bodyEnd) {
const members = []; const members = [];
let index = bodyStart; let index = bodyStart;
let depth = 0; let depth = 0;
let quote = null; let quote = null;
@@ -414,7 +457,9 @@ function findRootMembers(source, bodyStart, bodyEnd) {
if (source.startsWith("<!--", index)) { if (source.startsWith("<!--", index)) {
const end = source.indexOf("-->", index + 4); const end = source.indexOf("-->", index + 4);
index = end === -1 ? bodyEnd : end + 3; index = end === -1 ? bodyEnd : end + 3;
continue; continue;
} }
@@ -430,25 +475,35 @@ function findRootMembers(source, bodyStart, bodyEnd) {
continue; continue;
} }
if (depth === 0 && /[A-Za-z_]/.test(character)) { if (depth === 0 && /[A-Za-z_$]/.test(character)) {
const start = index; const identifier = readIdentifier(source, index, bodyEnd);
index += 1;
while (index < bodyEnd && /[A-Za-z0-9_-]/.test(source[index])) { if (!identifier) {
index += 1; index += 1;
continue;
} }
const name = source.slice(start, index); index = identifier.end;
members.push({ members.push({
name, name: identifier.name,
start, start: identifier.start,
end: index, end: identifier.end,
}); });
// Single-line declarations must skip the rest of the line. if (identifier.name === "state" || identifier.name === "layout") {
if (name === "state" || name === "layout") {
skipLine(); skipLine();
continue;
}
if (identifier.name === "watch") {
index = skipWhitespace(source, index, bodyEnd);
const watchedState = readIdentifier(source, index, bodyEnd);
if (watchedState) {
index = watchedState.end;
}
} }
continue; continue;
@@ -460,6 +515,234 @@ function findRootMembers(source, bodyStart, bodyEnd) {
return members; 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)) {
const end = source.indexOf("-->", 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)) {
const end = source.indexOf("-->", 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) { function validateRootMembers(document, source, rootKind, rootMatch) {
const diagnostics = []; const diagnostics = [];
const allowed = VALID_MEMBERS[rootKind]; const allowed = VALID_MEMBERS[rootKind];
@@ -469,6 +752,7 @@ function validateRootMembers(document, source, rootKind, rootMatch) {
} }
const bodyRange = getRootBodyRange(source, rootMatch); const bodyRange = getRootBodyRange(source, rootMatch);
const members = findRootMembers(source, bodyRange.start, bodyRange.end); const members = findRootMembers(source, bodyRange.start, bodyRange.end);
for (const member of members) { for (const member of members) {
@@ -491,8 +775,182 @@ function validateRootMembers(document, source, rootKind, rootMatch) {
return diagnostics; 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) { function validateRequiredView(document, source, rootKind, rootMatch) {
const bodyRange = getRootBodyRange(source, rootMatch); const bodyRange = getRootBodyRange(source, rootMatch);
const body = source.slice(bodyRange.start, bodyRange.end); const body = source.slice(bodyRange.start, bodyRange.end);
if (/\bview\s*\{/.test(body)) { if (/\bview\s*\{/.test(body)) {
@@ -550,6 +1008,7 @@ function validateDocument(document) {
if (declaration.diagnostic) { if (declaration.diagnostic) {
diagnostics.push(declaration.diagnostic); diagnostics.push(declaration.diagnostic);
diagnostics.push(...validateBalancedCharacters(document, source)); diagnostics.push(...validateBalancedCharacters(document, source));
return diagnostics; return diagnostics;
@@ -576,6 +1035,12 @@ function validateDocument(document) {
diagnostics.push(...validateRootMembers(document, source, declaration.kind, declaration.match)); 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(...validateRequiredView(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateLayoutUsage(document, source, declaration.kind)); diagnostics.push(...validateLayoutUsage(document, source, declaration.kind));
@@ -593,19 +1058,20 @@ function registerDiagnostics(context) {
return; return;
} }
const previousTimer = timers.get(document.uri.toString()); const key = document.uri.toString();
const previousTimer = timers.get(key);
if (previousTimer) { if (previousTimer) {
clearTimeout(previousTimer); clearTimeout(previousTimer);
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
timers.delete(document.uri.toString()); timers.delete(key);
collection.set(document.uri, validateDocument(document)); collection.set(document.uri, validateDocument(document));
}, 150); }, 150);
timers.set(document.uri.toString(), timer); timers.set(key, timer);
}; };
for (const document of vscode.workspace.textDocuments) { for (const document of vscode.workspace.textDocuments) {
@@ -652,5 +1118,7 @@ module.exports = {
validateBalancedCharacters, validateBalancedCharacters,
validateDocument, validateDocument,
validateHtmlTags, validateHtmlTags,
validateLifecycleBlocks,
validateRootMembers, validateRootMembers,
validateWatchBlocks,
}; };
+243 -217
View File
@@ -2,257 +2,283 @@
"use strict"; "use strict";
const vscode = require("vscode"); const vscode = require("vscode");
const { formatWrn } = require("./formatter"); const { formatWrn } = require("./formatter");
const { registerCompletionProvider } = require("./completion"); const { registerCompletionProvider } = require("./completion");
const { registerDefinitionProvider } = require("./definition"); const { registerDefinitionProvider } = require("./definition");
const { registerDiagnostics } = require("./diagnostics"); 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) * The WRN compiler is bundled to CommonJS using:
// still works even if the bundle is missing. *
* 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; let compiler = null;
try { try {
compiler = require("./compiler.cjs"); compiler = require("./compiler.cjs");
} catch (err) { } catch (error) {
console.warn("[wrnexus] compiler bundle not found; diagnostics disabled.", err && err.message); 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 WRN_LANGUAGE_ID = "wrn";
const BLOCK_KEYWORDS = [ const COMPILER_DIAGNOSTIC_COLLECTION = "wrnexus-compiler";
["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 <slot> on a component"],
];
/** Client event bindings. */
const EVENTS = [
"click",
"input",
"change",
"submit",
"keydown",
"keyup",
"focus",
"blur",
"mouseenter",
"mouseleave",
];
/** /**
* Register the WRN document formatter.
*
* @param {vscode.ExtensionContext} context * @param {vscode.ExtensionContext} context
*/ */
function activate(context) { function registerFormatter(context) {
registerDiagnostics(context); const provider = vscode.languages.registerDocumentFormattingEditProvider(
registerCompletionProvider(context); {
registerDefinitionProvider(context); language: WRN_LANGUAGE_ID,
scheme: "file",
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", {
provideDocumentFormattingEdits(document, options) { 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 source = document.getText();
const printWidth = configuration.get("formatting.printWidth", 100);
const formatted = formatWrn(source, { const formatted = formatWrn(source, {
tabSize: options.tabSize, tabSize: options.tabSize,
insertSpaces: options.insertSpaces, insertSpaces: options.insertSpaces,
printWidth: 100, printWidth,
}); });
if (formatted === source) {
return [];
}
const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length)); const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length));
return [vscode.TextEdit.replace(range, formatted)]; 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", vscode.workspace.onDidSaveTextDocument(run),
{ provideCompletionItems: provideCompletions },
"-", 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 * Activate the WRNexus VS Code extension.
* failure position as `... at offset <N>` in the message; we resolve it to a *
* range. Errors without an offset (e.g. "Unexpected end of input") anchor to the * @param {vscode.ExtensionContext} context
* end of the document.
* @param {vscode.TextDocument} doc
* @param {unknown} err
* @returns {vscode.Diagnostic}
*/ */
function toDiagnostic(doc, err) { function activate(context) {
const message = err && err.message ? String(err.message) : "Failed to parse .wrn file"; registerDiagnostics(context);
const match = /offset\s+(\d+)/.exec(message); registerCompilerDiagnostics(context);
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;
}
/** registerCompletionProvider(context);
* @param {vscode.TextDocument} document registerDefinitionProvider(context);
* @param {vscode.Position} position registerFormatter(context);
* @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 `<name> { ... }` 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;
} }
function deactivate() {} function deactivate() {}
module.exports = { activate, deactivate }; module.exports = {
activate,
deactivate,
registerCompilerDiagnostics,
registerFormatter,
toCompilerDiagnostic,
};
+163 -45
View File
@@ -19,24 +19,35 @@ const VOID_ELEMENTS = new Set([
function findOpeningTagEnd(value) { function findOpeningTagEnd(value) {
let quote = null; let quote = null;
let escaped = false;
for (let index = 0; index < value.length; index += 1) { for (let index = 0; index < value.length; index += 1) {
const char = value[index]; const character = value[index];
if (quote !== null) { if (quote !== null) {
if (char === quote && value[index - 1] !== "\\") { if (escaped) {
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
quote = null; quote = null;
} }
continue; continue;
} }
if (char === '"' || char === "'") { if (character === '"' || character === "'") {
quote = char; quote = character;
continue; continue;
} }
if (char === ">") { if (character === ">") {
return index; return index;
} }
} }
@@ -46,7 +57,8 @@ function findOpeningTagEnd(value) {
function parseAttributes(value) { function parseAttributes(value) {
const attributes = []; const attributes = [];
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s"'=<>`]+))?/g;
let match; let match;
@@ -65,17 +77,21 @@ function parseOpeningTag(value) {
} }
const openingPart = value.slice(0, endIndex + 1); const openingPart = value.slice(0, endIndex + 1);
const remainder = value.slice(endIndex + 1).trim(); 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) { if (!match) {
return null; return null;
} }
const tagName = match[1]; const tagName = match[1];
const attributes = parseAttributes(match[2].trim()); const attributes = parseAttributes(match[2].trim());
const selfClosing = match[3] === "/"; const selfClosing = match[3] === "/";
const inlineClosing = remainder === `</${tagName}>`; const inlineClosing = remainder === `</${tagName}>`;
return { return {
@@ -98,7 +114,9 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) {
} }
const baseIndent = unit.repeat(depth); const baseIndent = unit.repeat(depth);
const attributeIndent = unit.repeat(depth + 1); const attributeIndent = unit.repeat(depth + 1);
const normalizedSingleLine = value.replace(/\s+/g, " ").trim(); const normalizedSingleLine = value.replace(/\s+/g, " ").trim();
const shouldBreak = const shouldBreak =
@@ -159,42 +177,144 @@ function isMultilineOpeningTagStart(value) {
} }
function isClosingTag(value) { function isClosingTag(value) {
return /^<\/[A-Za-z][\w:-]*\s*>/.test(value); return /^<\/[A-Za-z][\w$:.-]*\s*>/.test(value);
} }
function isInlineElement(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) { function countLeadingClosingBraces(value) {
return value === "}" || value.startsWith("} "); let index = 0;
} let count = 0;
function isWrnBlockOpening(value) { while (index < value.length) {
if (!value.endsWith("{")) { while (index < value.length && /\s/.test(value[index])) {
return false; 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 = true;
index += 3;
continue;
}
if (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 = {}) { function formatWrn(source, options = {}) {
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize || 4); const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize || 4);
const printWidth = options.printWidth || 100; const printWidth = options.printWidth || 100;
const inputLines = source.replace(/\r\n/g, "\n").split("\n"); const inputLines = source.replace(/\r\n/g, "\n").split("\n");
const output = []; const output = [];
let wrnDepth = 0; let codeDepth = 0;
let htmlDepth = 0; let htmlDepth = 0;
let index = 0; let index = 0;
let previousWasBlank = false; let previousWasBlank = false;
while (index < inputLines.length) { while (index < inputLines.length) {
const originalLine = inputLines[index]; const originalLine = inputLines[index];
const trimmed = originalLine.trim();
if (trimmed === "") { let value = originalLine.trim();
if (value === "") {
if (!previousWasBlank && output.length > 0) { if (!previousWasBlank && output.length > 0) {
output.push(""); output.push("");
} }
@@ -206,43 +326,31 @@ function formatWrn(source, options = {}) {
previousWasBlank = false; previousWasBlank = false;
let value = trimmed;
if (isMultilineOpeningTagStart(value)) { if (isMultilineOpeningTagStart(value)) {
const collected = [value]; const collected = collectOpeningTag(inputLines, index);
let cursor = index + 1;
while (cursor < inputLines.length) { value = collected.value;
const nextPart = inputLines[cursor].trim(); index = collected.endIndex;
collected.push(nextPart);
const joined = collected.join(" ");
if (findOpeningTagEnd(joined) !== -1) {
break;
}
cursor += 1;
}
value = collected.join(" ");
index = cursor;
} }
if (isWrnBlockClosing(value)) { const leadingClosingBraces = countLeadingClosingBraces(value);
wrnDepth = Math.max(0, wrnDepth - 1);
} const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces);
let lineHtmlDepth = htmlDepth;
if (isClosingTag(value)) { if (isClosingTag(value)) {
htmlDepth = Math.max(0, htmlDepth - 1); lineHtmlDepth = Math.max(0, htmlDepth - 1);
} }
const depth = wrnDepth + htmlDepth; const depth = lineCodeDepth + lineHtmlDepth;
if ( if (
value.startsWith("<") && value.startsWith("<") &&
!value.startsWith("</") && !value.startsWith("</") &&
!value.startsWith("<!--") && !value.startsWith("<!--") &&
!value.startsWith("<!") &&
!value.startsWith("<?") &&
!isInlineElement(value) !isInlineElement(value)
) { ) {
const formattedTag = formatOpeningTag(value, unit, depth, printWidth); const formattedTag = formatOpeningTag(value, unit, depth, printWidth);
@@ -256,10 +364,17 @@ function formatWrn(source, options = {}) {
output.push(`${unit.repeat(depth)}${value}`); output.push(`${unit.repeat(depth)}${value}`);
} }
if (isWrnBlockOpening(value)) { if (isClosingTag(value)) {
wrnDepth += 1; htmlDepth = lineHtmlDepth;
} }
const braces = countStructuralBraces(value);
codeDepth = Math.max(
0,
lineCodeDepth + braces.openings - Math.max(0, braces.closings - leadingClosingBraces),
);
index += 1; index += 1;
} }
@@ -271,6 +386,9 @@ function formatWrn(source, options = {}) {
} }
module.exports = { module.exports = {
countStructuralBraces,
formatOpeningTag, formatOpeningTag,
formatWrn, formatWrn,
parseAttributes,
parseOpeningTag,
}; };
+518 -159
View File
@@ -1,266 +1,599 @@
{ {
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Wire", "name": "WRNexus",
"scopeName": "source.wrn", "scopeName": "source.wrn",
"patterns": [{ "include": "#comments" }, { "include": "#declaration" }, { "include": "#blocks" }], "patterns": [
{
"include": "#comments"
},
{
"include": "#declaration"
},
{
"include": "#blocks"
}
],
"repository": { "repository": {
"comments": { "comments": {
"patterns": [ "patterns": [
{ {
"name": "comment.line.double-slash.wrn", "name": "comment.line.double-slash.wrn",
"match": "//.*$" "match": "//.*$"
},
{
"name": "comment.block.html.wrn",
"begin": "<!--",
"end": "-->"
} }
] ]
}, },
"declaration": { "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": { "captures": {
"1": { "name": "storage.type.wrn keyword.control.wrn" }, "1": {
"2": { "name": "entity.name.type.wrn" } "name": "storage.type.wrn keyword.control.wrn"
},
"2": {
"name": "entity.name.type.wrn"
}
} }
}, },
"blocks": { "blocks": {
"patterns": [ "patterns": [
{ "include": "#view-block" }, {
{ "include": "#style-block" }, "include": "#view-block"
{ "include": "#seo-block" }, },
{ "include": "#props-block" }, {
{ "include": "#realtime-block" }, "include": "#style-block"
{ "include": "#mode-block" }, },
{ "include": "#api-block" }, {
{ "include": "#functions-block" }, "include": "#seo-block"
{ "include": "#state-decl" }, },
{ "include": "#layout-decl" }, {
{ "include": "#strings" } "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": { "layout-decl": {
"match": "\\b(layout)\\b\\s*(=)", "match": "\\b(layout)\\b\\s*(=)",
"captures": { "captures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "keyword.operator.assignment.wrn" } "name": "keyword.control.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
} }
}, },
"state-decl": { "state-decl": {
"begin": "\\b(state)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)", "begin": "\\b(state)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
"beginCaptures": { "beginCaptures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "variable.other.wrn" }, "name": "keyword.control.wrn"
"3": { "name": "keyword.operator.assignment.wrn" } },
"2": {
"name": "variable.other.wrn"
},
"3": {
"name": "keyword.operator.assignment.wrn"
}
}, },
"end": "$", "end": "$",
"contentName": "meta.embedded.expr.wrn", "contentName": "meta.embedded.expr.wrn",
"patterns": [{ "include": "source.ts" }] "patterns": [
{
"include": "source.ts"
}
]
}, },
"props-block": { "props-block": {
"begin": "\\b(props)\\b\\s*(\\{)", "begin": "\\b(props)\\b\\s*(\\{)",
"beginCaptures": { "beginCaptures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "punctuation.definition.block.begin.wrn" } "name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
}, },
"end": "\\}", "end": "\\}",
"endCaptures": { "endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" } "0": {
"name": "punctuation.definition.block.end.wrn"
}
}, },
"patterns": [ "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": { "captures": {
"1": { "name": "variable.parameter.wrn" }, "1": {
"2": { "name": "keyword.operator.assignment.wrn" } "name": "variable.parameter.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
} }
}, },
{ "include": "#strings" } {
"include": "#strings"
}
] ]
}, },
"seo-block": { "seo-block": {
"begin": "\\b(seo)\\b\\s*(\\{)", "begin": "\\b(seo)\\b\\s*(\\{)",
"beginCaptures": { "beginCaptures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "punctuation.definition.block.begin.wrn" } "name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
}, },
"end": "\\}", "end": "\\}",
"endCaptures": { "endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" } "0": {
"name": "punctuation.definition.block.end.wrn"
}
}, },
"patterns": [ "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": { "captures": {
"1": { "name": "support.type.property-name.wrn" }, "1": {
"2": { "name": "keyword.operator.assignment.wrn" } "name": "support.type.property-name.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
} }
}, },
{ "include": "#strings" } {
"include": "#strings"
}
] ]
}, },
"style-block": { "style-block": {
"begin": "\\b(style)\\b\\s*(\\{)", "begin": "\\b(style)\\b\\s*(\\{)",
"beginCaptures": { "beginCaptures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "punctuation.definition.block.begin.wrn" } "name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
}, },
"end": "\\}", "end": "\\}",
"endCaptures": { "endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" } "0": {
"name": "punctuation.definition.block.end.wrn"
}
}, },
"contentName": "meta.embedded.block.css", "contentName": "meta.embedded.block.css",
"patterns": [{ "include": "#css-braces" }, { "include": "source.css" }] "patterns": [
{
"include": "#css-braces"
},
{
"include": "source.css"
}
]
}, },
"css-braces": { "css-braces": {
"begin": "\\{", "begin": "\\{",
"end": "\\}", "end": "\\}",
"contentName": "meta.embedded.block.css", "contentName": "meta.embedded.block.css",
"patterns": [{ "include": "#css-braces" }, { "include": "source.css" }] "patterns": [
{
"include": "#css-braces"
},
{
"include": "source.css"
}
]
}, },
"functions-block": { "functions-block": {
"begin": "\\b(functions)\\b\\s*(\\{)", "begin": "\\b(functions)\\b\\s*(\\{)",
"beginCaptures": { "beginCaptures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "punctuation.definition.block.begin.wrn" } "name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
}, },
"end": "\\}", "end": "\\}",
"endCaptures": { "endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" } "0": {
"name": "punctuation.definition.block.end.wrn"
}
}, },
"contentName": "meta.embedded.block.ts", "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": { "ts-braces": {
"begin": "\\{", "begin": "\\{",
"end": "\\}", "end": "\\}",
"contentName": "meta.embedded.block.ts", "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": [ "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*(\\{)", "include": "#ts-braces"
"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*(\\{)", "include": "source.ts"
"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" }]
} }
] ]
}, },
"api-block": {
"view-block": { "begin": "\\b(api)\\b\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
"begin": "\\b(view)\\b\\s*(\\{)",
"beginCaptures": { "beginCaptures": {
"1": { "name": "keyword.control.wrn" }, "1": {
"2": { "name": "punctuation.definition.block.begin.wrn" } "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": "\\}", "end": "\\}",
"endCaptures": { "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", "contentName": "meta.embedded.block.html",
"patterns": [ "patterns": [
{ "include": "#interpolation" }, {
{ "include": "#wire-event" }, "include": "#interpolation"
{ "include": "text.html.basic" } },
{
"include": "#wire-event"
},
{
"include": "#conditional-class"
},
{
"include": "text.html.basic"
}
] ]
}, },
"wire-event": { "wire-event": {
"match": "(@)([A-Za-z][A-Za-z0-9_-]*)(=)", "match": "(@)(?:(window|document)(:))?([A-Za-z][A-Za-z0-9_-]*)(\\s*=)",
"captures": { "captures": {
"1": { "name": "punctuation.definition.keyword.wrn" }, "1": {
"2": { "name": "entity.other.attribute-name.event.wrn" }, "name": "punctuation.definition.keyword.wrn"
"3": { "name": "keyword.operator.assignment.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": { "interpolation": {
"patterns": [ "patterns": [
{ {
"name": "meta.interpolation.i18n.wrn", "name": "meta.interpolation.i18n.wrn",
"match": "(\\{)(t:)([^}]*)(\\})", "match": "(\\{)(t:)([^}]*)(\\})",
"captures": { "captures": {
"1": { "name": "punctuation.section.interpolation.begin.wrn" }, "1": {
"2": { "name": "keyword.control.i18n.wrn" }, "name": "punctuation.section.interpolation.begin.wrn"
"3": { "name": "string.other.i18n-key.wrn" }, },
"4": { "name": "punctuation.section.interpolation.end.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": "\\{", "begin": "\\{",
"end": "\\}", "end": "\\}",
"beginCaptures": { "beginCaptures": {
"0": { "name": "punctuation.section.interpolation.begin.wrn" } "0": {
"name": "punctuation.section.interpolation.begin.wrn"
}
}, },
"endCaptures": { "endCaptures": {
"0": { "name": "punctuation.section.interpolation.end.wrn" } "0": {
"name": "punctuation.section.interpolation.end.wrn"
}
}, },
"contentName": "meta.embedded.expr.wrn", "contentName": "meta.embedded.expr.wrn",
"patterns": [{ "include": "#interp-braces" }, { "include": "source.ts" }] "patterns": [
{
"include": "#interp-braces"
},
{
"include": "source.ts"
}
]
} }
] ]
}, },
"interp-braces": { "interp-braces": {
"begin": "\\{", "begin": "\\{",
"end": "\\}", "end": "\\}",
"contentName": "meta.embedded.expr.wrn", "contentName": "meta.embedded.expr.wrn",
"patterns": [{ "include": "#interp-braces" }, { "include": "source.ts" }] "patterns": [
{
"include": "#interp-braces"
},
{
"include": "source.ts"
}
]
}, },
"strings": { "strings": {
"patterns": [ "patterns": [
{ {
"name": "string.quoted.double.wrn", "name": "string.quoted.double.wrn",
"begin": "\"", "begin": "\"",
"end": "\"", "end": "\"",
"patterns": [{ "name": "constant.character.escape.wrn", "match": "\\\\." }] "patterns": [
{
"name": "constant.character.escape.wrn",
"match": "\\\\."
}
]
}, },
{ {
"name": "string.quoted.single.wrn", "name": "string.quoted.single.wrn",
"begin": "'", "begin": "'",
"end": "'", "end": "'",
"patterns": [{ "name": "constant.character.escape.wrn", "match": "\\\\." }] "patterns": [
{
"name": "constant.character.escape.wrn",
"match": "\\\\."
}
]
} }
] ]
} }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+18
View File
@@ -357,6 +357,24 @@ const MIGRATIONS: Migration[] = [
// No generated application files require automatic 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.
},
},
{
version: "0.2.33",
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. */ /** Release tooling uses this to require an explicit migration entry per version. */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+3 -1
View File
@@ -925,7 +925,9 @@ function behaviorAttribute(behavior: ComponentBehavior | null): string {
return ""; return "";
} }
return ` data-wrn-behavior="${attrEscape(JSON.stringify(behavior))}"`; const encoded = Buffer.from(JSON.stringify(behavior), "utf8").toString("base64");
return ` data-wrn-behavior="${encoded}"`;
} }
const INTERP_RE = /\{([^{}]+)\}/g; const INTERP_RE = /\{([^{}]+)\}/g;
+179 -5
View File
@@ -16,6 +16,39 @@ async function compileAndImport(src: string): Promise<Record<string, unknown>> {
return import(pathToFileURL(file).href); return import(pathToFileURL(file).href);
} }
type CompiledBehavior = {
functions: string;
lifecycle: {
mount?: string;
update?: string;
unmount?: string;
};
watches: Array<{
state: string;
body: string;
}>;
};
function extractCompiledBehavior(output: string): CompiledBehavior {
const marker = 'data-wrn-behavior="';
const start = output.indexOf(marker);
expect(start).toBeGreaterThanOrEqual(0);
const valueStart = start + marker.length;
const valueEnd = output.indexOf('"', valueStart);
expect(valueEnd).toBeGreaterThan(valueStart);
const encoded = output.slice(valueStart, valueEnd);
expect(encoded).toMatch(/^[A-Za-z0-9+/]+=*$/);
return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as CompiledBehavior;
}
test("parses a page with a layout member", () => { test("parses a page with a layout member", () => {
const ast = parse(`page Home {\n layout = "public"\n view { <h1>Hi</h1> }\n}`); const ast = parse(`page Home {\n layout = "public"\n view { <h1>Hi</h1> }\n}`);
expect(ast.kind).toBe("page"); expect(ast.kind).toBe("page");
@@ -382,11 +415,13 @@ component LifecycleTest {
`), `),
); );
expect(output).toContain("&quot;mount&quot;"); const behavior = extractCompiledBehavior(output);
expect(output).toContain("&quot;update&quot;"); expect(behavior.lifecycle.mount).toContain("ready = true");
expect(output).toContain("&quot;unmount&quot;"); expect(behavior.lifecycle.update).toContain("console.log(ready)");
expect(behavior.lifecycle.unmount).toContain("ready = false");
}); });
test("component watchers compile into behavior metadata", () => { test("component watchers compile into behavior metadata", () => {
const output = generate( const output = generate(
@@ -405,9 +440,13 @@ component WatchTest {
`), `),
); );
expect(output).toContain("&quot;state&quot;:&quot;visible&quot;"); const behavior = extractCompiledBehavior(output);
expect(output).toContain("console.log"); expect(behavior.watches).toHaveLength(1);
expect(behavior.watches[0]?.state).toBe("visible");
expect(behavior.watches[0]?.body).toContain('console.log("changed", visible)');
}); });
test("behavior-only components still receive a reactive scope", () => { test("behavior-only components still receive a reactive scope", () => {
const output = generate( const output = generate(
@@ -436,3 +475,138 @@ component BehaviorOnly {
expect(output).toContain("data-wrn-behavior"); 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 {
<button @click="scrollToTop()">
Top
</button>
}
}
`),
);
expect(output).toContain("data-wrn-behavior=");
expect(output).not.toContain('data-wrn-behavior="{');
expect(output).not.toContain('data-wrn-behavior="%7B');
});
test("component watchers compile into behavior metadata", () => {
const output = generate(
parse(`
component WatchTest {
state visible = false
watch visible {
console.log("changed", visible)
}
view {
<div>{visible}</div>
}
}
`),
);
const match = /data-wrn-behavior="([^"]+)"/.exec(output);
expect(match).not.toBeNull();
const behavior = extractBehavior(output);
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 {
<div>{ready}</div>
}
}
`),
);
const match = /data-wrn-behavior="([^"]+)"/.exec(output);
expect(match).not.toBeNull();
const behavior = extractBehavior(output);
expect(behavior.lifecycle.mount).toContain("ready = true");
expect(behavior.lifecycle.update).toContain("console.log(ready)");
expect(behavior.lifecycle.unmount).toContain("ready = false");
});
function extractBehavior(output: string): {
functions: string;
lifecycle: {
mount?: string;
update?: string;
unmount?: string;
};
watches: Array<{
state: string;
body: string;
}>;
} {
const match = /data-wrn-behavior="([^"]+)"/.exec(output);
expect(match).not.toBeNull();
return JSON.parse(Buffer.from(match![1], "base64").toString("utf8"));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+37 -5
View File
@@ -43,14 +43,46 @@ export const REACTIVE_RUNTIME = String.raw`
}); });
} }
function decodeBehaviorPayload(value) {
var binary = window.atob(value);
var bytes = new Uint8Array(binary.length);
for (var index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return new TextDecoder("utf-8").decode(bytes);
}
function parseBehavior(element) { function parseBehavior(element) {
var raw = element.getAttribute("data-wrn-behavior"); var raw = element.getAttribute(
if (!raw) return null; "data-wrn-behavior",
);
if (!raw) {
return null;
}
try { try {
var parsed = JSON.parse(raw); var decoded =
return parsed && typeof parsed === "object" ? parsed : null; decodeBehaviorPayload(raw);
var parsed = JSON.parse(decoded);
if (
!parsed ||
typeof parsed !== "object"
) {
return null;
}
return parsed;
} catch (error) { } catch (error) {
console.error("[wrnexus] failed to parse component behavior", error); console.error(
"[wrnexus] failed to parse component behavior",
error,
);
return null; return null;
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.2.31", "version": "0.2.33",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.2.31", "version": "0.2.33",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",