@rohal12/spindle 0.50.0 → 0.50.1

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.
@@ -1,9 +1,767 @@
1
+ //#region src/markup/tokenizer.ts
2
+ /** Tag name must start with a letter (covers standard and custom elements). */
3
+ var VALID_TAG_START = /[a-zA-Z]/;
4
+ /** HTML void elements: never have children or a closing tag. */
5
+ var HTML_VOID_TAGS = new Set([
6
+ "area",
7
+ "base",
8
+ "br",
9
+ "col",
10
+ "embed",
11
+ "hr",
12
+ "img",
13
+ "input",
14
+ "link",
15
+ "meta",
16
+ "param",
17
+ "source",
18
+ "track",
19
+ "wbr"
20
+ ]);
21
+ /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
22
+ var RAW_BODY_MACROS = new Set(["do"]);
23
+ /**
24
+ * Parse a Twine link interior into display and target.
25
+ * Supports: display|target, display->target, target<-display, plain
26
+ */
27
+ function parseLink(inner) {
28
+ const pipeIdx = inner.indexOf("|");
29
+ if (pipeIdx !== -1) return {
30
+ display: inner.slice(0, pipeIdx).trim(),
31
+ target: inner.slice(pipeIdx + 1).trim()
32
+ };
33
+ const arrowIdx = inner.indexOf("->");
34
+ if (arrowIdx !== -1) return {
35
+ display: inner.slice(0, arrowIdx).trim(),
36
+ target: inner.slice(arrowIdx + 2).trim()
37
+ };
38
+ const revIdx = inner.indexOf("<-");
39
+ if (revIdx !== -1) return {
40
+ target: inner.slice(0, revIdx).trim(),
41
+ display: inner.slice(revIdx + 2).trim()
42
+ };
43
+ const trimmed = inner.trim();
44
+ return {
45
+ display: trimmed,
46
+ target: trimmed
47
+ };
48
+ }
49
+ /**
50
+ * Parse a macro opening: extract name and rawArgs.
51
+ * e.g. "set $x = 5" → { name: "set", rawArgs: "$x = 5" }
52
+ * e.g. "/if" → { name: "if", rawArgs: "", isClose: true }
53
+ * e.g. "elseif $x > 3" → { name: "elseif", rawArgs: "$x > 3" }
54
+ */
55
+ function parseMacroContent(content) {
56
+ const trimmed = content.trim();
57
+ const isClose = trimmed.startsWith("/");
58
+ const rest = isClose ? trimmed.slice(1) : trimmed;
59
+ const spaceIdx = rest.search(/\s/);
60
+ if (spaceIdx === -1) return {
61
+ name: rest,
62
+ rawArgs: "",
63
+ isClose
64
+ };
65
+ return {
66
+ name: rest.slice(0, spaceIdx),
67
+ rawArgs: rest.slice(spaceIdx + 1).trim(),
68
+ isClose
69
+ };
70
+ }
71
+ /**
72
+ * Parse CSS selectors: .foo.bar#baz → { className: "foo bar", id: "baz" }
73
+ * Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order.
74
+ * Returns space-joined class string, last id wins, and position after last segment.
75
+ */
76
+ function parseSelectors(input, startIdx) {
77
+ const classes = [];
78
+ let id = "";
79
+ let i = startIdx;
80
+ while (i < input.length && (input[i] === "." || input[i] === "#")) {
81
+ const prefix = input[i];
82
+ i++;
83
+ let name = "";
84
+ while (i < input.length) if (/[a-zA-Z0-9_-]/.test(input[i])) {
85
+ name += input[i];
86
+ i++;
87
+ } else if (input[i] === "{" && (input[i + 1] === "$" || input[i + 1] === "_" || input[i + 1] === "@")) {
88
+ const braceStart = i;
89
+ i += 2;
90
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
91
+ if (i < input.length && input[i] === "}") {
92
+ i++;
93
+ name += input.slice(braceStart, i);
94
+ } else {
95
+ i = braceStart;
96
+ break;
97
+ }
98
+ } else break;
99
+ if (name) if (prefix === ".") classes.push(name);
100
+ else id = name;
101
+ }
102
+ return {
103
+ className: classes.join(" "),
104
+ id,
105
+ endIdx: i
106
+ };
107
+ }
108
+ /**
109
+ * Parse HTML attributes from a string starting at position j.
110
+ * Returns the attributes and the position after the last attribute.
111
+ */
112
+ function parseHtmlAttributes(input, j) {
113
+ const attributes = {};
114
+ while (j < input.length) {
115
+ while (j < input.length && /\s/.test(input[j])) j++;
116
+ if (j >= input.length || input[j] === ">" || input[j] === "/" && input[j + 1] === ">") break;
117
+ const attrStart = j;
118
+ while (j < input.length && /[a-zA-Z0-9_\-:@]/.test(input[j])) j++;
119
+ const attrName = input.slice(attrStart, j);
120
+ if (!attrName) break;
121
+ if (input[j] === "=") {
122
+ j++;
123
+ if (input[j] === "\"" || input[j] === "'") {
124
+ const quote = input[j];
125
+ j++;
126
+ const valStart = j;
127
+ while (j < input.length) {
128
+ if (input[j] === "{") {
129
+ const closeIdx = scanBalancedBrace(input, j + 1);
130
+ if (closeIdx !== -1) {
131
+ j = closeIdx + 1;
132
+ continue;
133
+ }
134
+ } else if (input[j] === quote) break;
135
+ j++;
136
+ }
137
+ attributes[attrName] = input.slice(valStart, j);
138
+ if (j < input.length) j++;
139
+ } else {
140
+ const valStart = j;
141
+ while (j < input.length && /[^\s>]/.test(input[j])) j++;
142
+ attributes[attrName] = input.slice(valStart, j);
143
+ }
144
+ } else attributes[attrName] = "";
145
+ }
146
+ return {
147
+ attributes,
148
+ endIdx: j
149
+ };
150
+ }
151
+ /**
152
+ * Skip a '…' or "…" string literal opening at i.
153
+ * Returns the index just past the closing quote, or -1 if the string is
154
+ * not closed on the same line (JS strings can't span lines unescaped).
155
+ */
156
+ function skipQuoted(input, i) {
157
+ const quote = input[i];
158
+ let j = i + 1;
159
+ while (j < input.length) {
160
+ const c = input[j];
161
+ if (c === "\\") j += 2;
162
+ else if (c === quote) return j + 1;
163
+ else if (c === "\n") return -1;
164
+ else j++;
165
+ }
166
+ return -1;
167
+ }
168
+ /**
169
+ * Skip a `…` template literal opening at i, including ${…} parts.
170
+ * Returns the index just past the closing backtick, or -1 if unclosed.
171
+ */
172
+ function skipTemplate(input, i) {
173
+ let j = i + 1;
174
+ while (j < input.length) {
175
+ const c = input[j];
176
+ if (c === "\\") j += 2;
177
+ else if (c === "`") return j + 1;
178
+ else if (c === "$" && input[j + 1] === "{") {
179
+ const closeIdx = scanBalancedBrace(input, j + 2);
180
+ if (closeIdx === -1) return -1;
181
+ j = closeIdx + 1;
182
+ } else j++;
183
+ }
184
+ return -1;
185
+ }
186
+ /**
187
+ * A quote directly after a letter/digit is an apostrophe (don't), not a
188
+ * string; after a backslash it is an escaped attribute delimiter (\").
189
+ */
190
+ var NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
191
+ /**
192
+ * Scan for the balanced closing } starting at position i (just past the {).
193
+ * Braces inside string and template literals are ignored. A quote that
194
+ * can't start a string (apostrophe, escaped, unterminated) counts as text.
195
+ * Returns the index of the closing } or -1 if unbalanced.
196
+ */
197
+ function scanBalancedBrace(input, i) {
198
+ let depth = 1;
199
+ while (i < input.length) {
200
+ const c = input[i];
201
+ if (c === "{") depth++;
202
+ else if (c === "}") {
203
+ if (--depth === 0) return i;
204
+ } else if ((c === "\"" || c === "'") && !(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]))) {
205
+ const end = skipQuoted(input, i);
206
+ if (end !== -1) {
207
+ i = end;
208
+ continue;
209
+ }
210
+ } else if (c === "`") {
211
+ const end = skipTemplate(input, i);
212
+ if (end !== -1) {
213
+ i = end;
214
+ continue;
215
+ }
216
+ }
217
+ i++;
218
+ }
219
+ return -1;
220
+ }
221
+ /**
222
+ * Single-pass tokenizer for Twine passage content.
223
+ * Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
224
+ */
225
+ function tokenize(input) {
226
+ const tokens = [];
227
+ let i = 0;
228
+ let textStart = 0;
229
+ function flushText(end) {
230
+ if (end > textStart) tokens.push({
231
+ type: "text",
232
+ value: input.slice(textStart, end),
233
+ start: textStart,
234
+ end
235
+ });
236
+ }
237
+ /**
238
+ * After an opening raw-body macro ({do}), emit everything up to the
239
+ * first {/name} as a single text token so JavaScript source is not
240
+ * parsed as markup. A literal "{/do}" inside the code would end it early.
241
+ * Without a closer, nothing is consumed and the AST builder reports it.
242
+ */
243
+ function consumeRawBody(name, isClose) {
244
+ const lower = name.toLowerCase();
245
+ if (isClose || !RAW_BODY_MACROS.has(lower)) return;
246
+ const closeRe = new RegExp(`\\{/${lower}\\s*\\}`, "gi");
247
+ closeRe.lastIndex = i;
248
+ const m = closeRe.exec(input);
249
+ if (!m) return;
250
+ const closeStart = m.index;
251
+ const closeEnd = closeStart + m[0].length;
252
+ flushText(closeStart);
253
+ tokens.push({
254
+ type: "macro",
255
+ ...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
256
+ start: closeStart,
257
+ end: closeEnd
258
+ });
259
+ i = closeEnd;
260
+ textStart = closeEnd;
261
+ }
262
+ while (i < input.length) {
263
+ if (input[i] === "\\" && (input[i + 1] === "{" || input[i + 1] === "}")) {
264
+ flushText(i);
265
+ tokens.push({
266
+ type: "text",
267
+ value: input[i + 1],
268
+ start: i,
269
+ end: i + 2
270
+ });
271
+ i += 2;
272
+ textStart = i;
273
+ continue;
274
+ }
275
+ if (input[i] === "[" && input[i + 1] === "[") {
276
+ flushText(i);
277
+ const start = i;
278
+ i += 2;
279
+ let className;
280
+ let id;
281
+ if (input[i] === "." || input[i] === "#") {
282
+ const parsed = parseSelectors(input, i);
283
+ className = parsed.className || void 0;
284
+ id = parsed.id || void 0;
285
+ i = parsed.endIdx;
286
+ if (input[i] === " ") i++;
287
+ }
288
+ let depth = 1;
289
+ const innerStart = i;
290
+ while (i < input.length && depth > 0) if (input[i] === "[" && input[i + 1] === "[") {
291
+ depth++;
292
+ i += 2;
293
+ } else if (input[i] === "]" && input[i + 1] === "]") {
294
+ depth--;
295
+ if (depth === 0) break;
296
+ i += 2;
297
+ } else i++;
298
+ if (depth !== 0) {
299
+ i = start + 2;
300
+ textStart = start;
301
+ continue;
302
+ }
303
+ const inner = input.slice(innerStart, i);
304
+ i += 2;
305
+ const { display, target } = parseLink(inner);
306
+ const linkToken = {
307
+ type: "link",
308
+ display,
309
+ target,
310
+ start,
311
+ end: i
312
+ };
313
+ if (className) linkToken.className = className;
314
+ if (id) linkToken.id = id;
315
+ tokens.push(linkToken);
316
+ textStart = i;
317
+ continue;
318
+ }
319
+ if (input[i] === "{") {
320
+ const start = i;
321
+ let nextChar = input[i + 1];
322
+ let className;
323
+ let id;
324
+ if (nextChar === "." || nextChar === "#") {
325
+ flushText(i);
326
+ const parsed = parseSelectors(input, i + 1);
327
+ className = parsed.className || void 0;
328
+ id = parsed.id || void 0;
329
+ let afterSelectors = parsed.endIdx;
330
+ if (input[afterSelectors] === " ") afterSelectors++;
331
+ const charAfter = input[afterSelectors];
332
+ if (charAfter === "$") {
333
+ i = afterSelectors + 1;
334
+ const nameStart = i;
335
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
336
+ const name = input.slice(nameStart, i);
337
+ if (input[i] === "}") {
338
+ i++;
339
+ const token = {
340
+ type: "variable",
341
+ name,
342
+ scope: "variable",
343
+ start,
344
+ end: i
345
+ };
346
+ if (className) token.className = className;
347
+ if (id) token.id = id;
348
+ tokens.push(token);
349
+ textStart = i;
350
+ continue;
351
+ }
352
+ const closeIdx$ = scanBalancedBrace(input, nameStart);
353
+ if (closeIdx$ !== -1) {
354
+ const expression = input.slice(afterSelectors, closeIdx$);
355
+ i = closeIdx$ + 1;
356
+ const token = {
357
+ type: "expression",
358
+ expression,
359
+ start,
360
+ end: i
361
+ };
362
+ if (className) token.className = className;
363
+ if (id) token.id = id;
364
+ tokens.push(token);
365
+ textStart = i;
366
+ continue;
367
+ }
368
+ i = start + 1;
369
+ textStart = start;
370
+ continue;
371
+ }
372
+ if (charAfter === "_") {
373
+ i = afterSelectors + 1;
374
+ const nameStart = i;
375
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
376
+ const name = input.slice(nameStart, i);
377
+ if (input[i] === "}") {
378
+ i++;
379
+ const token = {
380
+ type: "variable",
381
+ name,
382
+ scope: "temporary",
383
+ start,
384
+ end: i
385
+ };
386
+ if (className) token.className = className;
387
+ if (id) token.id = id;
388
+ tokens.push(token);
389
+ textStart = i;
390
+ continue;
391
+ }
392
+ const closeIdx_ = scanBalancedBrace(input, nameStart);
393
+ if (closeIdx_ !== -1) {
394
+ const expression = input.slice(afterSelectors, closeIdx_);
395
+ i = closeIdx_ + 1;
396
+ const token = {
397
+ type: "expression",
398
+ expression,
399
+ start,
400
+ end: i
401
+ };
402
+ if (className) token.className = className;
403
+ if (id) token.id = id;
404
+ tokens.push(token);
405
+ textStart = i;
406
+ continue;
407
+ }
408
+ i = start + 1;
409
+ textStart = start;
410
+ continue;
411
+ }
412
+ if (charAfter === "@") {
413
+ i = afterSelectors + 1;
414
+ const nameStart = i;
415
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
416
+ const name = input.slice(nameStart, i);
417
+ if (input[i] === "}") {
418
+ i++;
419
+ const token = {
420
+ type: "variable",
421
+ name,
422
+ scope: "local",
423
+ start,
424
+ end: i
425
+ };
426
+ if (className) token.className = className;
427
+ if (id) token.id = id;
428
+ tokens.push(token);
429
+ textStart = i;
430
+ continue;
431
+ }
432
+ const closeIdx_at = scanBalancedBrace(input, nameStart);
433
+ if (closeIdx_at !== -1) {
434
+ const expression = input.slice(afterSelectors, closeIdx_at);
435
+ i = closeIdx_at + 1;
436
+ const token = {
437
+ type: "expression",
438
+ expression,
439
+ start,
440
+ end: i
441
+ };
442
+ if (className) token.className = className;
443
+ if (id) token.id = id;
444
+ tokens.push(token);
445
+ textStart = i;
446
+ continue;
447
+ }
448
+ i = start + 1;
449
+ textStart = start;
450
+ continue;
451
+ }
452
+ if (charAfter === "%") {
453
+ i = afterSelectors + 1;
454
+ const nameStart = i;
455
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
456
+ const name = input.slice(nameStart, i);
457
+ if (input[i] === "}") {
458
+ i++;
459
+ const token = {
460
+ type: "variable",
461
+ name,
462
+ scope: "transient",
463
+ start,
464
+ end: i
465
+ };
466
+ if (className) token.className = className;
467
+ if (id) token.id = id;
468
+ tokens.push(token);
469
+ textStart = i;
470
+ continue;
471
+ }
472
+ const closeIdx_pct = scanBalancedBrace(input, nameStart);
473
+ if (closeIdx_pct !== -1) {
474
+ const expression = input.slice(afterSelectors, closeIdx_pct);
475
+ i = closeIdx_pct + 1;
476
+ const token = {
477
+ type: "expression",
478
+ expression,
479
+ start,
480
+ end: i
481
+ };
482
+ if (className) token.className = className;
483
+ if (id) token.id = id;
484
+ tokens.push(token);
485
+ textStart = i;
486
+ continue;
487
+ }
488
+ i = start + 1;
489
+ textStart = start;
490
+ continue;
491
+ }
492
+ if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
493
+ const contentStart = afterSelectors;
494
+ const closeIdx = scanBalancedBrace(input, contentStart);
495
+ if (closeIdx === -1) {
496
+ i = start + 1;
497
+ textStart = start;
498
+ continue;
499
+ }
500
+ const content = input.slice(contentStart, closeIdx);
501
+ i = closeIdx + 1;
502
+ const { name, rawArgs, isClose } = parseMacroContent(content);
503
+ const token = {
504
+ type: "macro",
505
+ name,
506
+ rawArgs,
507
+ isClose,
508
+ start,
509
+ end: i
510
+ };
511
+ if (className) token.className = className;
512
+ if (id) token.id = id;
513
+ tokens.push(token);
514
+ textStart = i;
515
+ consumeRawBody(name, isClose);
516
+ continue;
517
+ }
518
+ i = start + 1;
519
+ textStart = start;
520
+ continue;
521
+ }
522
+ if (nextChar === "$") {
523
+ flushText(i);
524
+ i += 2;
525
+ const nameStart = i;
526
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
527
+ const name = input.slice(nameStart, i);
528
+ if (input[i] === "}") {
529
+ i++;
530
+ tokens.push({
531
+ type: "variable",
532
+ name,
533
+ scope: "variable",
534
+ start,
535
+ end: i
536
+ });
537
+ textStart = i;
538
+ continue;
539
+ }
540
+ const closeIdx = scanBalancedBrace(input, nameStart);
541
+ if (closeIdx !== -1) {
542
+ const expression = input.slice(start + 1, closeIdx);
543
+ i = closeIdx + 1;
544
+ tokens.push({
545
+ type: "expression",
546
+ expression,
547
+ start,
548
+ end: i
549
+ });
550
+ textStart = i;
551
+ continue;
552
+ }
553
+ i = start + 1;
554
+ textStart = start;
555
+ continue;
556
+ }
557
+ if (nextChar === "_") {
558
+ flushText(i);
559
+ i += 2;
560
+ const nameStart = i;
561
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
562
+ const name = input.slice(nameStart, i);
563
+ if (input[i] === "}") {
564
+ i++;
565
+ tokens.push({
566
+ type: "variable",
567
+ name,
568
+ scope: "temporary",
569
+ start,
570
+ end: i
571
+ });
572
+ textStart = i;
573
+ continue;
574
+ }
575
+ const closeIdx = scanBalancedBrace(input, nameStart);
576
+ if (closeIdx !== -1) {
577
+ const expression = input.slice(start + 1, closeIdx);
578
+ i = closeIdx + 1;
579
+ tokens.push({
580
+ type: "expression",
581
+ expression,
582
+ start,
583
+ end: i
584
+ });
585
+ textStart = i;
586
+ continue;
587
+ }
588
+ i = start + 1;
589
+ textStart = start;
590
+ continue;
591
+ }
592
+ if (nextChar === "@") {
593
+ flushText(i);
594
+ i += 2;
595
+ const nameStart = i;
596
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
597
+ const name = input.slice(nameStart, i);
598
+ if (input[i] === "}") {
599
+ i++;
600
+ tokens.push({
601
+ type: "variable",
602
+ name,
603
+ scope: "local",
604
+ start,
605
+ end: i
606
+ });
607
+ textStart = i;
608
+ continue;
609
+ }
610
+ const closeIdx = scanBalancedBrace(input, nameStart);
611
+ if (closeIdx !== -1) {
612
+ const expression = input.slice(start + 1, closeIdx);
613
+ i = closeIdx + 1;
614
+ tokens.push({
615
+ type: "expression",
616
+ expression,
617
+ start,
618
+ end: i
619
+ });
620
+ textStart = i;
621
+ continue;
622
+ }
623
+ i = start + 1;
624
+ textStart = start;
625
+ continue;
626
+ }
627
+ if (nextChar === "%") {
628
+ flushText(i);
629
+ i += 2;
630
+ const nameStart = i;
631
+ while (i < input.length && /[\w.]/.test(input[i])) i++;
632
+ const name = input.slice(nameStart, i);
633
+ if (input[i] === "}") {
634
+ i++;
635
+ tokens.push({
636
+ type: "variable",
637
+ name,
638
+ scope: "transient",
639
+ start,
640
+ end: i
641
+ });
642
+ textStart = i;
643
+ continue;
644
+ }
645
+ const closeIdx = scanBalancedBrace(input, nameStart);
646
+ if (closeIdx !== -1) {
647
+ const expression = input.slice(start + 1, closeIdx);
648
+ i = closeIdx + 1;
649
+ tokens.push({
650
+ type: "expression",
651
+ expression,
652
+ start,
653
+ end: i
654
+ });
655
+ textStart = i;
656
+ continue;
657
+ }
658
+ i = start + 1;
659
+ textStart = start;
660
+ continue;
661
+ }
662
+ if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
663
+ flushText(i);
664
+ const contentStart = i + 1;
665
+ const closeIdx = scanBalancedBrace(input, contentStart);
666
+ if (closeIdx === -1) {
667
+ i = start + 1;
668
+ textStart = start;
669
+ continue;
670
+ }
671
+ const content = input.slice(contentStart, closeIdx);
672
+ i = closeIdx + 1;
673
+ const { name, rawArgs, isClose } = parseMacroContent(content);
674
+ tokens.push({
675
+ type: "macro",
676
+ name,
677
+ rawArgs,
678
+ isClose,
679
+ start,
680
+ end: i
681
+ });
682
+ textStart = i;
683
+ consumeRawBody(name, isClose);
684
+ continue;
685
+ }
686
+ i++;
687
+ continue;
688
+ }
689
+ if (input[i] === "<") {
690
+ const start = i;
691
+ let j = i + 1;
692
+ const isClose = input[j] === "/";
693
+ if (isClose) j++;
694
+ const tagStart = j;
695
+ while (j < input.length && /[a-zA-Z0-9-]/.test(input[j])) j++;
696
+ const tag = input.slice(tagStart, j);
697
+ const tagLower = tag.toLowerCase();
698
+ if (tag && VALID_TAG_START.test(tag[0])) if (isClose) {
699
+ while (j < input.length && /\s/.test(input[j])) j++;
700
+ if (input[j] === ">") {
701
+ j++;
702
+ flushText(start);
703
+ if (HTML_VOID_TAGS.has(tagLower)) {
704
+ textStart = j;
705
+ i = j;
706
+ continue;
707
+ }
708
+ tokens.push({
709
+ type: "html",
710
+ tag,
711
+ attributes: {},
712
+ isClose: true,
713
+ isSelfClose: false,
714
+ start,
715
+ end: j
716
+ });
717
+ textStart = j;
718
+ i = j;
719
+ continue;
720
+ }
721
+ } else {
722
+ const parsed = parseHtmlAttributes(input, j);
723
+ j = parsed.endIdx;
724
+ let isSelfClose = HTML_VOID_TAGS.has(tagLower);
725
+ if (input[j] === "/") {
726
+ isSelfClose = true;
727
+ j++;
728
+ }
729
+ if (input[j] === ">") {
730
+ j++;
731
+ flushText(start);
732
+ tokens.push({
733
+ type: "html",
734
+ tag,
735
+ attributes: parsed.attributes,
736
+ isClose: false,
737
+ isSelfClose,
738
+ start,
739
+ end: j
740
+ });
741
+ textStart = j;
742
+ i = j;
743
+ continue;
744
+ }
745
+ }
746
+ i++;
747
+ continue;
748
+ }
749
+ i++;
750
+ }
751
+ flushText(input.length);
752
+ return tokens;
753
+ }
754
+ //#endregion
1
755
  //#region src/story-variables.ts
