@rohal12/spindle 0.51.0 → 0.51.2

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.
@@ -11,7 +11,8 @@ export function hasInterpolation(s: string): boolean {
11
11
  function resolveDotPath(root: unknown, parts: string[]): unknown {
12
12
  let value = root;
13
13
  for (let i = 1; i < parts.length; i++) {
14
- if (value == null || typeof value !== 'object') return undefined;
14
+ // Primitives box on access, so `{$name.length}` works too.
15
+ if (value == null) return undefined;
15
16
  value = (value as Record<string, unknown>)[parts[i]!];
16
17
  }
17
18
  return value;
package/src/markup/ast.ts CHANGED
@@ -91,6 +91,14 @@ const BRANCH_PARENT: Record<string, string> = {
91
91
  /** Block macros that use the branches[] array */
92
92
  const BRANCHING_BLOCK_MACROS = new Set(['if', 'switch', 'timed']);
93
93
 
94
+ /**
95
+ * Quote a bracket-link value as a {link} argument, escaping backslashes and
96
+ * double quotes so MacroLink reads the value back unchanged (#200).
97
+ */
98
+ function quoteArg(value: string): string {
99
+ return `"${value.replace(/[\\"]/g, '\\$&')}"`;
100
+ }
101
+
94
102
  /**
95
103
  * Build an AST from a token array. Block macros are nested into trees
96
104
  * using a stack. Throws on unclosed or mismatched macros.
@@ -118,7 +126,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
118
126
  break;
119
127
 
120
128
  case 'link': {
121
- const rawArgs = `"${token.display}" "${token.target}"`;
129
+ const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
122
130
  const macroNode: MacroNode = {
123
131
  type: 'macro',
124
132
  name: 'link',
@@ -339,7 +339,8 @@ function getVariableTextValue(
339
339
  else value = locals[root];
340
340
 
341
341
  for (let i = 1; i < parts.length; i++) {
342
- if (value == null || typeof value !== 'object') return '';
342
+ // Primitives box on access, so `{$name.length}` works too.
343
+ if (value == null) return '';
343
344
  value = (value as Record<string, unknown>)[parts[i]!];
344
345
  }
345
346
 
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Name of the passage most recently mounted by <Passage> (null until the
3
- * first render, e.g. when the story interface has no {passage}).
4
- * Lets Story.waitForActions() tell whether the current passage is on screen.
2
+ * Navigation id (the store's `navigationId`) of the passage most recently
3
+ * mounted by <Passage> (null until the first render, e.g. when the story
4
+ * interface has no {passage}). Lets Story.waitForActions() tell whether the
5
+ * current navigation is on screen, also when it revisits the passage shown.
5
6
  */
6
- let renderedPassage: string | null = null;
7
+ let renderedNavigationId: number | null = null;
7
8
 
8
- export function markPassageRendered(name: string): void {
9
- renderedPassage = name;
9
+ export function markPassageRendered(navigationId: number): void {
10
+ renderedNavigationId = navigationId;
10
11
  }
11
12
 
12
- export function getRenderedPassage(): string | null {
13
- return renderedPassage;
13
+ export function getRenderedNavigationId(): number | null {
14
+ return renderedNavigationId;
14
15
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Zustand's `create()` for Preact.
3
+ *
4
+ * Zustand's default entry (`zustand` / `zustand/react`) imports `react`, which
5
+ * Spindle doesn't depend on. Bundlers that alias `react` to `preact/compat`
6
+ * hide that, but the published headless entry keeps Zustand external and runs
7
+ * under plain Node resolution, where `react` doesn't exist. Building on the
8
+ * vanilla store and Preact's own hooks keeps `react` out of the module graph.
9
+ */
10
+ import { useSyncExternalStore } from 'preact/compat';
11
+ import { useCallback } from 'preact/hooks';
12
+ import {
13
+ createStore,
14
+ type ExtractState,
15
+ type Mutate,
16
+ type StateCreator,
17
+ type StoreApi,
18
+ type StoreMutatorIdentifier,
19
+ } from 'zustand/vanilla';
20
+
21
+ /** A store API that is also a selector hook, like Zustand's bound stores. */
22
+ export type UseBoundStore<S extends StoreApi<unknown>> = S & {
23
+ (): ExtractState<S>;
24
+ <U>(selector: (state: ExtractState<S>) => U): U;
25
+ };
26
+
27
+ const identity = <T>(value: T): T => value;
28
+
29
+ function bindPreact<S extends StoreApi<unknown>>(api: S): UseBoundStore<S> {
30
+ function useBoundStore<U>(
31
+ selector: (state: ExtractState<S>) => U = identity as (
32
+ state: ExtractState<S>,
33
+ ) => U,
34
+ ): U {
35
+ const getSnapshot = useCallback(
36
+ () => selector(api.getState() as ExtractState<S>),
37
+ [selector],
38
+ );
39
+ return useSyncExternalStore(api.subscribe, getSnapshot);
40
+ }
41
+ return Object.assign(useBoundStore, api) as UseBoundStore<S>;
42
+ }
43
+
44
+ /** Curried `create<State>()(initializer)`, as in Zustand. */
45
+ export function create<T>() {
46
+ return <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
47
+ initializer: StateCreator<T, [], Mos>,
48
+ ): UseBoundStore<Mutate<StoreApi<T>, Mos>> =>
49
+ bindPreact(createStore<T>()(initializer));
50
+ }
@@ -7,7 +7,7 @@ import type {
7
7
  SaveExport,
8
8
  StorageInfo,
9
9
  } from './types';
10
- import { isSavePayload } from './types';
10
+ import { isSaveExport, isSavePayload } from './types';
11
11
  import { getBackend, resetBackend } from './storage';
12
12
  import { deepClone, serialize, deserialize } from '../class-registry';
13
13
  import { emit } from '../event-emitter';
@@ -102,6 +102,10 @@ export async function getCurrentPlaythroughId(
102
102
  * before `getPayload()` captures the state, so data a hook sets is part of the
103
103
  * save; `aftersave` fires once `write` has stored it. Every user-facing save
104
104
  * path goes through here.
105
+ *
106
+ * The hook and capture run synchronously, but an error they throw (a failing
107
+ * hook, an unserializable payload) rejects the returned promise like a failed
108
+ * write rather than escaping to the caller.
105
109
  */
106
110
  export function saveWithHooks<T>(
107
111
  slot: string | undefined,
@@ -109,12 +113,16 @@ export function saveWithHooks<T>(
109
113
  getPayload: () => SavePayload,
110
114
  write: (payload: SavePayload) => Promise<T>,
111
115
  ): Promise<T> {
112
- emit('beforesave', slot, custom);
113
- const payload = getPayload();
114
- return write(payload).then((result) => {
115
- emit('aftersave', slot);
116
- return result;
117
- });
116
+ try {
117
+ emit('beforesave', slot, custom);
118
+ const payload = getPayload();
119
+ return write(payload).then((result) => {
120
+ emit('aftersave', slot);
121
+ return result;
122
+ });
123
+ } catch (err) {
124
+ return Promise.reject(err);
125
+ }
118
126
  }
119
127
 
120
128
  // --- Save CRUD ---
@@ -204,8 +212,26 @@ export async function loadSave(
204
212
  return deserializePayload(record.payload);
205
213
  }
206
214
 
215
+ /**
216
+ * Delete a save record. If the default slot or a named slot holds it, that
217
+ * slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
218
+ */
207
219
  export async function deleteSaveById(saveId: string): Promise<void> {
208
- await (await getBackend()).deleteSave(saveId);
220
+ const backend = await getBackend();
221
+ const record = await backend.getSave(saveId);
222
+ await backend.deleteSave(saveId);
223
+ if (!record) return;
224
+
225
+ const ifid = record.meta.ifid;
226
+ const slots = [undefined, ...(await getIndexedSlots(ifid))];
227
+ for (const slot of slots) {
228
+ const metaKey = slotMetaKey(ifid, slot);
229
+ await withSlot(metaKey, async () => {
230
+ if ((await backend.getMeta<string>(metaKey)) !== saveId) return;
231
+ await backend.deleteMeta(metaKey);
232
+ await removeFromSlotIndex(ifid, slot);
233
+ });
234
+ }
209
235
  }
210
236
 
211
237
  export async function renameSave(
@@ -314,6 +340,25 @@ function slotMetaKey(ifid: string, slot?: string): string {
314
340
  : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
315
341
  }
316
342
 
343
+ /** Tail of each slot's operation queue, keyed by the slot's meta key. */
344
+ const slotQueues = new Map<string, Promise<unknown>>();
345
+
346
+ /**
347
+ * Run a complete operation on one slot (save, import, delete) after the ones
348
+ * already queued for it. Each operation reads the slot pointer and writes the
349
+ * record and pointer as a unit, so concurrent operations on the same slot
350
+ * apply in call order instead of each creating its own record.
351
+ */
352
+ function withSlot<T>(metaKey: string, op: () => Promise<T>): Promise<T> {
353
+ const run = (slotQueues.get(metaKey) ?? Promise.resolve()).then(op);
354
+ const tail = run.catch(() => {});
355
+ slotQueues.set(metaKey, tail);
356
+ void tail.then(() => {
357
+ if (slotQueues.get(metaKey) === tail) slotQueues.delete(metaKey);
358
+ });
359
+ return run;
360
+ }
361
+
317
362
  /** Tail of the queue that serializes slot index updates. */
318
363
  let slotIndexQueue: Promise<unknown> = Promise.resolve();
319
364
 
@@ -368,32 +413,34 @@ function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
368
413
  };
369
414
  }
370
415
 
371
- export async function quickSave(
416
+ export function quickSave(
372
417
  ifid: string,
373
418
  playthroughId: string,
374
419
  payload: SavePayload,
375
420
  slot?: string,
376
421
  custom?: Record<string, unknown>,
377
422
  ): Promise<SaveRecord> {
378
- const backend = await getBackend();
379
423
  const metaKey = slotMetaKey(ifid, slot);
380
- const existingId = await backend.getMeta<string>(metaKey);
424
+ return withSlot(metaKey, async () => {
425
+ const backend = await getBackend();
426
+ const existingId = await backend.getMeta<string>(metaKey);
381
427
 
382
- if (existingId) {
383
- const updated = await overwriteSave(existingId, payload, custom);
384
- if (updated) return updated;
385
- }
428
+ if (existingId) {
429
+ const updated = await overwriteSave(existingId, payload, custom);
430
+ if (updated) return updated;
431
+ }
386
432
 
387
- // Create new save
388
- const record = await createSave(ifid, playthroughId, payload, {
389
- isAutosave: !isNamedSlot(slot),
390
- ...(isNamedSlot(slot) ? { slot } : {}),
391
- ...custom,
392
- });
393
- await backend.setMeta(metaKey, record.meta.id);
394
- await addToSlotIndex(ifid, slot);
433
+ // Create new save
434
+ const record = await createSave(ifid, playthroughId, payload, {
435
+ isAutosave: !isNamedSlot(slot),
436
+ ...(isNamedSlot(slot) ? { slot } : {}),
437
+ ...custom,
438
+ });
439
+ await backend.setMeta(metaKey, record.meta.id);
440
+ await addToSlotIndex(ifid, slot);
395
441
 
396
- return record;
442
+ return record;
443
+ });
397
444
  }
398
445
 
399
446
  export async function hasQuickSave(
@@ -425,7 +472,8 @@ export async function loadQuickSave(
425
472
  export async function populateKnownSaves(
426
473
  ifid: string,
427
474
  ): Promise<Record<string, true>> {
428
- const result: Record<string, true> = {};
475
+ // No prototype, so a slot named '__proto__' is recorded as an entry
476
+ const result = Object.create(null) as Record<string, true>;
429
477
 
430
478
  // Check default autosave
431
479
  if (await hasQuickSave(ifid)) {
@@ -481,20 +529,19 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
481
529
  /**
482
530
  * Delete a save by slot name. Removes from slot index if named.
483
531
  */
484
- export async function deleteSlotSave(
485
- ifid: string,
486
- slot?: string,
487
- ): Promise<void> {
488
- const backend = await getBackend();
532
+ export function deleteSlotSave(ifid: string, slot?: string): Promise<void> {
489
533
  const metaKey = slotMetaKey(ifid, slot);
490
- const existingId = await backend.getMeta<string>(metaKey);
491
- if (!existingId) return;
534
+ return withSlot(metaKey, async () => {
535
+ const backend = await getBackend();
536
+ const existingId = await backend.getMeta<string>(metaKey);
537
+ if (!existingId) return;
492
538
 
493
- await backend.deleteSave(existingId);
494
- await backend.deleteMeta(metaKey);
539
+ await backend.deleteSave(existingId);
540
+ await backend.deleteMeta(metaKey);
495
541
 
496
- // Remove from slot index if named
497
- await removeFromSlotIndex(ifid, slot);
542
+ // Remove from slot index if named
543
+ await removeFromSlotIndex(ifid, slot);
544
+ });
498
545
  }
499
546
 
500
547
  // --- Session Persistence (survives F5, cleared on tab close) ---
@@ -569,6 +616,11 @@ async function prepareImport(
569
616
  `Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`,
570
617
  );
571
618
  }
619
+ // Callers may pass parsed JSON; check the full structure before anything
620
+ // is stored or replaced
621
+ if (!isSaveExport(data)) {
622
+ throw new Error('Invalid save file format');
623
+ }
572
624
 
573
625
  const backend = await getBackend();
574
626
 
@@ -624,32 +676,34 @@ export async function exportSlotSave(
624
676
  * validation as `importSave` (version, IFID) and keeps the playthrough record
625
677
  * and slot index consistent.
626
678
  */
627
- export async function importSlotSave(
679
+ export function importSlotSave(
628
680
  data: SaveExport,
629
681
  ifid: string,
630
682
  slot?: string,
631
683
  ): Promise<SaveInfo> {
632
- const record = await prepareImport(data, ifid);
633
-
634
- // The slot keys in `custom` describe where the save lives, so they follow
635
- // the target slot rather than the slot the save was exported from.
636
- const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
637
- record.meta.custom = {
638
- ...custom,
639
- isAutosave: !isNamedSlot(slot),
640
- ...(isNamedSlot(slot) ? { slot } : {}),
641
- };
642
-
643
- const backend = await getBackend();
644
684
  const metaKey = slotMetaKey(ifid, slot);
645
- const previousId = await backend.getMeta<string>(metaKey);
685
+ return withSlot(metaKey, async () => {
686
+ const record = await prepareImport(data, ifid);
687
+
688
+ // The slot keys in `custom` describe where the save lives, so they follow
689
+ // the target slot rather than the slot the save was exported from.
690
+ const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
691
+ record.meta.custom = {
692
+ ...custom,
693
+ isAutosave: !isNamedSlot(slot),
694
+ ...(isNamedSlot(slot) ? { slot } : {}),
695
+ };
646
696
 
647
- await backend.putSave(record);
648
- await backend.setMeta(metaKey, record.meta.id);
649
- await addToSlotIndex(ifid, slot);
650
- if (previousId) await backend.deleteSave(previousId);
697
+ const backend = await getBackend();
698
+ const previousId = await backend.getMeta<string>(metaKey);
651
699
 
652
- return toSaveInfo(record, slot);
700
+ await backend.putSave(record);
701
+ await backend.setMeta(metaKey, record.meta.id);
702
+ await addToSlotIndex(ifid, slot);
703
+ if (previousId) await backend.deleteSave(previousId);
704
+
705
+ return toSaveInfo(record, slot);
706
+ });
653
707
  }
654
708
 
655
709
  // --- Storage Management ---
@@ -548,14 +548,28 @@ async function detectBackend(): Promise<StorageBackend> {
548
548
  return createMemoryBackend();
549
549
  }
550
550
 
551
- export async function getBackend(): Promise<StorageBackend> {
552
- if (_backend) return _backend;
553
- _backend = await detectBackend();
554
- return _backend;
551
+ /**
552
+ * Detection in flight. Concurrent first callers share it so they all get the
553
+ * same backend (the memory fallback makes a new, empty store per detection).
554
+ */
555
+ let _backendPromise: Promise<StorageBackend> | null = null;
556
+
557
+ export function getBackend(): Promise<StorageBackend> {
558
+ if (_backend) return Promise.resolve(_backend);
559
+ if (!_backendPromise) {
560
+ const pending = detectBackend().then((backend) => {
561
+ // A reset while detecting discards this result
562
+ if (_backendPromise === pending) _backend = backend;
563
+ return backend;
564
+ });
565
+ _backendPromise = pending;
566
+ }
567
+ return _backendPromise;
555
568
  }
556
569
 
557
570
  export function resetBackend(): void {
558
571
  _backend = null;
572
+ _backendPromise = null;
559
573
  }
560
574
 
561
575
  /**
@@ -78,15 +78,64 @@ export function isSaveExport(value: unknown): value is SaveExport {
78
78
  if (typeof meta.updatedAt !== 'string') return false;
79
79
  if (typeof meta.title !== 'string') return false;
80
80
 
81
- const payload = save.payload as Record<string, unknown>;
82
- if (typeof payload.passage !== 'string') return false;
83
- if (!Array.isArray(payload.history) || payload.history.length === 0)
84
- return false;
85
- if (typeof payload.historyIndex !== 'number') return false;
86
- if (typeof payload.variables !== 'object' || payload.variables === null)
81
+ return isImportablePayload(save.payload);
82
+ }
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ /** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
89
+ function isOptionalPRNGSnapshot(value: unknown): boolean {
90
+ if (value == null) return true;
91
+ return (
92
+ isRecord(value) &&
93
+ typeof value.seed === 'string' &&
94
+ typeof value.pull === 'number' &&
95
+ Number.isInteger(value.pull) &&
96
+ value.pull >= 0
97
+ );
98
+ }
99
+
100
+ function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
101
+ return (
102
+ isRecord(value) &&
103
+ typeof value.passage === 'string' &&
104
+ isRecord(value.variables) &&
105
+ typeof value.timestamp === 'number' &&
106
+ isOptionalPRNGSnapshot(value.prng)
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Full check of a payload from outside the running story (an imported save):
112
+ * every history moment is well formed, and `historyIndex` is an integer that
113
+ * points at a moment of the payload's passage. Anything that passes can be
114
+ * stored and later loaded.
115
+ */
116
+ function isImportablePayload(value: unknown): value is SavePayload {
117
+ if (!isRecord(value)) return false;
118
+ if (typeof value.passage !== 'string') return false;
119
+ if (!isRecord(value.variables)) return false;
120
+
121
+ const { history, historyIndex } = value;
122
+ if (!Array.isArray(history) || history.length === 0) return false;
123
+ if (!history.every(isSaveHistoryMoment)) return false;
124
+ if (
125
+ typeof historyIndex !== 'number' ||
126
+ !Number.isInteger(historyIndex) ||
127
+ historyIndex < 0 ||
128
+ historyIndex >= history.length
129
+ ) {
87
130
  return false;
131
+ }
132
+ if (history[historyIndex]!.passage !== value.passage) return false;
88
133
 
89
- return true;
134
+ if (value.visitCounts !== undefined && !isRecord(value.visitCounts))
135
+ return false;
136
+ if (value.renderCounts !== undefined && !isRecord(value.renderCounts))
137
+ return false;
138
+ return isOptionalPRNGSnapshot(value.prng);
90
139
  }
91
140
 
92
141
  export function isSavePayload(value: unknown): value is SavePayload {
package/src/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { create } from 'zustand';
1
+ import { create } from './preact-store';
2
2
  import { immer } from 'zustand/middleware/immer';
3
3
  import {
4
4
  enablePatches,
@@ -214,8 +214,10 @@ function resetModuleState(base: Record<string, unknown>): void {
214
214
  /**
215
215
  * Record the state StoryInit left behind as the start moment: its variable
216
216
  * snapshot (the history base) and PRNG state. Called by executeStoryInit()
217
- * after the StoryInit passage has rendered; init()/restart() record the
218
- * start moment before StoryInit runs. A no-op once the story has moved on.
217
+ * after the StoryInit passage has rendered, and again after the `storyinit`
218
+ * handlers have run at boot (unless a session was restored) and on restart;
219
+ * init()/restart() record the start moment before StoryInit runs. A no-op
220
+ * once the story has moved on.
219
221
  */
220
222
  export function recordStoryInitState(): void {
221
223
  const { history, historyIndex, variables } = useStoryStore.getState();
@@ -777,6 +779,8 @@ export const useStoryStore = create<StoryState>()(
777
779
  executeStoryInit();
778
780
  clearSession(storyData.ifid);
779
781
  emit('storyinit');
782
+ // The storyinit handlers' changes belong to the start moment too
783
+ recordStoryInitState();
780
784
 
781
785
  // Start a new playthrough on restart
782
786
  startNewPlaythrough(storyData.ifid)
@@ -855,7 +859,8 @@ export const useStoryStore = create<StoryState>()(
855
859
  hasSave: (slot?: string) => {
856
860
  const { storyData, knownSaves } = get();
857
861
  if (!storyData) return false;
858
- return (slot ?? '') in knownSaves;
862
+ // Own entries only: slot names like 'constructor' are not inherited saves
863
+ return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? '');
859
864
  },
860
865
 
861
866
  getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
package/src/story-api.ts CHANGED
@@ -30,7 +30,7 @@ import type { MacroDefinition } from './define-macro';
30
30
  import { getMacroRegistry as _getMacroRegistry } from './registry';
31
31
  import type { MacroMetadata } from './registry';
32
32
  import { getActions, getAction, type StoryAction } from './action-registry';
33
- import { getRenderedPassage } from './passage-render-state';
33
+ import { getRenderedNavigationId } from './passage-render-state';
34
34
  import {
35
35
  initPRNG,
36
36
  isPRNGEnabled,
@@ -548,13 +548,19 @@ function createStoryAPI(): StoryAPI {
548
548
  });
549
549
  // A navigation can still be rendering: the passage display mounts the
550
550
  // new passage from an effect, after a fade-through's outgoing phase.
551
- // Its actions exist only once it is mounted, so wait for that.
552
- const { currentPassage, renderDeferred } = useStoryStore.getState();
553
- const rendered = getRenderedPassage();
554
- if (rendered !== null && rendered !== currentPassage && !renderDeferred) {
551
+ // Its actions exist only once it is mounted, so wait for that. Compare
552
+ // navigations, not passage names: revisiting the passage shown mounts
553
+ // it anew (#228).
554
+ const isRendered = () =>
555
+ getRenderedNavigationId() === useStoryStore.getState().navigationId;
556
+ if (
557
+ getRenderedNavigationId() !== null &&
558
+ !isRendered() &&
559
+ !useStoryStore.getState().renderDeferred
560
+ ) {
555
561
  await new Promise<void>((resolve) => {
556
- const off = emitterOn('passagerender', (name) => {
557
- if (name === useStoryStore.getState().currentPassage) {
562
+ const off = emitterOn('passagerender', () => {
563
+ if (isRendered()) {
558
564
  off();
559
565
  resolve();
560
566
  }
package/src/story-init.ts CHANGED
@@ -4,6 +4,8 @@ import { tokenize } from './markup/tokenizer';
4
4
  import { buildAST } from './markup/ast';
5
5
  import { renderNodes } from './markup/render';
6
6
  import { setSaveTitlePassage } from './saves/save-manager';
7
+ import { emit } from './event-emitter';
8
+ import type { SavePayload } from './saves/types';
7
9
 
8
10
  /** Hidden container holding the currently mounted StoryInit tree. */
9
11
  let storyInitContainer: HTMLElement | null = null;
@@ -60,3 +62,24 @@ export function executeStoryInit() {
60
62
  setSaveTitlePassage(saveTitlePassage.content);
61
63
  }
62
64
  }
65
+
66
+ /**
67
+ * Initialize a freshly booted story: run StoryInit, restore `session` (the
68
+ * state a page refresh left behind) if there is one, then fire `storyinit`
69
+ * once all state is settled. Without a session, what the `storyinit`
70
+ * handlers set synchronously belongs to the start moment, like StoryInit's
71
+ * changes; a restored session keeps the history it recorded.
72
+ */
73
+ export function initializeStory(session?: SavePayload): void {
74
+ executeStoryInit();
75
+
76
+ if (session) {
77
+ useStoryStore.getState().loadFromPayload(session);
78
+ }
79
+
80
+ emit('storyinit');
81
+
82
+ if (!session) {
83
+ recordStoryInitState();
84
+ }
85
+ }
@@ -26,6 +26,13 @@ const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
26
26
 
27
27
  const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
28
28
 
29
+ /** Boxed sample values whose members a primitive of each type can access. */
30
+ const PRIMITIVE_SAMPLES: Partial<Record<VarType, object>> = {
31
+ string: Object(''),
32
+ number: Object(0),
33
+ boolean: Object(false),
34
+ };
35
+
29
36
  function inferSchema(value: unknown): FieldSchema {
30
37
  if (Array.isArray(value)) {
31
38
  return { type: 'array' };
@@ -138,6 +145,17 @@ function validateRef(
138
145
  // so any field access on an array is allowed.
139
146
  if (current.type === 'array') return null;
140
147
 
148
+ // Primitives expose their wrapper's built-ins (length, toUpperCase,
149
+ // toFixed, etc.). Keep validating past properties of primitive type.
150
+ const sample = PRIMITIVE_SAMPLES[current.type];
151
+ if (sample && part in sample) {
152
+ const member: unknown = sample[part as keyof typeof sample];
153
+ const memberType = typeof member;
154
+ if (!VALID_VAR_TYPES.has(memberType)) return null;
155
+ current = { type: memberType as VarType };
156
+ continue;
157
+ }
158
+
141
159
  if (current.type !== 'object' || !current.fields) {
142
160
  return `Cannot access field "${part}" on $${parts.slice(0, i).join('.')} (type: ${current.type})`;
143
161
  }