@rohal12/spindle 0.59.1 → 0.59.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.
@@ -10,13 +10,18 @@ import { useStoryStore } from './store';
10
10
  import type { StoryState, VariableNamespaces } from './store';
11
11
  import { execute } from './expression';
12
12
  import {
13
+ aliasChanges,
13
14
  applyChange,
14
15
  deepClone,
15
16
  deepEqual,
16
17
  diffPaths,
18
+ existingObjects,
17
19
  isApplied,
18
20
  isMergeable,
21
+ locateObjects,
19
22
  mergeKeys,
23
+ pathKey,
24
+ underChange,
20
25
  type PathChange,
21
26
  } from './structural';
22
27
  import { getByPath } from './utils/object-path';
@@ -71,6 +76,39 @@ export function readState(): StoryState {
71
76
  const cloneValue = <T>(value: T): T =>
72
77
  deepClone(value, { keepUnregistered: true });
73
78
 
79
+ /** Copies values for one target, so what they share they share there too. */
80
+ type Cloner = <T>(value: T) => T;
81
+
82
+ /**
83
+ * A cloner of values written into `target`. The references among the values
84
+ * it copies are kept (`$b = $a` writes one array twice: the two paths get
85
+ * one copy), and, given where the objects of the source are (see
86
+ * locateObjects), an object that is one the target holds already is that
87
+ * one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
88
+ */
89
+ function clonerFor(
90
+ target: Record<string, unknown>,
91
+ paths?: ReadonlyMap<object, string[]>,
92
+ ): Cloner {
93
+ const seen = paths ? existingObjects(paths, target) : new Map();
94
+ return (value) => deepClone(value, { keepUnregistered: true, seen });
95
+ }
96
+
97
+ /** One cloner per target copy, made when first used. */
98
+ const clonersFor = (
99
+ paths?: (target: Record<string, unknown>) => ReadonlyMap<object, string[]>,
100
+ ) => {
101
+ const made = new Map<object, Cloner>();
102
+ return (target: Record<string, unknown>): Cloner => {
103
+ let cloner = made.get(target);
104
+ if (!cloner) {
105
+ cloner = clonerFor(target, paths?.(target));
106
+ made.set(target, cloner);
107
+ }
108
+ return cloner;
109
+ };
110
+ };
111
+
74
112
  /**
75
113
  * A frozen copy of a value read from a working copy, matching what reads
76
114
  * from the (frozen) store return: writing to it fails instead of changing
@@ -94,6 +132,7 @@ function mirror(
94
132
  ns: NamespaceName,
95
133
  change: PathChange,
96
134
  scopes: readonly MutationScope[] = activeScopes,
135
+ cloner: (target: Record<string, unknown>) => Cloner = clonerOf,
97
136
  ): void {
98
137
  const root = change.path[0]!;
99
138
  for (const scope of scopes) {
@@ -103,7 +142,7 @@ function mirror(
103
142
  copy,
104
143
  change.deleted
105
144
  ? change
106
- : { ...change, value: cloneValue(change.value) },
145
+ : { ...change, value: cloner(copy)(change.value) },
107
146
  );
108
147
  } catch {
109
148
  const stored = draft[ns][root];
@@ -119,6 +158,9 @@ function mirror(
119
158
  }
120
159
  }
121
160
 
161
+ /** A cloner of a copy of its own (no references shared with others). */
162
+ const clonerOf = (target: Record<string, unknown>): Cloner => clonerFor(target);
163
+
122
164
  const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
123
165
 
124
166
  /**
@@ -244,10 +286,19 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
244
286
  own.length ? writtenPaths(view[ns], next[ns], own) : [],
245
287
  ] as const;
246
288
  });
247
- const owned = (change: PathChange): PathChange =>
248
- change.deleted ? change : { ...change, value: cloneValue(change.value) };
289
+ // Each target takes its own copies, which share what the written values do
290
+ const cloners = clonersFor();
291
+ const owned = (
292
+ change: PathChange,
293
+ target: Record<string, unknown>,
294
+ ): PathChange =>
295
+ change.deleted
296
+ ? change
297
+ : { ...change, value: cloners(target)(change.value) };
249
298
  for (const [ns, list] of changes) {
250
- for (const change of list) applyChange(inner.work[ns], owned(change));
299
+ for (const change of list) {
300
+ applyChange(inner.work[ns], owned(change, inner.work[ns]));
301
+ }
251
302
  }
252
303
 
253
304
  return (draft) => {
@@ -266,7 +317,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
266
317
  for (const change of list) {
267
318
  const root = change.path[0]!;
268
319
  try {
269
- applyChange(namespaces[ns], owned(change));
320
+ applyChange(namespaces[ns], owned(change, namespaces[ns]));
270
321
  } catch {
271
322
  if (hasOwn(inner.work[ns], root)) {
272
323
  namespaces[ns][root] = cloneValue(inner.work[ns][root]);
@@ -274,7 +325,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
274
325
  delete namespaces[ns][root];
275
326
  }
276
327
  }
277
- mirror(namespaces, ns, change);
328
+ mirror(namespaces, ns, change, activeScopes, cloners);
278
329
  }
279
330
  }
280
331
  };
@@ -312,7 +363,7 @@ function commitScopes(commits: readonly Commit[]): void {
312
363
  scope,
313
364
  keepRunning,
314
365
  changes: NAMESPACES.map(
315
- (ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])] as const,
366
+ (ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
316
367
  ),
317
368
  }));
318
369
  const changed = all.filter(({ changes }) =>
@@ -336,35 +387,84 @@ function commitScopes(commits: readonly Commit[]): void {
336
387
  }
337
388
 
338
389
  function commitRecipe(draft: VariableNamespaces): void {
339
- all.forEach(({ scope, keepRunning, changes }, i) => {
340
- const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
390
+ all.forEach(({ scope, changes }, i) => {
391
+ // The copies the store and the enclosing mutations take are their own,
392
+ // and keep the references of the values written (one object written
393
+ // at two paths is one object there) and to the objects they hold
394
+ // already (see clonerFor). The code's working copy stays its own.
395
+ const paths = new Map(
396
+ changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]),
397
+ );
398
+ const own = new Map(
399
+ changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
400
+ );
401
+ const enclosing = all.slice(0, i).map((c) => c.scope);
402
+ const enclosingCloners = new Map(
403
+ changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns)!)]),
404
+ );
341
405
  for (const [ns, list] of changes) {
342
406
  const replaced = new Set<string>();
343
407
  for (const change of list) {
344
408
  const root = change.path[0]!;
345
- // A changed path already holding the value keeps its reference
346
- if (replaced.has(root) || isApplied(draft[ns], change)) continue;
409
+ if (replaced.has(root)) continue;
347
410
  try {
348
- applyChange(
349
- draft[ns],
350
- change.deleted ? change : { ...change, value: own(change.value) },
351
- );
411
+ if (change.deleted) {
412
+ if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
413
+ continue;
414
+ }
415
+ // A changed path already holding the value keeps its reference;
416
+ // an alias change is one of reference only: it holds when the
417
+ // very object is there.
418
+ const value = change.alias ? own.get(ns)!(change.value) : undefined;
419
+ if (
420
+ change.alias
421
+ ? Object.is(getByPath(draft[ns], change.path), value)
422
+ : isApplied(draft[ns], change)
423
+ ) {
424
+ continue;
425
+ }
426
+ applyChange(draft[ns], {
427
+ ...change,
428
+ value: change.alias ? value : own.get(ns)!(change.value),
429
+ });
352
430
  } catch {
353
431
  // An intermediate object the code wrote into is gone from the
354
432
  // store: the code's view of the whole root wins.
355
- draft[ns][root] = own(scope.work[ns][root]);
433
+ draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
356
434
  replaced.add(root);
357
435
  }
358
436
  }
359
437
  // Hand the changes to the mutations this one runs inside, before
360
438
  // watchers fired by this update run.
361
- const enclosing = all.slice(0, i).map((c) => c.scope);
362
- for (const change of list) mirror(draft, ns, change, enclosing);
439
+ for (const change of list) {
440
+ mirror(draft, ns, change, enclosing, enclosingCloners.get(ns)!);
441
+ }
363
442
  }
364
443
  });
365
444
  }
366
445
  }
367
446
 
447
+ /**
448
+ * The changes of the code to a namespace: its property paths that differ
449
+ * (see diffPaths), and those that hold equal content but another object
450
+ * than they did (see aliasChanges), those first.
451
+ */
452
+ function changesOf(
453
+ base: Record<string, unknown>,
454
+ work: Record<string, unknown>,
455
+ ): PathChange[] {
456
+ const written = diffPaths(base, work);
457
+ const keys = new Set(written.map((c) => pathKey(c.path)));
458
+ const aliases: PathChange[] = [];
459
+ for (const change of aliasChanges(base, work)) {
460
+ // Written whole by a change at it or above it
461
+ if (underChange(change.path, keys)) continue;
462
+ keys.add(pathKey(change.path));
463
+ aliases.push(change);
464
+ }
465
+ return [...aliases, ...written];
466
+ }
467
+
368
468
  /** Every running mutation, to commit with its code going on. */
369
469
  const running = (): Commit[] =>
370
470
  activeScopes.map((scope) => ({ scope, keepRunning: true }));
@@ -7,6 +7,7 @@ import type {
7
7
  SaveExport,
8
8
  StorageInfo,
9
9
  } from './types';
10
+ import { randomUUID } from '../utils/uuid';
10
11
  import { checkSaveExport, estimatePayloadBytes } from './types';
11
12
  import {
12
13
  decodePayload,
@@ -130,7 +131,7 @@ export const startNewPlaythrough = queued(startNewPlaythroughNow);
130
131
 
131
132
  async function startNewPlaythroughNow(
132
133
  ifid: string,
133
- id: string = crypto.randomUUID(),
134
+ id: string = randomUUID(),
134
135
  ): Promise<string> {
135
136
  const backend = await getBackend();
136
137
  const num = await nextPlaythroughNumber(ifid);
@@ -296,7 +297,7 @@ async function createSaveNow(
296
297
  ): Promise<SaveRecord> {
297
298
  const now = new Date().toISOString();
298
299
  const meta: SaveMeta = {
299
- id: crypto.randomUUID(),
300
+ id: randomUUID(),
300
301
  ifid,
301
302
  playthroughId,
302
303
  createdAt: now,
@@ -774,7 +775,7 @@ async function prepareImport(data: unknown, ifid: string): Promise<SaveRecord> {
774
775
 
775
776
  // Re-assign a new ID to avoid collisions
776
777
  const record = deepClone(data.save);
777
- record.meta.id = crypto.randomUUID();
778
+ record.meta.id = randomUUID();
778
779
  record.meta.updatedAt = new Date().toISOString();
779
780
  // The save now belongs to this story: it is listed, loaded and cleared
780
781
  // with it, whatever its own metadata says
@@ -289,7 +289,16 @@ function createIDBBackend(): StorageBackend {
289
289
  }
290
290
  };
291
291
 
292
- req.onsuccess = () => resolve(req.result);
292
+ req.onsuccess = () => {
293
+ const db = req.result;
294
+ // Let another tab delete or upgrade the database: close at once and
295
+ // reopen on the next use.
296
+ db.onversionchange = () => {
297
+ db.close();
298
+ dbPromise = null;
299
+ };
300
+ resolve(db);
301
+ };
293
302
  req.onerror = () => reject(req.error);
294
303
  });
295
304
 
@@ -361,6 +370,12 @@ function createIDBBackend(): StorageBackend {
361
370
  const req = indexedDB.deleteDatabase(IDB_DB_NAME);
362
371
  req.onsuccess = () => resolve();
363
372
  req.onerror = () => reject(req.error);
373
+ req.onblocked = () =>
374
+ reject(
375
+ new Error(
376
+ 'Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again.',
377
+ ),
378
+ );
364
379
  });
365
380
  });
