@rohal12/spindle 0.51.4 → 0.52.1

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 (69) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4512 -1702
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/action-registry.ts +70 -18
  7. package/src/automation/runner.ts +2 -1
  8. package/src/class-registry.ts +214 -103
  9. package/src/components/Passage.tsx +2 -2
  10. package/src/components/PassageDialog.tsx +2 -5
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Button.tsx +5 -31
  13. package/src/components/macros/Checkbox.tsx +7 -4
  14. package/src/components/macros/Computed.tsx +28 -14
  15. package/src/components/macros/For.tsx +29 -3
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +7 -6
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +12 -45
  20. package/src/components/macros/Meter.tsx +11 -3
  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 +5 -2
  25. package/src/components/macros/SaveManager.tsx +25 -8
  26. package/src/components/macros/Set.tsx +5 -4
  27. package/src/components/macros/Span.tsx +1 -0
  28. package/src/components/macros/StoryTitle.tsx +1 -0
  29. package/src/components/macros/Switch.tsx +13 -0
  30. package/src/components/macros/Unset.tsx +31 -10
  31. package/src/components/macros/VarDisplay.tsx +21 -4
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +17 -75
  34. package/src/components/macros/arg-utils.ts +107 -1
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +3 -2
  37. package/src/define-macro.ts +32 -5
  38. package/src/execute-mutation.ts +271 -68
  39. package/src/expression.ts +91 -59
  40. package/src/hooks/use-action.ts +24 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +12 -2
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1231 -97
  45. package/src/markup/ast.ts +7 -2
  46. package/src/markup/code-attributes.ts +64 -0
  47. package/src/markup/markdown.ts +188 -9
  48. package/src/markup/render.tsx +430 -49
  49. package/src/markup/tokenizer.ts +578 -110
  50. package/src/prng.ts +41 -8
  51. package/src/registry.ts +35 -0
  52. package/src/saves/save-manager.ts +346 -160
  53. package/src/saves/storage.ts +16 -7
  54. package/src/saves/types.ts +2 -1
  55. package/src/settings.ts +12 -9
  56. package/src/store.ts +640 -186
  57. package/src/story-api.ts +38 -73
  58. package/src/story-init.ts +1 -1
  59. package/src/story-variables.ts +98 -102
  60. package/src/triggers.ts +10 -12
  61. package/src/utils/counts.ts +45 -0
  62. package/src/utils/error-message.ts +12 -0
  63. package/src/utils/live-locals.ts +10 -3
  64. package/src/utils/namespace.ts +71 -0
  65. package/src/utils/object-path.ts +99 -14
  66. package/src/utils/stable-key.ts +14 -9
  67. package/src/widgets/widget-registry.ts +9 -0
  68. package/types/index.d.ts +43 -7
  69. 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';
@@ -8,7 +8,21 @@ import { getMacro, isSubMacro } from '../registry';
8
8
  import { markdownToHtml } from './markdown';
9
9
  import { h } from 'preact';
10
10
  import type { ASTNode, HtmlNode, MacroNode } from './ast';
11
- import { useInterpolate } from '../hooks/use-interpolate';
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';
12
26
 
13
27
  export interface LocalsUpdater {
14
28
  update: (key: string, value: unknown) => void;
@@ -21,10 +35,11 @@ const defaultUpdater: LocalsUpdater = {
21
35
  `Cannot set @${key} — local variables require a {for}, widget, {link}, or {button} scope`,
22
36
  );
23
37
  },
24
- getValues: () => ({}),
38
+ getValues: () => EMPTY_NAMESPACE,
25
39
  };
26
40
 
27
- export const LocalsValuesContext = createContext<Record<string, unknown>>({});
41
+ export const LocalsValuesContext =
42
+ createContext<Record<string, unknown>>(EMPTY_NAMESPACE);
28
43
  export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
29
44
  export const NobrContext = createContext(false);
30
45
  /**
@@ -33,23 +48,44 @@ export const NobrContext = createContext(false);
33
48
  * Macro and widget bodies read it so their content stays inline too.
34
49
  */
35
50
  export const InlineContext = createContext(false);
36
- export const SvgContext = 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']);
37
67
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
38
68
 
39
69
  /**
40
70
  * Components rendered for the non-text nodes of one renderNodes() call. Each
41
- * stands in the markdown source as `<span data-tw="NONCE:INDEX"></span>`.
71
+ * stands in the markdown source as `<span data-tw=NONCE:INDEX></span>`.
42
72
  * The per-call random nonce means author text that merely looks like a
43
73
  * placeholder (e.g. decoded from `&lt;span data-tw=...&gt;`) is never
44
- * swapped for a component.
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.
45
76
  */
46
77
  interface Placeholders {
47
78
  nonce: string;
48
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[];
49
85
  }
50
86
 
51
87
  function placeholderHtml(nonce: string, index: number): string {
52
- return `<span data-tw="${nonce}:${index}"></span>`;
88
+ return `<span data-tw=${nonce}:${index}></span>`;
53
89
  }
54
90
 
