@rohal12/spindle 0.51.3 → 0.52.0

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 (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. package/types/tooling.d.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  import { createContext } from 'preact';
2
- import { useContext } from 'preact/hooks';
2
+ import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
3
3
  import { VarDisplay } from '../components/macros/VarDisplay';
4
4
  import { ExprDisplay } from '../components/macros/ExprDisplay';
5
5
  import { WidgetInvocation } from '../components/macros/WidgetInvocation';
@@ -7,9 +7,22 @@ import { getWidget } from '../widgets/widget-registry';
7
7
  import { getMacro, isSubMacro } from '../registry';
8
8
  import { markdownToHtml } from './markdown';
9
9
  import { h } from 'preact';
10
- import type { ASTNode, HtmlNode, MacroNode, VariableNode } from './ast';
11
- import { useStoryStore } from '../store';
12
- import { useInterpolate } from '../hooks/use-interpolate';
10
+ import type { ASTNode, HtmlNode, MacroNode } from './ast';
11
+ import { useTextScope } from '../hooks/use-interpolate';
12
+ import {
13
+ hasInterpolation,
14
+ interpolateCode,
15
+ mapTextNodes,
16
+ parseText,
17
+ renderText,
18
+ type ParsedText,
19
+ type TextError,
20
+ type TextScope,
21
+ } from '../interpolation';
22
+ import { MacroError } from '../components/macros/MacroError';
23
+ import { isCodeAttribute } from './code-attributes';
24
+ import { errorMessage } from '../utils/error-message';
25
+ import { EMPTY_NAMESPACE } from '../utils/namespace';
13
26
 
14
27
  export interface LocalsUpdater {
15
28
  update: (key: string, value: unknown) => void;
@@ -22,33 +35,191 @@ const defaultUpdater: LocalsUpdater = {
22
35
  `Cannot set @${key} — local variables require a {for}, widget, {link}, or {button} scope`,
23
36
  );
24
37
  },
25
- getValues: () => ({}),
38
+ getValues: () => EMPTY_NAMESPACE,
26
39
  };
27
40
 
28
- export const LocalsValuesContext = createContext<Record<string, unknown>>({});
41
+ export const LocalsValuesContext =
42
+ createContext<Record<string, unknown>>(EMPTY_NAMESPACE);
29
43
  export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
30
44
  export const NobrContext = createContext(false);
31
- export const SvgContext = createContext(false);
45
+ /**
46
+ * True while rendering inside an inline HTML element (e.g. `<span>`), where
47
+ * block-level markdown and `<p>` wrappers would produce invalid HTML (#220).
48
+ * Macro and widget bodies read it so their content stays inline too.
49
+ */
50
+ export const InlineContext = createContext(false);
51
+ /**
52
+ * True while rendering inside an element whose content is not markdown: SVG
53
+ * (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
54
+ * and `<textarea>`, whose text (indentation, `#`, `*`, ...) is literal.
55
+ * Macro and widget bodies read it so their content stays literal too.
56
+ */
57
+ export const RawTextContext = createContext(false);
58
+
59
+ /**
60
+ * True inside an `<svg>` element. SVG attributes are case-sensitive and have
61
+ * no live form properties, so they are set as written (see splitAttributes).
62
+ */
63
+ const SvgContext = createContext(false);
64
+
65
+ /** Elements whose content is literal text, not markdown. */
66
+ const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
32
67
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
33
68
 
