Markup in a .wrn file highlights but has no tag or attribute completion,
no tag closing, and no tag-level folding: the grammar's embeddedLanguages
mapping only affects tokenization, and VS Code's HTML language service
never runs on these documents.
The design extracts view blocks into a virtual HTML document where
everything outside them is blanked to whitespace of identical length, so
source positions and virtual positions are the same and no mapping table
is needed. Region detection is a tolerant scanner rather than the parser,
because completion fires while the document is mid-edit and unparseable.
Completion merges WRNexus and HTML entries into one list ranked by
sortText, which also fixes an existing bug: the extension and the server
both answer completion on '<' today, so VS Code concatenates two lists.
Two decisions worth review:
- HTML formatting is excluded. formatWrn already formats markup, knows
WRNexus syntax, and would fight a second formatter that is free to
rewrite spacing inside @click={...} and client:visible.
- Only auto-close-on-type is client-side. Linked editing is standard LSP
and lives in the shared server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
HTML editing support for .wrn files — Design
Date: 2026-08-18
Status: Approved for implementation
Scope: HTML autocomplete, tag closing, hover, Emmet, and folding inside view { } blocks.
Goal
Writing markup in a .wrn file should feel like writing HTML. Today it does not: there is
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
tag-level folding.
The grammar already declares embeddedLanguages (meta.embedded.block.html → html), which is
why markup highlights. That mapping only affects tokenization — VS Code's HTML language
service does not run on .wrn documents, so none of the editing behaviour follows from it.
Non-goals
- HTML formatting. See "Formatting is deliberately excluded" below.
- Editor support outside VS Code beyond what standard LSP gives for free.
- Changing
.wrnsyntax or the compiler.
Decisions
| Question | Decision |
|---|---|
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
| Region detection | Tolerant scanner over a virtual document, not the AST |
| HTML knowledge | vscode-html-languageservice |
| Formatting | Excluded — formatWrn already owns markup formatting |
Architecture
Virtual HTML document
New module: packages/language-server/src/html-regions.ts, exporting
virtualHtmlDocument(document).
Everything outside a view { } block is replaced by whitespace of identical length, with
newlines preserved. The virtual document therefore has the same size and the same line/column
geometry as the source, so a position in the source is the position in the virtual document.
No mapping table and no translation layer.
This is deliberately not the same shape as the existing virtualTypeScriptDocument, which
compacts code and carries line mappings back to source. Compaction is necessary there because
the output must be valid TypeScript. HTML has no such requirement, so the simpler
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
does not arise.
The load-bearing invariant: virtualHtmlDocument(doc).text.length === doc.text.length, with
newlines at identical offsets. If this breaks, every feature reports positions off by some
amount rather than failing loudly.
Region detection
Region detection is a tolerant scanner, not the @wrnexus/syntax parser. Completion fires
while the document is being typed, which is exactly when it does not parse. The scanner finds
view followed by { and tracks brace depth to the matching close.
Two hazards it must handle, both of which defeat a naive implementation:
- Apostrophes in text content.
<p>it's fine</p>— a scanner treating'as a string delimiter anywhere will consider the rest of the file one open string and lose every later region. Quotes are tracked only inside attribute values, never in text nodes. - Nested braces from interpolation.
class={cond ? "a" : "b"}and{{ a: 1 }}nest, so depth must be counted rather than scanning for the next}.
WRNexus-specific syntax (@click, client:visible, {expr}) is not blanked. The HTML
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
Caching is keyed on document URI and version, so a burst of requests from one keystroke costs a single scan.
Completion
The server becomes the single authority inside view blocks
textDocument/completion gains a context check: a position is "in HTML" exactly when the
virtual document is non-blank there, which costs one character lookup.
Inside a view block, one list is assembled from two sources:
| Source | sortText prefix |
Content |
|---|---|---|
| WRNexus | 0 |
Components, their props/outputs/slots, directives (@click, client:*) |
| HTML | 1 |
Tags, attributes, attribute values |
sortText drives ordering independently of the label, so components rank above HTML tags
without filtering anything out. Outside a view block, behaviour is unchanged: WRN keywords
plus workspace items.
The server already indexes components, props, outputs, and slots
(buildWorkspaceCompletionItems in packages/language-server/src/workspace.ts), so both halves
of the merge are already available to it.
Deduplication on exact label match, WRNexus wins. A component named Table and the HTML
table differ in case and both survive; a component that genuinely shadows an HTML tag name
resolves to the component.
Trigger characters
The server currently declares ["<", "@", ":", "."]. Attributes and values additionally need
" ", "=", "\"", and "/".
This fixes an existing bug
The extension's completion.js registers its own provider with < among its trigger
characters, and the language server answers textDocument/completion as well. VS Code
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
involved at all.
As part of this work the extension's provider returns nothing when the position is inside a view block, and keeps its current behaviour elsewhere. One owner per context.
Consequence to accept knowingly: the server becomes authoritative for the richest completion
context, so future component-intelligence work belongs in the server rather than in
completion.js.
Hover
textDocument/hover answers from the HTML service over the virtual document when the position
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
region, existing hover behaviour is unchanged.
Where a position resolves to a WRNexus component or prop, the component's own detail wins over any HTML entry of the same name, matching the completion precedence rule above.
Tag handling
Linked editing is standard LSP
Renaming <div> and having </div> follow is textDocument/linkedEditingRange (LSP 3.16), so
it lives in the shared server like everything else.
Auto-close on type is the one client-side piece
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it client-side, and this follows the same shape:
- The extension subscribes to
onDidChangeTextDocument, filtered towrndocuments. - When the typed character is
>or/, it sends a custom request,wrn/tagComplete. - The server runs the HTML service's
doTagCompleteagainst the virtual document and returns a snippet ornull. - The client inserts it with
insertSnippet, so the cursor lands between the tags.
The decision stays server-side because it needs parse knowledge: void elements (<br>, <img>,
<input>) must not be closed, and an already-closed tag must not be closed twice. Returning
null outside a view region is what stops it firing inside functions { } or style { }.
Component tags come along for free: <Card> closes to </Card> because the HTML service closes
unknown tags like any other, and <Card / completes to <Card /> through the same / path.
New setting: wrnexus.html.autoClosingTags, default true, following the existing
wrnexus.* naming.
Emmet
A manifest change: emmet.includeLanguages: { "wrn": "html" } in contributes.configurationDefaults.
Known limitation: emmet.includeLanguages is per-language, not per-region, so Emmet is also
live inside functions { } and style { } blocks. VS Code offers no way to scope it to a
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
edge is real.
Folding
textDocument/foldingRange in the server returns tag-level ranges from the HTML service over
the virtual document, filtered to view regions.
Today folding comes only from language-configuration.json markers, which work at block level
(page, component, view, braces). Markup does not fold, so a long <table> cannot be
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
to work unchanged and tag folding appears inside markup.
One rule: return ranges only where the virtual document is non-blank. A range spanning outside a view region would let a fold swallow a brace boundary.
Formatting is deliberately excluded
formatWrn (packages/syntax/src/formatter.ts) is 927 lines, iterates to a fixed point with
cycle detection, and already handles tags, attribute wrapping, multilineAttributes, and
printWidth. It is a markup formatter that understands WRNexus syntax.
Adding HTML formatting would do two harmful things:
- Two formatters would fight. Output would depend on which ran last.
- It would mangle syntax it does not model.
@click={handler}andclient:visibleare not HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
If markup formatting is unsatisfying, the fix is improving formatWrn. That is separate work.
Dependencies
vscode-html-languageservice becomes a dependency of both packages/language-server and
editors/vscode.
The editor bundler (scripts/build-editor-language-server.mjs) bundles only workspace sources
and passes other requires through to Node, so the package must be resolvable at runtime from
the extension. editors/vscode currently ships exactly one runtime dependency
(vscode-languageclient); this adds the second.
check:editor-language-server already verifies the bundled .cjs starts under Node, so a
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
Testing
Region scanner (packages/language-server/test/)
- The invariant, property-style across fixtures: virtual text length equals source length and newlines sit at identical offsets.
- Apostrophes in text:
<p>it's fine</p>followed by a second view block — both regions found. - Nested interpolation:
class={cond ? "a" : "b"}and{{ a: 1 }}do not end the region. - Broken markup:
<div class="mid-typing still yields a region. This is the normal case for completion, not an edge case. - Multiple view blocks, and files with none.
Completion
- Inside a view block: both sources present, WRNexus
sortTextordering first. - Outside a view block: response identical to current behaviour — the guard proving non-markup contexts are undisturbed.
- Collision: a component named
Tableyields one entry, the component.
Tag handling
<div>→</div>;<br>→ nothing;<Card /→/>; outside a view region →null.- Linked editing returns ranges covering both the opening and closing tag names.
Hover
- Inside a view region, a known tag returns HTML documentation.
- A component name returns the component detail, not an HTML entry of the same name.
Folding
- Every returned range lies inside a view region.
- Block-level marker folding still works.
Toolchain guards
check:editor-language-serverpasses with the new dependency (bundle starts under Node).- Manifest assertion that
emmet.includeLanguagesmapswrn→html, alongside the existing marketplace checks ineditors/vscode/test.
Deferred
- HTML formatting — see above; improve
formatWrninstead. - Moving the remaining
completion.jscomponent intelligence into the server. This design only requires it to stand down inside view blocks; relocating the rest is follow-up work.