@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
@@ -7,9 +7,14 @@ import type {
7
7
  SaveExport,
8
8
  StorageInfo,
9
9
  } from './types';
10
- import { estimatePayloadBytes, isSaveExport, isSavePayload } from './types';
10
+ import { checkSaveExport, estimatePayloadBytes } from './types';
11
+ import {
12
+ decodePayload,
13
+ encodePayload,
14
+ IncompatibleSaveError,
15
+ SAVE_FORMAT_VERSION,
16
+ } from './format';
11
17
  import { getBackend, resetBackend } from './storage';
12
- import { serialize, deserialize } from '../class-registry';
13
18
  import { deepClone } from '../structural';
14
19
  import { emit } from '../event-emitter';
15
20
  import { withoutDraws } from '../prng';
@@ -272,30 +277,12 @@ export function saveWithHooks<T>(
272
277
 
273
278
  // --- Save CRUD ---
274
279
 
275
- /**
276
- * `payload` with `convert` applied to its live variables and to those of
277
- * every history moment.
278
- */
279
- function mapVariables(
280
- payload: SavePayload,
281
- convert: (variables: Record<string, unknown>) => Record<string, unknown>,
282
- ): SavePayload {
283
- return {
284
- ...payload,
285
- variables: convert(payload.variables),
286
- history: payload.history.map((m) => ({
287
- ...m,
288
- variables: convert(m.variables),
289
- })),
290
- };
291
- }
292
-
293
- /** The record to store for a save: `payload` serialized, and its size. */
280
+ /** The record to store for a save: `payload` encoded, and its size. */
294
281
  function storedRecord(meta: SaveMeta, payload: SavePayload): SaveRecord {
295
- const serialized = mapVariables(deepClone(payload), serialize);
282
+ const encoded = encodePayload(payload);
296
283
  return {
297
- meta: { ...meta, estimatedBytes: estimatePayloadBytes(serialized) },
298
- payload: serialized,
284
+ meta: { ...meta, estimatedBytes: estimatePayloadBytes(encoded) },
285
+ payload: encoded,
299
286
  };
300
287
  }
301
288
 
@@ -372,17 +359,16 @@ async function overwriteSaveNow(
372
359
  }
373
360
 
374
361
  /**
375
- * Turn a stored (serialized) payload into a live one, restoring class
376
- * instances and built-ins. Returns a new payload; the stored one is left
377
- * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
362
+ * The live payload of a stored save, if there is one (see decodePayload:
363
+ * throws for a save of an incompatible format). Payloads handed to the
364
+ * store's `loadFromPayload()` must be live.
378
365
  */
379
- export function deserializePayload(payload: SavePayload): SavePayload {
380
- return mapVariables(payload, deserialize);
381
- }
382
-
383
- /** The live payload of a stored save, if there is one. */
384
366
  const livePayload = (record: SaveRecord | undefined) =>
385
- record && deserializePayload(record.payload);
367
+ record && decodePayload(record.payload);
368
+
369
+ /** The live payload of a stored record's payload (see decodePayload). */
370
+ export const decodeSavePayload = (payload: SaveRecord['payload']) =>
371
+ decodePayload(payload);
386
372
 
387
373
  export const loadSave = readingSave(livePayload);
388
374
 
@@ -650,7 +636,7 @@ export function loadSlotSave(
650
636
  return inOrder(async () => {
651
637
  const record = await slotRecord(ifid, slot);
652
638
  if (!record) return undefined;
653
- const payload = deserializePayload(record.payload);
639
+ const payload = decodePayload(record.payload);
654
640
  const { playthroughId } = record.meta;
655
641
  if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
656
642
  return { payload, playthroughId };
@@ -715,28 +701,35 @@ export const deleteSlotSave = queued(async (ifid: string, slot?: string) => {
715
701
  const SESSION_KEY_PREFIX = 'spindle.session.';
716
702
 
717
703
  /**
718
- * Write a pre-serialized session payload to sessionStorage.
719
- * Callers are responsible for serializing variables (see persistSession in store.ts).
704
+ * Write the session payload to sessionStorage, encoded like a save (see
705
+ * encodePayload). Throws, like a save, when the state holds a value a save
706
+ * cannot (a function, an instance of an unregistered class, a unique
707
+ * symbol); the session then keeps its previous copy.
720
708
  */
721
- export function saveSession(ifid: string, data: unknown): void {
709
+ export function saveSession(ifid: string, payload: SavePayload): void {
710
+ const text = JSON.stringify(encodePayload(payload));
722
711
  try {
723
- sessionStorage.setItem(
724
- `${SESSION_KEY_PREFIX}${ifid}`,
725
- JSON.stringify(data),
726
- );
712
+ sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
727
713
  } catch {
728
714
  // sessionStorage unavailable or full — silently ignore
729
715
  }
730
716
  }
731
717
 
718
+ /**
719
+ * The live payload of the stored session, if there is one that loads. A
720
+ * session written by an incompatible version of Spindle is dropped with a
721
+ * warning, and the story starts fresh.
722
+ */
732
723
  export function loadSession(ifid: string): SavePayload | undefined {
733
724
  try {
734
725
  const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ifid}`);
735
726
  if (!raw) return undefined;
736
- const parsed: unknown = JSON.parse(raw);
737
- if (!isSavePayload(parsed)) return undefined;
738
- return deserializePayload(parsed);
739
- } catch {
727
+ return decodePayload(JSON.parse(raw));
728
+ } catch (err) {
729
+ if (err instanceof IncompatibleSaveError) {
730
+ console.warn(`spindle: ${err.message}; the session was not restored.`);
731
+ clearSession(ifid);
732
+ }
740
733
  return undefined;
741
734
  }
742
735
  }
@@ -755,7 +748,7 @@ export function clearSession(ifid: string): void {
755
748
  function toExport(record: SaveRecord | undefined): SaveExport | undefined {
756
749
  if (!record) return undefined;
757
750
  return {
758
- version: 1,
751
+ formatVersion: SAVE_FORMAT_VERSION,
759
752
  ifid: record.meta.ifid,
760
753
  exportedAt: new Date().toISOString(),
761
754
  save: record,
@@ -769,23 +762,15 @@ export const exportSave = readingSave(toExport);
769
762
  * a fresh save ID, and the playthrough record created if it doesn't exist yet.
770
763
  * The returned record is not stored yet.
771
764
  */
772
- async function prepareImport(
773
- data: SaveExport,
774
- ifid: string,
775
- ): Promise<SaveRecord> {
776
- if (data.version !== 1) {
777
- throw new Error(`Unsupported save version: ${data.version}`);
778
- }
765
+ async function prepareImport(data: unknown, ifid: string): Promise<SaveRecord> {
766
+ // Callers may pass parsed JSON; check the format version and the full
767
+ // structure before anything is stored or replaced
768
+ checkSaveExport(data);
779
769
  if (data.ifid !== ifid) {
780
770
  throw new Error(
781
771
  `Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`,
782
772
  );
783
773
  }
784
- // Callers may pass parsed JSON; check the full structure before anything
785
- // is stored or replaced
786
- if (!isSaveExport(data)) {
787
- throw new Error('Invalid save file format');
788
- }
789
774
 
790
775
  // Re-assign a new ID to avoid collisions
791
776
  const record = deepClone(data.save);
@@ -804,10 +789,7 @@ async function prepareImport(
804
789
  return record;
805
790
  }
806
791
 
807
- export function importSave(
808
- data: SaveExport,
809
- ifid: string,
810
- ): Promise<SaveRecord> {
792
+ export function importSave(data: unknown, ifid: string): Promise<SaveRecord> {
811
793
  return inOrder(async () => {
812
794
  const record = await prepareImport(data, ifid);
813
795
  await (await getBackend()).putSave(record);
@@ -828,7 +810,7 @@ export const exportSlotSave = readingSlot(toExport);
828
810
  * and slot index consistent.
829
811
  */
830
812
  export function importSlotSave(
831
- data: SaveExport,
813
+ data: unknown,
832
814
  ifid: string,
833
815
  slot?: string,
834
816
  ): Promise<SaveInfo> {
@@ -1,5 +1,13 @@
1
1
  import type { PRNGSnapshot } from '../prng';
2
- import { isDeserializable } from '../class-registry';
2
+ import { hasOwn } from '../utils/namespace';
3
+ import {
4
+ checkFormatVersion,
5
+ decodePayload,
6
+ IncompatibleSaveError,
7
+ type EncodedPayload,
8
+ } from './format';
9
+
10
+ export type { EncodedPayload } from './format';
3
11
 
4
12
  /** History moment as persisted in saves (full variable snapshots). */
5
13
  export interface SaveHistoryMoment {
@@ -38,7 +46,8 @@ export interface SaveMeta {
38
46
 
39
47
  export interface SaveRecord {
40
48
  meta: SaveMeta;
41
- payload: SavePayload;
49
+ /** The payload as stored, with its format version (see format.ts). */
50
+ payload: EncodedPayload;
42
51
  }
43
52
 
44
53
  export interface PlaythroughRecord {
@@ -59,111 +68,85 @@ export interface SaveInfo {
59
68
  }
60
69
 
61
70
  export interface SaveExport {
62
- version: 1;
71
+ /** The save format version of the export (see SAVE_FORMAT_VERSION). */
72
+ formatVersion: number;
63
73
  ifid: string;
64
74
  exportedAt: string;
65
75
  save: SaveRecord;
66
76
  }
67
77
 
68
- export function isSaveExport(value: unknown): value is SaveExport {
69
- if (typeof value !== 'object' || value === null) return false;
70
- const obj = value as Record<string, unknown>;
71
- if (obj.version !== 1 || typeof obj.ifid !== 'string') return false;
72
- if (typeof obj.save !== 'object' || obj.save === null) return false;
73
-
74
- const save = obj.save as Record<string, unknown>;
75
- if (typeof save.meta !== 'object' || save.meta === null) return false;
76
- if (typeof save.payload !== 'object' || save.payload === null) return false;
77
-
78
- const meta = save.meta as Record<string, unknown>;
79
- if (typeof meta.id !== 'string' || typeof meta.passage !== 'string')
80
- return false;
81
- if (typeof meta.ifid !== 'string') return false;
82
- if (typeof meta.playthroughId !== 'string') return false;
83
- if (typeof meta.createdAt !== 'string') return false;
84
- if (typeof meta.updatedAt !== 'string') return false;
85
- if (typeof meta.title !== 'string') return false;
86
-
87
- return isImportablePayload(save.payload);
88
- }
78
+ const INVALID_FILE = 'Invalid save file format';
89
79
 
90
80
  function isRecord(value: unknown): value is Record<string, unknown> {
91
81
  return typeof value === 'object' && value !== null && !Array.isArray(value);
92
82
  }
93
83
 
94
- /** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
95
- function isOptionalPRNGSnapshot(value: unknown): boolean {
96
- if (value == null) return true;
97
- return (
98
- isRecord(value) &&
99
- typeof value.seed === 'string' &&
100
- typeof value.pull === 'number' &&
101
- Number.isInteger(value.pull) &&
102
- value.pull >= 0
103
- );
104
- }
105
-
106
- /** A variable container whose values deserialize() can restore. */
107
- function isSerializedVariables(value: unknown): boolean {
108
- return isRecord(value) && isDeserializable(value);
109
- }
110
-
111
- function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
112
- return (
113
- isRecord(value) &&
114
- typeof value.passage === 'string' &&
115
- isSerializedVariables(value.variables) &&
116
- typeof value.timestamp === 'number' &&
117
- isOptionalPRNGSnapshot(value.prng)
118
- );
84
+ const META_STRINGS = [
85
+ 'id',
86
+ 'passage',
87
+ 'ifid',
88
+ 'playthroughId',
89
+ 'createdAt',
90
+ 'updatedAt',
91
+ 'title',
92
+ ] as const;
93
+
94
+ /** decodePayload(), without the warnings for unregistered classes. */
95
+ function decodeQuietly(encoded: unknown): void {
96
+ const warn = console.warn;
97
+ console.warn = () => {};
98
+ try {
99
+ decodePayload(encoded);
100
+ } finally {
101
+ console.warn = warn;
102
+ }
119
103
  }
120
104
 
121
105
  /**
122
- * Full check of a payload from outside the running story (an imported save):
123
- * every history moment is well formed, `historyIndex` is an integer that
124
- * points at a moment of the payload's passage, and the encoded values (Map,
125
- * Set, Date, RegExp, class instances) in the live variables and every
126
- * moment's variables have the shape deserialize() expects. Anything that
127
- * passes can be stored and later loaded.
106
+ * Check data from outside the running story (an imported save) in full:
107
+ * the export's and the payload's format versions, the record's metadata,
108
+ * and that the payload decodes (see decodePayload). Throws an
109
+ * IncompatibleSaveError for a missing or unknown format version, and an
110
+ * "Invalid save file format" error for anything else that is wrong.
111
+ * Anything that passes can be stored and later loaded.
128
112
  */
129
- function isImportablePayload(value: unknown): value is SavePayload {
130
- if (!isRecord(value)) return false;
131
- if (typeof value.passage !== 'string') return false;
132
- if (!isSerializedVariables(value.variables)) return false;
133
-
134
- const { history, historyIndex } = value;
135
- if (!Array.isArray(history) || history.length === 0) return false;
136
- // Array.from: every() skips the holes of a sparse array
137
- if (!Array.from(history).every(isSaveHistoryMoment)) return false;
138
- if (
139
- typeof historyIndex !== 'number' ||
140
- !Number.isInteger(historyIndex) ||
141
- historyIndex < 0 ||
142
- historyIndex >= history.length
143
- ) {
144
- return false;
113
+ export function checkSaveExport(value: unknown): asserts value is SaveExport {
114
+ if (!isRecord(value)) throw new Error(INVALID_FILE);
115
+ // An export from before format versions has `version: 1`: incompatible.
116
+ // Data that is no export at all is invalid.
117
+ if (!hasOwn(value, 'formatVersion')) {
118
+ throw value.version === 1
119
+ ? new IncompatibleSaveError()
120
+ : new Error(INVALID_FILE);
121
+ }
122
+ checkFormatVersion(value.formatVersion);
123
+ if (typeof value.ifid !== 'string') throw new Error(INVALID_FILE);
124
+ const save = value.save;
125
+ if (!isRecord(save) || !isRecord(save.meta)) throw new Error(INVALID_FILE);
126
+ const meta = save.meta;
127
+ if (META_STRINGS.some((key) => typeof meta[key] !== 'string')) {
128
+ throw new Error(INVALID_FILE);
129
+ }
130
+ try {
131
+ decodeQuietly(save.payload);
132
+ } catch (err) {
133
+ if (err instanceof IncompatibleSaveError) throw err;
134
+ throw new Error(INVALID_FILE);
145
135
  }
146
- if (history[historyIndex]!.passage !== value.passage) return false;
147
-
148
- if (value.visitCounts !== undefined && !isRecord(value.visitCounts))
149
- return false;
150
- if (value.renderCounts !== undefined && !isRecord(value.renderCounts))
151
- return false;
152
- return isOptionalPRNGSnapshot(value.prng);
153
136
  }
154
137
 
155
- export function isSavePayload(value: unknown): value is SavePayload {
156
- if (typeof value !== 'object' || value === null) return false;
157
- const obj = value as Record<string, unknown>;
158
- if (typeof obj.passage !== 'string') return false;
159
- if (typeof obj.variables !== 'object' || obj.variables === null) return false;
160
- if (!Array.isArray(obj.history) || obj.history.length === 0) return false;
161
- if (typeof obj.historyIndex !== 'number') return false;
162
- return true;
138
+ /** Whether checkSaveExport() accepts `value`. */
139
+ export function isSaveExport(value: unknown): value is SaveExport {
140
+ try {
141
+ checkSaveExport(value);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
163
146
  }
164
147
 
165
- /** Estimated byte size of a serialized save payload. */
166
- export function estimatePayloadBytes(payload: SavePayload): number {
148
+ /** Estimated byte size of a stored payload. */
149
+ export function estimatePayloadBytes(payload: EncodedPayload): number {
167
150
  return JSON.stringify(payload).length;
168
151
  }
169
152