diff --git a/examples/basic-app/app/pages/html-editing-check.wrn b/examples/basic-app/app/pages/html-editing-check.wrn
new file mode 100644
index 00000000..6e0f4437
--- /dev/null
+++ b/examples/basic-app/app/pages/html-editing-check.wrn
@@ -0,0 +1,13 @@
+page HtmlEditingCheck {
+ seo {
+ title = "HTML editing check"
+ description = "Scratch page for verifying editor support inside view blocks."
+ canonical = "/html-editing-check"
+ }
+
+ view {
+
+ Editor check
+
+ }
+}
diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts
index 86898346..6ec53fbb 100644
--- a/examples/basic-app/app/routes.gen.ts
+++ b/examples/basic-app/app/routes.gen.ts
@@ -9,6 +9,7 @@ export interface Routes {
"/client-only": Record;
"/dashboard": Record;
"/hello": Record;
+ "/html-editing-check": Record;
"/island-demo": Record;
"/language-tools": Record;
"/layout": Record;
@@ -32,6 +33,7 @@ export interface RouteNames {
"client.only": "/client-only";
"dashboard": "/dashboard";
"hello": "/hello";
+ "html.editing.check": "/html-editing-check";
"island.demo": "/island-demo";
"language.tools": "/language-tools";
"layout": "/layout";
@@ -124,6 +126,7 @@ export function route(
"client.only": "/client-only",
"dashboard": "/dashboard",
"hello": "/hello",
+ "html.editing.check": "/html-editing-check",
"island.demo": "/island-demo",
"language.tools": "/language-tools",
"layout": "/layout",
diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts
index 537a1c99..afb9e21a 100644
--- a/examples/basic-app/app/types/wrnexus.generated.d.ts
+++ b/examples/basic-app/app/types/wrnexus.generated.d.ts
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
: never;
type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown;
type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown;
- type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
+ type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "html.editing.check" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
diff --git a/packages/language-server/test/html-editing-e2e.test.ts b/packages/language-server/test/html-editing-e2e.test.ts
new file mode 100644
index 00000000..70e8c424
--- /dev/null
+++ b/packages/language-server/test/html-editing-e2e.test.ts
@@ -0,0 +1,199 @@
+import { expect, test } from "bun:test";
+import { fileURLToPath } from "node:url";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { positionAt } from "../src/index.ts";
+
+// End-to-end verification (Task 8): drives the real language server, over
+// real LSP stdio framing, against the real scratch page checked in at
+// examples/basic-app/app/pages/html-editing-check.wrn. The page's text is
+// read from disk rather than duplicated here, so this test breaks if the
+// page and the test drift apart.
+
+const pagePath = join(
+ fileURLToPath(new URL("../../../", import.meta.url)),
+ "examples/basic-app/app/pages/html-editing-check.wrn",
+);
+const pageText = readFileSync(pagePath, "utf8");
+
+function packet(value: unknown): Uint8Array {
+ const body = JSON.stringify(value);
+ return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
+}
+
+async function withServer(
+ run: (
+ send: (msg: unknown) => Promise,
+ readUntil: (marker: string) => Promise,
+ ) => Promise,
+): Promise {
+ const process = Bun.spawn(
+ ["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
+ { stdin: "pipe", stdout: "pipe", stderr: "pipe" },
+ );
+ const reader = process.stdout.getReader();
+ let output = "";
+ async function readUntil(marker: string): Promise {
+ while (!output.includes(marker)) {
+ const chunk = await reader.read();
+ if (chunk.done) break;
+ output += new TextDecoder().decode(chunk.value);
+ }
+ return output.slice(output.indexOf(marker));
+ }
+ async function send(message: unknown): Promise {
+ process.stdin.write(packet(message));
+ await process.stdin.flush();
+ }
+ try {
+ return await run(send, readUntil);
+ } finally {
+ process.kill();
+ await process.exited;
+ }
+}
+
+test("HTML editor support works end-to-end against the real scratch page", async () => {
+ const uri = "file:///html-editing-check.wrn";
+
+ // Positions derived from the real file content, not hardcoded line/column
+ // literals, so the test tracks the page if it changes.
+ const viewOpenBrace = pageText.indexOf("view {");
+ const viewCloseBrace = pageText.indexOf("}", pageText.indexOf(""));
+ const h1TagOffset = pageText.indexOf("") + 1; // inside "h1"
+ const mainOpenNameOffset = pageText.indexOf("") + 1; // inside "main" opening tag name
+ const mainOpenTagEndOffset = pageText.indexOf("") + "".length; // just after '>'
+ const seoTitleOffset = pageText.indexOf('title = "HTML editing check"'); // inside the seo block
+
+ // Simulate typing "<" inside the seo {} block: insert the character into a
+ // copy of the real page text (the checked-in file itself is never
+ // mutated) and ask for completion right after it. This is the scenario
+ // that actually exercises the view-region guard: if the guard were
+ // defeated, this exact position is where the HTML language service would
+ // offer tag completions, because it is positioned directly after an
+ // unclosed "<".
+ const seoInjectedText = pageText.slice(0, seoTitleOffset) + "<" + pageText.slice(seoTitleOffset);
+ const seoInjectedPosition = positionAt(seoInjectedText, seoTitleOffset + 1);
+
+ const viewPosition = positionAt(pageText, h1TagOffset);
+ const linkedEditingPosition = positionAt(pageText, mainOpenNameOffset);
+ const tagCompletePosition = positionAt(pageText, mainOpenTagEndOffset);
+ const viewStartLine = positionAt(pageText, viewOpenBrace).line;
+ const viewEndLine = positionAt(pageText, viewCloseBrace).line;
+
+ await withServer(async (send, readUntil) => {
+ await send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
+ await readUntil('"id":1');
+
+ await send({
+ jsonrpc: "2.0",
+ method: "textDocument/didOpen",
+ params: { textDocument: { uri, version: 1, text: pageText } },
+ });
+
+ // --- Completion inside view {} : HTML entries present, WRNexus sorts first ---
+ await send({
+ jsonrpc: "2.0",
+ id: 2,
+ method: "textDocument/completion",
+ params: { textDocument: { uri }, position: viewPosition },
+ });
+ const completionReply = await readUntil('"id":2');
+ // An HTML tag entry is present, tagged with the "1" (below-wrnexus) sortText prefix.
+ expect(completionReply).toMatch(/"label":"div"[^}]*"sortText":"1/);
+ // A WRNexus keyword entry is present, tagged with the "0" (above-html) sortText prefix.
+ expect(completionReply).toMatch(/"label":"page"[^}]*"sortText":"0/);
+ // No html-prefixed sortText is lexicographically smaller than any wrnexus one:
+ // every "0..." sortText must precede every "1..." sortText, which is exactly
+ // what makes WRNexus entries render above HTML ones in an editor.
+ expect(completionReply).not.toMatch(/"sortText":"1[^"]*"[\s\S]*"sortText":"0/);
+
+ // --- Negative case: completion right after "<" typed inside seo {} must NOT include HTML entries ---
+ const seoUri = "file:///html-editing-check-seo-inject.wrn";
+ await send({
+ jsonrpc: "2.0",
+ method: "textDocument/didOpen",
+ params: { textDocument: { uri: seoUri, version: 1, text: seoInjectedText } },
+ });
+ await send({
+ jsonrpc: "2.0",
+ id: 3,
+ method: "textDocument/completion",
+ params: { textDocument: { uri: seoUri }, position: seoInjectedPosition },
+ });
+ const seoReply = await readUntil('"id":3');
+ expect(seoReply).not.toContain('"label":"div"');
+ expect(seoReply).not.toContain('"label":"span"');
+ expect(seoReply).not.toContain('"label":"h1"');
+ expect(seoReply).not.toMatch(/"sortText":"1/);
+
+ // --- Hover over a tag inside the view block returns documentation ---
+ await send({
+ jsonrpc: "2.0",
+ id: 4,
+ method: "textDocument/hover",
+ params: { textDocument: { uri }, position: viewPosition },
+ });
+ const hoverReply = await readUntil('"id":4');
+ expect(hoverReply).toContain('"contents"');
+ expect(hoverReply).not.toMatch(/"result":\s*null/);
+
+ // --- Folding ranges are returned and all lie within the view block ---
+ await send({
+ jsonrpc: "2.0",
+ id: 5,
+ method: "textDocument/foldingRange",
+ params: { textDocument: { uri } },
+ });
+ const foldReply = await readUntil('"id":5');
+ expect(foldReply).toContain('"result":[');
+ const foldBody = foldReply.slice(foldReply.indexOf('"result":['));
+ const foldRanges = [...foldBody.matchAll(/"startLine":(\d+),"endLine":(\d+)/g)];
+ expect(foldRanges.length).toBeGreaterThan(0);
+ for (const [, start, end] of foldRanges) {
+ expect(Number(start)).toBeGreaterThanOrEqual(viewStartLine);
+ expect(Number(end)).toBeLessThanOrEqual(viewEndLine);
+ }
+
+ // --- Linked editing at the opening tag name returns two ranges ---
+ await send({
+ jsonrpc: "2.0",
+ id: 6,
+ method: "textDocument/linkedEditingRange",
+ params: { textDocument: { uri }, position: linkedEditingPosition },
+ });
+ const linkedReply = await readUntil('"id":6');
+ expect(linkedReply).toContain('"ranges"');
+ const rangeCount = (linkedReply.match(/"start":\{/g) ?? []).length;
+ expect(rangeCount).toBe(2);
+
+ // --- wrn/tagComplete after returns the closing snippet ---
+ await send({
+ jsonrpc: "2.0",
+ id: 7,
+ method: "wrn/tagComplete",
+ params: { textDocument: { uri }, position: tagCompletePosition },
+ });
+ const tagCompleteReply = await readUntil('"id":7');
+ expect(tagCompleteReply).toContain('"result":"$0"');
+
+ // --- wrn/tagComplete for a void element (
) returns null ---
+ const voidText = `page A {\n view {\n
\n }\n}\n`;
+ const voidUri = "file:///html-editing-check-void.wrn";
+ await send({
+ jsonrpc: "2.0",
+ method: "textDocument/didOpen",
+ params: { textDocument: { uri: voidUri, version: 1, text: voidText } },
+ });
+ const brOffset = voidText.indexOf("
") + "
".length;
+ const brPosition = positionAt(voidText, brOffset);
+ await send({
+ jsonrpc: "2.0",
+ id: 8,
+ method: "wrn/tagComplete",
+ params: { textDocument: { uri: voidUri }, position: brPosition },
+ });
+ const voidReply = await readUntil('"id":8');
+ expect(voidReply).toContain('"result":null');
+ });
+});