2
756
  function declarationRegex(sigil) {
3
757
  const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4
758
  return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
5
759
  }
6
760
  var VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
761
+ /** `{` followed by a sigil starts an interpolation block inside literal text. */
762
+ var INTERP_START_RE = /^[$_@%]\w/;
763
+ /** Quoted first argument of an input macro naming a story variable. */
764
+ var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
7
765
  var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
8
766
  var VALID_VAR_TYPES = new Set([
9
767
  "number",
@@ -95,21 +853,155 @@ function validateRef(ref, schema, forLocals) {
95
853
  return null;
96
854
  }
97
855
  /**
856
+ * Built-in input macros whose first argument names the bound story variable,
857
+ * quoted or not (e.g. `{textbox "$name"}`).
858
+ */
859
+ var BUILTIN_STORE_VAR_MACROS = [
860
+ "checkbox",
861
+ "cycle",
862
+ "listbox",
863
+ "numberbox",
864
+ "radiobutton",
865
+ "textarea",
866
+ "textbox"
867
+ ];
868
+ /** Report every `$var.path` in a code segment free of strings/comments. */
869
+ function scanRefs(segment, onRef) {
870
+ for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]);
871
+ }
872
+ /** Index of the quote closing the string opened at `start` (or the end). */
873
+ function findClosingQuote(code, start) {
874
+ const quote = code[start];
875
+ let i = start + 1;
876
+ while (i < code.length && code[i] !== quote) i += code[i] === "\\" ? 2 : 1;
877
+ return Math.min(i, code.length);
878
+ }
879
+ /**
880
+ * Scan literal text (string contents, HTML attribute values) for `{$…}`
881
+ * interpolation blocks, which interpolating macros and HTML attributes
882
+ * resolve at runtime. A bare `$word` in literal text is not a reference.
883
+ */
884
+ function scanInterpolations(text, onRef) {
885
+ let i = text.indexOf("{");
886
+ while (i !== -1) {
887
+ const next = INTERP_START_RE.test(text.slice(i + 1, i + 3)) ? scanCode(text, i + 1, onRef, true) : i + 1;
888
+ i = text.indexOf("{", next);
889
+ }
890
+ }
891
+ /**
892
+ * Scan a template literal starting just after its opening backtick: literal
893
+ * parts are text, `${…}` parts are code. Returns the index just past the
894
+ * closing backtick.
895
+ */
896
+ function scanTemplate(code, start, onRef) {
897
+ let i = start;
898
+ let textStart = start;
899
+ while (i < code.length) {
900
+ const ch = code[i];
901
+ if (ch === "\\") i += 2;
902
+ else if (ch === "`") {
903
+ scanInterpolations(code.slice(textStart, i), onRef);
904
+ return i + 1;
905
+ } else if (ch === "$" && code[i + 1] === "{") {
906
+ scanInterpolations(code.slice(textStart, i), onRef);
907
+ i = textStart = scanCode(code, i + 2, onRef, true);
908
+ } else i++;
909
+ }
910
+ scanInterpolations(code.slice(textStart), onRef);
911
+ return code.length;
912
+ }
913
+ /**
914
+ * Report `$var` references in JavaScript code. Like the string-aware
915
+ * expression transformer, sigils inside string literals are left alone while
916
+ * template-literal `${…}` parts are code; comments are skipped too. When
917
+ * `nested`, stops at the `}` closing the enclosing block and returns the index
918
+ * just past it.
919
+ */
920
+ function scanCode(code, start, onRef, nested = false) {
921
+ let i = start;
922
+ let segStart = start;
923
+ let depth = 0;
924
+ while (i < code.length) {
925
+ const ch = code[i];
926
+ const next = code[i + 1];
927
+ const isComment = ch === "/" && (next === "/" || next === "*");
928
+ if (ch === "\"" || ch === "'" || ch === "`" || isComment) {
929
+ scanRefs(code.slice(segStart, i), onRef);
930
+ if (ch === "`") i = scanTemplate(code, i + 1, onRef);
931
+ else if (isComment) {
932
+ const close = code.indexOf(next === "/" ? "\n" : "*/", i + 2);
933
+ i = close === -1 ? code.length : next === "/" ? close : close + 2;
934
+ } else {
935
+ const close = findClosingQuote(code, i);
936
+ scanInterpolations(code.slice(i + 1, close), onRef);
937
+ i = Math.min(close + 1, code.length);
938
+ }
939
+ segStart = i;
940
+ continue;
941
+ }
942
+ if (nested && ch === "{") depth++;
943
+ else if (nested && ch === "}" && depth-- === 0) {
944
+ scanRefs(code.slice(segStart, i), onRef);
945
+ return i + 1;
946
+ }
947
+ i++;
948
+ }
949
+ scanRefs(code.slice(segStart), onRef);
950
+ return code.length;
951
+ }
952
+ /**
953
+ * Report the `$var` references a passage evaluates at runtime: `{$var}`
954
+ * displays, `{$expr}` expressions, macro arguments and `{do}` bodies (as
955
+ * code), quoted variable names bound by input macros, and `{$…}`
956
+ * interpolations in HTML attributes. Prose is literal text and not scanned.
957
+ */
958
+ function collectPassageRefs(content, storeVarMacros, onRef) {
959
+ const tokens = tokenize(content);
960
+ for (let t = 0; t < tokens.length; t++) {
961
+ const token = tokens[t];
962
+ if (token.type === "variable") {
963
+ if (token.scope === "variable" && token.name) onRef(token.name);
964
+ } else if (token.type === "expression") scanCode(token.expression, 0, onRef);
965
+ else if (token.type === "html") for (const value of Object.values(token.attributes)) scanInterpolations(value, onRef);
966
+ else if (token.type === "macro" && !token.isClose) {
967
+ scanCode(token.rawArgs, 0, onRef);
968
+ if (storeVarMacros.has(token.name.toLowerCase())) {
969
+ const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
970
+ const quoted = QUOTED_VAR_ARG_RE.exec(first);
971
+ if (quoted) onRef(quoted[1]);
972
+ }
973
+ if (token.name === "do") {
974
+ let close = t + 1;
975
+ while (close < tokens.length) {
976
+ const c = tokens[close];
977
+ if (c.type === "macro" && c.isClose && c.name === "do") break;
978
+ close++;
979
+ }
980
+ if (close < tokens.length) {
981
+ scanCode(content.slice(token.end, tokens[close].start), 0, onRef);
982
+ t = close;
983
+ }
984
+ }
985
+ }
986
+ }
987
+ }
988
+ /**
98
989
  * Scan all passages for $var references, check against schema.
99
990
  * Returns list of error messages (empty = valid).
991
+ *
992
+ * `storeVarMacros` lists the input macros whose first argument names a bound
993
+ * variable; it defaults to the built-in ones.
100
994
  */
101
- function validatePassages(passages, schema) {
995
+ function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_MACROS) {
102
996
  const errors = [];
997
+ const storeVarSet = new Set(Array.from(storeVarMacros, (m) => m.toLowerCase()));
103
998
  for (const [name, passage] of passages) {
104
999
  if (name === "StoryVariables" || name === "StoryTransients") continue;
105
1000
  const forLocals = extractForLocals(passage.content);
106
- let match;
107
- VAR_REF_RE.lastIndex = 0;
108
- while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
109
- const ref = match[1];
1001
+ collectPassageRefs(passage.content, storeVarSet, (ref) => {
110
1002
  const error = validateRef(ref, schema, forLocals);
111
1003
  if (error) errors.push(`Passage "${name}": ${error}`);
112
- }
1004
+ });
113
1005
  }
114
1006
  return errors;
115
1007
  }