# HTML Editing Support for `.wrn` Files — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make writing markup inside a `.wrn` `view { }` block behave like writing HTML — tag and attribute completion, tag closing and renaming, hover docs, Emmet, and tag-level folding. **Architecture:** The language server extracts `view { }` blocks into a virtual HTML document where everything outside them is replaced by whitespace of identical length. Source and virtual positions are therefore the same, so no mapping table exists. `vscode-html-languageservice` answers completion, hover, folding, and tag closing against that virtual document, and its results merge with the WRNexus component index the server already builds. Only auto-close-on-type lives in the VS Code extension, because LSP has no request for it. **Tech Stack:** TypeScript 6.0.3, Bun (test runner), `vscode-html-languageservice`, VS Code extension API, LSP 3.16. **Spec:** `docs/superpowers/specs/2026-08-18-wrn-html-editing-design.md` ## Global Constraints - Test runner is **Bun**. Language server tests live in `packages/language-server/test/` and use `import { expect, test } from "bun:test";`. - The virtual HTML document must satisfy: `virtualHtmlDocument(doc).text.length === doc.text.length`, with newlines at identical offsets. This is what makes position mapping unnecessary. - Region detection uses a **tolerant scanner**, never the `@wrnexus/syntax` parser — completion fires on unparseable documents. - WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked out of the virtual document. - Completion inside a view block returns **one merged list**: WRNexus entries get `sortText` prefix `0`, HTML entries `1`. On exact label collision, the WRNexus entry wins and the HTML entry is dropped. - **HTML formatting is out of scope.** `formatWrn` owns markup formatting. Do not register an HTML formatter. - `vscode-html-languageservice` must be a dependency of **both** `packages/language-server` and `editors/vscode`. - New setting name, exactly: `wrnexus.html.autoClosingTags`, default `true`. - Custom LSP request name, exactly: `wrn/tagComplete`. - `TextDocument` is `{ uri: string; text: string; version?: number }` from `packages/language-server/src/index.ts`. - Commit after every task. Never use `--no-verify`. - After any change to `packages/language-server/src/`, run `bun run --cwd editors/vscode build` and commit the regenerated `editors/vscode/src/language-server.cjs`, or `check:editor-language-server` fails. ## File Structure **New:** | File | Responsibility | | ---------------------------------------------------- | -------------------------------------------------------------------------- | | `packages/language-server/src/html-regions.ts` | Tolerant view-block scanner + offset-preserving virtual HTML document | | `packages/language-server/src/html-service.ts` | Wraps `vscode-html-languageservice`: completion, hover, folding, tag close | | `packages/language-server/test/html-regions.test.ts` | Scanner and invariant tests | | `packages/language-server/test/html-service.test.ts` | Completion merge, hover, folding, tag-close tests | **Modified:** | File | Change | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `packages/language-server/package.json` | Add `vscode-html-languageservice` dependency | | `packages/language-server/src/server.ts` | Merge HTML into completion/hover, add folding + linked editing + `wrn/tagComplete`, extend trigger characters | | `editors/vscode/package.json` | Add dependency, Emmet mapping, `wrnexus.html.autoClosingTags` setting | | `editors/vscode/src/extension.js` | Auto-close-on-type listener | | `editors/vscode/src/completion.js` | Stand down inside view blocks | | `editors/vscode/test/validate.mjs` | Manifest assertions | --- ### Task 1: View-block scanner and virtual HTML document The load-bearing piece. Everything else reads positions through this, so its invariant is tested before any feature uses it. **Files:** - Create: `packages/language-server/src/html-regions.ts` - Create: `packages/language-server/test/html-regions.test.ts` **Interfaces:** - Consumes: `TextDocument` from `packages/language-server/src/index.ts`. - Produces: - `interface HtmlRegion { start: number; end: number }` - `viewRegions(text: string): HtmlRegion[]` - `virtualHtmlDocument(document: TextDocument): { uri: string; languageId: "html"; text: string }` - `isInsideHtml(document: TextDocument, offset: number): boolean` - `clearHtmlRegionCache(uri?: string): void` - [ ] **Step 1: Write the failing tests** Create `packages/language-server/test/html-regions.test.ts`: ```ts import { expect, test } from "bun:test"; import { isInsideHtml, viewRegions, virtualHtmlDocument } from "../src/html-regions.ts"; function doc(text: string): { uri: string; text: string; version?: number } { return { uri: `file:///${Math.random()}.wrn`, text }; } const PAGE = `page Home { view {
hello
} } `; test("the virtual document preserves length and newline offsets", () => { // This is what makes position mapping unnecessary. If it breaks, every // feature reports positions off by some amount instead of failing loudly. const source = doc(PAGE); const virtual = virtualHtmlDocument(source); expect(virtual.text.length).toBe(source.text.length); expect(virtual.languageId).toBe("html"); for (let i = 0; i < source.text.length; i += 1) { if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n"); } }); test("markup survives into the virtual document and everything else is blanked", () => { const virtual = virtualHtmlDocument(doc(PAGE)); expect(virtual.text).toContain('
hello
'); expect(virtual.text).not.toContain("page Home"); expect(virtual.text).not.toContain("view"); }); test("an apostrophe in text content does not swallow later regions", () => { // A scanner treating ' as a string delimiter anywhere considers the rest of // the file one open string and loses every later region. const source = `page A { view {

it's fine

} } component B { view { second } } `; expect(viewRegions(source)).toHaveLength(2); expect(virtualHtmlDocument(doc(source)).text).toContain("second"); }); test("interpolation braces nest without ending the region early", () => { const source = `page A { view {
after
} } `; const regions = viewRegions(source); expect(regions).toHaveLength(1); expect(virtualHtmlDocument(doc(source)).text).toContain("after"); }); test("unparseable mid-edit markup still yields a region", () => { // Completion fires exactly when the document does not parse. const source = `page A { view {
{ const source = `page A { functions { function go() {} } } `; expect(viewRegions(source)).toEqual([]); expect(virtualHtmlDocument(doc(source)).text.trim()).toBe(""); }); test("regions are cached per document version", () => { // One keystroke fans out into completion, hover, and tag-close requests. const first = doc(PAGE); first.version = 1; expect(isInsideHtml(first, PAGE.indexOf(" { const source = doc(PAGE); const markupOffset = PAGE.indexOf("it's

` * would otherwise open a string that never closes and swallow the rest of the * file. */ function matchingBrace(text: string, from: number): number { let depth = 1; let inTag = false; let quote: string | null = null; for (let index = from; index < text.length; index += 1) { const char = text[index]!; if (quote) { if (char === quote) quote = null; continue; } if (inTag && (char === '"' || char === "'")) { quote = char; continue; } if (char === "<") inTag = true; else if (char === ">") inTag = false; else if (char === "{") depth += 1; else if (char === "}") { depth -= 1; if (depth === 0) return index; } } return text.length; } /** * A parallel document containing only the markup. * * Everything outside a view block becomes whitespace of the same length, and * newlines are preserved, so an offset in the source is the same offset here. * That removes the need for a mapping table entirely. */ export function virtualHtmlDocument(document: TextDocument): { uri: string; languageId: "html"; text: string; } { const source = document.text; const keep = new Array(source.length).fill(false); for (const region of viewRegions(source)) { for (let index = region.start; index < region.end; index += 1) keep[index] = true; } let text = ""; for (let index = 0; index < source.length; index += 1) { const char = source[index]!; text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " "; } return { uri: `${document.uri}.html`, languageId: "html", text }; } export function isInsideHtml(document: TextDocument, offset: number): boolean { return regionsFor(document).some((region) => offset >= region.start && offset <= region.end); } /** * Regions for a document, cached by uri and version. * * A single keystroke produces a burst of completion, hover, and tag-close * requests; without this each one rescans the file. */ const regionCache = new Map(); function regionsFor(document: TextDocument): HtmlRegion[] { const version = document.version ?? -1; const cached = regionCache.get(document.uri); if (cached && cached.version === version) return cached.regions; const regions = viewRegions(document.text); regionCache.set(document.uri, { version, regions }); return regions; } /** Drops a document's cached regions. Call when a document closes. */ export function clearHtmlRegionCache(uri?: string): void { if (uri) regionCache.delete(uri); else regionCache.clear(); } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `bun test packages/language-server/test/html-regions.test.ts` Expected: PASS (8 tests) - [ ] **Step 5: Commit** ```bash git add packages/language-server/src/html-regions.ts packages/language-server/test/html-regions.test.ts git commit -m "feat(language-server): add offset-preserving virtual HTML document" ``` --- ### Task 2: HTML service wrapper **Files:** - Create: `packages/language-server/src/html-service.ts` - Create: `packages/language-server/test/html-service.test.ts` - Modify: `packages/language-server/package.json` **Interfaces:** - Consumes: `virtualHtmlDocument`, `isInsideHtml` from Task 1; `Position`, `TextDocument` from `./index.ts`. - Produces: - `interface HtmlCompletionItem { label: string; kind: number; detail?: string; documentation?: string; sortText?: string; insertText?: string }` - `htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[]` - `htmlHover(document: TextDocument, position: Position): { contents: string } | null` - `htmlFoldingRanges(document: TextDocument): Array<{ startLine: number; endLine: number }>` - `htmlTagComplete(document: TextDocument, position: Position): string | null` - [ ] **Step 1: Add the dependency** Run: `bun add --cwd packages/language-server vscode-html-languageservice` - [ ] **Step 2: Write the failing tests** Create `packages/language-server/test/html-service.test.ts`: ```ts import { expect, test } from "bun:test"; import { htmlCompletions, htmlFoldingRanges, htmlHover, htmlTagComplete, } from "../src/html-service.ts"; function doc(text: string) { return { uri: "file:///Page.wrn", text }; } function positionOf(text: string, needle: string) { const offset = text.indexOf(needle) + needle.length; const before = text.slice(0, offset); const lines = before.split("\n"); return { line: lines.length - 1, character: lines[lines.length - 1]!.length }; } test("suggests HTML tags inside a view block", () => { const text = `page A { view { < } } `; const items = htmlCompletions(doc(text), positionOf(text, " <")); expect(items.some((item) => item.label === "div")).toBe(true); expect(items.every((item) => item.sortText?.startsWith("1"))).toBe(true); }); test("suggests attributes inside a tag", () => { const text = `page A { view { item.label === "type")).toBe(true); }); test("returns nothing outside a view block", () => { const text = `page A { functions { function go() { } } } `; expect(htmlCompletions(doc(text), positionOf(text, "function go() "))).toEqual([]); }); test("hovers a tag inside a view block and nothing outside one", () => { const text = `page A { view {
x
} } `; expect(htmlHover(doc(text), positionOf(text, " { const open = `page A { view {
} } `; expect(htmlTagComplete(doc(open), positionOf(open, "
"))).toContain("
"); const void_ = `page A { view {
} } `; expect(htmlTagComplete(doc(void_), positionOf(void_, "
"))).toBeNull(); }); test("completes a self-closing component tag", () => { const text = `page A { view { "); }); test("returns no tag completion outside a view block", () => { const text = `page A { functions { function go() { } } } `; expect(htmlTagComplete(doc(text), positionOf(text, "function go() "))).toBeNull(); }); test("folding ranges stay inside view regions", () => { const text = `page A { view {
  • one
} } `; const ranges = htmlFoldingRanges(doc(text)); expect(ranges.length).toBeGreaterThan(0); const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1; for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1); }); ``` - [ ] **Step 3: Run the tests to verify they fail** Run: `bun test packages/language-server/test/html-service.test.ts` Expected: FAIL — cannot resolve `../src/html-service.ts` - [ ] **Step 4: Write the implementation** Create `packages/language-server/src/html-service.ts`: ```ts import { getLanguageService, TextDocument as HtmlTextDocument } from "vscode-html-languageservice"; import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts"; import { offsetAt, type Position, type TextDocument } from "./index.ts"; export interface HtmlCompletionItem { label: string; kind: number; detail?: string; documentation?: string; sortText?: string; insertText?: string; } const service = getLanguageService(); /** The virtual document as the HTML service's own document type. */ function htmlDocument(document: TextDocument) { const virtual = virtualHtmlDocument(document); return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text); } function markdown(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object" && "value" in value) { return String((value as { value: unknown }).value); } return ""; } /** * HTML completions for a position inside a view block. * * Every item carries the `1` sortText prefix so the server can rank WRNexus * entries above these without filtering either list. */ export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] { if (!isInsideHtml(document, offsetAt(document.text, position))) return []; const virtual = htmlDocument(document); const parsed = service.parseHTMLDocument(virtual); const list = service.doComplete(virtual, position, parsed); return list.items.map((item) => ({ label: item.label, kind: typeof item.kind === "number" ? item.kind : 1, detail: item.detail, documentation: markdown(item.documentation), sortText: `1${item.sortText ?? item.label}`, insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined, })); } export function htmlHover(document: TextDocument, position: Position): { contents: string } | null { if (!isInsideHtml(document, offsetAt(document.text, position))) return null; const virtual = htmlDocument(document); const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual)); if (!result) return null; const contents = markdown(result.contents); return contents ? { contents } : null; } export function htmlFoldingRanges( document: TextDocument, ): Array<{ startLine: number; endLine: number }> { return service .getFoldingRanges(htmlDocument(document)) .map((range) => ({ startLine: range.startLine, endLine: range.endLine })); } /** * The snippet that closes the tag being typed, or null. * * Void elements and already-closed tags return null, which is why this decision * belongs here rather than in the editor client. */ export function htmlTagComplete(document: TextDocument, position: Position): string | null { if (!isInsideHtml(document, offsetAt(document.text, position))) return null; const virtual = htmlDocument(document); return service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual)) ?? null; } ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `bun test packages/language-server/test/html-service.test.ts` Expected: PASS (8 tests) - [ ] **Step 6: Commit** ```bash git add packages/language-server/src/html-service.ts packages/language-server/test/html-service.test.ts packages/language-server/package.json bun.lock git commit -m "feat(language-server): answer HTML completion, hover, folding, and tag close" ``` --- ### Task 3: Merge HTML into server completion and hover **Files:** - Modify: `packages/language-server/src/server.ts` - Modify: `packages/language-server/test/language-server.test.ts` **Interfaces:** - Consumes: `htmlCompletions`, `htmlHover` from Task 2; `isInsideHtml` from Task 1. - Produces: `mergeCompletions(wrnexus: Array<{ label: string }>, html: Array<{ label: string }>)` exported from `packages/language-server/src/html-service.ts` for direct testing. - [ ] **Step 1: Write the failing test** Append to `packages/language-server/test/html-service.test.ts`: ```ts import { mergeCompletions } from "../src/html-service.ts"; test("merging ranks WRNexus entries above HTML and drops exact collisions", () => { const merged = mergeCompletions( [ { label: "Card", kind: 7 }, { label: "table", kind: 7 }, ], [ { label: "div", kind: 10, sortText: "1div" }, { label: "table", kind: 10, sortText: "1table" }, ], ); const labels = merged.map((item) => item.label); expect(labels.filter((label) => label === "table")).toHaveLength(1); expect(merged.find((item) => item.label === "Card")?.sortText?.startsWith("0")).toBe(true); expect(merged.find((item) => item.label === "div")?.sortText?.startsWith("1")).toBe(true); const sorted = [...merged].sort((a, b) => (a.sortText ?? "").localeCompare(b.sortText ?? "")); expect(sorted[0]!.label).toBe("Card"); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `bun test packages/language-server/test/html-service.test.ts` Expected: FAIL — `mergeCompletions` is not exported - [ ] **Step 3: Implement the merge** Append to `packages/language-server/src/html-service.ts`: ```ts /** * One completion list from both sources. * * WRNexus entries take the `0` sortText prefix so they rank above HTML without * either list being filtered. An exact label collision resolves to the * WRNexus entry: a component named `Table` is what the author meant. */ export function mergeCompletions( wrnexus: T[], html: T[], ): T[] { const taken = new Set(wrnexus.map((item) => item.label)); return [ ...wrnexus.map((item) => ({ ...item, sortText: `0${item.sortText ?? item.label}` })), ...html.filter((item) => !taken.has(item.label)), ]; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `bun test packages/language-server/test/html-service.test.ts` Expected: PASS - [ ] **Step 5: Wire completion and hover into the server** In `packages/language-server/src/server.ts`, add to the imports: ```ts import { htmlCompletions, htmlHover, mergeCompletions } from "./html-service.ts"; ``` Replace the `textDocument/completion` case (currently at line 198) with: ```ts case "textDocument/completion": { const document = documents.get(params.textDocument.uri); const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]; const html = document ? htmlCompletions(document, params.position) : []; result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus); break; } ``` Replace the body of the `textDocument/hover` case with: ```ts case "textDocument/hover": { const document = documents.get(params.textDocument.uri); const html = document ? htmlHover(document, params.position) : null; result(message.id, html ?? (document ? hover(document, params.position) : null)); break; } ``` - [ ] **Step 6: Extend the advertised trigger characters** In the `initialize` capabilities, replace the completion provider line with: ```ts completionProvider: { triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"], }, ``` - [ ] **Step 7: Verify nothing regressed** Run: `bun test packages/language-server` Expected: PASS, no failures - [ ] **Step 8: Rebuild the editor bundle and commit** ```bash bun run --cwd editors/vscode build git add packages/language-server/src/server.ts packages/language-server/src/html-service.ts packages/language-server/test/html-service.test.ts editors/vscode/src git commit -m "feat(language-server): merge HTML completions and hover into one response" ``` --- ### Task 4: Folding and linked editing **Files:** - Modify: `packages/language-server/src/server.ts` - Modify: `packages/language-server/src/html-service.ts` - Modify: `packages/language-server/test/html-service.test.ts` **Interfaces:** - Consumes: `htmlFoldingRanges` from Task 2. - Produces: `htmlLinkedEditingRanges(document: TextDocument, position: Position): Array<{ start: Position; end: Position }> | null` - [ ] **Step 1: Write the failing test** Append to `packages/language-server/test/html-service.test.ts`: ```ts import { htmlLinkedEditingRanges } from "../src/html-service.ts"; test("linked editing returns both the opening and closing tag names", () => { const text = `page A { view {
x
} } `; const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, " { const text = `page A { functions { function go() { } } } `; expect(htmlLinkedEditingRanges(doc(text), positionOf(text, "func"))).toBeNull(); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `bun test packages/language-server/test/html-service.test.ts` Expected: FAIL — `htmlLinkedEditingRanges` is not exported - [ ] **Step 3: Implement linked editing** Append to `packages/language-server/src/html-service.ts`: ```ts /** Ranges of the opening and closing tag names, so renaming one renames both. */ export function htmlLinkedEditingRanges( document: TextDocument, position: Position, ): Array<{ start: Position; end: Position }> | null { if (!isInsideHtml(document, offsetAt(document.text, position))) return null; const virtual = htmlDocument(document); const ranges = service.findLinkedEditingRanges( virtual, position, service.parseHTMLDocument(virtual), ); return ranges && ranges.length ? ranges : null; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `bun test packages/language-server/test/html-service.test.ts` Expected: PASS - [ ] **Step 5: Wire both into the server** In `packages/language-server/src/server.ts`, extend the import from `./html-service.ts` to include `htmlFoldingRanges` and `htmlLinkedEditingRanges`. Add to the `initialize` capabilities: ```ts foldingRangeProvider: true, linkedEditingRangeProvider: true, ``` Add these cases to the request switch, beside the existing `textDocument/hover` case: ```ts case "textDocument/foldingRange": { const document = documents.get(params.textDocument.uri); result(message.id, document ? htmlFoldingRanges(document) : []); break; } case "textDocument/linkedEditingRange": { const document = documents.get(params.textDocument.uri); const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null; result(message.id, ranges ? { ranges } : null); break; } ``` - [ ] **Step 6: Verify and commit** Run: `bun test packages/language-server` Expected: PASS ```bash bun run --cwd editors/vscode build git add packages/language-server/src editors/vscode/src git commit -m "feat(language-server): add tag folding and linked editing" ``` --- ### Task 5: Auto-close on type in the extension The only client-side piece: LSP has no request for closing a tag as it is typed. **Files:** - Modify: `packages/language-server/src/server.ts` - Modify: `editors/vscode/src/extension.js` - Modify: `editors/vscode/package.json` **Interfaces:** - Consumes: `htmlTagComplete` from Task 2. - Produces: LSP request `wrn/tagComplete`, params `{ textDocument: { uri }, position }`, returning a snippet string or `null`. - [ ] **Step 1: Add the server handler** In `packages/language-server/src/server.ts`, extend the `./html-service.ts` import to include `htmlTagComplete`, then add this case to the request switch: ```ts case "wrn/tagComplete": { const document = documents.get(params.textDocument.uri); result(message.id, document ? htmlTagComplete(document, params.position) : null); break; } ``` - [ ] **Step 2: Add the setting to the manifest** In `editors/vscode/package.json`, add to `contributes.configuration.properties`: ```json "wrnexus.html.autoClosingTags": { "type": "boolean", "default": true, "description": "Automatically close HTML tags inside .wrn view blocks." } ``` Add `vscode-html-languageservice` to `dependencies`, and add to `contributes.configurationDefaults`: ```json "emmet.includeLanguages": { "wrn": "html" } ``` - [ ] **Step 3: Add the client listener** In `editors/vscode/src/extension.js`, after the language client starts, register: ```js /** * Auto-close tags as they are typed. * * LSP has no request for this, so the client watches document changes and asks * the server whether the tag should close. The server owns the decision because * void elements and already-closed tags must not be closed. */ function registerAutoCloseTags(context, client) { const listener = vscode.workspace.onDidChangeTextDocument(async (event) => { if (event.document.languageId !== "wrn") return; if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return; const change = event.contentChanges[0]; if (!change || (change.text !== ">" && change.text !== "/")) return; const editor = vscode.window.activeTextEditor; if (!editor || editor.document !== event.document) return; const position = change.range.start.translate(0, change.text.length); const snippet = await client.sendRequest("wrn/tagComplete", { textDocument: { uri: event.document.uri.toString() }, position: { line: position.line, character: position.character }, }); if (typeof snippet !== "string" || !snippet) return; await editor.insertSnippet(new vscode.SnippetString(snippet), position); }); context.subscriptions.push(listener); } ``` Call `registerAutoCloseTags(context, client);` after the client is created, and extend the existing export line to . - [ ] **Step 4: Verify the bundle still starts under Node** Run: `bun run --cwd editors/vscode build && bun run check:editor-language-server` Expected: "Verified ... starts under Node." This is the check that catches a missing or unresolvable `vscode-html-languageservice`. - [ ] **Step 5: Commit** ```bash git add packages/language-server/src/server.ts editors/vscode/src editors/vscode/package.json git commit -m "feat(vscode): close HTML tags as they are typed in .wrn files" ``` --- ### Task 6: Stand down the duplicate client completion provider The extension and the server both answer completion on `<` today, so VS Code concatenates two lists. This makes the server the single owner inside view blocks. **Files:** - Modify: `editors/vscode/src/completion.js` - Create: `editors/vscode/test/completion-scope.test.js` **Interfaces:** - Consumes: nothing from earlier tasks — the check is a local text scan so the extension does not need the server for it. - Produces: `isInsideViewBlock(text: string, offset: number): boolean` exported from `editors/vscode/src/completion.js`. - [ ] **Step 1: Write the failing test** Create `editors/vscode/test/completion-scope.test.js`: ```js const test = require("node:test"); const assert = require("node:assert"); const { isInsideViewBlock } = require("../src/completion.js"); const PAGE = `page Home { view {
hello
} functions { function go() {} } } `; test("a markup offset is inside a view block", () => { assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf(" { assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `node --test editors/vscode/test/completion-scope.test.js` Expected: FAIL — `isInsideViewBlock` is not a function - [ ] **Step 3: Implement the guard** In `editors/vscode/src/completion.js`, add: ```js /** * Whether an offset sits inside a `view { }` block. * * The language server owns completion there and returns a merged list, so this * provider stands down to avoid VS Code concatenating two independent lists. * Quotes are only tracked inside a tag: `

it's

` would otherwise open a * string that never closes. */ function isInsideViewBlock(text, offset) { const pattern = /\bview\s*\{/g; let match; while ((match = pattern.exec(text))) { const start = match.index + match[0].length; let depth = 1; let inTag = false; let quote = null; let index = start; for (; index < text.length && depth > 0; index += 1) { const char = text[index]; if (quote) { if (char === quote) quote = null; continue; } if (inTag && (char === '"' || char === "'")) quote = char; else if (char === "<") inTag = true; else if (char === ">") inTag = false; else if (char === "{") depth += 1; else if (char === "}") depth -= 1; } if (offset >= start && offset <= index) return true; pattern.lastIndex = index; } return false; } ``` Add an early return at the top of `provideCompletionItems`: ```js if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return []; ``` Add `isInsideViewBlock` to the file's `module.exports`. - [ ] **Step 4: Run the test to verify it passes** Run: `node --test editors/vscode/test/completion-scope.test.js` Expected: PASS (2 tests) - [ ] **Step 5: Commit** ```bash git add editors/vscode/src/completion.js editors/vscode/test/completion-scope.test.js git commit -m "fix(vscode): stop duplicating completions inside view blocks" ``` --- ### Task 7: Manifest guards and the full gate **Files:** - Modify: `editors/vscode/test/validate.mjs` **Interfaces:** - Consumes: the manifest changes from Task 5. - Produces: nothing consumed downstream. - [ ] **Step 1: Add the manifest assertions** `editors/vscode/test/validate.mjs` is the file that already checks the manifest (marketplace icon, first-line detection, and so on). It uses `ok(name)` / `bad(name, detail)` helpers and counts `failures`, rather than a test framework. Match that style. The file already loads the manifest for the marketplace checks. Append, beside the existing manifest assertions: ```js const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"]; emmetLanguages?.wrn === "html" ? ok("Emmet is mapped for wrn documents") : bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`); const autoClose = manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"]; autoClose?.type === "boolean" && autoClose?.default === true ? ok("auto-closing tags setting is contributed") : bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`); manifest.dependencies?.["vscode-html-languageservice"] ? ok("HTML language service ships as a runtime dependency") : bad("HTML language service ships as a runtime dependency"); ``` If the manifest is loaded under a different variable name in that file, use the existing name. - [ ] **Step 2: Run the validator to verify it passes** Run: `bun run --cwd editors/vscode validate` Expected: the three new lines print `ok`, and the run reports no failures If any print `FAIL`, the corresponding manifest edit in Task 5 was missed; fix the manifest rather than the assertion. - [ ] **Step 3: Run the full production gate** Run: `bun run check:production` Expected: PASS If `check:public-api` fails, the new exports are intentional: review the diff is additive only, then run `bun run generate:public-api`. - [ ] **Step 4: Commit** ```bash git add editors/vscode/test/validate.mjs docs/public-api-0.8.json git commit -m "test(vscode): guard the Emmet mapping and auto-close setting" ``` --- ### Task 8: Verify in a real editor Every other task is unit-tested. This one confirms the features actually appear in VS Code, because a green suite has not proved that in this codebase before. **Files:** - Create: `examples/basic-app/app/pages/html-editing-check.wrn` **Interfaces:** - Consumes: everything above. - Produces: nothing consumed downstream. - [ ] **Step 1: Create a scratch page to type into** Create `examples/basic-app/app/pages/html-editing-check.wrn`: ``` page HtmlEditingCheck { seo { title = "HTML editing check" description = "Scratch page for verifying editor support inside view blocks." canonical = "/html-editing-check" } view {

Editor check

} } ``` - [ ] **Step 2: Launch the extension host** Run: `bun run --cwd editors/vscode build` Then open the repository in VS Code and press F5 to start the Extension Development Host, or install the built extension. - [ ] **Step 3: Verify each feature by hand** Open `examples/basic-app/app/pages/html-editing-check.wrn` in the host window and confirm, inside the `view { }` block: 1. Typing `<` suggests HTML tags, with any WRNexus components listed above them. 2. Typing `
` inserts `
` automatically. 3. Typing `
` does **not** insert a closing tag. 4. Renaming `
` to `
` renames the closing tag with it. 5. Hovering `

` shows documentation. 6. Typing `ul>li*3` and pressing Tab expands via Emmet. 7. The `
` element can be folded from the gutter. Then confirm, inside the `seo { }` block, that typing `<` does **not** offer HTML tags. - [ ] **Step 4: Remove the scratch page and commit** ```bash rm examples/basic-app/app/pages/html-editing-check.wrn git add -A examples/basic-app git commit -m "chore: remove HTML editing verification page" ``` If any check in Step 3 failed, stop and fix it before this commit rather than recording a pass that did not happen. --- ## Deferred - HTML formatting — `formatWrn` owns markup formatting; improving it is separate work. - Moving the remaining `completion.js` component intelligence into the server. This plan only requires it to stand down inside view blocks.