@rohal12/spindle 0.51.4 → 0.52.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.
Files changed (69) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4512 -1702
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/action-registry.ts +70 -18
  7. package/src/automation/runner.ts +2 -1
  8. package/src/class-registry.ts +214 -103
  9. package/src/components/Passage.tsx +2 -2
  10. package/src/components/PassageDialog.tsx +2 -5
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Button.tsx +5 -31
  13. package/src/components/macros/Checkbox.tsx +7 -4
  14. package/src/components/macros/Computed.tsx +28 -14
  15. package/src/components/macros/For.tsx +29 -3
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +7 -6
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +12 -45
  20. package/src/components/macros/Meter.tsx +11 -3
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +5 -2
  25. package/src/components/macros/SaveManager.tsx +25 -8
  26. package/src/components/macros/Set.tsx +5 -4
  27. package/src/components/macros/Span.tsx +1 -0
  28. package/src/components/macros/StoryTitle.tsx +1 -0
  29. package/src/components/macros/Switch.tsx +13 -0
  30. package/src/components/macros/Unset.tsx +31 -10
  31. package/src/components/macros/VarDisplay.tsx +21 -4
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +17 -75
  34. package/src/components/macros/arg-utils.ts +107 -1
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +3 -2
  37. package/src/define-macro.ts +32 -5
  38. package/src/execute-mutation.ts +271 -68
  39. package/src/expression.ts +91 -59
  40. package/src/hooks/use-action.ts +24 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +12 -2
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1231 -97
  45. package/src/markup/ast.ts +7 -2
  46. package/src/markup/code-attributes.ts +64 -0
  47. package/src/markup/markdown.ts +188 -9
  48. package/src/markup/render.tsx +430 -49
  49. package/src/markup/tokenizer.ts +578 -110
  50. package/src/prng.ts +41 -8
  51. package/src/registry.ts +35 -0
  52. package/src/saves/save-manager.ts +346 -160
  53. package/src/saves/storage.ts +16 -7
  54. package/src/saves/types.ts +2 -1
  55. package/src/settings.ts +12 -9
  56. package/src/store.ts +640 -186
  57. package/src/story-api.ts +38 -73
  58. package/src/story-init.ts +1 -1
  59. package/src/story-variables.ts +98 -102
  60. package/src/triggers.ts +10 -12
  61. package/src/utils/counts.ts +45 -0
  62. package/src/utils/error-message.ts +12 -0
  63. package/src/utils/live-locals.ts +10 -3
  64. package/src/utils/namespace.ts +71 -0
  65. package/src/utils/object-path.ts +99 -14
  66. package/src/utils/stable-key.ts +14 -9
  67. package/src/widgets/widget-registry.ts +9 -0
  68. package/types/index.d.ts +43 -7
  69. package/types/tooling.d.ts +1 -0
