diff --git a/docs/superpowers/specs/2026-08-18-wrn-html-editing-design.md b/docs/superpowers/specs/2026-08-18-wrn-html-editing-design.md new file mode 100644 index 00000000..438f23c0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-wrn-html-editing-design.md @@ -0,0 +1,252 @@ +# 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 `.wrn` syntax 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.** `

it's fine

` — 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 `
` and having `
` 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: + +1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents. +2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`. +3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a + snippet or `null`. +4. 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 (`
`, ``, +``) 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: `` closes to `` because the HTML service closes +unknown tags like any other, and `` 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 `` 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}` and `client:visible` are 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 `require`s 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**: `

it's fine

` 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**: `