@rohal12/spindle 0.50.0 → 0.51.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.
@@ -124,6 +124,43 @@ const INLINE_ELEMENTS = new Set([
124
124
  'wbr',
125
125
  ]);
126
126
 
127
+ /**
128
+ * HTML boolean attributes. Their presence means "on", but a parsed bare or
129
+ * `=""` attribute has the value '', which Preact would assign to the DOM
130
+ * property as a falsy value — so present ones are passed as `true` (#177).
131
+ */
132
+ const BOOLEAN_ATTRIBUTES = new Set([
133
+ 'allowfullscreen',
134
+ 'async',
135
+ 'autofocus',
136
+ 'autoplay',
137
+ 'checked',
138
+ 'controls',
139
+ 'default',
140
+ 'defer',
141
+ 'disabled',
142
+ 'formnovalidate',
143
+ 'hidden',
144
+ 'inert',
145
+ 'ismap',
146
+ 'itemscope',
147
+ 'loop',
148
+ 'multiple',
149
+ 'muted',
150
+ 'nomodule',
151
+ 'novalidate',
152
+ 'open',
153
+ 'playsinline',
154
+ 'readonly',
155
+ 'required',
156
+ 'reversed',
157
+ 'selected',
158
+ ]);
159
+
160
+ function isPresentBooleanAttribute(name: string, value: string): boolean {
161
+ return value === '' && BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
162
+ }
163
+
127
164
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
128
165
  const resolve = useInterpolate();
129
166
  const nobr = useContext(NobrContext);
@@ -131,7 +168,7 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
131
168
  const inSvg = useContext(SvgContext);
132
169
  const attrs: Record<string, unknown> = {};
133
170
  for (const [k, v] of Object.entries(node.attributes)) {
134
- attrs[k] = resolve(v) ?? v;
171
+ attrs[k] = isPresentBooleanAttribute(k, v) ? true : (resolve(v) ?? v);
135
172
  }
136
173
  const isSvgRoot = node.tag.toLowerCase() === 'svg';
137
174
  const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
@@ -161,7 +198,26 @@ function ChildrenSlot() {
161
198
  return <>{renderNodes(childrenAST, { nobr, locals })}</>;
162
199
  }
163
200
 