55
91
  /**
@@ -61,7 +97,7 @@ function placeholderHtml(nonce: string, index: number): string {
61
97
  * ESCAPE_GUARD in front of the placeholder.
62
98
  */
63
99
  const PLACEHOLDER_TEXT_RE =
64
- /(?:(?<=\\)\uE000)?<span data-tw="([0-9a-z]+):(\d+)"><\/span>/g;
100
+ /(?:(?<=\\)\uE000)?<span data-tw=([0-9a-z]+):(\d+)><\/span>/g;
65
101
 
66
102
  /**
67
103
  * Emitted between author text ending in a backslash and a placeholder, so
@@ -71,24 +107,90 @@ const PLACEHOLDER_TEXT_RE =
71
107
  */
72
108
  const ESCAPE_GUARD = '\uE000';
73
109
 
74
- /** Split text into literal parts and the components its placeholders name. */
75
- function expandPlaceholderText(
110
+ /**
111
+ * Split text into literal strings and the indexes of this call's
112
+ * placeholders in it.
113
+ */
114
+ function splitPlaceholderText(
76
115
  text: string,
77
116
  ph: Placeholders,
78
- ): preact.ComponentChildren[] {
79
- if (!text.includes('<span data-tw="')) return [text];
80
- const parts: preact.ComponentChildren[] = [];
117
+ ): (string | number)[] {
118
+ if (!text.includes('<span data-tw=')) return [text];
119
+ const parts: (string | number)[] = [];
81
120
  let last = 0;
82
121
  for (const m of text.matchAll(PLACEHOLDER_TEXT_RE)) {
83
122
  if (m[1] !== ph.nonce) continue;
84
123
  if (m.index > last) parts.push(text.slice(last, m.index));
85
- parts.push(ph.components[parseInt(m[2]!, 10)]);
124
+ parts.push(parseInt(m[2]!, 10));
86
125
  last = m.index + m[0].length;
87
126
  }
88
127
  if (last < text.length) parts.push(text.slice(last));
89
128
  return parts;
90
129
  }
91
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
+
92
194
  /** The component index of a placeholder element of this call, or -1. */
93
195
  function placeholderIndex(node: Node | null, ph: Placeholders): number {
94
196
  if (node == null || node.nodeType !== Node.ELEMENT_NODE) return -1;
@@ -150,9 +252,21 @@ function convertDomNode(
150
252
  }
151
253
 
152
254
  // Convert attributes
153
- const props: Record<string, string | number> = { key };
255
+ const props: Record<string, string> = {};
256
+ let withPlaceholders: Record<string, ASTNode[]> | undefined;
154
257
  for (const attr of Array.from(el.attributes)) {
155
- 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
+ );
156
270
  }
157
271
 
158
272
  // Convert children recursively
@@ -160,7 +274,18 @@ function convertDomNode(
160
274
  convertDomNode(child, i, ph),
161
275
  );
162
276
 
163
- 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);
164
289
  }
165
290
  return null;
166
291
  }
@@ -203,9 +328,19 @@ const INLINE_ELEMENTS = new Set([
203
328
  ]);
204
329
 
205
330
  /**
206
- * HTML boolean attributes. Their presence means "on", but a parsed bare or
207
- * `=""` attribute has the value '', which Preact would assign to the DOM
208
- * 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.
209
344
  */
210
345
  const BOOLEAN_ATTRIBUTES = new Set([
211
346
  'allowfullscreen',
@@ -235,31 +370,198 @@ const BOOLEAN_ATTRIBUTES = new Set([
235
370
  'selected',
236
371
  ]);
237
372
 
238
- function isPresentBooleanAttribute(name: string, value: string): boolean {
239
- 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;
240
523
  }
241
524
 
242
525
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
243
- const resolve = useInterpolate();
526
+ const scope = useTextScope();
244
527
  const nobr = useContext(NobrContext);
245
528
  const locals = useContext(LocalsValuesContext);
529
+ const inRaw = useContext(RawTextContext);
246
530
  const inSvg = useContext(SvgContext);
247
531
  const parentInline = useContext(InlineContext);
248
- const attrs: Record<string, unknown> = {};
249
- for (const [k, v] of Object.entries(node.attributes)) {
250
- attrs[k] = isPresentBooleanAttribute(k, v) ? true : (resolve(v) ?? v);
251
- }
252
- const isSvgRoot = node.tag.toLowerCase() === 'svg';
253
- const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
254
- // Inside SVG, skip markdown processing entirely — markdown wraps content
255
- // in <p> tags which break the SVG namespace.
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).
256
558
  // Inside inline elements, disable block-level markdown (lists, headings,
257
559
  // blockquotes) and <p> wrappers since those produce invalid HTML inside
258
560
  // inline containers. The inline flag reaches nested macro/widget bodies via
259
561
  // InlineContext; a block element nested inside resets it.
260
562
  let children: preact.ComponentChildren = undefined;
261
563
  if (node.children.length > 0) {
262
- if (inSvg || isSvgRoot) {
564
+ if (inRaw || isRawRoot) {
263
565
  children = renderInlineNodes(node.children);
264
566
  } else {
265
567
  children = renderNodes(node.children, { nobr, locals, inline: isInline });
@@ -272,11 +574,17 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
272
574
  }
273
575
  }
274
576
  }
275
- const element = h(node.tag, attrs, children);
276
- return isSvgRoot ? (
277
- <SvgContext.Provider value={true}>{element}</SvgContext.Provider>
278
- ) : (
279
- element
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
+ ),
280
588
  );
