@rohal12/spindle 0.52.3 → 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.
@@ -486,13 +486,13 @@ function scanRegex(src, start, cache) {
486
486
  };
487
487
  let i = start + 1;
488
488
  while (i < src.length) {
489
+ const lineEnd = regexLineEnd(src, i);
490
+ if (lineEnd !== -1) return done(lineEnd, false);
489
491
  const c = src.charAt(i);
490
492
  if (c === "\\") {
491
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
492
493
  i += 2;
493
494
  continue;
494
495
  }
495
- if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
496
496
  if (c === "[") {
497
497
  i = scanRegexClass(src, i, cache);
498
498
  if (src.charAt(i) !== "]") return done(i, false);
@@ -523,16 +523,17 @@ function scanRegexClass(src, open, cache) {
523
523
  const opens = [open];
524
524
  let i = open + 1;
525
525
  while (i < src.length) {
526
+ const lineEnd = regexLineEnd(src, i);
527
+ if (lineEnd !== -1) {
528
+ i = lineEnd;
529
+ break;
530
+ }
526
531
  const c = src.charAt(i);
527
532
  if (c === "\\") {
528
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
529
- i++;
530
- break;
531
- }
532
533
  i += 2;
533
534
  continue;
534
535
  }
535
- if (c === "]" || LINE_TERMINATOR_RE.test(c)) break;
536
+ if (c === "]") break;
536
537
  if (c === "[" && cache) opens.push(i);
537
538
  i++;
538
539
  }
@@ -541,6 +542,16 @@ function scanRegexClass(src, open, cache) {
541
542
  return i;
542
543
  }
543
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
+ /**
544
555
  * The next index from `from` on where `find` matches (-1 for none), for
545
556
  * searches from increasing positions: a search from within the stretch the
546
557
  * last one covered has the same answer.
@@ -1490,8 +1501,7 @@ function splitSigilTemplate(template) {
1490
1501
  }
1491
1502
  text += template.slice(i, brace);
1492
1503
  i = brace + 1;
1493
- const sigil = template[i];
1494
- const end = sigil === "$" || sigil === "_" || sigil === "@" || sigil === "%" ? scanBalancedBrace(template, i, memo) : -1;
1504
+ const end = isSigil(template[i]) ? scanBalancedBrace(template, i, memo) : -1;
1495
1505
  if (end === -1) {
1496
1506
  text += "{";
1497
1507
  continue;
@@ -1507,6 +1517,26 @@ function splitSigilTemplate(template) {
1507
1517
  }
1508
1518
  //#endregion
1509
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
+ }
1510
1540
  /** Tag name must start with a letter (covers standard and custom elements). */
1511
1541
  var VALID_TAG_START = /[a-zA-Z]/;
1512
1542
  /** HTML void elements: never have children or a closing tag. */
@@ -1526,13 +1556,6 @@ var HTML_VOID_TAGS = new Set([
1526
1556
  "track",
1527
1557
  "wbr"
1528
1558
  ]);
1529
- /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
1530
- var SIGIL_CHARS = new Set([
1531
- "$",
1532
- "_",
1533
- "@",
1534
- "%"
1535
- ]);
1536
1559
  /**
1537
1560
  * Characters other than a sigil that open an expression after `{`:
1538
1561
  * `{(Math.max($a, 0))}`, `{!$done}`. Other characters that can start an
@@ -1543,25 +1566,32 @@ var EXPRESSION_START = new Set(["(", "!"]);
1543
1566
  /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
1544
1567
  var RAW_BODY_MACROS = new Set(["do"]);
1545
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
+ /**
1546
1578
  * Parse a Twine link interior into display and target.
1547
1579
  * Supports: display|target, display->target, target<-display, plain
1548
1580
  */
1549
1581
  function parseLink(inner) {
1550
- const pipeIdx = inner.indexOf("|");
1551
- if (pipeIdx !== -1) return {
1552
- display: inner.slice(0, pipeIdx).trim(),
1553
- target: inner.slice(pipeIdx + 1).trim()
1554
- };
1555
- const arrowIdx = inner.indexOf("->");
1556
- if (arrowIdx !== -1) return {
1557
- display: inner.slice(0, arrowIdx).trim(),
1558
- target: inner.slice(arrowIdx + 2).trim()
1559
- };
1560
- const revIdx = inner.indexOf("<-");
1561
- if (revIdx !== -1) return {
1562
- target: inner.slice(0, revIdx).trim(),
1563
- display: inner.slice(revIdx + 2).trim()
1564
- };
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
+ }
1565
1595
  const trimmed = inner.trim();
1566
1596
  return {
1567
1597
  display: trimmed,
@@ -1592,8 +1622,10 @@ function parseMacroContent(content) {
1592
1622
  }
1593
1623
  /**
1594
1624
  * Parse CSS selectors: .foo.bar#baz → { className: "foo bar", id: "baz" }
1595
- * Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order.
1596
- * 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.
1597
1629
  */
1598
1630
  function parseSelectors(input, startIdx) {
1599
1631
  const classes = [];
@@ -1606,7 +1638,7 @@ function parseSelectors(input, startIdx) {
1606
1638
  while (i < input.length) if (/[a-zA-Z0-9_-]/.test(input[i])) {
1607
1639
  name += input[i];
1608
1640
  i++;
1609
- } else if (input[i] === "{" && SIGIL_CHARS.has(input[i + 1])) {
1641
+ } else if (input[i] === "{" && isSigil(input[i + 1])) {
1610
1642
  const braceStart = i;
1611
1643
  i += 2;
1612
1644
  while (i < input.length && /[\w.]/.test(input[i])) i++;
@@ -1621,10 +1653,13 @@ function parseSelectors(input, startIdx) {
1621
1653
  if (name) if (prefix === ".") classes.push(name);
1622
1654
  else id = name;
1623
1655
  }
1656
+ if (i > startIdx && input[i] === " ") i++;
1624
1657
  return {
1625
- className: classes.join(" "),
1626
- id,
1627
- endIdx: i
1658
+ selectors: withSelectors({}, {
1659
+ className: classes.join(" "),
1660
+ id
1661
+ }),
1662
+ end: i
1628
1663
  };
1629
1664
  }
1630
1665
  /**
@@ -1739,7 +1774,7 @@ function scanQuotedValue(input, j, quote, code, memo) {
1739
1774
  continue;
1740
1775
  }
1741
1776
  if (input[j] === "{") {
1742
- 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;
1743
1778
  if (closeIdx !== -1) {
1744
1779
  j = closeIdx + 1;
1745
1780
  checkpoint = true;
@@ -1815,12 +1850,7 @@ function scanMacroClose(input, contentStart, memo) {
1815
1850
  * code from just past the {. Returns -1 if it is unclosed.
1816
1851
  */
1817
1852
  function scanBlockClose(input, open, memo) {
1818
- let at = open + 1;
1819
- const c = input[at];
1820
- if (c === "." || c === "#") {
1821
- at = parseSelectors(input, at).endIdx;
1822
- if (input[at] === " ") at++;
1823
- }
1853
+ const at = parseSelectors(input, open + 1).end;
1824
1854
  const first = input[at];
1825
1855
  if (first !== void 0 && (first === "/" || /[a-zA-Z]/.test(first))) return scanMacroClose(input, at, memo);
1826
1856
  return scanBalancedBrace(input, at, memo);
@@ -2012,13 +2042,22 @@ function tokenize(input, options = {}) {
2012
2042
  const memo = createScanMemo();
2013
2043
  let i = 0;
2014
2044
  let textStart = 0;
2045
+ /** Push a text token for the text not yet tokenized before `end`. */
2015
2046
  function flushText(end) {
2016
- if (end > textStart) tokens.push({
2017
- type: "text",
2018
- value: input.slice(textStart, end),
2019
- start: textStart,
2020
- end
2021
- });
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;
2022
2061
  }
2023
2062
  /**
2024
2063
  * After an opening raw-body macro ({do}), emit everything up to its
@@ -2060,34 +2099,66 @@ function tokenize(input, options = {}) {
2060
2099
  atRe.lastIndex = closeStart;
2061
2100
  const closeEnd = closeStart + atRe.exec(input)[0].length;
2062
2101
  flushText(closeStart);
2063
- tokens.push({
2102
+ emit({
2064
2103
  type: "macro",
2065
2104
  ...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
2066
2105
  start: closeStart,
2067
2106
  end: closeEnd
2068
2107
  });
2069
- i = closeEnd;
2070
- textStart = closeEnd;
2071
2108
  }
2072
2109
  /**
2073
2110
  * Push an expression token for the `{…}` block opened at `start` whose
2074
- * expression starts at `exprStart`, flushing the text before it first
2075
- * 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.
2076
2113
  */
2077
- function pushExpression(exprStart, start, flush, className, id) {
2114
+ function pushExpression(exprStart, start, selectors) {
2078
2115
  const closeIdx = scanBalancedBrace(input, exprStart, memo);
2079
2116
  if (closeIdx === -1) return false;
2080
- if (flush) flushText(start);
2081
- const token = {
2117
+ flushText(start);
2118
+ emit(withSelectors({
2082
2119
  type: "expression",
2083
2120
  expression: input.slice(exprStart, closeIdx),
2084
2121
  start,
2085
2122
  end: closeIdx + 1
2086
- };
2087
- if (className) token.className = className;
2088
- if (id) token.id = id;
2089
- tokens.push(token);
2090
- 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);
2091
2162
  return true;
2092
2163
  }
2093
2164
  while (i < input.length) {
@@ -2111,14 +2182,12 @@ function tokenize(input, options = {}) {
2111
2182
  }
2112
2183
  if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
2113
2184
  flushText(k - 1);
2114
- tokens.push({
2185
+ emit({
2115
2186
  type: "text",
2116
2187
  value: next,
2117
2188
  start: k - 1,
2118
2189
  end: k + 1
2119
2190
  });
2120
- i = k + 1;
2121
- textStart = i;
2122
2191
  continue;
2123
2192
  }
2124
2193
  i = k;
@@ -2127,413 +2196,36 @@ function tokenize(input, options = {}) {
2127
2196
  if (!textMode && input[i] === "[" && input[i + 1] === "[") {
2128
2197
  flushText(i);
2129
2198
  const start = i;
2130
- i += 2;
2131
- let className;
2132
- let id;
2133
- if (input[i] === "." || input[i] === "#") {
2134
- const parsed = parseSelectors(input, i);
2135
- className = parsed.className || void 0;
2136
- id = parsed.id || void 0;
2137
- i = parsed.endIdx;
2138
- if (input[i] === " ") i++;
2139
- }
2140
- const innerStart = i;
2199
+ const { selectors, end: innerStart } = parseSelectors(input, i + 2);
2141
2200
  const closeIdx = scanLinkClose(input, innerStart, memo);
2142
2201
  if (closeIdx === -1) {
2143
2202
  i = start + 2;
2144
- textStart = start;
2145
2203
  continue;
2146
2204
  }
2147
- const inner = input.slice(innerStart, closeIdx);
2148
- i = closeIdx + 2;
2149
- const { display, target } = parseLink(inner);
2150
- const linkToken = {
2205
+ emit(withSelectors({
2151
2206
  type: "link",
2152
- display,
2153
- target,
2207
+ ...parseLink(input.slice(innerStart, closeIdx)),
2154
2208
  start,
2155
- end: i
2156
- };
2157
- if (className) linkToken.className = className;
2158
- if (id) linkToken.id = id;
2159
- tokens.push(linkToken);
2160
- textStart = i;
2209
+ end: closeIdx + 2
2210
+ }, selectors));
2161
2211
  continue;
2162
2212
  }
2163
2213
  if (input[i] === "{") {
2164
2214
  const start = i;
2165
- let nextChar = input[i + 1];
2166
- let className;
2167
- let id;
2168
- if (nextChar === "." || nextChar === "#") {
2169
- flushText(i);
2170
- const parsed = parseSelectors(input, i + 1);
2171
- className = parsed.className || void 0;
2172
- id = parsed.id || void 0;
2173
- let afterSelectors = parsed.endIdx;
2174
- if (input[afterSelectors] === " ") afterSelectors++;
2175
- const charAfter = input[afterSelectors];
2176
- if (charAfter === "$") {
2177
- i = afterSelectors + 1;
2178
- const nameStart = i;
2179
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2180
- const name = input.slice(nameStart, i);
2181
- if (input[i] === "}") {
2182
- i++;
2183
- const token = {
2184
- type: "variable",
2185
- name,
2186
- scope: "variable",
2187
- start,
2188
- end: i
2189
- };
2190
- if (className) token.className = className;
2191
- if (id) token.id = id;
2192
- tokens.push(token);
2193
- textStart = i;
2194
- continue;
2195
- }
2196
- const closeIdx$ = scanBalancedBrace(input, nameStart - 1, memo);
2197
- if (closeIdx$ !== -1) {
2198
- const expression = input.slice(afterSelectors, closeIdx$);
2199
- i = closeIdx$ + 1;
2200
- const token = {
2201
- type: "expression",
2202
- expression,
2203
- start,
2204
- end: i
2205
- };
2206
- if (className) token.className = className;
2207
- if (id) token.id = id;
2208
- tokens.push(token);
2209
- textStart = i;
2210
- continue;
2211
- }
2212
- i = start + 1;
2213
- textStart = start;
2214
- continue;
2215
- }
2216
- if (charAfter === "_") {
2217
- i = afterSelectors + 1;
2218
- const nameStart = i;
2219
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2220
- const name = input.slice(nameStart, i);
2221
- if (input[i] === "}") {
2222
- i++;
2223
- const token = {
2224
- type: "variable",
2225
- name,
2226
- scope: "temporary",
2227
- start,
2228
- end: i
2229
- };
2230
- if (className) token.className = className;
2231
- if (id) token.id = id;
2232
- tokens.push(token);
2233
- textStart = i;
2234
- continue;
2235
- }
2236
- const closeIdx_ = scanBalancedBrace(input, nameStart - 1, memo);
2237
- if (closeIdx_ !== -1) {
2238
- const expression = input.slice(afterSelectors, closeIdx_);
2239
- i = closeIdx_ + 1;
2240
- const token = {
2241
- type: "expression",
2242
- expression,
2243
- start,
2244
- end: i
2245
- };
2246
- if (className) token.className = className;
2247
- if (id) token.id = id;
2248
- tokens.push(token);
2249
- textStart = i;
2250
- continue;
2251
- }
2252
- i = start + 1;
2253
- textStart = start;
2254
- continue;
2255
- }
2256
- if (charAfter === "@") {
2257
- i = afterSelectors + 1;
2258
- const nameStart = i;
2259
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2260
- const name = input.slice(nameStart, i);
2261
- if (input[i] === "}") {
2262
- i++;
2263
- const token = {
2264
- type: "variable",
2265
- name,
2266
- scope: "local",
2267
- start,
2268
- end: i
2269
- };
2270
- if (className) token.className = className;
2271
- if (id) token.id = id;
2272
- tokens.push(token);
2273
- textStart = i;
2274
- continue;
2275
- }
2276
- const closeIdx_at = scanBalancedBrace(input, nameStart - 1, memo);
2277
- if (closeIdx_at !== -1) {
2278
- const expression = input.slice(afterSelectors, closeIdx_at);
2279
- i = closeIdx_at + 1;
2280
- const token = {
2281
- type: "expression",
2282
- expression,
2283
- start,
2284
- end: i
2285
- };
2286
- if (className) token.className = className;
2287
- if (id) token.id = id;
2288
- tokens.push(token);
2289
- textStart = i;
2290
- continue;
2291
- }
2292
- i = start + 1;
2293
- textStart = start;
2294
- continue;
2295
- }
2296
- if (charAfter === "%") {
2297
- i = afterSelectors + 1;
2298
- const nameStart = i;
2299
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2300
- const name = input.slice(nameStart, i);
2301
- if (input[i] === "}") {
2302
- i++;
2303
- const token = {
2304
- type: "variable",
2305
- name,
2306
- scope: "transient",
2307
- start,
2308
- end: i
2309
- };
2310
- if (className) token.className = className;
2311
- if (id) token.id = id;
2312
- tokens.push(token);
2313
- textStart = i;
2314
- continue;
2315
- }
2316
- const closeIdx_pct = scanBalancedBrace(input, nameStart - 1, memo);
2317
- if (closeIdx_pct !== -1) {
2318
- const expression = input.slice(afterSelectors, closeIdx_pct);
2319
- i = closeIdx_pct + 1;
2320
- const token = {
2321
- type: "expression",
2322
- expression,
2323
- start,
2324
- end: i
2325
- };
2326
- if (className) token.className = className;
2327
- if (id) token.id = id;
2328
- tokens.push(token);
2329
- textStart = i;
2330
- continue;
2331
- }
2332
- i = start + 1;
2333
- textStart = start;
2334
- continue;
2335
- }
2336
- if (EXPRESSION_START.has(charAfter) && pushExpression(afterSelectors, start, false, className, id)) continue;
2337
- if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
2338
- const contentStart = afterSelectors;
2339
- const closeIdx = scanMacroClose(input, contentStart, memo);
2340
- if (closeIdx === -1) {
2341
- i = start + 1;
2342
- textStart = start;
2343
- continue;
2344
- }
2345
- const content = input.slice(contentStart, closeIdx);
2346
- i = closeIdx + 1;
2347
- const { name, rawArgs, isClose } = parseMacroContent(content);
2348
- const token = {
2349
- type: "macro",
2350
- name,
2351
- rawArgs,
2352
- isClose,
2353
- start,
2354
- end: i
2355
- };
2356
- if (className) token.className = className;
2357
- if (id) token.id = id;
2358
- tokens.push(token);
2359
- textStart = i;
2360
- consumeRawBody(name, isClose);
2361
- continue;
2362
- }
2363
- i = start + 1;
2364
- textStart = start;
2365
- continue;
2366
- }
2367
- if (nextChar === "$") {
2368
- flushText(i);
2369
- i += 2;
2370
- const nameStart = i;
2371
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2372
- const name = input.slice(nameStart, i);
2373
- if (input[i] === "}") {
2374
- i++;
2375
- tokens.push({
2376
- type: "variable",
2377
- name,
2378
- scope: "variable",
2379
- start,
2380
- end: i
2381
- });
2382
- textStart = i;
2383
- continue;
2384
- }
2385
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2386
- if (closeIdx !== -1) {
2387
- const expression = input.slice(start + 1, closeIdx);
2388
- i = closeIdx + 1;
2389
- tokens.push({
2390
- type: "expression",
2391
- expression,
2392
- start,
2393
- end: i
2394
- });
2395
- textStart = i;
2396
- continue;
2397
- }
2398
- i = start + 1;
2399
- textStart = start;
2400
- continue;
2401
- }
2402
- if (nextChar === "_") {
2403
- flushText(i);
2404
- i += 2;
2405
- const nameStart = i;
2406
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2407
- const name = input.slice(nameStart, i);
2408
- if (input[i] === "}") {
2409
- i++;
2410
- tokens.push({
2411
- type: "variable",
2412
- name,
2413
- scope: "temporary",
2414
- start,
2415
- end: i
2416
- });
2417
- textStart = i;
2418
- continue;
2419
- }
2420
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2421
- if (closeIdx !== -1) {
2422
- const expression = input.slice(start + 1, closeIdx);
2423
- i = closeIdx + 1;
2424
- tokens.push({
2425
- type: "expression",
2426
- expression,
2427
- start,
2428
- end: i
2429
- });
2430
- textStart = i;
2431
- continue;
2432
- }
2433
- i = start + 1;
2434
- textStart = start;
2435
- continue;
2436
- }
2437
- if (nextChar === "@") {
2438
- flushText(i);
2439
- i += 2;
2440
- const nameStart = i;
2441
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2442
- const name = input.slice(nameStart, i);
2443
- if (input[i] === "}") {
2444
- i++;
2445
- tokens.push({
2446
- type: "variable",
2447
- name,
2448
- scope: "local",
2449
- start,
2450
- end: i
2451
- });
2452
- textStart = i;
2453
- continue;
2454
- }
2455
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2456
- if (closeIdx !== -1) {
2457
- const expression = input.slice(start + 1, closeIdx);
2458
- i = closeIdx + 1;
2459
- tokens.push({
2460
- type: "expression",
2461
- expression,
2462
- start,
2463
- end: i
2464
- });
2465
- textStart = i;
2466
- continue;
2467
- }
2468
- i = start + 1;
2469
- textStart = start;
2470
- continue;
2471
- }
2472
- if (nextChar === "%") {
2473
- flushText(i);
2474
- i += 2;
2475
- const nameStart = i;
2476
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2477
- const name = input.slice(nameStart, i);
2478
- if (input[i] === "}") {
2479
- i++;
2480
- tokens.push({
2481
- type: "variable",
2482
- name,
2483
- scope: "transient",
2484
- start,
2485
- end: i
2486
- });
2487
- textStart = i;
2488
- continue;
2489
- }
2490
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2491
- if (closeIdx !== -1) {
2492
- const expression = input.slice(start + 1, closeIdx);
2493
- i = closeIdx + 1;
2494
- tokens.push({
2495
- type: "expression",
2496
- expression,
2497
- start,
2498
- end: i
2499
- });
2500
- textStart = i;
2501
- continue;
2502
- }
2503
- i = start + 1;
2504
- textStart = start;
2505
- continue;
2506
- }
2507
- if (EXPRESSION_START.has(nextChar)) {
2508
- if (pushExpression(i + 1, start, true)) continue;
2509
- i++;
2510
- continue;
2511
- }
2512
- if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
2513
- flushText(i);
2514
- const contentStart = i + 1;
2515
- const closeIdx = scanMacroClose(input, contentStart, memo);
2516
- if (closeIdx === -1) {
2517
- i = start + 1;
2518
- textStart = start;
2519
- continue;
2520
- }
2521
- const content = input.slice(contentStart, closeIdx);
2522
- i = closeIdx + 1;
2523
- const { name, rawArgs, isClose } = parseMacroContent(content);
2524
- tokens.push({
2525
- type: "macro",
2526
- name,
2527
- rawArgs,
2528
- isClose,
2529
- start,
2530
- end: i
2531
- });
2532
- textStart = i;
2533
- consumeRawBody(name, isClose);
2534
- 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;
2535
2227
  }
2536
- i++;
2228
+ i = start + 1;
2537
2229
  continue;
2538
2230
  }
2539
2231
  if (!textMode && input[i] === "<") {
@@ -2551,11 +2243,10 @@ function tokenize(input, options = {}) {
2551
2243
  j++;
2552
2244
  flushText(start);
2553
2245
  if (HTML_VOID_TAGS.has(tagLower)) {
2554
- textStart = j;
2555
- i = j;
2246
+ i = textStart = j;
2556
2247
  continue;
2557
2248
  }
2558
- tokens.push({
2249
+ emit({
2559
2250
  type: "html",
2560
2251
  tag,
2561
2252
  attributes: {},
@@ -2564,8 +2255,6 @@ function tokenize(input, options = {}) {
2564
2255
  start,
2565
2256
  end: j
2566
2257
  });
2567
- textStart = j;
2568
- i = j;
2569
2258
  continue;
2570
2259
  }
2571
2260
  } else {
@@ -2579,7 +2268,7 @@ function tokenize(input, options = {}) {
2579
2268
  if (input[j] === ">") {
2580
2269
  j++;
2581
2270
  flushText(start);
2582
- tokens.push({
2271
+ emit({
2583
2272
  type: "html",
2584
2273
  tag,
2585
2274
  attributes: parseHtmlAttributes(input, attrsStart, memo).attributes,
@@ -2588,8 +2277,6 @@ function tokenize(input, options = {}) {
2588
2277
  start,
2589
2278
  end: j
2590
2279
  });
2591
- textStart = j;
2592
- i = j;
2593
2280
  continue;
2594
2281
  }
2595
2282
  }
@@ -2645,6 +2332,21 @@ var BRANCHING_BLOCK_MACROS = new Set([
2645
2332
  function quoteArg(value) {
2646
2333
  return `"${value.replace(/[\\"]/g, "\\$&")}"`;
2647
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
+ }
2648
2350
  /**
2649
2351
  * Build an AST from a token array. Block macros are nested into trees
2650
2352
  * using a stack. Throws on unclosed or mismatched macros.
@@ -2658,6 +2360,18 @@ function buildAST(tokens) {
2658
2360
  if (top.type === "macro" && top.branches && top.branches.length > 0) return top.branches[top.branches.length - 1].children;
2659
2361
  return top.children;
2660
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
+ }
2661
2375
  for (const token of tokens) switch (token.type) {
2662
2376
  case "text":
2663
2377
  current().push({
@@ -2666,133 +2380,72 @@ function buildAST(tokens) {
2666
2380
  });
2667
2381
  break;
2668
2382
  case "link": {
2669
- const macroNode = {
2383
+ const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
2384
+ current().push(withSelectors({
2670
2385
  type: "macro",
2671
2386
  name: "link",
2672
- rawArgs: `${quoteArg(token.display)} ${quoteArg(token.target)}`,
2387
+ rawArgs,
2673
2388
  children: []
2674
- };
2675
- if (token.className) macroNode.className = token.className;
2676
- if (token.id) macroNode.id = token.id;
2677
- current().push(macroNode);
2389
+ }, token));
2678
2390
  break;
2679
2391
  }
2680
- case "variable": {
2681
- const varNode = {
2392
+ case "variable":
2393
+ current().push(withSelectors({
2682
2394
  type: "variable",
2683
2395
  name: token.name,
2684
2396
  scope: token.scope
2685
- };
2686
- if (token.className) varNode.className = token.className;
2687
- if (token.id) varNode.id = token.id;
2688
- current().push(varNode);
2397
+ }, token));
2689
2398
  break;
2690
- }
2691
- case "expression": {
2692
- const exprNode = {
2399
+ case "expression":
2400
+ current().push(withSelectors({
2693
2401
  type: "expression",
2694
2402
  expression: token.expression
2695
- };
2696
- if (token.className) exprNode.className = token.className;
2697
- if (token.id) exprNode.id = token.id;
2698
- current().push(exprNode);
2403
+ }, token));
2699
2404
  break;
2700
- }
2701
2405
  case "html": {
2702
- if (token.isSelfClose) {
2703
- current().push({
2704
- type: "html",
2705
- tag: token.tag,
2706
- attributes: token.attributes,
2707
- children: []
2708
- });
2709
- break;
2710
- }
2711
- if (token.isClose) {
2712
- if (stack.length === 0) throw new Error(`Unexpected closing </${token.tag}> (at character ${token.start})`);
2713
- const top = stack[stack.length - 1];
2714
- if (top.node.type !== "html" || top.node.tag.toLowerCase() !== token.tag.toLowerCase()) {
2715
- const expected = top.node.type === "html" ? `</${top.node.tag}>` : `{/${top.node.name}}`;
2716
- throw new Error(`Expected ${expected} but found </${token.tag}> (at character ${token.start})`);
2717
- }
2718
- stack.pop();
2719
- current().push(top.node);
2720
- break;
2721
- }
2722
2406
  const htmlNode = {
2723
2407
  type: "html",
2724
2408
  tag: token.tag,
2725
2409
  attributes: token.attributes,
2726
2410
  children: []
2727
2411
  };
2728
- stack.push({
2412
+ if (token.isSelfClose) current().push(htmlNode);
2413
+ else if (token.isClose) close(token);
2414
+ else stack.push({
2729
2415
  node: htmlNode,
2730
2416
  start: token.start
2731
2417
  });
2732
2418
  break;
2733
2419
  }
2734
2420
  case "macro": {
2735
- const name = token.name.toLowerCase();
2736
2421
  if (token.isClose) {
2737
- if (stack.length === 0) throw new Error(`Unexpected closing {/${token.name}} (at character ${token.start})`);
2738
- const top = stack[stack.length - 1];
2739
- if (top.node.type !== "macro" || top.node.name !== name) {
2740
- const expected = top.node.type === "macro" ? `{/${top.node.name}}` : `</${top.node.tag}>`;
2741
- throw new Error(`Expected ${expected} but found {/${token.name}} (at character ${token.start})`);
2742
- }
2743
- stack.pop();
2744
- current().push(top.node);
2422
+ close(token);
2745
2423
  break;
2746
2424
  }
2425
+ const name = token.name.toLowerCase();
2747
2426
  const expectedParent = Object.prototype.hasOwnProperty.call(BRANCH_PARENT, name) ? BRANCH_PARENT[name] : void 0;
2748
2427
  if (expectedParent) {
2749
2428
  const topNode = stack.length > 0 ? stack[stack.length - 1].node : null;
2750
2429
  if (!topNode || topNode.type !== "macro" || topNode.name !== expectedParent) throw new Error(`{${token.name}} without matching {${expectedParent}} (at character ${token.start})`);
2751
- const branch = {
2752
- rawArgs: token.rawArgs,
2753
- children: []
2754
- };
2755
- if (token.className) branch.className = token.className;
2756
- if (token.id) branch.id = token.id;
2757
- topNode.branches.push(branch);
2430
+ topNode.branches.push(newBranch(token));
2758
2431
  break;
2759
2432
  }
2760
- if (BLOCK_MACROS.has(name)) {
2761
- const node = {
2762
- type: "macro",
2763
- name,
2764
- rawArgs: token.rawArgs,
2765
- children: []
2766
- };
2767
- if (BRANCHING_BLOCK_MACROS.has(name)) {
2768
- const firstBranch = {
2769
- rawArgs: token.rawArgs,
2770
- children: []
2771
- };
2772
- if (token.className) firstBranch.className = token.className;
2773
- if (token.id) firstBranch.id = token.id;
2774
- node.branches = [firstBranch];
2775
- } else {
2776
- if (token.className) node.className = token.className;
2777
- if (token.id) node.id = token.id;
2778
- }
2779
- stack.push({
2780
- node,
2781
- start: token.start
2782
- });
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));
2783
2441
  break;
2784
2442
  }
2785
- {
2786
- const macroNode = {
2787
- type: "macro",
2788
- name,
2789
- rawArgs: token.rawArgs,
2790
- children: []
2791
- };
2792
- if (token.className) macroNode.className = token.className;
2793
- if (token.id) macroNode.id = token.id;
2794
- current().push(macroNode);
2795
- }
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
+ });
2796
2449
  break;
2797
2450
  }
2798
2451
  default: throw new Error(`Unknown token type: ${token.type}`);
@@ -3463,12 +3116,6 @@ var MAX_DEPTH = 100;
3463
3116
  function display(value) {
3464
3117
  return value == null ? "" : String(value);
3465
3118
  }
3466
- var KIND_SIGILS = {
3467
- variable: "$",
3468
- temporary: "_",
3469
- local: "@",
3470
- transient: "%"
3471
- };
3472
3119
  /**
3473
3120
  * The value of a variable reference (`name` without its sigil, with an
3474
3121
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -3478,7 +3125,7 @@ var KIND_SIGILS = {
3478
3125
  function resolveVariable(scope, kind, name) {
3479
3126
  const parts = name.split(".");
3480
3127
  const root = parts[0];
3481
- checkVariableName(root, KIND_SIGILS[kind] + root);
3128
+ checkVariableName(root, SCOPE_SIGILS[kind] + root);
3482
3129
  let value = ownValue(kind === "variable" ? scope.variables : kind === "temporary" ? scope.temporary : kind === "transient" ? scope.transient : scope.locals, root);
3483
3130
  for (let i = 1; i < parts.length; i++) {
3484
3131
  if (value == null) return void 0;
@@ -3528,7 +3175,7 @@ var TextEvaluator = class {
3528
3175
  return display(resolveVariable(scope, kind, name));
3529
3176
  } catch (error) {
3530
3177
  this.errors.push({
3531
- macro: KIND_SIGILS[kind] + name,
3178
+ macro: SCOPE_SIGILS[kind] + name,
3532
3179
  error
3533
3180
  });
3534
3181
  return "";
@@ -3636,12 +3283,6 @@ function interpolateText(template, scope) {
3636
3283
  };
3637
3284
  return renderText(parsed.nodes, scope);
3638
3285
  }
3639
- var SIGIL_KINDS = {
3640
- $: "variable",
3641
- _: "temporary",
3642
- "@": "local",
3643
- "%": "transient"
3644
- };
3645
3286
  /**
3646
3287
  * Resolve the sigil references in the value of a code attribute (see
3647
3288
  * isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
@@ -3654,7 +3295,7 @@ function interpolateCode(template, scope, decode) {
3654
3295
  for (const part of splitSigilTemplate(template)) if ("text" in part) text += decode ? decode(part.text) : part.text;
3655
3296
  else if ("verbatim" in part) text += part.verbatim;
3656
3297
  else if (/^[$_@%][\w.]+$/.test(part.expr)) {
3657
- const kind = SIGIL_KINDS[part.expr[0]];
3298
+ const kind = SIGIL_SCOPES[part.expr[0]];
3658
3299
  try {
3659
3300
  text += display(resolveVariable(scope, kind, part.expr.slice(1)));
3660
3301
  } catch (error) {
@@ -8720,6 +8361,56 @@ function astContainsChildren(nodes) {
8720
8361
  return false;
8721
8362
  }
8722
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
8723
8414
  //#region src/components/macros/Set.tsx
8724
8415
  defineMacro({
8725
8416
  name: "set",
@@ -9514,52 +9205,6 @@ defineMacro({
9514
9205
  }
9515
9206
  });
9516
9207
  //#endregion
9517
- //#region src/components/macros/Widget.tsx
9518
- function parseWidgetDef(rawArgs) {
9519
- const tokens = rawArgs.trim().split(/\s+/);
9520
- const name = tokens[0].replace(/["']/g, "");
9521
- const params = tokens.slice(1).filter((t) => t.startsWith("@"));
9522
- for (const param of params) checkVariableName(param.slice(1), param);
9523
- return {
9524
- name,
9525
- params
9526
- };
9527
- }
9528
- defineMacro({
9529
- name: "widget",
9530
- block: true,
9531
- render({ rawArgs, children = [] }, ctx) {
9532
- let parsed;
9533
- let error;
9534
- try {
9535
- parsed = parseWidgetDef(rawArgs);
9536
- } catch (err) {
9537
- error = err;
9538
- }
9539
- const { name, params } = parsed ?? {
9540
- name: "",
9541
- params: []
9542
- };
9543
- const childrenKey = JSON.stringify(children);
9544
- const paramsKey = params.join(",");
9545
- ctx.hooks.useLayoutEffect(() => {
9546
- if (!parsed) return;
9547
- const isBlock = astContainsChildren(children);
9548
- registerWidget(name, children, params, isBlock);
9549
- if (isBlock) registerBlockMacro(name);
9550
- }, [
9551
- name,
9552
- childrenKey,
9553
- paramsKey
9554
- ]);
9555
- if (error) return /* @__PURE__ */ jsx(MacroError, {
9556
- macro: "widget",
9557
- error
9558
- });
9559
- return null;
9560
- }
9561
- });
9562
- //#endregion
9563
9208
  //#region src/components/macros/StoryTitle.tsx
9564
9209
  defineMacro({
9565
9210
  name: "story-title",
@@ -11005,9 +10650,7 @@ function boot() {
11005
10650
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
11006
10651
  const widgetAST = buildAST(tokenize(passage.content));
11007
10652
  for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
11008
- const tokens2 = node.rawArgs.trim().split(/\s+/);
11009
- const widgetName = tokens2[0].replace(/["']/g, "");
11010
- const params = tokens2.slice(1).filter((t) => t.startsWith("$") || t.startsWith("_") || t.startsWith("@"));
10653
+ const { name: widgetName, params } = parseWidgetDef(node.rawArgs, "$_@");
11011
10654
  const children = node.children;
11012
10655
  const isBlock = astContainsChildren(children);
11013
10656
  try {