@@ -1,3 +1,1126 @@
1
+ //#region src/js-lexer.ts
2
+ /** Transient name after `%`: an identifier, so `%3` is never a reference. */
3
+ var TRANS_NAME_RE = /[A-Za-z_]\w*/y;
4
+ /** An assignment operator: `=`, `+=`, `??=`, … but not `==` or `=>`. */
5
+ var ASSIGN_OP_RE = /(?:\*\*|<<|>>>?|&&|\|\||\?\?|[-+*/%&|^])?=(?![=>])/y;
6
+ /** Flags after the closing `/` of a regex literal. */
7
+ var REGEX_FLAGS_RE = /\w*/y;
8
+ /**
9
+ * Characters of identifiers (with the `\u…` escapes they may contain) and
10
+ * numbers. Surrogates stand for the astral identifier characters they encode.
11
+ */
12
+ var WORD_CHAR_RE = /[\p{ID_Continue}$\u200c\u200d\\\ud800-\udfff]/u;
13
+ /** An identifier, or the rest of one after a `$`, `_` or `@` sigil. */
14
+ var IDENT_RE = /[\p{ID_Continue}$\u200c\u200d]*/uy;
15
+ /** A sigil variable name: the whole identifier after the sigil. */
16
+ var VAR_NAME_RE = /^\w+$/;
17
+ /** What may start a property name after a `get`, `set`, … modifier. */
18
+ var KEY_START_RE = /[\p{ID_Continue}$\\"'[*#]/u;
19
+ var SPACE_RE = /\s/;
20
+ var LINE_TERMINATOR_RE = /[\n\r\u2028\u2029]/;
21
+ var LINE_TERMINATOR_G = /[\n\r\u2028\u2029]/g;
22
+ /**
23
+ * Keywords followed by an operand rather than an operator (a declaration's
24
+ * binding counts as one: `const of of xs`, `let { _a: x } = o`).
25
+ */
26
+ var OPERAND_KEYWORDS = new Set([
27
+ "await",
28
+ "case",
29
+ "const",
30
+ "delete",
31
+ "do",
32
+ "else",
33
+ "in",
34
+ "instanceof",
35
+ "let",
36
+ "new",
37
+ "return",
38
+ "throw",
39
+ "typeof",
40
+ "var",
41
+ "void",
42
+ "yield"
43
+ ]);
44
+ /** Keywords whose parenthesised header is followed by a statement. */
45
+ var HEADER_KEYWORDS = new Set([
46
+ "for",
47
+ "if",
48
+ "while",
49
+ "with"
50
+ ]);
51
+ /** Keywords that a line break ends the statement after. */
52
+ var RESTRICTED_KEYWORDS = new Set([
53
+ "break",
54
+ "continue",
55
+ "return"
56
+ ]);
57
+ /** Words that may precede a property name in an object literal or class. */
58
+ var MODIFIERS = new Set([
59
+ "async",
60
+ "get",
61
+ "set",
62
+ "static"
63
+ ]);
64
+ /**
65
+ * Scan the `"…"` or `'…'` string literal opening at `start`. `end` is the
66
+ * index just past its closing quote, or `src.length` when it is unterminated
67
+ * (`closed` false). A backslash escapes the character after it, so a quote
68
+ * after an even run of backslashes closes the string and one after an odd
69
+ * run does not.
70
+ */
71
+ function scanStringLiteral(src, start) {
72
+ const quote = src.charAt(start);
73
+ let i = start + 1;
74
+ while (i < src.length) {
75
+ const c = src.charAt(i);
76
+ if (c === "\\") i += 2;
77
+ else if (c === quote) return {
78
+ end: i + 1,
79
+ closed: true
80
+ };
81
+ else i++;
82
+ }
83
+ return {
84
+ end: src.length,
85
+ closed: false
86
+ };
87
+ }
88
+ /**
89
+ * Scan the regex literal (with flags) opening at `start`. `end` is the index
90
+ * just past it; an unterminated one (`closed` false) ends at the line break
91
+ * (escaped or not) or the end of the source.
92
+ */
93
+ function scanRegex(src, start, cache) {
94
+ const pending = [];
95
+ const done = (end, closed) => {
96
+ if (cache) for (const at of pending) cache.regexes.set(at, end * 2 + +closed);
97
+ return {
98
+ end,
99
+ closed
100
+ };
101
+ };
102
+ let i = start + 1;
103
+ while (i < src.length) {
104
+ const c = src.charAt(i);
105
+ if (c === "\\") {
106
+ if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
107
+ i += 2;
108
+ continue;
109
+ }
110
+ if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
111
+ if (c === "[") {
112
+ i = scanRegexClass(src, i, cache);
113
+ if (src.charAt(i) !== "]") return done(i, false);
114
+ i++;
115
+ const known = cache?.regexes.get(i);
116
+ if (known !== void 0) return done(Math.floor(known / 2), known % 2 === 1);
117
+ pending.push(i);
118
+ continue;
119
+ }
120
+ if (c === "/") {
121
+ REGEX_FLAGS_RE.lastIndex = i + 1;
122
+ const flags = REGEX_FLAGS_RE.exec(src)?.[0].length ?? 0;
123
+ return done(i + 1 + flags, true);
124
+ }
125
+ i++;
126
+ }
127
+ return done(src.length, false);
128
+ }
129
+ /**
130
+ * Index of the `]` closing the regex character class opening at `open`, or
131
+ * of the line break (or the end of the source) that leaves it unterminated.
132
+ * Every `[` in a class reads the rest of it the same way, so the result is
133
+ * recorded for them too: scans of `/[/[/[…` from each `/` share one pass.
134
+ */
135
+ function scanRegexClass(src, open, cache) {
136
+ const known = cache?.classes.get(open);
137
+ if (known !== void 0) return known;
138
+ const opens = [open];
139
+ let i = open + 1;
140
+ while (i < src.length) {
141
+ const c = src.charAt(i);
142
+ if (c === "\\") {
143
+ if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
144
+ i++;
145
+ break;
146
+ }
147
+ i += 2;
148
+ continue;
149
+ }
150
+ if (c === "]" || LINE_TERMINATOR_RE.test(c)) break;
151
+ if (c === "[" && cache) opens.push(i);
152
+ i++;
153
+ }
154
+ i = Math.min(i, src.length);
155
+ if (cache) for (const o of opens) cache.classes.set(o, i);
156
+ return i;
157
+ }
158
+ /**
159
+ * The next index from `from` on where `find` matches (-1 for none), for
160
+ * searches from increasing positions: a search from within the stretch the
161
+ * last one covered has the same answer.
162
+ */
163
+ function nextMatch(memo, from, find) {
164
+ if (memo && from >= memo.from && (memo.at < 0 || from <= memo.at)) return memo.at;
165
+ const at = find(from);
166
+ if (memo) {
167
+ memo.from = from;
168
+ memo.at = at;
169
+ }
170
+ return at;
171
+ }
172
+ /**
173
+ * Index just past the comment opening at `start` (`//` or `/*`), or -1 for
174
+ * an unterminated `/*` comment.
175
+ */
176
+ function findCommentEnd(src, start, cache) {
177
+ if (src.charAt(start + 1) === "/") {
178
+ const end = nextLineBreak(src, start, cache);
179
+ return end < 0 ? src.length : end;
180
+ }
181
+ const end = nextMatch(cache?.commentClose, start + 2, (from) => src.indexOf("*/", from));
182
+ return end < 0 ? -1 : end + 2;
183
+ }
184
+ /** Next line break from `from` on (-1 for none). */
185
+ function nextLineBreak(src, from, cache) {
186
+ return nextMatch(cache?.lineEnd, from, (at) => {
187
+ LINE_TERMINATOR_G.lastIndex = at;
188
+ return LINE_TERMINATOR_G.exec(src)?.index ?? -1;
189
+ });
190
+ }
191
+ /** Is there a line break between `from` and `to`? */
192
+ function lineBreakIn(src, from, to, cache) {
193
+ if (!cache) return LINE_TERMINATOR_RE.test(src.slice(from, to));
194
+ const at = nextLineBreak(src, from, cache);
195
+ return at >= 0 && at < to;
196
+ }
197
+ /** Index just past the comment opening at `start` (`//` or `/*`). */
198
+ function skipComment(src, start) {
199
+ const end = findCommentEnd(src, start);
200
+ return end < 0 ? src.length : end;
201
+ }
202
+ /** Index of the first character from `i` on that is no space or comment. */
203
+ function skipTrivia(src, i) {
204
+ while (i < src.length) {
205
+ const c = src.charAt(i);
206
+ if (SPACE_RE.test(c)) i++;
207
+ else if (c === "/" && "/*".includes(src.charAt(i + 1))) i = skipComment(src, i);
208
+ else break;
209
+ }
210
+ return i;
211
+ }
212
+ /** Characters that a scan checkpoint follows (`checkpointKey`). */
213
+ var CHECKPOINT_AFTER = new Set([
214
+ "}",
215
+ "\"",
216
+ "'",
217
+ "`",
218
+ "/",
219
+ "\n",
220
+ "\r",
221
+ "\u2028",
222
+ "\u2029",
223
+ " ",
224
+ " "
225
+ ]);
226
+ /**
227
+ * How far into a scan its results start to be shared within brackets
228
+ * (`checkpointKey`, `knownParen`). Most scans end sooner, so they don't pay
229
+ * for recording results no other scan will use; a long one pays this much
230
+ * before it can use what earlier scans recorded, which keeps scans from
231
+ * many starts about linear.
232
+ */
233
+ var SHARE_AFTER = 256;
234
+ /** A frame result: it is still open at the end of the source. */
235
+ var UNCLOSED = -1;
236
+ /** A frame result: a lexical error inside it ends the scan. */
237
+ var MALFORMED = -2;
238
+ /** A frame result: the scan stops at index `s` inside it (`STOPPED - s`). */
239
+ var STOPPED = -3;
240
+ /** The closers whose search for their frame a frame result may depend on. */
241
+ var CLOSERS = [
242
+ ")",
243
+ "]",
244
+ "}"
245
+ ];
246
+ var FRAME_KINDS = [
247
+ "block",
248
+ "object",
249
+ "class",
250
+ "template"
251
+ ];
252
+ /**
253
+ * Key of a frame result, for the frames whose code lexes the same whatever
254
+ * surrounds them: braces (a block, an object literal, a class body), a
255
+ * template literal and its interpolations. Undefined for parentheses and
256
+ * square brackets, which a stray closer inside may close.
257
+ */
258
+ function frameKey(f) {
259
+ const kind = f.interpolation ? 4 : FRAME_KINDS.indexOf(f.kind);
260
+ return kind < 0 ? void 0 : f.open * 5 + kind;
261
+ }
262
+ function createJsScanCache() {
263
+ return {
264
+ checkpoints: /* @__PURE__ */ new Map(),
265
+ stacks: /* @__PURE__ */ new Map(),
266
+ parens: /* @__PURE__ */ new Map(),
267
+ shareAfter: SHARE_AFTER,
268
+ braces: /* @__PURE__ */ new Map(),
269
+ brackets: /* @__PURE__ */ new Map(),
270
+ classes: /* @__PURE__ */ new Map(),
271
+ regexes: /* @__PURE__ */ new Map(),
272
+ lineEnd: {
273
+ from: Infinity,
274
+ at: -1
275
+ },
276
+ commentClose: {
277
+ from: Infinity,
278
+ at: -1
279
+ }
280
+ };
281
+ }
282
+ var newContext = () => ({
283
+ lookahead: true,
284
+ brackets: /* @__PURE__ */ new Map()
285
+ });
286
+ /** Index of the `]` matching the `[` at `open`, or `src.length`. */
287
+ function matchBracket(src, open, ctx) {
288
+ let close = ctx.brackets.get(open);
289
+ if (close === void 0) {
290
+ const ahead = {
291
+ lookahead: false,
292
+ brackets: ctx.brackets
293
+ };
294
+ close = scan(src, {}, open + 1, 0, frame("expr", "]", open), ahead);
295
+ ctx.brackets.set(open, close);
296
+ }
297
+ return close;
298
+ }
299
+ /**
300
+ * Is the code at `i`, just past a `%name`, the rest of an assignment target
301
+ * and its operator: ` = 1`, `.a[b] += 2`, but not `== 1`, `=> 1` or `% 2`?
302
+ */
303
+ function assignmentFollows(src, i, ctx) {
304
+ for (;;) {
305
+ i = skipTrivia(src, i);
306
+ const c = src.charAt(i);
307
+ if (c === "." && src.charAt(i + 1) !== ".") {
308
+ IDENT_RE.lastIndex = skipTrivia(src, i + 1);
309
+ if (!IDENT_RE.exec(src)?.[0]) return false;
310
+ i = IDENT_RE.lastIndex;
311
+ } else if (c === "[") {
312
+ i = matchBracket(src, i, ctx);
313
+ if (i >= src.length) return false;
314
+ i++;
315
+ } else break;
316
+ }
317
+ ASSIGN_OP_RE.lastIndex = i;
318
+ return ASSIGN_OP_RE.test(src);
319
+ }
320
+ function frame(kind, closer, open = -1) {
321
+ return {
322
+ kind,
323
+ closer,
324
+ open,
325
+ header: false,
326
+ operand: false,
327
+ ternary: 0,
328
+ interpolation: false
329
+ };
330
+ }
331
+ /**
332
+ * Walk `src`, reporting code characters, literal text and variable
333
+ * references to `handlers` in source order. Every character is reported
334
+ * exactly once (a variable reference covers its sigil and name). Returns
335
+ * `src.length`.
336
+ */
337
+ function lexJs(src, handlers, goal = "expression") {
338
+ return scan(src, handlers, 0, 0, frame(goal === "statements" ? "block" : "expr", ""), newContext());
339
+ }
340
+ /**
341
+ * Find where the code starting at `start` ends, lexing it as JavaScript:
342
+ * braces, quotes and backticks inside string, template and regex literals and
343
+ * comments don't count.
344
+ *
345
+ * Without `stop`, the code ends at the first `}` in code outside the
346
+ * brackets it opened (the `}` closing a `{…}` around it); with `stop`, at
347
+ * the first `{` in code, at any depth, for which `stop` holds. Returns the
348
+ * index of that `}` or `{`.
349
+ *
350
+ * Returns -1 when there is no such end, or when the code before it is not
351
+ * well-formed JavaScript as far as a lexer can tell: an unterminated string,
352
+ * regex literal or block comment, or a quote directly after an identifier or
353
+ * number (`don't`, but not `typeof'x'`). Callers fall back to a more lenient
354
+ * reading there, so text that only looks like code is not swallowed by an
355
+ * apostrophe or a stray quote.
356
+ */
357
+ function findCodeEnd(src, start, { goal = "expression", stop, stopKey, cache } = {}) {
358
+ const strict = {
359
+ stop,
360
+ malformed: false,
361
+ stopped: false,
362
+ passed: []
363
+ };
364
+ strict.cache = cache;
365
+ if (cache && !stop) strict.braces = cache.braces;
366
+ if (cache && (!stop || stopKey !== void 0)) {
367
+ const kindKey = `${goal} ${stop ? `stop ${stopKey}` : "}"}`;
368
+ let checkpoints = cache.checkpoints.get(kindKey);
369
+ if (!checkpoints) cache.checkpoints.set(kindKey, checkpoints = /* @__PURE__ */ new Map());
370
+ strict.checkpoints = checkpoints;
371
+ let parens = cache.parens.get(kindKey);
372
+ if (!parens) cache.parens.set(kindKey, parens = /* @__PURE__ */ new Map());
373
+ strict.parens = parens;
374
+ }
375
+ const ctx = {
376
+ lookahead: true,
377
+ brackets: cache?.brackets ?? /* @__PURE__ */ new Map(),
378
+ strict
379
+ };
380
+ const end = scan(src, {}, start, 0, frame(goal === "statements" ? "block" : "expr", stop ? "" : "}", start - 1), ctx);
381
+ if (strict.checkpoints) {
382
+ const ended = stop ? strict.stopped : end < src.length;
383
+ const result = strict.malformed ? MALFORMED : ended ? end : UNCLOSED;
384
+ for (const at of strict.passed) strict.checkpoints.set(at, result);
385
+ }
386
+ if (strict.malformed) return -1;
387
+ if (stop) return strict.stopped ? end : -1;
388
+ return end < src.length ? end : -1;
389
+ }
390
+ /**
391
+ * Scan from `start` within `outer`. Returns `src.length`, or the index of
392
+ * the `]` closing an `outer` square bracket, or the index just past the
393
+ * backtick closing an `outer` template literal.
394
+ *
395
+ * Nested brackets, template literals and their interpolations are frames
396
+ * on a stack, not recursive calls: however deep the nesting, the scan
397
+ * cannot overflow the call stack.
398
+ */
399
+ function scan(src, handlers, start, nesting, outer, ctx) {
400
+ const frames = [outer];
401
+ const top = () => frames[frames.length - 1];
402
+ /**
403
+ * Indices of the open frames closed by `)`, `]` and `}` (above `outer`),
404
+ * innermost last: the frame a closer closes is found without walking the
405
+ * stack.
406
+ */
407
+ const closers = {
408
+ ")": [],
409
+ "]": [],
410
+ "}": []
411
+ };
412
+ const innermost = (c) => {
413
+ const list = closers[c];
414
+ return list.length ? list[list.length - 1] : 0;
415
+ };
416
+ let i = start;
417
+ let operandNext = true;
418
+ let stmtNext = outer.kind === "block";
419
+ let arrowBody = false;
420
+ let keyNext = false;
421
+ let word = "";
422
+ let afterDot = false;
423
+ let dots = 0;
424
+ let afterHeaderKeyword = false;
425
+ let lastPunct = "";
426
+ let lastKeyword = "";
427
+ let lineBreak = false;
428
+ /** The next `{` at this depth opens a function or class body. */
429
+ let pendingBody;
430
+ const strict = ctx.strict;
431
+ const braces = strict?.braces;
432
+ const stacks = strict?.checkpoints && strict.cache?.stacks;
433
+ /**
434
+ * With checkpoints, per open frame the id of the stack up to it, found
435
+ * when a checkpoint needs it: the kind and flags of each frame, and the
436
+ * conditional-expression count of each but the innermost (which changes
437
+ * only while it is innermost, and is part of the checkpoint state). How a
438
+ * scan goes on depends on these, not on where the frames opened.
439
+ */
440
+ const stackIds = stacks ? [0] : void 0;
441
+ /** The id of the stack of open frames (`stackIds`). */
442
+ function stackId() {
443
+ let k = frames.length - 1;
444
+ while (stackIds[k] === void 0) k--;
445
+ for (k++; k < frames.length; k++) {
446
+ const f = frames[k];
447
+ const key = `${stackIds[k - 1]} ${frames[k - 1].ternary} ${f.kind} ${f.closer} ${+f.header}${+f.operand}${+f.interpolation}`;
448
+ let id = stacks.get(key);
449
+ if (id === void 0) stacks.set(key, id = stacks.size + 1);
450
+ stackIds[k] = id;
451
+ }
452
+ return stackIds[frames.length - 1];
453
+ }
454
+ /** A `{…}` frame result found in `braces`, to skip to. */
455
+ let skipTo;
456
+ const parens = strict?.parens;
457
+ const shareAfter = strict?.cache?.shareAfter ?? SHARE_AFTER;
458
+ /**
459
+ * With `parens`, per open frame and per closer in `CLOSERS`: the lowest
460
+ * frame index the closer looked for its frame at, while this frame or one
461
+ * opened in it was innermost. Below a frame's own index, the code in it
462
+ * depended on what is around it. A frame's entry takes in those of the
463
+ * frames opened in it as they close.
464
+ */
465
+ const lowest = parens ? [[
466
+ Infinity,
467
+ Infinity,
468
+ Infinity
469
+ ]] : void 0;
470
+ /**
471
+ * How many function or class bodies to come the scan has started, and with
472
+ * `parens`, that count when each open frame opened. Code in a `(…)` or
473
+ * `[…]` that starts one replaces the one to come, and the new one can't
474
+ * follow once the frame is closed: none is to come then.
475
+ */
476
+ let bodyStarts = 0;
477
+ const startsAt = parens ? [0] : void 0;
478
+ /** A `(…)` or `[…]` frame result found in `parens`, to skip to. */
479
+ let parenTo;
480
+ /** The closer `c` looked for its frame and found index `k`. */
481
+ function lookedFor(c, k) {
482
+ if (!lowest) return;
483
+ const entry = lowest[lowest.length - 1];
484
+ const n = CLOSERS.indexOf(c);
485
+ if (k < entry[n]) entry[n] = k;
486
+ }
487
+ /** Take the entry of the frame at index `k` into the one below it. */
488
+ function foldLowest(k) {
489
+ const from = lowest[k];
490
+ const into = lowest[k - 1];
491
+ for (let n = 0; n < 3; n++) if (from[n] < into[n]) into[n] = from[n];
492
+ }
493
+ /** Key of a `(…)` or `[…]` frame result, but for the closers it met. */
494
+ const parenKey = (f) => (f.open * 3 + (f.closer === "]" ? 2 : +f.header)) * 8;
495
+ /**
496
+ * Record how the frame at index `k` ends, if it is a `(…)` or `[…]`, its
497
+ * `lowest` entry complete: under the closers that looked below it, which
498
+ * found nothing to close (else the frame was closed with them).
499
+ */
500
+ function recordParen(k, result) {
501
+ const f = frames[k];
502
+ if (k === 0 || f.closer !== ")" && f.closer !== "]") return;
503
+ if (f.open - start < shareAfter) return;
504
+ let met = 0;
505
+ for (let n = 0; n < 3; n++) if (lowest[k][n] < k) met |= 1 << n;
506
+ parens.set(parenKey(f) + met, result);
507
+ }
508
+ /** Record how the open `(…)` and `[…]` frames end: the scan ends in them. */
509
+ function recordOpenParens(result) {
510
+ if (!lowest) return;
511
+ for (let k = frames.length - 1; k > 0; k--) {
512
+ recordParen(k, result);
513
+ foldLowest(k);
514
+ }
515
+ }
516
+ /**
517
+ * How an earlier scan found a `(…)` or `[…]` frame opening here to end,
518
+ * if it did: a result recorded under closers that find nothing to close
519
+ * around the frame here too. The frame is not open yet.
520
+ */
521
+ function knownParen(f) {
522
+ if (!parens || f.open - start < shareAfter) return void 0;
523
+ const at = [
524
+ Math.max(innermost(")"), innermost("}")),
525
+ Math.max(innermost("]"), innermost("}")),
526
+ innermost("}")
527
+ ];
528
+ let stray = 0;
529
+ for (let n = 0; n < 3; n++) {
530
+ const k = at[n];
531
+ if (k === 0 || n < 2 && frames[k].closer !== CLOSERS[n]) stray |= 1 << n;
532
+ }
533
+ const key = parenKey(f);
534
+ for (let met = stray;; met = met - 1 & stray) {
535
+ const result = parens.get(key + met);
536
+ if (result !== void 0) {
537
+ for (let n = 0; n < 3; n++) if (met & 1 << n) lookedFor(CLOSERS[n], at[n]);
538
+ return result;
539
+ }
540
+ if (met === 0) return void 0;
541
+ }
542
+ }
543
+ /** Record how the open frames end, when the scan ends in them. */
544
+ function recordOpen(result) {
545
+ if (!braces) return;
546
+ for (let k = 1; k < frames.length; k++) record(frames[k], result);
547
+ }
548
+ /** Record how a frame ends. */
549
+ function record(f, result) {
550
+ const key = braces && frameKey(f);
551
+ if (key !== void 0) braces.set(key, result);
552
+ }
553
+ /** How an earlier scan found the frame `f` to end, if it did. */
554
+ const known = (f) => {
555
+ const key = braces && frameKey(f);
556
+ return key === void 0 ? void 0 : braces.get(key);
557
+ };
558
+ /** End a strict scan at a lexical error. */
559
+ const malformed = () => {
560
+ recordOpen(MALFORMED);
561
+ recordOpenParens(MALFORMED);
562
+ strict.malformed = true;
563
+ return src.length;
564
+ };
565
+ function push(f) {
566
+ stackIds?.push(void 0);
567
+ closers[f.closer]?.push(frames.length);
568
+ frames.push(f);
569
+ lowest?.push([
570
+ Infinity,
571
+ Infinity,
572
+ Infinity
573
+ ]);
574
+ startsAt?.push(bodyStarts);
575
+ }
576
+ /**
577
+ * Just past a token or a space, line break or comment, but not within a
578
+ * word: the key of the position and the whole scan state there, the open
579
+ * frames included, which decide how the scan goes on (or undefined
580
+ * elsewhere). No word is being read there, and the character before is no
581
+ * word character, so a quote after it starts a string in any scan.
582
+ *
583
+ * Within brackets too, once the scan is long (`SHARE_AFTER`). Scans that
584
+ * never passed such a point in the same state each ran on to the end of
585
+ * the source, so many of them took quadratic time: inside an unclosed `(`
586
+ * in `{(}{(}{(…` (there was no point within brackets), or after the regex
587
+ * literals of `{a'</a </p>…` (there was no point after a space).
588
+ */
589
+ function checkpointKey() {
590
+ const ternary = top().ternary;
591
+ if (ternary > 3 || !CHECKPOINT_AFTER.has(src.charAt(i - 1)) || frames.length > 1 && i - start < shareAfter) return;
592
+ let state = ternary;
593
+ if (operandNext) state |= 4;
594
+ if (stmtNext) state |= 8;
595
+ if (keyNext) state |= 16;
596
+ if (pendingBody) {
597
+ state |= pendingBody.frame.kind === "class" ? 32 : 64;
598
+ if (pendingBody.frame.operand) state |= 128;
599
+ }
600
+ if (arrowBody) state |= 256;
601
+ if (afterDot) state |= 512;
602
+ if (afterHeaderKeyword) state |= 1024;
603
+ if (lineBreak) state |= 2048;
604
+ if (RESTRICTED_KEYWORDS.has(lastKeyword)) state |= 4096;
605
+ if (lastPunct === "=") state |= 8192;
606
+ if (lastPunct === ".") state |= Math.min(dots, 4) << 14;
607
+ const at = i * (1 << 17) + state;
608
+ if (frames.length === 1) return at;
609
+ const body = pendingBody ? frames.length - pendingBody.depth : "";
610
+ return `${at} ${stackId()} ${body}`;
611
+ }
612
+ /**
613
+ * May a string literal follow the word just read (`word`, or a variable
614
+ * reference) with nothing between? Only after a keyword taking an operand
615
+ * (`typeof'x'`, `case"a"`), `of` in a `for` header, or a modifier before a
616
+ * property name (`static'x'`, `get"y"() {}`); elsewhere the quote is an
617
+ * apostrophe (`don't`), and the text is no JavaScript.
618
+ */
619
+ function stringMayFollowWord() {
620
+ return OPERAND_KEYWORDS.has(word) || word === "of" && top().header || keyNext && MODIFIERS.has(word);
621
+ }
622
+ /** Close the frames from index `k` on. */
623
+ function truncate(k) {
624
+ if (lowest) {
625
+ for (let j = frames.length - 1; j >= k; j--) foldLowest(j);
626
+ lowest.length = k;
627
+ startsAt.length = k;
628
+ }
629
+ frames.length = k;
630
+ if (stackIds) stackIds.length = k;
631
+ for (const list of Object.values(closers)) while (list.length && list[list.length - 1] >= k) list.pop();
632
+ if (pendingBody && pendingBody.depth > k) pendingBody = void 0;
633
+ }
634
+ const code = (ch, index) => handlers.code?.(ch, index, nesting);
635
+ const literal = (text, index) => handlers.literal?.(text, index, nesting);
636
+ /** Literal text from `from` to `to`, sliced only for a handler. */
637
+ const literalSpan = (from, to) => handlers.literal?.(src.slice(from, to), from, nesting);
638
+ /** Track the word just read (`i` is the index just past it). */
639
+ function endWord() {
640
+ if (!word) return;
641
+ const keyword = afterDot || keyNext ? "" : word;
642
+ const inOperandPosition = operandNext && !stmtNext;
643
+ operandNext = OPERAND_KEYWORDS.has(keyword) || keyword === "of" && top().header && !operandNext;
644
+ afterHeaderKeyword = HEADER_KEYWORDS.has(keyword);
645
+ if (keyword === "function" || keyword === "class") {
646
+ const body = frame(keyword === "class" ? "class" : "block", "}");
647
+ body.operand = inOperandPosition;
648
+ pendingBody = {
649
+ depth: frames.length,
650
+ frame: body
651
+ };
652
+ bodyStarts++;
653
+ }
654
+ keyNext = keyNext && MODIFIERS.has(word) && KEY_START_RE.test(src.charAt(skipTrivia(src, i)));
655
+ stmtNext = keyword === "else" || keyword === "do" || keyword === "async" && stmtNext;
656
+ arrowBody = false;
657
+ afterDot = false;
658
+ lastPunct = "";
659
+ lastKeyword = keyword;
660
+ lineBreak = false;
661
+ word = "";
662
+ }
663
+ /** A string, template or regex literal, or a variable reference, ended. */
664
+ function endOperand() {
665
+ endWord();
666
+ operandNext = false;
667
+ stmtNext = false;
668
+ arrowBody = false;
669
+ keyNext = false;
670
+ afterHeaderKeyword = false;
671
+ afterDot = false;
672
+ lastPunct = "";
673
+ lastKeyword = "";
674
+ lineBreak = false;
675
+ }
676
+ /** Track an operator token: `operandNext` tells what may follow it. */
677
+ function endPunct(punct, nextIsOperand) {
678
+ operandNext = nextIsOperand;
679
+ stmtNext = false;
680
+ arrowBody = false;
681
+ keyNext = false;
682
+ afterDot = punct === "." && !nextIsOperand;
683
+ afterHeaderKeyword = false;
684
+ lastPunct = punct;
685
+ lastKeyword = "";
686
+ lineBreak = false;
687
+ }
688
+ /**
689
+ * A line break between tokens. After `return`, `break` or `continue` it
690
+ * ends the statement (ASI): `return⏎function f() {}` declares `f`.
691
+ */
692
+ function lineBreakSeen() {
693
+ lineBreak = true;
694
+ if (RESTRICTED_KEYWORDS.has(lastKeyword)) {
695
+ operandNext = true;
696
+ stmtNext = true;
697
+ lastKeyword = "";
698
+ }
699
+ }
700
+ function open(f) {
701
+ push(f);
702
+ stmtNext = f.kind === "block";
703
+ keyNext = f.kind === "object" || f.kind === "class";
704
+ }
705
+ function openBrace() {
706
+ let f;
707
+ if (pendingBody?.depth === frames.length) {
708
+ f = pendingBody.frame;
709
+ pendingBody = void 0;
710
+ } else if (!operandNext || stmtNext || arrowBody) f = frame("block", "}");
711
+ else {
712
+ f = frame("object", "}");
713
+ f.operand = true;
714
+ }
715
+ f.open = i;
716
+ endPunct("{", true);
717
+ const end = known(f);
718
+ if (end === void 0) open(f);
719
+ else skipTo = {
720
+ frame: f,
721
+ end
722
+ };
723
+ }
724
+ /** State after the `}` closing `closed`, a block or literal. */
725
+ function afterBrace(closed) {
726
+ if (closed.operand) endPunct("}", false);
727
+ else {
728
+ endPunct("}", true);
729
+ stmtNext = top().kind === "block";
730
+ keyNext = top().kind === "class";
731
+ }
732
+ }
733
+ /**
734
+ * Close the innermost frame that `c` closes; a stray closer, with no such
735
+ * frame within the innermost braces (a block, an object literal, a class
736
+ * body or an interpolation), is ignored. So a stray `)` or `]` never
737
+ * closes the braces around it, and how a `{…}` ends depends only on the
738
+ * code inside it.
739
+ */
740
+ function close(c) {
741
+ const k = Math.max(innermost(c), innermost("}"));
742
+ lookedFor(c, k);
743
+ if (k === 0 || frames[k].closer !== c) {
744
+ endPunct(c, c === "}");
745
+ return;
746
+ }
747
+ const closed = frames[k];
748
+ if (lowest && c !== "}") {
749
+ for (let j = frames.length - 1; j > k; j--) foldLowest(j);
750
+ const bodyStarted = bodyStarts !== startsAt[k];
751
+ recordParen(k, i * 2 + +bodyStarted);
752
+ }
753
+ truncate(k);
754
+ if (c === "]" && !ctx.lookahead) ctx.brackets.set(closed.open, i);
755
+ if (c === ")") {
756
+ endPunct(c, closed.header);
757
+ stmtNext = closed.header;
758
+ } else if (c === "]") endPunct(c, false);
759
+ else {
760
+ record(closed, i);
761
+ afterBrace(closed);
762
+ }
763
+ }
764
+ function trackCode(c) {
765
+ if (WORD_CHAR_RE.test(c)) {
766
+ word += c;
767
+ return;
768
+ }
769
+ endWord();
770
+ if (LINE_TERMINATOR_RE.test(c)) lineBreakSeen();
771
+ if (SPACE_RE.test(c)) return;
772
+ const t = top();
773
+ switch (c) {
774
+ case "(":
775
+ case "[": {
776
+ const f = frame("expr", c === "(" ? ")" : "]", i);
777
+ f.header = c === "(" && afterHeaderKeyword;
778
+ endPunct(c, true);
779
+ const result = knownParen(f);
780
+ if (result === void 0) open(f);
781
+ else parenTo = {
782
+ frame: f,
783
+ result
784
+ };
785
+ break;
786
+ }
787
+ case "{":
788
+ openBrace();
789
+ break;
790
+ case ")":
791
+ case "]":
792
+ case "}":
793
+ close(c);
794
+ break;
795
+ case ".":
796
+ dots = lastPunct === "." ? dots + 1 : 1;
797
+ endPunct(c, dots === 3);
798
+ break;
799
+ case ";":
800
+ endPunct(c, true);
801
+ stmtNext = t.kind === "block";
802
+ keyNext = t.kind === "class";
803
+ break;
804
+ case ",":
805
+ endPunct(c, true);
806
+ keyNext = t.kind === "object";
807
+ break;
808
+ case "?":
809
+ endPunct(c, true);
810
+ t.ternary++;
811
+ break;
812
+ case ":":
813
+ endPunct(c, true);
814
+ if (t.ternary > 0) t.ternary--;
815
+ else stmtNext = t.kind === "block";
816
+ break;
817
+ case "#":
818
+ endPunct(c, false);
819
+ afterDot = true;
820
+ break;
821
+ case "*": {
822
+ const key = keyNext;
823
+ endPunct(c, true);
824
+ keyNext = key;
825
+ break;
826
+ }
827
+ case ">": {
828
+ const arrow = lastPunct === "=";
829
+ endPunct(c, true);
830
+ arrowBody = arrow;
831
+ break;
832
+ }
833
+ default: endPunct(c, true);
834
+ }
835
+ }
836
+ while (i < src.length) {
837
+ const ch = src.charAt(i);
838
+ const checkpoints = strict?.checkpoints;
839
+ const key = checkpoints && checkpointKey();
840
+ if (key !== void 0) {
841
+ const known = checkpoints.get(key);
842
+ if (known === void 0) strict.passed.push(key);
843
+ else if (frames.length > 1 && known < 0) {
844
+ strict.malformed = known === MALFORMED;
845
+ return src.length;
846
+ } else if (known === MALFORMED) return malformed();
847
+ else if (known === UNCLOSED) {
848
+ i = src.length;
849
+ break;
850
+ } else {
851
+ strict.stopped = true;
852
+ return known;
853
+ }
854
+ }
855
+ if (top().kind === "template") {
856
+ if (ch === "\\") {
857
+ literal(src.slice(i, i + 2), i);
858
+ i += 2;
859
+ } else if (ch === "`") {
860
+ literal(ch, i);
861
+ if (frames.length === 1) return i + 1;
862
+ record(top(), i);
863
+ i++;
864
+ truncate(frames.length - 1);
865
+ endOperand();
866
+ } else if (ch === "$" && src.charAt(i + 1) === "{") {
867
+ const f = frame("expr", "}", i);
868
+ f.interpolation = true;
869
+ const end = known(f);
870
+ if (end === MALFORMED) return malformed();
871
+ if (end === UNCLOSED) {
872
+ i = src.length;
873
+ break;
874
+ }
875
+ if (end !== void 0) {
876
+ i = end + 1;
877
+ continue;
878
+ }
879
+ literal("${", i);
880
+ i += 2;
881
+ nesting++;
882
+ endPunct("{", true);
883
+ open(f);
884
+ } else {
885
+ literal(ch, i);
886
+ i++;
887
+ }
888
+ continue;
889
+ }
890
+ if (ch === "\"" || ch === "'") {
891
+ if (strict && i > start && WORD_CHAR_RE.test(src.charAt(i - 1))) {
892
+ if (!stringMayFollowWord()) return malformed();
893
+ }
894
+ endWord();
895
+ const { end, closed } = scanStringLiteral(src, i);
896
+ if (strict && !closed) return malformed();
897
+ literalSpan(i, end);
898
+ i = end;
899
+ endOperand();
900
+ continue;
901
+ }
902
+ if (ch === "`") {
903
+ endWord();
904
+ const f = frame("template", "`", i);
905
+ const end = known(f);
906
+ if (end === MALFORMED) return malformed();
907
+ if (end === UNCLOSED) {
908
+ i = src.length;
909
+ break;
910
+ }
911
+ if (end !== void 0) {
912
+ i = end + 1;
913
+ endOperand();
914
+ continue;
915
+ }
916
+ literal(ch, i);
917
+ push(f);
918
+ i++;
919
+ continue;
920
+ }
921
+ if (ch === "{" && strict?.stop?.(i)) {
922
+ recordOpenParens(STOPPED - i);
923
+ strict.stopped = true;
924
+ return i;
925
+ }
926
+ if (ch === "}") {
927
+ const k = innermost("}");
928
+ lookedFor("}", k);
929
+ if (frames[k].interpolation) {
930
+ endWord();
931
+ record(frames[k], i);
932
+ truncate(k);
933
+ nesting--;
934
+ literal(ch, i);
935
+ i++;
936
+ continue;
937
+ }
938
+ }
939
+ if (ch === "/") {
940
+ endWord();
941
+ const next = src.charAt(i + 1);
942
+ if (next === "/" || next === "*") {
943
+ let end = findCommentEnd(src, i, strict?.cache);
944
+ if (end < 0) {
945
+ if (strict) return malformed();
946
+ end = src.length;
947
+ }
948
+ literalSpan(i, end);
949
+ if (next === "*" && lineBreakIn(src, i, end, strict?.cache)) lineBreakSeen();
950
+ i = end;
951
+ continue;
952
+ }
953
+ if (operandNext) {
954
+ const { end, closed } = scanRegex(src, i, strict?.cache);
955
+ if (strict && !closed) return malformed();
956
+ literalSpan(i, end);
957
+ i = end;
958
+ endOperand();
959
+ continue;
960
+ }
961
+ }
962
+ if (!word && lineBreak && !operandNext && top().kind === "class") {
963
+ IDENT_RE.lastIndex = i;
964
+ const next = IDENT_RE.exec(src)?.[0];
965
+ if (next && next !== "in" && next !== "instanceof") keyNext = true;
966
+ }
967
+ if (ch === "@" || (ch === "$" || ch === "_") && !word) {
968
+ endWord();
969
+ IDENT_RE.lastIndex = i + 1;
970
+ const name = IDENT_RE.exec(src)?.[0] ?? "";
971
+ if (VAR_NAME_RE.test(name) && !isPropertyName(ch, i + 1 + name.length)) {
972
+ handlers.variable?.(ch, name, i, nesting);
973
+ i += 1 + name.length;
974
+ endOperand();
975
+ continue;
976
+ }
977
+ }
978
+ if (ch === "%") {
979
+ endWord();
980
+ TRANS_NAME_RE.lastIndex = i + 1;
981
+ const name = TRANS_NAME_RE.exec(src)?.[0];
982
+ if (name) {
983
+ const end = i + 1 + name.length;
984
+ if (operandNext || lineBreak && ctx.lookahead && assignmentFollows(src, end, ctx)) {
985
+ handlers.variable?.("%", name, i, nesting);
986
+ i = end;
987
+ endOperand();
988
+ continue;
989
+ }
990
+ }
991
+ }
992
+ if ((ch === "+" || ch === "-") && src.charAt(i + 1) === ch) {
993
+ endWord();
994
+ const postfix = !operandNext && !lineBreak;
995
+ code(ch, i);
996
+ code(ch, i + 1);
997
+ i += 2;
998
+ endPunct(ch, !postfix);
999
+ continue;
1000
+ }
1001
+ if (ch === "?") {
1002
+ const next = src.charAt(i + 1);
1003
+ if (next === "?" || next === "." && !/\d/.test(src.charAt(i + 2))) {
1004
+ endWord();
1005
+ code(ch, i);
1006
+ code(next, i + 1);
1007
+ i += 2;
1008
+ dots = 1;
1009
+ endPunct(next, next === "?");
1010
+ continue;
1011
+ }
1012
+ }
1013
+ if (ch === outer.closer && frames.length === 1) break;
1014
+ trackCode(ch);
1015
+ if (skipTo) {
1016
+ const { frame: skipped, end } = skipTo;
1017
+ skipTo = void 0;
1018
+ if (end === MALFORMED) return malformed();
1019
+ if (end === UNCLOSED) {
1020
+ i = src.length;
1021
+ break;
1022
+ }
1023
+ i = end;
1024
+ afterBrace(skipped);
1025
+ i++;
1026
+ continue;
1027
+ }
1028
+ if (parenTo) {
1029
+ const { frame: skipped, result } = parenTo;
1030
+ parenTo = void 0;
1031
+ if (result === MALFORMED) return malformed();
1032
+ if (result === UNCLOSED) {
1033
+ i = src.length;
1034
+ break;
1035
+ }
1036
+ if (result <= STOPPED) {
1037
+ recordOpenParens(result);
1038
+ strict.stopped = true;
1039
+ return STOPPED - result;
1040
+ }
1041
+ i = Math.floor(result / 2);
1042
+ if (result % 2 === 1) pendingBody = void 0;
1043
+ if (skipped.closer === ")") {
1044
+ endPunct(")", skipped.header);
1045
+ stmtNext = skipped.header;
1046
+ } else endPunct("]", false);
1047
+ i++;
1048
+ continue;
1049
+ }
1050
+ code(ch, i);
1051
+ i++;
1052
+ }
1053
+ if (i >= src.length) {
1054
+ recordOpen(UNCLOSED);
1055
+ recordOpenParens(UNCLOSED);
1056
+ }
1057
+ if (!ctx.lookahead) {
1058
+ for (const f of frames) if (f.closer === "]") ctx.brackets.set(f.open, i);
1059
+ }
1060
+ return Math.min(i, src.length);
1061
+ /**
1062
+ * Is the `$`/`_` word ending at `end` a property name: after `.`, a key
1063
+ * in an object literal (`{ _id: 1 }`, `{ _m() {} }`) or a member name in
1064
+ * a class body?
1065
+ */
1066
+ function isPropertyName(sigil, end) {
1067
+ if (afterDot) return true;
1068
+ if (!keyNext || sigil === "@") return false;
1069
+ if (top().kind === "class") return true;
1070
+ const next = src.charAt(skipTrivia(src, end));
1071
+ return next === ":" || next === "(";
1072
+ }
1073
+ }
1074
+ //#endregion
1075
+ //#region src/markup/code-attributes.ts
1076
+ /**
1077
+ * Attributes whose value is code with braces of its own, not text: event
1078
+ * handlers (`on…`, JavaScript), `pattern` (a regular expression, as in
1079
+ * `\p{L}{2,3}`) and `srcdoc` (an HTML document, with its own scripts and
1080
+ * styles). Their braces are code, so they are not read as markup: only
1081
+ * sigil references (`{$x}`, `{_x}`, `{@x}`, `{%x}` and expressions starting
1082
+ * with one) are resolved, and any other `{` is literal.
1083
+ */
1084
+ function isCodeAttribute(name) {
1085
+ const lower = name.toLowerCase();
1086
+ return lower.startsWith("on") || lower === "pattern" || lower === "srcdoc";
1087
+ }
1088
+ /**
1089
+ * Split a code attribute value into literal text and its `{…}` blocks that
1090
+ * start with a sigil. Braces inside strings don't close a block; an
1091
+ * unclosed block, any other brace and backslashes are text. A block whose
1092
+ * sigil is not followed by a word character (`{$(…)}`) is code, kept
1093
+ * verbatim: not resolved, and not decoded like text.
1094
+ */
1095
+ function splitSigilTemplate(template) {
1096
+ const parts = [];
1097
+ const memo = createScanMemo();
1098
+ let text = "";
1099
+ let i = 0;
1100
+ while (i < template.length) {
1101
+ const brace = template.indexOf("{", i);
1102
+ if (brace === -1) {
1103
+ text += template.slice(i);
1104
+ break;
1105
+ }
1106
+ text += template.slice(i, brace);
1107
+ i = brace + 1;
1108
+ const sigil = template[i];
1109
+ const end = sigil === "$" || sigil === "_" || sigil === "@" || sigil === "%" ? scanBalancedBrace(template, i, memo) : -1;
1110
+ if (end === -1) {
1111
+ text += "{";
1112
+ continue;
1113
+ }
1114
+ if (text) parts.push({ text });
1115
+ text = "";
1116
+ const block = template.slice(i, end);
1117
+ parts.push(/^.\w/.test(block) ? { expr: block } : { verbatim: `{${block}}` });
1118
+ i = end + 1;
1119
+ }
1120
+ if (text) parts.push({ text });
1121
+ return parts;
1122
+ }
1123
+ //#endregion
1
1124
  //#region src/markup/tokenizer.ts
