@rohal12/spindle 0.43.3 → 0.43.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.43.3",
3
+ "version": "0.43.4",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -8,14 +8,31 @@ import {
8
8
  /**
9
9
  * Parse a text string as CommonMark markdown and return an HTML string.
10
10
  * Includes GFM table and strikethrough extensions.
11
+ *
12
+ * When `inline` is true, block-level constructs (lists, headings, blockquotes,
13
+ * thematic breaks) are disabled. This is used when rendering content inside
14
+ * inline HTML elements like `<span>` where block-level output is invalid.
11
15
  */
12
- export function markdownToHtml(text: string): string {
16
+ export function markdownToHtml(
17
+ text: string,
18
+ options?: { inline?: boolean },
19
+ ): string {
20
+ const disabled: string[] = ['codeIndented'];
21
+ if (options?.inline) {
22
+ disabled.push(
23
+ 'list',
24
+ 'headingAtx',
25
+ 'setextUnderline',
26
+ 'thematicBreak',
27
+ 'blockQuote',
28
+ );
29
+ }
13
30
  return micromark(text, {
14
31
  allowDangerousHtml: true,
15
32
  extensions: [
16
33
  gfmTable(),
17
34
  gfmStrikethrough(),
18
- { disable: { null: ['codeIndented'] } },
35
+ { disable: { null: disabled } },
19
36
  ],
20
37
  htmlExtensions: [gfmTableHtml(), gfmStrikethroughHtml()],
21
38
  });
@@ -87,6 +87,43 @@ function convertDomNode(
87
87
  return null;
88
88
  }
89
89
 
90
+ /** Inline elements where block-level markdown (lists, headings) is invalid. */
91
+ const INLINE_ELEMENTS = new Set([
92
+ 'a',
93
+ 'abbr',
94
+ 'b',
95
+ 'bdi',
96
+ 'bdo',
97
+ 'br',
98
+ 'cite',
99
+ 'code',
100
+ 'data',
101
+ 'dfn',
102
+ 'em',
103
+ 'i',
104
+ 'kbd',
105
+ 'label',
106
+ 'mark',
107
+ 'meter',
108
+ 'output',
109
+ 'progress',
110
+ 'q',
111
+ 'rp',
112
+ 'rt',
113
+ 'ruby',
114
+ 's',
115
+ 'samp',
116
+ 'small',
117
+ 'span',
118
+ 'strong',
119
+ 'sub',
120
+ 'sup',
121
+ 'time',
122
+ 'u',
123
+ 'var',
124
+ 'wbr',
125
+ ]);
126
+
90
127
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
91
128
  const resolve = useInterpolate();
92
129
  const nobr = useContext(NobrContext);
@@ -97,13 +134,16 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
97
134
  attrs[k] = resolve(v) ?? v;
98
135
  }
99
136
  const isSvgRoot = node.tag.toLowerCase() === 'svg';
100
- // Inside SVG, skip markdown processing — markdown wraps content in <p> tags
101
- // which break the SVG namespace and produce zero-dimension elements.
137
+ const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
138
+ // Inside SVG, skip markdown processing entirely — markdown wraps content
139
+ // in <p> tags which break the SVG namespace.
140
+ // Inside inline elements, disable block-level markdown (lists, headings,
141
+ // blockquotes) since those produce invalid HTML inside inline containers.
102
142
  const children =
103
143
  node.children.length > 0
104
144
  ? inSvg || isSvgRoot
105
145
  ? renderInlineNodes(node.children)
106
- : renderNodes(node.children, { nobr, locals })
146
+ : renderNodes(node.children, { nobr, locals, inline: isInline })
107
147
  : undefined;
108
148
  const element = h(node.tag, attrs, children);
109
149
  return isSvgRoot ? (
@@ -254,6 +294,39 @@ function getVariableTextValue(
254
294
  return value == null ? '' : String(value);
255
295
  }
256
296
 
297
+ /**
298
+ * Characters/patterns that trigger CommonMark or GFM transformations.
299
+ * Any match → fall through to the full micromark pipeline.
300
+ * False positives (e.g. `-` used as text, not list) just use the slower path.
301
+ */
302
+ const MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\./;
303
+ const BLANK_LINE_RE = /\n\s*\n/;
304
+ const PLACEHOLDER_SPLIT_RE = /(<span data-tw="\d+"><\/span>)/;
305
+ const PLACEHOLDER_IDX_RE = /^<span data-tw="(\d+)"><\/span>$/;
306
+ const PLACEHOLDER_STRIP_RE = /<span data-tw="\d+"><\/span>/g;
307
+
308
+ /**
309
+ * Build Preact vnodes from a combined string that contains only plain text
310
+ * and <span data-tw="N"></span> placeholders. No micromark, no innerHTML.
311
+ */
312
+ function buildPlainTextVnodes(
313
+ combined: string,
314
+ components: preact.ComponentChildren[],
315
+ nobr?: boolean,
316
+ ): preact.ComponentChildren {
317
+ const parts = combined.split(PLACEHOLDER_SPLIT_RE);
318
+ const children: preact.ComponentChildren[] = [];
319
+ for (const part of parts) {
320
+ const m = PLACEHOLDER_IDX_RE.exec(part);
321
+ if (m) {
322
+ children.push(components[parseInt(m[1]!, 10)]);
323
+ } else if (part) {
324
+ children.push(part);
325
+ }
326
+ }
327
+ return nobr ? <>{children}</> : h('p', null, ...children);
328
+ }
329
+
257
330
  /**
258
331
  * Render AST nodes with full CommonMark markdown support.
259
332
  *
@@ -267,7 +340,11 @@ function getVariableTextValue(
267
340
  */
268
341
  export function renderNodes(
269
342
  nodes: ASTNode[],
270
- options?: { nobr?: boolean; locals?: Record<string, unknown> },
343
+ options?: {
344
+ nobr?: boolean;
345
+ locals?: Record<string, unknown>;
346
+ inline?: boolean;
347
+ },
271
348
  ): preact.ComponentChildren {
272
349
  if (nodes.length === 0) return null;
273
350
 
@@ -303,8 +380,17 @@ export function renderNodes(
303
380
  }
304
381
  }
305
382
 
383
+ // Fast path: skip micromark + innerHTML when text has no markdown syntax.
384
+ // This eliminates ~655 innerHTML calls on plain UI text like "ALMA",
385
+ // "▸ Crew", "Activate" that pass through the full pipeline only to
386
+ // produce the same text they started with (issue #145).
387
+ const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, '');
388
+ if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) {
389
+ return buildPlainTextVnodes(combined, components, options?.nobr);
390
+ }
391
+
306
392
  // Run combined text through markdown
307
- const html = markdownToHtml(combined);
393
+ const html = markdownToHtml(combined, { inline: options?.inline });
308
394
 
309
395
  // Convert HTML to Preact VNodes, replacing placeholders with components
310
396
  return htmlToPreact(html, components, options?.nobr);