@rohal12/spindle 0.59.10 → 0.59.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.10",
3
+ "version": "0.59.12",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -93,8 +93,6 @@ defineMacro({
93
93
  { name: 'expression', type: 'expression', required: true },
94
94
  ],
95
95
  render({ rawArgs }, ctx) {
96
- const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
97
-
98
96
  let target: string;
99
97
  let expr: string;
100
98
  try {
@@ -133,13 +131,9 @@ defineMacro({
133
131
  }
134
132
 
135
133
  // The merged values only decide when to recompute; computeAndApply reads
136
- // the current ones.
137
- ctx.hooks.useLayoutEffect(compute, [
138
- mergedVars,
139
- mergedTemps,
140
- mergedLocals,
141
- mergedTrans,
142
- ]);
134
+ // the current ones. The tuple is renewed with them and with the render
135
+ // counts, which rendered() / hasRendered() read.
136
+ ctx.hooks.useLayoutEffect(compute, [ctx.merged]);
143
137
 
144
138
  return null;
145
139
  },
@@ -3,6 +3,7 @@ import { LocalsValuesContext } from '../../markup/render';
3
3
  import { defineMacro } from '../../define-macro';
4
4
  import { MacroError } from './MacroError';
5
5
  import { stableKey } from '../../utils/stable-key';
6
+ import { controlEditVersion } from '../../utils/control-edits';
6
7
  import type { ASTNode } from '../../markup/ast';
7
8
  import { checkVariableName } from '../../utils/namespace';
8
9
  import { LocalsScope } from './locals-scope';
@@ -76,6 +77,14 @@ function ForIteration({
76
77
  );
77
78
  }
78
79
 
80
+ /** What {for} remembers of its iterations between renders. */
81
+ interface IterationKeys {
82
+ contents: string[];
83
+ generations: number[];
84
+ next: number;
85
+ edits: number;
86
+ }
87
+
79
88
  defineMacro({
80
89
  name: 'for',
81
90
  block: true,
@@ -88,6 +97,12 @@ defineMacro({
88
97
  ],
89
98
  render({ rawArgs, children = [] }, ctx) {
90
99
  const parentValues = useContext(LocalsValuesContext);
100
+ const keys = ctx.hooks.useRef<IterationKeys>({
101
+ contents: [],
102
+ generations: [],
103
+ next: 0,
104
+ edits: controlEditVersion(),
105
+ }).current;
91
106
 
92
107
  let loop: ReturnType<typeof loopArgs>;
93
108
  let list: unknown[];
@@ -112,9 +127,27 @@ defineMacro({
112
127
  }
113
128
 
114
129
  const { itemVar, indexVar } = loop;
130
+ // An iteration is remounted when its item's contents change, so mount-only
131
+ // macros run again for the new item (#45) -- unless the change is a
132
+ // reader editing the item through a control: remounting would drop the
133
+ // control's focus after the first keystroke.
134
+ const edited = controlEditVersion() !== keys.edits;
135
+ keys.edits = controlEditVersion();
136
+ const generations = list.map((item, i) => {
137
+ const contents = stableKey(item);
138
+ if (
139
+ keys.generations[i] === undefined ||
140
+ (contents !== keys.contents[i] && !edited)
141
+ ) {
142
+ keys.generations[i] = keys.next++;
143
+ }
144
+ keys.contents[i] = contents;
145
+ return keys.generations[i]!;
146
+ });
147
+ keys.contents.length = keys.generations.length = list.length;
115
148
  const content = list.map((item, i) => (
116
149
  <ForIteration
117
- key={`${i}-${stableKey(item)}`}
150
+ key={`${i}-${generations[i]}`}
118
151
  parentValues={parentValues}
119
152
  itemVar={itemVar}
120
153
  itemValue={item}
@@ -1,8 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
3
  import type { Branch } from '../../markup/ast';
4
- import { selectBranch } from './branches';
5
- import { wrapContent } from './display';
4
+ import { selectBranch, renderBranch } from './branches';
6
5
 
7
6
  /**
8
7
  * The {if}/{elseif}/{else} branch whose condition holds, else the {else}
@@ -37,11 +36,7 @@ defineMacro({
37
36
  );
38
37
  }
39
38
  if (!branch) return null;
40
- return wrapContent(
41
- ctx.resolve!(branch.className),
42
- ctx.resolve!(branch.id),
43
- ctx.renderNodes(branch.children),
44
- );
39
+ return renderBranch(branch, ctx);
45
40
  },
46
41
  text({ branches = [] }, ctx) {
47
42
  const branch = selectIfBranch(branches, ctx.evaluate);
@@ -345,12 +345,15 @@ export function SaveManagerContent() {
345
345
  class="playthrough-group"
346
346
  key={group.playthrough.id}
347
347
  >
348
- <div
348
+ <button
349
+ type="button"
349
350
  class="playthrough-header"
351
+ aria-expanded={!isCollapsed}
350
352
  onClick={() => toggleCollapse(group.playthrough.id)}
351
353
  >
352
354
  <span
353
355
  class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
356
+ aria-hidden="true"
354
357
  >
355
358
  ▶
356
359
  </span>
@@ -361,7 +364,7 @@ export function SaveManagerContent() {
361
364
  <span class="playthrough-date">
362
365
  {formatDate(group.playthrough.createdAt)}
363
366
  </span>
364
- </div>
367
+ </button>
365
368
 
366
369
  {!isCollapsed && (
367
370
  <div class="playthrough-saves">
@@ -1,7 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
3
  import type { Branch } from '../../markup/ast';
4
- import { selectBranch } from './branches';
4
+ import { selectBranch, renderBranch } from './branches';
5
5
 
6
6
  /**
7
7
  * The first {case} branch whose value equals `value`, else the {default}
@@ -20,6 +20,7 @@ defineMacro({
20
20
  name: 'switch',
21
21
  subMacros: ['case', 'default'],
22
22
  parameters: [{ name: 'expression', type: 'expression', required: true }],
23
+ interpolate: true,
23
24
  merged: true,
24
25
  render({ rawArgs, branches = [] }, ctx) {
25
26
  let switchValue: unknown;
@@ -46,7 +47,8 @@ defineMacro({
46
47
  );
47
48
  }
48
49
 
49
- return branch && <>{ctx.renderNodes(branch.children)}</>;
50
+ if (!branch) return null;
51
+ return renderBranch(branch, ctx);
50
52
  },
51
53
  text({ rawArgs, branches = [] }, ctx) {
52
54
  const branch = selectCase(ctx.evaluate(rawArgs), branches, ctx.evaluate);
@@ -1,6 +1,4 @@
1
- import { registerWidget } from '../../widgets/widget-registry';
2
- import { astContainsChildren } from '../../widgets/ast-scanner';
3
- import { registerBlockMacro, type ASTNode } from '../../markup/ast';
1
+ import { registerWidgetDef } from '../../widgets/register-widget-def';
4
2
  import { defineMacro } from '../../define-macro';
5
3
  import { checkVariableName } from '../../utils/namespace';
6
4
  import { MacroError } from './MacroError';
@@ -10,22 +8,6 @@ import {
10
8
  type WidgetDef,
11
9
  } from '../../widgets/widget-def';
12
10
 
13
- /**
14
- * Register the widget a definition declares, with its body. Widgets whose
15
- * body renders {@children} take a closing tag, so they are registered as
16
- * block macros too: passages parsed later nest their content. A parameter
17
- * that no namespace can hold throws (see registerWidget), registering
18
- * nothing.
19
- */
20
- export function registerWidgetDef(
21
- { name, params }: WidgetDef,
22
- body: ASTNode[],
23
- ): void {
24
- const isBlock = astContainsChildren(body);
25
- registerWidget(name, body, params, isBlock);
26
- if (isBlock) registerBlockMacro(name);
27
- }
28
-
29
11
  defineMacro({
30
12
  name: 'widget',
31
13
  block: true,
@@ -7,6 +7,8 @@ import { splitArgs } from './arg-utils';
7
7
  import { createNamespace } from '../../utils/namespace';
8
8
  import { useRenderOptions } from '../../hooks/use-render-options';
9
9
  import { LocalsScope } from './locals-scope';
10
+ import { useInterpolate } from '../../hooks/use-interpolate';
11
+ import { wrapContent } from './display';
10
12
 
11
13
  export { splitArgs };
12
14
 
@@ -20,17 +22,14 @@ interface WidgetInvocationProps {
20
22
  }
21
23
 
22
24
  export function WidgetInvocation(props: WidgetInvocationProps) {
23
- const { className, id } = props;
24
- const content = <WidgetContent {...props} />;
25
- // Invocation selectors (`{.badge#hero Stats}`) need an element to land on.
26
- if (!className && !id) return content;
27
- return (
28
- <span
29
- class={className}
30
- id={id}
31
- >
32
- {content}
33
- </span>
25
+ const resolve = useInterpolate();
26
+ // Invocation selectors (`{.badge#hero Stats}`) need an element to land on;
27
+ // dynamic ones (`{.{$theme} Stats}`) follow the state like a built-in
28
+ // macro's.
29
+ return wrapContent(
30
+ resolve(props.className),
31
+ resolve(props.id),
32
+ <WidgetContent {...props} />,
34
33
  );
35
34
  }
36
35
 
@@ -39,8 +38,6 @@ function WidgetContent({
39
38
  params,
40
39
  rawArgs,
41
40
  invocationChildren,
42
- className,
43
- id,
44
41
  }: WidgetInvocationProps) {
45
42
  const renderOptions = useRenderOptions();
46
43
  const parentValues = renderOptions.locals;
@@ -49,16 +46,9 @@ function WidgetContent({
49
46
 
50
47
  const childrenValue = invocationChildren?.length ? invocationChildren : null;
51
48
 
52
- // Parameterized widgets always get their own local scope, even when invoked
53
- // without arguments: missing parameters shadow outer locals as undefined.
54
- if (params.length === 0) {
55
- return (
56
- <WidgetChildrenContext.Provider value={childrenValue}>
57
- {renderNodes(body, renderOptions)}
58
- </WidgetChildrenContext.Provider>
59
- );
60
- }
61
-
49
+ // Every widget gets its own local scope, even when it declares no
50
+ // parameters or is invoked without arguments: missing parameters shadow
51
+ // outer locals as undefined, and the body's own locals stay in the widget.
62
52
  const argExprs = rawArgs ? splitArgs(rawArgs) : [];
63
53
  const values: unknown[] = [];
64
54
 
@@ -1,4 +1,7 @@
1
+ import type { ComponentChildren, VNode } from 'preact';
1
2
  import type { Branch } from '../../markup/ast';
3
+ import type { MacroContext } from '../../define-macro';
4
+ import { wrapContent } from './display';
2
5
 
3
6
  /**
4
7
  * The branch a conditional block ({if}, {switch}) shows: the first of
@@ -16,3 +19,20 @@ export function selectBranch(
16
19
  }
17
20
  return fallback;
18
21
  }
22
+
23
+ /**
24
+ * A selected branch's content, in a wrapper if the branch has CSS selectors
25
+ * (`{.hot#id case 1}`). The macro must be defined with `interpolate`.
26
+ */
27
+ export function renderBranch(
28
+ branch: Branch,
29
+ ctx: Pick<MacroContext, 'resolve'> & {
30
+ renderNodes: (nodes: Branch['children']) => ComponentChildren;
31
+ },
32
+ ): VNode<any> {
33
+ return wrapContent(
34
+ ctx.resolve!(branch.className),
35
+ ctx.resolve!(branch.id),
36
+ ctx.renderNodes(branch.children),
37
+ );
38
+ }
@@ -10,6 +10,7 @@ import {
10
10
  useMemo,
11
11
  } from 'preact/hooks';
12
12
  import { useInterpolate } from './hooks/use-interpolate';
13
+ import { noteControlEdit } from './utils/control-edits';
13
14
  import { useMergedLocals } from './hooks/use-merged-locals';
14
15
  import { useRenderOptions } from './hooks/use-render-options';
15
16
  import {
@@ -228,6 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
228
229
  // In program order, also when mutation code performs the input
229
230
  ctx.getValue = () => getByPath(readState().variables, segments);
230
231
  ctx.setValue = (value: unknown) => {
232
+ noteControlEdit();
231
233
  // The commit mutation code uses, which keeps the references among
232
234
  // variables: a write to a draft copies only the path written, and
233
235
  // other variables sharing the object would keep the old one.
@@ -10,7 +10,7 @@ const FOCUSABLE = [
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
12
  '[contenteditable="true"]',
13
- '[tabindex]:not([tabindex="-1"])',
13
+ '[tabindex]',
14
14
  ].join(',');
15
15
 
16
16
  /**
@@ -27,10 +27,57 @@ function isRendered(el: HTMLElement): boolean {
27
27
  return true;
28
28
  }
29
29
 
30
+ /** The `tabindex` of `el` as an integer, or null when absent or invalid. */
31
+ function tabIndexAttr(el: HTMLElement): number | null {
32
+ const raw = el.getAttribute('tabindex');
33
+ if (raw === null || !/^\s*[+-]?\d+\s*$/.test(raw)) return null;
34
+ return parseInt(raw, 10);
35
+ }
36
+
37
+ /**
38
+ * Whether the tab key can reach `el`: a negative tabindex only allows
39
+ * scripted focus, and of a radio group the browser tabs to the checked
40
+ * member only (the first one when none is checked).
41
+ */
42
+ function isTabStop(el: HTMLElement): boolean {
43
+ const tabIndex = tabIndexAttr(el);
44
+ if (tabIndex !== null && tabIndex < 0) return false;
45
+ if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
46
+ const group = Array.from(
47
+ (
48
+ el.form ?? (el.getRootNode() as ParentNode)
49
+ ).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
50
+ ).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
51
+ const stop = group.find((r) => r.checked) ?? group[0];
52
+ return el === stop;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ /**
58
+ * The elements of `root` in the order Tab visits them: positive tabindexes
59
+ * first, ascending, then the others in document order.
60
+ */
30
61
  function focusables(root: HTMLElement): HTMLElement[] {
31
- return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
32
- (el) => !el.hidden && !el.closest('[hidden], [inert]') && isRendered(el),
62
+ const stops = Array.from(
63
+ root.querySelectorAll<HTMLElement>(FOCUSABLE),
64
+ ).filter(
65
+ (el) =>
66
+ !el.hidden &&
67
+ !el.closest('[hidden], [inert]') &&
68
+ // Also covers controls disabled by a <fieldset disabled> ancestor
69
+ !el.matches(':disabled') &&
70
+ isRendered(el) &&
71
+ isTabStop(el),
33
72
  );
73
+ const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
74
+ // Array.prototype.sort is stable: equal tabindexes keep document order
75
+ return [
76
+ ...stops
77
+ .filter((el) => positive(el) > 0)
78
+ .sort((a, b) => positive(a) - positive(b)),
79
+ ...stops.filter((el) => positive(el) === 0),
80
+ ];
34
81
  }
35
82
 
36
83
  interface ModalEntry {
package/src/index.tsx CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  import { blockWidgetNames, parseWidgetDef } from './widgets/widget-def';
28
28
  import { parseMarkup } from './markup/parse';
29
29
  import { registerBlockMacro } from './markup/ast';
30
- import { registerWidgetDef } from './components/macros/Widget';
30
+ import { registerWidgetDefinitions } from './widgets/register-widget-def';
31
31
  import { errorMessage } from './utils/error-message';
32
32
  import type { ASTNode } from './markup/ast';
33
33
  import './macros/register-builtins';
@@ -193,6 +193,14 @@ export function boot() {
193
193
  // Enter runtime phase — handlers registered from here on are cleaned on restart
194
194
  enterRuntimePhase();
195
195
 
196
+ // Pass 2: Full parse and register widgets from passages tagged "widget",
197
+ // before StoryInit runs so that it can invoke them
198
+ for (const [, passage] of storyData.passages) {
199
+ if (passage.tags.includes('widget')) {
200
+ registerWidgetDefinitions(parseMarkup(passage.content), passage.name);
201
+ }
202
+ }
203
+
196
204
  // Run StoryInit, restore the session if the page was refreshed, and fire
197
205
  // storyinit after all state is settled (defaults + StoryInit + session)
198
206
  initializeStory(loadSession(storyData.ifid));
@@ -205,27 +213,6 @@ export function boot() {
205
213
  ];
206
214
  if (markup.length > 0) stopWithErrors(markup);
207
215
 
208
- // Pass 2: Full parse and register widgets from passages tagged "widget"
209
- for (const [, passage] of storyData.passages) {
210
- if (passage.tags.includes('widget')) {
211
- const widgetAST = parseMarkup(passage.content);
212
- for (const node of widgetAST) {
213
- if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
214
- // Read and registered as the {widget} macro does
215
- const def = parseWidgetDef(node.rawArgs);
216
- try {
217
- registerWidgetDef(def, node.children as ASTNode[]);
218
- } catch (err) {
219
- // As the {widget} macro refuses it: the others still register
220
- console.error(
221
- `spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
222
- );
223
- }
224
- }
225
- }
226
- }
227
- }
228
-
229
216
  // Reset action ID counters on every navigation (the passage remounts even
230
217
  // when its name is unchanged). Controls that stay mounted, e.g. in
231
218
  // StoryInterface, keep their IDs: allocation skips IDs still registered.
package/src/js-lexer.ts CHANGED
@@ -128,7 +128,7 @@ const OPERAND_ENDS: ReadonlySet<TokenType> = new Set([
128
128
  * whether a `{` opens a block or an object literal, which decides whether a
129
129
  * `/` after its `}` opens a regex and a `%` a transient: after the `:` of a
130
130
  * conditional it opens an object literal (`a ? b : {} / 2`), not a block as
131
- * after a label; after a block's `}` it opens another block (`{}{} %n = 1`),
131
+ * after a label, and a `function` or `class` there is an expression; after a block's `}` it opens another block (`{}{} %n = 1`),
132
132
  * and so it does on a new line after a statement (`p⏎{} %n = 1`).
133
133
  */
134
134
  const SigilParser = class extends (Parser as unknown as Base) {
@@ -146,6 +146,8 @@ const SigilParser = class extends (Parser as unknown as Base) {
146
146
  closedBlock = false;
147
147
  /** The value of the token before the one being finished. */
148
148
  prevValue: unknown;
149
+ /** The type of the token before the last one. */
150
+ typeBeforeLast: TokenType = tt.eof;
149
151
  /** Look ahead after a `%name` starting a line (off in look-aheads). */
150
152
  lookahead = true;
151
153
  /**
@@ -222,6 +224,21 @@ const SigilParser = class extends (Parser as unknown as Base) {
222
224
  (prevType === tt.dot || prevType === tt.questionDot)) ||
223
225
  ((type === tt._function || type === tt._class) &&
224
226
  notABody(self.input, self.pos));
227
+ // acorn takes a `function` or `class` for a statement's (whose `}` a
228
+ // regex may follow) after the `:` of a conditional, and `async function`
229
+ // always. Both are expressions where an operand is (`a ? b : class {} / 2`,
230
+ // `x = async function () {} / 2`): read them as after what comes before.
231
+ if (!property && (type === tt._function || type === tt._class)) {
232
+ const afterAsync =
233
+ type === tt._function &&
234
+ prevType === tt.name &&
235
+ this.prevValue === 'async' &&
236
+ !this.breakHere;
237
+ const before = afterAsync ? this.typeBeforeLast : prevType;
238
+ self.type =
239
+ before === tt.colon && this.colonEndsTernary ? tt.parenL : before;
240
+ }
241
+ this.typeBeforeLast = prevType;
225
242
  // @ts-expect-error acorn internals
226
243
  super.finishToken(property ? tt.name : type, value);
227
244
  // A variable named `of` (`const of of list`): acorn takes it for the
@@ -348,7 +348,11 @@ function convertDomNode(
348
348
  return null;
349
349
  }
350
350
 
351
- /** Inline elements where block-level markdown (lists, headings) is invalid. */
351
+ /**
352
+ * Elements whose content is phrasing content only, so block-level markdown
353
+ * (lists, headings, paragraphs) is invalid in them: inline elements, and
354
+ * block elements such as headings and buttons that only take inline children.
355
+ */
352
356
  const INLINE_ELEMENTS = new Set([
353
357
  'a',
354
358
  'abbr',
@@ -356,17 +360,27 @@ const INLINE_ELEMENTS = new Set([
356
360
  'bdi',
357
361
  'bdo',
358
362
  'br',
363
+ 'button',
359
364
  'cite',
360
365
  'code',
361
366
  'data',
362
367
  'dfn',
363
368
  'em',
364
369
  'i',
370
+ 'h1',
371
+ 'h2',
372
+ 'h3',
373
+ 'h4',
374
+ 'h5',
375
+ 'h6',
365
376
  'kbd',
366
377
  'label',
378
+ 'legend',
367
379
  'mark',
368
380
  'meter',
381
+ 'option',
369
382
  'output',
383
+ 'p',
370
384
  'progress',
371
385
  'q',
372
386
  'rp',
@@ -524,25 +538,43 @@ function useDirectAttributes(
524
538
  return direct.length > 0 ? elementRef : undefined;
525
539
  }
526
540
 
541
+ const decodedText = new Map<string, string>();
527
542
  const decodedAttributeText = new Map<string, string>();
528
543
 
529
544
  /**
530
- * Decode character references (`&amp;`, `&#123;`) in attribute text the way
531
- * the HTML parser decodes an attribute value, by letting it parse one.
545
+ * Decode character references (`&amp;`, `&#123;`) in `text` the way the HTML
546
+ * parser decodes them, by letting it parse them: in an attribute value, or
547
+ * else in element text.
532
548
  */
533
- function decodeAttributeText(text: string): string {
549
+ function decodeReferences(
550
+ text: string,
551
+ cache: Map<string, string>,
552
+ parse: (text: string) => string | null,
553
+ ): string {
534
554
  if (!text.includes('&')) return text;
535
- let decoded = decodedAttributeText.get(text);
555
+ let decoded = cache.get(text);
536
556
  if (decoded === undefined) {
537
- const html = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
538
- decoded =
539
- (parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
540
- text;
541
- decodedAttributeText.set(text, decoded);
557
+ decoded = parse(text) ?? text;
558
+ cache.set(text, decoded);
542
559
  }
543
560
  return decoded;
544
561
  }
545
562
 
563
+ function decodeAttributeText(text: string): string {
564
+ return decodeReferences(text, decodedAttributeText, (t) => {
565
+ const html = `<i title="${t.replace(/"/g, '&quot;')}"></i>`;
566
+ return (parseHtmlInert(html).firstChild as Element).getAttribute('title');
567
+ });
568
+ }
569
+
570
+ /** Literal text outside markdown (preformatted and SVG elements). */
571
+ function decodeText(text: string): string {
572
+ return decodeReferences(text, decodedText, (t) => {
573
+ const html = `<i>${t.replace(/</g, '&lt;')}</i>`;
574
+ return parseHtmlInert(html).firstChild!.textContent;
575
+ });
576
+ }
577
+
546
578
  /** A `{` and a sigil starting a reference, in a code attribute. */
547
579
  const SIGIL_REFERENCE = /\{[$_@%]\w/;
548
580
 
@@ -725,7 +757,8 @@ function renderMacro(node: MacroNode, key: string) {
725
757
  * Render a non-text AST node to a Preact element.
726
758
  */
727
759
  function renderSingleNode(node: ASTNode): preact.ComponentChildren {
728
- if (node.type === 'text') return node.value;
760
+ // Authored text outside markdown still has its character references decoded
761
+ if (node.type === 'text') return decodeText(node.value);
729
762
  const key = nodeKey(node);
730
763
  switch (node.type) {
731
764
  case 'variable':
@@ -233,6 +233,7 @@ Content
233
233
  Item
234
234
  = TextRun
235
235
  / Escape
236
+ / Comment
236
237
  / LinkLexeme
237
238
  / HtmlElement
238
239
  / VoidCloser
@@ -291,6 +292,7 @@ Tokens
291
292
  TokenItem
292
293
  = TextRun
293
294
  / Escape
295
+ / Comment
294
296
  / LinkLexeme
295
297
  / &{ return !textMode; } tag:HtmlOpen { return tag; }
296
298
  / &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
@@ -317,6 +319,15 @@ Here
317
319
  TextRun
318
320
  = value:$[^\\{[<]+ { return lexeme('text', location(), { value }); }
319
321
 
322
+ /**
323
+ * A closed HTML comment, opaque: its tags and macros are not markup, and
324
+ * rendering drops it. (An unclosed `<!--` is ordinary text.)
325
+ */
326
+ Comment
327
+ = value:$("<!--" (">" / "->" / (!"-->" .)* "-->")) {
328
+ return lexeme('text', location(), { value });
329
+ }
330
+
320
331
  /**
321
332
  * A backslash run. Before a brace, an odd run escapes it; in text mode the
322
333
  * run is also paired up (`\\` is one backslash), in passage text markdown
@@ -36,7 +36,16 @@ export function setSaveTitlePassage(content: string): void {
36
36
  saveTitlePassageContent = content;
37
37
  }
38
38
 
39
+ /**
40
+ * The title of a save. The SaveTitle passage and title generators may draw
41
+ * random numbers; like the save hooks they must not advance the story's PRNG
42
+ * (see withoutDraws()).
43
+ */
39
44
  function generateTitle(payload: SavePayload): string {
45
+ return withoutDraws(() => generateTitleNow(payload));
46
+ }
47
+
48
+ function generateTitleNow(payload: SavePayload): string {
40
49
  // SaveTitle passage takes precedence
41
50
  if (saveTitlePassageContent) {
42
51
  try {
package/src/story-init.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { h, render } from 'preact';
2
2
  import { useStoryStore, recordStoryInitState, missingPassage } from './store';
3
3
  import { parseMarkup } from './markup/parse';
4
+ import { registerWidgetDefinitions } from './widgets/register-widget-def';
4
5
  import { renderNodes } from './markup/render';
5
6
  import { setSaveTitlePassage } from './saves/save-manager';
6
7
  import { emit } from './event-emitter';
@@ -35,6 +36,9 @@ export function executeStoryInit() {
35
36
  const storyInit = state.storyData.passages.get('StoryInit');
36
37
  if (storyInit) {
37
38
  const ast = parseMarkup(storyInit.content);
39
+ // Its own widgets are usable from the passage itself: the macros render
40
+ // before the {widget} definitions' effects run.
41
+ registerWidgetDefinitions(ast, 'StoryInit');
38
42
 
39
43
  // Mount into a persistent hidden container. It stays mounted until the
40
44
  // next execution (restart) — this lets async effects (useEffect,
package/src/styles.css CHANGED
@@ -430,6 +430,11 @@ tw-storydata {
430
430
 
431
431
  .playthrough-header {
432
432
  display: flex;
433
+ width: 100%;
434
+ border: 0;
435
+ background: none;
436
+ font-family: inherit;
437
+ text-align: left;
433
438
  align-items: center;
434
439
  gap: 0.5em;
435
440
  padding: 0.4em 0;