diff --git a/packages/language-server/src/html-regions.ts b/packages/language-server/src/html-regions.ts
new file mode 100644
index 00000000..076f8c91
--- /dev/null
+++ b/packages/language-server/src/html-regions.ts
@@ -0,0 +1,116 @@
+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: `
it's
`
+ * would otherwise open a string that never closes and swallow the rest of the
+ * file.
+ */
+function matchingBrace(text: string, from: number): number {
+ let depth = 1;
+ let inTag = false;
+ let quote: string | null = null;
+
+ for (let index = from; index < text.length; index += 1) {
+ const char = text[index]!;
+
+ if (quote) {
+ if (char === quote) quote = null;
+ continue;
+ }
+ if (inTag && (char === '"' || char === "'")) {
+ quote = char;
+ continue;
+ }
+ if (char === "<") inTag = true;
+ else if (char === ">") inTag = false;
+ else if (char === "{") depth += 1;
+ else if (char === "}") {
+ depth -= 1;
+ if (depth === 0) return index;
+ }
+ }
+ return text.length;
+}
+
+/**
+ * A parallel document containing only the markup.
+ *
+ * Everything outside a view block becomes whitespace of the same length, and
+ * newlines are preserved, so an offset in the source is the same offset here.
+ * That removes the need for a mapping table entirely.
+ */
+export function virtualHtmlDocument(document: TextDocument): {
+ uri: string;
+ languageId: "html";
+ text: string;
+} {
+ const source = document.text;
+ const keep = new Array(source.length).fill(false);
+ for (const region of viewRegions(source)) {
+ for (let index = region.start; index < region.end; index += 1) keep[index] = true;
+ }
+
+ let text = "";
+ for (let index = 0; index < source.length; index += 1) {
+ const char = source[index]!;
+ text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " ";
+ }
+
+ return { uri: `${document.uri}.html`, languageId: "html", text };
+}
+
+export function isInsideHtml(document: TextDocument, offset: number): boolean {
+ return regionsFor(document).some((region) => offset >= region.start && offset <= region.end);
+}
+
+/**
+ * Regions for a document, cached by uri and version.
+ *
+ * A single keystroke produces a burst of completion, hover, and tag-close
+ * requests; without this each one rescans the file.
+ */
+const regionCache = new Map();
+
+function regionsFor(document: TextDocument): HtmlRegion[] {
+ const version = document.version ?? -1;
+ const cached = regionCache.get(document.uri);
+ if (cached && cached.version === version) return cached.regions;
+
+ const regions = viewRegions(document.text);
+ regionCache.set(document.uri, { version, regions });
+ return regions;
+}
+
+/** Drops a document's cached regions. Call when a document closes. */
+export function clearHtmlRegionCache(uri?: string): void {
+ if (uri) regionCache.delete(uri);
+ else regionCache.clear();
+}
diff --git a/packages/language-server/test/html-regions.test.ts b/packages/language-server/test/html-regions.test.ts
new file mode 100644
index 00000000..bf13dd24
--- /dev/null
+++ b/packages/language-server/test/html-regions.test.ts
@@ -0,0 +1,109 @@
+import { expect, test } from "bun:test";
+import { isInsideHtml, viewRegions, virtualHtmlDocument } from "../src/html-regions.ts";
+
+function doc(text: string): { uri: string; text: string; version?: number } {
+ return { uri: `file:///${Math.random()}.wrn`, text };
+}
+
+const PAGE = `page Home {
+ view {
+ hello
+ }
+}
+`;
+
+test("the virtual document preserves length and newline offsets", () => {
+ // This is what makes position mapping unnecessary. If it breaks, every
+ // feature reports positions off by some amount instead of failing loudly.
+ const source = doc(PAGE);
+ const virtual = virtualHtmlDocument(source);
+
+ expect(virtual.text.length).toBe(source.text.length);
+ expect(virtual.languageId).toBe("html");
+ for (let i = 0; i < source.text.length; i += 1) {
+ if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n");
+ }
+});
+
+test("markup survives into the virtual document and everything else is blanked", () => {
+ const virtual = virtualHtmlDocument(doc(PAGE));
+ expect(virtual.text).toContain('hello
');
+ expect(virtual.text).not.toContain("page Home");
+ expect(virtual.text).not.toContain("view");
+});
+
+test("an apostrophe in text content does not swallow later regions", () => {
+ // A scanner treating ' as a string delimiter anywhere considers the rest of
+ // the file one open string and loses every later region.
+ const source = `page A {
+ view {
+ it's fine
+ }
+}
+component B {
+ view {
+ second
+ }
+}
+`;
+ expect(viewRegions(source)).toHaveLength(2);
+ expect(virtualHtmlDocument(doc(source)).text).toContain("second");
+});
+
+test("interpolation braces nest without ending the region early", () => {
+ const source = `page A {
+ view {
+ after
+ }
+}
+`;
+ const regions = viewRegions(source);
+ expect(regions).toHaveLength(1);
+ expect(virtualHtmlDocument(doc(source)).text).toContain("after");
+});
+
+test("unparseable mid-edit markup still yields a region", () => {
+ // Completion fires exactly when the document does not parse.
+ const source = `page A {
+ view {
+ {
+ const source = `page A {
+ functions {
+ function go() {}
+ }
+}
+`;
+ expect(viewRegions(source)).toEqual([]);
+ expect(virtualHtmlDocument(doc(source)).text.trim()).toBe("");
+});
+
+test("regions are cached per document version", () => {
+ // One keystroke fans out into completion, hover, and tag-close requests.
+ const first = doc(PAGE);
+ first.version = 1;
+ expect(isInsideHtml(first, PAGE.indexOf("
{
+ const source = doc(PAGE);
+ const markupOffset = PAGE.indexOf("