366
381
  }
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { create } from './preact-store';
2
+ import { randomUUID } from './utils/uuid';
2
3
  import { immer } from 'zustand/middleware/immer';
3
4
  import type { StateCreator } from 'zustand/vanilla';
4
5
  import {
@@ -595,7 +596,7 @@ function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
595
596
 
596
597
  /** Move the running game to a new playthrough at once (see restart). */
597
598
  function switchToNewPlaythrough(ifid: string): void {
598
- const id = crypto.randomUUID();
599
+ const id = randomUUID();
599
600
  const stored = startNewPlaythrough(ifid, id).catch((err) => {
600
601
  console.error('spindle: failed to start new playthrough', err);
601
602
  });
@@ -1484,7 +1485,7 @@ export const useStoryStore = create<StoryState>()(
1484
1485
  const ifid = storyData.ifid;
1485
1486
  const current = knownPlaythroughId();
1486
1487
  const established = playthroughSetup;
1487
- const replacementId = crypto.randomUUID();
1488
+ const replacementId = randomUUID();
1488
1489
  const deletion = smDeletePlaythroughData(ifid, playthroughId, {
1489
1490
  current: current || established,
1490
1491
  id: replacementId,
@@ -333,6 +333,8 @@ function collectTokenRefs(
333
333
  const token = tokens[t]!;
334
334
  if (token.type === 'variable') {
335
335
  if (token.scope === 'variable' && token.name) onRef(token.name);
336
+ } else if (token.type === 'link') {
337
+ scanInterpolations(token.display, onRef);
336
338
  } else if (token.type === 'expression') {
337
339
  scanCode(token.expression, onRef);
338
340
  } else if (token.type === 'html') {
@@ -342,6 +344,9 @@ function collectTokenRefs(
342
344
  }
343
345
  } else if (token.type === 'macro' && !token.isClose) {
344
346
  scanArgs(token, parametersOf(token.name), onRef);
347
+ // Selectors (`{.{$cls} button}`) are interpolated when rendered
348
+ if (token.className) scanInterpolations(token.className, onRef);
349
+ if (token.id) scanInterpolations(token.id, onRef);
345
350
 
346
351
  if (storeVarMacros.has(token.name.toLowerCase())) {
347
352
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
@@ -351,13 +356,19 @@ function collectTokenRefs(
351
356
  else if (!/^["'`]/.test(first)) scanCode(first, onRef);
352
357
  }
353
358
 
354
- if (token.name === 'do') {
359
+ if (token.name.toLowerCase() === 'do') {
355
360
  // A {do} body is JavaScript: scan its source text as code, however
356
361
  // the markup tokens split it up.
357
362
  let close = t + 1;
358
363
  while (close < tokens.length) {
359
364
  const c = tokens[close]!;
360
- if (c.type === 'macro' && c.isClose && c.name === 'do') break;
365
+ if (
366
+ c.type === 'macro' &&
367
+ c.isClose &&
368
+ c.name.toLowerCase() === 'do'
369
+ ) {
370
+ break;
371
+ }
361
372
  close++;
362
373
  }
363
374
  if (close < tokens.length) {
package/src/structural.ts CHANGED
@@ -334,9 +334,17 @@ function equalBuiltin(
334
334
  ): boolean | undefined {
335
335
  if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
336
336
  if (a instanceof RegExp) return String(a) === String(b);
337
- if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) {
338
- return equalBytes(a, b as ArrayBuffer);
337
+ if (ArrayBuffer.isView(a)) {
338
+ // A view is its place in its backing buffer, and the whole buffer (which
339
+ // other views can share): equal only if both match.
340
+ const v = b as ArrayBufferView;
341
+ return (
342
+ a.byteOffset === v.byteOffset &&
343
+ a.byteLength === v.byteLength &&
344
+ equal(a.buffer, v.buffer, assumed)
345
+ );
339
346
  }
347
+ if (a instanceof ArrayBuffer) return equalBytes(a, b as ArrayBuffer);
340
348
  if (a instanceof URL || a instanceof URLSearchParams) {
341
349
  return String(a) === String(b);
342
350
  }
@@ -521,7 +529,17 @@ export function keyChanges(
521
529
 
522
530
  /** A write to one property path of an object, or its deletion. */
523
531
  export type PathChange =
524
- | { path: string[]; deleted: false; value: unknown }
532
+ | {
533
+ path: string[];
534
+ deleted: false;
535
+ value: unknown;
536
+ /**
537
+ * Set where the value is the same content as before and only which
538
+ * object the path refers to changed (see aliasChanges), so a store
539
+ * holding equal content there still takes the write.
540
+ */
541
+ alias?: true;
542
+ }
525
543
  | { path: string[]; deleted: true };
526
544
 
527
545
  /**
@@ -560,6 +578,130 @@ export function diffPaths(
560
578
  return changes;
561
579
  }
562
580
 
581
+ export const pathKey = (path: readonly string[]): string =>
582
+ JSON.stringify(path);
583
+
584
+ /** Whether `path`, or one of its ancestors, is one of `keys`. */
585
+ export function underChange(
586
+ path: readonly string[],
587
+ keys: ReadonlySet<string>,
588
+ ): boolean {
589
+ for (let n = 1; n <= path.length; n++) {
590
+ if (keys.has(pathKey(path.slice(0, n)))) return true;
591
+ }
592
+ return false;
593
+ }
594
+
595
+ /**
596
+ * Where each object (or array, or other value) of `root` first appears, in
597
+ * depth-first order. Plain objects are entered; arrays and other values are
598
+ * leaves. A path at or below one of `skip` is left out.
599
+ */
600
+ function firstPaths(
601
+ root: Record<string, unknown>,
602
+ skip: ReadonlySet<string> = new Set(),
603
+ ): Map<object, string[]> {
604
+ const found = new Map<object, string[]>();
605
+ (function walk(node: Record<string, unknown>, path: string[]): void {
606
+ for (const key of Object.keys(node)) {
607
+ const value = node[key];
608
+ if (!isObjectValue(value) || found.has(value)) continue;
609
+ const at = [...path, key];
610
+ if (skip.has(pathKey(at))) continue;
611
+ found.set(value, at);
612
+ if (isMergeable(value)) walk(value, at);
613
+ }
614
+ })(root, []);
615
+ return found;
616
+ }
617
+
618
+ /**
619
+ * The paths whose object is another one in the references of `after` than in
620
+ * `before`, as to where its first appearance is: `$a = $b` makes `a` another
621
+ * name for the object of `b` even where both hold equal content, and a
622
+ * `diffPaths` over the content finds nothing. Only paths both hold an object
623
+ * at are reported, parents before children; each is a write of the object
624
+ * `after` holds.
625
+ */
626
+ export function aliasChanges(
627
+ before: Record<string, unknown>,
628
+ after: Record<string, unknown>,
629
+ ): PathChange[] {
630
+ const earlier = firstPaths(before);
631
+ const later = firstPaths(after);
632
+ const changes: PathChange[] = [];
633
+ (function walk(
634
+ b: Record<string, unknown>,
635
+ a: Record<string, unknown>,
636
+ path: string[],
637
+ ): void {
638
+ for (const key of Object.keys(a)) {
639
+ if (!hasOwn(b, key)) continue;
640
+ const x = b[key];
641
+ const y = a[key];
642
+ if (!isObjectValue(x) || !isObjectValue(y)) continue;
643
+ const at = [...path, key];
644
+ const wasAt = earlier.get(x)!;
645
+ const isAt = later.get(y)!;
646
+ const k = pathKey(at);
647
+ if (pathKey(wasAt) !== pathKey(isAt)) {
648
+ changes.push({ path: at, deleted: false, value: y, alias: true });
649
+ }
650
+ // Only paths that are first appearances are entered: elsewhere the
651
+ // contents are those of the object met first.
652
+ if (
653
+ isMergeable(x) &&
654
+ mergesWith(x, y, false) &&
655
+ pathKey(isAt) === k &&
656
+ pathKey(wasAt) === k
657
+ ) {
658
+ walk(x, y as Record<string, unknown>, at);
659
+ }
660
+ }
661
+ })(before, after, []);
662
+ return changes;
663
+ }
664
+
665
+ /**
666
+ * Where the objects of `source` are, other than at or below the paths of
667
+ * `changes` (which write something new there), for `existingObjects`.
668
+ */
669
+ export function locateObjects(
670
+ source: Record<string, unknown>,
671
+ changes: readonly PathChange[],
672
+ ): Map<object, string[]> {
673
+ return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
674
+ }
675
+
676
+ /**
677
+ * A map for `deepClone`'s `seen`: an object of the source `paths` locate
678
+ * (see locateObjects) is copied as the object at the same path in `target`,
679
+ * so a value that refers to an object the store holds refers to it again
680
+ * after the copy, not to a copy of it. Objects are looked up in `target`
681
+ * when met, not up front.
682
+ */
683
+ export function existingObjects(
684
+ paths: ReadonlyMap<object, string[]>,
685
+ target: Record<string, unknown>,
686
+ ): Map<object, object> {
687
+ return new (class extends Map<object, object> {
688
+ override has(obj: object): boolean {
689
+ if (super.has(obj)) return true;
690
+ const path = paths.get(obj);
691
+ if (!path) return false;
692
+ const found = getByPath(target, path);
693
+ const like =
694
+ isObjectValue(found) &&
695
+ (Array.isArray(found)
696
+ ? Array.isArray(obj)
697
+ : Object.getPrototypeOf(found) === Object.getPrototypeOf(obj));
698
+ if (!like) return false;
699
+ super.set(obj, found as object);
700
+ return true;
701
+ }
702
+ })();
703
+ }
704
+
563
705
  /** Whether `target` already holds what `change` would write. */
564
706
  export function isApplied(
565
707
  target: Record<string, unknown>,
package/src/triggers.ts CHANGED
@@ -305,15 +305,35 @@ export function registerDialogHost(callbacks: DialogHostCallbacks): () => void {
305
305
  };
306
306
  }
307
307
 
308
+ /**
309
+ * The dialogs on display, bottom first, whoever opened them: the trigger
310
+ * host, `{dialog}` or a menubar button (each PassageDialog registers itself
311
+ * while mounted), as the function that closes each.
312
+ */
313
+ const openDialogs: Array<() => void> = [];
314
+
315
+ /** Register a displayed dialog; returns the function that unregisters it. */
316
+ export function registerOpenDialog(close: () => void): () => void {
317
+ openDialogs.push(close);
318
+ return () => {
319
+ const i = openDialogs.lastIndexOf(close);
320
+ if (i >= 0) openDialogs.splice(i, 1);
321
+ };
322
+ }
323
+
324
+ /** Close the topmost dialog. */
308
325
  export function closeCurrentDialog(): void {
309
- dialogHostCallbacks?.close();
326
+ const top = openDialogs[openDialogs.length - 1];
327
+ if (top) top();
328
+ else dialogHostCallbacks?.close();
310
329
  }
311
330
 
312
331
  export function closeAllOpenDialogs(): void {
313
332
  clearDialogQueue();
333
+ for (const close of [...openDialogs].reverse()) close();
314
334
  dialogHostCallbacks?.closeAll();
315
335
  }
316
336
 
317
337
  export function isDialogShowing(): boolean {
318
- return dialogHostCallbacks?.isOpen() ?? false;
338
+ return openDialogs.length > 0 || (dialogHostCallbacks?.isOpen() ?? false);
319
339
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A random UUID (v4). `crypto.randomUUID` exists only in secure contexts, so
3
+ * on a plain HTTP origin the ID is built from `crypto.getRandomValues`.
4
+ */
5
+ export function randomUUID(): string {
6
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
7
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
8
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
9
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
10
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
11
+ return [
12
+ hex.slice(0, 4).join(''),
13
+ hex.slice(4, 6).join(''),
14
+ hex.slice(6, 8).join(''),
15
+ hex.slice(8, 10).join(''),
16
+ hex.slice(10).join(''),
17
+ ].join('-');
18
+ }