@abide/abide 0.41.0 → 0.42.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.
Files changed (61) hide show
  1. package/AGENTS.md +333 -326
  2. package/CHANGELOG.md +92 -0
  3. package/README.md +91 -83
  4. package/package.json +1 -1
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +3 -5
  7. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  8. package/src/lib/shared/escapeRegex.ts +9 -0
  9. package/src/lib/shared/fileName.ts +9 -0
  10. package/src/lib/shared/fileStem.ts +3 -1
  11. package/src/lib/shared/programNameForPackage.ts +3 -1
  12. package/src/lib/shared/stripImport.ts +3 -1
  13. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  14. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  15. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  16. package/src/lib/ui/compile/compileShadow.ts +46 -9
  17. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  18. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  19. package/src/lib/ui/compile/generateBuild.ts +13 -6
  20. package/src/lib/ui/compile/generateSSR.ts +23 -14
  21. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  22. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  23. package/src/lib/ui/compile/parseTemplate.ts +524 -104
  24. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  25. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  26. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  27. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  28. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  29. package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
  30. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  31. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  32. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  33. package/src/lib/ui/dom/appendText.ts +1 -5
  34. package/src/lib/ui/dom/applyResolved.ts +4 -9
  35. package/src/lib/ui/dom/awaitBlock.ts +37 -31
  36. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  37. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  38. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  39. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  40. package/src/lib/ui/dom/each.ts +7 -11
  41. package/src/lib/ui/dom/eachAsync.ts +12 -17
  42. package/src/lib/ui/dom/isComment.ts +6 -0
  43. package/src/lib/ui/dom/isElement.ts +6 -0
  44. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  45. package/src/lib/ui/dom/mountRange.ts +4 -3
  46. package/src/lib/ui/dom/mountSlot.ts +4 -3
  47. package/src/lib/ui/dom/on.ts +6 -1
  48. package/src/lib/ui/dom/replaceRange.ts +24 -0
  49. package/src/lib/ui/dom/skeleton.ts +35 -92
  50. package/src/lib/ui/dom/switchBlock.ts +13 -10
  51. package/src/lib/ui/dom/when.ts +13 -10
  52. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  53. package/src/lib/ui/runtime/batch.ts +22 -0
  54. package/src/lib/ui/runtime/clientPage.ts +3 -8
  55. package/src/lib/ui/runtime/createDoc.ts +12 -16
  56. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  57. package/src/lib/ui/seedResolved.ts +28 -0
  58. package/src/lib/ui/startClient.ts +6 -4
  59. package/src/lib/ui/types/ResolvedFrame.ts +15 -0
  60. package/template/.zed/settings.json +4 -0
  61. package/template/src/ui/pages/page.abide +4 -4
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { BunPlugin } from 'bun'
3
+ import { fileName } from '../../shared/fileName.ts'
3
4
  import { messageFromError } from '../../shared/messageFromError.ts'
4
5
  import { AbideCompileError } from './AbideCompileError.ts'
5
6
  import { analyzeComponent } from './analyzeComponent.ts'
