# 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 {
    <div class="card">hello</div>
  }
}
`;

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('<div class="card">hello</div>');
  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 {
    <p>it's fine</p>
  }
}
component B {
  view {
    <span>second</span>
  }
}
`;
  expect(viewRegions(source)).toHaveLength(2);
  expect(virtualHtmlDocument(doc(source)).text).toContain("<span>second</span>");
});

test("interpolation braces nest without ending the region early", () => {
  const source = `page A {
  view {
    <div class={cond ? "a" : "b"} data-x={{ a: 1 }}>after</div>
  }
}
`;
  const regions = viewRegions(source);
  expect(regions).toHaveLength(1);
  expect(virtualHtmlDocument(doc(source)).text).toContain("after</div>");
});

test("unparseable mid-edit markup still yields a region", () => {
  // Completion fires exactly when the document does not parse.
  const source = `page A {
  view {
    <div class="
  }
}
`;
  expect(viewRegions(source).length).toBe(1);
});

test("a file with no view block yields no regions and a fully blank document", () => {
  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("<div"))).toBe(true);

  // Same version, mutated text: the cached regions are reused, proving the
  // scan did not run again.
  first.text = "page A { }";
  expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);

  first.version = 2;
  expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(false);
});

test("isInsideHtml distinguishes markup from surrounding code", () => {
  const source = doc(PAGE);
  const markupOffset = PAGE.indexOf("<div");
  const keywordOffset = PAGE.indexOf("page");

  expect(isInsideHtml(source, markupOffset)).toBe(true);
  expect(isInsideHtml(source, keywordOffset)).toBe(false);
});
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `bun test packages/language-server/test/html-regions.test.ts`
Expected: FAIL — cannot resolve `../src/html-regions.ts`

- [ ] **Step 3: Write the implementation**

Create `packages/language-server/src/html-regions.ts`:

```ts
import type { TextDocument } from "./index.ts";

export interface HtmlRegion {
  start: number;
  end: number;
}

/**
 * Byte ranges of the markup inside each `view { }` block.
 *
 * This is a tolerant scanner rather than the parser: completion fires while
 * the document is being typed, which is exactly when it does not parse.
 */
export function viewRegions(text: string): HtmlRegion[] {
  const regions: HtmlRegion[] = [];
  const pattern = /\bview\s*\{/g;
  let match: RegExpExecArray | null;

  while ((match = pattern.exec(text))) {
    const bodyStart = match.index + match[0].length;
    const end = matchingBrace(text, bodyStart);
    regions.push({ start: bodyStart, end });
    pattern.lastIndex = end;
  }
  return regions;
}

/**
 * Offset of the brace closing the block that starts at `from`, or the end of
 * the text when it is never closed (an unterminated block is normal mid-edit).
 *
 * Quotes are only tracked inside a tag, never in text content: `<p>it's</p>`
 * 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<boolean>(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<string, { version: number; regions: HtmlRegion[] }>();

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 {
    <input 
  }
}
`;
  const items = htmlCompletions(doc(text), positionOf(text, "<input "));
  expect(items.some((item) => 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 {
    <div>x</div>
  }
}
`;
  expect(htmlHover(doc(text), positionOf(text, "<di"))).not.toBeNull();

  const code = `page A {
  functions {
    function go() { }
  }
}
`;
  expect(htmlHover(doc(code), positionOf(code, "func"))).toBeNull();
});

test("closes an open tag and leaves void elements alone", () => {
  const open = `page A {
  view {
    <div>
  }
}
`;
  expect(htmlTagComplete(doc(open), positionOf(open, "<div>"))).toContain("</div>");

  const void_ = `page A {
  view {
    <br>
  }
}
`;
  expect(htmlTagComplete(doc(void_), positionOf(void_, "<br>"))).toBeNull();
});

test("completes a self-closing component tag", () => {
  const text = `page A {
  view {
    <Card /
  }
}
`;
  expect(htmlTagComplete(doc(text), positionOf(text, "<Card /"))).toBe(">");
});

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 {
    <ul>
      <li>one</li>
    </ul>
  }
}
`;
  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<T extends { label: string; sortText?: string }>(
  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 {
    <div>x</div>
  }
}
`;
  const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, "<di"));
  expect(ranges).not.toBeNull();
  expect(ranges).toHaveLength(2);
});

test("linked editing returns null outside a view block", () => {
  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 {
    <div>hello</div>
  }
  functions {
    function go() {}
  }
}
`;

test("a markup offset is inside a view block", () => {
  assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
});

test("a functions-block offset is not inside a view block", () => {
  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: `<p>it's</p>` 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 {
    <main>
      <h1>Editor check</h1>
    </main>
  }
}
```

- [ ] **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 `<section>` inserts `</section>` automatically.
3. Typing `<br>` does **not** insert a closing tag.
4. Renaming `<main>` to `<article>` renames the closing tag with it.
5. Hovering `<h1>` shows documentation.
6. Typing `ul>li*3` and pressing Tab expands via Emmet.
7. The `<main>` 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.
