# 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 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.