@@ -42,7 +43,7 @@ export const abideUiPlugin: BunPlugin = {
42
43
  build.onLoad({ filter: /\.abide$/ }, async (args) => {
43
44
  const source = await Bun.file(args.path).text()
44
45
  const moduleId = relative(nearestProjectRoot(args.path, process.cwd()), args.path)
45
- const isLayout = (args.path.split('/').pop() ?? '') === 'layout.abide'
46
+ const isLayout = fileName(args.path) === 'layout.abide'
46
47
  /* Bun frames a plugin throw at `<file>:0` regardless of the real spot, so
47
48
  carry the component path + resolved line:col in the message — otherwise a
48
49
  control-flow / compile error reads as `:0` and (in deep imports) can look
@@ -91,6 +91,10 @@ export function compileShadow(source: string): CompiledShadow {
91
91
  pre-assembled scope line carrying its own embedded segments. */
92
92
  type Builder = {
93
93
  raw: (text: string) => void
94
+ /* Appends `text` verbatim and maps it back to source, WITHOUT the `expr`
95
+ parens — for binding names and other spans that must stay bare TS syntax
96
+ (`for (const NAME of …)`). `text` must equal the source at `sourceLoc`. */
97
+ mapped: (text: string, sourceLoc: number | undefined) => void
94
98
  expr: (code: string, sourceLoc: number | undefined) => void
95
99
  stmt: (code: string, sourceLoc: number | undefined) => void
96
100
  flush: (line: ScopeLine) => void
@@ -115,6 +119,16 @@ function createBuilder(): Builder {
115
119
  raw(text) {
116
120
  code += text
117
121
  },
122
+ mapped(text, sourceLoc) {
123
+ if (sourceLoc !== undefined) {
124
+ mappings.push({
125
+ shadowStart: code.length,
126
+ sourceStart: sourceLoc,
127
+ length: text.length,
128
+ })
129
+ }
130
+ code += text
131
+ },
118
132
  expr(exprCode, sourceLoc) {
119
133
  code += '('
120
134
  if (sourceLoc !== undefined) {
@@ -408,14 +422,24 @@ function emitNode(node: TemplateNode, builder: Builder): void {
408
422
  }
409
423
  case 'each':
410
424
  /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
411
- so the item binds to the element type under either iteration protocol. */
412
- builder.raw(
413
- node.async ? `for await (const ${node.as} of ` : `for (const ${node.as} of `,
414
- )
425
+ so the item binds to the element type under either iteration protocol. The
426
+ binding name is `mapped` (not `raw`) so hover/highlighting land on it. */
427
+ builder.raw(node.async ? 'for await (const ' : 'for (const ')
428
+ builder.mapped(node.as, node.asLoc)
429
+ builder.raw(' of ')
415
430
  builder.expr(node.items, node.loc)
416
431
  builder.raw(') {\n')
417
432
  if (node.key !== undefined) {
418
- builder.raw(`void (${node.key});\n`)
433
+ builder.raw('void ')
434
+ builder.expr(node.key, node.keyLoc)
435
+ builder.raw(';\n')
436
+ }
437
+ /* `index="i"` binds the row's position — always a number (the row ordinal,
438
+ or an async stream's arrival count). Declare it so body references check. */
439
+ if (node.index !== undefined) {
440
+ builder.raw('const ')
441
+ builder.mapped(node.index, node.indexLoc)
442
+ builder.raw(': number = 0;\n')
419
443
  }
420
444
  emitNodes(node.children, builder)
421
445
  builder.raw('}\n')
@@ -434,7 +458,9 @@ function emitNode(node: TemplateNode, builder: Builder): void {
434
458
  const pending = node.children.filter((child) => child.kind !== 'branch')
435
459
  const branches = node.children.filter((child) => child.kind === 'branch')
436
460
  if (node.blocking && node.as !== undefined) {
437
- builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
461
+ builder.raw('{\nconst ')
462
+ builder.mapped(node.as, node.asLoc)
463
+ builder.raw(` = ${resolved};\n`)
438
464
  emitNodes(pending, builder)
439
465
  builder.raw('}\n')
440
466
  } else {
@@ -446,9 +472,13 @@ function emitNode(node: TemplateNode, builder: Builder): void {
446
472
  }
447
473
  builder.raw('{\n')
448
474
  if (branch.branch === 'then' && branch.as !== undefined) {
449
- builder.raw(`const ${branch.as} = ${resolved};\n`)
475
+ builder.raw('const ')
476
+ builder.mapped(branch.as, branch.asLoc)
477
+ builder.raw(` = ${resolved};\n`)
450
478
  } else if (branch.branch === 'catch' && branch.as !== undefined) {
451
- builder.raw(`const ${branch.as} = undefined as any;\n`)
479
+ builder.raw('const ')
480
+ builder.mapped(branch.as, branch.asLoc)
481
+ builder.raw(' = undefined as any;\n')
452
482
  }
453
483
  emitNodes(branch.children, builder)
454
484
  builder.raw('}\n')
@@ -503,7 +533,14 @@ function emitNode(node: TemplateNode, builder: Builder): void {
503
533
  builder.raw('}\n')
504
534
  return
505
535
  case 'snippet':
506
- builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
536
+ /* `args={}` is the parameter list; `mapped` (not `raw`) so hover/highlighting
537
+ land on the binding. `loc` is the `args` expression's offset (see
538
+ toSnippetOrTemplate). The name is a static attribute with no tracked offset. */
539
+ builder.raw(`const ${node.name} = (`)
540
+ if (node.params !== undefined) {
541
+ builder.mapped(node.params, node.loc)
542
+ }
543
+ builder.raw(') => {\n')
507
544
  emitNodes(node.children, builder)
508
545
  builder.raw('};\n')
509
546
  return
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import ts from 'typescript'
3
3
  import { messageFromError } from '../../shared/messageFromError.ts'
4
+ import { mapTsClassification } from './ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
4
5
  import { assetModulesFile } from './assetModulesFile.ts'
5
6
  import { compileShadow } from './compileShadow.ts'
6
7
  import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
@@ -10,6 +11,7 @@ import { shadowNaming } from './shadowNaming.ts'
10
11
  import { sourceToShadowOffset } from './sourceToShadowOffset.ts'
11
12
  import type { AbideDiagnostic } from './types/AbideDiagnostic.ts'
12
13
  import type { CompiledShadow } from './types/CompiledShadow.ts'
14
+ import type { SemanticToken } from './types/SemanticToken.ts'
13
15
 
14
16
  const { suffixed, isShadow, sourceOf } = shadowNaming
15
17
 
@@ -27,6 +29,8 @@ export type ShadowLanguageService = {
27
29
  /* Hover info at a source offset, or undefined if the offset isn't a checked
28
30
  expression (markup, whitespace) or TypeScript has nothing to report. */
29
31
  quickInfo: (abidePath: string, sourceOffset: number) => ShadowQuickInfo | undefined
32
+ /* Type-aware semantic tokens for every checked expression, in source coords. */
33
+ semanticClassifications: (abidePath: string) => SemanticToken[]
30
34
  }
31
35
 
32
36
  /*
@@ -194,5 +198,49 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
194
198
  length: span?.length ?? 1,
195
199
  }
196
200
  },
201
+ semanticClassifications(abidePath) {
202
+ const fileName = suffixed(abidePath)
203
+ /* Compile first so the mappings cache is current. */
204
+ shadowText(abidePath)
205
+ const shadow = shadows.get(abidePath)
206
+ if (shadow === undefined) {
207
+ return []
208
+ }
209
+ const { spans } = service.getEncodedSemanticClassifications(
210
+ fileName,
211
+ { start: 0, length: shadow.code.length },
212
+ ts.SemanticClassificationFormat.TwentyTwenty,
213
+ )
214
+ /* `spans` is flat triples [start, length, classification, …] in shadow
215
+ coords; keep only those overlapping a mapped expression segment. */
216
+ const tokens: SemanticToken[] = []
217
+ for (let index = 0; index + 2 < spans.length; index += 3) {
218
+ const spanStart = spans[index]
219
+ const spanLength = spans[index + 1]
220
+ const classification = spans[index + 2]
221
+ if (
222
+ spanStart === undefined ||
223
+ spanLength === undefined ||
224
+ classification === undefined
225
+ ) {
226
+ continue
227
+ }
228
+ const mapped = mapTsClassification(classification)
229
+ if (mapped === undefined) {
230
+ continue
231
+ }
232
+ const located = remapShadowDiagnostic(shadow.mappings, spanStart, spanLength)
233
+ if (located === undefined) {
234
+ continue
235
+ }
236
+ tokens.push({
237
+ start: located.start,
238
+ length: located.length,
239
+ type: mapped.type,
240
+ modifiers: mapped.modifiers,
241
+ })
242
+ }
243
+ return tokens
244
+ },
197
245
  }
198
246
  }
@@ -0,0 +1,37 @@
1
+ import { ABIDE_SEMANTIC_TOKENS_LEGEND } from './ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
2
+ import { offsetToPosition } from './offsetToPosition.ts'
3
+ import type { SemanticToken } from './types/SemanticToken.ts'
4
+
5
+ /*
6
+ Encodes source-coordinate tokens into the LSP semantic-tokens `data` array: five
7
+ integers per token (deltaLine, deltaStartChar, length, tokenTypeIndex,
8
+ modifierBitset), each position relative to the previous token. Tokens are sorted
9
+ by start; tokens with an unknown legend type, and any token overlapping the one
10
+ before it, are dropped — the protocol requires a strictly non-overlapping,
11
+ positionally-ordered stream.
12
+ */
13
+ export function encodeSemanticTokens(text: string, tokens: SemanticToken[]): number[] {
14
+ const sorted = [...tokens].sort((a, b) => a.start - b.start || a.length - b.length)
15
+ const data: number[] = []
16
+ let previousLine = 0
17
+ let previousCharacter = 0
18
+ let previousEnd = -1
19
+ for (const token of sorted) {
20
+ const typeIndex = ABIDE_SEMANTIC_TOKENS_LEGEND.tokenTypes.indexOf(token.type)
21
+ if (typeIndex < 0 || token.start < previousEnd) {
22
+ continue
23
+ }
24
+ const modifierBitset = token.modifiers.reduce((bits, name) => {
25
+ const bit = ABIDE_SEMANTIC_TOKENS_LEGEND.tokenModifiers.indexOf(name)
26
+ return bit < 0 ? bits : bits | (1 << bit)
27
+ }, 0)
28
+ const { line, character } = offsetToPosition(text, token.start)
29
+ const deltaLine = line - previousLine
30
+ const deltaCharacter = deltaLine === 0 ? character - previousCharacter : character
31
+ data.push(deltaLine, deltaCharacter, token.length, typeIndex, modifierBitset)
32
+ previousLine = line
33
+ previousCharacter = character
34
+ previousEnd = token.start + token.length
35
+ }
36
+ return data
37
+ }
@@ -1,6 +1,7 @@
1
1
  import { assertExhaustive } from '../../shared/assertExhaustive.ts'
2
2
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
4
+ import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
4
5
  import { asOutlet } from './asOutlet.ts'
5
6
  import { bindListenEvent } from './bindListenEvent.ts'
6
7
  import { composeProps } from './composeProps.ts'
@@ -9,6 +10,7 @@ import { groupBindParts } from './groupBindParts.ts'
9
10
  import { isControlFlow } from './isControlFlow.ts'
10
11
  import { isWhitespaceText } from './isWhitespaceText.ts'
11
12
  import { lowerContext } from './lowerContext.ts'
13
+ import { makeVarNamer } from './makeVarNamer.ts'
12
14
  import { resolveBranches } from './resolveBranches.ts'
13
15
  import { scopeAttr } from './scopeAttr.ts'
14
16
  import { skeletonContext } from './skeletonContext.ts'
@@ -18,6 +20,12 @@ import { staticTextPart } from './staticTextPart.ts'
18
20
  import type { TemplateNode } from './types/TemplateNode.ts'
19
21
  import { VOID_TAGS } from './VOID_TAGS.ts'
20
22
 
23
+ /* The skeleton positioning anchor a control-flow block / component / slot / outlet
24
+ stamps into its skeleton markup, sourced from the same `ANCHOR` wire-alphabet constant
25
+ the client's anchor scan (`skeleton`) matches — so the markup the build clones and the
26
+ markup the scan reads can never drift on a literal. */
27
+ const ANCHOR_COMMENT = `<!--${ANCHOR}-->`
28
+
21
29
  /*
22
30
  Generates the build statements for a parsed template: element creation, static
23
31
  attributes, reactive `attr`/`text` bindings, `on` listeners, keyed `each`, and
@@ -54,8 +62,7 @@ export function generateBuild(
54
62
  computedNames: ReadonlySet<string>,
55
63
  isLayout = false,
56
64
  ): string {
57
- let counter = 0
58
- const nextVar = (prefix: string): string => `${prefix}${counter++}`
65
+ const nextVar = makeVarNamer()
59
66
 
60
67
  /* In a layout, `<slot/>` outlets are rewritten to `OUTLET_TAG` elements up front
61
68
  (`asOutlet`) so the static-clone path carries them as ordinary structure. `asOutlet`
@@ -213,7 +220,7 @@ export function generateBuild(
213
220
  binds.push(
214
221
  `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
215
222
  )
216
- return '<!--a-->'
223
+ return ANCHOR_COMMENT
217
224
  })
218
225
  .join('')
219
226
  }
@@ -226,7 +233,7 @@ export function generateBuild(
226
233
  element — so its root lays out as a true direct child of `anchor.parentNode`. */
227
234
  const anchorVar = anchorVarAt(node, skVar, binds)
228
235
  binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
229
- return '<!--a-->'
236
+ return ANCHOR_COMMENT
230
237
  }
231
238
  if (node.kind === 'script') {
232
239
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
@@ -251,7 +258,7 @@ export function generateBuild(
251
258
  No wrapper element — the filled child lays out as a direct child of the parent. */
252
259
  const anchorVar = anchorVarAt(node, skVar, binds)
253
260
  binds.push(`outlet(${anchorVar}.parentNode, anchorCursor(${anchorVar}));\n`)
254
- return '<!--a-->'
261
+ return ANCHOR_COMMENT
255
262
  }
256
263
  if (node.tag === 'slot') {
257
264
  /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
@@ -261,7 +268,7 @@ export function generateBuild(
261
268
  binds.push(
262
269
  `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
263
270
  )
264
- return '<!--a-->'
271
+ return ANCHOR_COMMENT
265
272
  }
266
273
  const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
267
274
  const reactiveTextChild = node.children.find(
@@ -1,11 +1,18 @@
1
1
  import { assertExhaustive } from '../../shared/assertExhaustive.ts'
2
+ import { escapeRegex } from '../../shared/escapeRegex.ts'
2
3
  import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
3
4
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
5
+ import {
6
+ ANCHOR,
7
+ RANGE_CLOSE as RANGE_CLOSE_DATA,
8
+ RANGE_OPEN as RANGE_OPEN_DATA,
9
+ } from '../runtime/RANGE_MARKER.ts'
4
10
  import { asOutlet } from './asOutlet.ts'
5
11
  import { composeProps } from './composeProps.ts'
6
12
  import { groupBindParts } from './groupBindParts.ts'
7
13
  import { isAnchorPositioned } from './isAnchorPositioned.ts'
8
14
  import { lowerContext } from './lowerContext.ts'
15
+ import { makeVarNamer } from './makeVarNamer.ts'
9
16
  import { resolveBranches } from './resolveBranches.ts'
10
17
  import { scopeAttr } from './scopeAttr.ts'
11
18
  import { skeletonContext } from './skeletonContext.ts'
@@ -16,12 +23,16 @@ import { stripEffects } from './stripEffects.ts'
16
23
  import type { TemplateNode } from './types/TemplateNode.ts'
17
24
  import { VOID_TAGS } from './VOID_TAGS.ts'
18
25
 
19
- /* The range boundary comments a control-flow block emits around its content. They
20
- serialize exactly as the client's `document.createComment('[' | ']')` markers, so
21
- the client claims the same `[ ]` boundary it builds the comment-marked range
22
- that lets a branch hold any content. */
23
- const RANGE_OPEN = '<!--[-->'
24
- const RANGE_CLOSE = '<!--]-->'
26
+ /* The range boundary comments a control-flow block emits around its content. Sourced
27
+ from the SAME wire-alphabet constants the client's `document.createComment` markers
28
+ use (`RANGE_MARKER`), wrapped in comment syntax so a server-emitted boundary and the
29
+ client `[ ]` boundary it claims can never drift on a literal. */
30
+ const RANGE_OPEN = `<!--${RANGE_OPEN_DATA}-->`
31
+ const RANGE_CLOSE = `<!--${RANGE_CLOSE_DATA}-->`
32
+
33
+ /* The skeleton positioning anchor a control-flow block / slot / outlet emits, sourced
34
+ from the same `ANCHOR` constant the client's anchor scan matches. */
35
+ const ANCHOR_COMMENT = `<!--${ANCHOR}-->`
25
36
 
26
37
  /*
27
38
  Server code generator: turns the parsed template into statements that push HTML
@@ -46,10 +57,9 @@ export function generateSSR(
46
57
  computedNames: ReadonlySet<string>,
47
58
  isLayout = false,
48
59
  ): string {
49
- /* Compile-time counter for unique temp var names (runtime block ids, child render
50
- results) block ids themselves are allocated at runtime via `$ctx.next++`. */
51
- let varCounter = 0
52
- const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
60
+ /* Unique temp var names (child render results); runtime block ids are
61
+ allocated separately at runtime via `$ctx.next++`. */
62
+ const nextVar = makeVarNamer()
53
63
 
54
64
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
55
65
  const {
@@ -81,13 +91,12 @@ export function generateSSR(
81
91
  regex metacharacters escaped, or e.g. a trailing `$` would read as an end-anchor and the
82
92
  call site would never match — leaving an un-awaited Promise stringified as `[object
83
93
  Promise]`. */
84
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
85
94
  /* A leading boundary that, unlike `\b`, also fires before a `$`-leading name: `\b$row`
86
95
  never matches (`$` is a non-word char, so there is no word boundary before it), which
87
96
  would silently miss every `$row(...)` call. A negative lookbehind for word-or-`$`
88
97
  matches the same call sites as `\b` for word-leading names while also catching them. */
89
98
  const callPattern = (name: string): RegExp =>
90
- new RegExp(`(?<![$\\w])${escapeRegExp(name)}\\s*\\(`)
99
+ new RegExp(`(?<![$\\w])${escapeRegex(name)}\\s*\\(`)
91
100
  /* A subtree call to any of `names` from a TEXT interpolation (`name()` / `name(args)`). */
92
101
  const subtreeCalls = (children: TemplateNode[], names: ReadonlySet<string>): boolean =>
93
102
  children.some((child) => {
@@ -172,7 +181,7 @@ export function generateSSR(
172
181
  Outside a skeleton (top-level / inside a branch) blocks mount on the host directly,
173
182
  so no anchor. */
174
183
  const anchorMark = (node: TemplateNode, target: string): string =>
175
- inSkeleton.get(node) ? push(target, '<!--a-->') : ''
184
+ inSkeleton.get(node) ? push(target, ANCHOR_COMMENT) : ''
176
185
 
177
186
  function generate(node: TemplateNode, target: string): string {
178
187
  /* Every kind that mounts as a marker range is positioned by an `<!--a-->` anchor when
@@ -197,7 +206,7 @@ export function generateSSR(
197
206
  ? `$text(await (${lowered}))`
198
207
  : `$text(${lowered})`
199
208
  return markText.get(node)
200
- ? `${target}.push('<!--a-->' + ${value});\n`
209
+ ? `${target}.push('${ANCHOR_COMMENT}' + ${value});\n`
201
210
  : `${target}.push(${value});\n`
202
211
  })
203
212
  .join('')
@@ -0,0 +1,10 @@
1
+ /*
2
+ Compile-time generator of unique temp var names: each call appends a fresh
3
+ incrementing suffix to `prefix` (`row` → `row0`, `row1`, …). Shared by the
4
+ generateSSR / generateBuild codegen passes so the two hand-mirrored traversals
5
+ allocate names one way. Runtime block ids stay separate (`$ctx.next++`).
6
+ */
7
+ export function makeVarNamer(): (prefix: string) => string {
8
+ let counter = 0
9
+ return (prefix: string): string => `${prefix}${counter++}`
10
+ }
@@ -0,0 +1,9 @@
1
+ /* An absolute offset → LSP `{ line, character }` (0-based, UTF-16 code units). */
2
+ export function offsetToPosition(
3
+ text: string,
4
+ offset: number,
5
+ ): { line: number; character: number } {
6
+ const before = text.slice(0, offset)
7
+ const line = before.split('\n').length - 1
8
+ return { line, character: offset - (before.lastIndexOf('\n') + 1) }
9
+ }