2
1125
  /** Tag name must start with a letter (covers standard and custom elements). */
3
1126
  var VALID_TAG_START = /[a-zA-Z]/;
@@ -18,6 +1141,20 @@ var HTML_VOID_TAGS = new Set([
18
1141
  "track",
19
1142
  "wbr"
20
1143
  ]);
1144
+ /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
1145
+ var SIGIL_CHARS = new Set([
1146
+ "$",
1147
+ "_",
1148
+ "@",
1149
+ "%"
1150
+ ]);
1151
+ /**
1152
+ * Characters other than a sigil that open an expression after `{`:
1153
+ * `{(Math.max($a, 0))}`, `{!$done}`. Other characters that can start an
1154
+ * expression (quotes, digits, `[`, `-`) are left out, because braces around
1155
+ * them are common as literal text (JSON, regex quantifiers, `{[[link]]}`).
1156
+ */
1157
+ var EXPRESSION_START = new Set(["(", "!"]);
21
1158
  /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
22
1159
  var RAW_BODY_MACROS = new Set(["do"]);
23
1160
  /**
@@ -84,7 +1221,7 @@ function parseSelectors(input, startIdx) {
84
1221
  while (i < input.length) if (/[a-zA-Z0-9_-]/.test(input[i])) {
85
1222
  name += input[i];
86
1223
  i++;
87
- } else if (input[i] === "{" && (input[i + 1] === "$" || input[i + 1] === "_" || input[i + 1] === "@")) {
1224
+ } else if (input[i] === "{" && SIGIL_CHARS.has(input[i + 1])) {
88
1225
  const braceStart = i;
89
1226
  i += 2;
90
1227
  while (i < input.length && /[\w.]/.test(input[i])) i++;
@@ -109,10 +1246,48 @@ function parseSelectors(input, startIdx) {
109
1246
  * Parse HTML attributes from a string starting at position j.
110
1247
  * Returns the attributes and the position after the last attribute.
111
1248
  */
