@cockernutx/language-plugin-brace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vue-brace contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @cockernutx/language-plugin-brace
2
+
3
+ Volar / Vue language tools plugin for `lang="brace"` templates.
4
+
5
+ Without it, Volar hands the brace source to the HTML parser: template expressions are never
6
+ *checked*, so `@if (…)` / `@for (…)` conditions are seen as plain text and the whole block
7
+ produces spurious errors and no hover.
8
+
9
+ ## Build before you use it
10
+
11
+ ```sh
12
+ deno task build:plugin # from the repo root
13
+ ```
14
+
15
+ **Volar loads a language plugin by resolving it and calling `require()` on the result**, in
16
+ `vue-tsc` and in the editor's language server — both Node processes. Serving
17
+ `src/index.ts` works only under Deno, which can `require()` TypeScript directly. In Node it
18
+ is a `SyntaxError`; Volar catches it and carries on **without the plugin**, so every brace
19
+ template loses hover, completions and diagnostics, and the only trace is a warning in the
20
+ Vue Language Server output channel:
21
+
22
+ ```
23
+ [Vue] Resolve plugin path failed: @cockernutx/language-plugin-brace SyntaxError: …
24
+ ```
25
+
26
+ This is the failure mode this package was written in, and it is worth knowing because it
27
+ looks like "the plugin does nothing" rather than "the plugin did not load". `index.cjs`
28
+ therefore loads `dist/index.js`, and `deno task test` rebuilds it; a test fails if `dist` is
29
+ missing or older than the source it came from.
30
+
31
+ **How to tell it loaded:** open a brace template and hover a variable in a `@try` block. With
32
+ the plugin loaded you get a type; without it, `any` (or nothing) — and Volar will be reporting
33
+ errors on lines of brace syntax that are perfectly valid. **Reload the window** after building:
34
+ the language server only loads plugins at startup.
35
+
36
+ ## Setup
37
+
38
+ ```jsonc
39
+ // tsconfig.app.json
40
+ {
41
+ "vueCompilerOptions": {
42
+ "plugins": ["@cockernutx/language-plugin-brace"]
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## How it works
48
+
49
+ Volar reaches a custom template language through three hooks. The plugin implements all
50
+ three:
51
+
52
+ 1. **`getEmbeddedCodes` / `resolveEmbeddedCode`** — publish the raw brace block as an
53
+ embedded document with `lang: 'brace'`. Volar's built-in `vue-sfc-template` plugin only
54
+ does this for `lang === 'html'`, so without these hooks a brace template has no virtual
55
+ file at all: no highlighting and no in-template features.
56
+ 2. **`compileSFCTemplate`** — compiles the generated template with language-core's
57
+ `compileTemplate` and supplies the AST that template type checking works from.
58
+ 3. **Offset remapping** — every location in that AST is rewritten back into brace-source
59
+ coordinates.
60
+
61
+ Step 3 is why `compileBrace` returns a map. Because the transform emits one line per input
62
+ line, a generated line belongs to exactly one source line, and within it every copied
63
+ fragment is recorded as a `{ gen, src, length }` segment. A generated position inside one of
64
+ those segments maps to the exact source character; anything else is synthesised markup
65
+ (`<template v-if="…">`) and falls back to the end of the nearest copied run, or to the end of
66
+ the source line when it has none.
67
+
68
+ Per-fragment mapping matters more than it sounds. One `@for` line becomes
69
+ `<template v-for="item in items" :key="item.id">`, so `item`, `items` and `item.id` all sit
70
+ at different offsets from their generated counterparts; a single shift per line gets only
71
+ the first of them right, and the rest — the ones outside an `@if` line, which is most of a
72
+ template — point at the wrong token, which is what makes hover and go-to-definition fail.
73
+
74
+ The embedded content pushed in step 1 is the *raw brace source*, which is the same
75
+ coordinate space the remapped AST uses. Keeping those two in step is the whole trick, and
76
+ it is why the two hooks cannot be implemented independently.
77
+
78
+ A malformed block is reported through `onError` rather than thrown, so a bad template
79
+ produces a diagnostic instead of a dead language server.
80
+
81
+ ### Why the AST must stay in brace coordinates
82
+
83
+ Volar does not only read offsets out of this AST — it also slices text:
84
+
85
+ - `options.template` (the brace source) is sliced using AST offsets, so offsets have to be
86
+ brace coordinates for expressions, props and children to map anywhere sensible.
87
+ - `parseVForNode` slices `node.loc.source` — the *generated* text — using offsets relative to
88
+ the node. That is the one place where the two coordinate spaces collide, and it is what used
89
+ to make `@for` with `index` / `key` report two TypeScript syntax errors; the alignment pass
90
+ described under Caveats is what reconciles them. Everything else is consistent because both
91
+ the offsets and the document they describe end up in brace coordinates.
92
+
93
+ ## Highlighting
94
+
95
+ The `lang: 'brace'` embedded document needs a grammar to be useful, which VS Code can only
96
+ get from an extension. `packages/vscode-vue-brace` registers that language id and a TextMate
97
+ grammar — install it as described in its README. Without it, brace templates render as
98
+ plain text.
99
+
100
+ ## Entry point
101
+
102
+ Volar resolves plugin names with `require()` and then calls the resolved module *itself* as
103
+ the factory (no `.default` unwrapping), so the entry has to be a callable CommonJS module
104
+ that **Node** can load. `index.cjs` re-exports the default from `dist/index.js`, which
105
+ `deno task build:plugin` produces. Pointing it at `src/index.ts` instead is the trap
106
+ described at the top of this file: Deno loads it happily and the editor never does.
107
+
108
+ ## Caveats
109
+
110
+ - The plugin API version is pinned to `2.2`. language-core's `validVersions` is
111
+ `[2, 2.1, 2.2]`; anything else makes it drop the plugin with only a console warning.
112
+ - **`@for` with `index` or `key` needs the element aligned to the author's column.** Volar
113
+ rebuilds the
114
+ `v-for` binding by slicing the generated `<template v-for="(item, i) in …">` text with
115
+ offsets *relative to the element*, so those deltas have to come out as the generated ones.
116
+ `collectVForPatterns` reads the binding names with generated deltas *before* the remap;
117
+ `alignVForPatterns` then anchors the element on the `@for` column the author wrote and
118
+ rebuilds `loc.source` as a padded pattern occupying the author's own columns. Before that,
119
+ the slice produced garbage: the errors above, plus garbage binding names whose semantic
120
+ tokens landed across the template. (An earlier attempt moved the element's start onto the
121
+ generated geometry instead. That repairs the slice and corrupts every other consumer of that
122
+ offset, which reads it as a *source* position — do not reintroduce it.)
123
+ - **`vue-tsc` needs a Node runtime.** It cannot run under Deno — Volar registers `.vue`
124
+ support by rewriting `tsc.js` through an `fs.readFileSync` hook that Deno bypasses — so a
125
+ Node binary has to be present to type-check templates. That is how the loader bug above was
126
+ found: `node …/vue-tsc.js --noEmit` reported `Resolve plugin path failed`, and
127
+ `--listFiles` showed no `.vue` files in the program at all.
128
+
@@ -0,0 +1,15 @@
1
+ import type { VueLanguagePlugin } from '@vue/language-core';
2
+ /**
3
+ * Volar plugin for `lang="brace"`.
4
+ *
5
+ * Volar only reaches a custom template language through `compileSFCTemplate`; without it
6
+ * the brace source is handed to the HTML parser, so template expressions are never
7
+ * type-checked. Wire it up with:
8
+ *
9
+ * ```jsonc
10
+ * // tsconfig.json
11
+ * { "vueCompilerOptions": { "plugins": ["@cockernutx/language-plugin-brace"] } }
12
+ * ```
13
+ */
14
+ declare const plugin: VueLanguagePlugin;
15
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const compile_1 = require("@cockernutx/brace-template/compile");
4
+ const mapper_1 = require("@cockernutx/brace-template/mapper");
5
+ const preprocessor_1 = require("@cockernutx/brace-template/preprocessor");
6
+ /**
7
+ * Apply `visit` to every object in the tree, once.
8
+ *
9
+ * Every *object*, not every AST node: `remapOffsets` has to reach the `{ start, end }`
10
+ * locations nested inside `loc`, which carry no `type` of their own, so the filter has to be
11
+ * the callback's business.
12
+ */
13
+ function walk(value, visit, seen = new Set()) {
14
+ if (!value || typeof value !== 'object' || seen.has(value))
15
+ return;
16
+ seen.add(value);
17
+ const record = value;
18
+ visit(record);
19
+ for (const key in record)
20
+ walk(record[key], visit, seen);
21
+ }
22
+ /**
23
+ * Rewrite `offset` on every node of the tree, in place.
24
+ *
25
+ * The compiler's AST carries offsets on expression nodes and locations; Volar derives lines
26
+ * and columns from them, so every one of them has to move into brace-source coordinates.
27
+ */
28
+ function remapOffsets(value, toSourceOffset) {
29
+ walk(value, (node) => {
30
+ if (typeof node.offset === 'number')
31
+ node.offset = toSourceOffset(node.offset);
32
+ });
33
+ }
34
+ /**
35
+ * Point each element's `loc.start` at its tag name rather than at the `<`.
36
+ *
37
+ * Volar's component-name tokens (`vue-component-semantic-tokens` in the language server) compute
38
+ * the range like this:
39
+ *
40
+ * ```js
41
+ * let start = element.loc.start.offset
42
+ * if (template.lang === 'html') start += 1 // skip the `<`
43
+ * push({ start, length: element.tag.length })
44
+ * ```
45
+ *
46
+ * So for `lang === 'html'` the AST is assumed to start an element at the `<` and the provider
47
+ * compensates; for any other lang it takes `loc.start` to be the *name*. Our brace AST follows
48
+ * the html convention, so the token came out covering `<FragileChil` — one character wide of the
49
+ * name, leaving the last character to the grammar's colour. Volar's own mappings were exact
50
+ * throughout, which is what made this look like a mapping bug on our side; it is a convention
51
+ * mismatch in a branch keyed on the template's `lang`.
52
+ *
53
+ * Shifting by one keeps every other consumer working: `getElementTagOffsets` searches with
54
+ * `indexOf(node.tag, node.loc.start.offset)`, and the name starts exactly at that position, so
55
+ * the offset it returns is unchanged.
56
+ */
57
+ function alignElementStartsToTagNames(ast) {
58
+ walk(ast, (node) => {
59
+ if (node.type !== 1 || typeof node.tag !== 'string' || !node.loc?.start)
60
+ return;
61
+ node.loc.start.offset += 1;
62
+ });
63
+ }
64
+ /**
65
+ * A `v-for`'s binding pattern, as Volar reads it.
66
+ *
67
+ * `codegen/template/vFor.js` (`parseVForNode`) rebuilds the pattern by slicing the element's
68
+ * own text between two offsets, relative to the element:
69
+ *
70
+ * ```js
71
+ * node.loc.source.slice(value.loc.start.offset - node.loc.start.offset,
72
+ * (index ?? key ?? value).loc.end.offset - node.loc.start.offset)
73
+ * ```
74
+ *
75
+ * It then emits `for (const [<slice>] of …)` and maps the slice 1:1 onto the source range
76
+ * `[value.loc.start, (index ?? key ?? value).loc.end)`. Two coordinate spaces meet here:
77
+ *
78
+ * - `loc.source` is the element's text as parsed from the *generated* markup, so it has to be
79
+ * read with **generated** offsets;
80
+ * - the offsets themselves are remapped to **source** coordinates, because the same `loc` drives
81
+ * hover and navigation for the author's file.
82
+ *
83
+ * Reading the names with remapped offsets silently returned `"(it` instead of `item`. And
84
+ * because the emitted pattern is only as long as the slice, the range it maps onto has to be
85
+ * the author's clause — otherwise the two bindings land on the wrong columns (`index` used to
86
+ * be mapped onto `of`, and the whole range was scaled down to seven characters).
87
+ */
88
+ function collectVForPatterns(ast) {
89
+ const found = [];
90
+ walk(ast, (node) => {
91
+ const parseResult = node.parseResult;
92
+ const { value, index, key } = parseResult ?? {};
93
+ if (!value?.loc || !node.loc)
94
+ return;
95
+ const last = index ?? key ?? value;
96
+ const pattern = node.loc.source;
97
+ if (typeof pattern !== 'string')
98
+ return;
99
+ const relative = (offset) => offset - node.loc.start.offset;
100
+ const names = [value, last].filter((name, i, all) => all.indexOf(name) === i);
101
+ found.push({
102
+ node,
103
+ value,
104
+ names: names.map((name) => [
105
+ name,
106
+ pattern.slice(relative(name.loc.start.offset), relative(name.loc.end.offset)),
107
+ ]),
108
+ });
109
+ });
110
+ return found;
111
+ }
112
+ /**
113
+ * Give the element a source position, and build the pattern text Volar will slice out of it.
114
+ *
115
+ * The element is synthesised (`@for (…) {` becomes `<template v-for="(item, i) in …">`), so it
116
+ * is anchored to the directive the author wrote. The names are then placed at the columns they
117
+ * occupy in the author's clause, and the pattern is padded to the clause's length: the slice is
118
+ * a valid binding pattern, and because it is exactly as long as the source range it maps onto,
119
+ * each binding lands on the name that was typed. The padding is whitespace inside `[…]`, which
120
+ * is legal and inert.
121
+ */
122
+ function alignVForPatterns(patterns, template) {
123
+ for (const { node, value, names } of patterns) {
124
+ const sourceLineStart = template.lastIndexOf('\n', node.loc.end.offset) + 1;
125
+ const indent = /^[ \t]*/.exec(template.slice(sourceLineStart))?.[0].length ?? 0;
126
+ node.loc.start.offset = sourceLineStart + indent;
127
+ const end = names.at(-1)[0].loc.end.offset - node.loc.start.offset;
128
+ if (end <= 0)
129
+ continue;
130
+ const chars = Array.from({ length: end }, () => ' ');
131
+ for (const [name, text] of names) {
132
+ const at = name.loc.start.offset - node.loc.start.offset;
133
+ if (at < 0)
134
+ continue;
135
+ for (let i = 0; i < text.length && at + i < end; i += 1)
136
+ chars[at + i] = text[i];
137
+ }
138
+ // Separate the names, so the slice parses as a two-element binding pattern.
139
+ if (names.length > 1) {
140
+ chars[value.loc.end.offset - node.loc.start.offset] = ',';
141
+ }
142
+ node.loc.source = chars.join('');
143
+ }
144
+ }
145
+ /**
146
+ * Volar plugin for `lang="brace"`.
147
+ *
148
+ * Volar only reaches a custom template language through `compileSFCTemplate`; without it
149
+ * the brace source is handed to the HTML parser, so template expressions are never
150
+ * type-checked. Wire it up with:
151
+ *
152
+ * ```jsonc
153
+ * // tsconfig.json
154
+ * { "vueCompilerOptions": { "plugins": ["@cockernutx/language-plugin-brace"] } }
155
+ * ```
156
+ */
157
+ const plugin = ({ modules }) => {
158
+ const { codeFeatures, compileTemplate } = modules['@vue/language-core'];
159
+ return {
160
+ name: '@cockernutx/language-plugin-brace',
161
+ // `validVersions` is [2, 2.1, 2.2]; anything else makes language-core drop the plugin
162
+ // with only a console warning.
163
+ version: 2.2,
164
+ /**
165
+ * language-core's built-in `vue-sfc-template` plugin only produces an embedded document
166
+ * when `lang === 'html'`, so without this a brace template has no virtual file in the
167
+ * editor at all: no highlighting, and no completions, hover, navigation or diagnostics
168
+ * inside it.
169
+ *
170
+ * The content pushed in `resolveEmbeddedCode` is the *raw brace source*, which is
171
+ * exactly the coordinate space the remapped AST uses — the two have to agree for any of
172
+ * this to line up, and keeping them in step is why `compileBraceWithMap` exists.
173
+ */
174
+ getEmbeddedCodes(_fileName, ir) {
175
+ const template = ir.template;
176
+ if (!template || template.lang !== preprocessor_1.BRACE_LANG)
177
+ return [];
178
+ return [{ id: 'template', lang: preprocessor_1.BRACE_LANG }];
179
+ },
180
+ resolveEmbeddedCode(_fileName, ir, embeddedFile) {
181
+ const template = ir.template;
182
+ if (embeddedFile.id !== 'template' || !template || template.lang !== preprocessor_1.BRACE_LANG)
183
+ return;
184
+ embeddedFile.content.push([
185
+ template.content,
186
+ template.name,
187
+ 0,
188
+ codeFeatures.full,
189
+ ]);
190
+ },
191
+ compileSFCTemplate(lang, template, options) {
192
+ if (lang !== preprocessor_1.BRACE_LANG)
193
+ return;
194
+ let compiled;
195
+ try {
196
+ compiled = (0, compile_1.compileBraceWithMap)(template);
197
+ }
198
+ catch (error) {
199
+ // A malformed block must surface as a diagnostic, never as a dead language server. The
200
+ // cast names the error type of *this* options object rather than one imported from
201
+ // `@vue/compiler-dom`, for the reason explained at `TemplateAst`.
202
+ options.onError?.({
203
+ name: 'BraceCompileError',
204
+ message: error instanceof Error ? error.message : String(error),
205
+ code: 0,
206
+ });
207
+ return { ast: compileTemplate('', options), code: '', preamble: '' };
208
+ }
209
+ const toSourceOffset = (0, mapper_1.createOffsetMapper)(template, compiled.code, compiled.lines);
210
+ /**
211
+ * Move a diagnostic's location into brace coordinates.
212
+ *
213
+ * Generic on purpose: the identity of Volar's warning and error types is Volar's business,
214
+ * not ours, and a signature naming them would reintroduce the coupling this file avoids.
215
+ */
216
+ const remap = (diagnostic) => {
217
+ if (diagnostic.loc) {
218
+ diagnostic.loc.start.offset = toSourceOffset(diagnostic.loc.start.offset);
219
+ diagnostic.loc.end.offset = toSourceOffset(diagnostic.loc.end.offset);
220
+ }
221
+ return diagnostic;
222
+ };
223
+ const ast = compileTemplate(compiled.code, {
224
+ ...options,
225
+ onWarn: (warning) => options.onWarn?.(remap(warning)),
226
+ onError: (error) => options.onError?.(remap(error)),
227
+ });
228
+ // The `v-for` pattern needs both coordinate spaces: the names are read from the generated
229
+ // markup *before* the remap, and placed into the author's columns *after* it.
230
+ const vForPatterns = collectVForPatterns(ast);
231
+ remapOffsets(ast, toSourceOffset);
232
+ // Volar's component-token provider reads an element's `loc.start` as its tag *name* for
233
+ // any template whose lang is not `html` (see `alignElementStartsToTagNames`), so that has
234
+ // to be true of the offsets we hand over — and after the remap, so the shift applies to a
235
+ // source offset.
236
+ alignElementStartsToTagNames(ast);
237
+ alignVForPatterns(vForPatterns, template);
238
+ return { ast, code: '', preamble: '' };
239
+ },
240
+ };
241
+ };
242
+ exports.default = plugin;
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
package/index.cjs ADDED
@@ -0,0 +1,11 @@
1
+ // Volar loads language plugins by resolving a specifier and calling `require()` on the
2
+ // result, with no `.default` unwrapping — see how `@vue/language-plugin-pug` ships as
3
+ // CommonJS.
4
+ //
5
+ // This file used to require `./src/index.ts`, which only works because Deno can `require()`
6
+ // TypeScript. The editor's language server and `vue-tsc` are Node processes, where that is a
7
+ // `SyntaxError`: Volar catches it and carries on without the plugin, so every brace template
8
+ // silently loses brace syntax, hover and diagnostics, and the only trace is a warning in the
9
+ // language server's output channel. `deno task build:plugin` compiles the plugin to `dist/`,
10
+ // which is what Node loads.
11
+ module.exports = require('./dist/index.js').default
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@cockernutx/language-plugin-brace",
3
+ "version": "0.1.0",
4
+ "description": "Volar / Vue language tools plugin for lang=\"brace\" SFC templates",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "require": "./index.cjs",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "index.cjs", "README.md", "LICENSE"],
16
+ "scripts": {
17
+ "prepack": "deno task --cwd=../.. build:plugin",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest"
20
+ },
21
+ "keywords": ["vue", "volar", "language-server", "plugin", "template"],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/strawberyy-coconut/vue-brace.git",
25
+ "directory": "packages/language-plugin-brace"
26
+ },
27
+ "homepage": "https://github.com/strawberyy-coconut/vue-brace#readme",
28
+ "bugs": "https://github.com/strawberyy-coconut/vue-brace/issues",
29
+ "dependencies": {
30
+ "@cockernutx/brace-template": "^0.1.0",
31
+ "@vue/language-core": "^3.3.11"
32
+ },
33
+ "peerDependencies": {
34
+ "vue": "^3.5.0 || >=3.6.0-0"
35
+ },
36
+ "devDependencies": {
37
+ "@vue/compiler-dom": "catalog:",
38
+ "vitest": "^5.0.2",
39
+ "vue": "catalog:"
40
+ }
41
+ }