Compare commits

...
14 Commits
Author SHA1 Message Date
Clintchiz 0f915c3c98 release: WRNexusJS 0.2.44 2026-07-15 18:54:29 +05:30
Clintchiz 9aa13fb4db release: WRNexusJS 0.2.43 2026-07-15 18:41:55 +05:30
Clintchiz 9070a1ef9c release: WRNexusJS 0.2.42 2026-07-15 18:15:22 +05:30
Clintchiz 4230de7065 release: WRNexusJS 0.2.41 2026-07-15 14:52:56 +05:30
Clintchiz 0006aef2c4 release: WRNexusJS 0.2.40 2026-07-15 14:02:27 +05:30
Clintchiz 7dfca4a190 release: WRNexusJS 0.2.40 2026-07-15 13:42:18 +05:30
Clintchiz 29bbeefe92 release: WRNexusJS 0.2.39 2026-07-15 13:22:20 +05:30
Clintchiz 98445c2ec7 release: WRNexusJS 0.2.38 2026-07-15 10:26:24 +05:30
Clintchiz 17a535d4dc release: WRNexusJS 0.2.36 2026-07-15 10:04:14 +05:30
Clintchiz afb14c2fbf release: WRNexusJS 0.2.35 2026-07-15 08:57:07 +05:30
Clintchiz 2c6e6f6de3 release: WRNexusJS 0.2.35 2026-07-15 08:19:54 +05:30
Clintchiz cade812569 release: WRNexusJS 0.2.34 2026-07-15 07:29:59 +05:30
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
74 changed files with 5449 additions and 774 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.2.31", "version": "0.2.44",
"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.44",
"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.44",
"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.44",
"@wrnexus/router": "^0.2.31", "@wrnexus/router": "^0.2.44",
"@wrnexus/csr": "^0.2.31", "@wrnexus/csr": "^0.2.44",
"@wrnexus/compiler": "^0.2.31", "@wrnexus/compiler": "^0.2.44",
"@wrnexus/styles": "^0.2.31", "@wrnexus/styles": "^0.2.44",
"@wrnexus/dev-server": "^0.2.31", "@wrnexus/dev-server": "^0.2.44",
"@wrnexus/ui": "^0.2.31", "@wrnexus/ui": "^0.2.44",
"@wrnexus/validation": "^0.2.31", "@wrnexus/validation": "^0.2.44",
"@wrnexus/i18n": "^0.2.31", "@wrnexus/i18n": "^0.2.44",
"@wrnexus/db": "^0.2.31" "@wrnexus/db": "^0.2.44"
}, },
"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.44",
"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.44",
"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.44",
"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.44"
}, },
"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.44",
"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.44",
"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.44",
"@wrnexus/router": "^0.2.31", "@wrnexus/router": "^0.2.44",
"@wrnexus/ssr": "^0.2.31", "@wrnexus/ssr": "^0.2.44",
"@wrnexus/csr": "^0.2.31", "@wrnexus/csr": "^0.2.44",
"@wrnexus/compiler": "^0.2.31", "@wrnexus/compiler": "^0.2.44",
"@wrnexus/styles": "^0.2.31", "@wrnexus/styles": "^0.2.44",
"@wrnexus/ui": "^0.2.31", "@wrnexus/ui": "^0.2.44",
"@wrnexus/validation": "^0.2.31", "@wrnexus/validation": "^0.2.44",
"@wrnexus/i18n": "^0.2.31", "@wrnexus/i18n": "^0.2.44",
"@wrnexus/db": "^0.2.31", "@wrnexus/db": "^0.2.44",
"@wrnexus/pubsub": "^0.2.31", "@wrnexus/pubsub": "^0.2.44",
"@wrnexus/uploader": "^0.2.31" "@wrnexus/uploader": "^0.2.44"
}, },
"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.44",
"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.44",
"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.44"
}, },
"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.44",
"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.44"
}, },
"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.44",
"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.44",
"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.44"
}, },
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"@wrnexus/core": "^0.2.31" "@wrnexus/core": "^0.2.44"
}, },
"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.44",
"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.44"
}, },
"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.44",
"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.44"
}, },
"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.44",
"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.44",
"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.44",
"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.44"
}, },
"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.44",
"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.44"
}, },
"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.44",
"type": "module", "type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.", "description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
+17 -1
View File
@@ -42,5 +42,21 @@
"editors/vscode/src/compiler.cjs": true "editors/vscode/src/compiler.cjs": true
}, },
"wrnexus.diagnostics.enable": true "wrnexus.diagnostics.enable": true,
"editor.semanticHighlighting.enabled": true,
"editor.semanticTokenColorCustomizations": {
"rules": {
"variable:wrn": {
"foreground": "#8B5CF6"
},
"variable.declaration:wrn": {
"foreground": "#A78BFA",
"fontStyle": "bold"
},
"variable.modification:wrn": {
"foreground": "#F59E0B"
}
}
}
} }
+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.
+105 -8
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.10",
"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": {
@@ -151,7 +225,30 @@
} }
] ]
} }
} },
"semanticTokenScopes": [
{
"language": "wrn",
"scopes": {
"variable.declaration": [
"variable.other.readwrite.wrn",
"variable.other.readwrite.declaration.wrn"
],
"variable": [
"variable.other.readwrite.wrn"
],
"parameter": [
"variable.parameter.wrn"
],
"function": [
"entity.name.function.wrn"
],
"property": [
"variable.other.property.wrn"
]
}
}
]
}, },
"scripts": { "scripts": {
"build:compiler": "bun build ../../packages/compiler/src/index.ts --target=node --format=cjs --outfile=src/compiler.cjs", "build:compiler": "bun build ../../packages/compiler/src/index.ts --target=node --format=cjs --outfile=src/compiler.cjs",
+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>",
" }",
"}"
]
} }
} }
+291 -34
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
}; };
@@ -449,12 +493,6 @@ function parseHtmlView(src, pos) {
let i = pos; let i = pos;
const isNameStart = (c) => /[A-Za-z_]/.test(c); const isNameStart = (c) => /[A-Za-z_]/.test(c);
const isTagNamePart = (c) => /[A-Za-z0-9_$:.-]/.test(c); const isTagNamePart = (c) => /[A-Za-z0-9_$:.-]/.test(c);
const isAttributeNamePart = (c, next) => {
if (c === "/") {
return next !== ">";
}
return /[A-Za-z0-9_$:.[\]%-]/.test(c);
};
const isWs2 = (c) => c === " " || c === "\t" || c === ` const isWs2 = (c) => c === " " || c === "\t" || c === `
` || c === "\r"; ` || c === "\r";
const fail = (msg) => { const fail = (msg) => {
@@ -502,14 +540,22 @@ function parseHtmlView(src, pos) {
return src.slice(start, i); return src.slice(start, i);
}; };
const readAttributeName = () => { const readAttributeName = () => {
const first = src[i]; if (i >= src.length) {
if (i >= src.length || !isNameStart(first) && first !== ":" && first !== "$") {
return fail("Expected an attribute name"); return fail("Expected an attribute name");
} }
const start = i++; const start = i;
while (i < src.length && isAttributeNamePart(src[i], src[i + 1])) { while (i < src.length) {
const char = src[i];
const next = src[i + 1];
if (char === "=" || char === ">" || char === '"' || char === "'" || char === " " || char === "\t" || char === `
` || char === "\r" || char === "/" && next === ">") {
break;
}
i++; i++;
} }
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i); return src.slice(start, i);
}; };
const parseTag = () => { const parseTag = () => {
@@ -686,6 +732,7 @@ function parseHtmlView(src, pos) {
} }
// ../../packages/compiler/src/codegen.ts // ../../packages/compiler/src/codegen.ts
var import_node_buffer = require("node:buffer");
function isComponentTag(tag) { function isComponentTag(tag) {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
} }
@@ -716,10 +763,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) {
@@ -916,21 +971,23 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`; return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
} }
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) { function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
const attrs = node.attrs.filter((attr) => attr.name !== "data-component"); const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => renderPageComponentAttr(attr, loops)).join("");
const inner = node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join(""); const inner = node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join("");
return `<div data-component="${attrEscape(node.tag)}"${renderAttrs(attrs, undefined, reactive)}>${inner}</div>`; return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
} }
function renderNestedComponentInvocation(node, ctx) { 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}`;
} }
const rendered = ` ${attr.name}="` + `${compileAttrValue(attr.value, ctx)}"`; const wholeExpression = wholeAttributeExpression(attr.value);
if (!attr.value.includes("{") || !exprRefsState(attr.value, ctx.stateNames)) { const compiledValue = wholeExpression ? `\${__wireProp(${ctx.resolveExpr(wholeExpression)})}` : compileAttrValue(attr.value, ctx);
const rendered = ` ${attr.name}="${compiledValue}"`;
if (wholeExpression || !attr.value.includes("{") || !exprRefsState(attr.value, ctx.stateNames)) {
return rendered; return rendered;
} }
const marker = attrEscape(JSON.stringify([attr.name, attr.value])); const marker = attrEscape(JSON.stringify([attr.name, attr.value]));
@@ -1031,6 +1088,28 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any):
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters); return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
} }
function __wrnexusPropAttr(
value: unknown,
): string {
const serialized =
value !== null &&
typeof value === "object"
? JSON.stringify(value)
: String(value == null ? "" : value);
return serialized.replace(
/[&<>"]/g,
(character) =>
character === "&"
? "&amp;"
: character === "<"
? "&lt;"
: character === ">"
? "&gt;"
: "&quot;",
);
}
async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> { async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> {
if (typeof ctx.__wrnexusCallApi === "function") { if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method); return await ctx.__wrnexusCallApi(path, method);
@@ -1262,6 +1341,35 @@ 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 "";
}
const encoded = import_node_buffer.Buffer.from(JSON.stringify(behavior), "utf8").toString("base64");
return ` data-wrn-behavior="${encoded}"`;
}
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) {
@@ -1271,7 +1379,17 @@ function exprRefsState(expr, stateNames) {
return false; return false;
} }
function viewHasEvents(nodes) { function viewHasEvents(nodes) {
return nodes.some((n) => n.type === "element" && (n.attrs.some((a) => a.event) || viewHasEvents(n.children))); return nodes.some((node) => {
if (node.type === "text")
return false;
if (node.type === "each") {
return viewHasEvents(node.body) || viewHasEvents(node.empty);
}
if (node.type === "if") {
return node.branches.some((branch) => viewHasEvents(branch.body));
}
return node.attrs.some((attr) => attr.event) || viewHasEvents(node.children);
});
} }
function compileText(raw, ctx) { function compileText(raw, ctx) {
let out = ""; let out = "";
@@ -1315,11 +1433,36 @@ function compileAttrValue(raw, ctx) {
} }
return out + escLit(attrEscape(raw.slice(last))); return out + escLit(attrEscape(raw.slice(last)));
} }
function renderComponentIfNode(node, ctx) {
let expression = "``";
for (let index = node.branches.length - 1;index >= 0; index--) {
const branch = node.branches[index];
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
const bodyExpression = "`" + body + "`";
expression = branch.cond === null ? bodyExpression : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
}
return "${" + expression + "}";
}
function renderComponentEachNode(node, ctx) {
const item = node.item;
const index = node.index ?? "__wi";
const list = ctx.resolveExpr(node.list);
const childCtx = {
...ctx,
serverLocals: new Set([...ctx.serverLocals ?? [], item, index])
};
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
return "${(() => { const __wl = Array.isArray(" + list + ") ? (" + list + ") : []; return __wl.length ? __wl.map((" + item + ", " + index + ") => `" + body + '`).join("") : `' + empty + "`; })()}";
}
function renderComponentNode(node, ctx) { function renderComponentNode(node, ctx) {
if (node.type === "text") if (node.type === "text")
return compileText(node.value, ctx); return compileText(node.value, ctx);
if (node.type === "each" || node.type === "if") { if (node.type === "each") {
throw new Error("Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client)."); return renderComponentEachNode(node, ctx);
}
if (node.type === "if") {
return renderComponentIfNode(node, ctx);
} }
if (isComponentTag(node.tag)) { if (isComponentTag(node.tag)) {
return renderNestedComponentInvocation(node, ctx); return renderNestedComponentInvocation(node, ctx);
@@ -1340,7 +1483,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}`;
@@ -1355,14 +1498,19 @@ function renderComponentNode(node, ctx) {
const initialConditionalClasses = conditionalClasses.map(({ className, expression }) => { const initialConditionalClasses = conditionalClasses.map(({ className, expression }) => {
return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`; return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`;
}).join(""); }).join("");
const classAttribute = staticClasses.length > 0 || conditionalClasses.length > 0 ? ` class="${compileAttrValue(staticClasses.join(" "), ctx)}${initialConditionalClasses}"` : ""; const staticClassValue = staticClasses.join(" ");
const classBindings = conditionalClasses.map(({ className, expression }, index) => { const classHasReactiveExpression = staticClassValue.includes("{") && exprRefsState(staticClassValue, ctx.stateNames);
const classAttribute = staticClasses.length > 0 || conditionalClasses.length > 0 ? ` class="${compileAttrValue(staticClassValue, ctx)}${initialConditionalClasses}"` : "";
const classReactiveBinding = classHasReactiveExpression ? ` data-wrn-bind-class="${escLit(attrEscape(JSON.stringify(["class", staticClassValue])))}"` : "";
const classBindings = conditionalClasses.filter(({ expression }) => {
return !ctx.serverLocals || !exprRefsState(expression, ctx.serverLocals);
}).map(({ className, expression }, index) => {
const marker = attrEscape(JSON.stringify([className, expression])); const marker = attrEscape(JSON.stringify([className, expression]));
return ` data-wrn-class-${index}="${escLit(marker)}"`; return ` data-wrn-class-${index}="${escLit(marker)}"`;
}).join(""); }).join("");
const loops = loopVarsOf(node); const loops = loopVarsOf(node);
const childCtx = loops.length > 0 ? { ...ctx, loopVars: new Set([...ctx.loopVars ?? [], ...loops]) } : ctx; const childCtx = loops.length > 0 ? { ...ctx, loopVars: new Set([...ctx.loopVars ?? [], ...loops]) } : ctx;
const allAttrs = `${classAttribute}${classBindings}${attrs}`; const allAttrs = `${classAttribute}` + `${classReactiveBinding}` + `${classBindings}` + `${attrs}`;
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${allAttrs}>`; return `<${node.tag}${allAttrs}>`;
} }
@@ -1400,11 +1548,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 +1562,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,19 +1571,107 @@ ${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) {
if (typeof def === "number") return Number(v); return def;
if (typeof def === "boolean") return v === true || v === "" || v === "true"; }
if (typeof def === "number") {
return Number(v);
}
if (typeof def === "boolean") {
return v === true || v === "" || v === "true";
}
if (Array.isArray(def)) {
if (Array.isArray(v)) {
return v;
}
if (typeof v === "string") {
try {
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : def;
} catch {
return def;
}
}
return def;
}
if (def !== null && typeof def === "object") {
if (
v !== null &&
typeof v === "object" &&
!Array.isArray(v)
) {
return v;
}
if (typeof v === "string") {
try {
const parsed = JSON.parse(v);
return (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
)
? parsed
: def;
} catch {
return def;
}
}
return def;
}
return String(v); return String(v);
} }
function __wireHtml(v: any): string { function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;")); return String(v == null ? "" : v).replace(
} /[&<>]/g,
function __wireAttr(v: any): string { (c) =>
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&"
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;", ? "&amp;"
: c === "<"
? "&lt;"
: "&gt;",
); );
}
function __wireAttr(v: any): string {
return String(v == null ? "" : v).replace(
/[&<>"]/g,
(c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: "&quot;",
);
}
function __wireProp(v: any): string {
const value =
v !== null && typeof v === "object"
? JSON.stringify(v)
: String(v == null ? "" : v);
return __wireAttr(value);
}
function __wireRaw(v: any): string {
return String(v == null ? "" : v);
}`); }`);
if (needsScope) { if (needsScope) {
out.push(`function __wrnexusScopeDecl(obj: Record<string, any>): string { out.push(`function __wrnexusScopeDecl(obj: Record<string, any>): string {
@@ -1461,6 +1699,25 @@ function __wireAttr(v: any): string {
`) + ` `) + `
`; `;
} }
function wholeAttributeExpression(value) {
const match = /^\s*\{([\s\S]+)\}\s*$/.exec(value);
return match?.[1]?.trim() || null;
}
function renderPageComponentAttr(attr, dynamicExpressions) {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
}
if (attr.boolean) {
return ` ${attr.name}`;
}
const expression = wholeAttributeExpression(attr.value);
if (!expression) {
return ` ${attr.name}="${attrEscape(attr.value)}"`;
}
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return ` ${attr.name}="${marker}"`;
}
// ../../packages/compiler/src/native-codegen.ts // ../../packages/compiler/src/native-codegen.ts
class NativeCompileError extends Error { class NativeCompileError extends Error {
+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,
}; };
+587 -227
View File
@@ -2,257 +2,617 @@
"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
*/
function registerFormatter(context) {
const selector = {
language: WRN_LANGUAGE_ID,
};
const provider = vscode.languages.registerDocumentFormattingEditProvider(selector, {
provideDocumentFormattingEdits(document, options, token) {
if (token.isCancellationRequested) {
return [];
}
const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri);
const enabled = configuration.get("format.enable", true);
if (!enabled) {
return [];
}
const source = document.getText();
if (!source.trim()) {
return [];
}
const printWidth = configuration.get("formatting.printWidth", 100);
try {
const formatted = formatWrn(source, {
tabSize: options.tabSize || 2,
insertSpaces: options.insertSpaces !== false,
printWidth,
});
if (typeof formatted !== "string" || formatted === source) {
return [];
}
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(source.length),
);
return [vscode.TextEdit.replace(fullRange, formatted)];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[wrnexus] formatting failed:", message);
vscode.window.showErrorMessage(`WRNexus formatting failed: ${message}`);
return [];
}
},
});
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.workspace.onDidSaveTextDocument(run),
vscode.workspace.onDidCloseTextDocument((document) => {
const key = document.uri.toString();
const timer = timers.get(key);
if (timer) {
clearTimeout(timer);
timers.delete(key);
}
collection.delete(document.uri);
}),
vscode.workspace.onDidChangeConfiguration((event) => {
if (!event.affectsConfiguration("wrnexus.diagnostics.enable")) {
return;
}
for (const document of vscode.workspace.textDocuments) {
run(document);
}
}),
{
dispose() {
for (const timer of timers.values()) {
clearTimeout(timer);
}
timers.clear();
},
},
);
}
/**
* Register semantic highlighting for WRN state declarations and references.
*
* @param {vscode.ExtensionContext} context
*/
function registerSemanticTokens(context) {
const semanticTokenLegend = new vscode.SemanticTokensLegend(
["variable"],
["declaration", "modification"],
);
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const collectStateVariables = (text) => {
const states = new Map();
const pattern = /\bstate\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?==|;|\r?$)/gm;
let match;
while ((match = pattern.exec(text)) !== null) {
const name = match[1];
if (!name) {
continue;
}
const offset = match.index + match[0].lastIndexOf(name);
const declarations = states.get(name) ?? new Set();
declarations.add(offset);
states.set(name, declarations);
}
return states;
};
const isModification = (text, offset, length) => {
const after = text.slice(offset + length).match(/^\s*(=|\+=|-=|\*=|\/=|%=|\+\+|--)/);
if (after) {
return true;
}
const before = text.slice(Math.max(0, offset - 8), offset).match(/(\+\+|--)\s*$/);
return Boolean(before);
};
const provider = {
provideDocumentSemanticTokens(document, token) {
const builder = new vscode.SemanticTokensBuilder(semanticTokenLegend);
const text = document.getText();
const states = collectStateVariables(text);
for (const [name, declarationOffsets] of states) {
if (token.isCancellationRequested) {
return builder.build();
}
const pattern = new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`, "g");
let match;
while ((match = pattern.exec(text)) !== null) {
const offset = match.index;
const position = document.positionAt(offset);
let modifiers = [];
if (declarationOffsets.has(offset)) {
modifiers = ["declaration"];
} else if (isModification(text, offset, name.length)) {
modifiers = ["modification"];
}
builder.push(position.line, position.character, name.length, "variable", modifiers);
}
}
return builder.build();
},
};
const registration = vscode.languages.registerDocumentSemanticTokensProvider(
{
language: WRN_LANGUAGE_ID,
},
provider,
semanticTokenLegend,
);
context.subscriptions.push(registration);
}
/**
* Activate the WRNexus VS Code extension.
*
* @param {vscode.ExtensionContext} context * @param {vscode.ExtensionContext} context
*/ */
function activate(context) { function activate(context) {
registerDiagnostics(context); registerDiagnostics(context);
registerCompilerDiagnostics(context);
registerCompletionProvider(context); registerCompletionProvider(context);
registerDefinitionProvider(context); registerDefinitionProvider(context);
registerFormatter(context);
registerSemanticTokens(context);
const diagnostics = vscode.languages.createDiagnosticCollection("wrn");
context.subscriptions.push(diagnostics);
const timers = new Map();
const runDiagnostics = (doc) => {
if (doc.languageId !== "wrn") return;
if (!vscode.workspace.getConfiguration("wrnexus").get("diagnostics.enable", true)) {
diagnostics.delete(doc.uri);
return;
}
if (!compiler || typeof compiler.compileWireFile !== "function") return;
const text = doc.getText();
/** @type {vscode.Diagnostic[]} */
const found = [];
try {
compiler.compileWireFile(text);
} catch (err) {
found.push(toDiagnostic(doc, err));
}
diagnostics.set(doc.uri, found);
};
const schedule = (doc) => {
const key = doc.uri.toString();
clearTimeout(timers.get(key));
timers.set(
key,
setTimeout(() => {
timers.delete(key);
runDiagnostics(doc);
}, 250),
);
};
// Lint on open, edit and save; clear on close.
vscode.workspace.textDocuments.forEach(runDiagnostics);
context.subscriptions.push( context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(runDiagnostics), vscode.languages.registerDocumentSemanticTokensProvider(
vscode.workspace.onDidChangeTextDocument((e) => schedule(e.document)), {
vscode.workspace.onDidSaveTextDocument(runDiagnostics), language: "wrn",
vscode.workspace.onDidCloseTextDocument((doc) => diagnostics.delete(doc.uri)),
);
// Completions.
context.subscriptions.push(
vscode.languages.registerDocumentFormattingEditProvider("wrn", {
provideDocumentFormattingEdits(document, options) {
const source = document.getText();
const formatted = formatWrn(source, {
tabSize: options.tabSize,
insertSpaces: options.insertSpaces,
printWidth: 100,
});
const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length));
return [vscode.TextEdit.replace(range, formatted)];
}, },
}), wrnSemanticTokensProvider,
vscode.languages.registerCompletionItemProvider( semanticTokenLegend,
"wrn",
{ provideCompletionItems: provideCompletions },
"-",
"@",
"{",
":",
), ),
); );
} }
/**
* Map a thrown ParseError to a VS Code diagnostic. The compiler encodes the
* 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
* end of the document.
* @param {vscode.TextDocument} doc
* @param {unknown} err
* @returns {vscode.Diagnostic}
*/
function toDiagnostic(doc, err) {
const message = err && err.message ? String(err.message) : "Failed to parse .wrn file";
const match = /offset\s+(\d+)/.exec(message);
let range;
if (match) {
const offset = Number(match[1]);
const start = doc.positionAt(offset);
const wordRange = doc.getWordRangeAtPosition(start);
range = wordRange || new vscode.Range(start, doc.positionAt(offset + 1));
} else {
const last = doc.lineAt(Math.max(0, doc.lineCount - 1));
range = new vscode.Range(last.range.start, last.range.end);
}
const diag = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error);
diag.source = "wrn";
return diag;
}
/**
* @param {vscode.TextDocument} document
* @param {vscode.Position} position
* @returns {vscode.CompletionItem[]}
*/
function provideCompletions(document, position) {
const line = document.lineAt(position).text;
const before = line.slice(0, position.character);
const full = document.getText();
const upto = document.offsetAt(position);
const inView = isInsideBlock(full, upto, "view");
// `{t:` — offer nothing structured, just let the user type the key.
// `@` inside a view — event bindings.
if (inView && /@[A-Za-z-]*$/.test(before)) {
return EVENTS.map((ev) => {
const item = new vscode.CompletionItem(ev, vscode.CompletionItemKind.Event);
item.insertText = new vscode.SnippetString(`${ev}="$0"`);
item.detail = "wrn event binding";
// Replace the `@`-less part already typed.
return item;
});
}
// `data-` inside a view — reactive attributes.
if (inView && /(^|\s)data-[A-Za-z-]*$/.test(before)) {
return DATA_ATTRS.map(([label, snippet, doc]) => {
const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Property);
item.insertText = new vscode.SnippetString(String(snippet));
item.documentation = new vscode.MarkdownString(String(doc));
item.detail = "wrn runtime attribute";
const dashIdx = before.lastIndexOf("data-");
item.range = new vscode.Range(position.line, dashIdx, position.line, position.character);
return item;
});
}
// Top-level block keywords, when not inside a view/style/functions body.
if (!inView && !isInsideBlock(full, upto, "style") && !isInsideBlock(full, upto, "functions")) {
return BLOCK_KEYWORDS.map(([label, snippet, doc]) => {
const item = new vscode.CompletionItem(String(label), vscode.CompletionItemKind.Keyword);
item.insertText = new vscode.SnippetString(String(snippet));
item.documentation = new vscode.MarkdownString(String(doc));
item.detail = "wrn block";
return item;
});
}
return [];
}
/**
* Very small heuristic: is `offset` inside a `<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 }; const semanticTokenLegend = new vscode.SemanticTokensLegend(
["variable"],
["declaration", "modification"],
);
/**
* @param {string} value
* @returns {string}
*/
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* @param {string} text
* @returns {Map<string, Set<number>>}
*/
function collectStateVariables(text) {
const states = new Map();
const statePattern = /\bstate\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?==|;|\r?$)/gm;
let match;
while ((match = statePattern.exec(text)) !== null) {
const name = match[1];
if (!name) {
continue;
}
const offset = match.index + match[0].lastIndexOf(name);
const declarations = states.get(name) || new Set();
declarations.add(offset);
states.set(name, declarations);
}
return states;
}
/**
* Comments are excluded, but quoted attribute expressions are deliberately
* included because WRN state references commonly appear in values such as
* class:flex="centered" and @click="enabled = !enabled".
*
* @param {string} text
* @returns {Array<{start: number, end: number}>}
*/
function collectCommentRanges(text) {
const ranges = [];
let index = 0;
let quote = null;
let escaped = false;
while (index < text.length) {
const current = text[index];
const next = text[index + 1];
if (quote) {
if (escaped) {
escaped = false;
index++;
continue;
}
if (current === "\\") {
escaped = true;
index++;
continue;
}
if (current === quote) {
quote = null;
}
index++;
continue;
}
if (current === '"' || current === "'" || current === "`") {
quote = current;
index++;
continue;
}
if (current === "/" && next === "/") {
const start = index;
index += 2;
while (index < text.length && text[index] !== "\n") {
index++;
}
ranges.push({ start, end: index });
continue;
}
if (current === "/" && next === "*") {
const start = index;
index += 2;
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) {
index++;
}
index = Math.min(text.length, index + 2);
ranges.push({ start, end: index });
continue;
}
index++;
}
return ranges;
}
/**
* @param {number} offset
* @param {Array<{start: number, end: number}>} ranges
* @returns {boolean}
*/
function isInsideComment(offset, ranges) {
let low = 0;
let high = ranges.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const range = ranges[middle];
if (!range) {
return false;
}
if (offset < range.start) {
high = middle - 1;
} else if (offset >= range.end) {
low = middle + 1;
} else {
return true;
}
}
return false;
}
/**
* @param {string} text
* @param {number} offset
* @param {number} length
* @returns {boolean}
*/
function isStateModification(text, offset, length) {
const after = text.slice(offset + length).match(/^\s*(=|\+=|-=|\*=|\/=|%=|\+\+|--)/);
if (after) {
return true;
}
const before = text.slice(Math.max(0, offset - 8), offset).match(/(\+\+|--)\s*$/);
return Boolean(before);
}
const wrnSemanticTokensProvider = {
/**
* @param {vscode.TextDocument} document
* @param {vscode.CancellationToken} token
*/
provideDocumentSemanticTokens(document, token) {
const builder = new vscode.SemanticTokensBuilder(semanticTokenLegend);
const text = document.getText();
const states = collectStateVariables(text);
const commentRanges = collectCommentRanges(text);
for (const [name, declarationOffsets] of states) {
if (token.isCancellationRequested) {
return builder.build();
}
const pattern = new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`, "g");
let match;
while ((match = pattern.exec(text)) !== null) {
const offset = match.index;
if (isInsideComment(offset, commentRanges)) {
continue;
}
const position = document.positionAt(offset);
let modifiers = [];
if (declarationOffsets.has(offset)) {
modifiers = ["declaration"];
} else if (isStateModification(text, offset, name.length)) {
modifiers = ["modification"];
}
builder.push(position.line, position.character, name.length, "variable", modifiers);
}
}
return builder.build();
},
};
module.exports = {
activate,
deactivate,
registerCompilerDiagnostics,
registerFormatter,
registerSemanticTokens,
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,
}; };
+570 -159
View File
@@ -1,266 +1,651 @@
{ {
"$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": "#conditional-class-single"
},
{
"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": {
"name": "meta.attribute.class-directive.wrn",
"begin": "(?<=\\s)(class)(:)((?:\\[[^\\]\\r\\n]*\\]|[^\\s=])+)(\\s*=\\s*)(\")",
"beginCaptures": {
"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"
},
"5": {
"name": "punctuation.definition.string.begin.wrn"
}
},
"conditional-class-single": {
"name": "meta.attribute.class-directive.wrn",
"begin": "(?<=\\s)(class)(:)((?:\\[[^\\]\\r\\n]*\\]|[^\\s=])+)(\\s*=\\s*)(')",
"beginCaptures": {
"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"
},
"5": {
"name": "punctuation.definition.string.begin.wrn"
}
},
"end": "'",
"endCaptures": {
"0": {
"name": "punctuation.definition.string.end.wrn"
}
},
"contentName": "meta.embedded.expression.wrn",
"patterns": [
{
"include": "source.ts"
}
]
},
"end": "\"",
"endCaptures": {
"0": {
"name": "punctuation.definition.string.end.wrn"
}
},
"contentName": "meta.embedded.expression.wrn",
"patterns": [
{
"include": "source.ts"
}
]
},
"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 +653,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.
Binary file not shown.
Binary file not shown.
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.44",
"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.44",
"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.44",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+117
View File
@@ -357,6 +357,123 @@ 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.
},
},
{
version: "0.2.34",
id: "reactive-bug",
description:
"Fixes component behavior encoding, lifecycle and watcher metadata parsing, CSR cleanup, and reactive form control property updates.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.35",
id: "class-bug",
description:
"Fixes component behavior encoding, lifecycle and watcher metadata parsing, CSR cleanup, and reactive form control property updates.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.36",
id: "attribute-bug",
description:
"Adds support for Tailwind arbitrary-value class directives and improves reactive class bindings.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.37",
id: "loop-condition-fix",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.38",
id: "loop-condition-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.39",
id: "server-function-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.40",
id: "conditions-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.41",
id: "conditions-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.42",
id: "conditions-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.43",
id: "conditions-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
apply() {
// No generated application files require automatic migration.
},
},
{
version: "0.2.44",
id: "conditions-bug",
description:
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
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.44",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+272 -29
View File
@@ -15,6 +15,8 @@
* realtime {..} -> export const websocket = { evt(ws, ...args) { b } } * realtime {..} -> export const websocket = { evt(ws, ...args) { b } }
*/ */
import { Buffer } from "node:buffer";
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts"; import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
interface RenderBinding { interface RenderBinding {
@@ -428,17 +430,16 @@ function renderPageComponentInvocation(
loops: string[], loops: string[],
reactive: PageReactive | null, reactive: PageReactive | null,
): string { ): string {
const attrs = node.attrs.filter((attr) => attr.name !== "data-component"); const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
.join("");
const inner = node.children const inner = node.children
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
.join(""); .join("");
return `<div data-component="${attrEscape(node.tag)}"${renderAttrs( return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
attrs,
undefined,
reactive,
)}>${inner}</div>`;
} }
function renderNestedComponentInvocation( function renderNestedComponentInvocation(
@@ -460,9 +461,19 @@ function renderNestedComponentInvocation(
return ` ${attr.name}`; return ` ${attr.name}`;
} }
const rendered = ` ${attr.name}="` + `${compileAttrValue(attr.value, ctx)}"`; const wholeExpression = wholeAttributeExpression(attr.value);
if (!attr.value.includes("{") || !exprRefsState(attr.value, ctx.stateNames)) { const compiledValue = wholeExpression
? `\${__wireProp(${ctx.resolveExpr(wholeExpression)})}`
: compileAttrValue(attr.value, ctx);
const rendered = ` ${attr.name}="${compiledValue}"`;
if (
wholeExpression ||
!attr.value.includes("{") ||
!exprRefsState(attr.value, ctx.stateNames)
) {
return rendered; return rendered;
} }
@@ -589,6 +600,28 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any):
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters); return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
} }
function __wrnexusPropAttr(
value: unknown,
): string {
const serialized =
value !== null &&
typeof value === "object"
? JSON.stringify(value)
: String(value == null ? "" : value);
return serialized.replace(
/[&<>"]/g,
(character) =>
character === "&"
? "&amp;"
: character === "<"
? "&lt;"
: character === ">"
? "&gt;"
: "&quot;",
);
}
async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> { async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> {
if (typeof ctx.__wrnexusCallApi === "function") { if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method); return await ctx.__wrnexusCallApi(path, method);
@@ -804,6 +837,8 @@ interface CompCtx {
/** `data-for` loop variables in scope their mustaches stay literal for the /** `data-for` loop variables in scope their mustaches stay literal for the
* client's list renderer (never baked server-side, since they have no value). */ * client's list renderer (never baked server-side, since they have no value). */
loopVars?: Set<string>; loopVars?: Set<string>;
/** Local identifiers introduced by server-rendered `{#each}` blocks. */
serverLocals?: Set<string>;
} }
interface ComponentBehavior { interface ComponentBehavior {
@@ -925,7 +960,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;
@@ -938,9 +975,19 @@ function exprRefsState(expr: string, stateNames: Set<string>): boolean {
} }
function viewHasEvents(nodes: ViewNode[]): boolean { function viewHasEvents(nodes: ViewNode[]): boolean {
return nodes.some( return nodes.some((node) => {
(n) => n.type === "element" && (n.attrs.some((a) => a.event) || viewHasEvents(n.children)), if (node.type === "text") return false;
);
if (node.type === "each") {
return viewHasEvents(node.body) || viewHasEvents(node.empty);
}
if (node.type === "if") {
return node.branches.some((branch) => viewHasEvents(branch.body));
}
return node.attrs.some((attr) => attr.event) || viewHasEvents(node.children);
});
} }
/** /**
@@ -1002,14 +1049,65 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
return out + escLit(attrEscape(raw.slice(last))); return out + escLit(attrEscape(raw.slice(last)));
} }
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
let expression = "``";
for (let index = node.branches.length - 1; index >= 0; index--) {
const branch = node.branches[index]!;
const body = branch.body.map((child) => renderComponentNode(child, ctx)).join("");
const bodyExpression = "`" + body + "`";
expression =
branch.cond === null
? bodyExpression
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
}
return "${" + expression + "}";
}
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
const item = node.item;
const index = node.index ?? "__wi";
const list = ctx.resolveExpr(node.list);
const childCtx: CompCtx = {
...ctx,
serverLocals: new Set([...(ctx.serverLocals ?? []), item, index]),
};
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
return (
"${(() => { const __wl = Array.isArray(" +
list +
") ? (" +
list +
") : []; return __wl.length ? __wl.map((" +
item +
", " +
index +
") => `" +
body +
'`).join("") : `' +
empty +
"`; })()}"
);
}
/** Render a component view node into template-literal-ready source. */ /** Render a component view node into template-literal-ready source. */
function renderComponentNode(node: ViewNode, ctx: CompCtx): string { function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
if (node.type === "text") return compileText(node.value, ctx); if (node.type === "text") return compileText(node.value, ctx);
if (node.type === "each" || node.type === "if") {
throw new Error( if (node.type === "each") {
"Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).", return renderComponentEachNode(node, ctx);
);
} }
if (node.type === "if") {
return renderComponentIfNode(node, ctx);
}
if (isComponentTag(node.tag)) { if (isComponentTag(node.tag)) {
return renderNestedComponentInvocation(node, ctx); return renderNestedComponentInvocation(node, ctx);
} }
@@ -1063,12 +1161,24 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
}) })
.join(""); .join("");
const staticClassValue = staticClasses.join(" ");
const classHasReactiveExpression =
staticClassValue.includes("{") && exprRefsState(staticClassValue, ctx.stateNames);
const classAttribute = const classAttribute =
staticClasses.length > 0 || conditionalClasses.length > 0 staticClasses.length > 0 || conditionalClasses.length > 0
? ` class="${compileAttrValue(staticClasses.join(" "), ctx)}${initialConditionalClasses}"` ? ` class="${compileAttrValue(staticClassValue, ctx)}${initialConditionalClasses}"`
: ""; : "";
const classReactiveBinding = classHasReactiveExpression
? ` data-wrn-bind-class="${escLit(attrEscape(JSON.stringify(["class", staticClassValue])))}"`
: "";
const classBindings = conditionalClasses const classBindings = conditionalClasses
.filter(({ expression }) => {
return !ctx.serverLocals || !exprRefsState(expression, ctx.serverLocals);
})
.map(({ className, expression }, index) => { .map(({ className, expression }, index) => {
const marker = attrEscape(JSON.stringify([className, expression])); const marker = attrEscape(JSON.stringify([className, expression]));
@@ -1081,7 +1191,8 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
const childCtx = const childCtx =
loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx; loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx;
const allAttrs = `${classAttribute}${classBindings}${attrs}`; const allAttrs =
`${classAttribute}` + `${classReactiveBinding}` + `${classBindings}` + `${attrs}`;
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${allAttrs}>`; return `<${node.tag}${allAttrs}>`;
@@ -1121,6 +1232,11 @@ function generateComponent(ast: PageAst): string {
}; };
const ctx: CompCtx = { stateNames, resolveExpr }; const ctx: CompCtx = { stateNames, resolveExpr };
const serverFunctions = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join(""); const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
const styles = ast.styles.map((body) => body.trim()).filter(Boolean); const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
const styleTag = const styleTag =
@@ -1151,7 +1267,7 @@ function generateComponent(ast: PageAst): string {
); );
} }
for (const state of ast.states) { for (const state of ast.states) {
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`); decls.push(` let ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
} }
const returnExpr = needsScope const returnExpr = needsScope
@@ -1176,18 +1292,103 @@ function generateComponent(ast: PageAst): string {
} }
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) {
if (typeof def === "number") return Number(v); return def;
if (typeof def === "boolean") return v === true || v === "" || v === "true"; }
if (typeof def === "number") {
return Number(v);
}
if (typeof def === "boolean") {
return v === true || v === "" || v === "true";
}
if (Array.isArray(def)) {
if (Array.isArray(v)) {
return v;
}
if (typeof v === "string") {
try {
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : def;
} catch {
return def;
}
}
return def;
}
if (def !== null && typeof def === "object") {
if (
v !== null &&
typeof v === "object" &&
!Array.isArray(v)
) {
return v;
}
if (typeof v === "string") {
try {
const parsed = JSON.parse(v);
return (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
)
? parsed
: def;
} catch {
return def;
}
}
return def;
}
return String(v); return String(v);
} }
function __wireHtml(v: any): string { function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;")); return String(v == null ? "" : v).replace(
} /[&<>]/g,
function __wireAttr(v: any): string { (c) =>
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&"
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;", ? "&amp;"
: c === "<"
? "&lt;"
: "&gt;",
); );
}
function __wireAttr(v: any): string {
return String(v == null ? "" : v).replace(
/[&<>"]/g,
(c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: "&quot;",
);
}
function __wireProp(v: any): string {
const value =
v !== null && typeof v === "object"
? JSON.stringify(v)
: String(v == null ? "" : v);
return __wireAttr(value);
}
function __wireRaw(v: any): string {
return String(v == null ? "" : v);
}`); }`);
if (needsScope) { if (needsScope) {
@@ -1206,10 +1407,13 @@ function __wireAttr(v: any): string {
}`); }`);
} }
const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : "";
out.push( out.push(
`export function render(props: Record<string, any> = {}): string {\n` + `export function render(props: Record<string, any> = {}): string {\n` +
` const __p = props || {};\n` + ` const __p = props || {};\n` +
(decls.length > 0 ? decls.join("\n") + "\n" : "") + (decls.length > 0 ? decls.join("\n") + "\n" : "") +
serverFunctionSource +
scopeLine + scopeLine +
` return ${returnExpr};\n` + ` return ${returnExpr};\n` +
`}`, `}`,
@@ -1218,12 +1422,51 @@ function __wireAttr(v: any): string {
return out.join("\n\n") + "\n"; return out.join("\n\n") + "\n";
} }
function __wireRaw(v: any): string {
return String(v == null ? "" : v);
}
function wholeAttributeExpression(value: string): string | null {
const match = /^\s*\{([\s\S]+)\}\s*$/.exec(value);
return match?.[1]?.trim() || null;
}
function __wireHtml(v: any): string { function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => return String(v == null ? "" : v).replace(/[&<>]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;", c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;",
); );
} }
function __wireRaw(v: any): string { function __wireAttr(v: any): string {
return String(v == null ? "" : v); return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
);
}
function __wireProp(v: any): string {
const value =
v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v);
return __wireAttr(value);
}
function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): string {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
}
if (attr.boolean) {
return ` ${attr.name}`;
}
const expression = wholeAttributeExpression(attr.value);
if (!expression) {
return ` ${attr.name}="${attrEscape(attr.value)}"`;
}
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return ` ${attr.name}="${marker}"`;
} }
+24 -12
View File
@@ -469,13 +469,6 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
const isTagNamePart = (c: string): boolean => /[A-Za-z0-9_$:.-]/.test(c); const isTagNamePart = (c: string): boolean => /[A-Za-z0-9_$:.-]/.test(c);
const isAttributeNamePart = (c: string, next: string | undefined): boolean => {
if (c === "/") {
return next !== ">";
}
return /[A-Za-z0-9_$:.[\]%-]/.test(c);
};
const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r"; const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";
const fail = (msg: string): never => { const fail = (msg: string): never => {
@@ -526,18 +519,37 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
}; };
const readAttributeName = (): string => { const readAttributeName = (): string => {
const first = src[i]; if (i >= src.length) {
if (i >= src.length || (!isNameStart(first!) && first !== ":" && first !== "$")) {
return fail("Expected an attribute name"); return fail("Expected an attribute name");
} }
const start = i++; const start = i;
while (i < src.length) {
const char = src[i];
const next = src[i + 1];
if (
char === "=" ||
char === ">" ||
char === '"' ||
char === "'" ||
char === " " ||
char === "\t" ||
char === "\n" ||
char === "\r" ||
(char === "/" && next === ">")
) {
break;
}
while (i < src.length && isAttributeNamePart(src[i]!, src[i + 1])) {
i++; i++;
} }
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i); return src.slice(start, i);
}; };
+409 -6
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { generate, parse } from "../src/index.ts"; import { generate, parse } from "../src/index.ts";
import { compileWireFile } from "../src/index.ts"; import { compileWireFile } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test";
let seq = 0; let seq = 0;
/** Compile a `.wrn` source and import the resulting module. */ /** Compile a `.wrn` source and import the resulting module. */
@@ -16,6 +17,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");
@@ -210,7 +244,9 @@ component ParentCard {
expect(output).toContain('data-component="ChildCard"'); expect(output).toContain('data-component="ChildCard"');
expect(output).toContain('title="${__wireAttr(title)}"'); expect(output).toContain('title="${__wireProp(title)}"');
expect(output).toContain("function __wireProp");
}); });
test("parses a layout block", () => { test("parses a layout block", () => {
@@ -382,11 +418,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 +443,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 +478,364 @@ 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"));
}
test("component interpolated class attributes remain reactive", () => {
const output = generate(
parse(`
component CookieToggle {
state personalizationCookies = false
view {
<span
class="base {personalizationCookies ? 'enabled' : 'disabled'}"
>
Cookie
</span>
}
}
`),
);
const match = output.match(/data-wrn-bind-class="([^"]+)"/);
expect(match).not.toBeNull();
const encodedBinding = match?.[1] ?? "";
const decodedBinding = encodedBinding
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
const binding = JSON.parse(decodedBinding) as [string, string];
expect(binding[0]).toBe("class");
expect(binding[1]).toBe("base {personalizationCookies ? 'enabled' : 'disabled'}");
});
test("parses class directives containing Tailwind arbitrary values", () => {
const ast = parse(`
component PageHeader {
state centered = false
view {
<div
class="py-12"
class:lg:grid-cols-[minmax(0,1fr)_auto]="!centered"
class:dark:bg-white/10="centered"
class:w-[calc(100%-2rem)]="centered"
>
Header
</div>
}
}
`);
expect(ast).toBeDefined();
});
test("compiles Tailwind arbitrary values in class directives", () => {
const output = generate(
parse(`
component PageHeader {
state centered = false
view {
<div
class="py-12"
class:lg:grid-cols-[minmax(0,1fr)_auto]="!centered"
class:dark:bg-white/10="centered"
>
Header
</div>
}
}
`),
);
expect(output).toContain("lg:grid-cols-[minmax(0,1fr)_auto]");
expect(output).toContain("dark:bg-white/10");
});
test("components support each blocks", () => {
const output = generate(
parse(`
component PageHeader {
props {
highlights = []
}
view {
<div>
{#each highlights as highlight}
<span>{highlight.title}</span>
{/each}
</div>
}
}
`),
);
expect(output).toContain("highlights");
expect(output).toContain("highlight.title");
expect(output).not.toContain("supported in pages, not components");
});
test("components support if blocks", () => {
const output = generate(
parse(`
component Banner {
props {
visible = true
}
view {
{#if visible}
<div>Visible</div>
{:else}
<div>Hidden</div>
{/if}
}
}
`),
);
expect(output).toContain("visible");
expect(output).toContain("Visible");
expect(output).toContain("Hidden");
});
test("component array props support each blocks", () => {
const output = generate(
parse(`
component HighlightList {
props {
highlights = []
}
view {
{#each highlights as highlight}
<p>{highlight.title}</p>
{/each}
}
}
`),
);
expect(output).toContain("Array.isArray(def)");
expect(output).toContain("JSON.parse(v)");
expect(output).toContain("highlights");
});
test("page component props evaluate whole expressions", () => {
const output = generate(
parse(`
page Home {
view {
<HighlightList
enabled="{true}"
highlights="{[
{ title: 'First' },
{ title: 'Second' }
]}"
/>
}
}
`),
);
expect(output).toContain("__wrnexusPropAttr(true)");
expect(output).toContain("__wrnexusPropAttr([");
});
test("component functions are available during server rendering", () => {
const output = generate(
parse(`
component FAQAccordion {
props {
allowMultiple = false
items = []
}
state openIndexes = []
functions {
function isOpen(index) {
return openIndexes.includes(index)
}
function toggleItem(index) {
if (isOpen(index)) {
openIndexes = openIndexes.filter(
(itemIndex) =>
itemIndex !== index
)
} else if (allowMultiple) {
openIndexes = [
...openIndexes,
index
]
} else {
openIndexes = [index]
}
}
}
view {
{#each items as item, index}
<button
class:hidden="!isOpen(index)"
@click="toggleItem(index)"
>
{item.title}
</button>
{/each}
}
}
`),
);
expect(output).toContain("function isOpen(index)");
expect(output).toContain("function toggleItem(index)");
expect(output).toContain("let openIndexes =");
expect(output).toContain("isOpen(index)");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.2.31", "version": "0.2.44",
"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.44",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.2.31", "version": "0.2.44",
"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.44",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+36
View File
@@ -0,0 +1,36 @@
import { existsSync, rmSync } from "node:fs";
import { resolve } from "node:path";
export interface ResetDevCacheOptions {
rootDir?: string;
cacheDir?: string;
enabled?: boolean;
}
export function resetDevCache(options: ResetDevCacheOptions = {}): string {
const rootDir = resolve(options.rootDir ?? process.cwd());
const cacheDir = resolve(rootDir, options.cacheDir ?? ".wrnexus");
if (options.enabled === false) {
return cacheDir;
}
// Safety: never allow deleting the project root.
if (cacheDir === rootDir) {
throw new Error("Refusing to remove the project root as the WRNexus cache directory.");
}
if (existsSync(cacheDir)) {
rmSync(cacheDir, {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 100,
});
console.log(`[wrnexus] cleared generated cache: ${cacheDir}`);
}
return cacheDir;
}
+7
View File
@@ -29,6 +29,7 @@ import { createDevAssetServer } from "./assets.ts";
import { HmrHub } from "./hmr.ts"; import { HmrHub } from "./hmr.ts";
import { startWatcher } from "./watch.ts"; import { startWatcher } from "./watch.ts";
export { RESTART_EXIT_CODE } from "./restart.ts"; export { RESTART_EXIT_CODE } from "./restart.ts";
import { resetDevCache } from "./cache.ts";
export interface ServeOptions { export interface ServeOptions {
appDir: string; appDir: string;
@@ -122,6 +123,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] }); const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
const styleEntry = opts.styleEntry ?? null; const styleEntry = opts.styleEntry ?? null;
const appRoot = dirname(appDir); const appRoot = dirname(appDir);
resetDevCache({
rootDir: appRoot,
enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1",
});
// Compile every `.wrn` into ONE cache dir at the project root, instead of a // Compile every `.wrn` into ONE cache dir at the project root, instead of a
// `.wrnexus/` next to each source file (and inside node_modules UI dirs). // `.wrnexus/` next to each source file (and inside node_modules UI dirs).
setCompileCacheDir(join(appRoot, ".wrnexus")); setCompileCacheDir(join(appRoot, ".wrnexus"));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.2.31", "version": "0.2.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"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.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+63
View File
@@ -0,0 +1,63 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const version = "0.2.44";
const dependencyGroups = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
for (const entry of readdirSync("packages", {
withFileTypes: true,
})) {
if (!entry.isDirectory()) {
continue;
}
const packageFile = join("packages", entry.name, "package.json");
if (!existsSync(packageFile)) {
continue;
}
const packageJson = JSON.parse(readFileSync(packageFile, "utf8"));
packageJson.version = version;
for (const group of dependencyGroups) {
const dependencies = packageJson[group];
if (!dependencies || typeof dependencies !== "object") {
continue;
}
for (const dependencyName of Object.keys(dependencies)) {
if (!dependencyName.startsWith("@wrnexus/")) {
continue;
}
const currentVersion = dependencies[dependencyName];
if (typeof currentVersion !== "string") {
continue;
}
if (currentVersion.startsWith("workspace:")) {
continue;
}
if (currentVersion.startsWith("^")) {
dependencies[dependencyName] = `^${version}`;
} else if (currentVersion.startsWith("~")) {
dependencies[dependencyName] = `~${version}`;
} else {
dependencies[dependencyName] = version;
}
}
}
writeFileSync(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
}