Compare commits

..
7 Commits
Author SHA1 Message Date
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
71 changed files with 4221 additions and 698 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.2.31",
"@wrnexus/router": "^0.2.31",
"@wrnexus/csr": "^0.2.31",
"@wrnexus/compiler": "^0.2.31",
"@wrnexus/styles": "^0.2.31",
"@wrnexus/dev-server": "^0.2.31",
"@wrnexus/ui": "^0.2.31",
"@wrnexus/validation": "^0.2.31",
"@wrnexus/i18n": "^0.2.31",
"@wrnexus/db": "^0.2.31"
"@wrnexus/core": "^0.2.38",
"@wrnexus/router": "^0.2.38",
"@wrnexus/csr": "^0.2.38",
"@wrnexus/compiler": "^0.2.38",
"@wrnexus/styles": "^0.2.38",
"@wrnexus/dev-server": "^0.2.38",
"@wrnexus/ui": "^0.2.38",
"@wrnexus/validation": "^0.2.38",
"@wrnexus/i18n": "^0.2.38",
"@wrnexus/db": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.31"
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,18 +25,18 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.31",
"@wrnexus/router": "^0.2.31",
"@wrnexus/ssr": "^0.2.31",
"@wrnexus/csr": "^0.2.31",
"@wrnexus/compiler": "^0.2.31",
"@wrnexus/styles": "^0.2.31",
"@wrnexus/ui": "^0.2.31",
"@wrnexus/validation": "^0.2.31",
"@wrnexus/i18n": "^0.2.31",
"@wrnexus/db": "^0.2.31",
"@wrnexus/pubsub": "^0.2.31",
"@wrnexus/uploader": "^0.2.31"
"@wrnexus/core": "^0.2.38",
"@wrnexus/router": "^0.2.38",
"@wrnexus/ssr": "^0.2.38",
"@wrnexus/csr": "^0.2.38",
"@wrnexus/compiler": "^0.2.38",
"@wrnexus/styles": "^0.2.38",
"@wrnexus/ui": "^0.2.38",
"@wrnexus/validation": "^0.2.38",
"@wrnexus/i18n": "^0.2.38",
"@wrnexus/db": "^0.2.38",
"@wrnexus/pubsub": "^0.2.38",
"@wrnexus/uploader": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.31"
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.31"
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.2.31"
"@wrnexus/native": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.2.31",
"@wrnexus/core": "^0.2.31"
"@wrnexus/compiler": "^0.2.38",
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.31"
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.2.31"
"@wrnexus/uploader": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.2.31"
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.31"
"@wrnexus/core": "^0.2.38"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
+17 -1
View File
@@ -42,5 +42,21 @@
"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
Language support for [WRNexus](https://wrnexusjs.dev) `.wrn` files — the SSR-first,
Bun-powered full-stack framework.
Complete VS Code language support for [WRNexus](https://wrnexusjs.dev) `.wrn` files.
WRNexus is an SSR-first, Bun-powered full-stack framework with reactive components, layouts, colocated APIs, browser functions, lifecycle hooks, state watchers, client-side navigation, and reusable UI components.
## Installation
After the first Marketplace release, search for **WRNexus Language Support** in
VS Code or run:
Search for **WRNexus Language Support** in the VS Code Extensions view or run:
```text
ext install wrnexus.wrnexus
```
For a local package, open the Command Palette, choose **Extensions: Install from
VSIX**, and select the generated `wrnexus-<version>.vsix` file.
For a local build:
1. Package the extension:
```bash
bun run package
```
2. Open the VS Code Command Palette.
3. Select **Extensions: Install from VSIX**.
4. Choose the generated `wrnexus-<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
- **Syntax highlighting** ("color-full code"). Keywords (`page`, `component`,
`state`, `view`, `api`, `realtime`, …) plus **embedded highlighting** for the
languages inside each block:
- `view { … }` → HTML, with `{expr}` interpolation and `{t:key}` translations
and `@event="…"` bindings picked out.
- `style { … }` → CSS.
- `functions { … }`, `api … { … }`, `ssr`/`client`, `realtime on(…) { … }` → TypeScript.
- **WrNexus attributes in distinct colors.** A grammar injection recolors the
framework's own attributes so they pop even inside plain HTML: `@event`
bindings and `{t:…}` in one accent, runtime directives (`data-component`,
`data-for`, `data-show`, `data-text`, `data-scope`, `data-slot`, `data-on-*`,
`data-wire-*`) in another. Default colors ship with the extension; change them
under `editor.tokenColorCustomizations` (scopes
`entity.other.attribute-name.wrn.directive` and `…wire.event`).
- **Diagnostics.** Parse errors from the real `@wrnexus/compiler` are shown inline
as you type, anchored to the exact offset.
- **Formatting.** Format Document and format-on-save normalize WRN block, HTML,
CSS, and function indentation without rewriting application expressions.
- **Snippets.** `page`, `component`, `view`, `state`, `props`, `seo`, `api`,
`ssr`, `client`, `realtime`, `functions`, `style`, plus view helpers `mount`,
`for`, `show`, `t`.
- **Completions.** Block keywords at file scope, `data-*` runtime attributes and
`@event` bindings inside a `view`, and HTTP methods after `api`.
### Syntax highlighting
## Settings
The extension provides highlighting for WRNexus declarations and embedded languages.
| Setting | Default | Description |
| ---------------------------- | ------- | ----------------------------------------- |
| `wrnexus.diagnostics.enable` | `true` | Show parse errors for `.wrn` files. |
| `wrnexus.format.enable` | `true` | Enable the built-in formatter for `.wrn`. |
Supported top-level declarations:
To format automatically when saving, add this to VS Code settings:
```wrn
page HomePage {
}
component UserCard {
}
layout PublicLayout {
}
```
Supported WRNexus blocks include:
```wrn
props {
}
state count = 0
seo {
}
functions {
}
lifecycle {
}
watch count {
}
view {
}
style {
}
api GET /api/users {
}
ssr {
}
client {
}
realtime chat {
}
```
Embedded language highlighting:
- `view { ... }` uses HTML highlighting.
- `style { ... }` uses CSS highlighting.
- `functions { ... }` uses TypeScript highlighting.
- Lifecycle hook bodies use TypeScript highlighting.
- Watcher bodies use TypeScript highlighting.
- API, SSR, client, and realtime bodies use TypeScript highlighting.
- `{expression}` interpolation uses TypeScript expression highlighting.
### Pages
Create route pages with layouts, SEO metadata, state, APIs, and views:
```wrn
page UserPage {
layout = "public"
state id = ctx.params.id
seo {
title = "User profile"
description = "View user information"
}
view {
<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
"[wrn]": {
@@ -57,29 +617,178 @@ To format automatically when saving, add this to VS Code settings:
}
```
Disable the formatter:
```json
"wrnexus.format.enable": false
```
Configure preferred line width:
```json
"wrnexus.formatting.printWidth": 100
```
## Autocomplete
Autocomplete is available for:
- pages
- components
- layouts
- state declarations
- props
- views
- SEO metadata
- functions
- lifecycle hooks
- watchers
- declared state names
- declared props
- declared functions
- watcher variables
- route parameters
- context values
- standard events
- window events
- document events
- reactive attributes
- conditional classes
After typing:
```wrn
watch
```
the extension suggests declared state names.
Inside a watcher, it suggests:
```text
value
previous
```
Declared component functions are suggested as callable snippets:
```text
updateVisibility()
scrollToTop()
```
## Go to definition
Use Ctrl+Click or **Go to Definition** for supported WRNexus symbols.
The extension can navigate to component declarations and locally declared functions when supported by the definition provider.
## Snippets
Useful snippet prefixes include:
| Prefix | Purpose |
| ------------------------ | ------------------------------------- |
| `wrn-page` | Create a page |
| `wrn-dynamic-page` | Create a dynamic route page |
| `wrn-component` | Create a component |
| `wrn-reactive-component` | Create a reactive component |
| `wrn-layout` | Create a layout |
| `wrn-state` | Declare state |
| `wrn-functions` | Create a functions block |
| `wrn-function` | Create a function |
| `wrn-async-function` | Create an async function |
| `wrn-lifecycle` | Create all lifecycle hooks |
| `wrn-mount` | Create a mount hook |
| `wrn-update` | Create an update hook |
| `wrn-unmount` | Create an unmount hook |
| `wrn-watch` | Create a watcher |
| `wrn-window-scroll` | Add a window scroll event |
| `wrn-window-resize` | Add a window resize event |
| `wrn-document-event` | Add a document event |
| `wrn-class-if` | Add a conditional class |
| `wrn-class-toggle` | Toggle two classes |
| `wrn-back-to-top` | Create a complete BackToTop component |
## Settings
| Setting | Default | Description |
| ------------------------------- | ------- | -------------------------------------- |
| `wrnexus.diagnostics.enable` | `true` | Enable editor and compiler diagnostics |
| `wrnexus.format.enable` | `true` | Enable the built-in formatter |
| `wrnexus.formatting.printWidth` | `100` | Preferred formatter line width |
Recommended VS Code configuration:
```json
{
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.snippetSuggestions": "top"
},
"wrnexus.diagnostics.enable": true,
"wrnexus.format.enable": true,
"wrnexus.formatting.printWidth": 100
}
```
## Privacy
The extension does not collect telemetry and does not send source code or usage
data over the network. Formatting, completion, and diagnostics run locally.
The extension does not collect telemetry.
It does not send source code, diagnostics, formatting data, or usage information over the network.
Syntax highlighting, formatting, autocomplete, snippets, definition navigation, and diagnostics run locally.
## Development
Diagnostics use a bundled copy of the compiler. Build and validate everything
after changing the extension or `@wrnexus/compiler`:
Install dependencies:
```bash
bun install
```
Rebuild the bundled compiler:
```bash
bun run build:compiler
```
Validate the extension:
```bash
bun run check
```
Package a VSIX:
```bash
bun run package
```
`bun run package` creates `wrnexus-<version>.vsix`. The extension still
highlights, provides snippets, and completes without the compiler bundle; only
inline error reporting requires it.
This creates:
Open this folder in VS Code and press <kbd>F5</kbd> to launch an Extension
Development Host, then open any `.wrn` file.
```text
wrnexus-<version>.vsix
```
See the
[Marketplace publishing guide](https://git.workroot.in/WorkRoot/WRNexusJS/src/branch/main/editors/vscode/PUBLISHING.md)
for the release procedure.
Install the local package:
```bash
code --install-extension ./wrnexus-<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",
"displayName": "WRNexus Language Support",
"description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.",
"version": "0.2.6",
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
"version": "0.2.9",
"publisher": "wrnexus",
"private": true,
"license": "SEE LICENSE IN LICENSE",
@@ -32,15 +32,22 @@
"Formatters"
],
"keywords": [
"wire",
"wrnexus",
"wrn",
"wire",
"wirejs",
"language support",
"formatter",
"diagnostics",
"syntax highlighting",
"autocomplete",
"snippets",
"lifecycle",
"state watcher",
"components",
"full-stack",
"ssr",
"csr",
"bun"
],
"main": "./src/extension.js",
@@ -52,7 +59,8 @@
{
"id": "wrn",
"aliases": [
"WrNexus",
"WRNexus",
"WRN",
"wrn"
],
"extensions": [
@@ -92,23 +100,82 @@
}
],
"configuration": {
"title": "WrNexus",
"title": "WRNexus",
"properties": {
"wrnexus.diagnostics.enable": {
"type": "boolean",
"default": true,
"description": "Show parse errors for .wrn files as you type."
"scope": "resource",
"description": "Enable WRNexus editor and compiler diagnostics for .wrn files."
},
"wrnexus.format.enable": {
"type": "boolean",
"default": true,
"description": "Enable the built-in formatter for .wrn files."
"scope": "resource",
"description": "Enable the built-in WRNexus formatter for .wrn files."
},
"wrnexus.formatting.printWidth": {
"type": "number",
"default": 100,
"minimum": 60,
"maximum": 240,
"scope": "resource",
"description": "Preferred maximum line width used by the WRNexus formatter."
}
}
},
"configurationDefaults": {
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": false,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.autoClosingBrackets": "always",
"editor.autoClosingQuotes": "always",
"editor.suggest.showSnippets": true,
"editor.snippetSuggestions": "top"
},
"editor.tokenColorCustomizations": {
"textMateRules": [
{
"scope": "storage.type.wrn",
"settings": {
"foreground": "#c084fc",
"fontStyle": "bold"
}
},
{
"scope": "entity.name.type.wrn",
"settings": {
"foreground": "#67e8f9"
}
},
{
"scope": "entity.name.function.wrn",
"settings": {
"foreground": "#a7f3d0"
}
},
{
"scope": "entity.name.function.lifecycle.wrn",
"settings": {
"foreground": "#fbbf24",
"fontStyle": "bold"
}
},
{
"scope": "variable.other.readwrite.wrn",
"settings": {
"foreground": "#93c5fd"
}
},
{
"scope": "variable.language.wrn",
"settings": {
"foreground": "#f0abfc",
"fontStyle": "italic"
}
},
{
"scope": "entity.other.attribute-name.wrn.directive",
"settings": {
@@ -123,6 +190,13 @@
"fontStyle": "bold"
}
},
{
"scope": "entity.other.attribute-name.event.wrn",
"settings": {
"foreground": "#f472b6",
"fontStyle": "bold"
}
},
{
"scope": "punctuation.definition.keyword.wrn",
"settings": {
@@ -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": {
"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": {
"prefix": ["wrn-dynamic-page", "dynamic-page"],
"description": "Create a WRN page with a route parameter",
"description": "Create a WRN page using a route parameter",
"body": [
"page ${1:DynamicPage} {",
" layout = \"${2:default}\"",
@@ -40,13 +40,15 @@
"WRN component": {
"prefix": ["wrn-component", "component"],
"description": "Create a WRN component",
"description": "Create a reusable WRN component",
"body": [
"component ${1:ComponentName} {",
" props {",
" ${2:title} = \"${3:Title}\"",
" }",
"",
" state ${4:ready} = false",
"",
" view {",
" <div>",
" <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": {
"prefix": ["wrn-layout", "layout"],
"description": "Create a WRN layout",
"description": "Create a reusable WRN layout",
"body": [
"layout ${1:LayoutName} {",
" view {",
@@ -76,6 +112,74 @@
"body": ["state ${1:name} = ${2:\"value\"}"]
},
"WRN functions block": {
"prefix": ["wrn-functions", "functions"],
"description": "Create a browser-side functions block",
"body": ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"]
},
"WRN function": {
"prefix": ["wrn-function", "function"],
"description": "Create a function inside a functions block",
"body": ["function ${1:name}(${2}) {", " $0", "}"]
},
"WRN async function": {
"prefix": ["wrn-async-function", "async-function"],
"description": "Create an asynchronous component function",
"body": ["async function ${1:name}(${2}) {", " $0", "}"]
},
"WRN lifecycle": {
"prefix": ["wrn-lifecycle", "lifecycle"],
"description": "Create mount, update, and unmount lifecycle hooks",
"body": [
"lifecycle {",
" mount {",
" ${1}",
" }",
"",
" update {",
" ${2}",
" }",
"",
" unmount {",
" $0",
" }",
"}"
]
},
"WRN mount hook": {
"prefix": ["wrn-mount", "mount"],
"description": "Create a component mount lifecycle hook",
"body": ["mount {", " $0", "}"]
},
"WRN update hook": {
"prefix": ["wrn-update", "update-hook"],
"description": "Create a batched component update lifecycle hook",
"body": ["update {", " $0", "}"]
},
"WRN unmount hook": {
"prefix": ["wrn-unmount", "unmount"],
"description": "Create a component unmount lifecycle hook",
"body": ["unmount {", " $0", "}"]
},
"WRN watcher": {
"prefix": ["wrn-watch", "watch"],
"description": "Watch a declared state value",
"body": ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"]
},
"WRN watcher with condition": {
"prefix": ["wrn-watch-if", "watch-if"],
"description": "Watch state and react to a specific value",
"body": ["watch ${1:stateName} {", " if (value === ${2:true}) {", " $0", " }", "}"]
},
"WRN SEO block": {
"prefix": ["wrn-seo", "seo"],
"description": "Create SEO metadata",
@@ -127,12 +231,36 @@
"body": ["class:${1:border-indigo-500}=\"${2:condition}\""]
},
"WRN conditional class pair": {
"prefix": ["wrn-class-toggle", "class-toggle"],
"description": "Toggle two classes from one condition",
"body": ["class:${1:opacity-100}=\"${3:visible}\"", "class:${2:opacity-0}=\"!${3:visible}\""]
},
"WRN click event": {
"prefix": ["wrn-click", "click"],
"description": "Add a click handler",
"description": "Add a click event handler",
"body": ["@click=\"${1:handler()}\""]
},
"WRN window scroll event": {
"prefix": ["wrn-window-scroll", "window-scroll"],
"description": "Add a window scroll event handler",
"body": ["@window:scroll=\"${1:handler()}\""]
},
"WRN window resize event": {
"prefix": ["wrn-window-resize", "window-resize"],
"description": "Add a window resize event handler",
"body": ["@window:resize=\"${1:handler()}\""]
},
"WRN document event": {
"prefix": ["wrn-document-event", "document-event"],
"description": "Add a document-level event handler",
"body": ["@document:${1:click}=\"${2:handler()}\""]
},
"WRN show directive": {
"prefix": ["wrn-show", "show"],
"description": "Conditionally show an element",
@@ -158,7 +286,7 @@
"api GET ${1:/api/resource} {",
" return Response.json({",
" ${2:success}: true",
" });",
" })",
"}"
]
},
@@ -168,32 +296,19 @@
"description": "Create a POST API route",
"body": [
"api POST ${1:/api/resource} {",
" const body = await ctx.request.json();",
" const body = await ctx.request.json()",
"",
" return Response.json({",
" success: true,",
" data: body",
" });",
"}"
]
},
"WRN middleware": {
"prefix": ["wrn-middleware", "middleware"],
"description": "Create middleware",
"body": [
"middleware ${1:auth} {",
" async function handle(ctx, next) {",
" ${2}",
" return await next();",
" }",
" })",
"}"
]
},
"WRN loading state": {
"prefix": ["wrn-loading", "loading-state"],
"description": "Create loading state and button",
"description": "Create loading state and a reactive button",
"body": [
"state loading = false",
"",
@@ -206,5 +321,64 @@
" {loading ? \"Loading...\" : \"${1:Submit}\"}",
"</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>",
" }",
"}"
]
}
}
+109 -12
View File
@@ -289,6 +289,8 @@ function parse(source) {
const functions = [];
const dataApis = [];
const modeFunctions = [];
const lifecycle = {};
const watches = [];
const apis = [];
const realtimes = [];
while (lx.peek().type !== "rbrace") {
@@ -413,6 +415,40 @@ function parse(source) {
styles.push(lx.readBalancedBraces());
break;
}
case "lifecycle": {
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const hook = lx.peek();
if (hook.type === "eof") {
throw new ParseError("Unexpected end of input inside lifecycle block");
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
}
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
}
const hookName = hook.value;
lx.next();
if (lifecycle[hookName] !== undefined) {
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
}
lifecycle[hookName] = lx.readBalancedBraces();
}
expect("rbrace");
break;
}
case "watch": {
lx.next();
const stateName = expect("ident").value;
const body = lx.readBalancedBraces();
watches.push({
state: stateName,
body
});
break;
}
case "functions": {
lx.next();
functions.push(lx.readBalancedBraces());
@@ -423,6 +459,12 @@ function parse(source) {
}
}
expect("rbrace");
const declaredStates = new Set(states.map((state) => state.name));
for (const watcher of watches) {
if (!declaredStates.has(watcher.state)) {
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
}
}
return {
type: "page",
kind,
@@ -436,6 +478,8 @@ function parse(source) {
functions,
dataApis,
modeFunctions,
lifecycle,
watches,
apis,
realtimes
};
@@ -502,14 +546,22 @@ function parseHtmlView(src, pos) {
return src.slice(start, i);
};
const readAttributeName = () => {
const first = src[i];
if (i >= src.length || !isNameStart(first) && first !== ":" && first !== "$") {
if (i >= src.length) {
return fail("Expected an attribute name");
}
const start = i++;
while (i < src.length && isAttributeNamePart(src[i], src[i + 1])) {
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 === `
` || char === "\r" || char === "/" && next === ">") {
break;
}
i++;
}
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i);
};
const parseTag = () => {
@@ -716,10 +768,18 @@ function renderAttr(attr) {
}
}
function eventAttribute(name) {
if (name.startsWith("browser-"))
if (name.startsWith("window:")) {
return `data-on-window-${name.slice("window:".length)}`;
}
if (name.startsWith("document:")) {
return `data-on-document-${name.slice("document:".length)}`;
}
if (name.startsWith("browser-")) {
return `data-on-wrnexus-browser-${name.slice(8)}`;
if (name.startsWith("mobile-"))
}
if (name.startsWith("mobile-")) {
return `data-on-wrnexus-mobile-${name.slice(7)}`;
}
return `data-on-${name}`;
}
function reactiveAttrValue(raw, reactive) {
@@ -924,7 +984,7 @@ function renderNestedComponentInvocation(node, ctx) {
let bindIndex = 0;
const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`;
return escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`);
}
if (attr.boolean) {
return ` ${attr.name}`;
@@ -1262,6 +1322,35 @@ function safeRef(name) {
function escLit(s) {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function componentBehavior(ast) {
const functions = ast.functions.map((body) => body.trim()).filter(Boolean).join(`
`);
const lifecycle = {
...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {},
...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {},
...ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {}
};
const watches = ast.watches.map((watch) => ({
state: watch.state,
body: watch.body.trim()
}));
if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) {
return null;
}
return {
functions,
lifecycle,
watches
};
}
function behaviorAttribute(behavior) {
if (!behavior) {
return "";
}
const encoded = Buffer.from(JSON.stringify(behavior), "utf8").toString("base64");
return ` data-wrn-behavior="${encoded}"`;
}
var INTERP_RE = /\{([^{}]+)\}/g;
function exprRefsState(expr, stateNames) {
for (const name of stateNames) {
@@ -1340,7 +1429,7 @@ function renderComponentNode(node, ctx) {
}
const attrs = node.attrs.filter((a) => a.name !== "class" && !a.name.startsWith("class:")).map((a) => {
if (a.event) {
return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
}
if (a.boolean) {
return ` ${a.name}`;
@@ -1355,14 +1444,17 @@ function renderComponentNode(node, ctx) {
const initialConditionalClasses = conditionalClasses.map(({ className, expression }) => {
return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`;
}).join("");
const classAttribute = staticClasses.length > 0 || conditionalClasses.length > 0 ? ` class="${compileAttrValue(staticClasses.join(" "), ctx)}${initialConditionalClasses}"` : "";
const staticClassValue = staticClasses.join(" ");
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.map(({ className, expression }, index) => {
const marker = attrEscape(JSON.stringify([className, expression]));
return ` data-wrn-class-${index}="${escLit(marker)}"`;
}).join("");
const loops = loopVarsOf(node);
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())) {
return `<${node.tag}${allAttrs}>`;
}
@@ -1400,11 +1492,13 @@ function generateComponent(ast) {
${styles.map(styleEscape).join(`
`)}
</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 = [
...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name)
];
const behaviorAttr = behaviorAttribute(behavior);
const decls = [];
for (const prop of effectiveProps) {
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
@@ -1412,7 +1506,7 @@ ${styles.map(styleEscape).join(`
for (const state of ast.states) {
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
}
const returnExpr = needsScope ? "`" + styleTag + '<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(", ")} });
` : needsScope ? ` const __scope = "";
` : "";
@@ -1421,6 +1515,9 @@ ${styles.map(styleEscape).join(`
} else {
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
}
if (behavior) {
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
}
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v);
+356 -30
View File
@@ -20,15 +20,26 @@ const BLOCK_COMPLETIONS = [
{
label: "component",
detail: "WRN component",
documentation: "Create a reusable WRN component.",
documentation:
"Create a reusable WRN component with state, functions, lifecycle hooks, watchers, and a view.",
snippet: [
"component ${1:ComponentName} {",
" props {",
' ${2:title} = "${3:Title}"',
" state ${2:ready} = false",
"",
" functions {",
" function ${3:initialize}() {",
" $0",
" }",
" }",
"",
" lifecycle {",
" mount {",
" ${3:initialize}()",
" }",
" }",
"",
" view {",
" $0",
" <div></div>",
" }",
"}",
].join("\n"),
@@ -47,9 +58,16 @@ const BLOCK_COMPLETIONS = [
"}",
].join("\n"),
},
{
label: "props",
detail: "Component or layout props block",
documentation: "Declare values accepted by a component or layout.",
snippet: ["props {", ' ${1:title} = "${2:Title}"', "}"].join("\n"),
},
{
label: "seo",
detail: "SEO metadata block",
documentation: "Declare title, description, and other page metadata.",
snippet: [
"seo {",
' title = "${1:Page title}"',
@@ -60,13 +78,84 @@ const BLOCK_COMPLETIONS = [
{
label: "view",
detail: "WRN view block",
documentation: "Declare the HTML view rendered by the page, component, or layout.",
snippet: ["view {", " $0", "}"].join("\n"),
},
{
label: "state",
detail: "Reactive state declaration",
documentation: "Declare reactive state owned by the current component, layout, or page.",
snippet: 'state ${1:name} = ${2:"value"}',
},
{
label: "functions",
detail: "Browser component functions block",
documentation:
"Declare functions that can be called from events, lifecycle hooks, and watchers.",
snippet: ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"].join(
"\n",
),
},
{
label: "function",
detail: "WRN component function",
documentation: "Declare a browser-side function inside a functions block.",
snippet: ["function ${1:name}(${2}) {", " $0", "}"].join("\n"),
},
{
label: "lifecycle",
detail: "Component lifecycle block",
documentation: "Declare mount, update, and unmount hooks for a component or layout.",
snippet: [
"lifecycle {",
" mount {",
" $1",
" }",
"",
" update {",
" $2",
" }",
"",
" unmount {",
" $0",
" }",
"}",
].join("\n"),
},
{
label: "mount",
detail: "Lifecycle mount hook",
documentation: "Runs once after the component is connected and hydrated.",
snippet: ["mount {", " $0", "}"].join("\n"),
},
{
label: "update",
detail: "Lifecycle update hook",
documentation: "Runs once after a batch of reactive state changes.",
snippet: ["update {", " $0", "}"].join("\n"),
},
{
label: "unmount",
detail: "Lifecycle unmount hook",
documentation:
"Runs before the component is removed. Use it to remove global listeners and release resources.",
snippet: ["unmount {", " $0", "}"].join("\n"),
},
{
label: "watch",
detail: "Reactive state watcher",
documentation:
"Run code when one declared state value changes. The watcher receives `value` and `previous`.",
snippet: ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"].join(
"\n",
),
},
{
label: "style",
detail: "Scoped style block",
documentation: "Declare styles for the current WRN declaration.",
snippet: ["style {", " $0", "}"].join("\n"),
},
];
const ATTRIBUTE_COMPLETIONS = [
@@ -76,6 +165,19 @@ const ATTRIBUTE_COMPLETIONS = [
["@submit", "Submit event handler", '@submit="${1:handler()}"'],
["@focus", "Focus event handler", '@focus="${1:handler()}"'],
["@blur", "Blur event handler", '@blur="${1:handler()}"'],
["@keydown", "Keyboard key-down event handler", '@keydown="${1:handler()}"'],
["@keyup", "Keyboard key-up event handler", '@keyup="${1:handler()}"'],
["@mouseenter", "Pointer enter event handler", '@mouseenter="${1:handler()}"'],
["@mouseleave", "Pointer leave event handler", '@mouseleave="${1:handler()}"'],
["@window:scroll", "Window scroll event handler", '@window:scroll="${1:handler()}"'],
["@window:resize", "Window resize event handler", '@window:resize="${1:handler()}"'],
["@window:keydown", "Window key-down event handler", '@window:keydown="${1:handler()}"'],
["@document:click", "Document click event handler", '@document:click="${1:handler()}"'],
[
"@document:visibilitychange",
"Document visibility-change event handler",
'@document:visibilitychange="${1:handler()}"',
],
["data-show", "Conditional visibility", 'data-show="${1:condition}"'],
["data-for", "Reactive loop", 'data-for="${1:item} in ${2:items}"'],
["class:", "Conditional CSS class", 'class:${1:border-indigo-500}="${2:condition}"'],
@@ -90,6 +192,11 @@ const CONTEXT_COMPLETIONS = [
["ctx.locals", "Request-local data"],
];
const WATCH_VALUE_COMPLETIONS = [
["value", "Current watcher state value"],
["previous", "Previous watcher state value"],
];
function completionKindFor(label) {
if (label.startsWith("@")) {
return vscode.CompletionItemKind.Event;
@@ -99,15 +206,47 @@ function completionKindFor(label) {
return vscode.CompletionItemKind.Property;
}
if (label === "function" || label === "functions") {
return vscode.CompletionItemKind.Function;
}
return vscode.CompletionItemKind.Keyword;
}
function createCompletion(label, detail, snippet) {
function createCompletion(label, detail, snippet, documentation) {
const item = new vscode.CompletionItem(label, completionKindFor(label));
item.detail = detail;
item.insertText = new vscode.SnippetString(snippet || label);
item.documentation = new vscode.MarkdownString(detail);
item.documentation = new vscode.MarkdownString(documentation || detail);
return item;
}
function createVariableCompletion(label, detail, insertion = label) {
const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Variable);
item.detail = detail;
item.insertText = insertion;
return item;
}
function createFunctionCompletion(name, parameters = []) {
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function);
item.detail = `WRN component function${
parameters.length > 0 ? ` (${parameters.join(", ")})` : ""
}`;
item.documentation = new vscode.MarkdownString(
`Call the browser function \`${name}\` declared in the current \`functions { ... }\` block.`,
);
const placeholders = parameters.map((parameter, index) => `\${${index + 1}:${parameter}}`);
item.insertText = new vscode.SnippetString(`${name}(${placeholders.join(", ")})`);
return item;
}
@@ -118,6 +257,7 @@ function getCurrentOpeningTag(document, position) {
);
const lastOpen = textBeforeCursor.lastIndexOf("<");
const lastClose = textBeforeCursor.lastIndexOf(">");
if (lastOpen > lastClose) {
@@ -129,6 +269,7 @@ function getCurrentOpeningTag(document, position) {
function extractRouteParams(document) {
const fileName = document.fileName.replace(/\\/g, "/");
const matches = [...fileName.matchAll(/\[([A-Za-z_$][\w$]*)\]/g)];
return matches.map((match) => match[1]);
@@ -136,13 +277,195 @@ function extractRouteParams(document) {
function extractStates(document) {
const source = document.getText();
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/gm)];
return matches.map((match) => match[1]);
return [...new Set(matches.map((match) => match[1]))];
}
function extractProps(document) {
const source = document.getText();
const propsBlockPattern = /\bprops\s*\{([\s\S]*?)\}/g;
const props = new Set();
let blockMatch;
while ((blockMatch = propsBlockPattern.exec(source)) !== null) {
const body = blockMatch[1];
for (const match of body.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*=/gm)) {
props.add(match[1]);
}
}
return [...props];
}
function extractFunctions(document) {
const source = document.getText();
const functions = [];
const seen = new Set();
const pattern = /(?:^|\s)(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/g;
let match;
while ((match = pattern.exec(source)) !== null) {
const name = match[1];
if (seen.has(name)) {
continue;
}
seen.add(name);
const parameters = match[2]
.split(",")
.map((parameter) => parameter.trim())
.filter(Boolean)
.map((parameter) => parameter.replace(/=.*$/, "").trim());
functions.push({
name,
parameters,
});
}
return functions;
}
function sourceBeforePosition(document, position) {
return document.getText(new vscode.Range(new vscode.Position(0, 0), position));
}
function isInsideNamedBlock(document, position, blockName) {
const source = sourceBeforePosition(document, position);
const tokenPattern = new RegExp(`\\b${blockName}\\s*\\{|\\{|\\}`, "g");
const stack = [];
let match;
while ((match = tokenPattern.exec(source)) !== null) {
const token = match[0];
if (token.startsWith(blockName)) {
stack.push(blockName);
continue;
}
if (token === "{") {
stack.push(null);
continue;
}
if (token === "}") {
stack.pop();
}
}
return stack.includes(blockName);
}
function isInsideLifecycle(document, position) {
return isInsideNamedBlock(document, position, "lifecycle");
}
function isInsideFunctions(document, position) {
return isInsideNamedBlock(document, position, "functions");
}
function isInsideWatch(document, position) {
const source = sourceBeforePosition(document, position);
const watchMatches = [...source.matchAll(/\bwatch\s+[A-Za-z_$][\w$]*\s*\{/g)];
if (watchMatches.length === 0) {
return false;
}
const latest = watchMatches[watchMatches.length - 1];
const tail = source.slice(latest.index);
let depth = 0;
for (const character of tail) {
if (character === "{") {
depth += 1;
} else if (character === "}") {
depth -= 1;
}
}
return depth > 0;
}
function isAfterWatchKeyword(document, position) {
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
return /^\s*watch\s+[A-Za-z0-9_$]*$/.test(linePrefix);
}
function addBlockCompletions(items, document, position) {
const insideLifecycle = isInsideLifecycle(document, position);
const insideFunctions = isInsideFunctions(document, position);
for (const completion of BLOCK_COMPLETIONS) {
if (insideLifecycle && !["mount", "update", "unmount"].includes(completion.label)) {
continue;
}
if (insideFunctions && completion.label !== "function") {
continue;
}
if (!insideLifecycle && ["mount", "update", "unmount"].includes(completion.label)) {
continue;
}
if (!insideFunctions && completion.label === "function") {
continue;
}
items.push(
createCompletion(
completion.label,
completion.detail,
completion.snippet,
completion.documentation,
),
);
}
}
function addStateCompletions(items, document, position) {
const states = extractStates(document);
const afterWatch = isAfterWatchKeyword(document, position);
for (const state of states) {
const item = createVariableCompletion(
state,
afterWatch ? "Declared WRN state available for watching" : "WRN reactive state",
);
if (afterWatch) {
item.sortText = `0-${state}`;
}
items.push(item);
}
}
function addFunctionCompletions(items, document) {
for (const fn of extractFunctions(document)) {
items.push(createFunctionCompletion(fn.name, fn.parameters));
}
}
function provideCompletionItems(document, position) {
const items = [];
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
const openingTag = getCurrentOpeningTag(document, position);
@@ -151,10 +474,10 @@ function provideCompletionItems(document, position) {
for (const [label, detail, snippet] of ATTRIBUTE_COMPLETIONS) {
items.push(createCompletion(label, detail, snippet));
}
} else if (isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
} else {
for (const completion of BLOCK_COMPLETIONS) {
items.push(createCompletion(completion.label, completion.detail, completion.snippet));
}
addBlockCompletions(items, document, position);
}
if (linePrefix.includes("ctx.") || linePrefix.includes("ctx.params.")) {
@@ -164,31 +487,27 @@ function provideCompletionItems(document, position) {
}
for (const param of extractRouteParams(document)) {
const item = new vscode.CompletionItem(param, vscode.CompletionItemKind.Variable);
items.push(createVariableCompletion(param, `Route parameter from [${param}].wrn`));
item.detail = `Route parameter from [${param}].wrn`;
item.insertText = param;
items.push(item);
const fullItem = new vscode.CompletionItem(
`ctx.params.${param}`,
vscode.CompletionItemKind.Variable,
items.push(
createVariableCompletion(`ctx.params.${param}`, `Route parameter from [${param}].wrn`),
);
fullItem.detail = `Route parameter from [${param}].wrn`;
fullItem.insertText = `ctx.params.${param}`;
items.push(fullItem);
}
for (const state of extractStates(document)) {
const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.Variable);
if (!isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
}
item.detail = "WRN state";
item.insertText = state;
for (const prop of extractProps(document)) {
items.push(createVariableCompletion(prop, "WRN component or layout prop"));
}
items.push(item);
addFunctionCompletions(items, document);
if (isInsideWatch(document, position)) {
for (const [label, detail] of WATCH_VALUE_COMPLETIONS) {
items.push(createVariableCompletion(label, detail));
}
}
return items;
@@ -196,7 +515,10 @@ function provideCompletionItems(document, position) {
function registerCompletionProvider(context) {
const provider = vscode.languages.registerCompletionItemProvider(
{ language: "wrn", scheme: "file" },
{
language: "wrn",
scheme: "file",
},
{
provideCompletionItems,
},
@@ -205,13 +527,17 @@ function registerCompletionProvider(context) {
".",
"<",
" ",
"(",
);
context.subscriptions.push(provider);
}
module.exports = {
extractFunctions,
extractProps,
extractRouteParams,
extractStates,
provideCompletionItems,
registerCompletionProvider,
};
+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_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]);
const VALID_MEMBERS = {
page: new Set([
"layout",
@@ -23,9 +25,9 @@ const VALID_MEMBERS = {
"realtime",
]),
component: new Set(["props", "state", "view", "style", "functions"]),
component: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
layout: new Set(["props", "state", "view", "style", "functions"]),
layout: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
};
function createDiagnostic(
@@ -59,7 +61,6 @@ function lineDiagnostic(
code,
) {
const line = document.lineAt(lineNumber);
const diagnostic = new vscode.Diagnostic(line.range, message, severity);
diagnostic.source = "WRNexus";
@@ -316,6 +317,15 @@ function validateHtmlTags(document, source) {
function getRootBodyRange(source, rootMatch) {
const openingBrace = rootMatch.index + rootMatch[0].lastIndexOf("{");
const closingBrace = findMatchingBrace(source, openingBrace);
return {
start: openingBrace + 1,
end: closingBrace === -1 ? source.length : closingBrace,
};
}
function findMatchingBrace(source, openingBrace) {
let depth = 0;
let quote = null;
let escaped = false;
@@ -346,6 +356,17 @@ function getRootBodyRange(source, rootMatch) {
continue;
}
if (source.startsWith("<!--", index)) {
const commentEnd = source.indexOf("-->", index + 4);
if (commentEnd === -1) {
return -1;
}
index = commentEnd + 2;
continue;
}
if (character === "{") {
depth += 1;
continue;
@@ -355,22 +376,44 @@ function getRootBodyRange(source, rootMatch) {
depth -= 1;
if (depth === 0) {
return {
start: openingBrace + 1,
end: index,
};
return index;
}
}
}
return -1;
}
function skipWhitespace(source, index, end) {
while (index < end && /\s/.test(source[index])) {
index += 1;
}
return index;
}
function readIdentifier(source, index, end) {
if (index >= end || !/[A-Za-z_$]/.test(source[index])) {
return null;
}
const start = index;
index += 1;
while (index < end && /[A-Za-z0-9_$-]/.test(source[index])) {
index += 1;
}
return {
start: openingBrace + 1,
end: source.length,
name: source.slice(start, index),
start,
end: index,
};
}
function findRootMembers(source, bodyStart, bodyEnd) {
const members = [];
let index = bodyStart;
let depth = 0;
let quote = null;
@@ -414,7 +457,9 @@ function findRootMembers(source, bodyStart, bodyEnd) {
if (source.startsWith("<!--", index)) {
const end = source.indexOf("-->", index + 4);
index = end === -1 ? bodyEnd : end + 3;
continue;
}
@@ -430,25 +475,35 @@ function findRootMembers(source, bodyStart, bodyEnd) {
continue;
}
if (depth === 0 && /[A-Za-z_]/.test(character)) {
const start = index;
index += 1;
if (depth === 0 && /[A-Za-z_$]/.test(character)) {
const identifier = readIdentifier(source, index, bodyEnd);
while (index < bodyEnd && /[A-Za-z0-9_-]/.test(source[index])) {
if (!identifier) {
index += 1;
continue;
}
const name = source.slice(start, index);
index = identifier.end;
members.push({
name,
start,
end: index,
name: identifier.name,
start: identifier.start,
end: identifier.end,
});
// Single-line declarations must skip the rest of the line.
if (name === "state" || name === "layout") {
if (identifier.name === "state" || identifier.name === "layout") {
skipLine();
continue;
}
if (identifier.name === "watch") {
index = skipWhitespace(source, index, bodyEnd);
const watchedState = readIdentifier(source, index, bodyEnd);
if (watchedState) {
index = watchedState.end;
}
}
continue;
@@ -460,6 +515,234 @@ function findRootMembers(source, bodyStart, bodyEnd) {
return members;
}
function findNamedBlocks(source, bodyStart, bodyEnd, blockName) {
const blocks = [];
let index = bodyStart;
while (index < bodyEnd) {
index = skipWhitespace(source, index, bodyEnd);
const identifier = readIdentifier(source, index, bodyEnd);
if (!identifier) {
index += 1;
continue;
}
index = identifier.end;
if (identifier.name !== blockName) {
const possibleBrace = skipWhitespace(source, index, bodyEnd);
if (source[possibleBrace] === "{") {
const end = findMatchingBrace(source, possibleBrace);
index = end === -1 ? bodyEnd : end + 1;
}
continue;
}
const openingBrace = skipWhitespace(source, index, bodyEnd);
if (source[openingBrace] !== "{") {
blocks.push({
name: blockName,
nameStart: identifier.start,
nameEnd: identifier.end,
openingBrace: -1,
closingBrace: -1,
});
continue;
}
const closingBrace = findMatchingBrace(source, openingBrace);
blocks.push({
name: blockName,
nameStart: identifier.start,
nameEnd: identifier.end,
openingBrace,
closingBrace,
});
index = closingBrace === -1 ? bodyEnd : closingBrace + 1;
}
return blocks;
}
function findStateDeclarations(source, bodyStart, bodyEnd) {
const states = new Map();
let index = bodyStart;
let depth = 0;
let quote = null;
let escaped = false;
while (index < bodyEnd) {
const character = source[index];
if (quote !== null) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === quote) {
quote = null;
}
index += 1;
continue;
}
if (character === '"' || character === "'") {
quote = character;
index += 1;
continue;
}
if (source.startsWith("<!--", index)) {
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) {
const diagnostics = [];
const allowed = VALID_MEMBERS[rootKind];
@@ -469,6 +752,7 @@ function validateRootMembers(document, source, rootKind, rootMatch) {
}
const bodyRange = getRootBodyRange(source, rootMatch);
const members = findRootMembers(source, bodyRange.start, bodyRange.end);
for (const member of members) {
@@ -491,8 +775,182 @@ function validateRootMembers(document, source, rootKind, rootMatch) {
return diagnostics;
}
function validateLifecycleBlocks(document, source, rootKind, rootMatch) {
if (rootKind !== "component" && rootKind !== "layout") {
return [];
}
const diagnostics = [];
const bodyRange = getRootBodyRange(source, rootMatch);
const blocks = findNamedBlocks(source, bodyRange.start, bodyRange.end, "lifecycle");
if (blocks.length > 1) {
for (const block of blocks.slice(1)) {
diagnostics.push(
createDiagnostic(
document,
block.nameStart,
block.nameEnd,
"Only one `lifecycle { ... }` block is allowed.",
vscode.DiagnosticSeverity.Error,
"wrn-duplicate-lifecycle",
),
);
}
}
for (const block of blocks) {
if (block.openingBrace === -1) {
diagnostics.push(
createDiagnostic(
document,
block.nameStart,
block.nameEnd,
"`lifecycle` must be followed by a block.",
vscode.DiagnosticSeverity.Error,
"wrn-invalid-lifecycle",
),
);
continue;
}
const lifecycleEnd = block.closingBrace === -1 ? bodyRange.end : block.closingBrace;
let index = block.openingBrace + 1;
const seenHooks = new Set();
while (index < lifecycleEnd) {
index = skipWhitespace(source, index, lifecycleEnd);
if (index >= lifecycleEnd) {
break;
}
const hook = readIdentifier(source, index, lifecycleEnd);
if (!hook) {
index += 1;
continue;
}
index = hook.end;
if (!VALID_LIFECYCLE_HOOKS.has(hook.name)) {
diagnostics.push(
createDiagnostic(
document,
hook.start,
hook.end,
`Unknown lifecycle hook \`${hook.name}\`. Use \`mount\`, \`update\`, or \`unmount\`.`,
vscode.DiagnosticSeverity.Error,
"wrn-invalid-lifecycle-hook",
),
);
} else if (seenHooks.has(hook.name)) {
diagnostics.push(
createDiagnostic(
document,
hook.start,
hook.end,
`Duplicate lifecycle hook \`${hook.name}\`.`,
vscode.DiagnosticSeverity.Error,
"wrn-duplicate-lifecycle-hook",
),
);
} else {
seenHooks.add(hook.name);
}
const openingBrace = skipWhitespace(source, index, lifecycleEnd);
if (source[openingBrace] !== "{") {
diagnostics.push(
createDiagnostic(
document,
hook.start,
hook.end,
`Lifecycle hook \`${hook.name}\` must be followed by a block.`,
vscode.DiagnosticSeverity.Error,
"wrn-invalid-lifecycle-hook",
),
);
index = hook.end;
continue;
}
const closingBrace = findMatchingBrace(source, openingBrace);
index = closingBrace === -1 ? lifecycleEnd : closingBrace + 1;
}
}
return diagnostics;
}
function validateWatchBlocks(document, source, rootKind, rootMatch) {
if (rootKind !== "component" && rootKind !== "layout") {
return [];
}
const diagnostics = [];
const bodyRange = getRootBodyRange(source, rootMatch);
const states = findStateDeclarations(source, bodyRange.start, bodyRange.end);
const watches = findWatchDeclarations(source, bodyRange.start, bodyRange.end);
for (const watch of watches) {
if (!watch.state) {
diagnostics.push(
createDiagnostic(
document,
watch.watchStart,
watch.watchEnd,
"`watch` must be followed by a declared state name.",
vscode.DiagnosticSeverity.Error,
"wrn-invalid-watch",
),
);
continue;
}
if (!states.has(watch.state.name)) {
diagnostics.push(
createDiagnostic(
document,
watch.state.start,
watch.state.end,
`Cannot watch undeclared state \`${watch.state.name}\`.`,
vscode.DiagnosticSeverity.Error,
"wrn-unknown-watch-state",
),
);
}
if (watch.openingBrace === -1) {
diagnostics.push(
createDiagnostic(
document,
watch.state.start,
watch.state.end,
`Watcher for \`${watch.state.name}\` must be followed by a block.`,
vscode.DiagnosticSeverity.Error,
"wrn-invalid-watch",
),
);
}
}
return diagnostics;
}
function validateRequiredView(document, source, rootKind, rootMatch) {
const bodyRange = getRootBodyRange(source, rootMatch);
const body = source.slice(bodyRange.start, bodyRange.end);
if (/\bview\s*\{/.test(body)) {
@@ -550,6 +1008,7 @@ function validateDocument(document) {
if (declaration.diagnostic) {
diagnostics.push(declaration.diagnostic);
diagnostics.push(...validateBalancedCharacters(document, source));
return diagnostics;
@@ -576,6 +1035,12 @@ function validateDocument(document) {
diagnostics.push(...validateRootMembers(document, source, declaration.kind, declaration.match));
diagnostics.push(
...validateLifecycleBlocks(document, source, declaration.kind, declaration.match),
);
diagnostics.push(...validateWatchBlocks(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateRequiredView(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateLayoutUsage(document, source, declaration.kind));
@@ -593,19 +1058,20 @@ function registerDiagnostics(context) {
return;
}
const previousTimer = timers.get(document.uri.toString());
const key = document.uri.toString();
const previousTimer = timers.get(key);
if (previousTimer) {
clearTimeout(previousTimer);
}
const timer = setTimeout(() => {
timers.delete(document.uri.toString());
timers.delete(key);
collection.set(document.uri, validateDocument(document));
}, 150);
timers.set(document.uri.toString(), timer);
timers.set(key, timer);
};
for (const document of vscode.workspace.textDocuments) {
@@ -652,5 +1118,7 @@ module.exports = {
validateBalancedCharacters,
validateDocument,
validateHtmlTags,
validateLifecycleBlocks,
validateRootMembers,
validateWatchBlocks,
};
+459 -216
View File
@@ -2,257 +2,500 @@
"use strict";
const vscode = require("vscode");
const { formatWrn } = require("./formatter");
const { registerCompletionProvider } = require("./completion");
const { registerDefinitionProvider } = require("./definition");
const { registerDiagnostics } = require("./diagnostics");
// The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded
// defensively so the rest of the extension (highlighting, snippets, completion)
// still works even if the bundle is missing.
/**
* The WRN compiler is bundled to CommonJS using:
*
* bun run build:compiler
*
* Loading is optional so highlighting, formatting, snippets,
* autocomplete and navigation continue working when the compiler
* bundle is temporarily unavailable.
*/
let compiler = null;
try {
compiler = require("./compiler.cjs");
} catch (err) {
console.warn("[wrnexus] compiler bundle not found; diagnostics disabled.", err && err.message);
} catch (error) {
console.warn(
"[wrnexus] compiler bundle not found; compiler diagnostics disabled.",
error instanceof Error ? error.message : String(error),
);
}
/** Top-level block keywords offered at file scope. */
const BLOCK_KEYWORDS = [
["page", "page ${1:Name} {\n\t$0\n}", "A route page"],
["component", "component ${1:Name} {\n\t$0\n}", "A reusable, prop-driven component"],
["layout", 'layout = "${1:public}"', "Select a layout for this page"],
["state", "state ${1:count} = ${2:0}", "Reactive state seeded into the scope"],
["props", 'props {\n\t${1:label} = ${2:"Label"}\n}', "Component props with typed defaults"],
["view", "view {\n\t$0\n}", "Server-rendered HTML"],
["seo", 'seo {\n\ttitle = "$1"\n\tdescription = "$2"\n}', "Per-page SEO metadata"],
["api", "api ${1|GET,POST,PUT,PATCH,DELETE|} ${2:/path} {\n\t$0\n}", "Colocated API route"],
["ssr", "ssr {\n\tapi ${1:load} GET ${2:/api/data} {\n\t\t$0\n\t}\n}", "Server-side data block"],
[
"client",
"client {\n\tapi ${1:load} GET ${2:/api/data} {\n\t\t$0\n\t}\n}",
"Client-side data block",
],
[
"realtime",
"realtime ${1:chat} {\n\ton ${2:message}(${3:data}) {\n\t\t$0\n\t}\n}",
"Realtime channel",
],
["functions", "functions {\n\t$0\n}", "Shared helper functions"],
["style", "style {\n\t$0\n}", "Scoped CSS"],
];
/** `data-*` attributes understood by the reactive runtime. */
const DATA_ATTRS = [
["data-component", 'data-component="$1"', "Mount a component by name"],
["data-for", 'data-for="${1:item} in ${2:items}"', "Repeat this element per item"],
["data-show", 'data-show="${1:condition}"', "Toggle visibility reactively"],
["data-text", 'data-text="${1:expr}"', "Bind text content to an expression"],
["data-scope", 'data-scope="${1:key}: ${2:value}"', "Declare a local reactive scope"],
["data-slot", 'data-slot="${1:name}"', "Fill a named <slot> on a component"],
];
/** Client event bindings. */
const EVENTS = [
"click",
"input",
"change",
"submit",
"keydown",
"keyup",
"focus",
"blur",
"mouseenter",
"mouseleave",
];
const WRN_LANGUAGE_ID = "wrn";
const COMPILER_DIAGNOSTIC_COLLECTION = "wrnexus-compiler";
/**
* Register the WRN document formatter.
*
* @param {vscode.ExtensionContext} context
*/
function activate(context) {
registerDiagnostics(context);
registerCompletionProvider(context);
registerDefinitionProvider(context);
const diagnostics = vscode.languages.createDiagnosticCollection("wrn");
context.subscriptions.push(diagnostics);
const timers = new Map();
const runDiagnostics = (doc) => {
if (doc.languageId !== "wrn") return;
if (!vscode.workspace.getConfiguration("wrnexus").get("diagnostics.enable", true)) {
diagnostics.delete(doc.uri);
return;
}
if (!compiler || typeof compiler.compileWireFile !== "function") return;
const text = doc.getText();
/** @type {vscode.Diagnostic[]} */
const found = [];
try {
compiler.compileWireFile(text);
} catch (err) {
found.push(toDiagnostic(doc, err));
}
diagnostics.set(doc.uri, found);
};
const schedule = (doc) => {
const key = doc.uri.toString();
clearTimeout(timers.get(key));
timers.set(
key,
setTimeout(() => {
timers.delete(key);
runDiagnostics(doc);
}, 250),
);
};
// Lint on open, edit and save; clear on close.
vscode.workspace.textDocuments.forEach(runDiagnostics);
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(runDiagnostics),
vscode.workspace.onDidChangeTextDocument((e) => schedule(e.document)),
vscode.workspace.onDidSaveTextDocument(runDiagnostics),
vscode.workspace.onDidCloseTextDocument((doc) => diagnostics.delete(doc.uri)),
);
// Completions.
context.subscriptions.push(
vscode.languages.registerDocumentFormattingEditProvider("wrn", {
function registerFormatter(context) {
const provider = vscode.languages.registerDocumentFormattingEditProvider(
{
language: WRN_LANGUAGE_ID,
scheme: "file",
},
{
provideDocumentFormattingEdits(document, options) {
const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri);
const enabled = configuration.get("format.enable", true);
if (!enabled) {
return [];
}
const source = document.getText();
const printWidth = configuration.get("formatting.printWidth", 100);
const formatted = formatWrn(source, {
tabSize: options.tabSize,
insertSpaces: options.insertSpaces,
printWidth: 100,
printWidth,
});
if (formatted === source) {
return [];
}
const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length));
return [vscode.TextEdit.replace(range, formatted)];
},
},
);
context.subscriptions.push(provider);
}
/**
* Convert a compiler ParseError into a VS Code diagnostic.
*
* Compiler errors normally contain:
*
* at offset 123
*
* When no offset exists, the diagnostic is attached to the last
* line of the document.
*
* @param {vscode.TextDocument} document
* @param {unknown} error
* @returns {vscode.Diagnostic}
*/
function toCompilerDiagnostic(document, error) {
const message =
error && typeof error === "object" && "message" in error
? String(error.message)
: "Failed to compile .wrn file.";
const offsetMatch = /offset\s+(\d+)/i.exec(message);
let range;
if (offsetMatch) {
const requestedOffset = Number(offsetMatch[1]);
const safeOffset = Math.max(0, Math.min(requestedOffset, document.getText().length));
const start = document.positionAt(safeOffset);
const wordRange = document.getWordRangeAtPosition(start);
range =
wordRange ||
new vscode.Range(
start,
document.positionAt(Math.min(safeOffset + 1, document.getText().length)),
);
} else {
const lastLine = document.lineAt(Math.max(0, document.lineCount - 1));
range = lastLine.range;
}
const diagnostic = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error);
diagnostic.source = "WRNexus Compiler";
diagnostic.code = "wrn-compiler-error";
return diagnostic;
}
/**
* Register diagnostics produced by the actual WRN compiler.
*
* The lightweight diagnostics in diagnostics.js provide immediate
* editor feedback. Compiler diagnostics verify that the document
* can also be parsed and generated by the real framework compiler.
*
* @param {vscode.ExtensionContext} context
*/
function registerCompilerDiagnostics(context) {
const collection = vscode.languages.createDiagnosticCollection(COMPILER_DIAGNOSTIC_COLLECTION);
const timers = new Map();
/**
* @param {vscode.TextDocument} document
*/
const run = (document) => {
if (document.languageId !== WRN_LANGUAGE_ID) {
return;
}
const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri);
const enabled = configuration.get("diagnostics.enable", true);
if (!enabled) {
collection.delete(document.uri);
return;
}
if (!compiler || typeof compiler.compileWireFile !== "function") {
collection.delete(document.uri);
return;
}
const source = document.getText();
if (!source.trim()) {
collection.delete(document.uri);
return;
}
try {
compiler.compileWireFile(source);
collection.set(document.uri, []);
} catch (error) {
collection.set(document.uri, [toCompilerDiagnostic(document, error)]);
}
};
/**
* @param {vscode.TextDocument} document
*/
const schedule = (document) => {
if (document.languageId !== WRN_LANGUAGE_ID) {
return;
}
const key = document.uri.toString();
const existing = timers.get(key);
if (existing) {
clearTimeout(existing);
}
const timer = setTimeout(() => {
timers.delete(key);
run(document);
}, 250);
timers.set(key, timer);
};
for (const document of vscode.workspace.textDocuments) {
run(document);
}
context.subscriptions.push(
collection,
vscode.workspace.onDidOpenTextDocument(run),
vscode.workspace.onDidChangeTextDocument((event) => {
schedule(event.document);
}),
vscode.languages.registerCompletionItemProvider(
"wrn",
{ provideCompletionItems: provideCompletions },
"-",
"@",
"{",
":",
),
vscode.workspace.onDidSaveTextDocument(run),
vscode.workspace.onDidCloseTextDocument((document) => {
const key = document.uri.toString();
const timer = timers.get(key);
if (timer) {
clearTimeout(timer);
timers.delete(key);
}
collection.delete(document.uri);
}),
vscode.workspace.onDidChangeConfiguration((event) => {
if (!event.affectsConfiguration("wrnexus.diagnostics.enable")) {
return;
}
for (const document of vscode.workspace.textDocuments) {
run(document);
}
}),
{
dispose() {
for (const timer of timers.values()) {
clearTimeout(timer);
}
timers.clear();
},
},
);
}
/**
* Map a thrown ParseError to a VS Code diagnostic. The compiler encodes the
* failure position as `... at offset <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}
* Activate the WRNexus VS Code extension.
*
* @param {vscode.ExtensionContext} context
*/
function toDiagnostic(doc, err) {
const message = err && err.message ? String(err.message) : "Failed to parse .wrn file";
const match = /offset\s+(\d+)/.exec(message);
let range;
if (match) {
const offset = Number(match[1]);
const start = doc.positionAt(offset);
const wordRange = doc.getWordRangeAtPosition(start);
range = wordRange || new vscode.Range(start, doc.positionAt(offset + 1));
} else {
const last = doc.lineAt(Math.max(0, doc.lineCount - 1));
range = new vscode.Range(last.range.start, last.range.end);
}
const diag = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error);
diag.source = "wrn";
return diag;
}
function activate(context) {
registerDiagnostics(context);
registerCompilerDiagnostics(context);
/**
* @param {vscode.TextDocument} document
* @param {vscode.Position} position
* @returns {vscode.CompletionItem[]}
*/
function provideCompletions(document, position) {
const line = document.lineAt(position).text;
const before = line.slice(0, position.character);
const full = document.getText();
const upto = document.offsetAt(position);
const inView = isInsideBlock(full, upto, "view");
registerCompletionProvider(context);
registerDefinitionProvider(context);
registerFormatter(context);
// `{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;
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
{
language: "wrn",
},
wrnSemanticTokensProvider,
semanticTokenLegend,
),
);
}
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,
toCompilerDiagnostic,
};
+163 -45
View File
@@ -19,24 +19,35 @@ const VOID_ELEMENTS = new Set([
function findOpeningTagEnd(value) {
let quote = null;
let escaped = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const character = value[index];
if (quote !== null) {
if (char === quote && value[index - 1] !== "\\") {
if (escaped) {
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (char === ">") {
if (character === ">") {
return index;
}
}
@@ -46,7 +57,8 @@ function findOpeningTagEnd(value) {
function parseAttributes(value) {
const attributes = [];
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s"'=<>`]+))?/g;
let match;
@@ -65,17 +77,21 @@ function parseOpeningTag(value) {
}
const openingPart = value.slice(0, endIndex + 1);
const remainder = value.slice(endIndex + 1).trim();
const match = /^<([A-Za-z][\w:-]*)([\s\S]*?)(\/?)>$/.exec(openingPart);
const match = /^<([A-Za-z][\w$:.-]*)([\s\S]*?)(\/?)>$/.exec(openingPart);
if (!match) {
return null;
}
const tagName = match[1];
const attributes = parseAttributes(match[2].trim());
const selfClosing = match[3] === "/";
const inlineClosing = remainder === `</${tagName}>`;
return {
@@ -98,7 +114,9 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) {
}
const baseIndent = unit.repeat(depth);
const attributeIndent = unit.repeat(depth + 1);
const normalizedSingleLine = value.replace(/\s+/g, " ").trim();
const shouldBreak =
@@ -159,42 +177,144 @@ function isMultilineOpeningTagStart(value) {
}
function isClosingTag(value) {
return /^<\/[A-Za-z][\w:-]*\s*>/.test(value);
return /^<\/[A-Za-z][\w$:.-]*\s*>/.test(value);
}
function isInlineElement(value) {
return /^<([A-Za-z][\w:-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value);
return /^<([A-Za-z][\w$:.-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value);
}
function isWrnBlockClosing(value) {
return value === "}" || value.startsWith("} ");
}
function countLeadingClosingBraces(value) {
let index = 0;
let count = 0;
function isWrnBlockOpening(value) {
if (!value.endsWith("{")) {
return false;
while (index < value.length) {
while (index < value.length && /\s/.test(value[index])) {
index += 1;
}
if (value[index] !== "}") {
break;
}
count += 1;
index += 1;
}
return !value.startsWith("{");
return count;
}
/**
* Count braces outside strings and HTML comments.
*
* This supports WRN blocks, function bodies, lifecycle hooks,
* watcher bodies and multiline JavaScript object literals.
*/
function countStructuralBraces(value) {
let openings = 0;
let closings = 0;
let quote = null;
let escaped = false;
let htmlComment = false;
for (let index = 0; index < value.length; index += 1) {
if (!quote && !htmlComment && value.startsWith("<!--", index)) {
htmlComment = 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 = {}) {
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize || 4);
const printWidth = options.printWidth || 100;
const inputLines = source.replace(/\r\n/g, "\n").split("\n");
const output = [];
let wrnDepth = 0;
let codeDepth = 0;
let htmlDepth = 0;
let index = 0;
let previousWasBlank = false;
while (index < inputLines.length) {
const originalLine = inputLines[index];
const trimmed = originalLine.trim();
if (trimmed === "") {
let value = originalLine.trim();
if (value === "") {
if (!previousWasBlank && output.length > 0) {
output.push("");
}
@@ -206,43 +326,31 @@ function formatWrn(source, options = {}) {
previousWasBlank = false;
let value = trimmed;
if (isMultilineOpeningTagStart(value)) {
const collected = [value];
let cursor = index + 1;
const collected = collectOpeningTag(inputLines, index);
while (cursor < inputLines.length) {
const nextPart = inputLines[cursor].trim();
collected.push(nextPart);
const joined = collected.join(" ");
if (findOpeningTagEnd(joined) !== -1) {
break;
}
cursor += 1;
}
value = collected.join(" ");
index = cursor;
value = collected.value;
index = collected.endIndex;
}
if (isWrnBlockClosing(value)) {
wrnDepth = Math.max(0, wrnDepth - 1);
}
const leadingClosingBraces = countLeadingClosingBraces(value);
const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces);
let lineHtmlDepth = htmlDepth;
if (isClosingTag(value)) {
htmlDepth = Math.max(0, htmlDepth - 1);
lineHtmlDepth = Math.max(0, htmlDepth - 1);
}
const depth = wrnDepth + htmlDepth;
const depth = lineCodeDepth + lineHtmlDepth;
if (
value.startsWith("<") &&
!value.startsWith("</") &&
!value.startsWith("<!--") &&
!value.startsWith("<!") &&
!value.startsWith("<?") &&
!isInlineElement(value)
) {
const formattedTag = formatOpeningTag(value, unit, depth, printWidth);
@@ -256,10 +364,17 @@ function formatWrn(source, options = {}) {
output.push(`${unit.repeat(depth)}${value}`);
}
if (isWrnBlockOpening(value)) {
wrnDepth += 1;
if (isClosingTag(value)) {
htmlDepth = lineHtmlDepth;
}
const braces = countStructuralBraces(value);
codeDepth = Math.max(
0,
lineCodeDepth + braces.openings - Math.max(0, braces.closings - leadingClosingBraces),
);
index += 1;
}
@@ -271,6 +386,9 @@ function formatWrn(source, options = {}) {
}
module.exports = {
countStructuralBraces,
formatOpeningTag,
formatWrn,
parseAttributes,
parseOpeningTag,
};
+570 -159
View File
@@ -1,266 +1,651 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Wire",
"name": "WRNexus",
"scopeName": "source.wrn",
"patterns": [{ "include": "#comments" }, { "include": "#declaration" }, { "include": "#blocks" }],
"patterns": [
{
"include": "#comments"
},
{
"include": "#declaration"
},
{
"include": "#blocks"
}
],
"repository": {
"comments": {
"patterns": [
{
"name": "comment.line.double-slash.wrn",
"match": "//.*$"
},
{
"name": "comment.block.html.wrn",
"begin": "<!--",
"end": "-->"
}
]
},
"declaration": {
"match": "\\b(page|component|layout)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)?",
"match": "\\b(page|component|layout)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)?",
"captures": {
"1": { "name": "storage.type.wrn keyword.control.wrn" },
"2": { "name": "entity.name.type.wrn" }
"1": {
"name": "storage.type.wrn keyword.control.wrn"
},
"2": {
"name": "entity.name.type.wrn"
}
}
},
"blocks": {
"patterns": [
{ "include": "#view-block" },
{ "include": "#style-block" },
{ "include": "#seo-block" },
{ "include": "#props-block" },
{ "include": "#realtime-block" },
{ "include": "#mode-block" },
{ "include": "#api-block" },
{ "include": "#functions-block" },
{ "include": "#state-decl" },
{ "include": "#layout-decl" },
{ "include": "#strings" }
{
"include": "#view-block"
},
{
"include": "#style-block"
},
{
"include": "#seo-block"
},
{
"include": "#props-block"
},
{
"include": "#lifecycle-block"
},
{
"include": "#watch-block"
},
{
"include": "#realtime-block"
},
{
"include": "#mode-block"
},
{
"include": "#api-block"
},
{
"include": "#functions-block"
},
{
"include": "#state-decl"
},
{
"include": "#layout-decl"
},
{
"include": "#strings"
}
]
},
"layout-decl": {
"match": "\\b(layout)\\b\\s*(=)",
"captures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "keyword.operator.assignment.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
}
},
"state-decl": {
"begin": "\\b(state)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(=)",
"begin": "\\b(state)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "variable.other.wrn" },
"3": { "name": "keyword.operator.assignment.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "variable.other.wrn"
},
"3": {
"name": "keyword.operator.assignment.wrn"
}
},
"end": "$",
"contentName": "meta.embedded.expr.wrn",
"patterns": [{ "include": "source.ts" }]
"patterns": [
{
"include": "source.ts"
}
]
},
"props-block": {
"begin": "\\b(props)\\b\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "punctuation.definition.block.begin.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{ "include": "#comments" },
{
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(=)",
"include": "#comments"
},
{
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
"captures": {
"1": { "name": "variable.parameter.wrn" },
"2": { "name": "keyword.operator.assignment.wrn" }
"1": {
"name": "variable.parameter.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
}
},
{ "include": "#strings" }
{
"include": "#strings"
}
]
},
"seo-block": {
"begin": "\\b(seo)\\b\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "punctuation.definition.block.begin.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{ "include": "#comments" },
{
"match": "\\b([A-Za-z_][A-Za-z0-9_-]*)\\s*(=)",
"include": "#comments"
},
{
"match": "\\b([A-Za-z_$][A-Za-z0-9_$-]*)\\s*(=)",
"captures": {
"1": { "name": "support.type.property-name.wrn" },
"2": { "name": "keyword.operator.assignment.wrn" }
"1": {
"name": "support.type.property-name.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
}
},
{ "include": "#strings" }
{
"include": "#strings"
}
]
},
"style-block": {
"begin": "\\b(style)\\b\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "punctuation.definition.block.begin.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.css",
"patterns": [{ "include": "#css-braces" }, { "include": "source.css" }]
"patterns": [
{
"include": "#css-braces"
},
{
"include": "source.css"
}
]
},
"css-braces": {
"begin": "\\{",
"end": "\\}",
"contentName": "meta.embedded.block.css",
"patterns": [{ "include": "#css-braces" }, { "include": "source.css" }]
"patterns": [
{
"include": "#css-braces"
},
{
"include": "source.css"
}
]
},
"functions-block": {
"begin": "\\b(functions)\\b\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "punctuation.definition.block.begin.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.ts",
"patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }]
"patterns": [
{
"match": "\\b(async\\s+)?(function)\\s+([A-Za-z_$][A-Za-z0-9_$]*)",
"captures": {
"1": {
"name": "storage.modifier.async.ts"
},
"2": {
"name": "storage.type.function.ts"
},
"3": {
"name": "entity.name.function.wrn"
}
}
},
{
"include": "#ts-braces"
},
{
"include": "source.ts"
}
]
},
"lifecycle-block": {
"begin": "\\b(lifecycle)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{
"include": "#comments"
},
{
"begin": "\\b(mount|update|unmount)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "entity.name.function.lifecycle.wrn keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.ts",
"patterns": [
{
"include": "#ts-braces"
},
{
"include": "source.ts"
}
]
}
]
},
"watch-block": {
"begin": "\\b(watch)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "variable.other.readwrite.wrn"
},
"3": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.ts",
"patterns": [
{
"match": "\\b(value|previous)\\b",
"name": "variable.language.wrn"
},
{
"include": "#ts-braces"
},
{
"include": "source.ts"
}
]
},
"ts-braces": {
"begin": "\\{",
"end": "\\}",
"contentName": "meta.embedded.block.ts",
"patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }]
},
"api-block": {
"begin": "\\b(api)\\b\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "constant.language.http-method.wrn" },
"3": { "name": "string.unquoted.route.wrn" },
"4": { "name": "punctuation.definition.block.begin.wrn" }
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
},
"contentName": "meta.embedded.block.ts",
"patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }]
},
"mode-block": {
"begin": "\\b(ssr|client)\\b\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "punctuation.definition.block.begin.wrn" }
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
},
"patterns": [
{ "include": "#comments" },
{
"begin": "\\b(api)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "entity.name.function.wrn" },
"3": { "name": "constant.language.http-method.wrn" },
"4": { "name": "string.unquoted.route.wrn" },
"5": { "name": "punctuation.definition.block.begin.wrn" }
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
},
"contentName": "meta.embedded.block.ts",
"patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }]
"include": "#ts-braces"
},
{ "include": "#functions-block" }
]
},
"realtime-block": {
"begin": "\\b(realtime)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "entity.name.function.wrn" },
"3": { "name": "punctuation.definition.block.begin.wrn" }
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
},
"patterns": [
{ "include": "#comments" },
{
"begin": "\\b(on)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\([^)]*\\))\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "entity.name.function.event.wrn" },
"3": { "name": "variable.parameter.wrn" },
"4": { "name": "punctuation.definition.block.begin.wrn" }
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
},
"contentName": "meta.embedded.block.ts",
"patterns": [{ "include": "#ts-braces" }, { "include": "source.ts" }]
"include": "source.ts"
}
]
},
"view-block": {
"begin": "\\b(view)\\b\\s*(\\{)",
"api-block": {
"begin": "\\b(api)\\b\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
"beginCaptures": {
"1": { "name": "keyword.control.wrn" },
"2": { "name": "punctuation.definition.block.begin.wrn" }
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "constant.language.http-method.wrn"
},
"3": {
"name": "string.unquoted.route.wrn"
},
"4": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.wrn" }
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.ts",
"patterns": [
{
"include": "#ts-braces"
},
{
"include": "source.ts"
}
]
},
"mode-block": {
"begin": "\\b(ssr|client)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{
"include": "#comments"
},
{
"begin": "\\b(api)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "entity.name.function.wrn"
},
"3": {
"name": "constant.language.http-method.wrn"
},
"4": {
"name": "string.unquoted.route.wrn"
},
"5": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.ts",
"patterns": [
{
"include": "#ts-braces"
},
{
"include": "source.ts"
}
]
},
{
"include": "#functions-block"
}
]
},
"realtime-block": {
"begin": "\\b(realtime)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "entity.name.function.wrn"
},
"3": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{
"include": "#comments"
},
{
"begin": "\\b(on)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\([^)]*\\))\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "entity.name.function.event.wrn"
},
"3": {
"name": "variable.parameter.wrn"
},
"4": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.ts",
"patterns": [
{
"include": "#ts-braces"
},
{
"include": "source.ts"
}
]
}
]
},
"view-block": {
"begin": "\\b(view)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"contentName": "meta.embedded.block.html",
"patterns": [
{ "include": "#interpolation" },
{ "include": "#wire-event" },
{ "include": "text.html.basic" }
{
"include": "#interpolation"
},
{
"include": "#wire-event"
},
{
"include": "#conditional-class"
},
{
"include": "#conditional-class-single"
},
{
"include": "text.html.basic"
}
]
},
"wire-event": {
"match": "(@)([A-Za-z][A-Za-z0-9_-]*)(=)",
"match": "(@)(?:(window|document)(:))?([A-Za-z][A-Za-z0-9_-]*)(\\s*=)",
"captures": {
"1": { "name": "punctuation.definition.keyword.wrn" },
"2": { "name": "entity.other.attribute-name.event.wrn" },
"3": { "name": "keyword.operator.assignment.wrn" }
"1": {
"name": "punctuation.definition.keyword.wrn"
},
"2": {
"name": "support.type.object.wrn"
},
"3": {
"name": "punctuation.separator.key-value.wrn"
},
"4": {
"name": "entity.other.attribute-name.event.wrn"
},
"5": {
"name": "keyword.operator.assignment.wrn"
}
}
},
"conditional-class": {
"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": {
"patterns": [
{
"name": "meta.interpolation.i18n.wrn",
"match": "(\\{)(t:)([^}]*)(\\})",
"captures": {
"1": { "name": "punctuation.section.interpolation.begin.wrn" },
"2": { "name": "keyword.control.i18n.wrn" },
"3": { "name": "string.other.i18n-key.wrn" },
"4": { "name": "punctuation.section.interpolation.end.wrn" }
"1": {
"name": "punctuation.section.interpolation.begin.wrn"
},
"2": {
"name": "keyword.control.i18n.wrn"
},
"3": {
"name": "string.other.i18n-key.wrn"
},
"4": {
"name": "punctuation.section.interpolation.end.wrn"
}
}
},
{
@@ -268,37 +653,63 @@
"begin": "\\{",
"end": "\\}",
"beginCaptures": {
"0": { "name": "punctuation.section.interpolation.begin.wrn" }
"0": {
"name": "punctuation.section.interpolation.begin.wrn"
}
},
"endCaptures": {
"0": { "name": "punctuation.section.interpolation.end.wrn" }
"0": {
"name": "punctuation.section.interpolation.end.wrn"
}
},
"contentName": "meta.embedded.expr.wrn",
"patterns": [{ "include": "#interp-braces" }, { "include": "source.ts" }]
"patterns": [
{
"include": "#interp-braces"
},
{
"include": "source.ts"
}
]
}
]
},
"interp-braces": {
"begin": "\\{",
"end": "\\}",
"contentName": "meta.embedded.expr.wrn",
"patterns": [{ "include": "#interp-braces" }, { "include": "source.ts" }]
"patterns": [
{
"include": "#interp-braces"
},
{
"include": "source.ts"
}
]
},
"strings": {
"patterns": [
{
"name": "string.quoted.double.wrn",
"begin": "\"",
"end": "\"",
"patterns": [{ "name": "constant.character.escape.wrn", "match": "\\\\." }]
"patterns": [
{
"name": "constant.character.escape.wrn",
"match": "\\\\."
}
]
},
{
"name": "string.quoted.single.wrn",
"begin": "'",
"end": "'",
"patterns": [{ "name": "constant.character.escape.wrn", "match": "\\\\." }]
"patterns": [
{
"name": "constant.character.escape.wrn",
"match": "\\\\."
}
]
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+63
View File
@@ -357,6 +357,69 @@ const MIGRATIONS: Migration[] = [
// No generated application files require automatic migration.
},
},
{
version: "0.2.32",
id: "fix-runtime-bug",
description:
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
apply() {
// No generated application files require automatic migration.
},
},
{
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.
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+263 -28
View File
@@ -15,6 +15,8 @@
* 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";
interface RenderBinding {
@@ -428,17 +430,16 @@ function renderPageComponentInvocation(
loops: string[],
reactive: PageReactive | null,
): 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
.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(
@@ -460,9 +461,19 @@ function renderNestedComponentInvocation(
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;
}
@@ -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);
}
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> {
if (typeof ctx.__wrnexusCallApi === "function") {
return await ctx.__wrnexusCallApi(path, method);
@@ -804,6 +837,8 @@ interface CompCtx {
/** `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). */
loopVars?: Set<string>;
/** Local identifiers introduced by server-rendered `{#each}` blocks. */
serverLocals?: Set<string>;
}
interface ComponentBehavior {
@@ -925,7 +960,9 @@ function behaviorAttribute(behavior: ComponentBehavior | null): string {
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;
@@ -938,9 +975,19 @@ function exprRefsState(expr: string, stateNames: Set<string>): boolean {
}
function viewHasEvents(nodes: ViewNode[]): boolean {
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);
});
}
/**
@@ -1002,14 +1049,65 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
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. */
function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
if (node.type === "text") return compileText(node.value, ctx);
if (node.type === "each" || node.type === "if") {
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).",
);
if (node.type === "each") {
return renderComponentEachNode(node, ctx);
}
if (node.type === "if") {
return renderComponentIfNode(node, ctx);
}
if (isComponentTag(node.tag)) {
return renderNestedComponentInvocation(node, ctx);
}
@@ -1063,12 +1161,24 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
})
.join("");
const staticClassValue = staticClasses.join(" ");
const classHasReactiveExpression =
staticClassValue.includes("{") && exprRefsState(staticClassValue, ctx.stateNames);
const classAttribute =
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
.filter(({ expression }) => {
return !ctx.serverLocals || !exprRefsState(expression, ctx.serverLocals);
})
.map(({ className, expression }, index) => {
const marker = attrEscape(JSON.stringify([className, expression]));
@@ -1081,7 +1191,8 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
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())) {
return `<${node.tag}${allAttrs}>`;
@@ -1176,18 +1287,103 @@ function generateComponent(ast: PageAst): string {
}
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v);
if (typeof def === "boolean") return v === true || v === "" || v === "true";
if (v === undefined || v === null) {
return def;
}
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);
}
function __wireHtml(v: any): string {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
function __wireAttr(v: any): string {
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
return String(v == null ? "" : v).replace(
/[&<>]/g,
(c) =>
c === "&"
? "&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) {
@@ -1218,12 +1414,51 @@ function __wireAttr(v: any): string {
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 {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;",
);
}
function __wireRaw(v: any): string {
return String(v == null ? "" : v);
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 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 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 fail = (msg: string): never => {
@@ -526,18 +519,37 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
};
const readAttributeName = (): string => {
const first = src[i];
if (i >= src.length || (!isNameStart(first!) && first !== ":" && first !== "$")) {
if (i >= src.length) {
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++;
}
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i);
};
+353 -6
View File
@@ -16,6 +16,39 @@ async function compileAndImport(src: string): Promise<Record<string, unknown>> {
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", () => {
const ast = parse(`page Home {\n layout = "public"\n view { <h1>Hi</h1> }\n}`);
expect(ast.kind).toBe("page");
@@ -210,7 +243,9 @@ component ParentCard {
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", () => {
@@ -382,11 +417,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", () => {
const output = generate(
@@ -405,9 +442,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", () => {
const output = generate(
@@ -436,3 +477,309 @@ component BehaviorOnly {
expect(output).toContain("data-wrn-behavior");
});
test("component behavior metadata safely encodes multiline code", () => {
const output = generate(
parse(`
component BackToTop {
state visible = false
functions {
function updateVisibility() {
visible = window.scrollY > 500
}
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: "smooth"
})
}
}
lifecycle {
mount {
updateVisibility()
}
}
watch visible {
console.log(
value,
previous
)
}
view {
<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([");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+151 -7
View File
@@ -43,14 +43,46 @@ export const REACTIVE_RUNTIME = String.raw`
});
}
function decodeBehaviorPayload(value) {
var binary = window.atob(value);
var bytes = new Uint8Array(binary.length);
for (var index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return new TextDecoder("utf-8").decode(bytes);
}
function parseBehavior(element) {
var raw = element.getAttribute("data-wrn-behavior");
if (!raw) return null;
var raw = element.getAttribute(
"data-wrn-behavior",
);
if (!raw) {
return null;
}
try {
var parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : null;
var decoded =
decodeBehaviorPayload(raw);
var parsed = JSON.parse(decoded);
if (
!parsed ||
typeof parsed !== "object"
) {
return null;
}
return parsed;
} catch (error) {
console.error("[wrnexus] failed to parse component behavior", error);
console.error(
"[wrnexus] failed to parse component behavior",
error,
);
return null;
}
}
@@ -132,6 +164,119 @@ export const REACTIVE_RUNTIME = String.raw`
subscribe: function (f) { subs.add(f); return function () { subs.delete(f); }; }
};
}
function applyReactiveAttribute(node, name, value) {
var lowerName = String(name || "").toLowerCase();
if (
lowerName === "value" &&
(
node instanceof HTMLInputElement ||
node instanceof HTMLTextAreaElement ||
node instanceof HTMLSelectElement
)
) {
var nextValue =
value == null ? "" : String(value);
if (node.value !== nextValue) {
node.value = nextValue;
}
if (value == null) {
node.removeAttribute("value");
} else {
node.setAttribute(
"value",
nextValue,
);
}
return;
}
if (
lowerName === "checked" &&
node instanceof HTMLInputElement
) {
var checked = !!value;
if (node.checked !== checked) {
node.checked = checked;
}
if (checked) {
node.setAttribute(
"checked",
"",
);
} else {
node.removeAttribute(
"checked",
);
}
return;
}
if (
lowerName === "selected" &&
node instanceof HTMLOptionElement
) {
var selected = !!value;
node.selected = selected;
if (selected) {
node.setAttribute(
"selected",
"",
);
} else {
node.removeAttribute(
"selected",
);
}
return;
}
if (
lowerName === "disabled" ||
lowerName === "required" ||
lowerName === "readonly" ||
lowerName === "multiple"
) {
var enabled = !!value;
if (lowerName in node) {
try {
node[lowerName] = enabled;
} catch (_) {
// Fall through to attribute handling.
}
}
if (enabled) {
node.setAttribute(
name,
"",
);
} else {
node.removeAttribute(name);
}
return;
}
if (value === false || value == null) {
node.removeAttribute(name);
} else {
node.setAttribute(
name,
value === true ? "" : String(value),
);
}
}
function setupScope(el) {
if (el.__wrnexusScope) return; // idempotent: safe to call again after an HMR morph
@@ -454,8 +599,7 @@ export const REACTIVE_RUNTIME = String.raw`
} catch (e) { return ""; }
});
}
if (raw === false || raw == null) node.removeAttribute(name);
else node.setAttribute(name, raw === true ? "" : String(raw));
applyReactiveAttribute(node, name, raw);
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.31",
"version": "0.2.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.31",
"version": "0.2.38",
"private": true,
"type": "module",
"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.38";
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`);
}