112
- function parseHtmlAttributes(input, j) {
1249
+ function parseHtmlAttributes(input, j, memo) {
113
1250
  const attributes = {};
1251
+ const seen = /* @__PURE__ */ new Set();
1252
+ return {
1253
+ attributes,
1254
+ endIdx: scanAttributes(input, j, memo, (name, value) => {
1255
+ const lower = name.toLowerCase();
1256
+ if (seen.has(lower)) return;
1257
+ seen.add(lower);
1258
+ Object.defineProperty(attributes, name, {
1259
+ value,
1260
+ enumerable: true,
1261
+ writable: true,
1262
+ configurable: true
1263
+ });
1264
+ })
1265
+ };
1266
+ }
1267
+ /**
1268
+ * Scan the attributes of a tag from position j, handing each to `add` when
1269
+ * given. Returns the position after the last attribute.
1270
+ *
1271
+ * Where the attributes end depends only on where an attribute starts, so
1272
+ * without `add` the result is recorded for every attribute start passed (in
1273
+ * `memo.tag`) and a recorded one is used instead of scanning on. An unquoted
1274
+ * value takes in a `<`, so in `<a x=<a x=<a x=…` each opener's scan would
1275
+ * otherwise run over all the openers after it, taking quadratic time. A tag
1276
+ * whose attributes end in its `>` is consumed, so collecting the attributes
1277
+ * of those (with `add`, unrecorded) reads each character once.
1278
+ */
1279
+ function scanAttributes(input, j, memo, add) {
1280
+ const passed = [];
114
1281
  while (j < input.length) {
115
1282
  while (j < input.length && /\s/.test(input[j])) j++;
1283
+ if (!add) {
1284
+ const known = memo.tag.get(j);
1285
+ if (known !== void 0) {
1286
+ j = known;
1287
+ break;
1288
+ }
1289
+ passed.push(j);
1290
+ }
116
1291
  if (j >= input.length || input[j] === ">" || input[j] === "/" && input[j + 1] === ">") break;
117
1292
  const attrStart = j;
118
1293
  while (j < input.length && /[a-zA-Z0-9_\-:@]/.test(input[j])) j++;
@@ -127,31 +1302,180 @@ function parseHtmlAttributes(input, j) {
127
1302
  const quote = input[j];
128
1303
  j++;
129
1304
  const valStart = j;
130
- while (j < input.length) {
131
- if (input[j] === "{") {
132
- const closeIdx = scanBalancedBrace(input, j + 1);
133
- if (closeIdx !== -1) {
134
- j = closeIdx + 1;
135
- continue;
136
- }
137
- } else if (input[j] === quote) break;
138
- j++;
139
- }
140
- attributes[attrName] = input.slice(valStart, j);
1305
+ j = scanQuotedValue(input, j, quote, isCodeAttribute(attrName), memo);
1306
+ add?.(attrName, input.slice(valStart, j));
141
1307
  if (j < input.length) j++;
142
1308
  } else {
143
1309
  const valStart = j;
144
- while (j < input.length && /[^\s>]/.test(input[j])) j++;
145
- attributes[attrName] = input.slice(valStart, j);
1310
+ const run = memo.unquoted;
1311
+ if (j < run.from || j > run.to) {
1312
+ run.from = j;
1313
+ while (j < input.length && /[^\s>]/.test(input[j])) j++;
1314
+ run.to = j;
1315
+ }
1316
+ j = run.to;
1317
+ add?.(attrName, input.slice(valStart, j));
1318
+ }
1319
+ } else add?.(attrName, "");
1320
+ }
1321
+ for (const at of passed) memo.tag.set(at, j);
1322
+ return j;
1323
+ }
1324
+ /**
1325
+ * Index of the quote ending the attribute value that starts at `j`, or the
1326
+ * end of the input. A `{…}` interpolation in it is skipped whole, so quotes
1327
+ * inside it don't end the value. A code attribute's value is no markup
1328
+ * (`isCodeAttribute`): only `{` and a sigil open a reference, other braces
1329
+ * and backslashes are text, as `splitSigilTemplate` reads them.
1330
+ *
1331
+ * The value reads the same from just past an interpolation, however the scan
1332
+ * got there, so the end is recorded there (in `memo.value`), and a recorded
1333
+ * one is used. An unclosed value that skips interpolations to the end of
1334
+ * the input (`<a x="}<a x={<a x="}…`) is then read once, not once per tag.
1335
+ */
1336
+ function scanQuotedValue(input, j, quote, code, memo) {
1337
+ const variant = (quote === "\"" ? 0 : 2) + (code ? 1 : 0);
1338
+ const passed = [];
1339
+ let checkpoint = true;
1340
+ while (j < input.length) {
1341
+ if (checkpoint) {
1342
+ const known = memo.value.get(j * 4 + variant);
1343
+ if (known !== void 0) {
1344
+ j = known;
1345
+ break;
1346
+ }
1347
+ passed.push(j * 4 + variant);
1348
+ checkpoint = false;
1349
+ }
1350
+ if (!code && input[j] === "\\") {
1351
+ let k = j + 1;
1352
+ while (input[k] === "\\") k++;
1353
+ j = (input[k] === "{" || input[k] === "}") && (k - j) % 2 === 1 ? k + 1 : k;
1354
+ continue;
1355
+ }
1356
+ if (input[j] === "{") {
1357
+ const closeIdx = !code ? scanBlockClose(input, j, memo) : SIGIL_CHARS.has(input[j + 1]) ? scanBalancedBrace(input, j + 1, memo) : -1;
1358
+ if (closeIdx !== -1) {
1359
+ j = closeIdx + 1;
1360
+ checkpoint = true;
1361
+ continue;
146
1362
  }
147
- } else attributes[attrName] = "";
1363
+ } else if (input[j] === quote) break;
1364
+ j++;
148
1365
  }
