@rohal12/spindle 0.52.9 → 0.54.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 (50) hide show
  1. package/README.md +5 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +6484 -2558
  4. package/dist/pkg/story-variables.js +4009 -940
  5. package/dist/pkg/tooling.js +13 -1
  6. package/package.json +6 -1
  7. package/src/class-registry.ts +252 -250
  8. package/src/components/App.tsx +2 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/RuntimeErrors.tsx +46 -0
  12. package/src/components/StoryInterface.tsx +2 -4
  13. package/src/components/macros/Computed.tsx +5 -2
  14. package/src/components/macros/Do.tsx +4 -2
  15. package/src/components/macros/Goto.tsx +13 -3
  16. package/src/components/macros/Include.tsx +2 -3
  17. package/src/components/macros/MacroError.tsx +14 -7
  18. package/src/components/macros/SaveManager.tsx +2 -2
  19. package/src/components/macros/VarDisplay.tsx +1 -1
  20. package/src/components/macros/Widget.tsx +5 -33
  21. package/src/index.tsx +57 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/markup/ast.ts +11 -226
  24. package/src/markup/code-attributes.ts +3 -3
  25. package/src/markup/code-end.ts +121 -0
  26. package/src/markup/parse.ts +118 -0
  27. package/src/markup/render.tsx +1 -1
  28. package/src/markup/spindle.d.peggy.ts +24 -0
  29. package/src/markup/spindle.peggy +463 -0
  30. package/src/markup/tokens.ts +95 -0
  31. package/src/markup/validate.ts +219 -0
  32. package/src/parser.ts +19 -4
  33. package/src/runtime-errors.ts +65 -0
  34. package/src/saves/format.ts +199 -0
  35. package/src/saves/save-manager.ts +45 -63
  36. package/src/saves/types.ts +71 -88
  37. package/src/store.ts +167 -87
  38. package/src/story-init.ts +3 -5
  39. package/src/story-variables.ts +18 -4
  40. package/src/structural.ts +266 -57
  41. package/src/styles.css +45 -0
  42. package/src/tooling.ts +50 -0
  43. package/src/types-drift-check.ts +28 -1
  44. package/src/utils/object-path.ts +13 -12
  45. package/src/utils/stable-key.ts +2 -1
  46. package/src/utils/value-kinds.ts +45 -0
  47. package/src/widgets/widget-def.ts +60 -0
  48. package/types/index.d.ts +32 -8
  49. package/types/tooling.d.ts +42 -0
  50. package/src/markup/tokenizer.ts +0 -1112
package/src/store.ts CHANGED
@@ -6,10 +6,7 @@ import {
6
6
  enableMapSet,
7
7
  enablePatches,
8
8
  isDraft,
9
- produceWithPatches,
10
- applyPatches,
11
9
  type Draft,
12
- type Patch,
13
10
  } from 'immer';
14
11
  import type { StoryData } from './parser';
15
12
  import type { TransitionConfig } from './transition';
@@ -19,7 +16,6 @@ import type {
19
16
  SaveInfo,
20
17
  SaveExport,
21
18
  } from './saves/types';
22
- import { isSaveExport } from './saves/types';
23
19
  import { executeStoryInit } from './story-init';
24
20
  import { emit } from './event-emitter';
