@rohal12/spindle 0.52.7 → 0.52.9

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.
Files changed (47) hide show
  1. package/README.md +26 -0
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +986 -854
  4. package/dist/pkg/macro-registry.json +18 -0
  5. package/package.json +1 -1
  6. package/src/action-registry.ts +9 -0
  7. package/src/class-registry.ts +1 -5
  8. package/src/components/Passage.tsx +35 -39
  9. package/src/components/PassageDialog.tsx +2 -2
  10. package/src/components/StoryInterface.tsx +2 -2
  11. package/src/components/macros/Checkbox.tsx +3 -8
  12. package/src/components/macros/Computed.tsx +1 -1
  13. package/src/components/macros/Cycle.tsx +3 -6
  14. package/src/components/macros/ExprDisplay.tsx +12 -29
  15. package/src/components/macros/If.tsx +35 -41
  16. package/src/components/macros/Include.tsx +2 -1
  17. package/src/components/macros/Listbox.tsx +5 -7
  18. package/src/components/macros/Print.tsx +3 -5
  19. package/src/components/macros/Radiobutton.tsx +3 -5
  20. package/src/components/macros/Repeat.tsx +2 -1
  21. package/src/components/macros/Switch.tsx +6 -13
  22. package/src/components/macros/Timed.tsx +14 -18
  23. package/src/components/macros/Type.tsx +2 -1
  24. package/src/components/macros/VarDisplay.tsx +12 -32
  25. package/src/components/macros/Widget.tsx +49 -20
  26. package/src/components/macros/branches.ts +18 -0
  27. package/src/components/macros/display.ts +19 -0
  28. package/src/components/macros/input-macro.ts +33 -11
  29. package/src/components/macros/macro-args.ts +7 -0
  30. package/src/components/macros/option-utils.ts +10 -7
  31. package/src/define-macro.ts +4 -6
  32. package/src/expression.ts +58 -40
  33. package/src/hooks/use-action.ts +7 -15
  34. package/src/index.tsx +5 -15
  35. package/src/interpolation.ts +34 -41
  36. package/src/markup/ast.ts +4 -3
  37. package/src/markup/render.tsx +121 -65
  38. package/src/parser.ts +119 -13
  39. package/src/registry.ts +13 -12
  40. package/src/saves/save-manager.ts +18 -13
  41. package/src/saves/storage.ts +19 -20
  42. package/src/story-api.ts +30 -73
  43. package/src/structural.ts +1 -18
  44. package/src/types-drift-check.ts +37 -50
  45. package/src/utils/macro-names.ts +40 -0
  46. package/src/utils/namespace.ts +17 -0
  47. package/src/widgets/widget-registry.ts +5 -4
@@ -27,6 +27,7 @@ import { getMacro, getMacroText, isSubMacro } from './registry';
27
27
  import type { MacroTextContext } from './registry';
28
28
  import { getWidget } from './widgets/widget-registry';
29
29
  import { splitArgs } from './components/macros/arg-utils';
30
+ import { display } from './components/macros/display';
30
31
  import { splitSigilTemplate } from './markup/code-attributes';