1366
+ for (const at of passed) memo.value.set(at, j);
1367
+ return j;
1368
+ }
1369
+ function createScanMemo() {
149
1370
  return {
150
- attributes,
151
- endIdx: j
1371
+ code: /* @__PURE__ */ new Map(),
1372
+ js: createJsScanCache(),
1373
+ brace: /* @__PURE__ */ new Map(),
1374
+ template: /* @__PURE__ */ new Map(),
1375
+ templateText: /* @__PURE__ */ new Map(),
1376
+ name: {
1377
+ from: 0,
1378
+ to: -1
1379
+ },
1380
+ unquoted: {
1381
+ from: 0,
1382
+ to: -1
1383
+ },
1384
+ link: /* @__PURE__ */ new Map(),
1385
+ tag: /* @__PURE__ */ new Map(),
1386
+ value: /* @__PURE__ */ new Map(),
1387
+ rawClose: /* @__PURE__ */ new Map()
152
1388
  };
153
1389
  }
154
1390
  /**
1391
+ * Find the } closing the code that starts at position i: a `{$…}`
1392
+ * expression from its sigil on, an attribute interpolation. The code is
1393
+ * lexed as JavaScript, so only a } in code outside the brackets it opened
1394
+ * counts — not one in a string, template or regex literal or a comment —
1395
+ * and `/` after an operand divides.
1396
+ *
1397
+ * Text that is not well-formed JavaScript (an apostrophe, an unterminated
1398
+ * literal or comment, unbalanced brackets) is scanned leniently instead, as
1399
+ * prose-like macro arguments are: braces count except inside string and
1400
+ * template literals, and a quote that can't start a string (apostrophe,
1401
+ * escaped, not closed on its line) is text.
1402
+ *
1403
+ * Returns the index of the closing } or -1 if there is none. Pass the same
1404
+ * memo to repeated scans of one input to share their work.
1405
+ */
1406
+ function scanBalancedBrace(input, i, memo = createScanMemo()) {
1407
+ return scanClose(input, i, i, memo);
1408
+ }
1409
+ /**
1410
+ * Index of the } closing the macro whose content (name, then arguments)
1411
+ * starts at `contentStart`, or -1. The name runs up to whitespace or the }
1412
+ * (as `parseMacroContent` reads it); the arguments after it are code. The
1413
+ * lenient scan covers the whole content, as it always has.
1414
+ */
1415
+ function scanMacroClose(input, contentStart, memo) {
1416
+ const run = memo.name;
1417
+ if (contentStart < run.from || contentStart > run.to) {
1418
+ let k = contentStart;
1419
+ while (k < input.length && input[k] !== "}" && !/\s/.test(input[k])) k++;
1420
+ run.from = contentStart;
1421
+ run.to = k;
1422
+ }
1423
+ return scanClose(input, run.to, contentStart, memo);
1424
+ }
1425
+ /**
1426
+ * Index of the } closing the `{…}` block opened at `open`, read as passage
1427
+ * text reads it: a macro (`{name args}`, `{/name}`) has its arguments lexed
1428
+ * after its name, an expression (`{$…}`, `{(…)}`, `{!…}`) from its first
1429
+ * character, after any `.class#id` selectors. Any other block is scanned as
1430
+ * code from just past the {. Returns -1 if it is unclosed.
1431
+ */
1432
+ function scanBlockClose(input, open, memo) {
1433
+ let at = open + 1;
1434
+ const c = input[at];
1435
+ if (c === "." || c === "#") {
1436
+ at = parseSelectors(input, at).endIdx;
1437
+ if (input[at] === " ") at++;
1438
+ }
1439
+ const first = input[at];
1440
+ if (first !== void 0 && (first === "/" || /[a-zA-Z]/.test(first))) return scanMacroClose(input, at, memo);
1441
+ return scanBalancedBrace(input, at, memo);
1442
+ }
1443
+ /** Lex the code from `codeStart`, else scan leniently from `lenientStart`. */
1444
+ function scanClose(input, codeStart, lenientStart, memo) {
1445
+ let end = memo.code.get(lenientStart);
1446
+ if (end === void 0) {
1447
+ end = findCodeEnd(input, codeStart, { cache: memo.js });
1448
+ if (end === -1) end = scanBraceLenient(input, lenientStart, memo);
1449
+ memo.code.set(lenientStart, end);
1450
+ }
1451
+ return end;
1452
+ }
1453
+ /**
1454
+ * Index of the ]] closing the link whose text starts at `from`, or -1. A
1455
+ * [[ inside opens a nested pair. Each nested [[ starts a scan that reads the
1456
+ * rest the same way, so its result is recorded too: unclosed [[s don't each
1457
+ * scan to the end of the input.
1458
+ */
1459
+ function scanLinkClose(input, from, memo) {
1460
+ const known = memo.link.get(from);
1461
+ if (known !== void 0) return known;
1462
+ const opens = [from];
1463
+ let i = from;
1464
+ while (i < input.length) if (input[i] === "[" && input[i + 1] === "[") {
1465
+ i += 2;
1466
+ const inner = memo.link.get(i);
1467
+ if (inner === void 0) opens.push(i);
1468
+ else if (inner === -1) break;
1469
+ else i = inner + 2;
1470
+ } else if (input[i] === "]" && input[i + 1] === "]") {
1471
+ memo.link.set(opens.pop(), i);
1472
+ if (!opens.length) return i;
1473
+ i += 2;
1474
+ } else i++;
1475
+ for (const open of opens) memo.link.set(open, -1);
1476
+ return -1;
1477
+ }
1478
+ /**
155
1479
  * Skip a '…' or "…" string literal opening at i.
156
1480
  * Returns the index just past the closing quote, or -1 if the string is
157
1481
  * not closed on the same line (JS strings can't span lines unescaped).
@@ -169,24 +1493,6 @@ function skipQuoted(input, i) {
169
1493
  return -1;
170
1494
  }
171
1495
  /**
172
- * Skip a `…` template literal opening at i, including ${…} parts.
173
- * Returns the index just past the closing backtick, or -1 if unclosed.
174
- */
175
- function skipTemplate(input, i) {
176
- let j = i + 1;
177
- while (j < input.length) {
178
- const c = input[j];
179
- if (c === "\\") j += 2;
180
- else if (c === "`") return j + 1;
181
- else if (c === "$" && input[j + 1] === "{") {
182
- const closeIdx = scanBalancedBrace(input, j + 2);
183
- if (closeIdx === -1) return -1;
184
- j = closeIdx + 1;
185
- } else j++;
186
- }
187
- return -1;
188
- }
189
- /**
190
1496
  * A quote directly after a letter/digit is an apostrophe (don't), not a
191
1497
  * string; after a backslash it is an escaped attribute delimiter (\").
192
1498
  */