25
21
  import {
@@ -46,8 +42,10 @@ import {
46
42
  clearAllData as smClearAllData,
47
43
  deletePlaythroughData as smDeletePlaythroughData,
48
44
  } from './saves/save-manager';
49
- import { serialize } from './class-registry';
50
- import { deepClone, mergeKeys, mergesWith } from './structural';
45
+
46
+ import { deepClone, mergeKeys, mergesWith, shareEqual } from './structural';
47
+ import { shallowCopy } from './utils/object-path';
48
+ import { showRuntimeError } from './runtime-errors';
51
49
  import {
52
50
  snapshotPRNG,
53
51
  restorePRNG,
@@ -66,6 +64,7 @@ import {
66
64
  createNamespace,
67
65
  hasOwn,
68
66
  isNamespace,
67
+ setOwn,
69
68
  type Counts,
70
69
  type Namespace,
71
70
  } from './utils/namespace';
@@ -92,9 +91,14 @@ const SPECIAL_PASSAGES = new Set([
92
91
  // Patch-based variable history (module-level, outside Zustand)
93
92
  // ---------------------------------------------------------------------------
94
93
 
94
+ /** A change to one variable between two history moments. */
95
+ type VarChange =
96
+ | { key: string; deleted: true }
97
+ | { key: string; deleted: false; value: unknown };
98
+
99
+ /** The changes that turn one history moment's variables into the next's. */
95
100
  interface PatchEntry {
96
- forward: Patch[];
97
- inverse: Patch[];
101
+ forward: VarChange[];
98
102
  }
99
103
 
100
104
  /** Full variable snapshot at history index 0. */
@@ -110,31 +114,55 @@ let patchEntries: PatchEntry[] = [];
110
114
  /** Immer-produced reference to variables right after the last navigation. */
111
115
  let lastNavigationVars: Namespace = createNamespace();
112
116
 
113
- /** Deep-clone patch values so they are independent of future mutations. */
114
- function clonePatches(patches: Patch[]): Patch[] {
115
- return patches.map((p) => ({
116
- ...p,
117
- value: p.value !== undefined ? deepClone(p.value) : undefined,
118
- }));
119
- }
120
-
121
- /** Compute forward + inverse patches that transform `prev` into `curr`. */
117
+ /**
118
+ * The variables that differ between `prev` and `curr`: added, replaced (by
119
+ * reference: store values are immutable, so an unchanged variable keeps its
120
+ * object) or deleted. A recorded value is a deep copy, so that changing a
121
+ * live value in place (a class instance is not frozen) cannot change
122
+ * history; instances of unregistered classes are kept, as the store keeps
123
+ * them (a save then refuses them).
124
+ */
122
125
  function computeVarPatches(
123
126
  prev: Record<string, unknown>,
124
127
  curr: Record<string, unknown>,
125
128
  ): PatchEntry {
126
- const [, forward, inverse] = produceWithPatches(prev, (draft) => {
127
- const d = draft as Record<string, unknown>;
128
- for (const key of Object.keys(d)) {
129
- // Own keys only: `curr` may be a plain object (a loaded snapshot),
130
- // whose inherited `constructor` is no variable
131
- if (!hasOwn(curr, key)) delete d[key];
132
- }
133
- for (const [key, val] of Object.entries(curr)) {
134
- d[key] = val;
129
+ const forward: VarChange[] = [];
130
+ for (const key of Object.keys(prev)) {
131
+ // Own keys only: `curr` may be a plain object (a loaded snapshot),
132
+ // whose inherited `constructor` is no variable
133
+ if (!hasOwn(curr, key)) forward.push({ key, deleted: true });
134
+ }
135
+ for (const key of Object.keys(curr)) {
136
+ if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) {
137
+ forward.push({
138
+ key,
139
+ deleted: false,
140
+ value: deepClone(curr[key], { keepUnregistered: true }),
141
+ });
135
142
  }
136
- });
137
- return { forward: clonePatches(forward), inverse: clonePatches(inverse) };
143
+ }
144
+ return { forward };
145
+ }
146
+
147
+ /**
148
+ * The variables of the moment after `vars`, by `changes`. The recorded
149
+ * values are used as they are, not copied, so values the moments share
150
+ * stay shared, and cycles and shared references in them hold (Immer's
151
+ * applyPatches copies each value, and recurses forever on a cycle).
152
+ */
153
+ function applyVarPatches(
154
+ vars: Record<string, unknown>,
155
+ changes: readonly VarChange[],
156
+ ): Record<string, unknown> {
157
+ const next = Object.create(
158
+ Object.getPrototypeOf(vars) as object | null,
159
+ ) as Record<string, unknown>;
160
+ for (const key of Object.keys(vars)) setOwn(next, key, vars[key]);
161
+ for (const change of changes) {
162
+ if (change.deleted) delete next[change.key];
163
+ else setOwn(next, change.key, change.value);
164
+ }
165
+ return next;
138
166
  }
139
167
 
140
168
  /**
@@ -160,7 +188,7 @@ function forEachRecorded(
160
188
  ): void {
161
189
  let vars: Record<string, unknown> = variableBase;
162
190
  for (let i = 0; i < count; i++) {
163
- if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
191
+ if (i > 0) vars = applyVarPatches(vars, patchEntries[i - 1]!.forward);
164
192
  fn(vars, i);
165
193
  }
166
194
  }
@@ -179,7 +207,7 @@ function savedMoment<V>(moment: HistoryMoment, variables: V) {
179
207
  function reconstructVarsAt(index: number): Record<string, unknown> {
180
208
  let vars: Record<string, unknown> = variableBase;
181
209
  for (let i = 0; i < index; i++) {
182
- vars = applyPatches(vars, patchEntries[i]!.forward);
210
+ vars = applyVarPatches(vars, patchEntries[i]!.forward);
183
211
  }
184
212
  return vars;
185
213
  }
@@ -188,8 +216,18 @@ function reconstructVarsAt(index: number): Record<string, unknown> {
188
216
  // Session persistence (sessionStorage — survives F5, cleared on tab close)
189
217
  // ---------------------------------------------------------------------------
190
218
 
191
- let serializedHistory: unknown[] = [];
219
+ /**
220
+ * The history moments the session holds, with the variables recorded for
221
+ * each. Store state is immutable, so holding the recorded objects keeps
222
+ * them as they were; moments share the values that did not change between
223
+ * them, and the session stores those once (see encodePayload).
224
+ */
225
+ let sessionMoments: SaveHistoryMoment[] = [];
192
226
 
227
+ /**
228
+ * Write the game to the session: the whole payload, encoded in one piece
229
+ * on every navigation.
230
+ */
193
231
  function persistSession(get: () => StoryState): void {
194
232
  const {
195
233
  storyData,
@@ -204,22 +242,22 @@ function persistSession(get: () => StoryState): void {
204
242
 
205
243
  // Trim cache when history shrank (navigate() drops discarded forward
206
244
  // moments itself, since a replacement branch may keep the same length)
207
- if (serializedHistory.length > history.length) {
208
- serializedHistory.length = history.length;
245
+ if (sessionMoments.length > history.length) {
246
+ sessionMoments.length = history.length;
209
247
  }
210
248
 
211
249
  // Append new entries
212
- if (serializedHistory.length < history.length) {
213
- const gap = history.length - serializedHistory.length;
250
+ if (sessionMoments.length < history.length) {
251
+ const gap = history.length - sessionMoments.length;
214
252
  if (gap === 1) {
215
253
  // Common path: one new moment at the end — use current variables directly
216
254
  const i = history.length - 1;
217
- serializedHistory[i] = savedMoment(history[i]!, serialize(variables));
255
+ sessionMoments[i] = savedMoment(history[i]!, variables);
218
256
  } else {
219
257
  // Bulk fill (after loadFromPayload) — reconstruct incrementally
220
258
  forEachRecorded(history.length, (vars, i) => {
221
- if (i >= serializedHistory.length) {
222
- serializedHistory[i] = savedMoment(history[i]!, serialize(vars));
259
+ if (i >= sessionMoments.length) {
260
+ sessionMoments[i] = savedMoment(history[i]!, vars);
223
261
  }
224
262
  });
225
263
  }
@@ -227,8 +265,8 @@ function persistSession(get: () => StoryState): void {
227
265
 
228
266
  saveSession(storyData.ifid, {
229
267
  passage: currentPassage,
230
- variables: serialize(variables),
231
- history: serializedHistory,
268
+ variables,
269
+ history: sessionMoments,
232
270
  historyIndex,
233
271
  visitCounts,
234
272
  renderCounts,
@@ -236,6 +274,36 @@ function persistSession(get: () => StoryState): void {
236
274
  });
237
275
  }
238
276
 
277
+ /** What the page shows before a session write error (see RuntimeErrors). */
278
+ const SESSION_ERROR_CONTEXT =
279
+ 'The game could not be saved for a page reload; a reload goes back to the last passage it could save:';
280
+
281
+ /**
282
+ * Write the session, then run `after` (the rest of the operation: its
283
+ * events and queued navigations), then throw the session's error if writing
284
+ * it failed: a value a save cannot hold (a function, an instance of an
285
+ * unregistered class, a unique symbol) fails the operation that put it in
286
+ * the state's history, as it fails a save, but only after the operation is
287
+ * complete, so the story is not left half-way through it. The error is also
288
+ * shown on the page, and the session keeps its last good copy.
289
+ */
290
+ function persistSessionThen(get: () => StoryState, after?: () => void): void {
291
+ let failure: { error: unknown } | undefined;
292
+ try {
293
+ persistSession(get);
294
+ } catch (error) {
295
+ failure = { error };
296
+ // The player sees it too: a reload would go back to the last moment
297
+ // the session could hold
298
+ showRuntimeError(SESSION_ERROR_CONTEXT, error);
299
+ }
300
+ try {
301
+ after?.();
302
+ } finally {
303
+ if (failure) throw failure.error;
304
+ }
305
+ }
306
+
239
307
  /**
240
308
  * Trim history to `state.maxHistory` moments: keep the newest moments that
241
309
  * include the current one. After a navigation (the current moment is the
@@ -255,11 +323,11 @@ function trimHistory(state: {
255
323
  const end = start + state.maxHistory;
256
324
  // Advance base through trimmed transitions
257
325
  for (let i = 0; i < start; i++) {
258
- variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
326
+ variableBase = applyVarPatches(variableBase, patchEntries[i]!.forward);
259
327
  }
260
328
  state.history = state.history.slice(start, end);
261
329
  patchEntries = patchEntries.slice(start, end - 1);
262
- serializedHistory = serializedHistory.slice(start, end);
330
+ sessionMoments = sessionMoments.slice(start, end);
263
331
  state.historyIndex -= start;
264
332
  return true;
265
333
  }
@@ -309,19 +377,20 @@ function finishEnteredMoment(get: StoreGet, set: StoreSet): void {
309
377
  }
310
378
 
311
379
  /**
312
- * A deep copy of a variable namespace as save data: a plain object, as a
313
- * loaded save holds it (the store turns it back into a namespace on load).
380
+ * A variable namespace as save data: a plain object, as a loaded save holds
381
+ * it (the store turns it back into a namespace on load). The values are the
382
+ * store's own, immutable ones, not copies: the moments of a payload share
383
+ * the values that did not change between them, and a save stores those
384
+ * once (see encodePayload).
314
385
  */
315
- const plainCopy = (ns: Namespace): Record<string, unknown> => ({
316
- ...deepClone(ns),
317
- });
386
+ const plainCopy = (ns: Namespace): Record<string, unknown> => ({ ...ns });
318
387
 
319
388
  /** Reset all module-level state (called on init, restart, loadFromPayload). */
320
389
  function resetModuleState(base: Namespace): void {
321
390
  variableBase = base;
322
391
  patchEntries = [];
323
392
  lastNavigationVars = base;
324
- serializedHistory = [];
393
+ sessionMoments = [];
325
394
  }
326
395
 
327
396
  /**
@@ -382,9 +451,9 @@ function moveInHistory(get: StoreGet, set: StoreSet, step: -1 | 1): void {
382
451
  reinitTriggerState();
383
452
  lastNavigationVars = get().variables;
384
453
  restorePRNGFromMoment(get().history[get().historyIndex]);
385
- persistSession(get);
386
-
387
- emit('afternavigate', targetPassage, previousPassage);
454
+ persistSessionThen(get, () =>
455
+ emit('afternavigate', targetPassage, previousPassage),
456
+ );
388
457
  }
389
458
 
390
459
  /**
@@ -613,7 +682,8 @@ function loadedEntryMoment(
613
682
  * the `beforesave` hooks) into the payload's snapshot of the saved moment. A
614
683
  * load restores that snapshot, the state on entering the passage, and runs
615
684
  * the passage again, so data a hook adds to a save would otherwise be lost
616
- * on load (#227). Only the property paths the hooks changed are written: a
685
+ * on load (#227). Only the property paths the hooks changed are written (into
686
+ * copies of the objects on those paths, the rest staying shared): a
617
687
  * whole variable would bring along what the passage did to the rest of it,
618
688
  * which the passage then does again on load (#232). Only the payload's copy
619
689
  * changes: the live history keeps the recorded snapshot (#159).
@@ -652,7 +722,11 @@ function mergeHookWrites(
652
722
  ) {
653
723
  return false;
654
724
  }
655
- mergeHookWrites(target[key] as Record<string, unknown>, b, a, ancestors);
725
+ // The snapshot's values are the history's own (immutable, see
726
+ // plainCopy): merge into a copy, made along the merged path only
727
+ const copy = shallowCopy(target[key] as object);
728
+ target[key] = copy;
729
+ mergeHookWrites(copy, b, a, ancestors);
656
730
  return true;
657
731
  });
658
732
  ancestors.delete(after);
@@ -984,7 +1058,7 @@ export const useStoryStore = create<StoryState>()(
984
1058
  // A lower limit takes effect at once
985
1059
  trimmed = trimHistory(state);
986
1060
  });
987
- if (trimmed) persistSession(get);
1061
+ if (trimmed) persistSessionThen(get);
988
1062
  },
989
1063
 
990
1064
  setQuickSaveKey: (key: string | null) => {
@@ -1090,8 +1164,8 @@ export const useStoryStore = create<StoryState>()(
1090
1164
  // Truncate forward history if we navigated back then chose a new path
1091
1165
  state.history = state.history.slice(0, state.historyIndex + 1);
1092
1166
  patchEntries.length = state.historyIndex;
1093
- if (serializedHistory.length > state.historyIndex + 1) {
1094
- serializedHistory.length = state.historyIndex + 1;
1167
+ if (sessionMoments.length > state.historyIndex + 1) {
1168
+ sessionMoments.length = state.historyIndex + 1;
1095
1169
  }
1096
1170
 
1097
1171
  // Push new transition and moment
@@ -1125,11 +1199,10 @@ export const useStoryStore = create<StoryState>()(
1125
1199
  const deferred = deferredNavigations;
1126
1200
  deferredNavigations = [];
1127
1201
  finishEnteredMoment(get, set);
1128
- persistSession(get);
1129
-
1130
- emit('afternavigate', passageName, previousPassage);
1131
-
1132
- for (const next of deferred) get().navigate(next);
1202
+ persistSessionThen(get, () => {
1203
+ emit('afternavigate', passageName, previousPassage);
1204
+ for (const next of deferred) get().navigate(next);
1205
+ });
1133
1206
  },
1134
1207
 
1135
1208
  goBack: () => moveInHistory(get, set, -1),
@@ -1344,8 +1417,8 @@ export const useStoryStore = create<StoryState>()(
1344
1417
  importSave: async (data: unknown, slot?: string): Promise<SaveInfo> => {
1345
1418
  const { storyData } = get();
1346
1419
  if (!storyData) throw new Error('spindle: Story is not initialized.');
1347
- if (!isSaveExport(data)) throw new Error('Invalid save file format');
1348
1420
 
1421
+ // Checked in full (format version, structure) before it is stored
1349
1422
  const info = await importSlotSave(data, storyData.ifid, slot);
1350
1423
  recordKnownSave(set, slot, true);
1351
1424
  return info;
@@ -1420,15 +1493,29 @@ export const useStoryStore = create<StoryState>()(
1420
1493
  renderCounts,
1421
1494
  } = get();
1422
1495
 
1423
- // Reconstruct full variable snapshots from base + patches
1496
+ // Reconstruct full variable snapshots from base + patches. Replaying
1497
+ // the (deep-copied) patches gives every moment its own copy of a
1498
+ // variable that changed; share what equals the previous moment's, so
1499
+ // that a save stores it once (see encodePayload).
1424
1500
  const saveHistory: SaveHistoryMoment[] = [];
1425
1501
  forEachRecorded(history.length, (vars, i) => {
1426
- saveHistory.push(savedMoment(history[i]!, plainCopy(vars)));
1502
+ const recorded = plainCopy(vars);
1503
+ saveHistory.push(
1504
+ savedMoment(
1505
+ history[i]!,
1506
+ i === 0
1507
+ ? recorded
1508
+ : shareEqual(saveHistory[i - 1]!.variables, recorded),
1509
+ ),
1510
+ );
1427
1511
  });
1428
1512
 
1429
1513
  return {
1430
1514
  passage: currentPassage,
1431
- variables: plainCopy(variables),
1515
+ variables: shareEqual(
1516
+ saveHistory[historyIndex]?.variables,
1517
+ plainCopy(variables),
1518
+ ),
1432
1519
  history: saveHistory,
1433
1520
  historyIndex,
1434
1521
  visitCounts: { ...visitCounts },
@@ -1478,27 +1565,20 @@ export const useStoryStore = create<StoryState>()(
1478
1565
 
1479
1566
  // The payload is already live (deserialized at the storage boundary by
1480
1567
  // loadSave/loadSession); deserializing again would corrupt built-ins.
1481
- // Convert full snapshots to patch entries
1482
- const base = createNamespace(
1483
- deepClone(payload.history[0]?.variables ?? {}),
1568
+ // Convert full snapshots to patch entries. They are copied in one
1569
+ // piece, so values the moments share stay shared, and a transition's
1570
+ // patches hold only the variables that changed.
1571
+ const snapshots = deepClone(payload.history.map((m) => m.variables)).map(
1572
+ (vars) => createNamespace(vars),
1484
1573
  );
1485
- const newPatchEntries: PatchEntry[] = [];
1486
-
1487
- let prevVars: Record<string, unknown> = base;
1488
- for (let i = 1; i < payload.history.length; i++) {
1489
- const currVars = createNamespace(
1490
- deepClone(payload.history[i]!.variables),
1491
- );
1492
- newPatchEntries.push(computeVarPatches(prevVars, currVars));
1493
- prevVars = currVars;
1494
- }
1495
-
1496
- variableBase = deepClone(base);
1497
- patchEntries = newPatchEntries;
1574
+ variableBase = deepClone(snapshots[0]!);
1575
+ patchEntries = snapshots
1576
+ .slice(1)
1577
+ .map((curr, i) => computeVarPatches(snapshots[i]!, curr));
1498
1578
  // Seed the session cache from the payload's own snapshots, so
1499
1579
  // persistSession does not rebuild them from the live variables.
1500
- serializedHistory = payload.history.map((m) =>
1501
- savedMoment(m, serialize(m.variables)),
1580
+ sessionMoments = payload.history.map((m, i) =>
1581
+ savedMoment(m, snapshots[i]!),
1502
1582
  );
1503
1583
 
1504
1584
  set((state) => {
@@ -1535,11 +1615,11 @@ export const useStoryStore = create<StoryState>()(
1535
1615
  // whose moments predate PRNG snapshots use the payload's.
1536
1616
  restorePRNGFrom(entry?.prng !== undefined ? entry.prng : payload.prng);
1537
1617
 
1538
- // Write the loaded game to the session so a refresh restores it
1539
- persistSession(get);
1540
-
1618
+ // Write the loaded game to the session so a refresh restores it.
1541
1619
  // Draws made by the handlers would shift the passage's replayed rolls
1542
- withoutDraws(() => emit('afterload', slot));
1620
+ persistSessionThen(get, () =>
1621
+ withoutDraws(() => emit('afterload', slot)),
1622
+ );
1543
1623
  },
1544
1624
 
1545
1625
  getHistoryVariables: (index: number): Record<string, unknown> => {
package/src/story-init.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { h, render } from 'preact';
2
2
  import { useStoryStore, recordStoryInitState } from './store';
3
- import { tokenize } from './markup/tokenizer';
4
- import { buildAST } from './markup/ast';
3
+ import { parseMarkup } from './markup/parse';
5
4
  import { renderNodes } from './markup/render';
6
5
  import { setSaveTitlePassage } from './saves/save-manager';
7
6
  import { emit } from './event-emitter';
@@ -22,7 +21,7 @@ function unmountStoryInit(): void {
22
21
  }
23
22
 
24
23
  /**
25
- * Execute the StoryInit passage: tokenize, parse, and render all macros
24
+ * Execute the StoryInit passage: parse it and render all macros
26
25
  * into a detached DOM node so their side effects fire through the normal
27
26
  * Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
28
27
  * Re-executing (on restart) first unmounts the previous StoryInit tree.
@@ -35,8 +34,7 @@ export function executeStoryInit() {
35
34
 
36
35
  const storyInit = state.storyData.passages.get('StoryInit');
37
36
  if (storyInit) {
38
- const tokens = tokenize(storyInit.content);
39
- const ast = buildAST(tokens);
37
+ const ast = parseMarkup(storyInit.content);
40
38
 
41
39
  // Mount into a persistent hidden container. It stays mounted until the
42
40
  // next execution (restart) — this lets async effects (useEffect,
@@ -1,10 +1,24 @@
1
1
  import type { Passage } from './parser';
2
- import { tokenize, type Token } from './markup/tokenizer';
2
+ import type { Token } from './markup/tokens';
3
+ import { MarkupError, tokenizeMarkup } from './markup/parse';
3
4
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
4
5
  import { errorMessage } from './utils/error-message';
5
6
  import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
6
7
  import { createNamespace, variableNameError } from './utils/namespace';
7
8
 
9
+ /**
10
+ * The tokens of markup, or none if it is malformed: validateMarkup reports
11
+ * that, with its position.
12
+ */
13
+ function tokensOf(text: string, textMode: boolean): Token[] {
14
+ try {
15
+ return tokenizeMarkup(text, { text: textMode });
16
+ } catch (err) {
17
+ if (err instanceof MarkupError) return [];
18
+ throw err;
19
+ }
20
+ }
21
+
8
22
  export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
9
23
 
10
24
  export interface FieldSchema {
@@ -217,7 +231,7 @@ function scanInterpolations(
217
231
  storeVarMacros: ReadonlySet<string> = NO_STORE_VAR_MACROS,
218
232
  ): void {
219
233
  if (!text.includes('{')) return;
220
- collectTokenRefs(text, tokenize(text, { text: true }), storeVarMacros, onRef);
234
+ collectTokenRefs(text, tokensOf(text, true), storeVarMacros, onRef);
221
235
  }
222
236
 
223
237
  /**
@@ -281,7 +295,7 @@ function collectPassageRefs(
281
295
  storeVarMacros: ReadonlySet<string>,
282
296
  onRef: RefCallback,
283
297
  ): void {
284
- collectTokenRefs(content, tokenize(content), storeVarMacros, onRef);
298
+ collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef);
285
299
  }
286
300
 
287
301
  /** Report the `$var` references in the tokens of `content`. */
@@ -313,7 +327,7 @@ function collectTokenRefs(
313
327
 
314
328
  if (token.name === 'do') {
315
329
  // A {do} body is JavaScript: scan its source text as code, however
316
- // the markup tokenizer split it up.
330
+ // the markup tokens split it up.
317
331
  let close = t + 1;
318
332
  while (close < tokens.length) {
319
333
  const c = tokens[close]!;