69
+ /**
70
+ * Components rendered for the non-text nodes of one renderNodes() call. Each
71
+ * stands in the markdown source as `<span data-tw=NONCE:INDEX></span>`.
72
+ * The per-call random nonce means author text that merely looks like a
73
+ * placeholder (e.g. decoded from `&lt;span data-tw=...&gt;`) is never
74
+ * swapped for a component. The attribute value is unquoted so a placeholder
75
+ * can sit inside a quoted markdown link title or image alt text.
76
+ */
77
+ interface Placeholders {
78
+ nonce: string;
79
+ components: preact.ComponentChildren[];
80
+ /**
81
+ * Per placeholder, its node, for use in an attribute (image alt text, a
82
+ * link title), where it stands for its text (see interpolation.ts).
83
+ */
84
+ nodes: ASTNode[];
85
+ }
86
+
87
+ function placeholderHtml(nonce: string, index: number): string {
88
+ return `<span data-tw=${nonce}:${index}></span>`;
89
+ }
90
+
91
+ /**
92
+ * A placeholder as text. micromark escapes placeholders in code (a code span
93
+ * of any backtick length, or a fenced code block), so they reach the DOM as
94
+ * literal text and are swapped for the live component there, keeping it
95
+ * subscribed (#223). Letting micromark decide what is code keeps this exact,
96
+ * with no CommonMark re-implementation to drift from it. Also consumes an
97
+ * ESCAPE_GUARD in front of the placeholder.
98
+ */
99
+ const PLACEHOLDER_TEXT_RE =
100
+ /(?:(?<=\\)\uE000)?<span data-tw=([0-9a-z]+):(\d+)><\/span>/g;
101
+
102
+ /**
103
+ * Emitted between author text ending in a backslash and a placeholder, so
104
+ * the backslash cannot escape the placeholder's `<` and leak it as text. A
105
+ * backslash before a non-punctuation character stays literal in CommonMark,
106
+ * matching what the author wrote. Removed again when converting text nodes.
107
+ */
108
+ const ESCAPE_GUARD = '\uE000';
109
+
110
+ /**
111
+ * Split text into literal strings and the indexes of this call's
112
+ * placeholders in it.
113
+ */
114
+ function splitPlaceholderText(
115
+ text: string,
116
+ ph: Placeholders,
117
+ ): (string | number)[] {
118
+ if (!text.includes('<span data-tw=')) return [text];
119
+ const parts: (string | number)[] = [];
120
+ let last = 0;
121
+ for (const m of text.matchAll(PLACEHOLDER_TEXT_RE)) {
122
+ if (m[1] !== ph.nonce) continue;
123
+ if (m.index > last) parts.push(text.slice(last, m.index));
124
+ parts.push(parseInt(m[2]!, 10));
125
+ last = m.index + m[0].length;
126
+ }
127
+ if (last < text.length) parts.push(text.slice(last));
128
+ return parts;
129
+ }
130
+
131
+ /** Split text into literal parts and the components its placeholders name. */
132
+ function expandPlaceholderText(
133
+ text: string,
134
+ ph: Placeholders,
135
+ ): preact.ComponentChildren[] {
136
+ return splitPlaceholderText(text, ph).map((part) =>
137
+ typeof part === 'number' ? ph.components[part] : part,
138
+ );
139
+ }
140
+
141
+ /**
142
+ * An element whose attributes contain placeholders (a variable in image alt
143
+ * text or a link title). Each such attribute is evaluated as text against
144
+ * the store and locals, so it follows its variables as the component would.
145
+ */
146
+ function PlaceholderAttributes({
147
+ tag,
148
+ props,
149
+ attributes,
150
+ children,
151
+ }: {
152
+ tag: string;
153
+ props: Record<string, string>;
154
+ attributes: Record<string, ASTNode[]>;
155
+ children: preact.ComponentChildren[];
156
+ }) {
157
+ const scope = useTextScope();
158
+ const resolved: Record<string, string> = { ...props };
159
+ const errors: AttributeError[] = [];
160
+ for (const [name, nodes] of Object.entries(attributes)) {
161
+ const result = renderText(nodes, scope);
162
+ resolved[name] = result.text;
163
+ for (const error of result.errors) errors.push([name, error]);
164
+ }
165
+ return withAttributeErrors(errors, h(tag, resolved, ...children));
166
+ }
167
+
168
+ /** An error met while evaluating the named attribute. */
169
+ type AttributeError = [attribute: string, error: TextError];
170
+
171
+ /**
172
+ * Show the errors met in an element's attributes in front of it, the way a
173
+ * failing macro shows its error in passage text.
174
+ */
175
+ function withAttributeErrors(
176
+ errors: AttributeError[],
177
+ element: preact.ComponentChildren,
178
+ ): preact.ComponentChildren {
179
+ if (errors.length === 0) return element;
180
+ return (
181
+ <>
182
+ {errors.map(([name, { macro, error }], i) => (
183
+ <MacroError
184
+ key={i}
185
+ macro={macro}
186
+ error={new Error(`in attribute "${name}": ${errorMessage(error)}`)}
187
+ />
188
+ ))}
189
+ {element}
190
+ </>
191
+ );
192
+ }
193
+
194
+ /** The component index of a placeholder element of this call, or -1. */
195
+ function placeholderIndex(node: Node | null, ph: Placeholders): number {
196
+ if (node == null || node.nodeType !== Node.ELEMENT_NODE) return -1;
197
+ const value = (node as Element).getAttribute('data-tw');
198
+ const prefix = `${ph.nonce}:`;
199
+ if (value == null || !value.startsWith(prefix)) return -1;
200
+ return parseInt(value.slice(prefix.length), 10);
201
+ }
202
+
34
203
  /**
35
204
  * Convert an HTML string (from micromark) to Preact VNodes,
36
- * replacing <span data-tw="N"> placeholder elements with pre-rendered components.
205
+ * replacing placeholder elements with pre-rendered components.
206
+ * With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
207
+ * replaced by their children.
37
208
  */