@@ -194,39 +1500,131 @@ var NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
194
1500
  /**
195
1501
  * Scan for the balanced closing } starting at position i (just past the {).
196
1502
  * Braces inside string and template literals are ignored. A quote that
197
- * can't start a string (apostrophe, escaped, unterminated) counts as text.
1503
+ * can't start a string (apostrophe, escaped, unterminated) counts as text,
1504
+ * and so does a backtick without a closing one.
198
1505
  * Returns the index of the closing } or -1 if unbalanced.
1506
+ *
1507
+ * Template literals and their ${…} parts are scans on a stack, not
1508
+ * recursive calls, so deep nesting can't overflow the call stack. How the
1509
+ * text reads depends only on where a scan is, so a scan passing a point —
1510
+ * just past a {, a string or a template literal — goes on as a scan
1511
+ * starting there would: its result there is recorded too, and a recorded
1512
+ * result is used instead of scanning on. Scans from many starts in one
1513
+ * input then take about linear time.
199
1514
  */
200
- function scanBalancedBrace(input, i) {
201
- let depth = 1;
202
- while (i < input.length) {
203
- const c = input[i];
204
- if (c === "{") depth++;
205
- else if (c === "}") {
206
- if (--depth === 0) return i;
207
- } else if ((c === "\"" || c === "'") && !(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]))) {
208
- const end = skipQuoted(input, i);
209
- if (end !== -1) {
210
- i = end;
1515
+ function scanBraceLenient(input, i, memo) {
1516
+ const known = memo.brace.get(i);
1517
+ if (known !== void 0) return known;
1518
+ const stack = [{
1519
+ template: false,
1520
+ start: i,
1521
+ levels: [[]]
1522
+ }];
1523
+ for (;;) {
1524
+ const scan = stack[stack.length - 1];
1525
+ let end;
1526
+ if (scan.template) {
1527
+ let point = true;
1528
+ while (end === void 0 && i < input.length) {
1529
+ if (point) {
1530
+ const known = memo.templateText.get(i);
1531
+ if (known !== void 0) {
1532
+ end = known;
1533
+ break;
1534
+ }
1535
+ scan.passed.push(i);
1536
+ point = false;
1537
+ }
1538
+ const c = input[i];
1539
+ if (c === "\\") {
1540
+ i += 2;
1541
+ point = true;
1542
+ } else if (c === "`") end = i + 1;
1543
+ else if (c === "$" && input[i + 1] === "{") {
1544
+ const inner = memo.brace.get(i + 2);
1545
+ if (inner === void 0) break;
1546
+ if (inner === -1) end = -1;
1547
+ else {
1548
+ i = inner + 1;
1549
+ point = true;
1550
+ }
1551
+ } else i++;
1552
+ }
1553
+ if (end === void 0 && i < input.length) {
1554
+ stack.push({
1555
+ template: false,
1556
+ start: i + 2,
1557
+ levels: [[]]
1558
+ });
1559
+ i += 2;
211
1560
  continue;
212
1561
  }
213
- } else if (c === "`") {
214
- const end = skipTemplate(input, i);
215
- if (end !== -1) {
216
- i = end;
1562
+ end ?? (end = -1);
1563
+ } else {
1564
+ const { levels } = scan;
1565
+ /** The } at `close` ends the innermost level. */
1566
+ const closeLevel = (close) => {
1567
+ for (const at of levels.pop()) memo.brace.set(at, close);
1568
+ if (!levels.length) end = close;
1569
+ };
1570
+ while (end === void 0 && i < input.length) {
1571
+ const c = input[i];
1572
+ let at = -1;
1573
+ if (c === "{") {
1574
+ levels.push([]);
1575
+ at = ++i;
1576
+ } else if (c === "}") closeLevel(i++);
1577
+ else if ((c === "\"" || c === "'") && !(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]))) {
1578
+ const close = skipQuoted(input, i);
1579
+ if (close === -1) i++;
1580
+ else at = i = close;
1581
+ } else if (c === "`") {
1582
+ const close = memo.template.get(i);
1583
+ if (close === void 0) break;
1584
+ if (close === -1) i++;
1585
+ else at = i = close;
1586
+ } else i++;
1587
+ if (at === -1) continue;
1588
+ const known = memo.brace.get(at);
1589
+ if (known === void 0) levels[levels.length - 1].push(at);
1590
+ else if (known === -1) i = input.length;
1591
+ else {
1592
+ closeLevel(known);
1593
+ i = known + 1;
1594
+ }
1595
+ }
1596
+ if (end === void 0 && i < input.length) {
1597
+ stack.push({
1598
+ template: true,
1599
+ start: i,
1600
+ levels: [],
1601
+ passed: []
1602
+ });
1603
+ i++;
217
1604
  continue;
218
1605
  }
1606
+ if (end === void 0) {
1607
+ end = -1;
1608
+ for (const level of levels) for (const at of level) memo.brace.set(at, -1);
1609
+ }
219
1610
  }
220
- i++;
1611
+ stack.pop();
1612
+ (scan.template ? memo.template : memo.brace).set(scan.start, end);
1613
+ for (const at of scan.passed ?? []) memo.templateText.set(at, end);
1614
+ const parent = stack[stack.length - 1];
1615
+ if (!parent) return end;
1616
+ if (parent.template) i = end === -1 ? input.length : end + 1;
1617
+ else i = end === -1 ? scan.start + 1 : end;
221
1618
  }
222
- return -1;
223
1619
  }
224
1620
  /**
225
1621
  * Single-pass tokenizer for Twine passage content.
226
1622
  * Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
227
1623
  */
228
- function tokenize(input) {
1624
+ function tokenize(input, options = {}) {
1625
+ const textMode = options.text === true;
229
1626
  const tokens = [];
1627
+ const memo = createScanMemo();
230
1628
  let i = 0;
231
1629
  let textStart = 0;
232
1630
  function flushText(end) {
@@ -238,20 +1636,44 @@ function tokenize(input) {
238
1636
  });
239
1637
  }
240
1638
  /**
241
- * After an opening raw-body macro ({do}), emit everything up to the
242
- * first {/name} as a single text token so JavaScript source is not
243
- * parsed as markup. A literal "{/do}" inside the code would end it early.
244
- * Without a closer, nothing is consumed and the AST builder reports it.
1639
+ * After an opening raw-body macro ({do}), emit everything up to its
1640
+ * {/name} as a single text token so JavaScript source is not parsed as
1641
+ * markup. The body is lexed as JavaScript statements: a {/do} in code ends
1642
+ * it, one inside a string, template or regex literal or a comment does
1643
+ * not. If the body is not well-formed JavaScript up to a {/do} in code,
1644
+ * the first {/do} ends it. Without a closer, nothing is consumed and the
1645
+ * AST builder reports it.
245
1646
  */
246
1647
  function consumeRawBody(name, isClose) {
247
1648
  const lower = name.toLowerCase();
248
1649
  if (isClose || !RAW_BODY_MACROS.has(lower)) return;
249
- const closeRe = new RegExp(`\\{/${lower}\\s*\\}`, "gi");
250
- closeRe.lastIndex = i;
251
- const m = closeRe.exec(input);
252
- if (!m) return;
253
- const closeStart = m.index;
254
- const closeEnd = closeStart + m[0].length;
1650
+ const closer = `\\{/${lower}\\s*\\}`;
1651
+ let last = memo.rawClose.get(lower);
1652
+ if (!last || i < last.from || last.at >= 0 && i > last.at) {
1653
+ const firstRe = new RegExp(closer, "gi");
1654
+ firstRe.lastIndex = i;
1655
+ last = {
1656
+ from: i,
1657
+ at: firstRe.exec(input)?.index ?? -1
1658
+ };
1659
+ memo.rawClose.set(lower, last);
1660
+ }
1661
+ const first = last.at;
1662
+ if (first === -1) return;
1663
+ const atRe = new RegExp(closer, "iy");
1664
+ const closerAt = (k) => {
1665
+ atRe.lastIndex = k;
1666
+ return atRe.test(input);
1667
+ };
1668
+ let closeStart = findCodeEnd(input, i, {
1669
+ goal: "statements",
1670
+ stop: closerAt,
1671
+ stopKey: lower,
1672
+ cache: memo.js
1673
+ });
1674
+ if (closeStart === -1) closeStart = first;
1675
+ atRe.lastIndex = closeStart;
1676
+ const closeEnd = closeStart + atRe.exec(input)[0].length;
255
1677
  flushText(closeStart);
256
1678
  tokens.push({
257
1679
  type: "macro",
@@ -262,11 +1684,46 @@ function tokenize(input) {
262
1684
  i = closeEnd;
263
1685
  textStart = closeEnd;
264
1686
  }
1687
+ /**
1688
+ * Push an expression token for the `{…}` block opened at `start` whose
1689
+ * expression starts at `exprStart`, flushing the text before it first
1690
+ * when `flush`. Returns false, consuming nothing, if the block is unclosed.
1691
+ */
1692
+ function pushExpression(exprStart, start, flush, className, id) {
1693
+ const closeIdx = scanBalancedBrace(input, exprStart, memo);
1694
+ if (closeIdx === -1) return false;
1695
+ if (flush) flushText(start);
1696
+ const token = {
1697
+ type: "expression",
1698
+ expression: input.slice(exprStart, closeIdx),
1699
+ start,
1700
+ end: closeIdx + 1
1701
+ };
1702
+ if (className) token.className = className;
1703
+ if (id) token.id = id;
1704
+ tokens.push(token);
1705
+ i = textStart = closeIdx + 1;
1706
+ return true;
1707
+ }
265
1708
  while (i < input.length) {
266
1709
  if (input[i] === "\\") {
267
1710
  let k = i + 1;
268
1711
  while (input[k] === "\\") k++;
269
1712
  const next = input[k];
1713
+ if (textMode && (next === "{" || next === "}")) {
1714
+ const escaped = (k - i) % 2 === 1;
1715
+ const end = escaped ? k + 1 : k;
1716
+ const value = "\\".repeat(k - i >> 1) + (escaped ? next : "");
1717
+ flushText(i);
1718
+ if (value) tokens.push({
1719
+ type: "text",
1720
+ value,
1721
+ start: i,
1722
+ end
1723
+ });
1724
+ i = textStart = end;
1725
+ continue;
1726
+ }
270
1727
  if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
271
1728
  flushText(k - 1);
272
1729
  tokens.push({
@@ -282,7 +1739,7 @@ function tokenize(input) {
282
1739
  i = k;
283
1740
  continue;
284
1741
  }
285
- if (input[i] === "[" && input[i + 1] === "[") {
1742
+ if (!textMode && input[i] === "[" && input[i + 1] === "[") {
286
1743
  flushText(i);
287
1744
  const start = i;
288
1745
  i += 2;
@@ -295,23 +1752,15 @@ function tokenize(input) {
295
1752
  i = parsed.endIdx;
296
1753
  if (input[i] === " ") i++;
297
1754
  }
298
- let depth = 1;
299
1755
  const innerStart = i;
300
- while (i < input.length && depth > 0) if (input[i] === "[" && input[i + 1] === "[") {
301
- depth++;
302
- i += 2;
303
- } else if (input[i] === "]" && input[i + 1] === "]") {
304
- depth--;
305
- if (depth === 0) break;
306
- i += 2;
307
- } else i++;
308
- if (depth !== 0) {
1756
+ const closeIdx = scanLinkClose(input, innerStart, memo);
1757
+ if (closeIdx === -1) {
309
1758
  i = start + 2;
310
1759
  textStart = start;
311
1760
  continue;
312
1761
  }
313
- const inner = input.slice(innerStart, i);
314
- i += 2;
1762
+ const inner = input.slice(innerStart, closeIdx);
1763
+ i = closeIdx + 2;
315
1764
  const { display, target } = parseLink(inner);
316
1765
  const linkToken = {
317
1766
  type: "link",
@@ -359,7 +1808,7 @@ function tokenize(input) {
359
1808
  textStart = i;
360
1809
  continue;
361
1810
  }
362
- const closeIdx$ = scanBalancedBrace(input, nameStart);
1811
+ const closeIdx$ = scanBalancedBrace(input, nameStart - 1, memo);
363
1812
  if (closeIdx$ !== -1) {
364
1813
  const expression = input.slice(afterSelectors, closeIdx$);
365
1814
  i = closeIdx$ + 1;
@@ -399,7 +1848,7 @@ function tokenize(input) {
399
1848
  textStart = i;
400
1849
  continue;
401
1850
  }
402
- const closeIdx_ = scanBalancedBrace(input, nameStart);
1851
+ const closeIdx_ = scanBalancedBrace(input, nameStart - 1, memo);
403
1852
  if (closeIdx_ !== -1) {
404
1853
  const expression = input.slice(afterSelectors, closeIdx_);
405
1854
  i = closeIdx_ + 1;
@@ -439,7 +1888,7 @@ function tokenize(input) {
439
1888
  textStart = i;
440
1889
  continue;
441
1890
  }
442
- const closeIdx_at = scanBalancedBrace(input, nameStart);
1891
+ const closeIdx_at = scanBalancedBrace(input, nameStart - 1, memo);
443
1892
  if (closeIdx_at !== -1) {
444
1893
  const expression = input.slice(afterSelectors, closeIdx_at);
445
1894
  i = closeIdx_at + 1;
@@ -479,7 +1928,7 @@ function tokenize(input) {
479
1928
  textStart = i;
480
1929
  continue;
481
1930
  }
482
- const closeIdx_pct = scanBalancedBrace(input, nameStart);
1931
+ const closeIdx_pct = scanBalancedBrace(input, nameStart - 1, memo);
483
1932
  if (closeIdx_pct !== -1) {
484
1933
  const expression = input.slice(afterSelectors, closeIdx_pct);
485
1934
  i = closeIdx_pct + 1;
@@ -499,9 +1948,10 @@ function tokenize(input) {
499
1948
  textStart = start;
500
1949
  continue;
501
1950
  }
1951
+ if (EXPRESSION_START.has(charAfter) && pushExpression(afterSelectors, start, false, className, id)) continue;
502
1952
  if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
503
1953
  const contentStart = afterSelectors;
504
- const closeIdx = scanBalancedBrace(input, contentStart);
1954
+ const closeIdx = scanMacroClose(input, contentStart, memo);
505
1955
  if (closeIdx === -1) {
506
1956
  i = start + 1;
507
1957
  textStart = start;
@@ -547,7 +1997,7 @@ function tokenize(input) {
547
1997
  textStart = i;
548
1998
  continue;
549
1999
  }
550
- const closeIdx = scanBalancedBrace(input, nameStart);
2000
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
551
2001
  if (closeIdx !== -1) {
552
2002
  const expression = input.slice(start + 1, closeIdx);
553
2003
  i = closeIdx + 1;
@@ -582,7 +2032,7 @@ function tokenize(input) {
582
2032
  textStart = i;
583
2033
  continue;
584
2034
  }
585
- const closeIdx = scanBalancedBrace(input, nameStart);
2035
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
586
2036
  if (closeIdx !== -1) {
587
2037
  const expression = input.slice(start + 1, closeIdx);
588
2038
  i = closeIdx + 1;
@@ -617,7 +2067,7 @@ function tokenize(input) {
617
2067
  textStart = i;
618
2068
  continue;
619
2069
  }
620
- const closeIdx = scanBalancedBrace(input, nameStart);
2070
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
621
2071
  if (closeIdx !== -1) {
622
2072
  const expression = input.slice(start + 1, closeIdx);
623
2073
  i = closeIdx + 1;
@@ -652,7 +2102,7 @@ function tokenize(input) {
652
2102
  textStart = i;
653
2103
  continue;
654
2104
  }
655
- const closeIdx = scanBalancedBrace(input, nameStart);
2105
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
656
2106
  if (closeIdx !== -1) {
657
2107
  const expression = input.slice(start + 1, closeIdx);
658
2108
  i = closeIdx + 1;
@@ -669,10 +2119,15 @@ function tokenize(input) {
669
2119
  textStart = start;
670
2120
  continue;
671
2121
  }
2122
+ if (EXPRESSION_START.has(nextChar)) {
2123
+ if (pushExpression(i + 1, start, true)) continue;
2124
+ i++;
2125
+ continue;
2126
+ }
672
2127
  if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
673
2128
  flushText(i);
674
2129
  const contentStart = i + 1;
675
- const closeIdx = scanBalancedBrace(input, contentStart);
2130
+ const closeIdx = scanMacroClose(input, contentStart, memo);
676
2131
  if (closeIdx === -1) {
677
2132
  i = start + 1;
678
2133
  textStart = start;
@@ -696,7 +2151,7 @@ function tokenize(input) {
696
2151
  i++;
697
2152
  continue;
698
2153
  }
699
- if (input[i] === "<") {
2154
+ if (!textMode && input[i] === "<") {
700
2155
  const start = i;
701
2156
  let j = i + 1;
702
2157
  const isClose = input[j] === "/";
@@ -729,8 +2184,8 @@ function tokenize(input) {
729
2184
  continue;
730
2185
  }
731
2186
  } else {
732
- const parsed = parseHtmlAttributes(input, j);
733
- j = parsed.endIdx;
2187
+ const attrsStart = j;
2188
+ j = scanAttributes(input, attrsStart, memo);
734
2189
  let isSelfClose = HTML_VOID_TAGS.has(tagLower);
735
2190
  if (input[j] === "/") {
736
2191
  isSelfClose = true;
@@ -742,7 +2197,7 @@ function tokenize(input) {
742
2197
  tokens.push({
743
2198
  type: "html",
744
2199
  tag,
745
- attributes: parsed.attributes,
2200
+ attributes: parseHtmlAttributes(input, attrsStart, memo).attributes,
746
2201
  isClose: false,
747
2202
  isSelfClose,
748
2203
  start,
@@ -762,14 +2217,41 @@ function tokenize(input) {
762
2217
  return tokens;
763
2218
  }
764
2219
  //#endregion
2220
+ //#region src/utils/error-message.ts
2221
+ /**
2222
+ * The message to show for a caught value. Anything can be thrown, not only
2223
+ * Errors: an Error gives its message, a string is its own message, and any
2224
+ * other value gives `fallback` when one is passed, else its string form
2225
+ * (never `undefined`). An empty message also gives `fallback`, or for an
2226
+ * Error its string form (e.g. "Error").
2227
+ */
2228
+ function errorMessage(err, fallback) {
2229
+ if (err instanceof Error) return err.message || fallback || String(err);
2230
+ if (typeof err === "string" && err !== "") return err;
2231
+ return fallback ?? String(err);
2232
+ }
2233
+ //#endregion
2234
+ //#region src/utils/namespace.ts
2235
+ /** The variable name no namespace can hold. */
2236
+ var RESERVED_NAME = "__proto__";
2237
+ /** A namespace with no prototype holding the own entries of `sources`. */
2238
+ function createNamespace(...sources) {
2239
+ const ns = Object.create(null);
2240
+ for (const source of sources) {
2241
+ if (!source) continue;
2242
+ for (const key of Object.keys(source)) ns[key] = source[key];
2243
+ }
2244
+ return ns;
2245
+ }
2246
+ Object.freeze(createNamespace());
2247
+ //#endregion
765
2248
  //#region src/story-variables.ts
766
2249
  function declarationRegex(sigil) {
767
2250
  const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
768
2251
  return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
769
2252
  }
770
- var VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
771
- /** `{` followed by a sigil starts an interpolation block inside literal text. */
772
- var INTERP_START_RE = /^[$_@%]\w/;
2253
+ /** A `$name` reference with its dot path, at a `$` the lexer found. */
2254
+ var VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
773
2255
  /** Quoted first argument of an input macro naming a story variable. */
774
2256
  var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
775
2257
  var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
@@ -812,17 +2294,18 @@ function parseStoryVariables(content, sigil = "$") {
812
2294
  const match = line.match(DECLARATION_RE);
813
2295
  if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
814
2296
  const [, name, expr] = match;
2297
+ if (name === "__proto__") throw new Error(`${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
815
2298
  let value;
816
2299
  try {
817
2300
  value = new Function("return (" + expr + ")")();
818
2301
  } catch (err) {
819
- throw new Error(`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`);
2302
+ throw new Error(`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${errorMessage(err)}`);
820
2303
  }
821
2304
  let fieldSchema;
822
2305
  try {
823
2306
  fieldSchema = inferSchema(value);
824
2307
  } catch (err) {
825
- throw new Error(`${passageName}: ${err instanceof Error ? err.message : err}`);
2308
+ throw new Error(`${passageName}: ${errorMessage(err)}`);
826
2309
  }
827
2310
  schema.set(name, {
828
2311
  ...fieldSchema,
@@ -854,6 +2337,7 @@ function extractForLocals(content) {
854
2337
  function validateRef(ref, schema, forLocals) {
855
2338
  const parts = ref.split(".");
856
2339
  const rootName = parts[0];
2340
+ if (rootName === "__proto__") return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
857
2341
  if (forLocals.has(rootName)) return null;
858
2342
  const rootSchema = schema.get(rootName);
859
2343
  if (!rootSchema) return `Undeclared variable: $${ref}`;
@@ -888,89 +2372,59 @@ var BUILTIN_STORE_VAR_MACROS = [
888
2372
  "textarea",
889
2373
  "textbox"
890
2374
  ];
891
- /** Report every `$var.path` in a code segment free of strings/comments. */
892
- function scanRefs(segment, onRef) {
893
- for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]);
894
- }
895
- /** Index of the quote closing the string opened at `start` (or the end). */
896
- function findClosingQuote(code, start) {
897
- const quote = code[start];
898
- let i = start + 1;
899
- while (i < code.length && code[i] !== quote) i += code[i] === "\\" ? 2 : 1;
900
- return Math.min(i, code.length);
901
- }
2375
+ var NO_STORE_VAR_MACROS = /* @__PURE__ */ new Set();
902
2376
  /**
903
- * Scan literal text (string contents, HTML attribute values) for `{$…}`
904
- * interpolation blocks, which interpolating macros and HTML attributes
905
- * resolve at runtime. A bare `$word` in literal text is not a reference.
2377
+ * Scan the value of a code attribute (`onclick`, see isCodeAttribute) for
2378
+ * the `{$…}` references resolved in it; its other braces are code.
906
2379
  */
907
- function scanInterpolations(text, onRef) {
908
- let i = text.indexOf("{");
909
- while (i !== -1) {
910
- const next = INTERP_START_RE.test(text.slice(i + 1, i + 3)) ? scanCode(text, i + 1, onRef, true) : i + 1;
911
- i = text.indexOf("{", next);
912
- }
2380
+ function scanSigilReferences(value, onRef) {
2381
+ for (const part of splitSigilTemplate(value)) if ("expr" in part) scanCode(part.expr, onRef);
913
2382
  }
914
2383
  /**
915
- * Scan a template literal starting just after its opening backtick: literal
916
- * parts are text, `${…}` parts are code. Returns the index just past the
917
- * closing backtick.
2384
+ * Scan literal text (string contents, HTML attribute values) for the markup
2385
+ * that labels and HTML attributes evaluate at runtime: `{$…}` displays,
2386
+ * expressions and macros, read as in passage text. A bare `$word` in
2387
+ * literal text is not a reference.
918
2388
  */
919
- function scanTemplate(code, start, onRef) {
920
- let i = start;
921
- let textStart = start;
922
- while (i < code.length) {
923
- const ch = code[i];
924
- if (ch === "\\") i += 2;
925
- else if (ch === "`") {
926
- scanInterpolations(code.slice(textStart, i), onRef);
927
- return i + 1;
928
- } else if (ch === "$" && code[i + 1] === "{") {
929
- scanInterpolations(code.slice(textStart, i), onRef);
930
- i = textStart = scanCode(code, i + 2, onRef, true);
931
- } else i++;
932
- }
933
- scanInterpolations(code.slice(textStart), onRef);
934
- return code.length;
2389
+ function scanInterpolations(text, onRef, storeVarMacros = NO_STORE_VAR_MACROS) {
2390
+ if (!text.includes("{")) return;
2391
+ collectTokenRefs(text, tokenize(text, { text: true }), storeVarMacros, onRef);
935
2392
  }
936
2393
  /**
937
- * Report `$var` references in JavaScript code. Like the string-aware
938
- * expression transformer, sigils inside string literals are left alone while
939
- * template-literal `${…}` parts are code; comments are skipped too. When
940
- * `nested`, stops at the `}` closing the enclosing block and returns the index
941
- * just past it.
2394
+ * Report `$var` references in JavaScript code, lexed as the expression
2395
+ * engine lexes it (`lexJs`): references in code, but none inside string or
2396
+ * regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
2397
+ * The text of string and template literals is scanned for the markup that
2398
+ * labels evaluate (`{$…}`); the `${…}` parts of template literals are code.
942
2399
  */
943
- function scanCode(code, start, onRef, nested = false) {
944
- let i = start;
945
- let segStart = start;
946
- let depth = 0;
947
- while (i < code.length) {
948
- const ch = code[i];
949
- const next = code[i + 1];
950
- const isComment = ch === "/" && (next === "/" || next === "*");
951
- if (ch === "\"" || ch === "'" || ch === "`" || isComment) {
952
- scanRefs(code.slice(segStart, i), onRef);
953
- if (ch === "`") i = scanTemplate(code, i + 1, onRef);
954
- else if (isComment) {
955
- const close = code.indexOf(next === "/" ? "\n" : "*/", i + 2);
956
- i = close === -1 ? code.length : next === "/" ? close : close + 2;
957
- } else {
958
- const close = findClosingQuote(code, i);
959
- scanInterpolations(code.slice(i + 1, close), onRef);
960
- i = Math.min(close + 1, code.length);
2400
+ function scanCode(code, onRef, goal = "expression") {
2401
+ /** Open template literals: their text so far, null in an interpolation. */
2402
+ const templates = [];
2403
+ lexJs(code, {
2404
+ variable(sigil, _name, index) {
2405
+ if (sigil !== "$") return;
2406
+ VAR_PATH_RE.lastIndex = index;
2407
+ onRef(VAR_PATH_RE.exec(code)[1]);
2408
+ },
2409
+ literal(text, _index, nesting) {
2410
+ const template = templates[templates.length - 1];
2411
+ if (template?.nesting === nesting) if (template.text === null) template.text = "";
2412
+ else if (text === "`" || text === "${") {
2413
+ scanInterpolations(template.text, onRef);
2414
+ if (text === "`") templates.pop();
2415
+ else template.text = null;
2416
+ } else template.text += text;
2417
+ else if (text === "`") templates.push({
2418
+ nesting,
2419
+ text: ""
2420
+ });
2421
+ else if (text[0] === "\"" || text[0] === "'") {
2422
+ const { closed } = scanStringLiteral(text, 0);
2423
+ scanInterpolations(text.slice(1, closed ? -1 : void 0), onRef);
961
2424
  }
962
- segStart = i;
963
- continue;
964
- }
965
- if (nested && ch === "{") depth++;
966
- else if (nested && ch === "}" && depth-- === 0) {
967
- scanRefs(code.slice(segStart, i), onRef);
968
- return i + 1;
969
2425
  }
970
- i++;
971
- }
972
- scanRefs(code.slice(segStart), onRef);
973
- return code.length;
2426
+ }, goal);
2427
+ for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
974
2428
  }
975
2429
  /**
976
2430
  * Report the `$var` references a passage evaluates at runtime: `{$var}`
@@ -979,15 +2433,19 @@ function scanCode(code, start, onRef, nested = false) {
979
2433
  * interpolations in HTML attributes. Prose is literal text and not scanned.
980
2434
  */
981
2435
  function collectPassageRefs(content, storeVarMacros, onRef) {
982
- const tokens = tokenize(content);
2436
+ collectTokenRefs(content, tokenize(content), storeVarMacros, onRef);
2437
+ }
2438
+ /** Report the `$var` references in the tokens of `content`. */
2439
+ function collectTokenRefs(content, tokens, storeVarMacros, onRef) {
983
2440
  for (let t = 0; t < tokens.length; t++) {
984
2441
  const token = tokens[t];
985
2442
  if (token.type === "variable") {
986
2443
  if (token.scope === "variable" && token.name) onRef(token.name);
987
- } else if (token.type === "expression") scanCode(token.expression, 0, onRef);
988
- else if (token.type === "html") for (const value of Object.values(token.attributes)) scanInterpolations(value, onRef);
2444
+ } else if (token.type === "expression") scanCode(token.expression, onRef);
2445
+ else if (token.type === "html") for (const [name, value] of Object.entries(token.attributes)) if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
2446
+ else scanInterpolations(value, onRef, storeVarMacros);
989
2447
  else if (token.type === "macro" && !token.isClose) {
990
- scanCode(token.rawArgs, 0, onRef);
2448
+ scanCode(token.rawArgs, onRef);
991
2449
  if (storeVarMacros.has(token.name.toLowerCase())) {
992
2450
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
993
2451
  const quoted = QUOTED_VAR_ARG_RE.exec(first);
@@ -1001,7 +2459,7 @@ function collectPassageRefs(content, storeVarMacros, onRef) {
1001
2459
  close++;
1002
2460
  }
1003
2461
  if (close < tokens.length) {
1004
- scanCode(content.slice(token.end, tokens[close].start), 0, onRef);
2462
+ scanCode(content.slice(token.end, tokens[close].start), onRef, "statements");
1005
2463
  t = close;
1006
2464
  }
1007
2465
  }
@@ -1029,10 +2487,11 @@ function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_M
1029
2487
  return errors;
1030
2488
  }
1031
2489
  /**
1032
- * Extract default values from the schema as a plain object.
2490
+ * Extract default values from the schema as a record without a prototype,
2491
+ * like the namespaces it initializes (see utils/namespace.ts).
1033
2492
  */
1034
2493
  function extractDefaults(schema) {
1035
- const defaults = {};
2494
+ const defaults = createNamespace();
1036
2495
  for (const [name, varSchema] of schema) defaults[name] = varSchema.default;
1037
2496
  return defaults;
1038
2497
  }