@rohal12/spindle 0.52.2 → 0.52.4

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.
@@ -4,6 +4,7 @@ import { useSyncExternalStore } from "preact/compat";
4
4
  import { createStore } from "zustand/vanilla";
5
5
  import { immer } from "zustand/middleware/immer";
6
6
  import { Immer, applyPatches, current, enableMapSet, enablePatches, freeze, isDraft, produceWithPatches } from "immer";
7
+ import { shallow } from "zustand/vanilla/shallow";
7
8
  import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
8
9
  import { micromark } from "micromark";
9
10
  import { gfmTable, gfmTableHtml } from "micromark-extension-gfm-table";
@@ -485,13 +486,13 @@ function scanRegex(src, start, cache) {
485
486
  };
486
487
  let i = start + 1;
487
488
  while (i < src.length) {
489
+ const lineEnd = regexLineEnd(src, i);
490
+ if (lineEnd !== -1) return done(lineEnd, false);
488
491
  const c = src.charAt(i);
489
492
  if (c === "\\") {
490
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
491
493
  i += 2;
492
494
  continue;
493
495
  }
494
- if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
495
496
  if (c === "[") {
496
497
  i = scanRegexClass(src, i, cache);
497
498
  if (src.charAt(i) !== "]") return done(i, false);
@@ -522,16 +523,17 @@ function scanRegexClass(src, open, cache) {
522
523
  const opens = [open];
523
524
  let i = open + 1;
524
525
  while (i < src.length) {
526
+ const lineEnd = regexLineEnd(src, i);
527
+ if (lineEnd !== -1) {
528
+ i = lineEnd;
529
+ break;
530
+ }
525
531
  const c = src.charAt(i);
526
532
  if (c === "\\") {
527
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
528
- i++;
529
- break;
530
- }
531
533
  i += 2;
532
534
  continue;
533
535
  }
534
- if (c === "]" || LINE_TERMINATOR_RE.test(c)) break;
536
+ if (c === "]") break;
535
537
  if (c === "[" && cache) opens.push(i);
536
538
  i++;
537
539
  }
@@ -540,6 +542,16 @@ function scanRegexClass(src, open, cache) {
540
542
  return i;
541
543
  }
542
544
  /**
545
+ * Index of the line break at `i`, escaped (`\` then a line break) or not,
546
+ * where a regex literal or class ends unterminated; -1 if there is none.
547
+ */
548
+ function regexLineEnd(src, i) {
549
+ const c = src.charAt(i);
550
+ if (LINE_TERMINATOR_RE.test(c)) return i;
551
+ if (c === "\\" && LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return i + 1;
552
+ return -1;
553
+ }
554
+ /**
543
555
  * The next index from `from` on where `find` matches (-1 for none), for
544
556
  * searches from increasing positions: a search from within the stretch the
545
557
  * last one covered has the same answer.
@@ -1489,8 +1501,7 @@ function splitSigilTemplate(template) {
1489
1501
  }
1490
1502
  text += template.slice(i, brace);
1491
1503
  i = brace + 1;
1492
- const sigil = template[i];
1493
- const end = sigil === "$" || sigil === "_" || sigil === "@" || sigil === "%" ? scanBalancedBrace(template, i, memo) : -1;
1504
+ const end = isSigil(template[i]) ? scanBalancedBrace(template, i, memo) : -1;
1494
1505
  if (end === -1) {
1495
1506
  text += "{";
1496
1507
  continue;
@@ -1506,6 +1517,26 @@ function splitSigilTemplate(template) {
1506
1517
  }
1507
1518
  //#endregion
1508
1519
  //#region src/markup/tokenizer.ts
1520
+ /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
1521
+ var SIGIL_SCOPES = {
1522
+ $: "variable",
1523
+ _: "temporary",
1524
+ "@": "local",
1525
+ "%": "transient"
1526
+ };
1527
+ /** The sigil of each variable scope. */
1528
+ var SCOPE_SIGILS = Object.fromEntries(Object.entries(SIGIL_SCOPES).map(([sigil, scope]) => [scope, sigil]));
1529
+ var SIGIL_CHARS = new Set(Object.keys(SIGIL_SCOPES));
1530
+ /** Whether `c` is a variable sigil. */
1531
+ function isSigil(c) {
1532
+ return c !== void 0 && SIGIL_CHARS.has(c);
1533
+ }
1534
+ /** Copy the selectors set in `from` onto `target`, and return it. */
1535
+ function withSelectors(target, from) {
1536
+ if (from.className) target.className = from.className;
1537
+ if (from.id) target.id = from.id;
1538
+ return target;
1539
+ }
1509
1540
  /** Tag name must start with a letter (covers standard and custom elements). */
1510
1541
  var VALID_TAG_START = /[a-zA-Z]/;
1511
1542
  /** HTML void elements: never have children or a closing tag. */
@@ -1525,13 +1556,6 @@ var HTML_VOID_TAGS = new Set([
1525
1556
  "track",
1526
1557
  "wbr"
1527
1558
  ]);
1528
- /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
1529
- var SIGIL_CHARS = new Set([
1530
- "$",
1531
- "_",
1532
- "@",
1533
- "%"
1534
- ]);
1535
1559
  /**
1536
1560
  * Characters other than a sigil that open an expression after `{`:
1537
1561
  * `{(Math.max($a, 0))}`, `{!$done}`. Other characters that can start an
@@ -1542,25 +1566,32 @@ var EXPRESSION_START = new Set(["(", "!"]);
1542
1566
  /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
1543
1567
  var RAW_BODY_MACROS = new Set(["do"]);
1544
1568
  /**
1569
+ * Link separators, in the order they are tried, and whether the target
1570
+ * comes first: display|target, display->target, target<-display.
1571
+ */
1572
+ var LINK_SEPARATORS = [
1573
+ ["|", false],
1574
+ ["->", false],
1575
+ ["<-", true]
1576
+ ];
1577
+ /**
1545
1578
  * Parse a Twine link interior into display and target.
1546
1579
  * Supports: display|target, display->target, target<-display, plain
1547
1580
  */
1548
1581
  function parseLink(inner) {
1549
- const pipeIdx = inner.indexOf("|");
1550
- if (pipeIdx !== -1) return {
1551
- display: inner.slice(0, pipeIdx).trim(),
1552
- target: inner.slice(pipeIdx + 1).trim()
1553
- };
1554
- const arrowIdx = inner.indexOf("->");
1555
- if (arrowIdx !== -1) return {
1556
- display: inner.slice(0, arrowIdx).trim(),
1557
- target: inner.slice(arrowIdx + 2).trim()
1558
- };
1559
- const revIdx = inner.indexOf("<-");
1560
- if (revIdx !== -1) return {
1561
- target: inner.slice(0, revIdx).trim(),
1562
- display: inner.slice(revIdx + 2).trim()
1563
- };
1582
+ for (const [separator, targetFirst] of LINK_SEPARATORS) {
1583
+ const idx = inner.indexOf(separator);
1584
+ if (idx === -1) continue;
1585
+ const before = inner.slice(0, idx).trim();
1586
+ const after = inner.slice(idx + separator.length).trim();
1587
+ return targetFirst ? {
1588
+ display: after,
1589
+ target: before
1590
+ } : {
1591
+ display: before,
1592
+ target: after
1593
+ };
1594
+ }
1564
1595
  const trimmed = inner.trim();
1565
1596
  return {
1566
1597
  display: trimmed,
@@ -1591,8 +1622,10 @@ function parseMacroContent(content) {
1591
1622
  }
1592
1623
  /**
1593
1624
  * Parse CSS selectors: .foo.bar#baz → { className: "foo bar", id: "baz" }
1594
- * Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order.
1595
- * Returns space-joined class string, last id wins, and position after last segment.
1625
+ * Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order, and one
1626
+ * space after them. Returns space-joined class string, last id wins (each
1627
+ * left out if empty), and the position after them: `startIdx` if there are
1628
+ * none.
1596
1629
  */
1597
1630
  function parseSelectors(input, startIdx) {
1598
1631
  const classes = [];
@@ -1605,7 +1638,7 @@ function parseSelectors(input, startIdx) {
1605
1638
  while (i < input.length) if (/[a-zA-Z0-9_-]/.test(input[i])) {
1606
1639
  name += input[i];
1607
1640
  i++;
1608
- } else if (input[i] === "{" && SIGIL_CHARS.has(input[i + 1])) {
1641
+ } else if (input[i] === "{" && isSigil(input[i + 1])) {
1609
1642
  const braceStart = i;
1610
1643
  i += 2;
1611
1644
  while (i < input.length && /[\w.]/.test(input[i])) i++;
@@ -1620,10 +1653,13 @@ function parseSelectors(input, startIdx) {
1620
1653
  if (name) if (prefix === ".") classes.push(name);
1621
1654
  else id = name;
1622
1655
  }
1656
+ if (i > startIdx && input[i] === " ") i++;
1623
1657
  return {
1624
- className: classes.join(" "),
1625
- id,
1626
- endIdx: i
1658
+ selectors: withSelectors({}, {
1659
+ className: classes.join(" "),
1660
+ id
1661
+ }),
1662
+ end: i
1627
1663
  };
1628
1664
  }
1629
1665
  /**
@@ -1738,7 +1774,7 @@ function scanQuotedValue(input, j, quote, code, memo) {
1738
1774
  continue;
1739
1775
  }
1740
1776
  if (input[j] === "{") {
1741
- const closeIdx = !code ? scanBlockClose(input, j, memo) : SIGIL_CHARS.has(input[j + 1]) ? scanBalancedBrace(input, j + 1, memo) : -1;
1777
+ const closeIdx = !code ? scanBlockClose(input, j, memo) : isSigil(input[j + 1]) ? scanBalancedBrace(input, j + 1, memo) : -1;
1742
1778
  if (closeIdx !== -1) {
1743
1779
  j = closeIdx + 1;
1744
1780
  checkpoint = true;
@@ -1814,12 +1850,7 @@ function scanMacroClose(input, contentStart, memo) {
1814
1850
  * code from just past the {. Returns -1 if it is unclosed.
1815
1851
  */
1816
1852
  function scanBlockClose(input, open, memo) {
1817
- let at = open + 1;
1818
- const c = input[at];
1819
- if (c === "." || c === "#") {
1820
- at = parseSelectors(input, at).endIdx;
1821
- if (input[at] === " ") at++;
1822
- }
1853
+ const at = parseSelectors(input, open + 1).end;
1823
1854
  const first = input[at];
1824
1855
  if (first !== void 0 && (first === "/" || /[a-zA-Z]/.test(first))) return scanMacroClose(input, at, memo);
1825
1856
  return scanBalancedBrace(input, at, memo);
@@ -2011,13 +2042,22 @@ function tokenize(input, options = {}) {
2011
2042
  const memo = createScanMemo();
2012
2043
  let i = 0;
2013
2044
  let textStart = 0;
2045
+ /** Push a text token for the text not yet tokenized before `end`. */
2014
2046
  function flushText(end) {
2015
- if (end > textStart) tokens.push({
2016
- type: "text",
2017
- value: input.slice(textStart, end),
2018
- start: textStart,
2019
- end
2020
- });
2047
+ if (end > textStart) {
2048
+ tokens.push({
2049
+ type: "text",
2050
+ value: input.slice(textStart, end),
2051
+ start: textStart,
2052
+ end
2053
+ });
2054
+ textStart = end;
2055
+ }
2056
+ }
2057
+ /** Push `token` and go on after it. */
2058
+ function emit(token) {
2059
+ tokens.push(token);
2060
+ i = textStart = token.end;
2021
2061
  }
2022
2062
  /**
2023
2063
  * After an opening raw-body macro ({do}), emit everything up to its
@@ -2059,34 +2099,66 @@ function tokenize(input, options = {}) {
2059
2099
  atRe.lastIndex = closeStart;
2060
2100
  const closeEnd = closeStart + atRe.exec(input)[0].length;
2061
2101
  flushText(closeStart);
2062
- tokens.push({
2102
+ emit({
2063
2103
  type: "macro",
2064
2104
  ...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
2065
2105
  start: closeStart,
2066
2106
  end: closeEnd
2067
2107
  });
2068
- i = closeEnd;
2069
- textStart = closeEnd;
2070
2108
  }
2071
2109
  /**
2072
2110
  * Push an expression token for the `{…}` block opened at `start` whose
2073
- * expression starts at `exprStart`, flushing the text before it first
2074
- * when `flush`. Returns false, consuming nothing, if the block is unclosed.
2111
+ * expression starts at `exprStart`, flushing the text before it first.
2112
+ * Returns false, consuming nothing, if the block is unclosed.
2075
2113
  */
2076
- function pushExpression(exprStart, start, flush, className, id) {
2114
+ function pushExpression(exprStart, start, selectors) {
2077
2115
  const closeIdx = scanBalancedBrace(input, exprStart, memo);
2078
2116
  if (closeIdx === -1) return false;
2079
- if (flush) flushText(start);
2080
- const token = {
2117
+ flushText(start);
2118
+ emit(withSelectors({
2081
2119
  type: "expression",
2082
2120
  expression: input.slice(exprStart, closeIdx),
2083
2121
  start,
2084
2122
  end: closeIdx + 1
2085
- };
2086
- if (className) token.className = className;
2087
- if (id) token.id = id;
2088
- tokens.push(token);
2089
- i = textStart = closeIdx + 1;
2123
+ }, selectors));
2124
+ return true;
2125
+ }
2126
+ /**
2127
+ * Push a variable token for the `{…}` block opened at `start` whose sigil
2128
+ * is at `sigilAt`: `{$name}`, `{_name.field.subfield}`. Anything else
2129
+ * after the name makes the block an expression from the sigil on
2130
+ * (`{$expr[...]}`). Returns false, consuming nothing, if it is unclosed.
2131
+ */
2132
+ function pushVariable(sigilAt, start, selectors) {
2133
+ let nameEnd = sigilAt + 1;
2134
+ while (nameEnd < input.length && /[\w.]/.test(input[nameEnd])) nameEnd++;
2135
+ if (input[nameEnd] !== "}") return pushExpression(sigilAt, start, selectors);
2136
+ emit(withSelectors({
2137
+ type: "variable",
2138
+ name: input.slice(sigilAt + 1, nameEnd),
2139
+ scope: SIGIL_SCOPES[input[sigilAt]],
2140
+ start,
2141
+ end: nameEnd + 1
2142
+ }, selectors));
2143
+ return true;
2144
+ }
2145
+ /**
2146
+ * Push a macro token for the `{…}` block opened at `start` whose content
2147
+ * (name, then arguments) starts at `contentStart`, then consume the body
2148
+ * of a raw-body macro. Returns false, consuming nothing, if the block is
2149
+ * unclosed.
2150
+ */
2151
+ function pushMacro(contentStart, start, selectors) {
2152
+ const closeIdx = scanMacroClose(input, contentStart, memo);
2153
+ if (closeIdx === -1) return false;
2154
+ const token = withSelectors({
2155
+ type: "macro",
2156
+ ...parseMacroContent(input.slice(contentStart, closeIdx)),
2157
+ start,
2158
+ end: closeIdx + 1
2159
+ }, selectors);
2160
+ emit(token);
2161
+ consumeRawBody(token.name, token.isClose);
2090
2162
  return true;
2091
2163
  }
2092
2164
  while (i < input.length) {
@@ -2110,14 +2182,12 @@ function tokenize(input, options = {}) {
2110
2182
  }
2111
2183
  if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
2112
2184
  flushText(k - 1);
2113
- tokens.push({
2185
+ emit({
2114
2186
  type: "text",
2115
2187
  value: next,
2116
2188
  start: k - 1,
2117
2189
  end: k + 1
2118
2190
  });
2119
- i = k + 1;
2120
- textStart = i;
2121
2191
  continue;
2122
2192
  }
2123
2193
  i = k;
@@ -2126,413 +2196,36 @@ function tokenize(input, options = {}) {
2126
2196
  if (!textMode && input[i] === "[" && input[i + 1] === "[") {
2127
2197
  flushText(i);
2128
2198
  const start = i;
2129
- i += 2;
2130
- let className;
2131
- let id;
2132
- if (input[i] === "." || input[i] === "#") {
2133
- const parsed = parseSelectors(input, i);
2134
- className = parsed.className || void 0;
2135
- id = parsed.id || void 0;
2136
- i = parsed.endIdx;
2137
- if (input[i] === " ") i++;
2138
- }
2139
- const innerStart = i;
2199
+ const { selectors, end: innerStart } = parseSelectors(input, i + 2);
2140
2200
  const closeIdx = scanLinkClose(input, innerStart, memo);
2141
2201
  if (closeIdx === -1) {
2142
2202
  i = start + 2;
2143
- textStart = start;
2144
2203
  continue;
2145
2204
  }
2146
- const inner = input.slice(innerStart, closeIdx);
2147
- i = closeIdx + 2;
2148
- const { display, target } = parseLink(inner);
2149
- const linkToken = {
2205
+ emit(withSelectors({
2150
2206
  type: "link",
2151
- display,
2152
- target,
2207
+ ...parseLink(input.slice(innerStart, closeIdx)),
2153
2208
  start,
2154
- end: i
2155
- };
2156
- if (className) linkToken.className = className;
2157
- if (id) linkToken.id = id;
2158
- tokens.push(linkToken);
2159
- textStart = i;
2209
+ end: closeIdx + 2
2210
+ }, selectors));
2160
2211
  continue;
2161
2212
  }
2162
2213
  if (input[i] === "{") {
2163
2214
  const start = i;
2164
- let nextChar = input[i + 1];
2165
- let className;
2166
- let id;
2167
- if (nextChar === "." || nextChar === "#") {
2168
- flushText(i);
2169
- const parsed = parseSelectors(input, i + 1);
2170
- className = parsed.className || void 0;
2171
- id = parsed.id || void 0;
2172
- let afterSelectors = parsed.endIdx;
2173
- if (input[afterSelectors] === " ") afterSelectors++;
2174
- const charAfter = input[afterSelectors];
2175
- if (charAfter === "$") {
2176
- i = afterSelectors + 1;
2177
- const nameStart = i;
2178
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2179
- const name = input.slice(nameStart, i);
2180
- if (input[i] === "}") {
2181
- i++;
2182
- const token = {
2183
- type: "variable",
2184
- name,
2185
- scope: "variable",
2186
- start,
2187
- end: i
2188
- };
2189
- if (className) token.className = className;
2190
- if (id) token.id = id;
2191
- tokens.push(token);
2192
- textStart = i;
2193
- continue;
2194
- }
2195
- const closeIdx$ = scanBalancedBrace(input, nameStart - 1, memo);
2196
- if (closeIdx$ !== -1) {
2197
- const expression = input.slice(afterSelectors, closeIdx$);
2198
- i = closeIdx$ + 1;
2199
- const token = {
2200
- type: "expression",
2201
- expression,
2202
- start,
2203
- end: i
2204
- };
2205
- if (className) token.className = className;
2206
- if (id) token.id = id;
2207
- tokens.push(token);
2208
- textStart = i;
2209
- continue;
2210
- }
2211
- i = start + 1;
2212
- textStart = start;
2213
- continue;
2214
- }
2215
- if (charAfter === "_") {
2216
- i = afterSelectors + 1;
2217
- const nameStart = i;
2218
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2219
- const name = input.slice(nameStart, i);
2220
- if (input[i] === "}") {
2221
- i++;
2222
- const token = {
2223
- type: "variable",
2224
- name,
2225
- scope: "temporary",
2226
- start,
2227
- end: i
2228
- };
2229
- if (className) token.className = className;
2230
- if (id) token.id = id;
2231
- tokens.push(token);
2232
- textStart = i;
2233
- continue;
2234
- }
2235
- const closeIdx_ = scanBalancedBrace(input, nameStart - 1, memo);
2236
- if (closeIdx_ !== -1) {
2237
- const expression = input.slice(afterSelectors, closeIdx_);
2238
- i = closeIdx_ + 1;
2239
- const token = {
2240
- type: "expression",
2241
- expression,
2242
- start,
2243
- end: i
2244
- };
2245
- if (className) token.className = className;
2246
- if (id) token.id = id;
2247
- tokens.push(token);
2248
- textStart = i;
2249
- continue;
2250
- }
2251
- i = start + 1;
2252
- textStart = start;
2253
- continue;
2254
- }
2255
- if (charAfter === "@") {
2256
- i = afterSelectors + 1;
2257
- const nameStart = i;
2258
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2259
- const name = input.slice(nameStart, i);
2260
- if (input[i] === "}") {
2261
- i++;
2262
- const token = {
2263
- type: "variable",
2264
- name,
2265
- scope: "local",
2266
- start,
2267
- end: i
2268
- };
2269
- if (className) token.className = className;
2270
- if (id) token.id = id;
2271
- tokens.push(token);
2272
- textStart = i;
2273
- continue;
2274
- }
2275
- const closeIdx_at = scanBalancedBrace(input, nameStart - 1, memo);
2276
- if (closeIdx_at !== -1) {
2277
- const expression = input.slice(afterSelectors, closeIdx_at);
2278
- i = closeIdx_at + 1;
2279
- const token = {
2280
- type: "expression",
2281
- expression,
2282
- start,
2283
- end: i
2284
- };
2285
- if (className) token.className = className;
2286
- if (id) token.id = id;
2287
- tokens.push(token);
2288
- textStart = i;
2289
- continue;
2290
- }
2291
- i = start + 1;
2292
- textStart = start;
2293
- continue;
2294
- }
2295
- if (charAfter === "%") {
2296
- i = afterSelectors + 1;
2297
- const nameStart = i;
2298
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2299
- const name = input.slice(nameStart, i);
2300
- if (input[i] === "}") {
2301
- i++;
2302
- const token = {
2303
- type: "variable",
2304
- name,
2305
- scope: "transient",
2306
- start,
2307
- end: i
2308
- };
2309
- if (className) token.className = className;
2310
- if (id) token.id = id;
2311
- tokens.push(token);
2312
- textStart = i;
2313
- continue;
2314
- }
2315
- const closeIdx_pct = scanBalancedBrace(input, nameStart - 1, memo);
2316
- if (closeIdx_pct !== -1) {
2317
- const expression = input.slice(afterSelectors, closeIdx_pct);
2318
- i = closeIdx_pct + 1;
2319
- const token = {
2320
- type: "expression",
2321
- expression,
2322
- start,
2323
- end: i
2324
- };
2325
- if (className) token.className = className;
2326
- if (id) token.id = id;
2327
- tokens.push(token);
2328
- textStart = i;
2329
- continue;
2330
- }
2331
- i = start + 1;
2332
- textStart = start;
2333
- continue;
2334
- }
2335
- if (EXPRESSION_START.has(charAfter) && pushExpression(afterSelectors, start, false, className, id)) continue;
2336
- if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
2337
- const contentStart = afterSelectors;
2338
- const closeIdx = scanMacroClose(input, contentStart, memo);
2339
- if (closeIdx === -1) {
2340
- i = start + 1;
2341
- textStart = start;
2342
- continue;
2343
- }
2344
- const content = input.slice(contentStart, closeIdx);
2345
- i = closeIdx + 1;
2346
- const { name, rawArgs, isClose } = parseMacroContent(content);
2347
- const token = {
2348
- type: "macro",
2349
- name,
2350
- rawArgs,
2351
- isClose,
2352
- start,
2353
- end: i
2354
- };
2355
- if (className) token.className = className;
2356
- if (id) token.id = id;
2357
- tokens.push(token);
2358
- textStart = i;
2359
- consumeRawBody(name, isClose);
2360
- continue;
2361
- }
2362
- i = start + 1;
2363
- textStart = start;
2364
- continue;
2215
+ const { selectors, end: at } = parseSelectors(input, i + 1);
2216
+ const prefixed = at > i + 1;
2217
+ if (prefixed) flushText(start);
2218
+ const c = input[at];
2219
+ if (isSigil(c)) {
2220
+ flushText(start);
2221
+ if (pushVariable(at, start, selectors)) continue;
2222
+ } else if (EXPRESSION_START.has(c)) {
2223
+ if (pushExpression(at, start, selectors)) continue;
2224
+ } else if (c !== void 0 && (/[a-zA-Z]/.test(c) || c === "/" && !prefixed)) {
2225
+ flushText(start);
2226
+ if (pushMacro(at, start, selectors)) continue;
2365
2227
  }
2366
- if (nextChar === "$") {
2367
- flushText(i);
2368
- i += 2;
2369
- const nameStart = i;
2370
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2371
- const name = input.slice(nameStart, i);
2372
- if (input[i] === "}") {
2373
- i++;
2374
- tokens.push({
2375
- type: "variable",
2376
- name,
2377
- scope: "variable",
2378
- start,
2379
- end: i
2380
- });
2381
- textStart = i;
2382
- continue;
2383
- }
2384
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2385
- if (closeIdx !== -1) {
2386
- const expression = input.slice(start + 1, closeIdx);
2387
- i = closeIdx + 1;
2388
- tokens.push({
2389
- type: "expression",
2390
- expression,
2391
- start,
2392
- end: i
2393
- });
2394
- textStart = i;
2395
- continue;
2396
- }
2397
- i = start + 1;
2398
- textStart = start;
2399
- continue;
2400
- }
2401
- if (nextChar === "_") {
2402
- flushText(i);
2403
- i += 2;
2404
- const nameStart = i;
2405
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2406
- const name = input.slice(nameStart, i);
2407
- if (input[i] === "}") {
2408
- i++;
2409
- tokens.push({
2410
- type: "variable",
2411
- name,
2412
- scope: "temporary",
2413
- start,
2414
- end: i
2415
- });
2416
- textStart = i;
2417
- continue;
2418
- }
2419
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2420
- if (closeIdx !== -1) {
2421
- const expression = input.slice(start + 1, closeIdx);
2422
- i = closeIdx + 1;
2423
- tokens.push({
2424
- type: "expression",
2425
- expression,
2426
- start,
2427
- end: i
2428
- });
2429
- textStart = i;
2430
- continue;
2431
- }
2432
- i = start + 1;
2433
- textStart = start;
2434
- continue;
2435
- }
2436
- if (nextChar === "@") {
2437
- flushText(i);
2438
- i += 2;
2439
- const nameStart = i;
2440
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2441
- const name = input.slice(nameStart, i);
2442
- if (input[i] === "}") {
2443
- i++;
2444
- tokens.push({
2445
- type: "variable",
2446
- name,
2447
- scope: "local",
2448
- start,
2449
- end: i
2450
- });
2451
- textStart = i;
2452
- continue;
2453
- }
2454
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2455
- if (closeIdx !== -1) {
2456
- const expression = input.slice(start + 1, closeIdx);
2457
- i = closeIdx + 1;
2458
- tokens.push({
2459
- type: "expression",
2460
- expression,
2461
- start,
2462
- end: i
2463
- });
2464
- textStart = i;
2465
- continue;
2466
- }
2467
- i = start + 1;
2468
- textStart = start;
2469
- continue;
2470
- }
2471
- if (nextChar === "%") {
2472
- flushText(i);
2473
- i += 2;
2474
- const nameStart = i;
2475
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2476
- const name = input.slice(nameStart, i);
2477
- if (input[i] === "}") {
2478
- i++;
2479
- tokens.push({
2480
- type: "variable",
2481
- name,
2482
- scope: "transient",
2483
- start,
2484
- end: i
2485
- });
2486
- textStart = i;
2487
- continue;
2488
- }
2489
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2490
- if (closeIdx !== -1) {
2491
- const expression = input.slice(start + 1, closeIdx);
2492
- i = closeIdx + 1;
2493
- tokens.push({
2494
- type: "expression",
2495
- expression,
2496
- start,
2497
- end: i
2498
- });
2499
- textStart = i;
2500
- continue;
2501
- }
2502
- i = start + 1;
2503
- textStart = start;
2504
- continue;
2505
- }
2506
- if (EXPRESSION_START.has(nextChar)) {
2507
- if (pushExpression(i + 1, start, true)) continue;
2508
- i++;
2509
- continue;
2510
- }
2511
- if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
2512
- flushText(i);
2513
- const contentStart = i + 1;
2514
- const closeIdx = scanMacroClose(input, contentStart, memo);
2515
- if (closeIdx === -1) {
2516
- i = start + 1;
2517
- textStart = start;
2518
- continue;
2519
- }
2520
- const content = input.slice(contentStart, closeIdx);
2521
- i = closeIdx + 1;
2522
- const { name, rawArgs, isClose } = parseMacroContent(content);
2523
- tokens.push({
2524
- type: "macro",
2525
- name,
2526
- rawArgs,
2527
- isClose,
2528
- start,
2529
- end: i
2530
- });
2531
- textStart = i;
2532
- consumeRawBody(name, isClose);
2533
- continue;
2534
- }
2535
- i++;
2228
+ i = start + 1;
2536
2229
  continue;
2537
2230
  }
2538
2231
  if (!textMode && input[i] === "<") {
@@ -2550,11 +2243,10 @@ function tokenize(input, options = {}) {
2550
2243
  j++;
2551
2244
  flushText(start);
2552
2245
  if (HTML_VOID_TAGS.has(tagLower)) {
2553
- textStart = j;
2554
- i = j;
2246
+ i = textStart = j;
2555
2247
  continue;
2556
2248
  }
2557
- tokens.push({
2249
+ emit({
2558
2250
  type: "html",
2559
2251
  tag,
2560
2252
  attributes: {},
@@ -2563,8 +2255,6 @@ function tokenize(input, options = {}) {
2563
2255
  start,
2564
2256
  end: j
2565
2257
  });
2566
- textStart = j;
2567
- i = j;
2568
2258
  continue;
2569
2259
  }
2570
2260
  } else {
@@ -2578,7 +2268,7 @@ function tokenize(input, options = {}) {
2578
2268
  if (input[j] === ">") {
2579
2269
  j++;
2580
2270
  flushText(start);
2581
- tokens.push({
2271
+ emit({
2582
2272
  type: "html",
2583
2273
  tag,
2584
2274
  attributes: parseHtmlAttributes(input, attrsStart, memo).attributes,
@@ -2587,8 +2277,6 @@ function tokenize(input, options = {}) {
2587
2277
  start,
2588
2278
  end: j
2589
2279
  });
2590
- textStart = j;
2591
- i = j;
2592
2280
  continue;
2593
2281
  }
2594
2282
  }
@@ -2644,6 +2332,21 @@ var BRANCHING_BLOCK_MACROS = new Set([
2644
2332
  function quoteArg(value) {
2645
2333
  return `"${value.replace(/[\\"]/g, "\\$&")}"`;
2646
2334
  }
2335
+ /** A new branch for the macro token opening it, with its selectors. */
2336
+ function newBranch(token) {
2337
+ return withSelectors({
2338
+ rawArgs: token.rawArgs,
2339
+ children: []
2340
+ }, token);
2341
+ }
2342
+ /** The name of a macro or HTML element. */
2343
+ function tagName(tagged) {
2344
+ return tagged.type === "html" ? tagged.tag : tagged.name;
2345
+ }
2346
+ /** The tag closing a macro or HTML element, as written in markup. */
2347
+ function closerOf(tagged) {
2348
+ return tagged.type === "html" ? `</${tagged.tag}>` : `{/${tagged.name}}`;
2349
+ }
2647
2350
  /**
2648
2351
  * Build an AST from a token array. Block macros are nested into trees
2649
2352
  * using a stack. Throws on unclosed or mismatched macros.
@@ -2657,6 +2360,18 @@ function buildAST(tokens) {
2657
2360
  if (top.type === "macro" && top.branches && top.branches.length > 0) return top.branches[top.branches.length - 1].children;
2658
2361
  return top.children;
2659
2362
  }
2363
+ /**
2364
+ * Pop the node on top of the stack, which the closing `token` (`</b>`,
2365
+ * `{/if}`) must close. Names match ignoring case.
2366
+ */
2367
+ function close(token) {
2368
+ const found = closerOf(token);
2369
+ if (stack.length === 0) throw new Error(`Unexpected closing ${found} (at character ${token.start})`);
2370
+ const top = stack[stack.length - 1];
2371
+ if (top.node.type !== token.type || tagName(top.node).toLowerCase() !== tagName(token).toLowerCase()) throw new Error(`Expected ${closerOf(top.node)} but found ${found} (at character ${token.start})`);
2372
+ stack.pop();
2373
+ current().push(top.node);
2374
+ }
2660
2375
  for (const token of tokens) switch (token.type) {
2661
2376
  case "text":
2662
2377
  current().push({
@@ -2665,133 +2380,72 @@ function buildAST(tokens) {
2665
2380
  });
2666
2381
  break;
2667
2382
  case "link": {
2668
- const macroNode = {
2383
+ const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
2384
+ current().push(withSelectors({
2669
2385
  type: "macro",
2670
2386
  name: "link",
2671
- rawArgs: `${quoteArg(token.display)} ${quoteArg(token.target)}`,
2387
+ rawArgs,
2672
2388
  children: []
2673
- };
2674
- if (token.className) macroNode.className = token.className;
2675
- if (token.id) macroNode.id = token.id;
2676
- current().push(macroNode);
2389
+ }, token));
2677
2390
  break;
2678
2391
  }
2679
- case "variable": {
2680
- const varNode = {
2392
+ case "variable":
2393
+ current().push(withSelectors({
2681
2394
  type: "variable",
2682
2395
  name: token.name,
2683
2396
  scope: token.scope
2684
- };
2685
- if (token.className) varNode.className = token.className;
2686
- if (token.id) varNode.id = token.id;
2687
- current().push(varNode);
2397
+ }, token));
2688
2398
  break;
2689
- }
2690
- case "expression": {
2691
- const exprNode = {
2399
+ case "expression":
2400
+ current().push(withSelectors({
2692
2401
  type: "expression",
2693
2402
  expression: token.expression
2694
- };
2695
- if (token.className) exprNode.className = token.className;
2696
- if (token.id) exprNode.id = token.id;
2697
- current().push(exprNode);
2403
+ }, token));
2698
2404
  break;
2699
- }
2700
2405
  case "html": {
2701
- if (token.isSelfClose) {
2702
- current().push({
2703
- type: "html",
2704
- tag: token.tag,
2705
- attributes: token.attributes,
2706
- children: []
2707
- });
2708
- break;
2709
- }
2710
- if (token.isClose) {
2711
- if (stack.length === 0) throw new Error(`Unexpected closing </${token.tag}> (at character ${token.start})`);
2712
- const top = stack[stack.length - 1];
2713
- if (top.node.type !== "html" || top.node.tag.toLowerCase() !== token.tag.toLowerCase()) {
2714
- const expected = top.node.type === "html" ? `</${top.node.tag}>` : `{/${top.node.name}}`;
2715
- throw new Error(`Expected ${expected} but found </${token.tag}> (at character ${token.start})`);
2716
- }
2717
- stack.pop();
2718
- current().push(top.node);
2719
- break;
2720
- }
2721
2406
  const htmlNode = {
2722
2407
  type: "html",
2723
2408
  tag: token.tag,
2724
2409
  attributes: token.attributes,
2725
2410
  children: []
2726
2411
  };
2727
- stack.push({
2412
+ if (token.isSelfClose) current().push(htmlNode);
2413
+ else if (token.isClose) close(token);
2414
+ else stack.push({
2728
2415
  node: htmlNode,
2729
2416
  start: token.start
2730
2417
  });
2731
2418
  break;
2732
2419
  }
2733
2420
  case "macro": {
2734
- const name = token.name.toLowerCase();
2735
2421
  if (token.isClose) {
2736
- if (stack.length === 0) throw new Error(`Unexpected closing {/${token.name}} (at character ${token.start})`);
2737
- const top = stack[stack.length - 1];
2738
- if (top.node.type !== "macro" || top.node.name !== name) {
2739
- const expected = top.node.type === "macro" ? `{/${top.node.name}}` : `</${top.node.tag}>`;
2740
- throw new Error(`Expected ${expected} but found {/${token.name}} (at character ${token.start})`);
2741
- }
2742
- stack.pop();
2743
- current().push(top.node);
2422
+ close(token);
2744
2423
  break;
2745
2424
  }
2425
+ const name = token.name.toLowerCase();
2746
2426
  const expectedParent = Object.prototype.hasOwnProperty.call(BRANCH_PARENT, name) ? BRANCH_PARENT[name] : void 0;
2747
2427
  if (expectedParent) {
2748
2428
  const topNode = stack.length > 0 ? stack[stack.length - 1].node : null;
2749
2429
  if (!topNode || topNode.type !== "macro" || topNode.name !== expectedParent) throw new Error(`{${token.name}} without matching {${expectedParent}} (at character ${token.start})`);
2750
- const branch = {
2751
- rawArgs: token.rawArgs,
2752
- children: []
2753
- };
2754
- if (token.className) branch.className = token.className;
2755
- if (token.id) branch.id = token.id;
2756
- topNode.branches.push(branch);
2430
+ topNode.branches.push(newBranch(token));
2757
2431
  break;
2758
2432
  }
2759
- if (BLOCK_MACROS.has(name)) {
2760
- const node = {
2761
- type: "macro",
2762
- name,
2763
- rawArgs: token.rawArgs,
2764
- children: []
2765
- };
2766
- if (BRANCHING_BLOCK_MACROS.has(name)) {
2767
- const firstBranch = {
2768
- rawArgs: token.rawArgs,
2769
- children: []
2770
- };
2771
- if (token.className) firstBranch.className = token.className;
2772
- if (token.id) firstBranch.id = token.id;
2773
- node.branches = [firstBranch];
2774
- } else {
2775
- if (token.className) node.className = token.className;
2776
- if (token.id) node.id = token.id;
2777
- }
2778
- stack.push({
2779
- node,
2780
- start: token.start
2781
- });
2433
+ const node = {
2434
+ type: "macro",
2435
+ name,
2436
+ rawArgs: token.rawArgs,
2437
+ children: []
2438
+ };
2439
+ if (!BLOCK_MACROS.has(name)) {
2440
+ current().push(withSelectors(node, token));
2782
2441
  break;
2783
2442
  }
2784
- {
2785
- const macroNode = {
2786
- type: "macro",
2787
- name,
2788
- rawArgs: token.rawArgs,
2789
- children: []
2790
- };
2791
- if (token.className) macroNode.className = token.className;
2792
- if (token.id) macroNode.id = token.id;
2793
- current().push(macroNode);
2794
- }
2443
+ if (BRANCHING_BLOCK_MACROS.has(name)) node.branches = [newBranch(token)];
2444
+ else withSelectors(node, token);
2445
+ stack.push({
2446
+ node,
2447
+ start: token.start
2448
+ });
2795
2449
  break;
2796
2450
  }
2797
2451
  default: throw new Error(`Unknown token type: ${token.type}`);
@@ -2804,15 +2458,28 @@ function buildAST(tokens) {
2804
2458
  return root;
2805
2459
  }
2806
2460
  //#endregion
2461
+ //#region src/hooks/use-story-fields.ts
2462
+ /**
2463
+ * Story state fields `keys`, re-rendering when any of them changes (as one
2464
+ * useStoryStore() selector per field would). The result keeps its identity
2465
+ * while the fields don't change, as the store's snapshot must.
2466
+ */
2467
+ function useStoryFields(...keys) {
2468
+ const prev = useRef();
2469
+ return useStoryStore((s) => {
2470
+ const next = {};
2471
+ for (const key of keys) next[key] = s[key];
2472
+ return shallow(prev.current, next) ? prev.current : prev.current = next;
2473
+ });
2474
+ }
2475
+ //#endregion
2807
2476
  //#region src/hooks/use-merged-locals.ts
2808
2477
  /**
2809
2478
  * Return store variables, temporary, locals from context, and transient.
2810
2479
  * All four dicts use unprefixed keys suitable for evaluate/execute.
2811
2480
  */
2812
2481
  function useMergedLocals() {
2813
- const variables = useStoryStore((s) => s.variables);
2814
- const temporary = useStoryStore((s) => s.temporary);
2815
- const transient = useStoryStore((s) => s.transient);
2482
+ const { variables, temporary, transient } = useStoryFields("variables", "temporary", "transient");
2816
2483
  const localsValues = useContext(LocalsValuesContext);
2817
2484
  return useMemo(() => {
2818
2485
  return [
@@ -2957,21 +2624,38 @@ function getPRNGPull() {
2957
2624
  /** The variable name no namespace can hold. */
2958
2625
  var RESERVED_NAME = "__proto__";
2959
2626
  /**
2960
- * Throw a TypeError if `name` (without its sigil) cannot be a variable
2961
- * name. `label` is the name as the author wrote it (e.g. `$__proto__`).
2627
+ * Why `name` (without its sigil) cannot be a variable name, or undefined if
2628
+ * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
2962
2629
  */
2630
+ function variableNameError(name, label = name) {
2631
+ return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
2632
+ }
2633
+ /** Throw a TypeError if `name` cannot be a variable name. */
2963
2634
  function checkVariableName(name, label = name) {
2964
- if (name === "__proto__") throw new TypeError(`spindle: "${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
2635
+ const error = variableNameError(name, label);
2636
+ if (error) throw new TypeError(`spindle: ${error}`);
2965
2637
  }
2966
- /** A namespace with no prototype holding the own entries of `sources`. */
2967
- function createNamespace(...sources) {
2968
- const ns = Object.create(null);
2638
+ /** Whether `obj` holds `key` as an own property. */
2639
+ var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
2640
+ /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2641
+ var ownValue = (ns, key) => ns && hasOwn$2(ns, key) ? ns[key] : void 0;
2642
+ /**
2643
+ * A record with no prototype holding the own entries of `sources`, or only
2644
+ * those whose value passes `keep`.
2645
+ */
2646
+ function ownEntries(sources, keep = () => true) {
2647
+ const record = Object.create(null);
2969
2648
  for (const source of sources) {
2970
2649
  if (!source) continue;
2971
- for (const key of Object.keys(source)) ns[key] = source[key];
2650
+ for (const key of Object.keys(source)) {
2651
+ const value = source[key];
2652
+ if (keep(value)) record[key] = value;
2653
+ }
2972
2654
  }
2973
- return ns;
2655
+ return record;
2974
2656
  }
2657
+ /** A namespace with no prototype holding the own entries of `sources`. */
2658
+ var createNamespace = (...sources) => ownEntries(sources);
2975
2659
  /** Whether `value` is an object with no prototype. */
2976
2660
  var isNamespace = (value) => Object.getPrototypeOf(value) === null;
2977
2661
  /** `ns` itself if it has no prototype, else a namespace copy of it. */
@@ -2984,15 +2668,14 @@ function withEntry(ns, key, value) {
2984
2668
  }
2985
2669
  /** An empty namespace that cannot change. */
2986
2670
  var EMPTY_NAMESPACE = Object.freeze(createNamespace());
2987
- /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2988
- var ownValue = (ns, key) => Object.prototype.hasOwnProperty.call(ns, key) ? ns[key] : void 0;
2989
- //#endregion
2990
- //#region src/utils/counts.ts
2671
+ /** The names whose own entries differ between `a` and `b`, `a`'s first. */
2672
+ function changedNames(a, b) {
2673
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
2674
+ }
2991
2675
  var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
2992
2676
  /** The count of `name` in `counts`: its own entry, or 0. */
2993
2677
  function countOf(counts, name) {
2994
- if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
2995
- const value = counts[name];
2678
+ const value = ownValue(counts, name);
2996
2679
  return isCount(value) ? value : 0;
2997
2680
  }
2998
2681
  /**
@@ -3002,11 +2685,7 @@ function countOf(counts, name) {
3002
2685
  * more than counting in place.
3003
2686
  */
3004
2687
  function createCounts(source, name) {
3005
- const counts = Object.create(null);
3006
- if (source) for (const key of Object.keys(source)) {
3007
- const value = source[key];
3008
- if (isCount(value)) counts[key] = value;
3009
- }
2688
+ const counts = ownEntries([source], isCount);
3010
2689
  if (name !== void 0) counts[name] = countOf(source, name) + 1;
3011
2690
  return counts;
3012
2691
  }
@@ -3045,7 +2724,8 @@ function transform(expr, goal = "expression") {
3045
2724
  result += text;
3046
2725
  },
3047
2726
  variable(sigil, name) {
3048
- if (name === "__proto__") throw new SyntaxError(`spindle: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
2727
+ const error = variableNameError(name, sigil + name);
2728
+ if (error) throw new SyntaxError(`spindle: ${error}`);
3049
2729
  if (IDENT_END_RE.test(result.slice(-2))) result += " ";
3050
2730
  result += `${NAMESPACES$1[sigil]}["${name}"]`;
3051
2731
  }
@@ -3068,41 +2748,50 @@ function getOrCompile(key, body) {
3068
2748
  }
3069
2749
  return fn;
3070
2750
  }
2751
+ /** The passage being shown. */
2752
+ function currentPassage() {
2753
+ const s = useStoryStore.getState();
2754
+ return s.storyData?.passages.get(s.currentPassage);
2755
+ }
2756
+ /** The passage shown before the current one in history. */
2757
+ function previousPassage() {
2758
+ const s = useStoryStore.getState();
2759
+ if (s.historyIndex <= 0) return void 0;
2760
+ const prevName = s.history[s.historyIndex - 1]?.passage;
2761
+ return prevName ? s.storyData?.passages.get(prevName) : void 0;
2762
+ }
2763
+ /**
2764
+ * visited()/rendered() and their has-forms, counting in the counters
2765
+ * `counts()` returns. A missing name means the current passage.
2766
+ */
2767
+ function countQueries(counts) {
2768
+ const count = (name) => countOf(counts(), name ?? useStoryStore.getState().currentPassage);
2769
+ return {
2770
+ count,
2771
+ has: (name) => count(name) > 0,
2772
+ any: (...names) => names.some((n) => count(n) > 0),
2773
+ all: (...names) => names.every((n) => count(n) > 0)
2774
+ };
2775
+ }
3071
2776
  var cachedFns = null;
3072
2777
  var cachedVisitCounts = null;
3073
2778
  var cachedRenderCounts = null;
3074
2779
  function buildExpressionFns() {
3075
2780
  const { visitCounts, renderCounts } = useStoryStore.getState();
3076
2781
  if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
3077
- const visited = (name) => countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
3078
- const hasVisited = (name) => visited(name) > 0;
3079
- const hasVisitedAny = (...names) => names.some((n) => visited(n) > 0);
3080
- const hasVisitedAll = (...names) => names.every((n) => visited(n) > 0);
3081
- const rendered = (name) => countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
3082
- const hasRendered = (name) => rendered(name) > 0;
3083
- const hasRenderedAny = (...names) => names.some((n) => rendered(n) > 0);
3084
- const hasRenderedAll = (...names) => names.every((n) => rendered(n) > 0);
3085
- const currentPassage = () => {
3086
- const s = useStoryStore.getState();
3087
- return s.storyData?.passages.get(s.currentPassage);
3088
- };
3089
- const previousPassage = () => {
3090
- const s = useStoryStore.getState();
3091
- if (s.historyIndex <= 0) return void 0;
3092
- const prevName = s.history[s.historyIndex - 1]?.passage;
3093
- return prevName ? s.storyData?.passages.get(prevName) : void 0;
3094
- };
2782
+ const visits = countQueries(() => visitCounts);
2783
+ const renders = countQueries(() => renderCounts);
3095
2784
  cachedFns = {
3096
2785
  currentPassage,
3097
2786
  previousPassage,
3098
- visited,
3099
- hasVisited,
3100
- hasVisitedAny,
3101
- hasVisitedAll,
3102
- rendered,
3103
- hasRendered,
3104
- hasRenderedAny,
3105
- hasRenderedAll,
2787
+ visited: visits.count,
2788
+ hasVisited: visits.has,
2789
+ hasVisitedAny: visits.any,
2790
+ hasVisitedAll: visits.all,
2791
+ rendered: renders.count,
2792
+ hasRendered: renders.has,
2793
+ hasRenderedAny: renders.any,
2794
+ hasRenderedAll: renders.all,
3106
2795
  random,
3107
2796
  randomInt
3108
2797
  };
@@ -3427,12 +3116,6 @@ var MAX_DEPTH = 100;
3427
3116
  function display(value) {
3428
3117
  return value == null ? "" : String(value);
3429
3118
  }
3430
- var KIND_SIGILS = {
3431
- variable: "$",
3432
- temporary: "_",
3433
- local: "@",
3434
- transient: "%"
3435
- };
3436
3119
  /**
3437
3120
  * The value of a variable reference (`name` without its sigil, with an
3438
3121
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -3442,7 +3125,7 @@ var KIND_SIGILS = {
3442
3125
  function resolveVariable(scope, kind, name) {
3443
3126
  const parts = name.split(".");
3444
3127
  const root = parts[0];
3445
- checkVariableName(root, KIND_SIGILS[kind] + root);
3128
+ checkVariableName(root, SCOPE_SIGILS[kind] + root);
3446
3129
  let value = ownValue(kind === "variable" ? scope.variables : kind === "temporary" ? scope.temporary : kind === "transient" ? scope.transient : scope.locals, root);
3447
3130
  for (let i = 1; i < parts.length; i++) {
3448
3131
  if (value == null) return void 0;
@@ -3492,7 +3175,7 @@ var TextEvaluator = class {
3492
3175
  return display(resolveVariable(scope, kind, name));
3493
3176
  } catch (error) {
3494
3177
  this.errors.push({
3495
- macro: KIND_SIGILS[kind] + name,
3178
+ macro: SCOPE_SIGILS[kind] + name,
3496
3179
  error
3497
3180
  });
3498
3181
  return "";
@@ -3600,12 +3283,6 @@ function interpolateText(template, scope) {
3600
3283
  };
3601
3284
  return renderText(parsed.nodes, scope);
3602
3285
  }
3603
- var SIGIL_KINDS = {
3604
- $: "variable",
3605
- _: "temporary",
3606
- "@": "local",
3607
- "%": "transient"
3608
- };
3609
3286
  /**
3610
3287
  * Resolve the sigil references in the value of a code attribute (see
3611
3288
  * isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
@@ -3618,7 +3295,7 @@ function interpolateCode(template, scope, decode) {
3618
3295
  for (const part of splitSigilTemplate(template)) if ("text" in part) text += decode ? decode(part.text) : part.text;
3619
3296
  else if ("verbatim" in part) text += part.verbatim;
3620
3297
  else if (/^[$_@%][\w.]+$/.test(part.expr)) {
3621
- const kind = SIGIL_KINDS[part.expr[0]];
3298
+ const kind = SIGIL_SCOPES[part.expr[0]];
3622
3299
  try {
3623
3300
  text += display(resolveVariable(scope, kind, part.expr.slice(1)));
3624
3301
  } catch (error) {
@@ -3700,6 +3377,11 @@ var SIGILS = {
3700
3377
  local: "@",
3701
3378
  transient: "%"
3702
3379
  };
3380
+ var STORE_NAMESPACES = {
3381
+ variable: "variables",
3382
+ temporary: "temporary",
3383
+ transient: "transient"
3384
+ };
3703
3385
  function VarDisplay({ name, scope, className, id }) {
3704
3386
  const resolve = useInterpolate();
3705
3387
  className = resolve(className);
@@ -3707,10 +3389,11 @@ function VarDisplay({ name, scope, className, id }) {
3707
3389
  const localsValues = useContext(LocalsValuesContext);
3708
3390
  const parts = name.split(".");
3709
3391
  const root = parts[0];
3710
- const storeValue = useStoryStore((s) => scope === "variable" ? ownValue(s.variables, root) : scope === "temporary" ? ownValue(s.temporary, root) : scope === "transient" ? ownValue(s.transient, root) : void 0);
3711
- if (root === "__proto__") return /* @__PURE__ */ jsx("span", {
3392
+ const storeValue = useStoryStore((s) => scope === "local" ? void 0 : ownValue(s[STORE_NAMESPACES[scope]], root));
3393
+ const nameError = variableNameError(root, SIGILS[scope] + root);
3394
+ if (nameError) return /* @__PURE__ */ jsx("span", {
3712
3395
  class: "error",
3713
- children: `{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`
3396
+ children: `{${SIGILS[scope]}${name} error: ${nameError}}`
3714
3397
  });
3715
3398
  let value;
3716
3399
  if (scope === "local") value = ownValue(localsValues, root);
@@ -3737,10 +3420,7 @@ function ExprDisplay({ expression, className, id }) {
3737
3420
  const resolve = useInterpolate();
3738
3421
  className = resolve(className);
3739
3422
  id = resolve(id);
3740
- const localsValues = useContext(LocalsValuesContext);
3741
- const variables = useStoryStore((s) => s.variables);
3742
- const temporary = useStoryStore((s) => s.temporary);
3743
- const transient = useStoryStore((s) => s.transient);
3423
+ const [variables, temporary, localsValues, transient] = useMergedLocals();
3744
3424
  let display;
3745
3425
  try {
3746
3426
  const value = evaluate(expression, variables, temporary, localsValues, transient);
@@ -5049,10 +4729,14 @@ function on(event, cb) {
5049
4729
  set.delete(cb);
5050
4730
  };
5051
4731
  }
5052
- function emit(event, ...args) {
4732
+ /** Call `call` with each listener of `event`. */
4733
+ function forEachListener(event, call) {
5053
4734
  const set = listeners.get(event);
5054
4735
  if (!set) return;
5055
- for (const cb of [...set]) cb(...args);
4736
+ for (const cb of [...set]) call(cb);
4737
+ }
4738
+ function emit(event, ...args) {
4739
+ forEachListener(event, (cb) => cb(...args));
5056
4740
  }
5057
4741
  /**
5058
4742
  * Emit an event from inside a render commit (layout effect). A throwing
@@ -5060,13 +4744,13 @@ function emit(event, ...args) {
5060
4744
  * render; the remaining handlers still run.
5061
4745
  */
5062
4746
  function emitFromRender(event, ...args) {
5063
- const set = listeners.get(event);
5064
- if (!set) return;
5065
- for (const cb of [...set]) try {
5066
- cb(...args);
5067
- } catch (err) {
5068
- console.error(`spindle: Error in ${event} handler:`, err);
5069
- }
4747
+ forEachListener(event, (cb) => {
4748
+ try {
4749
+ cb(...args);
4750
+ } catch (err) {
4751
+ console.error(`spindle: Error in ${event} handler:`, err);
4752
+ }
4753
+ });
5070
4754
  }
5071
4755
  //#endregion
5072
4756
  //#region src/saves/save-manager.ts
@@ -5784,7 +5468,6 @@ function initializeStory(session) {
5784
5468
  }
5785
5469
  //#endregion
5786
5470
  //#region src/utils/object-path.ts
5787
- var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5788
5471
  /**
5789
5472
  * Traverse dot-path segments on an object and return the nested value.
5790
5473
  * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
@@ -7638,9 +7321,7 @@ function TriggerDialogHost() {
7638
7321
  //#endregion
7639
7322
  //#region src/components/App.tsx
7640
7323
  function App() {
7641
- const storyData = useStoryStore((s) => s.storyData);
7642
- const currentPassage = useStoryStore((s) => s.currentPassage);
7643
- const nobr = useStoryStore((s) => s.nobr);
7324
+ const { storyData, currentPassage, nobr } = useStoryFields("storyData", "currentPassage", "nobr");
7644
7325
  useEffect(() => {
7645
7326
  const onKeyDown = (e) => {
7646
7327
  const { quickSaveKey, quickLoadKey, save, load } = useStoryStore.getState();
@@ -7759,45 +7440,47 @@ function loadFromStorage() {
7759
7440
  }
7760
7441
  } catch {}
7761
7442
  }
7443
+ /** Register a setting, starting from its default unless it has a value. */
7444
+ function define(name, def) {
7445
+ definitions.set(name, def);
7446
+ if (!hasOwn$2(values, name)) values[name] = def.config.default;
7447
+ loadFromStorage();
7448
+ }
7449
+ /** The value of setting `name` if it has the type of `fallback`, else that. */
7450
+ function valueOf(name, fallback) {
7451
+ const v = ownValue(values, name);
7452
+ return typeof v === typeof fallback ? v : fallback;
7453
+ }
7762
7454
  var settings = {
7763
7455
  addToggle(name, config) {
7764
- definitions.set(name, {
7456
+ define(name, {
7765
7457
  type: "toggle",
7766
7458
  config
7767
7459
  });
7768
- if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7769
- loadFromStorage();
7770
7460
  },
7771
7461
  addList(name, config) {
7772
- definitions.set(name, {
7462
+ define(name, {
7773
7463
  type: "list",
7774
7464
  config
7775
7465
  });
7776
- if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7777
- loadFromStorage();
7778
7466
  },
7779
7467
  addRange(name, config) {
7780
- definitions.set(name, {
7468
+ define(name, {
7781
7469
  type: "range",
7782
7470
  config
7783
7471
  });
7784
- if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7785
- loadFromStorage();
7786
7472
  },
7787
7473
  get(name) {
7788
7474
  return ownValue(values, name);
7789
7475
  },
7790
7476
  getToggle(name) {
7791
- const v = ownValue(values, name);
7792
- return typeof v === "boolean" ? v : false;
7477
+ return valueOf(name, false);
7793
7478
  },
7794
7479
  getList(name) {
7795
- const v = ownValue(values, name);
7796
- return typeof v === "string" ? v : "";
7480
+ return valueOf(name, "");
7797
7481
  },
7798
7482
  getRange(name) {
7799
- const v = ownValue(values, name);
7800
- return typeof v === "number" ? v : 0;
7483
+ return valueOf(name, 0);
7801
7484
  },
7802
7485
  set(name, value) {
7803
7486
  values[name] = value;
@@ -8096,26 +7779,17 @@ function ensureVariableChangedSubscription() {
8096
7779
  let prevTrans = useStoryStore.getState().transient;
8097
7780
  useStoryStore.subscribe((state) => {
8098
7781
  const changed = {};
8099
- let hasChanges = false;
8100
- const allVarKeys = new Set([...Object.keys(prevVars), ...Object.keys(state.variables)]);
8101
- for (const key of allVarKeys) if (state.variables[key] !== prevVars[key]) {
8102
- changed[key] = {
8103
- from: prevVars[key],
8104
- to: state.variables[key]
7782
+ const collect = (prev, next, prefix) => {
7783
+ for (const key of changedNames(prev, next)) changed[prefix + key] = {
7784
+ from: ownValue(prev, key),
7785
+ to: ownValue(next, key)
8105
7786
  };
8106
- hasChanges = true;
8107
- }
8108
- const allTransKeys = new Set([...Object.keys(prevTrans), ...Object.keys(state.transient)]);
8109
- for (const key of allTransKeys) if (state.transient[key] !== prevTrans[key]) {
8110
- changed[`%${key}`] = {
8111
- from: prevTrans[key],
8112
- to: state.transient[key]
8113
- };
8114
- hasChanges = true;
8115
- }
7787
+ };
7788
+ collect(prevVars, state.variables, "");
7789
+ collect(prevTrans, state.transient, "%");
8116
7790
  prevVars = state.variables;
8117
7791
  prevTrans = state.transient;
8118
- if (hasChanges) emit("variableChanged", changed);
7792
+ if (Object.keys(changed).length > 0) emit("variableChanged", changed);
8119
7793
  });
8120
7794
  }
8121
7795
  var declaredVariables = null;
@@ -8160,13 +7834,15 @@ function setOne(draft, name, value) {
8160
7834
  else namespace[key] = value;
8161
7835
  }
8162
7836
  function createStoryAPI() {
7837
+ const visits = countQueries(() => useStoryStore.getState().visitCounts);
7838
+ const renders = countQueries(() => useStoryStore.getState().renderCounts);
8163
7839
  return {
8164
7840
  get(name) {
8165
7841
  const { isTransient, key } = parseName(name);
8166
7842
  const scope = getActiveMutationScope();
8167
7843
  const source = scope ?? useStoryStore.getState();
8168
7844
  const namespace = isTransient ? source.transient : source.variables;
8169
- const value = key.includes(".") ? getByPath(namespace, key.split(".")) : namespace[key];
7845
+ const value = key.includes(".") ? getByPath(namespace, key.split(".")) : ownValue(namespace, key);
8170
7846
  return scope ? frozenCopy(value) : value;
8171
7847
  },
8172
7848
  set(nameOrVars, value) {
@@ -8215,46 +7891,16 @@ function createStoryAPI() {
8215
7891
  importSave(data, slot) {
8216
7892
  return useStoryStore.getState().importSave(data, slot);
8217
7893
  },
8218
- visited(name) {
8219
- const state = useStoryStore.getState();
8220
- return countOf(state.visitCounts, name ?? state.currentPassage);
8221
- },
8222
- hasVisited(name) {
8223
- return this.visited(name) > 0;
8224
- },
8225
- hasVisitedAny(...names) {
8226
- const { visitCounts } = useStoryStore.getState();
8227
- return names.some((n) => countOf(visitCounts, n) > 0);
8228
- },
8229
- hasVisitedAll(...names) {
8230
- const { visitCounts } = useStoryStore.getState();
8231
- return names.every((n) => countOf(visitCounts, n) > 0);
8232
- },
8233
- rendered(name) {
8234
- const state = useStoryStore.getState();
8235
- return countOf(state.renderCounts, name ?? state.currentPassage);
8236
- },
8237
- hasRendered(name) {
8238
- return this.rendered(name) > 0;
8239
- },
8240
- hasRenderedAny(...names) {
8241
- const { renderCounts } = useStoryStore.getState();
8242
- return names.some((n) => countOf(renderCounts, n) > 0);
8243
- },
8244
- hasRenderedAll(...names) {
8245
- const { renderCounts } = useStoryStore.getState();
8246
- return names.every((n) => countOf(renderCounts, n) > 0);
8247
- },
8248
- currentPassage() {
8249
- const state = useStoryStore.getState();
8250
- return state.storyData?.passages.get(state.currentPassage);
8251
- },
8252
- previousPassage() {
8253
- const state = useStoryStore.getState();
8254
- if (state.historyIndex <= 0) return void 0;
8255
- const prevName = state.history[state.historyIndex - 1]?.passage;
8256
- return prevName ? state.storyData?.passages.get(prevName) : void 0;
8257
- },
7894
+ visited: visits.count,
7895
+ hasVisited: visits.has,
7896
+ hasVisitedAny: visits.any,
7897
+ hasVisitedAll: visits.all,
7898
+ rendered: renders.count,
7899
+ hasRendered: renders.has,
7900
+ hasRenderedAny: renders.any,
7901
+ hasRenderedAll: renders.all,
7902
+ currentPassage,
7903
+ previousPassage,
8258
7904
  get title() {
8259
7905
  return useStoryStore.getState().storyData?.name || "";
8260
7906
  },
@@ -8492,7 +8138,8 @@ function parseStoryVariables(content, sigil = "$") {
8492
8138
  const match = line.match(DECLARATION_RE);
8493
8139
  if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
8494
8140
  const [, name, expr] = match;
8495
- if (name === "__proto__") throw new Error(`${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`);
8141
+ const nameError = variableNameError(name, sigil + name);
8142
+ if (nameError) throw new Error(`${passageName}: ${nameError}`);
8496
8143
  let value;
8497
8144
  try {
8498
8145
  value = new Function("return (" + expr + ")")();
@@ -8535,7 +8182,8 @@ function extractForLocals(content) {
8535
8182
  function validateRef(ref, schema, forLocals) {
8536
8183
  const parts = ref.split(".");
8537
8184
  const rootName = parts[0];
8538
- if (rootName === "__proto__") return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
8185
+ const nameError = variableNameError(rootName, "$" + rootName);
8186
+ if (nameError) return nameError;
8539
8187
  if (forLocals.has(rootName)) return null;
8540
8188
  const rootSchema = schema.get(rootName);
8541
8189
  if (!rootSchema) return `Undeclared variable: $${ref}`;
@@ -8713,6 +8361,56 @@ function astContainsChildren(nodes) {
8713
8361
  return false;
8714
8362
  }
8715
8363
  //#endregion
8364
+ //#region src/components/macros/Widget.tsx
8365
+ /**
8366
+ * Read the arguments of a {widget} definition: its name (quotes dropped)
8367
+ * and its parameters, the words after it that start with one of the
8368
+ * characters of `sigils`.
8369
+ */
8370
+ function parseWidgetDef(rawArgs, sigils = "@") {
8371
+ const [first, ...rest] = rawArgs.trim().split(/\s+/);
8372
+ return {
8373
+ name: first.replace(/["']/g, ""),
8374
+ params: rest.filter((word) => sigils.includes(word[0]))
8375
+ };
8376
+ }
8377
+ defineMacro({
8378
+ name: "widget",
8379
+ block: true,
8380
+ render({ rawArgs, children = [] }, ctx) {
8381
+ let parsed;
8382
+ let error;
8383
+ try {
8384
+ const def = parseWidgetDef(rawArgs);
8385
+ for (const param of def.params) checkVariableName(param.slice(1), param);
8386
+ parsed = def;
8387
+ } catch (err) {
8388
+ error = err;
8389
+ }
8390
+ const { name, params } = parsed ?? {
8391
+ name: "",
8392
+ params: []
8393
+ };
8394
+ const childrenKey = JSON.stringify(children);
8395
+ const paramsKey = params.join(",");
8396
+ ctx.hooks.useLayoutEffect(() => {
8397
+ if (!parsed) return;
8398
+ const isBlock = astContainsChildren(children);
8399
+ registerWidget(name, children, params, isBlock);
8400
+ if (isBlock) registerBlockMacro(name);
8401
+ }, [
8402
+ name,
8403
+ childrenKey,
8404
+ paramsKey
8405
+ ]);
8406
+ if (error) return /* @__PURE__ */ jsx(MacroError, {
8407
+ macro: "widget",
8408
+ error
8409
+ });
8410
+ return null;
8411
+ }
8412
+ });
8413
+ //#endregion
8716
8414
  //#region src/components/macros/Set.tsx
8717
8415
  defineMacro({
8718
8416
  name: "set",
@@ -9159,7 +8857,7 @@ defineMacro({
9159
8857
  function liveLocalsView(getValues) {
9160
8858
  return new Proxy(createNamespace(), {
9161
8859
  get: (_, key) => typeof key === "string" ? ownValue(getValues(), key) : void 0,
9162
- has: (_, key) => typeof key === "string" && Object.prototype.hasOwnProperty.call(getValues(), key),
8860
+ has: (_, key) => typeof key === "string" && hasOwn$2(getValues(), key),
9163
8861
  ownKeys: () => Reflect.ownKeys(getValues()),
9164
8862
  getOwnPropertyDescriptor: (_, key) => {
9165
8863
  const desc = Object.getOwnPropertyDescriptor(getValues(), key);
@@ -9507,52 +9205,6 @@ defineMacro({
9507
9205
  }
9508
9206
  });
9509
9207
  //#endregion
9510
- //#region src/components/macros/Widget.tsx
9511
- function parseWidgetDef(rawArgs) {
9512
- const tokens = rawArgs.trim().split(/\s+/);
9513
- const name = tokens[0].replace(/["']/g, "");
9514
- const params = tokens.slice(1).filter((t) => t.startsWith("@"));
9515
- for (const param of params) checkVariableName(param.slice(1), param);
9516
- return {
9517
- name,
9518
- params
9519
- };
9520
- }
9521
- defineMacro({
9522
- name: "widget",
9523
- block: true,
9524
- render({ rawArgs, children = [] }, ctx) {
9525
- let parsed;
9526
- let error;
9527
- try {
9528
- parsed = parseWidgetDef(rawArgs);
9529
- } catch (err) {
9530
- error = err;
9531
- }
9532
- const { name, params } = parsed ?? {
9533
- name: "",
9534
- params: []
9535
- };
9536
- const childrenKey = JSON.stringify(children);
9537
- const paramsKey = params.join(",");
9538
- ctx.hooks.useLayoutEffect(() => {
9539
- if (!parsed) return;
9540
- const isBlock = astContainsChildren(children);
9541
- registerWidget(name, children, params, isBlock);
9542
- if (isBlock) registerBlockMacro(name);
9543
- }, [
9544
- name,
9545
- childrenKey,
9546
- paramsKey
9547
- ]);
9548
- if (error) return /* @__PURE__ */ jsx(MacroError, {
9549
- macro: "widget",
9550
- error
9551
- });
9552
- return null;
9553
- }
9554
- });
9555
- //#endregion
9556
9208
  //#region src/components/macros/StoryTitle.tsx
9557
9209
  defineMacro({
9558
9210
  name: "story-title",
@@ -9631,10 +9283,13 @@ defineMenubarAction({
9631
9283
  name: "quicksave",
9632
9284
  label: "QuickSave",
9633
9285
  actionType: "save",
9634
- setup: () => ({
9635
- perform: useStoryStore((s) => s.save),
9636
- title: hotkeyTitle("Quick Save", useStoryStore((s) => s.quickSaveKey))
9637
- })
9286
+ setup: () => {
9287
+ const { save, quickSaveKey } = useStoryFields("save", "quickSaveKey");
9288
+ return {
9289
+ perform: save,
9290
+ title: hotkeyTitle("Quick Save", quickSaveKey)
9291
+ };
9292
+ }
9638
9293
  });
9639
9294
  defineMenubarAction({
9640
9295
  name: "quickload",
@@ -9642,14 +9297,11 @@ defineMenubarAction({
9642
9297
  actionType: "load",
9643
9298
  confirm: "Load saved game? Current progress will be lost.",
9644
9299
  setup: () => {
9645
- const load = useStoryStore((s) => s.load);
9646
- const hasSave = useStoryStore((s) => s.hasSave);
9647
- const key = useStoryStore((s) => s.quickLoadKey);
9648
- useStoryStore((s) => s.knownSaves);
9300
+ const { load, hasSave, quickLoadKey } = useStoryFields("load", "hasSave", "quickLoadKey", "knownSaves");
9649
9301
  return {
9650
9302
  perform: () => load(),
9651
9303
  disabled: !hasSave(),
9652
- title: hotkeyTitle("Quick Load", key)
9304
+ title: hotkeyTitle("Quick Load", quickLoadKey)
9653
9305
  };
9654
9306
  }
9655
9307
  });
@@ -9737,10 +9389,7 @@ function SaveManagerContent() {
9737
9389
  const renameInputRef = useRef(null);
9738
9390
  const fileInputRef = useRef(null);
9739
9391
  const closeDialog = useContext(DialogCloseContext);
9740
- const storyData = useStoryStore((s) => s.storyData);
9741
- const playthroughId = useStoryStore((s) => s.playthroughId);
9742
- const beginSave = useStoryStore((s) => s.beginSave);
9743
- const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
9392
+ const { storyData, playthroughId, beginSave, loadFromPayload } = useStoryFields("storyData", "playthroughId", "beginSave", "loadFromPayload");
9744
9393
  const ifid = storyData?.ifid ?? "";
9745
9394
  const refresh = useCallback(async () => {
9746
9395
  if (!ifid) return;
@@ -10680,10 +10329,7 @@ defineMacro({
10680
10329
  name: "passage",
10681
10330
  interpolate: true,
10682
10331
  render(_props, ctx) {
10683
- const currentPassage = useStoryStore((s) => s.currentPassage);
10684
- const navigationId = useStoryStore((s) => s.navigationId);
10685
- const storyData = useStoryStore((s) => s.storyData);
10686
- const renderDeferred = useStoryStore((s) => s.renderDeferred);
10332
+ const { currentPassage, navigationId, storyData, renderDeferred } = useStoryFields("currentPassage", "navigationId", "storyData", "renderDeferred");
10687
10333
  const [displayed, setDisplayed] = useState({
10688
10334
  passage: currentPassage,
10689
10335
  id: navigationId
@@ -11004,9 +10650,7 @@ function boot() {
11004
10650
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
11005
10651
  const widgetAST = buildAST(tokenize(passage.content));
11006
10652
  for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
11007
- const tokens2 = node.rawArgs.trim().split(/\s+/);
11008
- const widgetName = tokens2[0].replace(/["']/g, "");
11009
- const params = tokens2.slice(1).filter((t) => t.startsWith("$") || t.startsWith("_") || t.startsWith("@"));
10653
+ const { name: widgetName, params } = parseWidgetDef(node.rawArgs, "$_@");
11010
10654
  const children = node.children;
11011
10655
  const isBlock = astContainsChildren(children);
11012
10656
  try {