164
- function renderMacro(node: MacroNode, key: number) {
201
+ /**
202
+ * Stable per-node keys. Keying rendered children by AST node identity (not
203
+ * by position) makes Preact remount components when different content
204
+ * occupies the same slot — e.g. when an {if}/{switch} branch or an included
205
+ * passage changes — so mount-only macros like {set} and {do} run for the new
206
+ * content, while re-rendering the same AST keeps existing instances (#175).
207
+ */
208
+ const nodeKeys = new WeakMap<ASTNode, string>();
209
+ let nextNodeKey = 0;
210
+
211
+ function nodeKey(node: ASTNode): string {
212
+ let key = nodeKeys.get(node);
213
+ if (key === undefined) {
214
+ key = `n${nextNodeKey++}`;
215
+ nodeKeys.set(node, key);
216
+ }
217
+ return key;
218
+ }
219
+
220
+ function renderMacro(node: MacroNode, key: string) {
165
221
  if (isSubMacro(node.name)) return null;
166
222
 
167
223
  const widget = getWidget(node.name);
@@ -204,14 +260,10 @@ function renderMacro(node: MacroNode, key: number) {
204
260
  /**
205
261
  * Render a non-text AST node to a Preact element.
206
262
  */
207
- function renderSingleNode(
208
- node: ASTNode,
209
- key: number,
210
- ): preact.ComponentChildren {
263
+ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
264
+ if (node.type === 'text') return node.value;
265
+ const key = nodeKey(node);
211
266
  switch (node.type) {
212
- case 'text':
213
- return node.value;
214
-
215
267
  case 'variable':
216
268
  if (node.scope === 'local' && node.name === 'children') {
217
269
  return <ChildrenSlot key={key} />;
@@ -261,7 +313,7 @@ function renderSingleNode(
261
313
  */
262
314
  export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
263
315
  if (nodes.length === 0) return null;
264
- return nodes.map((node, i) => renderSingleNode(node, i));
316
+ return nodes.map((node) => renderSingleNode(node));
265
317
  }
266
318
 
267
319
  function hasUnclosedBacktick(s: string): boolean {
@@ -298,8 +350,11 @@ function getVariableTextValue(
298
350
  * Characters/patterns that trigger CommonMark or GFM transformations.
299
351
  * Any match → fall through to the full micromark pipeline.
300
352
  * False positives (e.g. `-` used as text, not list) just use the slower path.
353
+ * Includes character references (`&amp;`, `&#123;`) and two-space hard line
354
+ * breaks, which micromark decodes / turns into <br> (#171).
301
355
  */
302
- const MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\./;
356
+ const MARKDOWN_SYNTAX_RE =
357
+ /[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
303
358
  const BLANK_LINE_RE = /\n\s*\n/;
304
359
  const PLACEHOLDER_SPLIT_RE = /(<span data-tw="\d+"><\/span>)/;
305
360
  const PLACEHOLDER_IDX_RE = /^<span data-tw="(\d+)"><\/span>$/;
@@ -358,7 +413,7 @@ export function renderNodes(
358
413
  n.type === 'text' && (n.value.trim() !== '' || /\n\s*\n/.test(n.value)),
359
414
  );
360
415
  if (!needsMarkdown) {
361
- return nodes.map((node, i) => renderSingleNode(node, i));
416
+ return nodes.map((node) => renderSingleNode(node));
362
417
  }
363
418
 
364
419
  // Build combined markdown string with placeholders for non-text nodes
@@ -375,7 +430,7 @@ export function renderNodes(
375
430
  combined += getVariableTextValue(node, locals);
376
431
  } else {
377
432
  const phIdx = components.length;
378
- components.push(renderSingleNode(node, i));
433
+ components.push(renderSingleNode(node));
379
434
  combined += `<span data-tw="${phIdx}"></span>`;
380
435
  }
381
436
  }
@@ -66,7 +66,26 @@ export type Token =
66
66
  /** Tag name must start with a letter (covers standard and custom elements). */
67
67
  const VALID_TAG_START = /[a-zA-Z]/;
68
68
 
69
- const HTML_VOID_TAGS = new Set(['br', 'col', 'hr', 'img', 'wbr']);
69
+ /** HTML void elements: never have children or a closing tag. */
70
+ const HTML_VOID_TAGS = new Set([
71
+ 'area',
72
+ 'base',
73
+ 'br',
74
+ 'col',
75
+ 'embed',
76
+ 'hr',
77
+ 'img',
78
+ 'input',
79
+ 'link',
80
+ 'meta',
81
+ 'param',
82
+ 'source',
83
+ 'track',
84
+ 'wbr',
85
+ ]);
86
+
87
+ /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
88
+ const RAW_BODY_MACROS = new Set(['do']);
70
89
 
71
90
  /**
72
91
  * Parse a Twine link interior into display and target.
@@ -219,11 +238,15 @@ function parseHtmlAttributes(
219
238
  const quote = input[j]!;
220
239
  j++; // skip opening quote
221
240
  const valStart = j;
222
- let braceDepth = 0;
223
241
  while (j < input.length) {
224
- if (input[j] === '{') braceDepth++;
225
- else if (input[j] === '}') braceDepth--;
226
- else if (input[j] === quote && braceDepth <= 0) break;
242
+ if (input[j] === '{') {
243
+ // Skip a whole {…} interpolation so quotes inside it don't end the value
244
+ const closeIdx = scanBalancedBrace(input, j + 1);
245
+ if (closeIdx !== -1) {
246
+ j = closeIdx + 1;
247
+ continue;
248
+ }
249
+ } else if (input[j] === quote) break;
227
250
  j++;
228
251
  }
229
252
  attributes[attrName] = input.slice(valStart, j);
@@ -243,18 +266,86 @@ function parseHtmlAttributes(
243
266
  return { attributes, endIdx: j };
244
267
  }
245
268
 
269
+ /**
270
+ * Skip a '…' or "…" string literal opening at i.
271
+ * Returns the index just past the closing quote, or -1 if the string is
272
+ * not closed on the same line (JS strings can't span lines unescaped).
273
+ */
274
+ function skipQuoted(input: string, i: number): number {
275
+ const quote = input[i];
276
+ let j = i + 1;
277
+ while (j < input.length) {
278
+ const c = input[j];
279
+ if (c === '\\') j += 2;
280
+ else if (c === quote) return j + 1;
281
+ else if (c === '\n') return -1;
282
+ else j++;
283
+ }
284
+ return -1;
285
+ }
286
+
287
+ /**
288
+ * Skip a `…` template literal opening at i, including ${…} parts.
289
+ * Returns the index just past the closing backtick, or -1 if unclosed.
290
+ */
291
+ function skipTemplate(input: string, i: number): number {
292
+ let j = i + 1;
293
+ while (j < input.length) {
294
+ const c = input[j];
295
+ if (c === '\\') {
296
+ j += 2;
297
+ } else if (c === '`') {
298
+ return j + 1;
299
+ } else if (c === '$' && input[j + 1] === '{') {
300
+ const closeIdx = scanBalancedBrace(input, j + 2);
301
+ if (closeIdx === -1) return -1;
302
+ j = closeIdx + 1;
303
+ } else {
304
+ j++;
305
+ }
306
+ }
307
+ return -1;
308
+ }
309
+
310
+ /**
311
+ * A quote directly after a letter/digit is an apostrophe (don't), not a
312
+ * string; after a backslash it is an escaped attribute delimiter (\").
313
+ */
314
+ const NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
315
+
246
316
  /**
247
317
  * Scan for the balanced closing } starting at position i (just past the {).
318
+ * Braces inside string and template literals are ignored. A quote that
319
+ * can't start a string (apostrophe, escaped, unterminated) counts as text.
248
320
  * Returns the index of the closing } or -1 if unbalanced.
249
321
  */
250
- function scanBalancedBrace(input: string, i: number): number {
322
+ export function scanBalancedBrace(input: string, i: number): number {
251
323
  let depth = 1;
252
- while (i < input.length && depth > 0) {
253
- if (input[i] === '{') depth++;
254
- else if (input[i] === '}') depth--;
255
- if (depth > 0) i++;
324
+ while (i < input.length) {
325
+ const c = input[i]!;
326
+ if (c === '{') {
327
+ depth++;
328
+ } else if (c === '}') {
329
+ if (--depth === 0) return i;
330
+ } else if (
331
+ (c === '"' || c === "'") &&
332
+ !(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]!))
333
+ ) {
334
+ const end = skipQuoted(input, i);
335
+ if (end !== -1) {
336
+ i = end;
337
+ continue;
338
+ }
339
+ } else if (c === '`') {
340
+ const end = skipTemplate(input, i);
341
+ if (end !== -1) {
342
+ i = end;
343
+ continue;
344
+ }
345
+ }
346
+ i++;
256
347
  }
257
- return depth === 0 ? i : -1;
348
+ return -1;
258
349
  }
259
350
 
260
351
  /**
@@ -277,6 +368,32 @@ export function tokenize(input: string): Token[] {
277
368
  }
278
369
  }
279
370
 
371
+ /**
372
+ * After an opening raw-body macro ({do}), emit everything up to the
373
+ * first {/name} as a single text token so JavaScript source is not
374
+ * parsed as markup. A literal "{/do}" inside the code would end it early.
375
+ * Without a closer, nothing is consumed and the AST builder reports it.
376
+ */
377
+ function consumeRawBody(name: string, isClose: boolean) {
378
+ const lower = name.toLowerCase();
379
+ if (isClose || !RAW_BODY_MACROS.has(lower)) return;
380
+ const closeRe = new RegExp(`\\{/${lower}\\s*\\}`, 'gi');
381
+ closeRe.lastIndex = i;
382
+ const m = closeRe.exec(input);
383
+ if (!m) return;
384
+ const closeStart = m.index;
385
+ const closeEnd = closeStart + m[0].length;
386
+ flushText(closeStart);
387
+ tokens.push({
388
+ type: 'macro',
389
+ ...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
390
+ start: closeStart,
391
+ end: closeEnd,
392
+ });
393
+ i = closeEnd;
394
+ textStart = closeEnd;
395
+ }
396
+
280
397
  while (i < input.length) {
281
398
  // Handle escaped braces: \{ and \}
282
399
  if (input[i] === '\\' && (input[i + 1] === '{' || input[i + 1] === '}')) {
@@ -546,25 +663,18 @@ export function tokenize(input: string): Token[] {
546
663
 
547
664
  if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
548
665
  // {.class#id macroName args}
549
- i = afterSelectors;
550
-
551
- // Scan to closing }, tracking brace nesting
552
- let depth = 1;
553
- const contentStart = i;
554
- while (i < input.length && depth > 0) {
555
- if (input[i] === '{') depth++;
556
- else if (input[i] === '}') depth--;
557
- if (depth > 0) i++;
558
- }
666
+ // Scan to closing }, tracking brace nesting and string literals
667
+ const contentStart = afterSelectors;
668
+ const closeIdx = scanBalancedBrace(input, contentStart);
559
669
 
560
- if (depth !== 0) {
670
+ if (closeIdx === -1) {
561
671
  i = start + 1;
562
672
  textStart = start;
563
673
  continue;
564
674
  }
565
675
 
566
- const content = input.slice(contentStart, i);
567
- i++; // skip closing }
676
+ const content = input.slice(contentStart, closeIdx);
677
+ i = closeIdx + 1; // skip closing }
568
678
 
569
679
  const { name, rawArgs, isClose } = parseMacroContent(content);
570
680
  const token: MacroToken = {
@@ -579,6 +689,7 @@ export function tokenize(input: string): Token[] {
579
689
  if (id) token.id = id;
580
690
  tokens.push(token);
581
691
  textStart = i;
692
+ consumeRawBody(name, isClose);
582
693
  continue;
583
694
  }
584
695
 
@@ -755,26 +866,21 @@ export function tokenize(input: string): Token[] {
755
866
  (nextChar === '/' || /[a-zA-Z]/.test(nextChar))
756
867
  ) {
757
868
  flushText(i);
758
- i++; // skip {
759
-
760
- // Scan to closing }, tracking brace nesting for object literals
761
- let depth = 1;
762
- const contentStart = i;
763
- while (i < input.length && depth > 0) {
764
- if (input[i] === '{') depth++;
765
- else if (input[i] === '}') depth--;
766
- if (depth > 0) i++;
767
- }
768
869
 
769
- if (depth !== 0) {
870
+ // Scan to closing }, tracking brace nesting (object literals)
871
+ // and string literals
872
+ const contentStart = i + 1;
873
+ const closeIdx = scanBalancedBrace(input, contentStart);
874
+
875
+ if (closeIdx === -1) {
770
876
  // Unclosed macro — treat as text
771
877
  i = start + 1;
772
878
  textStart = start;
773
879
  continue;
774
880
  }
775
881
 
776
- const content = input.slice(contentStart, i);
777
- i++; // skip closing }
882
+ const content = input.slice(contentStart, closeIdx);
883
+ i = closeIdx + 1; // skip closing }
778
884
 
779
885
  const { name, rawArgs, isClose } = parseMacroContent(content);
780
886
  tokens.push({
@@ -786,6 +892,7 @@ export function tokenize(input: string): Token[] {
786
892
  end: i,
787
893
  });
788
894
  textStart = i;
895
+ consumeRawBody(name, isClose);
789
896
  continue;
790
897
  }
791
898
 
@@ -817,6 +924,12 @@ export function tokenize(input: string): Token[] {
817
924
  if (input[j] === '>') {
818
925
  j++;
819
926
  flushText(start);
927
+ if (HTML_VOID_TAGS.has(tagLower)) {
928
+ // Void elements never take a closer; drop a redundant </input>
929
+ textStart = j;
930
+ i = j;
931
+ continue;
932
+ }
820
933
  tokens.push({
821
934
  type: 'html',
822
935
  tag,
@@ -10,6 +10,7 @@ import type {
10
10
  import { isSavePayload } from './types';
11
11
  import { getBackend, resetBackend } from './storage';
12
12
  import { deepClone, serialize, deserialize } from '../class-registry';
13
+ import { emit } from '../event-emitter';
13
14
 
14
15
  type TitleGenerator = (payload: SavePayload) => string;
15
16
 
@@ -94,6 +95,28 @@ export async function getCurrentPlaythroughId(
94
95
  return (await getBackend()).getMeta<string>(`currentPlaythroughId.${ifid}`);
95
96
  }
96
97
 
98
+ // --- Save Hooks ---
99
+
100
+ /**
101
+ * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
102
+ * before `getPayload()` captures the state, so data a hook sets is part of the
103
+ * save; `aftersave` fires once `write` has stored it. Every user-facing save
104
+ * path goes through here.
105
+ */
106
+ export function saveWithHooks<T>(
107
+ slot: string | undefined,
108
+ custom: Record<string, unknown> | undefined,
109
+ getPayload: () => SavePayload,
110
+ write: (payload: SavePayload) => Promise<T>,
111
+ ): 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
+ });
118
+ }
119
+
97
120
  // --- Save CRUD ---
98
121
 
99
122
  export async function createSave(
@@ -157,18 +180,28 @@ export async function overwriteSave(
157
180
  return updated;
158
181
  }
159
182
 
183
+ /**
184
+ * Turn a stored (serialized) payload into a live one, restoring class
185
+ * instances and built-ins. Returns a new payload; the stored one is left
186
+ * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
187
+ */
188
+ export function deserializePayload(payload: SavePayload): SavePayload {
189
+ return {
190
+ ...payload,
191
+ variables: deserialize(payload.variables),
192
+ history: payload.history.map((m) => ({
193
+ ...m,
194
+ variables: deserialize(m.variables),
195
+ })),
196
+ };
197
+ }
198
+
160
199
  export async function loadSave(
161
200
  saveId: string,
162
201
  ): Promise<SavePayload | undefined> {
163
202
  const record = await (await getBackend()).getSave(saveId);
164
203
  if (!record) return undefined;
165
- const payload = record.payload;
166
- payload.variables = deserialize(payload.variables);
167
- payload.history = payload.history.map((m) => ({
168
- ...m,
169
- variables: deserialize(m.variables),
170
- }));
171
- return payload;
204
+ return deserializePayload(record.payload);
172
205
  }
173
206
 
174
207
  export async function deleteSaveById(saveId: string): Promise<void> {
@@ -267,26 +300,66 @@ const AUTOSAVE_KEY_PREFIX = 'autosave.';
267
300
  const SLOT_KEY_PREFIX = 'slot.';
268
301
  const SLOT_INDEX_KEY_PREFIX = 'slotIndex.';
269
302
 
303
+ /**
304
+ * Whether `slot` names a slot. The default (autosave) slot is addressed by
305
+ * omitting `slot` or by `''`, the identifier its SaveInfo reports.
306
+ */
307
+ function isNamedSlot(slot: string | undefined): slot is string {
308
+ return slot != null && slot !== '';
309
+ }
310
+
270
311
  function slotMetaKey(ifid: string, slot?: string): string {
271
- return slot != null
312
+ return isNamedSlot(slot)
272
313
  ? `${SLOT_KEY_PREFIX}${slot}.${ifid}`
273
314
  : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
274
315
  }
275
316
 
317
+ /** Tail of the queue that serializes slot index updates. */
318
+ let slotIndexQueue: Promise<unknown> = Promise.resolve();
319
+
320
+ /**
321
+ * Read-modify-write the per-story slot index. Updates are queued so that
322
+ * concurrent saves, imports and deletes can't overwrite each other's change.
323
+ */
324
+ function updateSlotIndex(
325
+ ifid: string,
326
+ update: (slots: string[]) => string[],
327
+ ): Promise<void> {
328
+ const run = slotIndexQueue.then(async () => {
329
+ const backend = await getBackend();
330
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
331
+ const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
332
+ const updated = update(existing);
333
+ if (updated !== existing) await backend.setMeta(indexKey, updated);
334
+ });
335
+ slotIndexQueue = run.catch(() => {});
336
+ return run;
337
+ }
338
+
276
339
  /** Record a named slot in the per-story slot index (no-op for the default slot). */
277
- async function addToSlotIndex(ifid: string, slot?: string): Promise<void> {
278
- if (slot == null) return;
279
- const backend = await getBackend();
340
+ function addToSlotIndex(ifid: string, slot?: string): Promise<void> {
341
+ if (!isNamedSlot(slot)) return Promise.resolve();
342
+ return updateSlotIndex(ifid, (slots) =>
343
+ slots.includes(slot) ? slots : [...slots, slot],
344
+ );
345
+ }
346
+
347
+ /** Remove a named slot from the per-story slot index (no-op for the default slot). */
348
+ function removeFromSlotIndex(ifid: string, slot?: string): Promise<void> {
349
+ if (!isNamedSlot(slot)) return Promise.resolve();
350
+ return updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
351
+ }
352
+
353
+ /** Named slots in the per-story slot index. */
354
+ async function getIndexedSlots(ifid: string): Promise<string[]> {
280
355
  const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
281
- const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
282
- if (!existing.includes(slot)) {
283
- await backend.setMeta(indexKey, [...existing, slot]);
284
- }
356
+ const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
357
+ return slots.filter(isNamedSlot);
285
358
  }
286
359
 
287
360
  function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
288
361
  return {
289
- slot: slot ?? '',
362
+ slot: isNamedSlot(slot) ? slot : '',
290
363
  title: record.meta.title,
291
364
  passage: record.meta.passage,
292
365
  createdAt: record.meta.createdAt,
@@ -313,8 +386,8 @@ export async function quickSave(
313
386
 
314
387
  // Create new save
315
388
  const record = await createSave(ifid, playthroughId, payload, {
316
- isAutosave: !slot,
317
- ...(slot != null ? { slot } : {}),
389
+ isAutosave: !isNamedSlot(slot),
390
+ ...(isNamedSlot(slot) ? { slot } : {}),
318
391
  ...custom,
319
392
  });
320
393
  await backend.setMeta(metaKey, record.meta.id);
@@ -360,9 +433,7 @@ export async function populateKnownSaves(
360
433
  }
361
434
 
362
435
  // Check named slots from the index
363
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
364
- const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
365
- for (const slot of slots) {
436
+ for (const slot of await getIndexedSlots(ifid)) {
366
437
  if (await hasQuickSave(ifid, slot)) {
367
438
  result[slot] = true;
368
439
  }
@@ -399,9 +470,7 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
399
470
  if (defaultInfo) result.push(defaultInfo);
400
471
 
401
472
  // Check named slots from the index
402
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
403
- const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
404
- for (const slot of slots) {
473
+ for (const slot of await getIndexedSlots(ifid)) {
405
474
  const info = await getSlotSaveInfo(ifid, slot);
406
475
  if (info) result.push(info);
407
476
  }
@@ -425,12 +494,7 @@ export async function deleteSlotSave(
425
494
  await backend.deleteMeta(metaKey);
426
495
 
427
496
  // Remove from slot index if named
428
- if (slot != null) {
429
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
430
- const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
431
- const updated = existing.filter((s) => s !== slot);
432
- await backend.setMeta(indexKey, updated);
433
- }
497
+ await removeFromSlotIndex(ifid, slot);
434
498
  }
435
499
 
436
500
  // --- Session Persistence (survives F5, cleared on tab close) ---
@@ -458,13 +522,7 @@ export function loadSession(ifid: string): SavePayload | undefined {
458
522
  if (!raw) return undefined;
459
523
  const parsed: unknown = JSON.parse(raw);
460
524
  if (!isSavePayload(parsed)) return undefined;
461
- const payload = parsed;
462
- payload.variables = deserialize(payload.variables);
463
- payload.history = payload.history.map((m) => ({
464
- ...m,
465
- variables: deserialize(m.variables),
466
- }));
467
- return payload;
525
+ return deserializePayload(parsed);
468
526
  } catch {
469
527
  return undefined;
470
528
  }
@@ -578,8 +636,8 @@ export async function importSlotSave(
578
636
  const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
579
637
  record.meta.custom = {
580
638
  ...custom,
581
- isAutosave: !slot,
582
- ...(slot != null ? { slot } : {}),
639
+ isAutosave: !isNamedSlot(slot),
640
+ ...(isNamedSlot(slot) ? { slot } : {}),
583
641
  };
584
642
 
585
643
  const backend = await getBackend();
package/src/settings.ts CHANGED
@@ -27,6 +27,7 @@ export type SettingDef =
27
27
  const definitions = new Map<string, SettingDef>();
28
28
  let values: Record<string, unknown> = {};
29
29
  let storageLoaded = false;
30
+ let unsubscribeStoryData: (() => void) | null = null;
30
31
 
31
32
  function storageKey(): string {
32
33
  const storyData = useStoryStore.getState().storyData;
@@ -40,6 +41,20 @@ function persist(): void {
40
41
 
41
42
  function loadFromStorage(): void {
42
43
  if (storageLoaded) return;
44
+ // Settings registered from Story JavaScript run before store.init(), when
45
+ // the IFID (and thus the storage key) is not known yet. Defer the load
46
+ // until story data arrives instead of reading `spindle.unknown.settings`.
47
+ if (!useStoryStore.getState().storyData?.ifid) {
48
+ if (!unsubscribeStoryData) {
49
+ unsubscribeStoryData = useStoryStore.subscribe((state) => {
50
+ if (!state.storyData?.ifid) return;
51
+ unsubscribeStoryData?.();
52
+ unsubscribeStoryData = null;
53
+ loadFromStorage();
54
+ });
55
+ }
56
+ return;
57
+ }
43
58
  storageLoaded = true;
44
59
  try {
45
60
  const raw = localStorage.getItem(storageKey());