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 <noreply@anthropic.com>
38 KiB
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 useimport { 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/syntaxparser — 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
sortTextprefix0, HTML entries1. On exact label collision, the WRNexus entry wins and the HTML entry is dropped. - HTML formatting is out of scope.
formatWrnowns markup formatting. Do not register an HTML formatter. vscode-html-languageservicemust be a dependency of bothpackages/language-serverandeditors/vscode.- New setting name, exactly:
wrnexus.html.autoClosingTags, defaulttrue. - Custom LSP request name, exactly:
wrn/tagComplete. TextDocumentis{ uri: string; text: string; version?: number }frompackages/language-server/src/index.ts.- Commit after every task. Never use
--no-verify. - After any change to
packages/language-server/src/, runbun run --cwd editors/vscode buildand commit the regeneratededitors/vscode/src/language-server.cjs, orcheck:editor-language-serverfails.
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:
TextDocumentfrompackages/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): booleanclearHtmlRegionCache(uri?: string): void
-
Step 1: Write the failing tests
Create packages/language-server/test/html-regions.test.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:
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
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,isInsideHtmlfrom Task 1;Position,TextDocumentfrom./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 } | nullhtmlFoldingRanges(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:
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:
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
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,htmlHoverfrom Task 2;isInsideHtmlfrom Task 1. -
Produces:
mergeCompletions(wrnexus: Array<{ label: string }>, html: Array<{ label: string }>)exported frompackages/language-server/src/html-service.tsfor direct testing. -
Step 1: Write the failing test
Append to packages/language-server/test/html-service.test.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:
/**
* 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:
import { htmlCompletions, htmlHover, mergeCompletions } from "./html-service.ts";
Replace the textDocument/completion case (currently at line 198) with:
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:
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:
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
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:
htmlFoldingRangesfrom 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:
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:
/** 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:
foldingRangeProvider: true,
linkedEditingRangeProvider: true,
Add these cases to the request switch, beside the existing textDocument/hover case:
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
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:
htmlTagCompletefrom Task 2. -
Produces: LSP request
wrn/tagComplete, params{ textDocument: { uri }, position }, returning a snippet string ornull. -
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:
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:
"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:
"emmet.includeLanguages": {
"wrn": "html"
}
- Step 3: Add the client listener
In editors/vscode/src/extension.js, after the language client starts, register:
/**
* 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
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): booleanexported fromeditors/vscode/src/completion.js. -
Step 1: Write the failing test
Create editors/vscode/test/completion-scope.test.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:
/**
* 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:
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
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:
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
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:
- Typing
<suggests HTML tags, with any WRNexus components listed above them. - Typing
<section>inserts</section>automatically. - Typing
<br>does not insert a closing tag. - Renaming
<main>to<article>renames the closing tag with it. - Hovering
<h1>shows documentation. - Typing
ul>li*3and pressing Tab expands via Emmet. - 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
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 —
formatWrnowns markup formatting; improving it is separate work. - Moving the remaining
completion.jscomponent intelligence into the server. This plan only requires it to stand down inside view blocks.