281
589
  }
282
590
 
@@ -284,9 +592,10 @@ function ChildrenSlot() {
284
592
  const childrenAST = useContext(WidgetChildrenContext);
285
593
  const nobr = useContext(NobrContext);
286
594
  const inline = useContext(InlineContext);
595
+ const raw = useContext(RawTextContext);
287
596
  const locals = useContext(LocalsValuesContext);
288
597
  if (!childrenAST || childrenAST.length === 0) return null;
289
- return <>{renderNodes(childrenAST, { nobr, locals, inline })}</>;
598
+ return <>{renderNodes(childrenAST, { nobr, locals, inline, raw })}</>;
290
599
  }
291
600
 
292
601
  /**
@@ -412,23 +721,68 @@ export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
412
721
  * Any match → fall through to the full micromark pipeline.
413
722
  * False positives (e.g. `-` used as text, not list) just use the slower path.
414
723
  * Includes character references (`&amp;`, `&#123;`) and two-space hard line
415
- * 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.
416
729
  */
417
730
  const MARKDOWN_SYNTAX_RE =
418
- /[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
419
- const BLANK_LINE_RE = /\n\s*\n/;
420
- const PLACEHOLDER_STRIP_RE = /<span data-tw="[0-9a-z]+:\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
+ }
421
773
 
422
774
  /**
423
775
  * Build Preact vnodes from a combined string that contains only plain text
424
- * and 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.
425
779
  */
426
780
  function buildPlainTextVnodes(
427
- combined: string,
781
+ core: string,
428
782
  ph: Placeholders,
429
783
  unwrapParagraphs?: boolean,
430
784
  ): preact.ComponentChildren {
431
- const children = expandPlaceholderText(combined, ph).filter(
785
+ const children = expandPlaceholderText(trimLineEdges(core), ph).filter(
432
786
  (part) => part !== '',
433
787
  );
434
788
  return unwrapParagraphs ? <>{children}</> : h('p', null, ...children);
@@ -455,9 +809,12 @@ export function renderNodes(
455
809
  /** Unused: components read locals from LocalsValuesContext. Kept for API compatibility. */
456
810
  locals?: Record<string, unknown>;
457
811
  inline?: boolean;
812
+ /** Literal content (see RawTextContext): no markdown processing. */
813
+ raw?: boolean;
458
814
  },
459
815
  ): preact.ComponentChildren {
460
816
  if (nodes.length === 0) return null;
817
+ if (options?.raw) return renderInlineNodes(nodes);
461
818
 
462
819
  // Skip the markdown pipeline when text nodes contain only whitespace.
463
820
  // This eliminates ~97 redundant micromark + innerHTML calls per render
@@ -466,7 +823,8 @@ export function renderNodes(
466
823
  // have markdown semantics (paragraph separation).
467
824
  const needsMarkdown = nodes.some(
468
825
  (n) =>
469
- 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)),
470
828
  );
471
829
  if (!needsMarkdown) {
472
830
  return nodes.map((node) => renderSingleNode(node));
@@ -477,6 +835,7 @@ export function renderNodes(
477
835
  const ph: Placeholders = {
478
836
  nonce: Math.random().toString(36).slice(2, 10) || '0',
479
837
  components,
838
+ nodes: [],
480
839
  };
481
840
  let combined = '';
482
841
 
@@ -488,6 +847,7 @@ export function renderNodes(
488
847
  }
489
848
  const phIdx = components.length;
490
849
  components.push(renderSingleNode(node));
850
+ ph.nodes.push(node);
491
851
  if (combined.endsWith('\\')) combined += ESCAPE_GUARD;
492
852
  combined += placeholderHtml(ph.nonce, phIdx);
493
853
  }
@@ -498,14 +858,35 @@ export function renderNodes(
498
858
  // produce the same text they started with (issue #145).
499
859
  // Inline content (inside <span> etc.) never gets <p> wrappers (#220).
500
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
+
501
882
  const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, '');
502
883
  if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) {
503
- return buildPlainTextVnodes(combined, ph, unwrapParagraphs);
884
+ return edges(buildPlainTextVnodes(core, ph, unwrapParagraphs));
504
885
  }
505
886
 
506
887
  // Run combined text through markdown
507
888
  const html = markdownToHtml(combined, { inline: options?.inline });
508
889
 
509
890
  // Convert HTML to Preact VNodes, replacing placeholders with components
510
- return htmlToPreact(html, ph, unwrapParagraphs);
891
+ return edges(htmlToPreact(html, ph, unwrapParagraphs));
511
892
  }