diff --git a/docs/superpowers/plans/2026-08-18-wrn-html-editing.md b/docs/superpowers/plans/2026-08-18-wrn-html-editing.md new file mode 100644 index 00000000..b29f2250 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-wrn-html-editing.md @@ -0,0 +1,1178 @@ +# 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.