@rohal12/spindle 0.52.6 → 0.52.8

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 (48) hide show
  1. package/README.md +26 -0
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +764 -735
  4. package/dist/pkg/macro-registry.json +18 -0
  5. package/dist/pkg/story-variables.js +54 -32
  6. package/package.json +1 -1
  7. package/src/action-registry.ts +9 -0
  8. package/src/class-registry.ts +1 -5
  9. package/src/components/Passage.tsx +35 -39
  10. package/src/components/PassageDialog.tsx +2 -2
  11. package/src/components/StoryInterface.tsx +2 -2
  12. package/src/components/macros/Checkbox.tsx +3 -8
  13. package/src/components/macros/Computed.tsx +1 -1
  14. package/src/components/macros/Cycle.tsx +3 -6
  15. package/src/components/macros/ExprDisplay.tsx +12 -29
  16. package/src/components/macros/If.tsx +35 -41
  17. package/src/components/macros/Include.tsx +2 -1
  18. package/src/components/macros/Listbox.tsx +5 -7
  19. package/src/components/macros/Print.tsx +3 -5
  20. package/src/components/macros/Radiobutton.tsx +3 -5
  21. package/src/components/macros/Repeat.tsx +2 -1
  22. package/src/components/macros/Switch.tsx +6 -13
  23. package/src/components/macros/Timed.tsx +14 -18
  24. package/src/components/macros/Type.tsx +2 -1
  25. package/src/components/macros/VarDisplay.tsx +12 -32
  26. package/src/components/macros/Widget.tsx +49 -20
  27. package/src/components/macros/branches.ts +18 -0
  28. package/src/components/macros/display.ts +19 -0
  29. package/src/components/macros/input-macro.ts +33 -11
  30. package/src/components/macros/macro-args.ts +7 -0
  31. package/src/components/macros/option-utils.ts +10 -7
  32. package/src/define-macro.ts +4 -6
  33. package/src/expression.ts +58 -40
  34. package/src/hooks/use-action.ts +7 -15
  35. package/src/index.tsx +5 -15
  36. package/src/interpolation.ts +34 -41
  37. package/src/js-lexer.ts +80 -45
  38. package/src/markup/ast.ts +4 -3
  39. package/src/markup/render.tsx +4 -12
  40. package/src/registry.ts +13 -12
  41. package/src/saves/save-manager.ts +18 -13
  42. package/src/saves/storage.ts +19 -20
  43. package/src/story-api.ts +30 -73
  44. package/src/structural.ts +1 -18
  45. package/src/types-drift-check.ts +37 -50
  46. package/src/utils/macro-names.ts +40 -0
  47. package/src/utils/namespace.ts +17 -0
  48. package/src/widgets/widget-registry.ts +5 -4
