@rohal12/spindle 0.59.1 → 0.59.3

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.
@@ -32,6 +32,26 @@ function create() {
32
32
  return (initializer) => bindPreact(createStore()(initializer));
33
33
  }
34
34
  //#endregion
35
+ //#region src/utils/uuid.ts
36
+ /**
37
+ * A random UUID (v4). `crypto.randomUUID` exists only in secure contexts, so
38
+ * on a plain HTTP origin the ID is built from `crypto.getRandomValues`.
39
+ */
40
+ function randomUUID() {
41
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
42
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
43
+ bytes[6] = bytes[6] & 15 | 64;
44
+ bytes[8] = bytes[8] & 63 | 128;
45
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
46
+ return [
47
+ hex.slice(0, 4).join(""),
48
+ hex.slice(4, 6).join(""),
49
+ hex.slice(6, 8).join(""),
50
+ hex.slice(8, 10).join(""),
51
+ hex.slice(10).join("")
52
+ ].join("-");
53
+ }
54
+ //#endregion
35
55
  //#region src/markup/tokens.ts
36
56
  /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
37
57
  var SIGIL_SCOPES$1 = {
@@ -13061,7 +13081,14 @@ function createIDBBackend() {
13061
13081
  for (const [index, path] of Object.entries(indexes)) store.createIndex(index, path, { unique: false });
13062
13082
  }
13063
13083
  };
13064
- req.onsuccess = () => resolve(req.result);
13084
+ req.onsuccess = () => {
13085
+ const db = req.result;
13086
+ db.onversionchange = () => {
13087
+ db.close();
13088
+ dbPromise = null;
13089
+ };
13090
+ resolve(db);
13091
+ };
13065
13092
  req.onerror = () => reject(req.error);
13066
13093
  });
13067
13094
  return dbPromise;
@@ -13117,6 +13144,7 @@ function createIDBBackend() {
13117
13144
  const req = indexedDB.deleteDatabase(IDB_DB_NAME);
13118
13145
  req.onsuccess = () => resolve();
13119
13146
  req.onerror = () => reject(req.error);
13147
+ req.onblocked = () => reject(/* @__PURE__ */ new Error("Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again."));
13120
13148
  });
13121
13149
  });
13122
13150
  }
@@ -13517,7 +13545,11 @@ function equalBytes(a, b) {
13517
13545
  function equalBuiltin(a, b, assumed) {
13518
13546
  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
13519
13547
  if (a instanceof RegExp) return String(a) === String(b);
13520
- if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) return equalBytes(a, b);
13548
+ if (ArrayBuffer.isView(a)) {
13549
+ const v = b;
13550
+ return a.byteOffset === v.byteOffset && a.byteLength === v.byteLength && equal(a.buffer, v.buffer, assumed);
13551
+ }
13552
+ if (a instanceof ArrayBuffer) return equalBytes(a, b);
13521
13553
  if (a instanceof URL || a instanceof URLSearchParams) return String(a) === String(b);
13522
13554
  if (isBoxed(a)) return Object.is(a.valueOf(), b.valueOf());
13523
13555
  if (isTemporal(a)) return String(a) === String(b);
@@ -13654,6 +13686,91 @@ function diffPaths(before, after) {
13654
13686
  })(before, after, []);
13655
13687
  return changes;
13656
13688
  }
