From 7d481df652f6c8e5e6d4295ddbc957475e282f57 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 20:30:34 +0530 Subject: [PATCH] docs(html-editing): add implementation plan Eight TDD tasks: the view-block scanner and virtual document, the HTML service wrapper, merging HTML into completion and hover, folding and linked editing, auto-close on type, standing down the duplicate client provider, manifest guards, and a manual editor check. Task 1 comes first because everything reads positions through it: its length-and-newline invariant is what removes position mapping, and a break there would misreport positions everywhere rather than fail. The last task is manual verification in an Extension Development Host. Unit tests cannot show that completions actually appear in an editor, and a green suite has hidden non-functional features in this repo before. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-18-wrn-html-editing.md | 1178 +++++++++++++++++ 1 file changed, 1178 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-wrn-html-editing.md 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.