package/src/expression.ts CHANGED
@@ -132,7 +132,7 @@ export function previousPassage(): Passage | undefined {
132
132
  * visited()/rendered() and their has-forms, counting in the counters
133
133
  * `counts()` returns. A missing name means the current passage.
134
134
  */
135
- export function countQueries(counts: () => Counts) {
135
+ function countQueries(counts: () => Counts) {
136
136
  const count = (name?: string): number =>
137
137
  countOf(counts(), name ?? useStoryStore.getState().currentPassage);
138
138
  return {
@@ -143,6 +143,31 @@ export function countQueries(counts: () => Counts) {
143
143
  };
144
144
  }
145
145
 
146
+ /**
147
+ * The history functions of passage code and the story API: the current and
148
+ * previous passage, and visited()/rendered() with their has-forms, counting
149
+ * in the counters `visitCounts()` and `renderCounts()` return.
150
+ */
151
+ export function historyQueries(
152
+ visitCounts: () => Counts,
153
+ renderCounts: () => Counts,
154
+ ) {
155
+ const visits = countQueries(visitCounts);
156
+ const renders = countQueries(renderCounts);
157
+ return {
158
+ currentPassage,
159
+ previousPassage,
160
+ visited: visits.count,
161
+ hasVisited: visits.has,
162
+ hasVisitedAny: visits.any,
163
+ hasVisitedAll: visits.all,
164
+ rendered: renders.count,
165
+ hasRendered: renders.has,
166
+ hasRenderedAny: renders.any,
167
+ hasRenderedAll: renders.all,
168
+ };
169
+ }
170
+
146
171
  let cachedFns: ExpressionFns | null = null;
147
172
  let cachedVisitCounts: Counts | null = null;
148
173
  let cachedRenderCounts: Counts | null = null;
@@ -159,19 +184,11 @@ export function buildExpressionFns() {
159
184
  return cachedFns;
160
185
  }
161
186
 
162
- const visits = countQueries(() => visitCounts);
163
- const renders = countQueries(() => renderCounts);
164
187
  cachedFns = {
165
- currentPassage,
166
- previousPassage,
167
- visited: visits.count,
168
- hasVisited: visits.has,
169
- hasVisitedAny: visits.any,
170
- hasVisitedAll: visits.all,
171
- rendered: renders.count,
172
- hasRendered: renders.has,
173
- hasRenderedAny: renders.any,
174
- hasRenderedAll: renders.all,
188
+ ...historyQueries(
189
+ () => visitCounts,
190
+ () => renderCounts,
191
+ ),
175
192
  random,
176
193
  randomInt,
177
194
  };
@@ -195,21 +212,24 @@ function namespaceArg(ns: Namespace): Namespace {
195
212
  return ns;
196
213
  }
197
214
 
198
- /**
199
- * Evaluate an expression and return its value.
200
- * e.g. evaluate("$health + 10", variables, temporary) → number
201
- */
202
- export function evaluate(
203
- expr: string,
215
+ /** The namespaces code runs in, as evaluate() and execute() take them. */
216
+ type Namespaces = [
204
217
  variables: Namespace,
205
218
  temporary: Namespace,
206
- locals: Namespace = EMPTY_NAMESPACE,
207
- transient: Namespace = EMPTY_NAMESPACE,
219
+ locals?: Namespace,
220
+ transient?: Namespace,
221
+ ];
222
+
223
+ /** Run compiled code in `namespaces`. */
224
+ function run(
225
+ fn: CompiledExpression,
226
+ ...[
227
+ variables,
228
+ temporary,
229
+ locals = EMPTY_NAMESPACE,
230
+ transient = EMPTY_NAMESPACE,
231
+ ]: Namespaces
208
232
  ): unknown {
209
- const transformed = transform(expr);
210
- // The line break keeps a trailing `// comment` from swallowing the `)`.
211
- const body = `return (${transformed}\n);`;
212
- const fn = getOrCompile(body, body);
213
233
  return fn(
214
234
  namespaceArg(variables),
215
235
  namespaceArg(temporary),
@@ -219,26 +239,24 @@ export function evaluate(
219
239
  );
220
240
  }
221
241
 
242
+ /**
243
+ * Evaluate an expression and return its value.
244
+ * e.g. evaluate("$health + 10", variables, temporary) → number
245
+ */
246
+ export function evaluate(expr: string, ...namespaces: Namespaces): unknown {
247
+ const transformed = transform(expr);
248
+ // The line break keeps a trailing `// comment` from swallowing the `)`.
249
+ const body = `return (${transformed}\n);`;
250
+ return run(getOrCompile(body, body), ...namespaces);
251
+ }
252
+
222
253
  /**
223
254
  * Execute statements (no return value).
224
255
  * e.g. execute("$health = 100; $name = 'Hero'", variables, temporary)
225
256
  */
226
- export function execute(
227
- code: string,
228
- variables: Namespace,
229
- temporary: Namespace,
230
- locals: Namespace = EMPTY_NAMESPACE,
231
- transient: Namespace = EMPTY_NAMESPACE,
232
- ): void {
257
+ export function execute(code: string, ...namespaces: Namespaces): void {
233
258
  const transformed = transform(code, 'statements');
234
- const fn = getOrCompile('exec:' + transformed, transformed);
235
- fn(
236
- namespaceArg(variables),
237
- namespaceArg(temporary),
238
- namespaceArg(locals),
239
- buildExpressionFns(),
240
- namespaceArg(transient),
241
- );
259
+ run(getOrCompile('exec:' + transformed, transformed), ...namespaces);
242
260
  }
243
261
 
244
262
  /**
@@ -2,22 +2,15 @@ import { useLayoutEffect, useRef } from 'preact/hooks';
2
2
  import {
3
3
  registerAction,
4
4
  generateActionId,
5
+ OPTIONAL_ACTION_FIELDS,
5
6
  type ActionRegistration,
6
- type ActionType,
7
7
  type StoryAction,
8
8
  } from '../action-registry';
9
9
 
10
- export interface UseActionOptions {
11
- type: ActionType;
10
+ /** The action to register, and what its ID is generated from. */
11
+ export interface UseActionOptions extends Omit<StoryAction, 'id'> {
12
12
  key: string;
13
13
  authorId?: string;
14
- label: string;
15
- target?: string;
16
- variable?: string;
17
- options?: string[];
18
- value?: unknown;
19
- disabled?: boolean;
20
- perform: (value?: unknown) => void;
21
14
  }
22
15
 
23
16
  export function useAction(opts: UseActionOptions): string {
@@ -39,11 +32,10 @@ export function useAction(opts: UseActionOptions): string {
39
32
  label: opts.label,
40
33
  perform: (...args) => performRef.current(...args),
41
34
  };
42
- if (opts.target !== undefined) action.target = opts.target;
43
- if (opts.variable !== undefined) action.variable = opts.variable;
44
- if (opts.options !== undefined) action.options = opts.options;
45
- if (opts.value !== undefined) action.value = opts.value;
46
- if (opts.disabled !== undefined) action.disabled = opts.disabled;
35
+ for (const field of OPTIONAL_ACTION_FIELDS) {
36
+ if (opts[field] !== undefined)
37
+ Object.assign(action, { [field]: opts[field] });
38
+ }
47
39
  return action;
48
40
  };
49
41
 
package/src/index.tsx CHANGED
@@ -19,9 +19,7 @@ import {
19
19
  import { getMacroRegistry } from './registry';
20
20
  import { tokenize } from './markup/tokenizer';
21
21
  import { buildAST, registerBlockMacro } from './markup/ast';
22
- import { registerWidget } from './widgets/widget-registry';
23
- import { astContainsChildren } from './widgets/ast-scanner';
24
- import { parseWidgetDef } from './components/macros/Widget';
22
+ import { parseWidgetDef, registerWidgetDef } from './components/macros/Widget';
25
23
  import { errorMessage } from './utils/error-message';
26
24
  import type { ASTNode } from './markup/ast';
27
25
  import './macros/register-builtins';
@@ -174,23 +172,15 @@ export function boot() {
174
172
  const widgetAST = buildAST(widgetTokens);
175
173
  for (const node of widgetAST) {
176
174
  if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
177
- const { name: widgetName, params } = parseWidgetDef(
178
- node.rawArgs,
179
- '$_@',
180
- );
181
- const children = node.children as ASTNode[];
182
- const isBlock = astContainsChildren(children);
175
+ // Read and registered as the {widget} macro does
176
+ const def = parseWidgetDef(node.rawArgs);
183
177
  try {
184
- registerWidget(widgetName, children, params, isBlock);
178
+ registerWidgetDef(def, node.children as ASTNode[]);
185
179
  } catch (err) {
186
180
  // As the {widget} macro refuses it: the others still register
187
181
  console.error(
188
- `spindle: widget "${widgetName}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
182
+ `spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
189
183
  );
190
- continue;
191
- }
192
- if (isBlock) {
193
- registerBlockMacro(widgetName);
194
184
  }
195
185
  }
196
186
  }
@@ -27,6 +27,7 @@ import { getMacro, getMacroText, isSubMacro } from './registry';
27
27
  import type { MacroTextContext } from './registry';
28
28
  import { getWidget } from './widgets/widget-registry';
29
29
  import { splitArgs } from './components/macros/arg-utils';
30
+ import { display } from './components/macros/display';
30
31
  import { splitSigilTemplate } from './markup/code-attributes';
31
32
  import {
32
33
  checkVariableName,
@@ -118,10 +119,6 @@ export function mapTextNodes(
118
119
  /** Deep enough for any real nesting; stops a widget that includes itself. */
119
120
  const MAX_DEPTH = 100;
120
121
 
121
- function display(value: unknown): string {
122
- return value == null ? '' : String(value);
123
- }
124
-
125
122
  /**
126
123
  * The value of a variable reference (`name` without its sigil, with an
127
124
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -154,6 +151,17 @@ function resolveVariable(
154
151
  return value;
155
152
  }
156
153
 
154
+ /** Evaluate an expression in `scope`. */
155
+ function evaluateIn(expr: string, scope: TextScope): unknown {
156
+ return evaluate(
157
+ expr,
158
+ scope.variables,
159
+ scope.temporary,
160
+ scope.locals,
161
+ scope.transient,
162
+ );
163
+ }
164
+
157
165
  class TextEvaluator {
158
166
  readonly errors: TextError[] = [];
159
167
 
@@ -163,16 +171,6 @@ class TextEvaluator {
163
171
  return text;
164
172
  }
165
173
 
166
- private evaluate(expr: string, scope: TextScope): unknown {
167
- return evaluate(
168
- expr,
169
- scope.variables,
170
- scope.temporary,
171
- scope.locals,
172
- scope.transient,
173
- );
174
- }
175
-
176
174
  private node(node: ASTNode, scope: TextScope, depth: number): string {
177
175
  switch (node.type) {
178
176
  case 'text':
@@ -190,7 +188,7 @@ class TextEvaluator {
190
188
  return this.variable(node.scope, node.name, scope);
191
189
  case 'expression':
192
190
  try {
193
- return display(this.evaluate(node.expression, scope));
191
+ return display(evaluateIn(node.expression, scope));
194
192
  } catch (error) {
195
193
  this.errors.push({ macro: 'expression', error });
196
194
  return '';
@@ -255,7 +253,7 @@ class TextEvaluator {
255
253
  const expr = argExprs[i];
256
254
  if (expr !== undefined) {
257
255
  try {
258
- value = this.evaluate(expr, scope);
256
+ value = evaluateIn(expr, scope);
259
257
  } catch {
260
258
  value = undefined;
261
259
  }
@@ -279,7 +277,7 @@ class TextEvaluator {
279
277
  }
280
278
 
281
279
  const ctx: MacroTextContext = {
282
- evaluate: (expr) => this.evaluate(expr, scope),
280
+ evaluate: (expr) => evaluateIn(expr, scope),
283
281
  renderText: (nodes, locals) =>
284
282
  this.nested(
285
283
  node.name,
@@ -369,15 +367,7 @@ export function interpolateCode(
369
367
  }
370
368
  } else {
371
369
  try {
372
- text += display(
373
- evaluate(
374
- part.expr,
375
- scope.variables,
376
- scope.temporary,
377
- scope.locals,
378
- scope.transient,
379
- ),
380
- );
370
+ text += display(evaluateIn(part.expr, scope));
381
371
  } catch (error) {
382
372
  errors.push({ macro: 'expression', error });
383
373
  }
@@ -386,22 +376,28 @@ export function interpolateCode(
386
376
  return { text, errors };
387
377
  }
388
378
 
379
+ /** The namespaces of a TextScope, passed one by one. */
380
+ type ScopeArgs = [
381
+ variables: Record<string, unknown>,
382
+ temporary: Record<string, unknown>,
383
+ locals: Record<string, unknown>,
384
+ transient?: Record<string, unknown>,
385
+ ];
386
+
387
+ function scopeOf(
388
+ ...[variables, temporary, locals, transient = {}]: ScopeArgs
389
+ ): TextScope {
390
+ return { variables, temporary, locals, transient };
391
+ }
392
+
389
393
  /**
390
394
  * Evaluate text-only markup to a string, throwing the first error met.
391
395
  */
392
396
  export function interpolate(
393
397
  template: string,
394
- variables: Record<string, unknown>,
395
- temporary: Record<string, unknown>,
396
- locals: Record<string, unknown>,
397
- transient: Record<string, unknown> = {},
398
+ ...namespaces: ScopeArgs
398
399
  ): string {
399
- const { text, errors } = interpolateText(template, {
400
- variables,
401
- temporary,
402
- locals,
403
- transient,
404
- });
400
+ const { text, errors } = interpolateText(template, scopeOf(...namespaces));
405
401
  if (errors.length > 0) throw errors[0]!.error;
406
402
  return text;
407
403
  }
@@ -412,10 +408,7 @@ export function interpolate(
412
408
  */
413
409
  export function interpolateExpression(
414
410
  expr: string,
415
- variables: Record<string, unknown>,
416
- temporary: Record<string, unknown>,
417
- locals: Record<string, unknown>,
418
- transient: Record<string, unknown> = {},
411
+ ...namespaces: ScopeArgs
419
412
  ): string {
420
- return display(evaluate(expr, variables, temporary, locals, transient));
413
+ return display(evaluateIn(expr, scopeOf(...namespaces)));
421
414
  }
package/src/js-lexer.ts CHANGED
@@ -397,6 +397,14 @@ const MALFORMED = -2;
397
397
  /** A frame result: the scan stops at index `s` inside it (`STOPPED - s`). */
398
398
  const STOPPED = -3;
399
399
 
400
+ /**
401
+ * The result of a frame that closes at `end`. `bodyStarted`: the code inside
402
+ * started a function or class body. That body replaced the one to come, and
403
+ * is gone once the frame is closed, so after the frame no body is to come —
404
+ * a scan that skips the frame must know, as must the frames around it.
405
+ */
406
+ const frameEnd = (end: number, bodyStarted: boolean) => end * 2 + +bodyStarted;
407
+
400
408
  /** The closers whose search for their frame a frame result may depend on. */
401
409
  const CLOSERS = [')', ']', '}'] as const;
402
410
  const FRAME_KINDS: readonly Frame['kind'][] = [
@@ -423,11 +431,13 @@ function frameKey(f: Frame): number | undefined {
423
431
  */
424
432
  export interface JsScanCache {
425
433
  /**
426
- * Where each `{…}` frame, template literal or `${…}` interpolation closes:
427
- * the index of its `}` or closing backtick, `UNCLOSED` or `MALFORMED`. The
428
- * code inside such a frame lexes the same whatever surrounds it, given
429
- * where it opens and its kind — a stray `)` or `]` inside never closes it
430
- * — so a later scan entering the same frame skips to its end.
434
+ * Where each `{…}` frame, template literal or `${…}` interpolation closes,
435
+ * as a frame result (`frameEnd`): its `}` or closing backtick, and whether
436
+ * the code inside started a function or class body; or `UNCLOSED` or
437
+ * `MALFORMED`. The code inside such a frame lexes the same whatever
438
+ * surrounds it, given where it opens and its kind — a stray `)` or `]`
439
+ * inside never closes it — so a later scan entering the same frame skips
440
+ * to its end.
431
441
  */
432
442
  braces: Map<number, number>;
433
443
  /** Look-ahead bracket matches (`ScanContext.brackets`). */
@@ -457,10 +467,8 @@ export interface JsScanCache {
457
467
  stacks: Map<string, number>;
458
468
  /**
459
469
  * How `(…)` and `[…]` frames end, by the kind of scan and then by the frame
460
- * (`parenKey`): `end * 2 + 1` if it closes at `end` and the code inside
461
- * started a function or class body (which replaced the one to come, and
462
- * can't follow once the frame is closed), `end * 2` otherwise, or
463
- * `UNCLOSED`, `MALFORMED` or `STOPPED - s`. Unlike braces, a stray closer inside may close a frame
470
+ * (`parenKey`): a frame result (`frameEnd`), or `UNCLOSED`, `MALFORMED` or
471
+ * `STOPPED - s`. Unlike braces, a stray closer inside may close a frame
464
472
  * around them, so the code inside lexes the same only around frames for
465
473
  * which the closers it met find nothing to close; the key says which
466
474
  * closers met none.
@@ -748,15 +756,34 @@ function scan(
748
756
  : undefined;
749
757
  /**
750
758
  * How many function or class bodies to come the scan has started, and with
751
- * `parens`, that count when each open frame opened. Code in a `(…)` or
752
- * `[…]` that starts one replaces the one to come, and the new one can't
753
- * follow once the frame is closed: none is to come then.
759
+ * `braces` or `parens`, that count when each open frame opened. Code in a
760
+ * frame that starts one replaces the one to come, and the new one can't
761
+ * follow once the frame is closed: none is to come then. A scan skipping
762
+ * the frame learns this from its result (`frameEnd`, `resume`), and counts
763
+ * the body as started too, for the frames around it to record.
754
764
  */
755
765
  let bodyStarts = 0;
756
- const startsAt: number[] | undefined = parens ? [0] : undefined;
766
+ const startsAt: number[] | undefined = braces || parens ? [0] : undefined;
757
767
  /** A `(…)` or `[…]` frame result found in `parens`, to skip to. */
758
768
  let parenTo: { frame: Frame; result: number } | undefined;
759
769
 
770
+ /** The result of the frame at index `k`, closing at `i`. */
771
+ const closedAt = (k: number) =>
772
+ frameEnd(i, startsAt !== undefined && bodyStarts !== startsAt[k]);
773
+
774
+ /**
775
+ * Go on after a frame an earlier scan lexed, whose result (`frameEnd`) is
776
+ * `result`: returns the index it closes at. A body started in it is gone
777
+ * after it, with the one it replaced.
778
+ */
779
+ function resume(result: number): number {
780
+ if (result % 2 === 1) {
781
+ pendingBody = undefined;
782
+ bodyStarts++;
783
+ }
784
+ return Math.floor(result / 2);
785
+ }
786
+
760
787
  /** The closer `c` looked for its frame and found index `k`. */
761
788
  function lookedFor(c: (typeof CLOSERS)[number], k: number) {
762
789
  if (!lowest) return;
@@ -933,8 +960,8 @@ function scan(
933
960
  if (lowest) {
934
961
  for (let j = frames.length - 1; j >= k; j--) foldLowest(j);
935
962
  lowest.length = k;
936
- startsAt!.length = k;
937
963
  }
964
+ if (startsAt) startsAt.length = k;
938
965
  frames.length = k;
939
966
  if (stackIds) stackIds.length = k;
940
967
  for (const list of Object.values(closers)) {
@@ -1034,6 +1061,19 @@ function scan(
1034
1061
  keyNext = f.kind === 'object' || f.kind === 'class';
1035
1062
  }
1036
1063
 
1064
+ /**
1065
+ * Go past frame `f` if an earlier scan lexed it: to just after it, or to
1066
+ * the end of the source when it never closes. Whether it did, or
1067
+ * MALFORMED when that scan found the code malformed.
1068
+ */
1069
+ function skipLexed(f: Frame): boolean | typeof MALFORMED {
1070
+ const end = known(f);
1071
+ if (end === undefined) return false;
1072
+ if (end === MALFORMED) return MALFORMED;
1073
+ i = end === UNCLOSED ? src.length : resume(end) + 1;
1074
+ return true;
1075
+ }
1076
+
1037
1077
  function openBrace() {
1038
1078
  let f: Frame;
1039
1079
  if (pendingBody?.depth === frames.length) {
@@ -1079,12 +1119,11 @@ function scan(
1079
1119
  return;
1080
1120
  }
1081
1121
  const closed = frames[k]!;
1122
+ const result = closedAt(k);
1082
1123
  if (lowest && c !== '}') {
1083
1124
  // Its entry complete, with those of the frames still open in it
1084
1125
  for (let j = frames.length - 1; j > k; j--) foldLowest(j);
1085
- // Whether code in it started a function or class body: none is to come
1086
- const bodyStarted = bodyStarts !== startsAt![k];
1087
- recordParen(k, i * 2 + +bodyStarted);
1126
+ recordParen(k, result);
1088
1127
  }
1089
1128
  truncate(k);
1090
1129
  if (c === ']' && !ctx.lookahead) ctx.brackets.set(closed.open, i);
@@ -1095,7 +1134,7 @@ function scan(
1095
1134
  } else if (c === ']') {
1096
1135
  endPunct(c, false);
1097
1136
  } else {
1098
- record(closed, i);
1137
+ record(closed, result);
1099
1138
  afterBrace(closed);
1100
1139
  }
1101
1140
  }
@@ -1214,24 +1253,17 @@ function scan(
1214
1253
  } else if (ch === '`') {
1215
1254
  literal(ch, i);
1216
1255
  if (frames.length === 1) return i + 1; // the end of `lexTemplate`
1217
- record(top(), i);
1256
+ record(top(), closedAt(frames.length - 1));
1218
1257
  i++;
1219
1258
  truncate(frames.length - 1);
1220
1259
  endOperand();
1221
1260
  } else if (ch === '$' && src.charAt(i + 1) === '{') {
1222
1261
  const f = frame('expr', '}', i);
1223
1262
  f.interpolation = true;
1224
- const end = known(f);
1225
- if (end === MALFORMED) return malformed();
1226
- if (end === UNCLOSED) {
1227
- i = src.length;
1228
- break;
1229
- }
1230
- if (end !== undefined) {
1231
- // An interpolation an earlier scan lexed: on with the text after it
1232
- i = end + 1;
1233
- continue;
1234
- }
1263
+ // An interpolation an earlier scan lexed: on with the text after it
1264
+ const skipped = skipLexed(f);
1265
+ if (skipped === MALFORMED) return malformed();
1266
+ if (skipped) continue;
1235
1267
  literal('${', i);
1236
1268
  i += 2;
1237
1269
  nesting++;
@@ -1261,15 +1293,10 @@ function scan(
1261
1293
  if (ch === '`') {
1262
1294
  endWord();
1263
1295
  const f = frame('template', '`', i);
1264
- const end = known(f);
1265
- if (end === MALFORMED) return malformed();
1266
- if (end === UNCLOSED) {
1267
- i = src.length;
1268
- break;
1269
- }
1270
- if (end !== undefined) {
1271
- // A template literal an earlier scan lexed: on after it
1272
- i = end + 1;
1296
+ // A template literal an earlier scan lexed: on after it
1297
+ const skipped = skipLexed(f);
1298
+ if (skipped === MALFORMED) return malformed();
1299
+ if (skipped) {
1273
1300
  endOperand();
1274
1301
  continue;
1275
1302
  }
@@ -1291,7 +1318,7 @@ function scan(
1291
1318
  lookedFor('}', k);
1292
1319
  if (frames[k]!.interpolation) {
1293
1320
  endWord();
1294
- record(frames[k]!, i);
1321
+ record(frames[k]!, closedAt(k));
1295
1322
  truncate(k);
1296
1323
  nesting--;
1297
1324
  literal(ch, i);
@@ -1399,8 +1426,17 @@ function scan(
1399
1426
  }
1400
1427
  }
1401
1428
 
1402
- // End of an `outer` square bracket
1403
- if (ch === outer.closer && frames.length === 1) break;
1429
+ // End of an `outer` square bracket: a `]` that closes no `[` within the
1430
+ // innermost braces closes it, over any `(` left open in it, as it closes
1431
+ // a nested `[`. Look-ahead scans record where the `[`s nested in theirs
1432
+ // end (`ctx.brackets`), so one starting at a `[` must find the same.
1433
+ if (
1434
+ ch === outer.closer &&
1435
+ (frames.length === 1 ||
1436
+ (ch === ']' && innermost(']') === 0 && innermost('}') === 0))
1437
+ ) {
1438
+ break;
1439
+ }
1404
1440
 
1405
1441
  // Regular code character
1406
1442
  trackCode(ch);
@@ -1413,7 +1449,7 @@ function scan(
1413
1449
  i = src.length;
1414
1450
  break;
1415
1451
  }
1416
- i = end;
1452
+ i = resume(end);
1417
1453
  afterBrace(skipped);
1418
1454
  i++;
1419
1455
  continue;
@@ -1432,8 +1468,7 @@ function scan(
1432
1468
  strict!.stopped = true;
1433
1469
  return STOPPED - result;
1434
1470
  }
1435
- i = Math.floor(result / 2);
1436
- if (result % 2 === 1) pendingBody = undefined;
1471
+ i = resume(result);
1437
1472
  if (skipped.closer === ')') {
1438
1473
  endPunct(')', skipped.header);
1439
1474
  stmtNext = skipped.header;
package/src/markup/ast.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type Token,
7
7
  type VariableScope,
8
8
  } from './tokenizer';
9
+ import { NameSet } from '../utils/macro-names';
9
10
 
10
11
  export interface TextNode {
11
12
  type: 'text';
@@ -51,7 +52,7 @@ export type ASTNode =
51
52
  | HtmlNode;
52
53
 
53
54
  /** Macros that require a closing tag and can contain children */
54
- const BLOCK_MACROS = new Set([
55
+ const BLOCK_MACROS = new NameSet([
55
56
  'if',
56
57
  'for',
57
58
  'do',
@@ -70,12 +71,12 @@ const BLOCK_MACROS = new Set([
70
71
 
71
72
  /** Register a custom macro as a block macro so the AST builder nests children. */
72
73
  export function registerBlockMacro(name: string): void {
73
- BLOCK_MACROS.add(name.toLowerCase());
74
+ BLOCK_MACROS.add(name);
74
75
  }
75
76
 
76
77
  /** Unregister a custom block macro (for test cleanup). */
77
78
  export function unregisterBlockMacro(name: string): void {
78
- BLOCK_MACROS.delete(name.toLowerCase());
79
+ BLOCK_MACROS.delete(name);
79
80
  }
80
81
 
81
82
  /** Map from branch macro name → required parent macro name */
@@ -588,12 +588,9 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
588
588
 
589
589
  function ChildrenSlot() {
590
590
  const childrenAST = useContext(WidgetChildrenContext);
591
- const nobr = useContext(NobrContext);
592
- const inline = useContext(InlineContext);
593
- const raw = useContext(RawTextContext);
594
- const locals = useContext(LocalsValuesContext);
591
+ const renderOptions = useRenderOptions();
595
592
  if (!childrenAST || childrenAST.length === 0) return null;
596
- return <>{renderNodes(childrenAST, { nobr, locals, inline, raw })}</>;
593
+ return <>{renderNodes(childrenAST, renderOptions)}</>;
597
594
  }
598
595
 
599
596
  /**
@@ -669,10 +666,7 @@ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
669
666
  return (
670
667
  <VarDisplay
671
668
  key={key}
672
- name={node.name}
673
- scope={node.scope}
674
- className={node.className}
675
- id={node.id}
669
+ node={node}
676
670
  />
677
671
  );
678
672
 
@@ -680,9 +674,7 @@ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
680
674
  return (
681
675
  <ExprDisplay
682
676
  key={key}
683
- expression={node.expression}
684
- className={node.className}
685
- id={node.id}
677
+ node={node}
686
678
  />
687
679
  );
688
680