@abide/abide 0.41.1 → 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.
- package/AGENTS.md +19 -13
- package/CHANGELOG.md +48 -0
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/abideLsp.ts +46 -8
- package/src/abideResolverPlugin.ts +3 -5
- package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
- package/src/lib/shared/escapeRegex.ts +9 -0
- package/src/lib/shared/fileName.ts +9 -0
- package/src/lib/shared/fileStem.ts +3 -1
- package/src/lib/shared/programNameForPackage.ts +3 -1
- package/src/lib/shared/stripImport.ts +3 -1
- package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/compileShadow.ts +42 -10
- package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
- package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
- package/src/lib/ui/compile/generateBuild.ts +2 -2
- package/src/lib/ui/compile/generateSSR.ts +6 -6
- package/src/lib/ui/compile/makeVarNamer.ts +10 -0
- package/src/lib/ui/compile/offsetToPosition.ts +9 -0
- package/src/lib/ui/compile/parseTemplate.ts +524 -104
- package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
- package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
- package/src/lib/ui/dom/appendText.ts +1 -5
- package/src/lib/ui/dom/applyResolved.ts +1 -5
- package/src/lib/ui/dom/isComment.ts +6 -0
- package/src/lib/ui/runtime/createDoc.ts +3 -3
- package/src/lib/ui/runtime/pathSegments.ts +10 -0
- package/template/.zed/settings.json +4 -0
- package/template/src/ui/pages/page.abide +4 -4
|
@@ -90,11 +90,323 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
90
90
|
return { kind: 'style', css }
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/* True when the cursor sits on a block-directive open `{#`. A `{:`/`{/` is only
|
|
94
|
+
valid INSIDE a block (consumed by readBlockChildren), so at top level it would be
|
|
95
|
+
a stray-branch error — handled where blocks are read, not here. */
|
|
96
|
+
function atBlock(): boolean {
|
|
97
|
+
return source.charAt(cursor) === '{' && source.charAt(cursor + 1) === '#'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Reads a `{#…}` / `{:…}` / `{/…}` token starting on `{`. Tracks string literals
|
|
101
|
+
and nested braces (same scan as readBracedExpression) so a brace/quote inside an
|
|
102
|
+
expression (`{#if obj.has('}')}`) doesn't end the token early. Returns the sigil,
|
|
103
|
+
the trimmed directive body WITHOUT the sigil, and the absolute loc of the body's
|
|
104
|
+
first post-trim char. */
|
|
105
|
+
function readBlockToken(): { sigil: '#' | ':' | '/'; body: string; loc: number } {
|
|
106
|
+
const sigil = source.charAt(cursor + 1) as '#' | ':' | '/'
|
|
107
|
+
cursor += 2 // past `{` and the sigil
|
|
108
|
+
const start = cursor
|
|
109
|
+
let depth = 1
|
|
110
|
+
while (cursor < source.length && depth > 0) {
|
|
111
|
+
const char = source.charAt(cursor)
|
|
112
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
113
|
+
cursor += 1
|
|
114
|
+
while (cursor < source.length && source.charAt(cursor) !== char) {
|
|
115
|
+
if (source.charAt(cursor) === '\\') {
|
|
116
|
+
cursor += 1
|
|
117
|
+
}
|
|
118
|
+
cursor += 1
|
|
119
|
+
}
|
|
120
|
+
} else if (char === '{') {
|
|
121
|
+
depth += 1
|
|
122
|
+
} else if (char === '}') {
|
|
123
|
+
depth -= 1
|
|
124
|
+
}
|
|
125
|
+
cursor += 1
|
|
126
|
+
}
|
|
127
|
+
const raw = source.slice(start, cursor - 1)
|
|
128
|
+
const leading = raw.length - raw.trimStart().length
|
|
129
|
+
return { sigil, body: raw.trim(), loc: baseOffset + start + leading }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* The leading keyword of a directive body (`if`, `for`, `await`, `switch`, `try`,
|
|
133
|
+
`else`, `then`, `catch`, `finally`, `case`, `default`). */
|
|
134
|
+
function headKeyword(body: string): string {
|
|
135
|
+
const match = body.match(/^\s*(\S+)/)
|
|
136
|
+
return match?.[1] ?? ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Reads a `{#…}` control block: the open token, its children up to a continuation
|
|
140
|
+
`{:…}` (a branch) or close `{/…}`, recursing. Emits the same nodes toControlFlow
|
|
141
|
+
does today (if/each/await/switch/try + case/branch children). */
|
|
142
|
+
function readBlock(): TemplateNode {
|
|
143
|
+
const open = readBlockToken() // sigil is '#'
|
|
144
|
+
const keyword = headKeyword(open.body)
|
|
145
|
+
if (keyword === 'if') {
|
|
146
|
+
const start = open.body.indexOf('if') + 2
|
|
147
|
+
const condition = open.body.slice(start).trim()
|
|
148
|
+
if (condition === '') {
|
|
149
|
+
throw new Error('[abide] {#if} requires a condition expression')
|
|
150
|
+
}
|
|
151
|
+
const children = readBlockChildren('if')
|
|
152
|
+
return { kind: 'if', condition, children, loc: exprLoc(open.loc, open.body, start) }
|
|
153
|
+
}
|
|
154
|
+
if (keyword === 'for') {
|
|
155
|
+
const head = parseForHead(open.body, open.loc)
|
|
156
|
+
const children = readBlockChildren('for')
|
|
157
|
+
return {
|
|
158
|
+
kind: 'each',
|
|
159
|
+
items: head.items,
|
|
160
|
+
as: head.as,
|
|
161
|
+
index: head.index,
|
|
162
|
+
key: head.key,
|
|
163
|
+
async: head.async,
|
|
164
|
+
children,
|
|
165
|
+
loc: head.loc,
|
|
166
|
+
asLoc: head.asLoc,
|
|
167
|
+
keyLoc: head.keyLoc,
|
|
168
|
+
indexLoc: head.indexLoc,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (keyword === 'await') {
|
|
172
|
+
const start = open.body.indexOf('await') + 5
|
|
173
|
+
const afterAwait = open.body.slice(start)
|
|
174
|
+
const thenAt = keywordAtDepthZero(afterAwait, 'then')
|
|
175
|
+
const promise = (thenAt === -1 ? afterAwait : afterAwait.slice(0, thenAt)).trim()
|
|
176
|
+
if (promise === '') {
|
|
177
|
+
throw new Error('[abide] {#await} requires a promise expression')
|
|
178
|
+
}
|
|
179
|
+
const as =
|
|
180
|
+
thenAt === -1
|
|
181
|
+
? undefined
|
|
182
|
+
: afterAwait.slice(thenAt + 'then'.length).trim() || undefined
|
|
183
|
+
const children = readBlockChildren('await')
|
|
184
|
+
return {
|
|
185
|
+
kind: 'await',
|
|
186
|
+
promise,
|
|
187
|
+
blocking: thenAt !== -1,
|
|
188
|
+
as,
|
|
189
|
+
children,
|
|
190
|
+
loc: exprLoc(open.loc, open.body, start),
|
|
191
|
+
asLoc:
|
|
192
|
+
as === undefined
|
|
193
|
+
? undefined
|
|
194
|
+
: exprLoc(open.loc, open.body, start + thenAt + 'then'.length),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (keyword === 'switch') {
|
|
198
|
+
const start = open.body.indexOf('switch') + 6
|
|
199
|
+
const subject = open.body.slice(start).trim()
|
|
200
|
+
if (subject === '') {
|
|
201
|
+
throw new Error('[abide] {#switch} requires a subject expression')
|
|
202
|
+
}
|
|
203
|
+
const children = readBlockChildren('switch')
|
|
204
|
+
return { kind: 'switch', subject, children, loc: exprLoc(open.loc, open.body, start) }
|
|
205
|
+
}
|
|
206
|
+
if (keyword === 'try') {
|
|
207
|
+
const children = readBlockChildren('try')
|
|
208
|
+
return { kind: 'try', children }
|
|
209
|
+
}
|
|
210
|
+
throw new Error(
|
|
211
|
+
`[abide] unknown control block {#${keyword}} — expected if/for/await/switch/try`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* Shared scan body for block/branch child loops. Reads one node at the current
|
|
216
|
+
cursor position and pushes it onto `nodes`. Returns false when the caller's
|
|
217
|
+
terminator fires (caller decides what to do next); returns true while scanning
|
|
218
|
+
continues. `onBranch` fires when `{:` is seen — block-children consume it as a
|
|
219
|
+
new branch node; branch-children let the caller exit instead. */
|
|
220
|
+
function scanNode(nodes: TemplateNode[], onBranch: (() => boolean) | undefined): boolean {
|
|
221
|
+
/* Branch/close tokens — delegate to the caller's terminator logic. */
|
|
222
|
+
if (source.startsWith('{/', cursor)) {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
if (source.startsWith('{:', cursor)) {
|
|
226
|
+
return onBranch === undefined ? false : onBranch()
|
|
227
|
+
}
|
|
228
|
+
if (atBlock()) {
|
|
229
|
+
nodes.push(readBlock())
|
|
230
|
+
} else if (source.startsWith('<!--', cursor)) {
|
|
231
|
+
skipComment()
|
|
232
|
+
} else if (atStyleTag()) {
|
|
233
|
+
nodes.push(readStyle())
|
|
234
|
+
} else if (source.charAt(cursor) === '<') {
|
|
235
|
+
nodes.push(readElement())
|
|
236
|
+
} else {
|
|
237
|
+
nodes.push(readText())
|
|
238
|
+
}
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Reads children of a block until its close `{/<keyword>}`. A continuation token
|
|
243
|
+
`{:…}` ends the current branch's children and starts a new `case`/`branch` node
|
|
244
|
+
(per construct). The leading children (before the first `{:…}`) are the block's
|
|
245
|
+
own children (the `if`/`await`/`try` then-content). Returns the full children list
|
|
246
|
+
INCLUDING the case/branch nodes, matching toControlFlow's output. */
|
|
247
|
+
function readBlockChildren(keyword: string): TemplateNode[] {
|
|
248
|
+
const nodes: TemplateNode[] = []
|
|
249
|
+
while (cursor < source.length) {
|
|
250
|
+
const keepGoing = scanNode(nodes, () => {
|
|
251
|
+
nodes.push(readBranch(keyword))
|
|
252
|
+
return true
|
|
253
|
+
})
|
|
254
|
+
if (!keepGoing) {
|
|
255
|
+
const close = readBlockToken() // consume the close `{/keyword}`
|
|
256
|
+
const closeKeyword = headKeyword(close.body)
|
|
257
|
+
/* The close must name its open block — a mismatch (`{#if}…{/for}`) or
|
|
258
|
+
crossed nesting (`{#if}{#for}…{/if}{/for}`) would otherwise silently
|
|
259
|
+
mis-parse into a structurally wrong tree. */
|
|
260
|
+
if (closeKeyword !== keyword) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`[abide] {/${closeKeyword}} does not close the open {#${keyword}} — expected {/${keyword}}`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return nodes
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`[abide] unterminated {#${keyword}} block — missing {/${keyword}}`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* Reads a continuation token `{:…}` and the children up to the NEXT continuation or
|
|
272
|
+
close, returning the branch node for the parent construct. Handles `if`-chain
|
|
273
|
+
branches (else / else if) and `for await` catch branches. */
|
|
274
|
+
function readBranch(parentKeyword: string): TemplateNode {
|
|
275
|
+
const token = readBlockToken() // sigil ':'
|
|
276
|
+
const keyword = headKeyword(token.body)
|
|
277
|
+
const branchChildren = readBranchChildren()
|
|
278
|
+
if (parentKeyword === 'if') {
|
|
279
|
+
if (keyword === 'else' && headKeyword(token.body.slice(4).trim()) === 'if') {
|
|
280
|
+
const start = token.body.indexOf('if') + 2
|
|
281
|
+
const condition = token.body.slice(start).trim()
|
|
282
|
+
if (condition === '') {
|
|
283
|
+
throw new Error('[abide] {:else if} requires a condition expression')
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
kind: 'case',
|
|
287
|
+
match: undefined,
|
|
288
|
+
condition,
|
|
289
|
+
children: branchChildren,
|
|
290
|
+
loc: exprLoc(token.loc, token.body, start),
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (keyword === 'else') {
|
|
294
|
+
return { kind: 'case', match: undefined, children: branchChildren }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (parentKeyword === 'for' && keyword === 'catch') {
|
|
298
|
+
const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
|
|
299
|
+
return {
|
|
300
|
+
kind: 'branch',
|
|
301
|
+
branch: 'catch',
|
|
302
|
+
as,
|
|
303
|
+
children: branchChildren,
|
|
304
|
+
asLoc:
|
|
305
|
+
as === undefined
|
|
306
|
+
? undefined
|
|
307
|
+
: exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (parentKeyword === 'switch') {
|
|
311
|
+
if (keyword === 'case') {
|
|
312
|
+
const start = token.body.indexOf('case') + 4
|
|
313
|
+
const match = token.body.slice(start).trim()
|
|
314
|
+
if (match === '') {
|
|
315
|
+
throw new Error('[abide] {:case} requires a value expression')
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
kind: 'case',
|
|
319
|
+
match,
|
|
320
|
+
children: branchChildren,
|
|
321
|
+
loc: exprLoc(token.loc, token.body, start),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (keyword === 'default') {
|
|
325
|
+
return { kind: 'case', match: undefined, children: branchChildren }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (parentKeyword === 'await') {
|
|
329
|
+
if (keyword === 'then') {
|
|
330
|
+
const as = token.body.slice(token.body.indexOf('then') + 4).trim() || undefined
|
|
331
|
+
return {
|
|
332
|
+
kind: 'branch',
|
|
333
|
+
branch: 'then',
|
|
334
|
+
as,
|
|
335
|
+
children: branchChildren,
|
|
336
|
+
asLoc:
|
|
337
|
+
as === undefined
|
|
338
|
+
? undefined
|
|
339
|
+
: exprLoc(token.loc, token.body, token.body.indexOf('then') + 4),
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (keyword === 'catch') {
|
|
343
|
+
const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
|
|
344
|
+
return {
|
|
345
|
+
kind: 'branch',
|
|
346
|
+
branch: 'catch',
|
|
347
|
+
as,
|
|
348
|
+
children: branchChildren,
|
|
349
|
+
asLoc:
|
|
350
|
+
as === undefined
|
|
351
|
+
? undefined
|
|
352
|
+
: exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (keyword === 'finally') {
|
|
356
|
+
return {
|
|
357
|
+
kind: 'branch',
|
|
358
|
+
branch: 'finally',
|
|
359
|
+
as: undefined,
|
|
360
|
+
children: branchChildren,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (parentKeyword === 'try') {
|
|
365
|
+
if (keyword === 'catch') {
|
|
366
|
+
const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
|
|
367
|
+
return {
|
|
368
|
+
kind: 'branch',
|
|
369
|
+
branch: 'catch',
|
|
370
|
+
as,
|
|
371
|
+
children: branchChildren,
|
|
372
|
+
asLoc:
|
|
373
|
+
as === undefined
|
|
374
|
+
? undefined
|
|
375
|
+
: exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (keyword === 'finally') {
|
|
379
|
+
return {
|
|
380
|
+
kind: 'branch',
|
|
381
|
+
branch: 'finally',
|
|
382
|
+
as: undefined,
|
|
383
|
+
children: branchChildren,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`[abide] {:${keyword}} is not valid inside {#${parentKeyword}}`)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* Children of a branch: read until the next `{:…}` or `{/…}` WITHOUT consuming it
|
|
391
|
+
(the caller's readBlockChildren loop handles those). */
|
|
392
|
+
function readBranchChildren(): TemplateNode[] {
|
|
393
|
+
const nodes: TemplateNode[] = []
|
|
394
|
+
/* Pass undefined for onBranch so scanNode returns false on `{:`, leaving it unconsumed. */
|
|
395
|
+
while (cursor < source.length && scanNode(nodes, undefined)) {
|
|
396
|
+
// continue
|
|
397
|
+
}
|
|
398
|
+
return nodes
|
|
399
|
+
}
|
|
400
|
+
|
|
93
401
|
function readText(): TemplateNode {
|
|
94
402
|
const parts: TextPart[] = []
|
|
95
403
|
let literal = ''
|
|
96
404
|
while (cursor < source.length && source.charAt(cursor) !== '<') {
|
|
97
405
|
if (source.charAt(cursor) === '{') {
|
|
406
|
+
const next = source.charAt(cursor + 1)
|
|
407
|
+
if (next === '#' || next === ':' || next === '/') {
|
|
408
|
+
break // a block/continuation/close token — not interpolation
|
|
409
|
+
}
|
|
98
410
|
if (literal !== '') {
|
|
99
411
|
parts.push({ kind: 'static', value: decodeHtmlEntities(literal) })
|
|
100
412
|
literal = ''
|
|
@@ -246,11 +558,23 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
246
558
|
}
|
|
247
559
|
const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
|
|
248
560
|
if (tag === 'template') {
|
|
249
|
-
return
|
|
561
|
+
return toSnippetOrTemplate(attrs, children)
|
|
250
562
|
}
|
|
251
563
|
return { kind: 'element', tag, attrs, children }
|
|
252
564
|
}
|
|
253
565
|
|
|
566
|
+
/* A `{:…}`/`{/…}` reached OUTSIDE a block (the root scan or an element's children —
|
|
567
|
+
not readBlockChildren/readBranchChildren, which consume their own) is a continuation
|
|
568
|
+
or close with no open `{#…}`. Surface it as an error: readText breaks on `{:`/`{/`
|
|
569
|
+
without advancing, so falling through would loop forever. */
|
|
570
|
+
function throwIfStrayBranch(): void {
|
|
571
|
+
if (source.startsWith('{:', cursor) || source.startsWith('{/', cursor)) {
|
|
572
|
+
const end = source.indexOf('}', cursor)
|
|
573
|
+
const token = source.slice(cursor, end === -1 ? source.length : end + 1)
|
|
574
|
+
throw new Error(`[abide] ${token} has no open {#…} block`)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
254
578
|
function readChildren(closeTag: string): TemplateNode[] {
|
|
255
579
|
const nodes: TemplateNode[] = []
|
|
256
580
|
while (cursor < source.length) {
|
|
@@ -258,10 +582,13 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
258
582
|
cursor = source.indexOf('>', cursor) + 1
|
|
259
583
|
break
|
|
260
584
|
}
|
|
585
|
+
throwIfStrayBranch()
|
|
261
586
|
if (source.startsWith('<!--', cursor)) {
|
|
262
587
|
skipComment()
|
|
263
588
|
} else if (atStyleTag()) {
|
|
264
589
|
nodes.push(readStyle())
|
|
590
|
+
} else if (atBlock()) {
|
|
591
|
+
nodes.push(readBlock())
|
|
265
592
|
} else if (source.charAt(cursor) === '<') {
|
|
266
593
|
nodes.push(readElement())
|
|
267
594
|
} else {
|
|
@@ -273,10 +600,13 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
273
600
|
|
|
274
601
|
const roots: TemplateNode[] = []
|
|
275
602
|
while (cursor < source.length) {
|
|
603
|
+
throwIfStrayBranch()
|
|
276
604
|
if (source.startsWith('<!--', cursor)) {
|
|
277
605
|
skipComment()
|
|
278
606
|
} else if (atStyleTag()) {
|
|
279
607
|
roots.push(readStyle())
|
|
608
|
+
} else if (atBlock()) {
|
|
609
|
+
roots.push(readBlock())
|
|
280
610
|
} else if (source.charAt(cursor) === '<') {
|
|
281
611
|
roots.push(readElement())
|
|
282
612
|
} else {
|
|
@@ -287,6 +617,165 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
287
617
|
return { nodes: roots }
|
|
288
618
|
}
|
|
289
619
|
|
|
620
|
+
/* Finds the index of a ` <token> ` keyword (` of `, ` by `) at brace/paren/bracket
|
|
621
|
+
depth 0, scanning left to right, skipping string literals. Returns -1 if absent. */
|
|
622
|
+
function indexOfKeywordAtDepthZero(text: string, keyword: string): number {
|
|
623
|
+
let depth = 0
|
|
624
|
+
let i = 0
|
|
625
|
+
while (i < text.length) {
|
|
626
|
+
const char = text.charAt(i)
|
|
627
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
628
|
+
i += 1
|
|
629
|
+
while (i < text.length && text.charAt(i) !== char) {
|
|
630
|
+
if (text.charAt(i) === '\\') {
|
|
631
|
+
i += 1
|
|
632
|
+
}
|
|
633
|
+
i += 1
|
|
634
|
+
}
|
|
635
|
+
} else if (char === '{' || char === '(' || char === '[') {
|
|
636
|
+
depth += 1
|
|
637
|
+
} else if (char === '}' || char === ')' || char === ']') {
|
|
638
|
+
depth -= 1
|
|
639
|
+
} else if (depth === 0 && text.startsWith(keyword, i)) {
|
|
640
|
+
return i
|
|
641
|
+
}
|
|
642
|
+
i += 1
|
|
643
|
+
}
|
|
644
|
+
return -1
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* Index of a bare keyword (`then`) at brace/paren/bracket depth 0, requiring whitespace
|
|
648
|
+
on both sides — so it isn't matched inside an identifier (`q.then(x)`) and MAY sit
|
|
649
|
+
terminally. Unlike ` of `/` by `, an await `then` can end the head: `{#await p then}`
|
|
650
|
+
is a blocking await with no value binding, which a trailing-space match would miss
|
|
651
|
+
(folding `then` into the promise). Skips string literals. -1 if absent. */
|
|
652
|
+
function keywordAtDepthZero(text: string, word: string): number {
|
|
653
|
+
let depth = 0
|
|
654
|
+
let i = 0
|
|
655
|
+
while (i < text.length) {
|
|
656
|
+
const char = text.charAt(i)
|
|
657
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
658
|
+
i += 1
|
|
659
|
+
while (i < text.length && text.charAt(i) !== char) {
|
|
660
|
+
if (text.charAt(i) === '\\') {
|
|
661
|
+
i += 1
|
|
662
|
+
}
|
|
663
|
+
i += 1
|
|
664
|
+
}
|
|
665
|
+
} else if (char === '{' || char === '(' || char === '[') {
|
|
666
|
+
depth += 1
|
|
667
|
+
} else if (char === '}' || char === ')' || char === ']') {
|
|
668
|
+
depth -= 1
|
|
669
|
+
} else if (
|
|
670
|
+
depth === 0 &&
|
|
671
|
+
text.startsWith(word, i) &&
|
|
672
|
+
i > 0 &&
|
|
673
|
+
/\s/.test(text.charAt(i - 1)) &&
|
|
674
|
+
(i + word.length === text.length || /\s/.test(text.charAt(i + word.length)))
|
|
675
|
+
) {
|
|
676
|
+
return i
|
|
677
|
+
}
|
|
678
|
+
i += 1
|
|
679
|
+
}
|
|
680
|
+
return -1
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* The depth-0 comma index in a binding (`{id, title}, i` → the comma after `}`),
|
|
684
|
+
so a destructuring pattern's inner commas don't split the binding from its index. */
|
|
685
|
+
function bindingCommaAtDepthZero(text: string): number {
|
|
686
|
+
let depth = 0
|
|
687
|
+
let i = 0
|
|
688
|
+
while (i < text.length) {
|
|
689
|
+
const char = text.charAt(i)
|
|
690
|
+
if (char === '{' || char === '(' || char === '[') {
|
|
691
|
+
depth += 1
|
|
692
|
+
} else if (char === '}' || char === ')' || char === ']') {
|
|
693
|
+
depth -= 1
|
|
694
|
+
} else if (char === ',' && depth === 0) {
|
|
695
|
+
return i
|
|
696
|
+
}
|
|
697
|
+
i += 1
|
|
698
|
+
}
|
|
699
|
+
return -1
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/* Absolute source offset of the trimmed expression beginning at `start` within a
|
|
703
|
+
directive `body` whose first char is at absolute `bodyLoc` — skips the leading
|
|
704
|
+
whitespace `.trim()` drops so the offset points at the expression's first real char
|
|
705
|
+
(the shadow source-map invariant: source text at `loc` equals the emitted code). */
|
|
706
|
+
function exprLoc(bodyLoc: number, body: string, start: number): number {
|
|
707
|
+
const raw = body.slice(start)
|
|
708
|
+
return bodyLoc + start + (raw.length - raw.trimStart().length)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/* Parses `for [await] <binding>[, <index>] of <iterable> [by <key>]`. Offsets are
|
|
712
|
+
tracked against the original `body` (not trimmed slices) so `loc` points at the
|
|
713
|
+
iterable expression's first char — `bodyLoc` is the absolute offset of `body[0]`. */
|
|
714
|
+
function parseForHead(
|
|
715
|
+
body: string,
|
|
716
|
+
bodyLoc: number,
|
|
717
|
+
): {
|
|
718
|
+
items: string
|
|
719
|
+
as: string
|
|
720
|
+
index: string | undefined
|
|
721
|
+
key: string | undefined
|
|
722
|
+
async: boolean
|
|
723
|
+
loc: number
|
|
724
|
+
asLoc: number | undefined
|
|
725
|
+
keyLoc: number | undefined
|
|
726
|
+
indexLoc: number | undefined
|
|
727
|
+
} {
|
|
728
|
+
const leadingSpace = (text: string): number => text.length - text.trimStart().length
|
|
729
|
+
const skipSpace = (i: number): number => {
|
|
730
|
+
let at = i
|
|
731
|
+
while (at < body.length && /\s/.test(body.charAt(at))) {
|
|
732
|
+
at += 1
|
|
733
|
+
}
|
|
734
|
+
return at
|
|
735
|
+
}
|
|
736
|
+
let bindingStart = skipSpace(body.indexOf('for') + 3)
|
|
737
|
+
const isAsync = /^await\b/.test(body.slice(bindingStart))
|
|
738
|
+
if (isAsync) {
|
|
739
|
+
bindingStart = skipSpace(bindingStart + 'await'.length)
|
|
740
|
+
}
|
|
741
|
+
const region = body.slice(bindingStart)
|
|
742
|
+
const ofAt = indexOfKeywordAtDepthZero(region, ' of ')
|
|
743
|
+
if (ofAt === -1) {
|
|
744
|
+
throw new Error('[abide] {#for} requires `<binding> of <iterable>`')
|
|
745
|
+
}
|
|
746
|
+
const left = region.slice(0, ofAt).trim()
|
|
747
|
+
const itemsStart = skipSpace(bindingStart + ofAt + ' of '.length)
|
|
748
|
+
let itemsRegion = body.slice(itemsStart)
|
|
749
|
+
const byAt = indexOfKeywordAtDepthZero(itemsRegion, ' by ')
|
|
750
|
+
const keyRaw = byAt === -1 ? '' : itemsRegion.slice(byAt + ' by '.length)
|
|
751
|
+
const key = byAt === -1 ? undefined : keyRaw.trim()
|
|
752
|
+
const keyLoc =
|
|
753
|
+
byAt === -1 ? undefined : bodyLoc + itemsStart + byAt + ' by '.length + leadingSpace(keyRaw)
|
|
754
|
+
if (byAt !== -1) {
|
|
755
|
+
itemsRegion = itemsRegion.slice(0, byAt)
|
|
756
|
+
}
|
|
757
|
+
const commaAt = bindingCommaAtDepthZero(left)
|
|
758
|
+
const as = (commaAt === -1 ? left : left.slice(0, commaAt)).trim()
|
|
759
|
+
const indexRaw = commaAt === -1 ? '' : left.slice(commaAt + 1)
|
|
760
|
+
const index = commaAt === -1 ? undefined : indexRaw.trim()
|
|
761
|
+
/* `left` is trimmed and starts at `bindingStart` (skipSpace'd), so the binding
|
|
762
|
+
name begins exactly there; the index sits past the comma. */
|
|
763
|
+
return {
|
|
764
|
+
items: itemsRegion.trim(),
|
|
765
|
+
as: as === '' ? '_item' : as,
|
|
766
|
+
index,
|
|
767
|
+
key,
|
|
768
|
+
async: isAsync,
|
|
769
|
+
loc: bodyLoc + itemsStart,
|
|
770
|
+
asLoc: as === '' ? undefined : bodyLoc + bindingStart,
|
|
771
|
+
keyLoc,
|
|
772
|
+
indexLoc:
|
|
773
|
+
commaAt === -1
|
|
774
|
+
? undefined
|
|
775
|
+
: bodyLoc + bindingStart + commaAt + 1 + leadingSpace(indexRaw),
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
290
779
|
/* A `case` node (`<template else>`/`<template case>`/`<template default>`) is valid
|
|
291
780
|
only as a direct child of its `<template if>`/`<template switch>`; a `branch`
|
|
292
781
|
(`then`/`catch`/`finally`) only inside its `<template await>`/`<template try>`.
|
|
@@ -452,10 +941,39 @@ function attrName(attr: TemplateAttr): string {
|
|
|
452
941
|
}
|
|
453
942
|
|
|
454
943
|
/* Turns a `<template>` directive into a control node (if/each/await + then/catch). */
|
|
455
|
-
|
|
944
|
+
/* The control-flow attribute names that used to drive `<template>` directives — now
|
|
945
|
+
moved to `{#…}` blocks. A `<template>` carrying one is a migration error. */
|
|
946
|
+
const CONTROL_DIRECTIVES = new Set([
|
|
947
|
+
'if',
|
|
948
|
+
'elseif',
|
|
949
|
+
'else',
|
|
950
|
+
'each',
|
|
951
|
+
'await',
|
|
952
|
+
'then',
|
|
953
|
+
'catch',
|
|
954
|
+
'finally',
|
|
955
|
+
'switch',
|
|
956
|
+
'case',
|
|
957
|
+
'default',
|
|
958
|
+
'try',
|
|
959
|
+
])
|
|
960
|
+
|
|
961
|
+
/* A `<template>` is now ONLY a snippet declaration (`name`) or a plain inert
|
|
962
|
+
`<template>` element. Control flow moved to `{#…}` blocks; a directive attribute
|
|
963
|
+
(`if`/`each`/`await`/…) is a migration error pointing at the block form. `name`
|
|
964
|
+
makes the element callable; without it, it stays an inert reusable fragment. */
|
|
965
|
+
function toSnippetOrTemplate(attrs: TemplateAttr[], children: TemplateNode[]): TemplateNode {
|
|
456
966
|
const find = (name: string) => attrs.find((attr) => attrName(attr) === name)
|
|
457
|
-
|
|
458
|
-
|
|
967
|
+
const directive = attrs.find((attr) => CONTROL_DIRECTIVES.has(attrName(attr)))
|
|
968
|
+
if (directive !== undefined) {
|
|
969
|
+
const name = attrName(directive)
|
|
970
|
+
const block = name === 'elseif' || name === 'else' ? 'if' : name
|
|
971
|
+
throw new Error(
|
|
972
|
+
`[abide] <template ${name}> control flow was removed — use the {#${block}…} block instead`,
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
/* `<template name="row" args={item}>` declares a snippet — a named builder. `args`
|
|
976
|
+
(its parameter list) rides the `{…}` expression slot. */
|
|
459
977
|
const snippet = find('name')
|
|
460
978
|
if (snippet !== undefined) {
|
|
461
979
|
const name = attrText(snippet)
|
|
@@ -471,104 +989,6 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
|
|
|
471
989
|
loc: attrLoc(params),
|
|
472
990
|
}
|
|
473
991
|
}
|
|
474
|
-
/* `<template
|
|
475
|
-
|
|
476
|
-
if (find('try') !== undefined) {
|
|
477
|
-
return { kind: 'try', children }
|
|
478
|
-
}
|
|
479
|
-
/* `await` alongside `each` is the async-list switch (handled in the each branch
|
|
480
|
-
below), not an await block. */
|
|
481
|
-
const promise = find('await')
|
|
482
|
-
if (promise !== undefined && find('each') === undefined) {
|
|
483
|
-
const promiseCode = attrText(promise)
|
|
484
|
-
if (promiseCode === undefined) {
|
|
485
|
-
throw new Error('[abide] <template await> requires a promise expression')
|
|
486
|
-
}
|
|
487
|
-
/* A `then` attribute ON the await tag is the blocking switch: children become
|
|
488
|
-
the resolved content bound to its value (a `then` *child* is a streaming
|
|
489
|
-
branch, handled separately below when its own tag is parsed). */
|
|
490
|
-
const boundThen = find('then')
|
|
491
|
-
return {
|
|
492
|
-
kind: 'await',
|
|
493
|
-
promise: promiseCode,
|
|
494
|
-
blocking: boundThen !== undefined,
|
|
495
|
-
as: boundThen === undefined ? undefined : attrText(boundThen) || undefined,
|
|
496
|
-
children,
|
|
497
|
-
loc: attrLoc(promise),
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
const thenAttr = find('then')
|
|
501
|
-
if (thenAttr !== undefined) {
|
|
502
|
-
return { kind: 'branch', branch: 'then', as: attrText(thenAttr) || undefined, children }
|
|
503
|
-
}
|
|
504
|
-
const catchAttr = find('catch')
|
|
505
|
-
if (catchAttr !== undefined) {
|
|
506
|
-
return { kind: 'branch', branch: 'catch', as: attrText(catchAttr) || undefined, children }
|
|
507
|
-
}
|
|
508
|
-
/* `<template finally>` renders after settle on BOTH outcomes — outcome-agnostic,
|
|
509
|
-
so it binds no value. */
|
|
510
|
-
if (find('finally') !== undefined) {
|
|
511
|
-
return { kind: 'branch', branch: 'finally', as: undefined, children }
|
|
512
|
-
}
|
|
513
|
-
const subject = find('switch')
|
|
514
|
-
if (subject !== undefined) {
|
|
515
|
-
const subjectCode = attrText(subject)
|
|
516
|
-
if (subjectCode === undefined) {
|
|
517
|
-
throw new Error('[abide] <template switch> requires a subject expression')
|
|
518
|
-
}
|
|
519
|
-
return { kind: 'switch', subject: subjectCode, children, loc: attrLoc(subject) }
|
|
520
|
-
}
|
|
521
|
-
const caseAttr = find('case')
|
|
522
|
-
if (caseAttr !== undefined) {
|
|
523
|
-
const matchCode = attrText(caseAttr)
|
|
524
|
-
if (matchCode === undefined) {
|
|
525
|
-
throw new Error('[abide] <template case> requires a value expression')
|
|
526
|
-
}
|
|
527
|
-
return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
|
|
528
|
-
}
|
|
529
|
-
/* `<template elseif={c}>` is a match-less case carrying a condition — a branch of the
|
|
530
|
-
enclosing `<template if>` chain, truthy-tested in source order. */
|
|
531
|
-
const elseif = find('elseif')
|
|
532
|
-
if (elseif !== undefined) {
|
|
533
|
-
const conditionCode = attrText(elseif)
|
|
534
|
-
if (conditionCode === undefined) {
|
|
535
|
-
throw new Error('[abide] <template elseif> requires a condition expression')
|
|
536
|
-
}
|
|
537
|
-
return {
|
|
538
|
-
kind: 'case',
|
|
539
|
-
match: undefined,
|
|
540
|
-
condition: conditionCode,
|
|
541
|
-
children,
|
|
542
|
-
loc: attrLoc(elseif),
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
if (find('default') !== undefined || find('else') !== undefined) {
|
|
546
|
-
return { kind: 'case', match: undefined, children } // default (switch) / else (if)
|
|
547
|
-
}
|
|
548
|
-
const condition = find('if')
|
|
549
|
-
if (condition !== undefined) {
|
|
550
|
-
const conditionCode = attrText(condition)
|
|
551
|
-
if (conditionCode === undefined) {
|
|
552
|
-
throw new Error('[abide] <template if> requires a condition expression')
|
|
553
|
-
}
|
|
554
|
-
return { kind: 'if', condition: conditionCode, children, loc: attrLoc(condition) }
|
|
555
|
-
}
|
|
556
|
-
const items = find('each')
|
|
557
|
-
const itemsCode = items === undefined ? undefined : attrText(items)
|
|
558
|
-
if (itemsCode === undefined) {
|
|
559
|
-
throw new Error('[abide] <template> without a supported directive (if/each)')
|
|
560
|
-
}
|
|
561
|
-
const as = find('as')
|
|
562
|
-
const key = find('key')
|
|
563
|
-
const index = find('index')
|
|
564
|
-
return {
|
|
565
|
-
kind: 'each',
|
|
566
|
-
items: itemsCode,
|
|
567
|
-
as: (as === undefined ? undefined : attrText(as)) ?? '_item',
|
|
568
|
-
key: key === undefined ? undefined : attrText(key),
|
|
569
|
-
index: index === undefined ? undefined : attrText(index),
|
|
570
|
-
async: find('await') !== undefined, // `<template each await>` over an AsyncIterable
|
|
571
|
-
children,
|
|
572
|
-
loc: attrLoc(items),
|
|
573
|
-
}
|
|
992
|
+
/* A plain inert `<template>` element (e.g. client-side cloning) — keep as an element. */
|
|
993
|
+
return { kind: 'element', tag: 'template', attrs, children }
|
|
574
994
|
}
|