31
32
  import {
32
33
  checkVariableName,
@@ -118,10 +119,6 @@ export function mapTextNodes(
118
119
  /** Deep enough for any real nesting; stops a widget that includes itself. */
119
120
  const MAX_DEPTH = 100;
120
121
 
121
- function display(value: unknown): string {
122
- return value == null ? '' : String(value);
123
- }
124
-
125
122
  /**
126
123
  * The value of a variable reference (`name` without its sigil, with an
127
124
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -154,6 +151,17 @@ function resolveVariable(
154
151
  return value;
155
152
  }
156
153
 
154
+ /** Evaluate an expression in `scope`. */
155
+ function evaluateIn(expr: string, scope: TextScope): unknown {
156
+ return evaluate(
157
+ expr,
158
+ scope.variables,
159
+ scope.temporary,
160
+ scope.locals,
161
+ scope.transient,
162
+ );
163
+ }
164
+
157
165
  class TextEvaluator {
158
166
  readonly errors: TextError[] = [];
159
167
 
@@ -163,16 +171,6 @@ class TextEvaluator {
163
171
  return text;
164
172
  }
165
173
 
166
- private evaluate(expr: string, scope: TextScope): unknown {
167
- return evaluate(
168
- expr,
169
- scope.variables,
170
- scope.temporary,
171
- scope.locals,
172
- scope.transient,
173
- );
174
- }
175
-
176
174
  private node(node: ASTNode, scope: TextScope, depth: number): string {
177
175
  switch (node.type) {
178
176
  case 'text':
@@ -190,7 +188,7 @@ class TextEvaluator {
190
188
  return this.variable(node.scope, node.name, scope);
191
189
  case 'expression':
192
190
  try {
193
- return display(this.evaluate(node.expression, scope));
191
+ return display(evaluateIn(node.expression, scope));
194
192
  } catch (error) {
195
193
  this.errors.push({ macro: 'expression', error });
196
194
  return '';
@@ -255,7 +253,7 @@ class TextEvaluator {
255
253
  const expr = argExprs[i];
256
254
  if (expr !== undefined) {
257
255
  try {
258
- value = this.evaluate(expr, scope);
256
+ value = evaluateIn(expr, scope);
259
257
  } catch {
260
258
  value = undefined;
261
259
  }
@@ -279,7 +277,7 @@ class TextEvaluator {
279
277
  }
280
278
 
281
279
  const ctx: MacroTextContext = {
282
- evaluate: (expr) => this.evaluate(expr, scope),
280
+ evaluate: (expr) => evaluateIn(expr, scope),
283
281
  renderText: (nodes, locals) =>
284
282
  this.nested(
285
283
  node.name,
@@ -369,15 +367,7 @@ export function interpolateCode(
369
367
  }
370
368
  } else {
371
369
  try {
372
- text += display(
373
- evaluate(
374
- part.expr,
375
- scope.variables,
376
- scope.temporary,
377
- scope.locals,
378
- scope.transient,
379
- ),
380
- );
370
+ text += display(evaluateIn(part.expr, scope));
381
371
  } catch (error) {
382
372
  errors.push({ macro: 'expression', error });
383
373
  }
@@ -386,22 +376,28 @@ export function interpolateCode(
386
376
  return { text, errors };
387
377
  }
388
378
 
379
+ /** The namespaces of a TextScope, passed one by one. */
380
+ type ScopeArgs = [
381
+ variables: Record<string, unknown>,
382
+ temporary: Record<string, unknown>,
383
+ locals: Record<string, unknown>,
384
+ transient?: Record<string, unknown>,
385
+ ];
386
+
387
+ function scopeOf(
388
+ ...[variables, temporary, locals, transient = {}]: ScopeArgs
389
+ ): TextScope {
390
+ return { variables, temporary, locals, transient };
391
+ }
392
+
389
393
  /**
390
394
  * Evaluate text-only markup to a string, throwing the first error met.
391
395
  */
392
396
  export function interpolate(
393
397
  template: string,
394
- variables: Record<string, unknown>,
395
- temporary: Record<string, unknown>,
396
- locals: Record<string, unknown>,
397
- transient: Record<string, unknown> = {},
398
+ ...namespaces: ScopeArgs
398
399
  ): string {
399
- const { text, errors } = interpolateText(template, {
400
- variables,
401
- temporary,
402
- locals,
403
- transient,
404
- });
400
+ const { text, errors } = interpolateText(template, scopeOf(...namespaces));
405
401
  if (errors.length > 0) throw errors[0]!.error;
406
402
  return text;
407
403
  }
@@ -412,10 +408,7 @@ export function interpolate(
412
408
  */
413
409
  export function interpolateExpression(
414
410
  expr: string,
415
- variables: Record<string, unknown>,
416
- temporary: Record<string, unknown>,
417
- locals: Record<string, unknown>,
418
- transient: Record<string, unknown> = {},
411
+ ...namespaces: ScopeArgs
419
412
  ): string {
420
- return display(evaluate(expr, variables, temporary, locals, transient));
413
+ return display(evaluateIn(expr, scopeOf(...namespaces)));
421
414
  }
package/src/markup/ast.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type Token,
7
7
  type VariableScope,
8
8
  } from './tokenizer';
9
+ import { NameSet } from '../utils/macro-names';
9
10
 
10
11
  export interface TextNode {
11
12
  type: 'text';
@@ -51,7 +52,7 @@ export type ASTNode =
51
52
  | HtmlNode;
52
53
 
53
54
  /** Macros that require a closing tag and can contain children */
54
- const BLOCK_MACROS = new Set([
55
+ const BLOCK_MACROS = new NameSet([
55
56
  'if',
56
57
  'for',
57
58
  'do',
@@ -70,12 +71,12 @@ const BLOCK_MACROS = new Set([
70
71
 
71
72
  /** Register a custom macro as a block macro so the AST builder nests children. */
72
73
  export function registerBlockMacro(name: string): void {
73
- BLOCK_MACROS.add(name.toLowerCase());
74
+ BLOCK_MACROS.add(name);
74
75
  }
75
76
 
76
77
  /** Unregister a custom block macro (for test cleanup). */
77
78
  export function unregisterBlockMacro(name: string): void {
78
- BLOCK_MACROS.delete(name.toLowerCase());
79
+ BLOCK_MACROS.delete(name);
79
80
  }
80
81
 
81
82
  /** Map from branch macro name → required parent macro name */
@@ -1,4 +1,4 @@
1
- import { createContext } from 'preact';
1
+ import { createContext, type RefObject } from 'preact';
2
2
  import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
3
3
  import { VarDisplay } from '../components/macros/VarDisplay';
4
4
  import { ExprDisplay } from '../components/macros/ExprDisplay';
@@ -63,6 +63,8 @@ export const RawTextContext = createContext(false);
63
63
  */
64
64
  const SvgContext = createContext(false);
65
65
 
66
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
67
+
66
68
  /** Elements whose content is literal text, not markdown. */
67
69
  const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
68
70
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
@@ -139,31 +141,59 @@ function expandPlaceholderText(
139
141
  );
140
142
  }
141
143
 
144
+ /** An attribute: its name, value and value as written (see splitAttributes). */
145
+ type Attribute = [name: string, value: string, written: string];
146
+
142
147
  /**
143
- * An element whose attributes contain placeholders (a variable in image alt
144
- * text or a link title). Each such attribute is evaluated as text against
145
- * the store and locals, so it follows its variables as the component would.
148
+ * An element of markdown output whose attributes contain placeholders (a
149
+ * variable in image alt text or a link title). Each such attribute is
150
+ * evaluated as text against the store and locals, so it follows its
151
+ * variables as the component would.
146
152
  */
147
153
  function PlaceholderAttributes({
148
154
  tag,
149
- props,
150
155
  attributes,
156
+ placeholders,
157
+ svg,
151
158
  children,
152
159
  }: {
153
160
  tag: string;
154
- props: Record<string, string>;
155
- attributes: Record<string, ASTNode[]>;
161
+ attributes: Attribute[];
162
+ placeholders: Record<string, ASTNode[]>;
163
+ svg: boolean;
156
164
  children: preact.ComponentChildren[];
157
165
  }) {
158
166
  const scope = useTextScope();
159
- const resolved: Record<string, string> = { ...props };
160
167
  const errors: AttributeError[] = [];
161
- for (const [name, nodes] of Object.entries(attributes)) {
168
+ const resolved = attributes.map(([name, value, written]): Attribute => {
169
+ const nodes = placeholders[name];
170
+ if (!nodes) return [name, value, written];
162
171
  const result = renderText(nodes, scope);
163
- resolved[name] = result.text;
164
172
  for (const error of result.errors) errors.push([name, error]);
165
- }
166
- return withAttributeErrors(errors, h(tag, resolved, ...children));
173
+ return [name, result.text, written];
174
+ });
175
+ const { props, direct } = splitAttributes(resolved, svg);
176
+ const ref = useDirectAttributes(direct);
177
+ return withAttributeErrors(
178
+ errors,
179
+ h(tag, ref ? { ...props, ref } : props, ...children),
180
+ );
181
+ }
182
+
183
+ /** An element of markdown output with attributes to set directly. */
184
+ function DirectAttributes({
185
+ tag,
186
+ props,
187
+ direct,
188
+ children,
189
+ }: {
190
+ tag: string;
191
+ props: Record<string, unknown>;
192
+ direct: [string, string][];
193
+ children: preact.ComponentChildren[];
194
+ }) {
195
+ const ref = useDirectAttributes(direct);
196
+ return h(tag, { ...props, ref }, ...children);
167
197
  }
168
198
 
169
199
  /** An error met while evaluating the named attribute. */
@@ -201,6 +231,19 @@ function placeholderIndex(node: Node | null, ph: Placeholders): number {
201
231
  return parseInt(value.slice(prefix.length), 10);
202
232
  }
203
233
 
234
+ /**
235
+ * Parse HTML into nodes without side effects. The content of a `<template>`
236
+ * belongs to a document with no browsing context, so parsing it fetches no
237
+ * images and media, runs no inline event handlers (`<img onerror>`) and
238
+ * constructs no custom elements. A detached `<div>` belongs to the page's
239
+ * document, which does all of these while parsing.
240
+ */
241
+ export function parseHtmlInert(html: string): DocumentFragment {
242
+ const template = document.createElement('template');
243
+ template.innerHTML = html;
244
+ return template.content;
245
+ }
246
+
204
247
  /**
205
248
  * Convert an HTML string (from micromark) to Preact VNodes,
206
249
  * replacing placeholder elements with pre-rendered components.
@@ -212,14 +255,15 @@ function htmlToPreact(
212
255
  ph: Placeholders,
213
256
  unwrapParagraphs = false,
214
257
  ): preact.ComponentChildren {
215
- const temp = document.createElement('div');
216
- temp.innerHTML = html.trim();
258
+ const content = parseHtmlInert(html.trim());
217
259
  if (unwrapParagraphs) {
218
- for (const p of Array.from(temp.querySelectorAll(':scope > p'))) {
219
- p.replaceWith(...Array.from(p.childNodes));
260
+ for (const child of Array.from(content.childNodes)) {
261
+ if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === 'P') {
262
+ (child as Element).replaceWith(...Array.from(child.childNodes));
263
+ }
220
264
  }
221
265
  }
222
- const children = Array.from(temp.childNodes).map((child, i) =>
266
+ const children = Array.from(content.childNodes).map((child, i) =>
223
267
  convertDomNode(child, i, ph),
224
268
  );
225
269
  return <>{children}</>;
@@ -252,17 +296,17 @@ function convertDomNode(
252
296
  return ph.components[idx];
253
297
  }
254
298
 
255
- // Convert attributes
256
- const props: Record<string, string> = {};
257
- let withPlaceholders: Record<string, ASTNode[]> | undefined;
258
- for (const attr of Array.from(el.attributes)) {
259
- const parts = splitPlaceholderText(attr.value, ph);
260
- if (parts.every((part) => typeof part === 'string')) {
261
- props[attr.name] = attr.value;
262
- continue;
263
- }
264
- withPlaceholders ??= {};
265
- withPlaceholders[attr.name] = parts.map(
299
+ // Convert attributes, as author HTML's (see splitAttributes): markdown
300
+ // output holds raw HTML the passage tokenizer didn't take as a tag
301
+ const svg = el.namespaceURI === SVG_NAMESPACE;
302
+ const attributes: Attribute[] = [];
303
+ let placeholders: Record<string, ASTNode[]> | undefined;
304
+ for (const { name, value } of Array.from(el.attributes)) {
305
+ attributes.push([name, value, value]);
306
+ const parts = splitPlaceholderText(value, ph);
307
+ if (parts.every((part) => typeof part === 'string')) continue;
308
+ placeholders ??= {};
309
+ placeholders[name] = parts.map(
266
310
  (part): ASTNode =>
267
311
  typeof part === 'string'
268
312
  ? { type: 'text', value: part }
@@ -275,13 +319,26 @@ function convertDomNode(
275
319
  convertDomNode(child, i, ph),
276
320
  );
277
321
 
278
- if (withPlaceholders) {
322
+ if (placeholders) {
279
323
  return (
280
324
  <PlaceholderAttributes
325
+ key={key}
326
+ tag={tag}
327
+ attributes={attributes}
328
+ placeholders={placeholders}
329
+ svg={svg}
330
+ children={children}
331
+ />
332
+ );
333
+ }
334
+ const { props, direct } = splitAttributes(attributes, svg);
335
+ if (direct.length > 0) {
336
+ return (
337
+ <DirectAttributes
281
338
  key={key}
282
339
  tag={tag}
283
340
  props={props}
284
- attributes={withPlaceholders}
341
+ direct={direct}
285
342
  children={children}
286
343
  />
287
344
  );
@@ -395,9 +452,8 @@ function setRawAttribute(el: Element, name: string, value: string) {
395
452
  el.setAttribute(name, value);
396
453
  return;
397
454
  }
398
- const template = document.createElement('template');
399
- template.innerHTML = `<i ${name}=""></i>`;
400
- const parsed = (template.content.firstChild as Element).attributes[0];
455
+ const parsed = (parseHtmlInert(`<i ${name}=""></i>`).firstChild as Element)
456
+ .attributes[0];
401
457
  if (!parsed) return;
402
458
  const attr = parsed.cloneNode() as Attr;
403
459
  attr.value = value;
@@ -422,7 +478,7 @@ function setRawAttribute(el: Element, name: string, value: string) {
422
478
  * `className` into `class`) are set directly instead.
423
479
  */
424
480
  function splitAttributes(
425
- attributes: [name: string, value: string, written: string][],
481
+ attributes: Attribute[],
426
482
  svg: boolean,
427
483
  ): { props: Record<string, unknown>; direct: [string, string][] } {
428
484
  const props: Record<string, unknown> = {};
@@ -447,6 +503,27 @@ function splitAttributes(
447
503
  return { props, direct };
448
504
  }
449
505
 
506
+ /**
507
+ * Set the attributes `splitAttributes` leaves to set directly on the element
508
+ * given the returned ref (none if there are none), and remove them again
509
+ * when they change.
510
+ */
511
+ function useDirectAttributes(
512
+ direct: [string, string][],
513
+ ): RefObject<Element> | undefined {
514
+ const elementRef = useRef<Element>(null);
515
+ const directKey = JSON.stringify(direct);
516
+ useLayoutEffect(() => {
517
+ const el = elementRef.current;
518
+ if (!el || direct.length === 0) return;
519
+ for (const [name, value] of direct) setRawAttribute(el, name, value);
520
+ return () => {
521
+ for (const [name] of direct) el.removeAttribute(name);
522
+ };
523
+ }, [directKey]);
524
+ return direct.length > 0 ? elementRef : undefined;
525
+ }
526
+
450
527
  const decodedAttributeText = new Map<string, string>();
451
528
 
452
529
  /**
@@ -457,10 +534,10 @@ function decodeAttributeText(text: string): string {
457
534
  if (!text.includes('&')) return text;
458
535
  let decoded = decodedAttributeText.get(text);
459
536
  if (decoded === undefined) {
460
- const template = document.createElement('template');
461
- template.innerHTML = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
537
+ const html = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
462
538
  decoded =
463
- (template.content.firstChild as Element).getAttribute('title') ?? text;
539
+ (parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
540
+ text;
464
541
  decodedAttributeText.set(text, decoded);
465
542
  }
466
543
  return decoded;
@@ -532,24 +609,11 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
532
609
  const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
533
610
  const errors: AttributeError[] = [];
534
611
  const resolved = Object.entries(node.attributes).map(
535
- ([k, v]): [string, string, string] => [
536
- k,
537
- resolveAttributeValue(k, v, scope, errors),
538
- v,
539
- ],
612
+ ([k, v]): Attribute => [k, resolveAttributeValue(k, v, scope, errors), v],
540
613
  );
541
614
  const { props, direct } = splitAttributes(resolved, inSvg || isSvgRoot);
542
- const elementRef = useRef<Element>(null);
543
- const directKey = JSON.stringify(direct);
544
- useLayoutEffect(() => {
545
- const el = elementRef.current;
546
- if (!el || direct.length === 0) return;
547
- for (const [name, value] of direct) setRawAttribute(el, name, value);
548
- return () => {
549
- for (const [name] of direct) el.removeAttribute(name);
550
- };
551
- }, [directKey]);
552
- if (direct.length > 0) props.ref = elementRef;
615
+ const ref = useDirectAttributes(direct);
616
+ if (ref) props.ref = ref;
553
617
  const isInline = INLINE_ELEMENTS.has(tag);
554
618
  // Inside SVG and preformatted elements, skip markdown processing entirely
555
619
  // (see RawTextContext).
@@ -588,12 +652,9 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
588
652
 
589
653
  function ChildrenSlot() {
590
654
  const childrenAST = useContext(WidgetChildrenContext);
591
- const nobr = useContext(NobrContext);
592
- const inline = useContext(InlineContext);
593
- const raw = useContext(RawTextContext);
594
- const locals = useContext(LocalsValuesContext);
655
+ const renderOptions = useRenderOptions();
595
656
  if (!childrenAST || childrenAST.length === 0) return null;
596
- return <>{renderNodes(childrenAST, { nobr, locals, inline, raw })}</>;
657
+ return <>{renderNodes(childrenAST, renderOptions)}</>;
597
658
  }
598
659
 
599
660
  /**
@@ -669,10 +730,7 @@ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
669
730
  return (
670
731
  <VarDisplay
671
732
  key={key}
672
- name={node.name}
673
- scope={node.scope}
674
- className={node.className}
675
- id={node.id}
733
+ node={node}
676
734
  />
677
735
  );
678
736
 
@@ -680,9 +738,7 @@ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
680
738
  return (
681
739
  <ExprDisplay
682
740
  key={key}
683
- expression={node.expression}
684
- className={node.className}
685
- id={node.id}
741
+ node={node}
686
742
  />
687
743
  );
688
744
 
package/src/parser.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { tokenize } from './markup/tokenizer';
2
+ import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
3
+
1
4
  export interface Passage {
2
5
  pid: number;
3
6
  name: string;
@@ -18,25 +21,128 @@ export interface StoryData {
18
21
  userScript: string;
19
22
  }
20
23
 
24
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
25
+
26
+ /** HTML elements written without content or end tag, as innerHTML does. */
27
+ const VOID_ELEMENTS = new Set([
28
+ 'area',
29
+ 'base',
30
+ 'basefont',
31
+ 'bgsound',
32
+ 'br',
33
+ 'col',
34
+ 'embed',
35
+ 'frame',
36
+ 'hr',
37
+ 'img',
38
+ 'input',
39
+ 'keygen',
40
+ 'link',
41
+ 'meta',
42
+ 'param',
43
+ 'source',
44
+ 'track',
45
+ 'wbr',
46
+ ]);
47
+
21
48
  /**
22
- * Decode the HTML entities that browsers use when serializing innerHTML.
23
- * &amp; is decoded last to avoid double-decoding (e.g. &amp;lt; → &lt;, not <).
49
+ * The passage markup in the nodes of passage data.
50
+ *
51
+ * A compiler that HTML-escapes passage text (Twine, Tweego) leaves a text
52
+ * node whose text is the markup; a no-break space in it is U+00A0, which
53
+ * innerHTML writes as `&nbsp;`. One that doesn't escape it leaves the
54
+ * browser's parse of it, whose elements are written back as markup here.
55
+ * innerHTML did that too, but its escaping can't be undone: a `"` in an
56
+ * attribute value is written `&quot;`, and decoding that ends the value
57
+ * early, while text it leaves unescaped (in `<style>`, comments) would be
58
+ * decoded wrongly.
24
59
  */
25
- function decodeHtmlEntities(html: string): string {
26
- return html
27
- .replace(/&lt;/g, '<')
28
- .replace(/&gt;/g, '>')
29
- .replace(/&quot;/g, '"')
30
- .replace(/&#39;/g, "'")
31
- .replace(/&amp;/g, '&');
60
+ function passageMarkup(parent: ParentNode): string {
61
+ let markup = '';
62
+ for (const node of Array.from(parent.childNodes)) {
63
+ if (node.nodeType === Node.TEXT_NODE) {
64
+ markup += (node as Text).data;
65
+ } else if (node.nodeType === Node.COMMENT_NODE) {
66
+ markup += `<!--${(node as Comment).data}-->`;
67
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
68
+ markup += elementMarkup(node as Element);
69
+ }
70
+ }
71
+ return markup;
72
+ }
73
+
74
+ /** An element of passage data as markup (see passageMarkup). */
75
+ function elementMarkup(el: Element): string {
76
+ const name = el.localName;
77
+ let markup = `<${name}`;
78
+ for (const attr of Array.from(el.attributes)) {
79
+ markup += ` ${attr.name}=${quoteAttribute(attr.name, attr.value)}`;
80
+ }
81
+ markup += '>';
82
+ if (el.namespaceURI === HTML_NAMESPACE && VOID_ELEMENTS.has(name)) {
83
+ return markup;
84
+ }
85
+ const content =
86
+ name === 'template' ? (el as HTMLTemplateElement).content : el;
87
+ return `${markup}${passageMarkup(content)}</${name}>`;
88
+ }
89
+
90
+ /**
91
+ * An attribute value quoted so that the passage tokenizer reads it back:
92
+ * in double quotes, else single ones, else with the double quotes in its
93
+ * text written as `&quot;`, which rendering decodes. Quotes inside markup in
94
+ * the value (`{print "a"}`) are code, and don't end it.
95
+ */
96
+ function quoteAttribute(name: string, value: string): string {
97
+ const code = isCodeAttribute(name);
98
+ const probe = code ? 'on' : 'a';
99
+ const readsBack = (quoted: string) => {
100
+ const tokens = tokenize(`<i ${probe}=${quoted}>`);
101
+ const tag = tokens[0];
102
+ return (
103
+ tokens.length === 1 &&
104
+ tag?.type === 'html' &&
105
+ tag.attributes[probe] === quoted.slice(1, -1)
106
+ );
107
+ };
108
+ return (
109
+ [`"${value}"`, `'${value}'`].find(readsBack) ??
110
+ `"${escapeTextQuotes(value, code)}"`
111
+ );
112
+ }
113
+
114
+ /**
115
+ * `value` with the double quotes in its text written as `&quot;`, but not
116
+ * those in markup (`{print "a"}`) or, in a code attribute, in sigil
117
+ * references (see splitSigilTemplate), which are code.
118
+ */
119
+ function escapeTextQuotes(value: string, code: boolean): string {
120
+ const escape = (text: string) => text.replace(/"/g, '&quot;');
121
+ if (code) {
122
+ return splitSigilTemplate(value)
123
+ .map((part) =>
124
+ 'text' in part
125
+ ? escape(part.text)
126
+ : 'expr' in part
127
+ ? `{${part.expr}}`
128
+ : part.verbatim,
129
+ )
130
+ .join('');
131
+ }
132
+ return tokenize(value, { text: true })
133
+ .map((token) => {
134
+ const source = value.slice(token.start, token.end);
135
+ return token.type === 'text' ? escape(source) : source;
136
+ })
137
+ .join('');
32
138
  }
33
139
 
34
140
  /**
35
141
  * Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
36
142
  *
37
- * Uses innerHTML + entity decoding instead of textContent so that HTML
38
- * tags inside passage markup (e.g. <div> inside {button}) are preserved
39
- * regardless of whether the compiler HTML-encoded them.
143
+ * Passage content is read with passageMarkup instead of textContent, so
144
+ * that HTML tags inside passage markup (e.g. <div> inside {button}) are
145
+ * preserved whether or not the compiler HTML-encoded them.
40
146
  */
41
147
  export function parseStoryData(): StoryData {
42
148
  const storyEl = document.querySelector('tw-storydata');
@@ -67,7 +173,7 @@ export function parseStoryData(): StoryData {
67
173
  const tags = (el.getAttribute('tags') || '')
68
174
  .split(/\s+/)
69
175
  .filter((t) => t.length > 0);
70
- const content = decodeHtmlEntities(el.innerHTML);
176
+ const content = passageMarkup(el);
71
177
 
72
178
  const metadata: Record<string, string> = {};
73
179
  const skipAttrs = new Set(['pid', 'name', 'tags']);