@rohal12/spindle 0.35.0 → 0.36.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -53,68 +53,8 @@ export type Token =
53
53
  | VariableToken
54
54
  | HtmlToken;
55
55
 
56
- const HTML_TAGS = new Set([
57
- 'a',
58
- 'article',
59
- 'aside',
60
- 'b',
61
- 'blockquote',
62
- 'br',
63
- 'caption',
64
- 'code',
65
- 'col',
66
- 'colgroup',
67
- 'dd',
68
- 'del',
69
- 'details',
70
- 'dfn',
71
- 'div',
72
- 'dl',
73
- 'dt',
74
- 'em',
75
- 'figcaption',
76
- 'figure',
77
- 'footer',
78
- 'h1',
79
- 'h2',
80
- 'h3',
81
- 'h4',
82
- 'h5',
83
- 'h6',
84
- 'header',
85
- 'hr',
86
- 'i',
87
- 'img',
88
- 'ins',
89
- 'kbd',
90
- 'li',
91
- 'main',
92
- 'mark',
93
- 'nav',
94
- 'ol',
95
- 'p',
96
- 'pre',
97
- 'q',
98
- 's',
99
- 'samp',
100
- 'section',
101
- 'small',
102
- 'span',
103
- 'strong',
104
- 'sub',
105
- 'summary',
106
- 'sup',
107
- 'table',
108
- 'tbody',
109
- 'td',
110
- 'tfoot',
111
- 'th',
112
- 'thead',
113
- 'tr',
114
- 'u',
115
- 'ul',
116
- 'wbr',
117
- ]);
56
+ /** Tag name must start with a letter (covers standard and custom elements). */
57
+ const VALID_TAG_START = /[a-zA-Z]/;
118
58
 
119
59
  const HTML_VOID_TAGS = new Set(['br', 'col', 'hr', 'img', 'wbr']);
120
60
 
