@rohal12/spindle 0.59.9 → 0.59.11

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.9",
3
+ "version": "0.59.11",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -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);
@@ -1,6 +1,15 @@
1
- import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
2
- import { useStoryStore } from '../../store';
3
- import { useStoryFields } from '../../hooks/use-story-fields';
1
+ import {
2
+ useRef,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useCallback,
6
+ useState,
7
+ } from 'preact/hooks';
8
+ import { useStoryStore, type StoryState } from '../../store';
9
+ import {
10
+ useStoryFields,
11
+ FrozenStateContext,
12
+ } from '../../hooks/use-story-fields';
4
13
  import { Passage, renderPassageContent } from '../Passage';
5
14
  import { defineMacro } from '../../define-macro';
6
15
  import { resolveTransition, type ResolvedTransition } from '../../transition';
@@ -126,6 +135,13 @@ defineMacro({
126
135
  // Track previous history length to detect restart/load
127
136
  const prevHistoryLenRef = useRef(0);
128
137
 
138
+ const displayedId = displayed.id;
139
+ const isOutgoing = useCallback(
140
+ (state: StoryState) => state.navigationId !== displayedId,
141
+ [displayedId],
142
+ );
143
+ const lastElement = useRef<preact.VNode | null>(null);
144
+
129
145
  // Container ref for snapshot insertion
130
146
  const containerRef = useRef<HTMLDivElement>(null);
131
147
 
@@ -265,6 +281,22 @@ defineMacro({
265
281
  // eslint-disable-next-line react-hooks/exhaustive-deps
266
282
  }, [navigationId]);
267
283
 
284
+ // A new passage opens at its beginning, not where the previous one was
285
+ // scrolled to (#354). The page keeps its position on first display
286
+ // (a refresh restores it) and when the beginning is already in view.
287
+ const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
288
+ const lastShownId = useRef(shownId);
289
+ useLayoutEffect(() => {
290
+ if (shownId === null) return; // between passages of a transition
291
+ const previous = lastShownId.current;
292
+ lastShownId.current = shownId;
293
+ if (previous === null || previous === shownId) return;
294
+ const el = containerRef.current?.querySelector('.passage');
295
+ if (el && el.getBoundingClientRect().top < 0) {
296
+ el.scrollIntoView?.({ block: 'start' });
297
+ }
298
+ }, [shownId]);
299
+
268
300
  // When render is deferred, show StoryLoading passage or nothing
269
301
  const effectivePassage = renderDeferred
270
302
  ? (storyData?.passages.get('StoryLoading') ?? null)
@@ -282,6 +314,23 @@ defineMacro({
282
314
 
283
315
  const readyPassage = storyData?.passages.get('PassageReady');
284
316
 
317
+ // Between a navigation and the commit of its passage, the old passage is
318
+ // still mounted. It must not execute against the destination's state, so
319
+ // it keeps the element and the state it last rendered with (#352).
320
+ const outgoing = !renderDeferred && displayed.id !== navigationId;
321
+ const passageElement =
322
+ outgoing && lastElement.current
323
+ ? lastElement.current
324
+ : effectivePassage && (
325
+ <Passage
326
+ passage={effectivePassage}
327
+ key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
328
+ dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
329
+ navigationId={renderDeferred ? undefined : displayed.id}
330
+ />
331
+ );
332
+ lastElement.current = passageElement || null;
333
+
285
334
  return (
286
335
  <div
287
336
  id={ctx.id ?? 'story'}
@@ -299,13 +348,10 @@ defineMacro({
299
348
  class="passage-container"
300
349
  ref={containerRef}
301
350
  >
302
- {effectivePassage && (
303
- <Passage
304
- passage={effectivePassage}
305
- key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
306
- dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
307
- navigationId={renderDeferred ? undefined : displayed.id}
308
- />
351
+ {passageElement && (
352
+ <FrozenStateContext.Provider value={isOutgoing}>
353
+ {passageElement}
354
+ </FrozenStateContext.Provider>
309
355
  )}
310
356
  </div>
311
357
  </div>
@@ -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);
@@ -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
 
@@ -15,9 +17,23 @@ interface WidgetInvocationProps {
15
17
  params: string[];
16
18
  rawArgs?: string;
17
19
  invocationChildren?: ASTNode[];
20
+ className?: string;
21
+ id?: string;
18
22
  }
19
23
 
20
- export function WidgetInvocation({
24
+ export function WidgetInvocation(props: WidgetInvocationProps) {
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} />,
33
+ );
34
+ }
35
+
36
+ function WidgetContent({
21
37
  body,
22
38
  params,
23
39
  rawArgs,
@@ -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,16 +10,20 @@ import {
10
10
  } from '../../markup/render';
11
11
  import type { ASTNode } from '../../markup/ast';
12
12
  import { useRenderOptions } from '../../hooks/use-render-options';
13
+ import { FrozenStateContext } from '../../hooks/use-story-fields';
13
14
  import { liveLocalsView } from '../../utils/live-locals';
14
15
  import { runWithCommittedMutations } from '../../execute-mutation';
15
16
  import { RepeatContext } from './Repeat';
16
17
  import { DialogCloseContext } from '../PassageDialog';
17
18
 
19
+ const frozenAlways = () => true;
20
+
18
21
  /**
19
22
  * Return a function that runs a macro body once, outside the passage tree:
20
23
  * it renders `children` into a detached node, so the body's macros ({set},
21
24
  * {if}, {goto}, ...) fire their side effects through the normal Preact
22
- * pipeline. Used by {button} and {link} on click. The body stays mounted
25
+ * pipeline. Used by {button} and {link} on click. The body's state is frozen
26
+ * once it has rendered, so it runs once per click. It stays mounted
23
27
  * until the owning macro unmounts (its passage changes, say), so work it
24
28
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
25
29
  * finish; the passage leaving cancels it.
@@ -58,21 +62,23 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
58
62
  const locals = liveLocalsView(updater.getValues);
59
63
  const container = document.createElement('div');
60
64
  render(
61
- <LocalsUpdateContext.Provider value={updater}>
62
- <LocalsValuesContext.Provider value={locals}>
63
- <NobrContext.Provider value={nobr}>
64
- <InlineContext.Provider value={inline}>
65
- <WidgetChildrenContext.Provider value={widgetChildren}>
66
- <RepeatContext.Provider value={repeat}>
67
- <DialogCloseContext.Provider value={closeDialog}>
68
- {renderNodes(children, { nobr, inline, locals })}
69
- </DialogCloseContext.Provider>
70
- </RepeatContext.Provider>
71
- </WidgetChildrenContext.Provider>
72
- </InlineContext.Provider>
73
- </NobrContext.Provider>
74
- </LocalsValuesContext.Provider>
75
- </LocalsUpdateContext.Provider>,
65
+ <FrozenStateContext.Provider value={frozenAlways}>
66
+ <LocalsUpdateContext.Provider value={updater}>
67
+ <LocalsValuesContext.Provider value={locals}>
68
+ <NobrContext.Provider value={nobr}>
69
+ <InlineContext.Provider value={inline}>
70
+ <WidgetChildrenContext.Provider value={widgetChildren}>
71
+ <RepeatContext.Provider value={repeat}>
72
+ <DialogCloseContext.Provider value={closeDialog}>
73
+ {renderNodes(children, { nobr, inline, locals })}
74
+ </DialogCloseContext.Provider>
75
+ </RepeatContext.Provider>
76
+ </WidgetChildrenContext.Provider>
77
+ </InlineContext.Provider>
78
+ </NobrContext.Provider>
79
+ </LocalsValuesContext.Provider>
80
+ </LocalsUpdateContext.Provider>
81
+ </FrozenStateContext.Provider>,
76
82
  container,
77
83
  );
78
84
  mounted.current.push(container);
@@ -59,14 +59,29 @@ export function defineInputMacro(
59
59
  perform: (v) => ctx.setValue!(toValue(v)),
60
60
  });
61
61
 
62
+ // What the reader last typed. A number input reports an unfinished
63
+ // number ("-", "2e") as an empty value, so the text is kept for as
64
+ // long as it still converts to the variable's value, instead of being
65
+ // replaced by that value on every keystroke (#353).
66
+ const typed = ctx.hooks.useRef<string | null>(null);
67
+ const shown =
68
+ typed.current !== null &&
69
+ toValue(typed.current) === ctx.value &&
70
+ type === 'number'
71
+ ? typed.current
72
+ : display(ctx.value);
73
+
62
74
  return h(type ? 'input' : 'textarea', {
63
75
  type,
64
76
  id: ctx.id,
65
77
  class: ctx.cls,
66
- value: display(ctx.value),
78
+ value: shown,
67
79
  placeholder,
68
- onInput: (e: Event) =>
69
- ctx.setValue!(toValue((e.target as HTMLInputElement).value)),
80
+ onInput: (e: Event) => {
81
+ const text = (e.target as HTMLInputElement).value;
82
+ typed.current = text;
83
+ ctx.setValue!(toValue(text));
84
+ },
70
85
  });
71
86
  },
72
87
  });
@@ -12,14 +12,17 @@ export function useMergedLocals(): readonly [
12
12
  Record<string, unknown>,
13
13
  Record<string, unknown>,
14
14
  ] {
15
- const { variables, temporary, transient } = useStoryFields(
15
+ // renderCounts is read by rendered()/hasRendered() inside expressions, so
16
+ // it re-renders them (and renews the tuple) when an inclusion mounts.
17
+ const { variables, temporary, transient, renderCounts } = useStoryFields(
16
18
  'variables',
17
19
  'temporary',
18
20
  'transient',
21
+ 'renderCounts',
19
22
  );
20
23
  const localsValues = useContext(LocalsValuesContext);
21
24
 
22
25
  return useMemo(() => {
23
26
  return [variables, temporary, localsValues, transient] as const;
24
- }, [variables, temporary, localsValues, transient]);
27
+ }, [variables, temporary, localsValues, transient, renderCounts]);
25
28
  }
@@ -29,7 +29,12 @@ function isRendered(el: HTMLElement): boolean {
29
29
 
30
30
  function focusables(root: HTMLElement): HTMLElement[] {
31
31
  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
32
- (el) => !el.hidden && !el.closest('[hidden], [inert]') && isRendered(el),
32
+ (el) =>
33
+ !el.hidden &&
34
+ !el.closest('[hidden], [inert]') &&
35
+ // Also covers controls disabled by a <fieldset disabled> ancestor
36
+ !el.matches(':disabled') &&
37
+ isRendered(el),
33
38
  );
34
39
  }
35
40
 
@@ -1,7 +1,20 @@
1
- import { useRef } from 'preact/hooks';
1
+ import { createContext } from 'preact';
2
+ import { useContext, useRef } from 'preact/hooks';
2
3
  import { shallow } from 'zustand/vanilla/shallow';
3
4
  import { useStoryStore, type StoryState } from '../store';
4
5
 
6
+ /**
7
+ * Tells the components below when to keep the state they last rendered with,
8
+ * given the store's current state: an owner (a click body, see
9
+ * useDetachedBody; a passage on its way out, see PassageDisplay) provides it
10
+ * so that a branch skipped earlier cannot run against later state (#351,
11
+ * #352). It runs inside the store's own notification, before the owner could
12
+ * re-render, which is why it is a predicate and not a flag.
13
+ */
14
+ export const FrozenStateContext = createContext<
15
+ ((state: StoryState) => boolean) | null
16
+ >(null);
17
+
5
18
  /**
6
19
  * Story state fields `keys`, re-rendering when any of them changes (as one
7
20
  * useStoryStore() selector per field would). The result keeps its identity
@@ -11,7 +24,9 @@ export function useStoryFields<K extends keyof StoryState>(
11
24
  ...keys: K[]
12
25
  ): Pick<StoryState, K> {
13
26
  const prev = useRef<Pick<StoryState, K>>();
27
+ const freeze = useContext(FrozenStateContext);
14
28
  return useStoryStore((s) => {
29
+ if (prev.current && freeze?.(s)) return prev.current;
15
30
  const next = {} as Pick<StoryState, K>;
16
31
  for (const key of keys) next[key] = s[key];
17
32
  return shallow(prev.current, next) ? prev.current! : (prev.current = next);
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
@@ -524,25 +524,43 @@ function useDirectAttributes(
524
524
  return direct.length > 0 ? elementRef : undefined;
525
525
  }
526
526
 
527
+ const decodedText = new Map<string, string>();
527
528
  const decodedAttributeText = new Map<string, string>();
528
529
 
529
530
  /**
530
- * Decode character references (`&amp;`, `&#123;`) in attribute text the way
531
- * the HTML parser decodes an attribute value, by letting it parse one.
531
+ * Decode character references (`&amp;`, `&#123;`) in `text` the way the HTML
532
+ * parser decodes them, by letting it parse them: in an attribute value, or
533
+ * else in element text.
532
534
  */
533
- function decodeAttributeText(text: string): string {
535
+ function decodeReferences(
536
+ text: string,
537
+ cache: Map<string, string>,
538
+ parse: (text: string) => string | null,
539
+ ): string {
534
540
  if (!text.includes('&')) return text;
535
- let decoded = decodedAttributeText.get(text);
541
+ let decoded = cache.get(text);
536
542
  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);
543
+ decoded = parse(text) ?? text;
544
+ cache.set(text, decoded);
542
545
  }
543
546
  return decoded;
544
547
  }
545
548
 
549
+ function decodeAttributeText(text: string): string {
550
+ return decodeReferences(text, decodedAttributeText, (t) => {
551
+ const html = `<i title="${t.replace(/"/g, '&quot;')}"></i>`;
552
+ return (parseHtmlInert(html).firstChild as Element).getAttribute('title');
553
+ });
554
+ }
555
+
556
+ /** Literal text outside markdown (preformatted and SVG elements). */
557
+ function decodeText(text: string): string {
558
+ return decodeReferences(text, decodedText, (t) => {
559
+ const html = `<i>${t.replace(/</g, '&lt;')}</i>`;
560
+ return parseHtmlInert(html).firstChild!.textContent;
561
+ });
562
+ }
563
+
546
564
  /** A `{` and a sigil starting a reference, in a code attribute. */
547
565
  const SIGIL_REFERENCE = /\{[$_@%]\w/;
548
566
 
@@ -679,14 +697,21 @@ function nodeKey(node: ASTNode): string {
679
697
  function renderMacro(node: MacroNode, key: string) {
680
698
  if (isSubMacro(node.name)) return null;
681
699
 
700
+ // What an invocation passes on to a widget and to a built-in macro alike
701
+ const invocation = {
702
+ rawArgs: node.rawArgs,
703
+ className: node.className,
704
+ id: node.id,
705
+ };
706
+
682
707
  const widget = getWidget(node.name);
683
708
  if (widget) {
684
709
  return (
685
710
  <WidgetInvocation
686
711
  key={key}
712
+ {...invocation}
687
713
  body={widget.body}
688
714
  params={widget.params}
689
- rawArgs={node.rawArgs}
690
715
  invocationChildren={node.children}
691
716
  />
692
717
  );
@@ -697,9 +722,7 @@ function renderMacro(node: MacroNode, key: string) {
697
722
  return (
698
723
  <Component
699
724
  key={key}
700
- rawArgs={node.rawArgs}
701
- className={node.className}
702
- id={node.id}
725
+ {...invocation}
703
726
  children={node.children}
704
727
  branches={node.branches}
705
728
  />
@@ -720,7 +743,8 @@ function renderMacro(node: MacroNode, key: string) {
720
743
  * Render a non-text AST node to a Preact element.
721
744
  */
722
745
  function renderSingleNode(node: ASTNode): preact.ComponentChildren {
723
- if (node.type === 'text') return node.value;
746
+ // Authored text outside markdown still has its character references decoded
747
+ if (node.type === 'text') return decodeText(node.value);
724
748
  const key = nodeKey(node);
725
749
  switch (node.type) {
726
750
  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