38
209
  function htmlToPreact(
39
210
  html: string,
40
- components: preact.ComponentChildren[],
41
- nobr = false,
211
+ ph: Placeholders,
212
+ unwrapParagraphs = false,
42
213
  ): preact.ComponentChildren {
43
214
  const temp = document.createElement('div');
44
215
  temp.innerHTML = html.trim();
45
- if (nobr) {
216
+ if (unwrapParagraphs) {
46
217
  for (const p of Array.from(temp.querySelectorAll(':scope > p'))) {
47
218
  p.replaceWith(...Array.from(p.childNodes));
48
219
  }
49
220
  }
50
221
  const children = Array.from(temp.childNodes).map((child, i) =>
51
- convertDomNode(child, i, components),
222
+ convertDomNode(child, i, ph),
52
223
  );
53
224
  return <>{children}</>;
54
225
  }
@@ -56,33 +227,65 @@ function htmlToPreact(
56
227
  function convertDomNode(
57
228
  node: Node,
58
229
  key: number,
59
- components: preact.ComponentChildren[],
230
+ ph: Placeholders,
60
231
  ): preact.ComponentChildren {
61
232
  if (node.nodeType === Node.TEXT_NODE) {
62
- return node.textContent;
233
+ let text = node.textContent ?? '';
234
+ // Drop the escape guard in front of a placeholder element.
235
+ if (
236
+ text.endsWith(`\\${ESCAPE_GUARD}`) &&
237
+ placeholderIndex(node.nextSibling, ph) !== -1
238
+ ) {
239
+ text = text.slice(0, -1);
240
+ }
241
+ const parts = expandPlaceholderText(text, ph);
242
+ return parts.length === 1 ? parts[0] : parts;
63
243
  }
64
244
  if (node.nodeType === Node.ELEMENT_NODE) {
65
245
  const el = node as Element;
66
246
  const tag = el.localName;
67
247
 
68
248
  // Check if it's a placeholder for a Twine component
69
- const twIdx = el.getAttribute('data-tw');
70
- if (twIdx != null) {
71
- return components[parseInt(twIdx, 10)];
249
+ const idx = placeholderIndex(el, ph);
250
+ if (idx !== -1) {
251
+ return ph.components[idx];
72
252
  }
73
253
 
74
254
  // Convert attributes
75
- const props: Record<string, string | number> = { key };
255
+ const props: Record<string, string> = {};
256
+ let withPlaceholders: Record<string, ASTNode[]> | undefined;
76
257
  for (const attr of Array.from(el.attributes)) {
77
- props[attr.name] = attr.value;
258
+ const parts = splitPlaceholderText(attr.value, ph);
259
+ if (parts.every((part) => typeof part === 'string')) {
260
+ props[attr.name] = attr.value;
261
+ continue;
262
+ }
263
+ withPlaceholders ??= {};
264
+ withPlaceholders[attr.name] = parts.map(
265
+ (part): ASTNode =>
266
+ typeof part === 'string'
267
+ ? { type: 'text', value: part }
268
+ : ph.nodes[part]!,
269
+ );
78
270
  }
79
271
 
80
272
  // Convert children recursively
81
273
  const children = Array.from(el.childNodes).map((child, i) =>
82
- convertDomNode(child, i, components),
274
+ convertDomNode(child, i, ph),
83
275
  );
84
276
 
85
- return h(tag, props, ...children);
277
+ if (withPlaceholders) {
278
+ return (
279
+ <PlaceholderAttributes
280
+ key={key}
281
+ tag={tag}
282
+ props={props}
283
+ attributes={withPlaceholders}
284
+ children={children}
285
+ />
286
+ );
287
+ }
288
+ return h(tag, { ...props, key }, ...children);
86
289
  }
87
290
  return null;
88
291
  }
@@ -125,9 +328,19 @@ const INLINE_ELEMENTS = new Set([
125
328
  ]);
126
329
 
127
330
  /**
128
- * HTML boolean attributes. Their presence means "on", but a parsed bare or
129
- * `=""` attribute has the value '', which Preact would assign to the DOM
130
- * property as a falsy value — so present ones are passed as `true` (#177).
331
+ * Attributes that Preact keeps as DOM properties on author HTML elements:
332
+ * the live state of form controls and media, which then follows its
333
+ * variable even after the reader changed it. Boolean ones written bare or
334
+ * as `=""` are passed as `true`, since '' is falsy (#177); one that only
335
+ * resolves to '' through an interpolation stays ''.
336
+ */
337
+ const LIVE_PROPERTIES = new Set(['value', 'checked', 'selected', 'muted']);
338
+
339
+ /**
340
+ * HTML boolean attributes: present means on. Written bare or with a value
341
+ * they are present, as in HTML; one whose interpolation resolves to ''
342
+ * (`disabled="{$locked ? 'disabled' : ''}"`) is left out, so a variable can
343
+ * switch it off.
131
344
  */
132
345
  const BOOLEAN_ATTRIBUTES = new Set([
133
346
  'allowfullscreen',
@@ -157,45 +370,232 @@ const BOOLEAN_ATTRIBUTES = new Set([
157
370
  'selected',
158
371
  ]);
159
372
 
160
- function isPresentBooleanAttribute(name: string, value: string): boolean {
161
- return value === '' && BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
373
+ /** Names Preact consumes instead of setting (case-sensitive). */
374
+ const PREACT_RESERVED = new Set([
375
+ 'key',
376
+ 'ref',
377
+ 'children',
378
+ 'dangerouslySetInnerHTML',
379
+ ]);
380
+
381
+ /** Names set directly on SVG elements (see splitAttributes). */
382
+ const SVG_DIRECT = new Set([...PREACT_RESERVED, 'class', 'className']);
383
+
384
+ /** Attribute names setAttribute accepts (`@click` is parsed, not settable). */
385
+ const SETTABLE_NAME = /^[A-Za-z_:][\w:.-]*$/;
386
+
387
+ /**
388
+ * Set an attribute exactly as written, also one whose name setAttribute
389
+ * rejects but the HTML parser accepts: such an attribute is parsed and its
390
+ * node copied over.
391
+ */
392
+ function setRawAttribute(el: Element, name: string, value: string) {
393
+ if (SETTABLE_NAME.test(name)) {
394
+ el.setAttribute(name, value);
395
+ return;
396
+ }
397
+ const template = document.createElement('template');
398
+ template.innerHTML = `<i ${name}=""></i>`;
399
+ const parsed = (template.content.firstChild as Element).attributes[0];
400
+ if (!parsed) return;
401
+ const attr = parsed.cloneNode() as Attr;
402
+ attr.value = value;
403
+ el.setAttributeNode(attr);
404
+ }
405
+
406
+ /**
407
+ * Split author attributes into Preact props and attributes to set directly.
408
+ *
409
+ * Author HTML means attributes, but as props Preact assigns names of DOM
410
+ * properties to the property (so `draggable="false"` and
411
+ * `spellcheck="false"` meant true), registers `on…` as event listeners (an
412
+ * `onclick="…"` string threw) and consumes `key`, `ref` (a string threw),
413
+ * `children` and `dangerouslySetInnerHTML`; preact/compat, which spindle
414
+ * loads, also drops an empty `class` and `translate="no"`.
415
+ *
416
+ * On HTML elements an upper-case prop name avoids all of these, and
417
+ * setAttribute lower-cases it back. Names compat matches in any case
418
+ * (`on…`, `translate`), names setAttribute rejects, and on SVG elements,
419
+ * whose names are case-sensitive, Preact's reserved names and the `class`
420
+ * and `className` compat rewrites (dropping an empty `class`, turning
421
+ * `className` into `class`) are set directly instead.
422
+ */
423
+ function splitAttributes(
424
+ attributes: [name: string, value: string, written: string][],
425
+ svg: boolean,
426
+ ): { props: Record<string, unknown>; direct: [string, string][] } {
427
+ const props: Record<string, unknown> = {};
428
+ const direct: [string, string][] = [];
429
+ for (const [name, value, written] of attributes) {
430
+ const lower = name.toLowerCase();
431
+ if (BOOLEAN_ATTRIBUTES.has(lower) && written !== '' && value === '') {
432
+ continue;
433
+ } else if (!svg && LIVE_PROPERTIES.has(lower)) {
434
+ props[lower] = lower !== 'value' && written === '' ? true : value;
435
+ } else if (
436
+ !SETTABLE_NAME.test(name) ||
437
+ lower.startsWith('on') ||
438
+ lower === 'translate' ||
439
+ (svg && (SVG_DIRECT.has(name) || name === '__proto__'))
440
+ ) {
441
+ direct.push([name, value]);
442
+ } else {
443
+ props[svg ? name : name.toUpperCase()] = value;
444
+ }
445
+ }
446
+ return { props, direct };
447
+ }
448
+
449
+ const decodedAttributeText = new Map<string, string>();
450
+
451
+ /**
452
+ * Decode character references (`&amp;`, `&#123;`) in attribute text the way
453
+ * the HTML parser decodes an attribute value, by letting it parse one.
454
+ */
455
+ function decodeAttributeText(text: string): string {
456
+ if (!text.includes('&')) return text;
457
+ let decoded = decodedAttributeText.get(text);
458
+ if (decoded === undefined) {
459
+ const template = document.createElement('template');
460
+ template.innerHTML = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
461
+ decoded =
462
+ (template.content.firstChild as Element).getAttribute('title') ?? text;
463
+ decodedAttributeText.set(text, decoded);
464
+ }
465
+ return decoded;
466
+ }
467
+
468
+ /** A `{` and a sigil starting a reference, in a code attribute. */
469
+ const SIGIL_REFERENCE = /\{[$_@%]\w/;
470
+
471
+ const attributeNodes = new Map<string, ParsedText>();
472
+ const ATTRIBUTE_CACHE_LIMIT = 2000;
473
+
474
+ /**
475
+ * Parse an author-written attribute value as text-only markup, with the
476
+ * character references in its literal text decoded (macro bodies included).
477
+ * Decoding follows parsing, so a reference that decodes to a brace
478
+ * (`&#123;$x}`) stays literal, and a variable's value is never decoded.
479
+ */
480
+ function parseAttributeValue(value: string): ParsedText {
481
+ let parsed = attributeNodes.get(value);
482
+ if (parsed === undefined) {
483
+ parsed = parseText(value);
484
+ if ('nodes' in parsed && value.includes('&')) {
485
+ parsed = { nodes: mapTextNodes(parsed.nodes, decodeAttributeText) };
486
+ }
487
+ if (attributeNodes.size >= ATTRIBUTE_CACHE_LIMIT) attributeNodes.clear();
488
+ attributeNodes.set(value, parsed);
489
+ }
490
+ return parsed;
491
+ }
492
+
493
+ /**
494
+ * An author-written attribute value with its markup evaluated (see
495
+ * interpolation.ts) and its character references decoded. Errors are added
496
+ * to `errors`; a value whose markup doesn't parse is kept as written. The
497
+ * value of a code attribute (`onclick`, see isCodeAttribute) only has its
498
+ * sigil references resolved.
499
+ */
500
+ function resolveAttributeValue(
501
+ name: string,
502
+ value: string,
503
+ scope: TextScope,
504
+ errors: AttributeError[],
505
+ ): string {
506
+ if (isCodeAttribute(name)) {
507
+ // As before #225: without a character reference, a value with no sigil
508
+ // reference is taken as written.
509
+ if (!value.includes('&') && !SIGIL_REFERENCE.test(value)) return value;
510
+ const result = interpolateCode(value, scope, decodeAttributeText);
511
+ for (const error of result.errors) errors.push([name, error]);
512
+ return result.text;
513
+ }
514
+ if (!hasInterpolation(value)) return decodeAttributeText(value);
515
+ const parsed = parseAttributeValue(value);
516
+ if ('error' in parsed) {
517
+ errors.push([name, { macro: 'markup', error: parsed.error }]);
518
+ return decodeAttributeText(value);
519
+ }
520
+ const result = renderText(parsed.nodes, scope);
521
+ for (const error of result.errors) errors.push([name, error]);
522
+ return result.text;
162
523
  }
163
524
 
164
525
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
165
- const resolve = useInterpolate();
526
+ const scope = useTextScope();
166
527
  const nobr = useContext(NobrContext);
167
528
  const locals = useContext(LocalsValuesContext);
529
+ const inRaw = useContext(RawTextContext);
168
530
  const inSvg = useContext(SvgContext);
169
- const attrs: Record<string, unknown> = {};
170
- for (const [k, v] of Object.entries(node.attributes)) {
171
- attrs[k] = isPresentBooleanAttribute(k, v) ? true : (resolve(v) ?? v);
172
- }
173
- const isSvgRoot = node.tag.toLowerCase() === 'svg';
174
- const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
175
- // Inside SVG, skip markdown processing entirely — markdown wraps content
176
- // in <p> tags which break the SVG namespace.
531
+ const parentInline = useContext(InlineContext);
532
+ const tag = node.tag.toLowerCase();
533
+ const isSvgRoot = tag === 'svg';
534
+ const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
535
+ const errors: AttributeError[] = [];
536
+ const resolved = Object.entries(node.attributes).map(
537
+ ([k, v]): [string, string, string] => [
538
+ k,
539
+ resolveAttributeValue(k, v, scope, errors),
540
+ v,
541
+ ],
542
+ );
543
+ const { props, direct } = splitAttributes(resolved, inSvg || isSvgRoot);
544
+ const elementRef = useRef<Element>(null);
545
+ const directKey = JSON.stringify(direct);
546
+ useLayoutEffect(() => {
547
+ const el = elementRef.current;
548
+ if (!el || direct.length === 0) return;
549
+ for (const [name, value] of direct) setRawAttribute(el, name, value);
550
+ return () => {
551
+ for (const [name] of direct) el.removeAttribute(name);
552
+ };
553
+ }, [directKey]);
554
+ if (direct.length > 0) props.ref = elementRef;
555
+ const isInline = INLINE_ELEMENTS.has(tag);
556
+ // Inside SVG and preformatted elements, skip markdown processing entirely
557
+ // (see RawTextContext).
177
558
  // Inside inline elements, disable block-level markdown (lists, headings,
178
- // blockquotes) since those produce invalid HTML inside inline containers.
179
- const children =
180
- node.children.length > 0
181
- ? inSvg || isSvgRoot
182
- ? renderInlineNodes(node.children)
183
- : renderNodes(node.children, { nobr, locals, inline: isInline })
184
- : undefined;
185
- const element = h(node.tag, attrs, children);
186
- return isSvgRoot ? (
187
- <SvgContext.Provider value={true}>{element}</SvgContext.Provider>
188
- ) : (
189
- element
559
+ // blockquotes) and <p> wrappers since those produce invalid HTML inside
560
+ // inline containers. The inline flag reaches nested macro/widget bodies via
561
+ // InlineContext; a block element nested inside resets it.
562
+ let children: preact.ComponentChildren = undefined;
563
+ if (node.children.length > 0) {
564
+ if (inRaw || isRawRoot) {
565
+ children = renderInlineNodes(node.children);
566
+ } else {
567
+ children = renderNodes(node.children, { nobr, locals, inline: isInline });
568
+ if (isInline !== parentInline) {
569
+ children = (
570
+ <InlineContext.Provider value={isInline}>
571
+ {children}
572
+ </InlineContext.Provider>
573
+ );
574
+ }
575
+ }
576
+ }
577
+ let element = h(node.tag, props, children);
578
+ if (isSvgRoot && !inSvg) {
579
+ element = <SvgContext.Provider value={true}>{element}</SvgContext.Provider>;
580
+ }
581
+ return withAttributeErrors(
582
+ errors,
583
+ isRawRoot ? (
584
+ <RawTextContext.Provider value={true}>{element}</RawTextContext.Provider>
585
+ ) : (
586
+ element
587
+ ),
190
588
  );
191
589
  }
192
590
 
193
591
  function ChildrenSlot() {
194
592
  const childrenAST = useContext(WidgetChildrenContext);
195
593
  const nobr = useContext(NobrContext);
594
+ const inline = useContext(InlineContext);
595
+ const raw = useContext(RawTextContext);
196
596
  const locals = useContext(LocalsValuesContext);
197
597
  if (!childrenAST || childrenAST.length === 0) return null;
198
- return <>{renderNodes(childrenAST, { nobr, locals })}</>;
598
+ return <>{renderNodes(childrenAST, { nobr, locals, inline, raw })}</>;
199
599
  }
200
600
 
201
601
  /**
@@ -316,93 +716,105 @@ export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
316
716
  return nodes.map((node) => renderSingleNode(node));
317
717
  }
318
718
 
319
- function hasUnclosedBacktick(s: string): boolean {
320
- let count = 0;
321
- for (let i = 0; i < s.length; i++) {
322
- if (s[i] === '`') count++;
323
- }
324
- return count % 2 === 1;
325
- }
326
-
327
- function getVariableTextValue(
328
- node: VariableNode,
329
- locals: Record<string, unknown>,
330
- ): string {
331
- const state = useStoryStore.getState();
332
- const parts = node.name.split('.');
333
- const root = parts[0]!;
334
-
335
- let value: unknown;
336
- if (node.scope === 'variable') value = state.variables[root];
337
- else if (node.scope === 'temporary') value = state.temporary[root];
338
- else if (node.scope === 'transient') value = state.transient[root];
339
- else value = locals[root];
340
-
341
- for (let i = 1; i < parts.length; i++) {
342
- // Primitives box on access, so `{$name.length}` works too.
343
- if (value == null) return '';
344
- value = (value as Record<string, unknown>)[parts[i]!];
345
- }
346
-
347
- return value == null ? '' : String(value);
348
- }
349
-
350
719
  /**
351
720
  * Characters/patterns that trigger CommonMark or GFM transformations.
352
721
  * Any match → fall through to the full micromark pipeline.
353
722
  * False positives (e.g. `-` used as text, not list) just use the slower path.
354
723
  * Includes character references (`&amp;`, `&#123;`) and two-space hard line
355
- * breaks, which micromark decodes / turns into <br> (#171).
724
+ * breaks, which micromark decodes / turns into <br> (#171), and the openers
725
+ * of raw HTML comments, processing instructions, CDATA sections and
726
+ * declarations (`<!`, `<?`), which micromark passes through as HTML. A CR
727
+ * line ending (`\r\n` or `\r`) also takes the full pipeline, which reads
728
+ * it as micromark does.
356
729
  */
357
730
  const MARKDOWN_SYNTAX_RE =
358
- /[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
359
- const BLANK_LINE_RE = /\n\s*\n/;
360
- const PLACEHOLDER_SPLIT_RE = /(<span data-tw="\d+"><\/span>)/;
361
- const PLACEHOLDER_IDX_RE = /^<span data-tw="(\d+)"><\/span>$/;
362
- const PLACEHOLDER_STRIP_RE = /<span data-tw="\d+"><\/span>/g;
731
+ /[*_`#|~\[>\\\-+=\r]|!\[|\d+[.)]|&#?[a-zA-Z0-9]+;| {2}\n|<[!?]/;
732
+ /** Two line endings (LF, CRLF or CR) with only whitespace between them. */
733
+ const BLANK_LINE_RE = /(?:\r\n|\r(?!\n)|\n)\s*[\r\n]/;
734
+ const PLACEHOLDER_STRIP_RE = /<span data-tw=[0-9a-z]+:\d+><\/span>/g;
735
+
736
+ /** Whitespace that markdown strips at the start and end of a paragraph. */
737
+ const EDGE_WS = ' \t\r\n';
738
+
739
+ /**
740
+ * Index of the first character from `from` on (`step` 1) or before `from`
741
+ * (`step` -1) that is not in `chars`, or where the run of them ends. A loop,
742
+ * not a regex: `/[ \t]*$/` and the like try each position of a whitespace
743
+ * run, taking quadratic time on a long run in the middle of a passage.
744
+ */
745
+ function skipChars(s: string, from: number, step: 1 | -1, chars: string) {
746
+ let i = from;
747
+ if (step === 1) {
748
+ while (i < s.length && chars.includes(s[i]!)) i++;
749
+ } else {
750
+ while (i > 0 && chars.includes(s[i - 1]!)) i--;
751
+ }
752
+ return i;
753
+ }
754
+
755
+ /**
756
+ * Drop the spaces and tabs around line endings, as markdown does within a
757
+ * paragraph.
758
+ */
759
+ function trimLineEdges(text: string): string {
760
+ if (!text.includes('\n')) return text;
761
+ return text
762
+ .split('\n')
763
+ .map((line, k, lines) => {
764
+ const start = k === 0 ? 0 : skipChars(line, 0, 1, ' \t');
765
+ const end =
766
+ k === lines.length - 1
767
+ ? line.length
768
+ : skipChars(line, line.length, -1, ' \t');
769
+ return line.slice(start, Math.max(start, end));
770
+ })
771
+ .join('\n');
772
+ }
363
773
 
364
774
  /**
365
775
  * Build Preact vnodes from a combined string that contains only plain text
366
- * and <span data-tw="N"></span> placeholders. No micromark, no innerHTML.
776
+ * and placeholders. No micromark, no innerHTML. The text is the content of
777
+ * one paragraph, so like micromark it drops spaces and tabs around line
778
+ * endings; its edges are already split off by the caller.
367
779
  */
368
780
  function buildPlainTextVnodes(
369
- combined: string,
370
- components: preact.ComponentChildren[],
371
- nobr?: boolean,
781
+ core: string,
782
+ ph: Placeholders,
783
+ unwrapParagraphs?: boolean,
372
784
  ): preact.ComponentChildren {
373
- const parts = combined.split(PLACEHOLDER_SPLIT_RE);
374
- const children: preact.ComponentChildren[] = [];
375
- for (const part of parts) {
376
- const m = PLACEHOLDER_IDX_RE.exec(part);
377
- if (m) {
378
- children.push(components[parseInt(m[1]!, 10)]);
379
- } else if (part) {
380
- children.push(part);
381
- }
382
- }
383
- return nobr ? <>{children}</> : h('p', null, ...children);
785
+ const children = expandPlaceholderText(trimLineEdges(core), ph).filter(
786
+ (part) => part !== '',
787
+ );
788
+ return unwrapParagraphs ? <>{children}</> : h('p', null, ...children);
384
789
  }
385
790
 
386
791
  /**
387
792
  * Render AST nodes with full CommonMark markdown support.
388
793
  *
389
- * Combines all nodes into a single markdown document, using <tw-N> placeholder
390
- * elements for non-text nodes (variables, macros, links, HTML). This allows
794
+ * Combines all nodes into a single markdown document, using placeholder
795
+ * elements (see Placeholders) for non-text nodes (variables, macros, links,
796
+ * HTML). This allows
391
797
  * markdown syntax to span across Twine tokens — e.g., markdown tables can
392
798
  * contain {$variables} and {macros} in their cells.
393
799
  *
394
800
  * After micromark processes the combined string, the HTML is parsed back into
395
801
  * Preact VNodes with placeholders replaced by the real rendered components.
802
+ * Placeholders that micromark escaped as code text are swapped back too (see
803
+ * PLACEHOLDER_TEXT_RE), so variables in code stay live components.
396
804
  */
397
805
  export function renderNodes(
398
806
  nodes: ASTNode[],
399
807
  options?: {
400
808
  nobr?: boolean;
809
+ /** Unused: components read locals from LocalsValuesContext. Kept for API compatibility. */
401
810
  locals?: Record<string, unknown>;
402
811
  inline?: boolean;
812
+ /** Literal content (see RawTextContext): no markdown processing. */
813
+ raw?: boolean;
403
814
  },
404
815
  ): preact.ComponentChildren {
405
816
  if (nodes.length === 0) return null;
817
+ if (options?.raw) return renderInlineNodes(nodes);
406
818
 
407
819
  // Skip the markdown pipeline when text nodes contain only whitespace.
408
820
  // This eliminates ~97 redundant micromark + innerHTML calls per render
@@ -411,7 +823,8 @@ export function renderNodes(
411
823
  // have markdown semantics (paragraph separation).
412
824
  const needsMarkdown = nodes.some(
413
825
  (n) =>
414
- n.type === 'text' && (n.value.trim() !== '' || /\n\s*\n/.test(n.value)),
826
+ n.type === 'text' &&
827
+ (n.value.trim() !== '' || BLANK_LINE_RE.test(n.value)),
415
828
  );
416
829
  if (!needsMarkdown) {
417
830
  return nodes.map((node) => renderSingleNode(node));
@@ -419,35 +832,61 @@ export function renderNodes(
419
832
 
420
833
  // Build combined markdown string with placeholders for non-text nodes
421
834
  const components: preact.ComponentChildren[] = [];
835
+ const ph: Placeholders = {
836
+ nonce: Math.random().toString(36).slice(2, 10) || '0',
837
+ components,
838
+ nodes: [],
839
+ };
422
840
  let combined = '';
423
- const locals = options?.locals ?? {};
424
841
 
425
842
  for (let i = 0; i < nodes.length; i++) {
426
843
  const node = nodes[i]!;
427
844
  if (node.type === 'text') {
428
845
  combined += node.value;
429
- } else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
430
- // Inline variable value to avoid placeholder inside code span
431
- combined += getVariableTextValue(node, locals);
432
- } else {
433
- const phIdx = components.length;
434
- components.push(renderSingleNode(node));
435
- combined += `<span data-tw="${phIdx}"></span>`;
846
+ continue;
436
847
  }
848
+ const phIdx = components.length;
849
+ components.push(renderSingleNode(node));
850
+ ph.nodes.push(node);
851
+ if (combined.endsWith('\\')) combined += ESCAPE_GUARD;
852
+ combined += placeholderHtml(ph.nonce, phIdx);
437
853
  }
438
854
 
439
855
  // Fast path: skip micromark + innerHTML when text has no markdown syntax.
440
856
  // This eliminates ~655 innerHTML calls on plain UI text like "ALMA",
441
857
  // "▸ Crew", "Activate" that pass through the full pipeline only to
442
858
  // produce the same text they started with (issue #145).
859
+ // Inline content (inside <span> etc.) never gets <p> wrappers (#220).
860
+ const unwrapParagraphs = !!(options?.nobr || options?.inline);
861
+
862
+ // Markdown strips whitespace at paragraph edges. Without <p> wrappers that
863
+ // whitespace separates this content from its neighbours (as in
864
+ // `<span>*HP*: </span>{$hp}`), so it is kept around the output there.
865
+ const lead = combined.slice(0, skipChars(combined, 0, 1, EDGE_WS));
866
+ const trail =
867
+ lead.length === combined.length
868
+ ? ''
869
+ : combined.slice(skipChars(combined, combined.length, -1, EDGE_WS));
870
+ const core = combined.slice(lead.length, combined.length - trail.length);
871
+ const edges = (content: preact.ComponentChildren) =>
872
+ unwrapParagraphs && (lead || trail) ? (
873
+ <>
874
+ {lead}
875
+ {content}
876
+ {trail}
877
+ </>
878
+ ) : (
879
+ content
880
+ );
881
+
443
882
  const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, '');
444
883
  if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) {
445
- return buildPlainTextVnodes(combined, components, options?.nobr);
884
+ return edges(buildPlainTextVnodes(core, ph, unwrapParagraphs));
446
885
  }
447
886
 
448
887
  // Run combined text through markdown
449
888
  const html = markdownToHtml(combined, { inline: options?.inline });
450
889
 
451
890
  // Convert HTML to Preact VNodes, replacing placeholders with components
452
- return htmlToPreact(html, components, options?.nobr);
891
+ return edges(htmlToPreact(html, ph, unwrapParagraphs));
453
892
  }