@rohal12/spindle 0.51.3 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  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 +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. 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,46 +1246,236 @@ 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++;
119
1294
  const attrName = input.slice(attrStart, j);
120
1295
  if (!attrName) break;
121
- if (input[j] === "=") {
122
- j++;
1296
+ let eqIdx = j;
1297
+ while (eqIdx < input.length && /\s/.test(input[eqIdx])) eqIdx++;
1298
+ if (input[eqIdx] === "=") {
1299
+ j = eqIdx + 1;
1300
+ while (j < input.length && /\s/.test(input[j])) j++;
123
1301
  if (input[j] === "\"" || input[j] === "'") {
124
1302
  const quote = input[j];
125
1303
  j++;
126
1304
  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);
1305
+ j = scanQuotedValue(input, j, quote, isCodeAttribute(attrName), memo);
1306
+ add?.(attrName, input.slice(valStart, j));
138
1307
  if (j < input.length) j++;
139
1308
  } else {
140
1309
  const valStart = j;
141
- while (j < input.length && /[^\s>]/.test(input[j])) j++;
142
- 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;
143
1362
  }
144
- } else attributes[attrName] = "";
1363
+ } else if (input[j] === quote) break;
1364
+ j++;
145
1365
  }
1366
+ for (const at of passed) memo.value.set(at, j);
1367
+ return j;
1368
+ }
1369
+ function createScanMemo() {
146
1370
  return {
147
- attributes,
148
- 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()
149
1388
  };
150
1389
  }
151
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
+ /**
152
1479
  * Skip a '…' or "…" string literal opening at i.
153
1480
  * Returns the index just past the closing quote, or -1 if the string is
154
1481
  * not closed on the same line (JS strings can't span lines unescaped).
@@ -166,24 +1493,6 @@ function skipQuoted(input, i) {
166
1493
  return -1;
167
1494
  }
168
1495
  /**
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
1496
  * A quote directly after a letter/digit is an apostrophe (don't), not a
188
1497
  * string; after a backslash it is an escaped attribute delimiter (\").
189
1498
  */
@@ -191,39 +1500,131 @@ var NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
191
1500
  /**
192
1501
  * Scan for the balanced closing } starting at position i (just past the {).
193
1502
  * Braces inside string and template literals are ignored. A quote that
194
- * 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.
195
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.
196
1514
  */
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;
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;
208
1560
  continue;
209
1561
  }
210
- } else if (c === "`") {
211
- const end = skipTemplate(input, i);
212
- if (end !== -1) {
213
- 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++;
214
1604
  continue;
215
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
+ }
216
1610
  }
217
- 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;
218
1618
  }
219
- return -1;
220
1619
  }
221
1620
  /**
222
1621
  * Single-pass tokenizer for Twine passage content.
223
1622
  * Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
224
1623
  */
