Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d481df652 | ||
|
|
5477f5436d | ||
|
|
b646ec8d00 | ||
|
|
e66d2425aa | ||
|
|
b3b65dddd8 | ||
|
|
5dbcc5b85d |
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.41",
|
"version": "0.8.42",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -322,14 +322,14 @@
|
|||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.15",
|
"version": "0.8.16",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.37",
|
"version": "0.8.38",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-toolbar": {
|
"packages/dev-toolbar": {
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.12",
|
"version": "0.8.13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -397,7 +397,7 @@
|
|||||||
},
|
},
|
||||||
"packages/i18n": {
|
"packages/i18n": {
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
|||||||
@@ -1674,6 +1674,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1710,6 +1711,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
# HTML editing support for `.wrn` files — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-18
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
|
||||||
|
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
|
||||||
|
tag-level folding.
|
||||||
|
|
||||||
|
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html` → `html`), which is
|
||||||
|
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
|
||||||
|
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **HTML formatting.** See "Formatting is deliberately excluded" below.
|
||||||
|
- Editor support outside VS Code beyond what standard LSP gives for free.
|
||||||
|
- Changing `.wrn` syntax or the compiler.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
|
||||||
|
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
|
||||||
|
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
|
||||||
|
| Region detection | Tolerant scanner over a virtual document, not the AST |
|
||||||
|
| HTML knowledge | `vscode-html-languageservice` |
|
||||||
|
| Formatting | Excluded — `formatWrn` already owns markup formatting |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Virtual HTML document
|
||||||
|
|
||||||
|
New module: `packages/language-server/src/html-regions.ts`, exporting
|
||||||
|
`virtualHtmlDocument(document)`.
|
||||||
|
|
||||||
|
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
|
||||||
|
newlines preserved. The virtual document therefore has the same size and the same line/column
|
||||||
|
geometry as the source, so a position in the source _is_ the position in the virtual document.
|
||||||
|
No mapping table and no translation layer.
|
||||||
|
|
||||||
|
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
|
||||||
|
compacts code and carries line mappings back to source. Compaction is necessary there because
|
||||||
|
the output must be valid TypeScript. HTML has no such requirement, so the simpler
|
||||||
|
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
|
||||||
|
does not arise.
|
||||||
|
|
||||||
|
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
|
||||||
|
newlines at identical offsets. If this breaks, every feature reports positions off by some
|
||||||
|
amount rather than failing loudly.
|
||||||
|
|
||||||
|
### Region detection
|
||||||
|
|
||||||
|
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
|
||||||
|
while the document is being typed, which is exactly when it does not parse. The scanner finds
|
||||||
|
`view` followed by `{` and tracks brace depth to the matching close.
|
||||||
|
|
||||||
|
Two hazards it must handle, both of which defeat a naive implementation:
|
||||||
|
|
||||||
|
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
|
||||||
|
delimiter anywhere will consider the rest of the file one open string and lose every later
|
||||||
|
region. Quotes are tracked only inside attribute values, never in text nodes.
|
||||||
|
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
|
||||||
|
depth must be counted rather than scanning for the next `}`.
|
||||||
|
|
||||||
|
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
|
||||||
|
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
|
||||||
|
|
||||||
|
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
|
||||||
|
costs a single scan.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
### The server becomes the single authority inside view blocks
|
||||||
|
|
||||||
|
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
|
||||||
|
virtual document is non-blank there, which costs one character lookup.
|
||||||
|
|
||||||
|
**Inside a view block**, one list is assembled from two sources:
|
||||||
|
|
||||||
|
| Source | `sortText` prefix | Content |
|
||||||
|
| ------- | ----------------- | ------------------------------------------------------------------------ |
|
||||||
|
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
|
||||||
|
| HTML | `1` | Tags, attributes, attribute values |
|
||||||
|
|
||||||
|
`sortText` drives ordering independently of the label, so components rank above HTML tags
|
||||||
|
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
|
||||||
|
plus workspace items.
|
||||||
|
|
||||||
|
The server already indexes components, props, outputs, and slots
|
||||||
|
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
|
||||||
|
of the merge are already available to it.
|
||||||
|
|
||||||
|
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
|
||||||
|
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
|
||||||
|
resolves to the component.
|
||||||
|
|
||||||
|
### Trigger characters
|
||||||
|
|
||||||
|
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
|
||||||
|
`" "`, `"="`, `"\""`, and `"/"`.
|
||||||
|
|
||||||
|
### This fixes an existing bug
|
||||||
|
|
||||||
|
The extension's `completion.js` registers its own provider with `<` among its trigger
|
||||||
|
characters, and the language server answers `textDocument/completion` as well. VS Code
|
||||||
|
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
|
||||||
|
involved at all.
|
||||||
|
|
||||||
|
As part of this work the extension's provider returns nothing when the position is inside a view
|
||||||
|
block, and keeps its current behaviour elsewhere. One owner per context.
|
||||||
|
|
||||||
|
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
|
||||||
|
context, so future component-intelligence work belongs in the server rather than in
|
||||||
|
`completion.js`.
|
||||||
|
|
||||||
|
## Hover
|
||||||
|
|
||||||
|
`textDocument/hover` answers from the HTML service over the virtual document when the position
|
||||||
|
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
|
||||||
|
region, existing hover behaviour is unchanged.
|
||||||
|
|
||||||
|
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
|
||||||
|
any HTML entry of the same name, matching the completion precedence rule above.
|
||||||
|
|
||||||
|
## Tag handling
|
||||||
|
|
||||||
|
### Linked editing is standard LSP
|
||||||
|
|
||||||
|
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
|
||||||
|
it lives in the shared server like everything else.
|
||||||
|
|
||||||
|
### Auto-close on type is the one client-side piece
|
||||||
|
|
||||||
|
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
|
||||||
|
client-side, and this follows the same shape:
|
||||||
|
|
||||||
|
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
|
||||||
|
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
|
||||||
|
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
|
||||||
|
snippet or `null`.
|
||||||
|
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
|
||||||
|
|
||||||
|
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
|
||||||
|
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
|
||||||
|
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
|
||||||
|
|
||||||
|
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
|
||||||
|
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
|
||||||
|
|
||||||
|
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
|
||||||
|
`wrnexus.*` naming.
|
||||||
|
|
||||||
|
### Emmet
|
||||||
|
|
||||||
|
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
|
||||||
|
|
||||||
|
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
|
||||||
|
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
|
||||||
|
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
|
||||||
|
edge is real.
|
||||||
|
|
||||||
|
## Folding
|
||||||
|
|
||||||
|
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
|
||||||
|
the virtual document, filtered to view regions.
|
||||||
|
|
||||||
|
Today folding comes only from `language-configuration.json` markers, which work at block level
|
||||||
|
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
|
||||||
|
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
|
||||||
|
to work unchanged and tag folding appears inside markup.
|
||||||
|
|
||||||
|
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
|
||||||
|
outside a view region would let a fold swallow a brace boundary.
|
||||||
|
|
||||||
|
## Formatting is deliberately excluded
|
||||||
|
|
||||||
|
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
|
||||||
|
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
|
||||||
|
`printWidth`. It is a markup formatter that understands WRNexus syntax.
|
||||||
|
|
||||||
|
Adding HTML formatting would do two harmful things:
|
||||||
|
|
||||||
|
- **Two formatters would fight.** Output would depend on which ran last.
|
||||||
|
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
|
||||||
|
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
|
||||||
|
|
||||||
|
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
|
||||||
|
`editors/vscode`.
|
||||||
|
|
||||||
|
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
|
||||||
|
and passes other `require`s through to Node, so the package must be resolvable at runtime from
|
||||||
|
the extension. `editors/vscode` currently ships exactly one runtime dependency
|
||||||
|
(`vscode-languageclient`); this adds the second.
|
||||||
|
|
||||||
|
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
|
||||||
|
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Region scanner (`packages/language-server/test/`)
|
||||||
|
|
||||||
|
- **The invariant**, property-style across fixtures: virtual text length equals source length and
|
||||||
|
newlines sit at identical offsets.
|
||||||
|
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
|
||||||
|
found.
|
||||||
|
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
|
||||||
|
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
|
||||||
|
completion, not an edge case.
|
||||||
|
- **Multiple view blocks**, and files with none.
|
||||||
|
|
||||||
|
### Completion
|
||||||
|
|
||||||
|
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
|
||||||
|
- Outside a view block: response identical to current behaviour — the guard proving non-markup
|
||||||
|
contexts are undisturbed.
|
||||||
|
- Collision: a component named `Table` yields one entry, the component.
|
||||||
|
|
||||||
|
### Tag handling
|
||||||
|
|
||||||
|
- `<div>` → `</div>`; `<br>` → nothing; `<Card /` → `/>`; outside a view region → `null`.
|
||||||
|
- Linked editing returns ranges covering both the opening and closing tag names.
|
||||||
|
|
||||||
|
### Hover
|
||||||
|
|
||||||
|
- Inside a view region, a known tag returns HTML documentation.
|
||||||
|
- A component name returns the component detail, not an HTML entry of the same name.
|
||||||
|
|
||||||
|
### Folding
|
||||||
|
|
||||||
|
- Every returned range lies inside a view region.
|
||||||
|
- Block-level marker folding still works.
|
||||||
|
|
||||||
|
### Toolchain guards
|
||||||
|
|
||||||
|
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
|
||||||
|
- Manifest assertion that `emmet.includeLanguages` maps `wrn` → `html`, alongside the existing
|
||||||
|
marketplace checks in `editors/vscode/test`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- HTML formatting — see above; improve `formatWrn` instead.
|
||||||
|
- Moving the remaining `completion.js` component intelligence into the server. This design only
|
||||||
|
requires it to stand down inside view blocks; relocating the rest is follow-up work.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
|
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
|
||||||
import type { Db, ExecResult } from "@wrnexus/db";
|
import type { Db, ExecResult } from "@wrnexus/db";
|
||||||
import { users } from "./schema.ts";
|
import { users } from "./schema.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.41",
|
"version": "0.8.42",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncI18n(nextDocument) {
|
function syncI18n(nextDocument) {
|
||||||
var script = Array.prototype.find.call(
|
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
nextDocument.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
|
||||||
if (!match) return;
|
|
||||||
try {
|
try {
|
||||||
var incoming = JSON.parse(match[1]);
|
var incoming = JSON.parse(String(script.textContent || "{}"));
|
||||||
var current = window.__wrnI18n || {};
|
var current = window.__wrnI18n || {};
|
||||||
var translator = current.t;
|
var translator = current.t;
|
||||||
var setter = current.set;
|
var setter = current.set;
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
|
|||||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
@@ -180,7 +180,7 @@ test("preserves same-language translations when an incoming navigation catalog i
|
|||||||
};
|
};
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.15",
|
"version": "0.8.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -230,5 +230,12 @@ export function generateQueriesFile(
|
|||||||
if (usedModels.size > 0) {
|
if (usedModels.size > 0) {
|
||||||
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
||||||
}
|
}
|
||||||
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
|
// The dialect is stamped into the header because it changes the emitted SQL:
|
||||||
|
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
|
||||||
|
// under a different profile therefore rewrites this committed file, and
|
||||||
|
// without the stamp the diff looks like unexplained churn.
|
||||||
|
return (
|
||||||
|
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
|
||||||
|
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
|
||||||
|
|
||||||
|
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
|
||||||
|
|
||||||
|
test("the generated header records the dialect it was built for", () => {
|
||||||
|
// The same command emits different SQL per dialect, so a build under another
|
||||||
|
// profile rewrites the committed file. The stamp makes that visible in the
|
||||||
|
// diff instead of looking like unexplained churn.
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("placeholder style follows the dialect", () => {
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generation is deterministic for a fixed dialect", () => {
|
||||||
|
const first = generateQueriesFile(queries, [], "postgres");
|
||||||
|
const second = generateQueriesFile(queries, [], "postgres");
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.37",
|
"version": "0.8.38",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
|
|||||||
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayWebSocketOriginAllowed(
|
export function gatewayWebSocketOriginAllowed(
|
||||||
req: Request,
|
req: Request,
|
||||||
target: Target,
|
target: Target,
|
||||||
configured: string[],
|
configured: string[],
|
||||||
@@ -174,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
|
|||||||
}
|
}
|
||||||
if (configured.includes(origin)) return true;
|
if (configured.includes(origin)) return true;
|
||||||
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
||||||
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
|
// Compare hostnames, not hosts: configured domains carry no port, while the
|
||||||
|
// browser's Origin does. publicOrigin above only ever matches domains[0], so
|
||||||
|
// every other domain fell through to here and was denied purely on the port,
|
||||||
|
// which left the HMR socket reconnecting forever on those hosts.
|
||||||
|
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
type TenancyConfig,
|
type TenancyConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
import {
|
import {
|
||||||
renderI18nData,
|
renderI18nDataTag,
|
||||||
makeT,
|
makeT,
|
||||||
resolveLang,
|
resolveLang,
|
||||||
translateHtml,
|
translateHtml,
|
||||||
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
|
|||||||
pendingSync = false;
|
pendingSync = false;
|
||||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
var i18nScript = Array.prototype.find.call(
|
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
doc.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (i18nScript) {
|
if (i18nScript) {
|
||||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
try {
|
||||||
if (i18nMatch) {
|
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||||
try {
|
var existingI18n = window.__wrnI18n || {};
|
||||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
incomingI18n.t = existingI18n.t;
|
||||||
var existingI18n = window.__wrnI18n || {};
|
incomingI18n.set = existingI18n.set;
|
||||||
incomingI18n.t = existingI18n.t;
|
window.__wrnI18n = incomingI18n;
|
||||||
incomingI18n.set = existingI18n.set;
|
} catch (error) {
|
||||||
window.__wrnI18n = incomingI18n;
|
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||||
} catch (error) {
|
|
||||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1978,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
extraBody:
|
extraBody:
|
||||||
[
|
[
|
||||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||||
deps.i18n
|
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
|
||||||
: "",
|
|
||||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
name: "web",
|
||||||
|
origin: "http://127.0.0.1:3101",
|
||||||
|
domains: ["localhost", "web.localhost"],
|
||||||
|
publicOrigin: "http://localhost:3000",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
function upgrade(origin: string, host: string): Request {
|
||||||
|
return new Request("http://" + host + "/__wrnexus/hmr", {
|
||||||
|
headers: { origin, host, upgrade: "websocket" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("allows an upgrade from the app's primary domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows an upgrade from a secondary domain on a non-default port", () => {
|
||||||
|
// publicOrigin is built from domains[0], so a browser on web.localhost falls
|
||||||
|
// through to the domain list — where the origin host still carries :3000 and
|
||||||
|
// the configured domain does not. That mismatch denied every HMR socket on
|
||||||
|
// any domain but the first, leaving the client reconnecting forever.
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://web.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies an unrelated origin", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://evil.example:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies a lookalike suffix domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.12",
|
"version": "0.8.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
|||||||
.replace(/\u2029/g, "\\u2029");
|
.replace(/\u2029/g, "\\u2029");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attribute marking the JSON block that carries per-request i18n data. */
|
||||||
|
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The i18n payload, emitted as JSON rather than as an assignment.
|
||||||
|
*
|
||||||
|
* It ships inside a `type="application/json"` block, which the browser never
|
||||||
|
* executes, so `script-src` does not apply to it. As an inline executable
|
||||||
|
* script it was blocked whenever the surrounding document's CSP nonce came
|
||||||
|
* from a different response, leaving window.__wrnI18n undefined.
|
||||||
|
*/
|
||||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||||
return `window.__wrnI18n=${safeJson({
|
return `${safeJson({
|
||||||
lang: active,
|
lang: active,
|
||||||
langs: i18n.langs,
|
langs: i18n.langs,
|
||||||
default: i18n.default,
|
default: i18n.default,
|
||||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
|||||||
directions: i18n.direction,
|
directions: i18n.direction,
|
||||||
labels: i18n.labels,
|
labels: i18n.labels,
|
||||||
cookie: i18n.cookie,
|
cookie: i18n.cookie,
|
||||||
})};`;
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full JSON block, including its script tag. */
|
||||||
|
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
|
||||||
|
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const I18N_RUNTIME = String.raw`
|
export const I18N_RUNTIME = String.raw`
|
||||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
|||||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function state() { return window.__wrnI18n || {}; }
|
function readDataBlock() {
|
||||||
|
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.textContent || "{}");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus] i18n data block was not valid JSON", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function state() {
|
||||||
|
if (!window.__wrnI18n) {
|
||||||
|
var data = readDataBlock();
|
||||||
|
if (data) window.__wrnI18n = data;
|
||||||
|
}
|
||||||
|
return window.__wrnI18n || {};
|
||||||
|
}
|
||||||
function t(key, params) {
|
function t(key, params) {
|
||||||
var current = state();
|
var current = state();
|
||||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
|
||||||
|
|
||||||
|
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
|
||||||
|
|
||||||
|
test("the i18n payload is plain JSON, not an assignment", () => {
|
||||||
|
const data = renderI18nData(i18n, "en");
|
||||||
|
expect(() => JSON.parse(data)).not.toThrow();
|
||||||
|
expect(data).not.toContain("window.__wrnI18n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the data tag is a non-executable JSON block", () => {
|
||||||
|
// An executable inline script is subject to script-src and gets blocked
|
||||||
|
// whenever the document's CSP nonce came from a different response, which is
|
||||||
|
// what left window.__wrnI18n undefined. A JSON block is never executed.
|
||||||
|
const tag = renderI18nDataTag(i18n, "es");
|
||||||
|
expect(tag).toContain('type="application/json"');
|
||||||
|
expect(tag).toContain("data-wrn-i18n");
|
||||||
|
expect(tag).not.toContain("nonce=");
|
||||||
|
expect(tag).toContain("Hola");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
|
||||||
|
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
});
|
||||||
@@ -145,7 +145,7 @@ check(
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"i18n runtime data is injected into rendered documents",
|
"i18n runtime data is injected into rendered documents",
|
||||||
has("packages/dev-server/src/runtime.ts", "renderI18nData(deps.i18n, language)") &&
|
has("packages/dev-server/src/runtime.ts", "renderI18nDataTag(deps.i18n, language)") &&
|
||||||
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
|
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
|
|||||||
Reference in New Issue
Block a user