13689
+ var pathKey = (path) => JSON.stringify(path);
13690
+ /** Whether `path`, or one of its ancestors, is one of `keys`. */
13691
+ function underChange(path, keys) {
13692
+ for (let n = 1; n <= path.length; n++) if (keys.has(pathKey(path.slice(0, n)))) return true;
13693
+ return false;
13694
+ }
13695
+ /**
13696
+ * Where each object (or array, or other value) of `root` first appears, in
13697
+ * depth-first order. Plain objects are entered; arrays and other values are
13698
+ * leaves. A path at or below one of `skip` is left out.
13699
+ */
13700
+ function firstPaths(root, skip = /* @__PURE__ */ new Set()) {
13701
+ const found = /* @__PURE__ */ new Map();
13702
+ (function walk(node, path) {
13703
+ for (const key of Object.keys(node)) {
13704
+ const value = node[key];
13705
+ if (!isObjectValue(value) || found.has(value)) continue;
13706
+ const at = [...path, key];
13707
+ if (skip.has(pathKey(at))) continue;
13708
+ found.set(value, at);
13709
+ if (isMergeable(value)) walk(value, at);
13710
+ }
13711
+ })(root, []);
13712
+ return found;
13713
+ }
13714
+ /**
13715
+ * The paths whose object is another one in the references of `after` than in
13716
+ * `before`, as to where its first appearance is: `$a = $b` makes `a` another
13717
+ * name for the object of `b` even where both hold equal content, and a
13718
+ * `diffPaths` over the content finds nothing. Only paths both hold an object
13719
+ * at are reported, parents before children; each is a write of the object
13720
+ * `after` holds.
13721
+ */
13722
+ function aliasChanges(before, after) {
13723
+ const earlier = firstPaths(before);
13724
+ const later = firstPaths(after);
13725
+ const changes = [];
13726
+ (function walk(b, a, path) {
13727
+ for (const key of Object.keys(a)) {
13728
+ if (!hasOwn(b, key)) continue;
13729
+ const x = b[key];
13730
+ const y = a[key];
13731
+ if (!isObjectValue(x) || !isObjectValue(y)) continue;
13732
+ const at = [...path, key];
13733
+ const wasAt = earlier.get(x);
13734
+ const isAt = later.get(y);
13735
+ const k = pathKey(at);
13736
+ if (pathKey(wasAt) !== pathKey(isAt)) changes.push({
13737
+ path: at,
13738
+ deleted: false,
13739
+ value: y,
13740
+ alias: true
13741
+ });
13742
+ if (isMergeable(x) && mergesWith(x, y, false) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
13743
+ }
13744
+ })(before, after, []);
13745
+ return changes;
13746
+ }
13747
+ /**
13748
+ * Where the objects of `source` are, other than at or below the paths of
13749
+ * `changes` (which write something new there), for `existingObjects`.
13750
+ */
13751
+ function locateObjects(source, changes) {
13752
+ return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
13753
+ }
13754
+ /**
13755
+ * A map for `deepClone`'s `seen`: an object of the source `paths` locate
13756
+ * (see locateObjects) is copied as the object at the same path in `target`,
13757
+ * so a value that refers to an object the store holds refers to it again
13758
+ * after the copy, not to a copy of it. Objects are looked up in `target`
13759
+ * when met, not up front.
13760
+ */
13761
+ function existingObjects(paths, target) {
13762
+ return new class extends Map {
13763
+ has(obj) {
13764
+ if (super.has(obj)) return true;
13765
+ const path = paths.get(obj);
13766
+ if (!path) return false;
13767
+ const found = getByPath(target, path);
13768
+ if (!(isObjectValue(found) && (Array.isArray(found) ? Array.isArray(obj) : Object.getPrototypeOf(found) === Object.getPrototypeOf(obj)))) return false;
13769
+ super.set(obj, found);
13770
+ return true;
13771
+ }
13772
+ }();
13773
+ }
13657
13774
  /** Whether `target` already holds what `change` would write. */
13658
13775
  function isApplied(target, change) {
13659
13776
  const parent = getByPath(target, change.path.slice(0, -1));
@@ -13798,7 +13915,7 @@ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(i
13798
13915
  * saves issued before this resolves are tagged with it).
13799
13916
  */
13800
13917
  var startNewPlaythrough = queued(startNewPlaythroughNow);
13801
- async function startNewPlaythroughNow(ifid, id = crypto.randomUUID()) {
13918
+ async function startNewPlaythroughNow(ifid, id = randomUUID()) {
13802
13919
  const backend = await getBackend();
13803
13920
  const num = await nextPlaythroughNumber(ifid);
13804
13921
  const record = {
@@ -13922,7 +14039,7 @@ var createSave = queued(createSaveNow);
13922
14039
  async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
13923
14040
  const now = (/* @__PURE__ */ new Date()).toISOString();
13924
14041
  const record = storedRecord({
13925
- id: crypto.randomUUID(),
14042
+ id: randomUUID(),
13926
14043
  ifid,
13927
14044
  playthroughId,
13928
14045
  createdAt: now,
@@ -14248,7 +14365,7 @@ async function prepareImport(data, ifid) {
14248
14365
  checkSaveExport(data);
14249
14366
  if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
14250
14367
  const record = deepClone(data.save);
14251
- record.meta.id = crypto.randomUUID();
14368
+ record.meta.id = randomUUID();
14252
14369
  record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
14253
14370
  record.meta.ifid = ifid;
14254
14371
  await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
@@ -14438,6 +14555,32 @@ function readState() {
14438
14555
  }
14439
14556
  var cloneValue = (value) => deepClone(value, { keepUnregistered: true });
14440
14557
  /**
14558
+ * A cloner of values written into `target`. The references among the values
14559
+ * it copies are kept (`$b = $a` writes one array twice: the two paths get
14560
+ * one copy), and, given where the objects of the source are (see
14561
+ * locateObjects), an object that is one the target holds already is that
14562
+ * one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
14563
+ */
14564
+ function clonerFor(target, paths) {
14565
+ const seen = paths ? existingObjects(paths, target) : /* @__PURE__ */ new Map();
14566
+ return (value) => deepClone(value, {
14567
+ keepUnregistered: true,
14568
+ seen
14569
+ });
14570
+ }
14571
+ /** One cloner per target copy, made when first used. */
14572
+ var clonersFor = (paths) => {
14573
+ const made = /* @__PURE__ */ new Map();
14574
+ return (target) => {
14575
+ let cloner = made.get(target);
14576
+ if (!cloner) {
14577
+ cloner = clonerFor(target, paths?.(target));
14578
+ made.set(target, cloner);
14579
+ }
14580
+ return cloner;
14581
+ };
14582
+ };
14583
+ /**
14441
14584
  * A frozen copy of a value read from a working copy, matching what reads
14442
14585
  * from the (frozen) store return: writing to it fails instead of changing
14443
14586
  * the code's pending state behind its back.
@@ -14454,12 +14597,12 @@ function frozenCopy(value) {
14454
14597
  * path (the code replaced or deleted an intermediate object), its root is
14455
14598
  * replaced with the store's root as written.
14456
14599
  */
14457
- function mirror(draft, ns, change, scopes = activeScopes) {
14600
+ function mirror(draft, ns, change, scopes = activeScopes, cloner = clonerOf) {
14458
14601
  const root = change.path[0];
14459
14602
  for (const scope of scopes) for (const copy of [scope.work[ns], scope.base[ns]]) try {
14460
14603
  applyChange(copy, change.deleted ? change : {
14461
14604
  ...change,
14462
- value: cloneValue(change.value)
14605
+ value: cloner(copy)(change.value)
14463
14606
  });
14464
14607
  } catch {
14465
14608
  const stored = draft[ns][root];
@@ -14467,6 +14610,8 @@ function mirror(draft, ns, change, scopes = activeScopes) {
14467
14610
  else delete copy[root];
14468
14611
  }
14469
14612
  }
14613
+ /** A cloner of a copy of its own (no references shared with others). */
14614
+ var clonerOf = (target) => clonerFor(target);
14470
14615
  var NAMESPACE_KEYS$1 = new Set(NAMESPACES);
14471
14616
  /**
14472
14617
  * Runs store updates on the running code's working copies. They stay the
@@ -14566,11 +14711,12 @@ function routeStoreUpdate(recipe) {
14566
14711
  const own = patches.filter((p) => p.path[0] === ns);
14567
14712
  return [ns, own.length ? writtenPaths(view[ns], next[ns], own) : []];
14568
14713
  });
14569
- const owned = (change) => change.deleted ? change : {
14714
+ const cloners = clonersFor();
14715
+ const owned = (change, target) => change.deleted ? change : {
14570
14716
  ...change,
14571
- value: cloneValue(change.value)
14717
+ value: cloners(target)(change.value)
14572
14718
  };
14573
- for (const [ns, list] of changes) for (const change of list) applyChange(inner.work[ns], owned(change));
14719
+ for (const [ns, list] of changes) for (const change of list) applyChange(inner.work[ns], owned(change, inner.work[ns]));
14574
14720
  return (draft) => {
14575
14721
  const target = draft;
14576
14722
  for (const key of Object.keys(next)) {
@@ -14581,12 +14727,12 @@ function routeStoreUpdate(recipe) {
14581
14727
  for (const [ns, list] of changes) for (const change of list) {
14582
14728
  const root = change.path[0];
14583
14729
  try {
14584
- applyChange(namespaces[ns], owned(change));
14730
+ applyChange(namespaces[ns], owned(change, namespaces[ns]));
14585
14731
  } catch {
14586
14732
  if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
14587
14733
  else delete namespaces[ns][root];
14588
14734
  }
14589
- mirror(namespaces, ns, change);
14735
+ mirror(namespaces, ns, change, activeScopes, cloners);
14590
14736
  }
14591
14737
  };
14592
14738
  }
@@ -14614,7 +14760,7 @@ function commitScopes(commits) {
14614
14760
  const all = commits.map(({ scope, keepRunning }) => ({
14615
14761
  scope,
14616
14762
  keepRunning,
14617
- changes: NAMESPACES.map((ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])])
14763
+ changes: NAMESPACES.map((ns) => [ns, changesOf(scope.base[ns], scope.work[ns])])
14618
14764
  }));
14619
14765
  const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
14620
14766
  if (changed.length === 0) return;
@@ -14626,29 +14772,53 @@ function commitScopes(commits) {
14626
14772
  committing = false;
14627
14773
  }
14628
14774
  function commitRecipe(draft) {
14629
- all.forEach(({ scope, keepRunning, changes }, i) => {
14630
- const own = (value) => keepRunning ? cloneValue(value) : value;
14775
+ all.forEach(({ scope, changes }, i) => {
14776
+ const paths = new Map(changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]));
14777
+ const own = new Map(changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]));
14778
+ const enclosing = all.slice(0, i).map((c) => c.scope);
14779
+ const enclosingCloners = new Map(changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns))]));
14631
14780
  for (const [ns, list] of changes) {
14632
14781
  const replaced = /* @__PURE__ */ new Set();
14633
14782
  for (const change of list) {
14634
14783
  const root = change.path[0];
14635
- if (replaced.has(root) || isApplied(draft[ns], change)) continue;
14784
+ if (replaced.has(root)) continue;
14636
14785
  try {
14637
- applyChange(draft[ns], change.deleted ? change : {
14786
+ if (change.deleted) {
14787
+ if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
14788
+ continue;
14789
+ }
14790
+ const value = change.alias ? own.get(ns)(change.value) : void 0;
14791
+ if (change.alias ? Object.is(getByPath(draft[ns], change.path), value) : isApplied(draft[ns], change)) continue;
14792
+ applyChange(draft[ns], {
14638
14793
  ...change,
14639
- value: own(change.value)
14794
+ value: change.alias ? value : own.get(ns)(change.value)
14640
14795
  });
14641
14796
  } catch {
14642
- draft[ns][root] = own(scope.work[ns][root]);
14797
+ draft[ns][root] = own.get(ns)(scope.work[ns][root]);
14643
14798
  replaced.add(root);
14644
14799
  }
14645
14800
  }
14646
- const enclosing = all.slice(0, i).map((c) => c.scope);
14647
- for (const change of list) mirror(draft, ns, change, enclosing);
14801
+ for (const change of list) mirror(draft, ns, change, enclosing, enclosingCloners.get(ns));
14648
14802
  }
14649
14803
  });
14650
14804
  }
14651
14805
  }
14806
+ /**
14807
+ * The changes of the code to a namespace: its property paths that differ
14808
+ * (see diffPaths), and those that hold equal content but another object
14809
+ * than they did (see aliasChanges), those first.
14810
+ */
14811
+ function changesOf(base, work) {
14812
+ const written = diffPaths(base, work);
14813
+ const keys = new Set(written.map((c) => pathKey(c.path)));
14814
+ const aliases = [];
14815
+ for (const change of aliasChanges(base, work)) {
14816
+ if (underChange(change.path, keys)) continue;
14817
+ keys.add(pathKey(change.path));
14818
+ aliases.push(change);
14819
+ }
14820
+ return [...aliases, ...written];
14821
+ }
14652
14822
  /** Every running mutation, to commit with its code going on. */
14653
14823
  var running = () => activeScopes.map((scope) => ({
14654
14824
  scope,
@@ -14884,15 +15054,33 @@ function registerDialogHost(callbacks) {
14884
15054
  if (dialogHostCallbacks === callbacks) dialogHostCallbacks = null;
14885
15055
  };
14886
15056
  }
15057
+ /**
15058
+ * The dialogs on display, bottom first, whoever opened them: the trigger
15059
+ * host, `{dialog}` or a menubar button (each PassageDialog registers itself
15060
+ * while mounted), as the function that closes each.
15061
+ */
15062
+ var openDialogs = [];
15063
+ /** Register a displayed dialog; returns the function that unregisters it. */
15064
+ function registerOpenDialog(close) {
15065
+ openDialogs.push(close);
15066
+ return () => {
15067
+ const i = openDialogs.lastIndexOf(close);
15068
+ if (i >= 0) openDialogs.splice(i, 1);
15069
+ };
15070
+ }
15071
+ /** Close the topmost dialog. */
14887
15072
  function closeCurrentDialog() {
14888
- dialogHostCallbacks?.close();
15073
+ const top = openDialogs[openDialogs.length - 1];
15074
+ if (top) top();
15075
+ else dialogHostCallbacks?.close();
14889
15076
  }
14890
15077
  function closeAllOpenDialogs() {
14891
15078
  clearDialogQueue();
15079
+ for (const close of [...openDialogs].reverse()) close();
14892
15080
  dialogHostCallbacks?.closeAll();
14893
15081
  }
14894
15082
  function isDialogShowing() {
14895
- return dialogHostCallbacks?.isOpen() ?? false;
15083
+ return openDialogs.length > 0 || (dialogHostCallbacks?.isOpen() ?? false);
14896
15084
  }
14897
15085
  //#endregion
14898
15086
  //#region src/runtime-errors.ts
@@ -15331,7 +15519,7 @@ function switchToPlaythrough(id, stored) {
15331
15519
  }
15332
15520
  /** Move the running game to a new playthrough at once (see restart). */
15333
15521
  function switchToNewPlaythrough(ifid) {
15334
- const id = crypto.randomUUID();
15522
+ const id = randomUUID();
15335
15523
  switchToPlaythrough(id, startNewPlaythrough(ifid, id).catch((err) => {
15336
15524
  console.error("spindle: failed to start new playthrough", err);
15337
15525
  }));
@@ -15844,7 +16032,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
15844
16032
  const ifid = storyData.ifid;
15845
16033
  const current = knownPlaythroughId();
15846
16034
  const established = playthroughSetup;
15847
- const replacementId = crypto.randomUUID();
16035
+ const replacementId = randomUUID();
15848
16036
  const deletion = deletePlaythroughData(ifid, playthroughId, {
15849
16037
  current: current || established,
15850
16038
  id: replacementId
@@ -16080,6 +16268,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16080
16268
  }, [markup, nobr]);
16081
16269
  const panelRef = useRef(null);
16082
16270
  useModalFocus(panelRef, ".dialog-body", dismissible, stableOnClose);
16271
+ useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
16083
16272
  useLayoutEffect(() => {
16084
16273
  if (panelRef.current) emitFromRender("dialogrender", passageName ?? "", panelRef.current);
16085
16274
  }, []);
@@ -16677,7 +16866,7 @@ function unset(param) {
16677
16866
  return param.type === "options" ? {} : void 0;
16678
16867
  }
16679
16868
  var WORD_RE = /\w+/y;
16680
- var DIGITS_RE = /\d+/y;
16869
+ var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
16681
16870
  var WORD_OR_PUNCT_RE = /\S+/y;
16682
16871
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
16683
16872
  function matchAt(re, src, pos) {
@@ -16686,7 +16875,8 @@ function matchAt(re, src, pos) {
16686
16875
  }
16687
16876
  /**
16688
16877
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
16689
- * quoted string (with or without whitespace before it) or digit run after
16878
+ * quoted string (with or without whitespace before it) or number (signed,
16879
+ * fractional) after
16690
16880
  * it as its value; keywords that aren't declared, and their values, are
16691
16881
  * skipped. A keyword of a `string` parameter must be followed by a quoted
16692
16882
  * string.
@@ -16705,7 +16895,7 @@ function readOptions(params, src, { at, spans }) {
16705
16895
  let j = i;
16706
16896
  while (j < src.length && isWhitespace(src[j])) j++;
16707
16897
  const quoted = readQuoted(src, j);
16708
- const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
16898
+ const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
16709
16899
  if (quoted) {
16710
16900
  val = quoted.value;
16711
16901
  i = quoted.end;
@@ -17444,15 +17634,22 @@ function* codeAndText(src, tokens, parametersOf) {
17444
17634
  const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
17445
17635
  for (let t = 0; t < tokens.length; t++) {
17446
17636
  const token = tokens[t];
17447
- if (token.type === "link") yield {
17448
- kind: "passage",
17449
- name: token.target,
17450
- offset: token.targetStart,
17451
- length: token.targetEnd - token.targetStart,
17452
- macro: "link",
17453
- label: src.slice(token.start, token.end)
17454
- };
17455
- else if (token.type === "expression") yield {
17637
+ if (token.type === "link") {
17638
+ yield {
17639
+ kind: "passage",
17640
+ name: token.target,
17641
+ offset: token.targetStart,
17642
+ length: token.targetEnd - token.targetStart,
17643
+ macro: "link",
17644
+ label: src.slice(token.start, token.end)
17645
+ };
17646
+ if (token.display.includes("{")) yield {
17647
+ kind: "text",
17648
+ text: token.display,
17649
+ offset: locate(src, token.display, token.start),
17650
+ where: "In the label of a link: "
17651
+ };
17652
+ } else if (token.type === "expression") yield {
17456
17653
  kind: "code",
17457
17654
  code: token.expression,
17458
17655
  offset: token.end - 1 - token.expression.length,
@@ -18059,22 +18256,25 @@ function collectTokenRefs(content, tokens, storeVarMacros, onRef, parametersOf =
18059
18256
  const token = tokens[t];
18060
18257
  if (token.type === "variable") {
18061
18258
  if (token.scope === "variable" && token.name) onRef(token.name);
18062
- } else if (token.type === "expression") scanCode(token.expression, onRef);
18259
+ } else if (token.type === "link") scanInterpolations(token.display, onRef);
18260
+ else if (token.type === "expression") scanCode(token.expression, onRef);
18063
18261
  else if (token.type === "html") for (const [name, value] of Object.entries(token.attributes)) if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
18064
18262
  else scanInterpolations(value, onRef, storeVarMacros);
18065
18263
  else if (token.type === "macro" && !token.isClose) {
18066
18264
  scanArgs(token, parametersOf(token.name), onRef);
18265
+ if (token.className) scanInterpolations(token.className, onRef);
18266
+ if (token.id) scanInterpolations(token.id, onRef);
18067
18267
  if (storeVarMacros.has(token.name.toLowerCase())) {
18068
18268
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
18069
18269
  const quoted = QUOTED_VAR_ARG_RE.exec(first);
18070
18270
  if (quoted) onRef(quoted[1]);
18071
18271
  else if (!/^["'`]/.test(first)) scanCode(first, onRef);
18072
18272
  }
18073
- if (token.name === "do") {
18273
+ if (token.name.toLowerCase() === "do") {
18074
18274
  let close = t + 1;
18075
18275
  while (close < tokens.length) {
18076
18276
  const c = tokens[close];
18077
- if (c.type === "macro" && c.isClose && c.name === "do") break;
18277
+ if (c.type === "macro" && c.isClose && c.name.toLowerCase() === "do") break;
18078
18278
  close++;
18079
18279
  }
18080
18280
  if (close < tokens.length) {
@@ -18913,7 +19113,7 @@ defineMacro({
18913
19113
  visibleCharsRef.current = visibleChars;
18914
19114
  useEffect(() => {
18915
19115
  if (containerRef.current) setTotalChars((containerRef.current.textContent || "").length);
18916
- }, []);
19116
+ });
18917
19117
  useEffect(() => {
18918
19118
  if (totalChars === 0) return;
18919
19119
  if (visibleCharsRef.current >= totalChars) return;
@@ -10040,7 +10040,7 @@ function unset(param) {
10040
10040
  return param.type === "options" ? {} : void 0;
10041
10041
  }
10042
10042
  var WORD_RE = /\w+/y;
10043
- var DIGITS_RE = /\d+/y;
10043
+ var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
10044
10044
  var WORD_OR_PUNCT_RE = /\S+/y;
10045
10045
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
10046
10046
  function matchAt(re, src, pos) {
@@ -10049,7 +10049,8 @@ function matchAt(re, src, pos) {
10049
10049
  }
10050
10050
  /**
10051
10051
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
10052
- * quoted string (with or without whitespace before it) or digit run after
10052
+ * quoted string (with or without whitespace before it) or number (signed,
10053
+ * fractional) after
10053
10054
  * it as its value; keywords that aren't declared, and their values, are
10054
10055
  * skipped. A keyword of a `string` parameter must be followed by a quoted
10055
10056
  * string.
@@ -10068,7 +10069,7 @@ function readOptions(params, src, { at, spans }) {
10068
10069
  let j = i;
10069
10070
  while (j < src.length && isWhitespace(src[j])) j++;
10070
10071
  const quoted = readQuoted(src, j);
10071
- const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
10072
+ const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
10072
10073
  if (quoted) {
10073
10074
  val = quoted.value;
10074
10075
  i = quoted.end;
@@ -10392,15 +10393,22 @@ function* codeAndText(src, tokens, parametersOf) {
10392
10393
  const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
10393
10394
  for (let t = 0; t < tokens.length; t++) {
10394
10395
  const token = tokens[t];
10395
- if (token.type === "link") yield {
10396
- kind: "passage",
10397
- name: token.target,
10398
- offset: token.targetStart,
10399
- length: token.targetEnd - token.targetStart,
10400
- macro: "link",
10401
- label: src.slice(token.start, token.end)
10402
- };
10403
- else if (token.type === "expression") yield {
10396
+ if (token.type === "link") {
10397
+ yield {
10398
+ kind: "passage",
10399
+ name: token.target,
10400
+ offset: token.targetStart,
10401
+ length: token.targetEnd - token.targetStart,
10402
+ macro: "link",
10403
+ label: src.slice(token.start, token.end)
10404
+ };
10405
+ if (token.display.includes("{")) yield {
10406
+ kind: "text",
10407
+ text: token.display,
10408
+ offset: locate(src, token.display, token.start),
10409
+ where: "In the label of a link: "
10410
+ };
10411
+ } else if (token.type === "expression") yield {
10404
10412
  kind: "code",
10405
10413
  code: token.expression,
10406
10414
  offset: token.end - 1 - token.expression.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.1",
3
+ "version": "0.59.3",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
package/src/code-check.ts CHANGED
@@ -249,6 +249,15 @@ export function* codeAndText(
249
249
  macro: 'link',
250
250
  label: src.slice(token.start, token.end),
251
251
  };
252
+ // The label is markup too (the link renders as `{link}`)
253
+ if (token.display.includes('{')) {
254
+ yield {
255
+ kind: 'text',
256
+ text: token.display,
257
+ offset: locate(src, token.display, token.start),
258
+ where: 'In the label of a link: ',
259
+ };
260
+ }
252
261
  } else if (token.type === 'expression') {
253
262
  yield {
254
263
  kind: 'code',
@@ -5,6 +5,7 @@ import { renderNodes, NobrContext } from '../markup/render';
5
5
  import { useStoryFields } from '../hooks/use-story-fields';
6
6
  import { emitFromRender } from '../event-emitter';
7
7
  import { useModalFocus } from '../hooks/use-modal-focus';
8
+ import { registerOpenDialog } from '../triggers';
8
9
  import { errorMessage } from '../utils/error-message';
9
10
 
10
11
  export const DialogCloseContext = createContext<(() => void) | null>(null);
@@ -70,6 +71,9 @@ export function PassageDialog({
70
71
  // Declared before the dialogrender effect so handlers can move focus.
71
72
  useModalFocus(panelRef, '.dialog-body', dismissible, stableOnClose);
72
73
 
74
+ // Be on the stack of displayed dialogs the Story dialog API works on.
75
+ useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
76
+
73
77
  // Signal that the dialog's DOM is committed (once per open).
74
78
  useLayoutEffect(() => {
75
79
  if (panelRef.current) {
@@ -15,12 +15,14 @@ defineMacro({
15
15
  const visibleCharsRef = useRef(0);
16
16
  visibleCharsRef.current = visibleChars;
17
17
 
18
+ // Measure after every render: reactive content can become nonempty (or
19
+ // change length) after mounting. Setting an equal count is a no-op.
18
20
  useEffect(() => {
19
21
  if (containerRef.current) {
20
22
  const text = containerRef.current.textContent || '';
21
23
  setTotalChars(text.length);
22
24
  }
23
- }, []);
25
+ });
24
26
 
25
27
  // Typewriter interval
26
28
  useEffect(() => {
@@ -108,7 +108,7 @@ function unset(param: ParameterDef): unknown {
108
108
  }
109
109
 
110
110
  const WORD_RE = /\w+/y;
111
- const DIGITS_RE = /\d+/y;
111
+ const NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
112
112
  const WORD_OR_PUNCT_RE = /\S+/y;
113
113
 
114
114
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
@@ -119,7 +119,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
119
119
 
120
120
  /**
121
121
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
122
- * quoted string (with or without whitespace before it) or digit run after
122
+ * quoted string (with or without whitespace before it) or number (signed,
123
+ * fractional) after
123
124
  * it as its value; keywords that aren't declared, and their values, are
124
125
  * skipped. A keyword of a `string` parameter must be followed by a quoted
125
126
  * string.
@@ -141,13 +142,13 @@ function readOptions(
141
142
  i += key.length;
142
143
 
143
144
  // A value is a quoted string, which may follow the keyword directly
144
- // (`goto"Hall"`: a quote can't be part of one), or a digit run after
145
+ // (`goto"Hall"`: a quote can't be part of one), or a number after
145
146
  // whitespace.
146
147
  let val: string | undefined;
147
148
  let j = i;
148
149
  while (j < src.length && isWhitespace(src[j]!)) j++;
149
150
  const quoted = readQuoted(src, j);
150
- const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
151
+ const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
151
152
  if (quoted) {
152
153
  val = quoted.value;
153
154
  i = quoted.end;