225
- function tokenize(input) {
1624
+ function tokenize(input, options = {}) {
1625
+ const textMode = options.text === true;
226
1626
  const tokens = [];
1627
+ const memo = createScanMemo();
227
1628
  let i = 0;
228
1629
  let textStart = 0;
229
1630
  function flushText(end) {
@@ -235,20 +1636,44 @@ function tokenize(input) {
235
1636
  });
236
1637
  }
237
1638
  /**
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.
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.
242
1646
  */
243
1647
  function consumeRawBody(name, isClose) {
244
1648
  const lower = name.toLowerCase();
245
1649
  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;
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;
252
1677
  flushText(closeStart);
253
1678
  tokens.push({
254
1679
  type: "macro",
@@ -259,20 +1684,62 @@ function tokenize(input) {
259
1684
  i = closeEnd;
260
1685
  textStart = closeEnd;
261
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
+ }
262
1708
  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;
1709
+ if (input[i] === "\\") {
1710
+ let k = i + 1;
1711
+ while (input[k] === "\\") k++;
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
+ }
1727
+ if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
1728
+ flushText(k - 1);
1729
+ tokens.push({
1730
+ type: "text",
1731
+ value: next,
1732
+ start: k - 1,
1733
+ end: k + 1
1734
+ });
1735
+ i = k + 1;
1736
+ textStart = i;
1737
+ continue;
1738
+ }
1739
+ i = k;
273
1740
  continue;
274
1741
  }
275
- if (input[i] === "[" && input[i + 1] === "[") {
1742
+ if (!textMode && input[i] === "[" && input[i + 1] === "[") {
276
1743
  flushText(i);
277
1744
  const start = i;
278
1745
  i += 2;
@@ -285,23 +1752,15 @@ function tokenize(input) {
285
1752
  i = parsed.endIdx;
286
1753
  if (input[i] === " ") i++;
287
1754
  }
288
- let depth = 1;
289
1755
  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) {
1756
+ const closeIdx = scanLinkClose(input, innerStart, memo);
1757
+ if (closeIdx === -1) {
299
1758
  i = start + 2;
300
1759
  textStart = start;
301
1760
  continue;
302
1761
  }
303
- const inner = input.slice(innerStart, i);
304
- i += 2;
1762
+ const inner = input.slice(innerStart, closeIdx);
1763
+ i = closeIdx + 2;
305
1764
  const { display, target } = parseLink(inner);
306
1765
  const linkToken = {
307
1766
  type: "link",
@@ -349,7 +1808,7 @@ function tokenize(input) {
349
1808
  textStart = i;
350
1809
  continue;
351
1810
  }
352
- const closeIdx$ = scanBalancedBrace(input, nameStart);
1811
+ const closeIdx$ = scanBalancedBrace(input, nameStart - 1, memo);
353
1812
  if (closeIdx$ !== -1) {
354
1813
  const expression = input.slice(afterSelectors, closeIdx$);
355
1814
  i = closeIdx$ + 1;
@@ -389,7 +1848,7 @@ function tokenize(input) {
389
1848
  textStart = i;
390
1849
  continue;
391
1850
  }
392
- const closeIdx_ = scanBalancedBrace(input, nameStart);
1851
+ const closeIdx_ = scanBalancedBrace(input, nameStart - 1, memo);
393
1852
  if (closeIdx_ !== -1) {
394
1853
  const expression = input.slice(afterSelectors, closeIdx_);
395
1854
  i = closeIdx_ + 1;
@@ -429,7 +1888,7 @@ function tokenize(input) {
429
1888
  textStart = i;
430
1889
  continue;
431
1890
  }
432
- const closeIdx_at = scanBalancedBrace(input, nameStart);
1891
+ const closeIdx_at = scanBalancedBrace(input, nameStart - 1, memo);
433
1892
  if (closeIdx_at !== -1) {
434
1893
  const expression = input.slice(afterSelectors, closeIdx_at);
435
1894
  i = closeIdx_at + 1;
@@ -469,7 +1928,7 @@ function tokenize(input) {
469
1928
  textStart = i;
470
1929
  continue;
471
1930
  }
472
- const closeIdx_pct = scanBalancedBrace(input, nameStart);
1931
+ const closeIdx_pct = scanBalancedBrace(input, nameStart - 1, memo);
473
1932
  if (closeIdx_pct !== -1) {
474
1933
  const expression = input.slice(afterSelectors, closeIdx_pct);
475
1934
  i = closeIdx_pct + 1;
@@ -489,9 +1948,10 @@ function tokenize(input) {
489
1948
  textStart = start;
490
1949
  continue;
491
1950
  }
1951
+ if (EXPRESSION_START.has(charAfter) && pushExpression(afterSelectors, start, false, className, id)) continue;
492
1952
  if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
493
1953
  const contentStart = afterSelectors;
494
- const closeIdx = scanBalancedBrace(input, contentStart);
1954
+ const closeIdx = scanMacroClose(input, contentStart, memo);
495
1955
  if (closeIdx === -1) {
496
1956
  i = start + 1;
497
1957
  textStart = start;
@@ -537,7 +1997,7 @@ function tokenize(input) {
537
1997
  textStart = i;
538
1998
  continue;
539
1999
  }
540
- const closeIdx = scanBalancedBrace(input, nameStart);
2000
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
541
2001
  if (closeIdx !== -1) {
542
2002
  const expression = input.slice(start + 1, closeIdx);
543
2003
  i = closeIdx + 1;
@@ -572,7 +2032,7 @@ function tokenize(input) {
572
2032
  textStart = i;
573
2033
  continue;
574
2034
  }
575
- const closeIdx = scanBalancedBrace(input, nameStart);
2035
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
576
2036
  if (closeIdx !== -1) {
577
2037
  const expression = input.slice(start + 1, closeIdx);
578
2038
  i = closeIdx + 1;
@@ -607,7 +2067,7 @@ function tokenize(input) {
607
2067
  textStart = i;
608
2068
  continue;
609
2069
  }
610
- const closeIdx = scanBalancedBrace(input, nameStart);
2070
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
611
2071
  if (closeIdx !== -1) {
612
2072
  const expression = input.slice(start + 1, closeIdx);
613
2073
  i = closeIdx + 1;
@@ -642,7 +2102,7 @@ function tokenize(input) {
642
2102
  textStart = i;
643
2103
  continue;
644
2104
  }
645
- const closeIdx = scanBalancedBrace(input, nameStart);
2105
+ const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
646
2106
  if (closeIdx !== -1) {
647
2107
  const expression = input.slice(start + 1, closeIdx);
648
2108
  i = closeIdx + 1;
@@ -659,10 +2119,15 @@ function tokenize(input) {
659
2119
  textStart = start;
660
2120
  continue;
661
2121
  }
2122
+ if (EXPRESSION_START.has(nextChar)) {
2123
+ if (pushExpression(i + 1, start, true)) continue;
2124
+ i++;
2125
+ continue;
2126
+ }
662
2127
  if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
663
2128
  flushText(i);
664
2129
  const contentStart = i + 1;
665
- const closeIdx = scanBalancedBrace(input, contentStart);
2130
+ const closeIdx = scanMacroClose(input, contentStart, memo);
666
2131
  if (closeIdx === -1) {
667
2132
  i = start + 1;
668
2133
  textStart = start;
@@ -686,7 +2151,7 @@ function tokenize(input) {
686
2151
  i++;
687
2152
  continue;
688
2153
  }
689
- if (input[i] === "<") {
2154
+ if (!textMode && input[i] === "<") {
690
2155
  const start = i;
691
2156
  let j = i + 1;
692
2157
  const isClose = input[j] === "/";
@@ -719,8 +2184,8 @@ function tokenize(input) {
719
2184
  continue;
720
2185
  }
721
2186
  } else {
722
- const parsed = parseHtmlAttributes(input, j);
723
- j = parsed.endIdx;
2187
+ const attrsStart = j;
2188
+ j = scanAttributes(input, attrsStart, memo);
724
2189
  let isSelfClose = HTML_VOID_TAGS.has(tagLower);
725
2190
  if (input[j] === "/") {
726
2191
  isSelfClose = true;
@@ -732,7 +2197,7 @@ function tokenize(input) {
732
2197
  tokens.push({
733
2198
  type: "html",
734
2199
  tag,
735
- attributes: parsed.attributes,
2200
+ attributes: parseHtmlAttributes(input, attrsStart, memo).attributes,
736
2201
  isClose: false,
737
2202
  isSelfClose,
738
2203
  start,
@@ -752,14 +2217,41 @@ function tokenize(input) {
752
2217
  return tokens;
753
2218
  }
754
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
755
2248
  //#region src/story-variables.ts
756
2249
  function declarationRegex(sigil) {
757
2250
  const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
758
2251
  return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
759
2252
  }
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/;
2253
+ /** A `$name` reference with its dot path, at a `$` the lexer found. */
2254
+ var VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
763
2255
  /** Quoted first argument of an input macro naming a story variable. */
764
2256
  var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
765
2257
  var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
@@ -802,17 +2294,18 @@ function parseStoryVariables(content, sigil = "$") {
802
2294
  const match = line.match(DECLARATION_RE);
803
2295
  if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
804
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)`);
805
2298
  let value;
806
2299
  try {
807
2300
  value = new Function("return (" + expr + ")")();
808
2301
  } catch (err) {
809
- 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)}`);
810
2303
  }
811
2304
  let fieldSchema;
812
2305
  try {
813
2306
  fieldSchema = inferSchema(value);
814
2307
  } catch (err) {
815
- throw new Error(`${passageName}: ${err instanceof Error ? err.message : err}`);
2308
+ throw new Error(`${passageName}: ${errorMessage(err)}`);
816
2309
  }
817
2310
  schema.set(name, {
818
2311
  ...fieldSchema,
@@ -844,6 +2337,7 @@ function extractForLocals(content) {
844
2337
  function validateRef(ref, schema, forLocals) {
845
2338
  const parts = ref.split(".");
846
2339
  const rootName = parts[0];
2340
+ if (rootName === "__proto__") return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
847
2341
  if (forLocals.has(rootName)) return null;
848
2342
  const rootSchema = schema.get(rootName);
849
2343
  if (!rootSchema) return `Undeclared variable: $${ref}`;
@@ -878,89 +2372,59 @@ var BUILTIN_STORE_VAR_MACROS = [
878
2372
  "textarea",
879
2373
  "textbox"
880
2374
  ];
881
- /** Report every `$var.path` in a code segment free of strings/comments. */
882
- function scanRefs(segment, onRef) {
883
- for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]);
884
- }
885
- /** Index of the quote closing the string opened at `start` (or the end). */
886
- function findClosingQuote(code, start) {
887
- const quote = code[start];
888
- let i = start + 1;
889
- while (i < code.length && code[i] !== quote) i += code[i] === "\\" ? 2 : 1;
890
- return Math.min(i, code.length);
891
- }
2375
+ var NO_STORE_VAR_MACROS = /* @__PURE__ */ new Set();
892
2376
  /**
893
- * Scan literal text (string contents, HTML attribute values) for `{$…}`
894
- * interpolation blocks, which interpolating macros and HTML attributes
895
- * 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.
896
2379
  */
897
- function scanInterpolations(text, onRef) {
898
- let i = text.indexOf("{");
899
- while (i !== -1) {
900
- const next = INTERP_START_RE.test(text.slice(i + 1, i + 3)) ? scanCode(text, i + 1, onRef, true) : i + 1;
901
- i = text.indexOf("{", next);
902
- }
2380
+ function scanSigilReferences(value, onRef) {
2381
+ for (const part of splitSigilTemplate(value)) if ("expr" in part) scanCode(part.expr, onRef);
903
2382
  }
904
2383
  /**
905
- * Scan a template literal starting just after its opening backtick: literal
906
- * parts are text, `${…}` parts are code. Returns the index just past the
907
- * 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.
908
2388
  */
909
- function scanTemplate(code, start, onRef) {
910
- let i = start;
911
- let textStart = start;
912
- while (i < code.length) {
913
- const ch = code[i];
914
- if (ch === "\\") i += 2;
915
- else if (ch === "`") {
916
- scanInterpolations(code.slice(textStart, i), onRef);
917
- return i + 1;
918
- } else if (ch === "$" && code[i + 1] === "{") {
919
- scanInterpolations(code.slice(textStart, i), onRef);
920
- i = textStart = scanCode(code, i + 2, onRef, true);
921
- } else i++;
922
- }
923
- scanInterpolations(code.slice(textStart), onRef);
924
- 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);
925
2392
  }
926
2393
  /**
927
- * Report `$var` references in JavaScript code. Like the string-aware
928
- * expression transformer, sigils inside string literals are left alone while
929
- * template-literal `${…}` parts are code; comments are skipped too. When
930
- * `nested`, stops at the `}` closing the enclosing block and returns the index
931
- * 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.
932
2399
  */
933
- function scanCode(code, start, onRef, nested = false) {
934
- let i = start;
935
- let segStart = start;
936
- let depth = 0;
937
- while (i < code.length) {
938
- const ch = code[i];
939
- const next = code[i + 1];
940
- const isComment = ch === "/" && (next === "/" || next === "*");
941
- if (ch === "\"" || ch === "'" || ch === "`" || isComment) {
942
- scanRefs(code.slice(segStart, i), onRef);
943
- if (ch === "`") i = scanTemplate(code, i + 1, onRef);
944
- else if (isComment) {
945
- const close = code.indexOf(next === "/" ? "\n" : "*/", i + 2);
946
- i = close === -1 ? code.length : next === "/" ? close : close + 2;
947
- } else {
948
- const close = findClosingQuote(code, i);
949
- scanInterpolations(code.slice(i + 1, close), onRef);
950
- 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);
951
2424
  }
952
- segStart = i;
953
- continue;
954
- }
955
- if (nested && ch === "{") depth++;
956
- else if (nested && ch === "}" && depth-- === 0) {
957
- scanRefs(code.slice(segStart, i), onRef);
958
- return i + 1;
959
2425
  }
960
- i++;
961
- }
962
- scanRefs(code.slice(segStart), onRef);
963
- return code.length;
2426
+ }, goal);
2427
+ for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
964
2428
  }
965
2429
  /**
966
2430
  * Report the `$var` references a passage evaluates at runtime: `{$var}`
@@ -969,15 +2433,19 @@ function scanCode(code, start, onRef, nested = false) {
969
2433
  * interpolations in HTML attributes. Prose is literal text and not scanned.
970
2434
  */
971
2435
  function collectPassageRefs(content, storeVarMacros, onRef) {
972
- 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) {
973
2440
  for (let t = 0; t < tokens.length; t++) {
974
2441
  const token = tokens[t];
975
2442
  if (token.type === "variable") {
976
2443
  if (token.scope === "variable" && token.name) onRef(token.name);
977
- } else if (token.type === "expression") scanCode(token.expression, 0, onRef);
978
- 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);
979
2447
  else if (token.type === "macro" && !token.isClose) {
980
- scanCode(token.rawArgs, 0, onRef);
2448
+ scanCode(token.rawArgs, onRef);
981
2449
  if (storeVarMacros.has(token.name.toLowerCase())) {
982
2450
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
983
2451
  const quoted = QUOTED_VAR_ARG_RE.exec(first);
@@ -991,7 +2459,7 @@ function collectPassageRefs(content, storeVarMacros, onRef) {
991
2459
  close++;
992
2460
  }
993
2461
  if (close < tokens.length) {
994
- scanCode(content.slice(token.end, tokens[close].start), 0, onRef);
2462
+ scanCode(content.slice(token.end, tokens[close].start), onRef, "statements");
995
2463
  t = close;
996
2464
  }
997
2465
  }
@@ -1019,10 +2487,11 @@ function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_M
1019
2487
  return errors;
1020
2488
  }
1021
2489
  /**
1022
- * 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).
1023
2492
  */
1024
2493
  function extractDefaults(schema) {
1025
- const defaults = {};
2494
+ const defaults = createNamespace();
1026
2495
  for (const [name, varSchema] of schema) defaults[name] = varSchema.default;
1027
2496
  return defaults;
1028
2497
  }