@@ -661,13 +601,26 @@ export function tokenize(input: string): Token[] {
661
601
  const isClose = input[j] === '/';
662
602
  if (isClose) j++;
663
603
 
664
- // Read tag name
604
+ // Read tag name (letters, digits, hyphens for custom elements)
665
605
  const tagStart = j;
666
- while (j < input.length && /[a-zA-Z0-9]/.test(input[j]!)) j++;
606
+ while (j < input.length && /[a-zA-Z0-9-]/.test(input[j]!)) j++;
667
607
  const tag = input.slice(tagStart, j).toLowerCase();
668
608
 
669
- // Only handle known HTML tags
670
- if (tag && HTML_TAGS.has(tag)) {
609
+ // Valid tag name must start with a letter
610
+ if (tag && VALID_TAG_START.test(tag[0]!)) {
611
+ // SVG is its own world — treat <svg>…</svg> as opaque text
612
+ // so the markdown pipeline handles it with proper namespace.
613
+ if (tag === 'svg' && !isClose) {
614
+ const closeTag = '</svg>';
615
+ const closeIdx = input.indexOf(closeTag, j);
616
+ if (closeIdx !== -1) {
617
+ const end = closeIdx + closeTag.length;
618
+ // Leave the entire <svg>…</svg> block as text
619
+ i = end;
620
+ continue;
621
+ }
622
+ }
623
+
671
624
  if (isClose) {
672
625
  // Closing tag: skip whitespace, expect >
673
626
  while (j < input.length && /\s/.test(input[j]!)) j++;
@@ -5,18 +5,10 @@ import type {
5
5
  SaveInfo,
6
6
  PlaythroughRecord,
7
7
  SaveExport,
8
+ StorageInfo,
8
9
  } from './types';
9
10
  import { isSavePayload } from './types';
10
- import {
11
- putSave,
12
- getSave,
13
- deleteSave as idbDeleteSave,
14
- getSavesByIfid,
15
- putPlaythrough,
16
- getPlaythroughsByIfid,
17
- getMeta,
18
- setMeta,
19
- } from './idb';
11
+ import { getBackend, resetBackend } from './storage';
20
12
  import { deepClone, serialize, deserialize } from '../class-registry';
21
13
 
22
14
  type TitleGenerator = (payload: SavePayload) => string;
@@ -79,7 +71,8 @@ export async function initSaveSystem(): Promise<void> {
79
71
  // --- Playthroughs ---
80
72
 
81
73
  export async function startNewPlaythrough(ifid: string): Promise<string> {
82
- const existing = await getPlaythroughsByIfid(ifid);
74
+ const backend = await getBackend();
75
+ const existing = await backend.getPlaythroughsByIfid(ifid);
83
76
  const num = existing.length + 1;
84
77
 
85
78
  const id = crypto.randomUUID();
@@ -90,15 +83,15 @@ export async function startNewPlaythrough(ifid: string): Promise<string> {
90
83
  label: `Playthrough ${num}`,
91
84
  };
92
85
 
93
- await putPlaythrough(record);
94
- await setMeta(`currentPlaythroughId.${ifid}`, id);
86
+ await backend.putPlaythrough(record);
87
+ await backend.setMeta(`currentPlaythroughId.${ifid}`, id);
95
88
  return id;
96
89
  }
97
90
 
98
91
  export async function getCurrentPlaythroughId(
99
92
  ifid: string,
100
93
  ): Promise<string | undefined> {
101
- return getMeta<string>(`currentPlaythroughId.${ifid}`);
94
+ return (await getBackend()).getMeta<string>(`currentPlaythroughId.${ifid}`);
102
95
  }
103
96
 
104
97
  // --- Save CRUD ---
@@ -128,7 +121,8 @@ export async function createSave(
128
121
  variables: serialize(m.variables),
129
122
  }));
130
123
  const record: SaveRecord = { meta, payload: serializedPayload };
131
- await putSave(record);
124
+ record.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
125
+ await (await getBackend()).putSave(record);
132
126
  return record;
133
127
  }
134
128
 
@@ -137,7 +131,8 @@ export async function overwriteSave(
137
131
  payload: SavePayload,
138
132
  custom?: Record<string, unknown>,
139
133
  ): Promise<SaveRecord | undefined> {
140
- const existing = await getSave(saveId);
134
+ const backend = await getBackend();
135
+ const existing = await backend.getSave(saveId);
141
136
  if (!existing) return undefined;
142
137
 
143
138
  const serializedPayload = deepClone(payload);
@@ -157,14 +152,15 @@ export async function overwriteSave(
157
152
  },
158
153
  payload: serializedPayload,
159
154
  };
160
- await putSave(updated);
155
+ updated.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
156
+ await backend.putSave(updated);
161
157
  return updated;
162
158
  }
163
159
 
164
160
  export async function loadSave(
165
161
  saveId: string,
166
162
  ): Promise<SavePayload | undefined> {
167
- const record = await getSave(saveId);
163
+ const record = await (await getBackend()).getSave(saveId);
168
164
  if (!record) return undefined;
169
165
  const payload = record.payload;
170
166
  payload.variables = deserialize(payload.variables);
@@ -176,14 +172,15 @@ export async function loadSave(
176
172
  }
177
173
 
178
174
  export async function deleteSaveById(saveId: string): Promise<void> {
179
- await idbDeleteSave(saveId);
175
+ await (await getBackend()).deleteSave(saveId);
180
176
  }
181
177
 
182
178
  export async function renameSave(
183
179
  saveId: string,
184
180
  newTitle: string,
185
181
  ): Promise<void> {
186
- const record = await getSave(saveId);
182
+ const backend = await getBackend();
183
+ const record = await backend.getSave(saveId);
187
184
  if (!record) return;
188
185
  const updated: SaveRecord = {
189
186
  ...record,
@@ -193,7 +190,7 @@ export async function renameSave(
193
190
  updatedAt: new Date().toISOString(),
194
191
  },
195
192
  };
196
- await putSave(updated);
193
+ await backend.putSave(updated);
197
194
  }
198
195
 
199
196
  // --- Grouped Retrieval ---
@@ -206,9 +203,10 @@ export interface PlaythroughGroup {
206
203
  export async function getSavesGrouped(
207
204
  ifid: string,
208
205
  ): Promise<PlaythroughGroup[]> {
206
+ const backend = await getBackend();
209
207
  const [allSaves, allPlaythroughs] = await Promise.all([
210
- getSavesByIfid(ifid),
211
- getPlaythroughsByIfid(ifid),
208
+ backend.getSavesByIfid(ifid),
209
+ backend.getPlaythroughsByIfid(ifid),
212
210
  ]);
213
211
 
214
212
  const ptMap = new Map<string, PlaythroughRecord>();
@@ -282,8 +280,9 @@ export async function quickSave(
282
280
  slot?: string,
283
281
  custom?: Record<string, unknown>,
284
282
  ): Promise<SaveRecord> {
283
+ const backend = await getBackend();
285
284
  const metaKey = slotMetaKey(ifid, slot);
286
- const existingId = await getMeta<string>(metaKey);
285
+ const existingId = await backend.getMeta<string>(metaKey);
287
286
 
288
287
  if (existingId) {
289
288
  const updated = await overwriteSave(existingId, payload, custom);
@@ -296,14 +295,14 @@ export async function quickSave(
296
295
  ...(slot != null ? { slot } : {}),
297
296
  ...custom,
298
297
  });
299
- await setMeta(metaKey, record.meta.id);
298
+ await backend.setMeta(metaKey, record.meta.id);
300
299
 
301
300
  // Track named slots in the index
302
301
  if (slot != null) {
303
302
  const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
304
- const existing = (await getMeta<string[]>(indexKey)) ?? [];
303
+ const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
305
304
  if (!existing.includes(slot)) {
306
- await setMeta(indexKey, [...existing, slot]);
305
+ await backend.setMeta(indexKey, [...existing, slot]);
307
306
  }
308
307
  }
309
308
 
@@ -314,10 +313,11 @@ export async function hasQuickSave(
314
313
  ifid: string,
315
314
  slot?: string,
316
315
  ): Promise<boolean> {
316
+ const backend = await getBackend();
317
317
  const metaKey = slotMetaKey(ifid, slot);
318
- const existingId = await getMeta<string>(metaKey);
318
+ const existingId = await backend.getMeta<string>(metaKey);
319
319
  if (!existingId) return false;
320
- const record = await getSave(existingId);
320
+ const record = await backend.getSave(existingId);
321
321
  return record !== undefined;
322
322
  }
323
323
 
@@ -326,13 +326,13 @@ export async function loadQuickSave(
326
326
  slot?: string,
327
327
  ): Promise<SavePayload | undefined> {
328
328
  const metaKey = slotMetaKey(ifid, slot);
329
- const existingId = await getMeta<string>(metaKey);
329
+ const existingId = await (await getBackend()).getMeta<string>(metaKey);
330
330
  if (!existingId) return undefined;
331
331
  return loadSave(existingId);
332
332
  }
333
333
 
334
334
  /**
335
- * Check IDB for all known saves and return a map of slot keys to true.
335
+ * Check storage for all known saves and return a map of slot keys to true.
336
336
  * The default (autosave) slot uses empty string as key.
337
337
  */
338
338
  export async function populateKnownSaves(
@@ -347,7 +347,7 @@ export async function populateKnownSaves(
347
347
 
348
348
  // Check named slots from the index
349
349
  const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
350
- const slots = (await getMeta<string[]>(indexKey)) ?? [];
350
+ const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
351
351
  for (const slot of slots) {
352
352
  if (await hasQuickSave(ifid, slot)) {
353
353
  result[slot] = true;
@@ -365,10 +365,11 @@ export async function getSlotSaveInfo(
365
365
  ifid: string,
366
366
  slot?: string,
367
367
  ): Promise<SaveInfo | null> {
368
+ const backend = await getBackend();
368
369
  const metaKey = slotMetaKey(ifid, slot);
369
- const existingId = await getMeta<string>(metaKey);
370
+ const existingId = await backend.getMeta<string>(metaKey);
370
371
  if (!existingId) return null;
371
- const record = await getSave(existingId);
372
+ const record = await backend.getSave(existingId);
372
373
  if (!record) return null;
373
374
  return {
374
375
  slot: slot ?? '',
@@ -392,7 +393,7 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
392
393
 
393
394
  // Check named slots from the index
394
395
  const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
395
- const slots = (await getMeta<string[]>(indexKey)) ?? [];
396
+ const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
396
397
  for (const slot of slots) {
397
398
  const info = await getSlotSaveInfo(ifid, slot);
398
399
  if (info) result.push(info);
@@ -408,19 +409,20 @@ export async function deleteSlotSave(
408
409
  ifid: string,
409
410
  slot?: string,
410
411
  ): Promise<void> {
412
+ const backend = await getBackend();
411
413
  const metaKey = slotMetaKey(ifid, slot);
412
- const existingId = await getMeta<string>(metaKey);
414
+ const existingId = await backend.getMeta<string>(metaKey);
413
415
  if (!existingId) return;
414
416
 
415
- await idbDeleteSave(existingId);
416
- await setMeta(metaKey, undefined);
417
+ await backend.deleteSave(existingId);
418
+ await backend.deleteMeta(metaKey);
417
419
 
418
420
  // Remove from slot index if named
419
421
  if (slot != null) {
420
422
  const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
421
- const existing = (await getMeta<string[]>(indexKey)) ?? [];
423
+ const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
422
424
  const updated = existing.filter((s) => s !== slot);
423
- await setMeta(indexKey, updated);
425
+ await backend.setMeta(indexKey, updated);
424
426
  }
425
427
  }
426
428
 
@@ -474,7 +476,7 @@ export function clearSession(ifid: string): void {
474
476
  export async function exportSave(
475
477
  saveId: string,
476
478
  ): Promise<SaveExport | undefined> {
477
- const record = await getSave(saveId);
479
+ const record = await (await getBackend()).getSave(saveId);
478
480
  if (!record) return undefined;
479
481
 
480
482
  return {
@@ -498,13 +500,15 @@ export async function importSave(
498
500
  );
499
501
  }
500
502
 
503
+ const backend = await getBackend();
504
+
501
505
  // Re-assign a new ID to avoid collisions
502
506
  const record = deepClone(data.save);
503
507
  record.meta.id = crypto.randomUUID();
504
508
  record.meta.updatedAt = new Date().toISOString();
505
509
 
506
510
  // Ensure the playthrough exists
507
- const playthroughs = await getPlaythroughsByIfid(ifid);
511
+ const playthroughs = await backend.getPlaythroughsByIfid(ifid);
508
512
  const ptExists = playthroughs.some((p) => p.id === record.meta.playthroughId);
509
513
  if (!ptExists) {
510
514
  // Create an "Imported" playthrough
@@ -514,9 +518,89 @@ export async function importSave(
514
518
  createdAt: record.meta.createdAt,
515
519
  label: 'Imported',
516
520
  };
517
- await putPlaythrough(pt);
521
+ await backend.putPlaythrough(pt);
518
522
  }
519
523
 
520
- await putSave(record);
524
+ await backend.putSave(record);
521
525
  return record;
522
526
  }
527
+
528
+ // --- Storage Management ---
529
+
530
+ export async function getStorageInfo(ifid: string): Promise<StorageInfo> {
531
+ const backend = await getBackend();
532
+ const saves = await backend.getSavesByIfid(ifid);
533
+ const playthroughs = await backend.getPlaythroughsByIfid(ifid);
534
+
535
+ let totalBytes = 0;
536
+ for (const save of saves) {
537
+ if (save.meta.estimatedBytes != null) {
538
+ totalBytes += save.meta.estimatedBytes;
539
+ } else {
540
+ // Lazy backfill for saves created before estimatedBytes was added
541
+ const bytes = JSON.stringify(save.payload).length;
542
+ save.meta.estimatedBytes = bytes;
543
+ await backend.putSave(save);
544
+ totalBytes += bytes;
545
+ }
546
+ }
547
+
548
+ return {
549
+ saveCount: saves.length,
550
+ playthroughCount: playthroughs.length,
551
+ totalBytes,
552
+ backend: backend.type,
553
+ };
554
+ }
555
+
556
+ export async function clearGameData(ifid: string): Promise<void> {
557
+ const backend = await getBackend();
558
+ await backend.deleteSavesByIfid(ifid);
559
+ await backend.deletePlaythroughsByIfid(ifid);
560
+ await backend.deleteMetaByIfid(ifid);
561
+ clearSession(ifid);
562
+ }
563
+
564
+ export async function clearAllData(): Promise<void> {
565
+ const backend = await getBackend();
566
+ await backend.destroy();
567
+ resetBackend();
568
+
569
+ // Clear all Spindle session keys from sessionStorage
570
+ if (typeof sessionStorage !== 'undefined') {
571
+ const toRemove: string[] = [];
572
+ for (let i = 0; i < sessionStorage.length; i++) {
573
+ const key = sessionStorage.key(i);
574
+ if (key?.startsWith('spindle.session.')) toRemove.push(key);
575
+ }
576
+ for (const key of toRemove) sessionStorage.removeItem(key);
577
+ }
578
+ }
579
+
580
+ export async function deletePlaythroughData(
581
+ ifid: string,
582
+ playthroughId: string,
583
+ ): Promise<void> {
584
+ const backend = await getBackend();
585
+ const deletedSaveIds = await backend.deleteSavesByPlaythrough(playthroughId);
586
+ await backend.deletePlaythroughById(playthroughId);
587
+
588
+ // Clean up slot/autosave meta keys pointing to deleted saves
589
+ const deletedSet = new Set(deletedSaveIds);
590
+ const allKeys = await backend.getAllMetaKeys();
591
+ for (const key of allKeys) {
592
+ if (key.startsWith('slot.') || key.startsWith('autosave.')) {
593
+ const value = await backend.getMeta<string>(key);
594
+ if (value && deletedSet.has(value)) {
595
+ await backend.deleteMeta(key);
596
+ }
597
+ }
598
+ }
599
+
600
+ // Clear currentPlaythroughId if it was this one
601
+ const currentPtKey = `currentPlaythroughId.${ifid}`;
602
+ const currentPt = await backend.getMeta<string>(currentPtKey);
603
+ if (currentPt === playthroughId) {
604
+ await backend.deleteMeta(currentPtKey);
605
+ }
606
+ }