@rohal12/spindle 0.51.3 → 0.51.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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Lexical scanner for the JavaScript in expressions and macro arguments.
3
+ *
4
+ * It is the one place that knows where string, template and regex literals
5
+ * and comments begin and end. The expression transformer (`expression.ts`)
6
+ * and the macro argument splitters (`components/macros/arg-utils.ts`) both
7
+ * walk source text through `lexJs` and differ only in what they do with the
8
+ * pieces it reports.
9
+ *
10
+ * Besides literals, the scanner tracks whether the next token is an operand
11
+ * or an operator, which decides two ambiguities: `/` opens a regex in operand
12
+ * position and divides otherwise, and `%name` is a transient reference in
13
+ * operand position while `%` after an operand — `($n)%3`, `$a[i] %2`,
14
+ * `_i++ %n` — is the modulo operator.
15
+ */
16
+
17
+ export interface JsLexHandlers {
18
+ /**
19
+ * One character of code, outside literals and comments. `nesting` is the
20
+ * number of template-literal `${…}` interpolations around it (0 at top
21
+ * level).
22
+ */
23
+ code?(ch: string, index: number, nesting: number): void;
24
+ /**
25
+ * Literal text passed through verbatim: a string or regex literal, a
26
+ * comment, or a piece of a template literal (its backticks, text, escapes
27
+ * and the `${` / `}` delimiters around interpolations, whose code is
28
+ * reported through `code`).
29
+ */
30
+ literal?(text: string, index: number, nesting: number): void;
31
+ /** A `%name` transient variable reference (`name` without the `%`). */
32
+ transient?(name: string, index: number, nesting: number): void;
33
+ }
34
+
35
+ /** Transient name after `%`: an identifier, so `%3` is never a reference. */
36
+ const TRANS_NAME_RE = /[A-Za-z_]\w*/y;
37
+ /**
38
+ * The rest of an assignment target and its operator after a transient name:
39
+ * ` = 1`, `.a.b += 2`, but not `== 1` or `=> 1`.
40
+ */
41
+ const ASSIGNMENT_RE =
42
+ /(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:\*\*|<<|>>>?|&&|\|\||\?\?|[-+*/%&|^])?=(?![=>])/y;
43
+ /** Flags after the closing `/` of a regex literal. */
44
+ const REGEX_FLAGS_RE = /\w*/y;
45
+ /** Characters of identifiers, numbers and sigil variable references. */
46
+ const WORD_CHAR_RE = /[\w$@]/;
47
+ const SPACE_RE = /\s/;
48
+ /** Keywords followed by an operand rather than an operator. */
49
+ const OPERAND_KEYWORDS = new Set([
50
+ 'await',
51
+ 'case',
52
+ 'delete',
53
+ 'do',
54
+ 'else',
55
+ 'in',
56
+ 'instanceof',
57
+ 'new',
58
+ 'of',
59
+ 'return',
60
+ 'throw',
61
+ 'typeof',
62
+ 'void',
63
+ 'yield',
64
+ ]);
65
+ /** Keywords whose parenthesised header is followed by a statement. */
66
+ const HEADER_KEYWORDS = new Set(['for', 'if', 'while', 'with']);
67
+
68
+ /**
69
+ * Scan the `"…"` or `'…'` string literal opening at `start`. `end` is the
70
+ * index just past its closing quote, or `src.length` when it is unterminated
71
+ * (`closed` false). A backslash escapes the character after it, so a quote
72
+ * after an even run of backslashes closes the string and one after an odd
73
+ * run does not.
74
+ */
75
+ export function scanStringLiteral(
76
+ src: string,
77
+ start: number,
78
+ ): { end: number; closed: boolean } {
79
+ const quote = src.charAt(start);
80
+ let i = start + 1;
81
+ while (i < src.length) {
82
+ const c = src.charAt(i);
83
+ if (c === '\\') i += 2;
84
+ else if (c === quote) return { end: i + 1, closed: true };
85
+ else i++;
86
+ }
87
+ return { end: src.length, closed: false };
88
+ }
89
+
90
+ /** Index just past the regex literal (with flags) opening at `start`. */
91
+ function skipRegex(src: string, start: number): number {
92
+ let inClass = false; // inside `[…]`, where `/` does not close
93
+ let i = start + 1;
94
+ while (i < src.length) {
95
+ const c = src.charAt(i);
96
+ if (c === '\\') {
97
+ i += 2;
98
+ continue;
99
+ }
100
+ if (c === '\n') return i; // unterminated: leave the rest to the parser
101
+ if (inClass) {
102
+ if (c === ']') inClass = false;
103
+ } else if (c === '[') {
104
+ inClass = true;
105
+ } else if (c === '/') {
106
+ REGEX_FLAGS_RE.lastIndex = i + 1;
107
+ return i + 1 + (REGEX_FLAGS_RE.exec(src)?.[0].length ?? 0);
108
+ }
109
+ i++;
110
+ }
111
+ return src.length;
112
+ }
113
+
114
+ /** Index just past the comment opening at `start` (`//` or `/*`). */
115
+ function skipComment(src: string, start: number): number {
116
+ if (src.charAt(start + 1) === '/') {
117
+ const end = src.indexOf('\n', start);
118
+ return end < 0 ? src.length : end;
119
+ }
120
+ const end = src.indexOf('*/', start + 2);
121
+ return end < 0 ? src.length : end + 2;
122
+ }
123
+
124
+ /**
125
+ * Lex the template literal opening at `start` (a backtick): its backticks,
126
+ * text and escapes are reported as literal text and the code of its `${…}`
127
+ * interpolations through `lexJs` one nesting level deeper. Returns the index
128
+ * just past the closing backtick, or `src.length` if it is unterminated.
129
+ */
130
+ export function lexTemplate(
131
+ src: string,
132
+ start: number,
133
+ handlers: JsLexHandlers = {},
134
+ nesting = 0,
135
+ ): number {
136
+ const literal = (text: string, index: number) =>
137
+ handlers.literal?.(text, index, nesting);
138
+ literal('`', start);
139
+ let i = start + 1;
140
+ while (i < src.length) {
141
+ const c = src.charAt(i);
142
+ if (c === '\\') {
143
+ literal(src.slice(i, i + 2), i);
144
+ i += 2;
145
+ } else if (c === '`') {
146
+ literal(c, i);
147
+ return i + 1;
148
+ } else if (c === '$' && src.charAt(i + 1) === '{') {
149
+ literal('${', i);
150
+ i = lexJs(src, handlers, i + 2, nesting + 1);
151
+ if (i < src.length) {
152
+ literal('}', i);
153
+ i++;
154
+ }
155
+ } else {
156
+ literal(c, i);
157
+ i++;
158
+ }
159
+ }
160
+ return Math.min(i, src.length);
161
+ }
162
+
163
+ /**
164
+ * Walk `src` from `start`, reporting code characters, literal text and
165
+ * transient references to `handlers` in source order. Every character of the
166
+ * scanned range is reported exactly once (a transient reference covers its
167
+ * `%` and name). Returns the index where scanning stopped: `src.length`, or
168
+ * — for an interpolation (`nesting > 0`) — the index of the `}` closing it.
169
+ */
170
+ export function lexJs(
171
+ src: string,
172
+ handlers: JsLexHandlers,
173
+ start = 0,
174
+ nesting = 0,
175
+ ): number {
176
+ const interpolation = nesting > 0;
177
+ let i = start;
178
+
179
+ let operandNext = true; // an operand (not an operator) comes next
180
+ let word = ''; // identifier/number currently being read
181
+ let afterDot = false; // `word` is a property name, never a keyword
182
+ let afterHeaderKeyword = false; // last token was if/while/for/with
183
+ let lastPunct = '';
184
+ let lineBreak = false; // a line break since the last token
185
+ let braceDepth = 0;
186
+ const parenIsHeader: boolean[] = []; // per open `(`: closes a header?
187
+
188
+ const code = (ch: string, index: number) =>
189
+ handlers.code?.(ch, index, nesting);
190
+ const literal = (text: string, index: number) =>
191
+ handlers.literal?.(text, index, nesting);
192
+
193
+ function endWord() {
194
+ if (!word) return;
195
+ operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
196
+ afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
197
+ afterDot = false;
198
+ lastPunct = '';
199
+ lineBreak = false;
200
+ word = '';
201
+ }
202
+
203
+ /** A string, template or regex literal, or a transient reference, ended. */
204
+ function endOperand() {
205
+ endWord();
206
+ operandNext = false;
207
+ afterHeaderKeyword = false;
208
+ afterDot = false;
209
+ lastPunct = '';
210
+ lineBreak = false;
211
+ }
212
+
213
+ /** Track an operator token: `operandNext` tells what may follow it. */
214
+ function endPunct(punct: string, nextIsOperand: boolean) {
215
+ operandNext = nextIsOperand;
216
+ afterDot = punct === '.';
217
+ afterHeaderKeyword = false;
218
+ lastPunct = punct;
219
+ lineBreak = false;
220
+ }
221
+
222
+ function trackCode(c: string) {
223
+ if (WORD_CHAR_RE.test(c)) {
224
+ word += c;
225
+ return;
226
+ }
227
+ endWord();
228
+ // A line break alone never changes operand/operator position:
229
+ // `$x = 5\n%n` continues the expression, as in JavaScript.
230
+ if (c === '\n') lineBreak = true;
231
+ if (SPACE_RE.test(c)) return;
232
+ if (c === '(') parenIsHeader.push(afterHeaderKeyword);
233
+ if (c === '{') braceDepth++;
234
+ if (c === '}') braceDepth--;
235
+ if (c === ')') {
236
+ // `if (…) %x = 1` vs `($n)%3`
237
+ endPunct(c, parenIsHeader.pop() ?? false);
238
+ } else if (c === '.') {
239
+ // Property access, unless it is the spread `...`
240
+ endPunct(c, lastPunct === '.');
241
+ } else {
242
+ // `]` ends an operand; `}` closes a block, so a statement may follow.
243
+ endPunct(c, c !== ']');
244
+ }
245
+ }
246
+
247
+ while (i < src.length) {
248
+ const ch = src.charAt(i);
249
+
250
+ // String literal — skip entirely
251
+ if (ch === '"' || ch === "'") {
252
+ const { end } = scanStringLiteral(src, i);
253
+ literal(src.slice(i, end), i);
254
+ i = end;
255
+ endOperand();
256
+ continue;
257
+ }
258
+
259
+ if (ch === '`') {
260
+ i = lexTemplate(src, i, handlers, nesting);
261
+ endOperand();
262
+ continue;
263
+ }
264
+
265
+ if (ch === '/') {
266
+ endWord();
267
+ const next = src.charAt(i + 1);
268
+ // Comment — skip entirely; it is not a token
269
+ if (next === '/' || next === '*') {
270
+ const end = skipComment(src, i);
271
+ const comment = src.slice(i, end);
272
+ literal(comment, i);
273
+ if (comment.includes('\n')) lineBreak = true;
274
+ i = end;
275
+ continue;
276
+ }
277
+ // Regex literal — only where an operand is expected
278
+ if (operandNext) {
279
+ const end = skipRegex(src, i);
280
+ literal(src.slice(i, end), i);
281
+ i = end;
282
+ endOperand();
283
+ continue;
284
+ }
285
+ }
286
+
287
+ // Transient reference — where an operand is expected, or as the target
288
+ // of an assignment starting a line: `$x = 5\n%a = 1` would otherwise be
289
+ // the invalid assignment `5 % a = 1`.
290
+ if (ch === '%') {
291
+ endWord();
292
+ TRANS_NAME_RE.lastIndex = i + 1;
293
+ const name = TRANS_NAME_RE.exec(src)?.[0];
294
+ if (name) {
295
+ ASSIGNMENT_RE.lastIndex = i + 1 + name.length;
296
+ if (operandNext || (lineBreak && ASSIGNMENT_RE.test(src))) {
297
+ handlers.transient?.(name, i, nesting);
298
+ i += 1 + name.length;
299
+ endOperand();
300
+ continue;
301
+ }
302
+ }
303
+ }
304
+
305
+ // Increment/decrement: postfix after an operand on the same line (no line
306
+ // break may precede postfix `++`), prefix otherwise.
307
+ if ((ch === '+' || ch === '-') && src.charAt(i + 1) === ch) {
308
+ endWord();
309
+ const postfix = !operandNext && !lineBreak;
310
+ code(ch, i);
311
+ code(ch, i + 1);
312
+ i += 2;
313
+ endPunct(ch, !postfix);
314
+ continue;
315
+ }
316
+
317
+ // End of a template interpolation
318
+ if (ch === '}' && interpolation && braceDepth === 0) break;
319
+
320
+ // Regular code character
321
+ trackCode(ch);
322
+ code(ch, i);
323
+ i++;
324
+ }
325
+ return i;
326
+ }
@@ -7,8 +7,7 @@ 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';
10
+ import type { ASTNode, HtmlNode, MacroNode } from './ast';
12
11
  import { useInterpolate } from '../hooks/use-interpolate';
13
12
 
14
13
  export interface LocalsUpdater {
@@ -28,27 +27,97 @@ const defaultUpdater: LocalsUpdater = {
28
27
  export const LocalsValuesContext = createContext<Record<string, unknown>>({});
29
28
  export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
30
29
  export const NobrContext = createContext(false);
30
+ /**
31
+ * True while rendering inside an inline HTML element (e.g. `<span>`), where
32
+ * block-level markdown and `<p>` wrappers would produce invalid HTML (#220).
33
+ * Macro and widget bodies read it so their content stays inline too.
34
+ */
35
+ export const InlineContext = createContext(false);
31
36
  export const SvgContext = createContext(false);
32
37
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
33
38
 
39
+ /**
40
+ * 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>`.
42
+ * The per-call random nonce means author text that merely looks like a
43
+ * placeholder (e.g. decoded from `&lt;span data-tw=...&gt;`) is never
44
+ * swapped for a component.
45
+ */
46
+ interface Placeholders {
47
+ nonce: string;
48
+ components: preact.ComponentChildren[];
49
+ }
50
+
51
+ function placeholderHtml(nonce: string, index: number): string {
52
+ return `<span data-tw="${nonce}:${index}"></span>`;
53
+ }
54
+
55
+ /**
56
+ * A placeholder as text. micromark escapes placeholders in code (a code span
57
+ * of any backtick length, or a fenced code block), so they reach the DOM as
58
+ * literal text and are swapped for the live component there, keeping it
59
+ * subscribed (#223). Letting micromark decide what is code keeps this exact,
60
+ * with no CommonMark re-implementation to drift from it. Also consumes an
61
+ * ESCAPE_GUARD in front of the placeholder.
62
+ */
63
+ const PLACEHOLDER_TEXT_RE =
64
+ /(?:(?<=\\)\uE000)?<span data-tw="([0-9a-z]+):(\d+)"><\/span>/g;
65
+
66
+ /**
67
+ * Emitted between author text ending in a backslash and a placeholder, so
68
+ * the backslash cannot escape the placeholder's `<` and leak it as text. A
69
+ * backslash before a non-punctuation character stays literal in CommonMark,
70
+ * matching what the author wrote. Removed again when converting text nodes.
71
+ */
72
+ const ESCAPE_GUARD = '\uE000';
73
+
74
+ /** Split text into literal parts and the components its placeholders name. */
75
+ function expandPlaceholderText(
76
+ text: string,
77
+ ph: Placeholders,
78
+ ): preact.ComponentChildren[] {
79
+ if (!text.includes('<span data-tw="')) return [text];
80
+ const parts: preact.ComponentChildren[] = [];
81
+ let last = 0;
82
+ for (const m of text.matchAll(PLACEHOLDER_TEXT_RE)) {
83
+ if (m[1] !== ph.nonce) continue;
84
+ if (m.index > last) parts.push(text.slice(last, m.index));
85
+ parts.push(ph.components[parseInt(m[2]!, 10)]);
86
+ last = m.index + m[0].length;
87
+ }
88
+ if (last < text.length) parts.push(text.slice(last));
89
+ return parts;
90
+ }
91
+
92
+ /** The component index of a placeholder element of this call, or -1. */
93
+ function placeholderIndex(node: Node | null, ph: Placeholders): number {
94
+ if (node == null || node.nodeType !== Node.ELEMENT_NODE) return -1;
95
+ const value = (node as Element).getAttribute('data-tw');
96
+ const prefix = `${ph.nonce}:`;
97
+ if (value == null || !value.startsWith(prefix)) return -1;
98
+ return parseInt(value.slice(prefix.length), 10);
99
+ }
100
+
34
101
  /**
35
102
  * Convert an HTML string (from micromark) to Preact VNodes,
36
- * replacing <span data-tw="N"> placeholder elements with pre-rendered components.
103
+ * replacing placeholder elements with pre-rendered components.
104
+ * With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
105
+ * replaced by their children.
37
106
  */
38
107
  function htmlToPreact(
39
108
  html: string,
40
- components: preact.ComponentChildren[],
41
- nobr = false,
109
+ ph: Placeholders,
110
+ unwrapParagraphs = false,
42
111
  ): preact.ComponentChildren {
43
112
  const temp = document.createElement('div');
44
113
  temp.innerHTML = html.trim();
45
- if (nobr) {
114
+ if (unwrapParagraphs) {
46
115
  for (const p of Array.from(temp.querySelectorAll(':scope > p'))) {
47
116
  p.replaceWith(...Array.from(p.childNodes));
48
117
  }
49
118
  }
50
119
  const children = Array.from(temp.childNodes).map((child, i) =>
51
- convertDomNode(child, i, components),
120
+ convertDomNode(child, i, ph),
52
121
  );
53
122
  return <>{children}</>;
54
123
  }
@@ -56,19 +125,28 @@ function htmlToPreact(
56
125
  function convertDomNode(
57
126
  node: Node,
58
127
  key: number,
59
- components: preact.ComponentChildren[],
128
+ ph: Placeholders,
60
129
  ): preact.ComponentChildren {
61
130
  if (node.nodeType === Node.TEXT_NODE) {
62
- return node.textContent;
131
+ let text = node.textContent ?? '';
132
+ // Drop the escape guard in front of a placeholder element.
133
+ if (
134
+ text.endsWith(`\\${ESCAPE_GUARD}`) &&
135
+ placeholderIndex(node.nextSibling, ph) !== -1
136
+ ) {
137
+ text = text.slice(0, -1);
138
+ }
139
+ const parts = expandPlaceholderText(text, ph);
140
+ return parts.length === 1 ? parts[0] : parts;
63
141
  }
64
142
  if (node.nodeType === Node.ELEMENT_NODE) {
65
143
  const el = node as Element;
66
144
  const tag = el.localName;
67
145
 
68
146
  // 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)];
147
+ const idx = placeholderIndex(el, ph);
148
+ if (idx !== -1) {
149
+ return ph.components[idx];
72
150
  }
73
151
 
74
152
  // Convert attributes
@@ -79,7 +157,7 @@ function convertDomNode(
79
157
 
80
158
  // Convert children recursively
81
159
  const children = Array.from(el.childNodes).map((child, i) =>
82
- convertDomNode(child, i, components),
160
+ convertDomNode(child, i, ph),
83
161
  );
84
162
 
85
163
  return h(tag, props, ...children);
@@ -166,6 +244,7 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
166
244
  const nobr = useContext(NobrContext);
167
245
  const locals = useContext(LocalsValuesContext);
168
246
  const inSvg = useContext(SvgContext);
247
+ const parentInline = useContext(InlineContext);
169
248
  const attrs: Record<string, unknown> = {};
170
249
  for (const [k, v] of Object.entries(node.attributes)) {
171
250
  attrs[k] = isPresentBooleanAttribute(k, v) ? true : (resolve(v) ?? v);
@@ -175,13 +254,24 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
175
254
  // Inside SVG, skip markdown processing entirely — markdown wraps content
176
255
  // in <p> tags which break the SVG namespace.
177
256
  // 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;
257
+ // blockquotes) and <p> wrappers since those produce invalid HTML inside
258
+ // inline containers. The inline flag reaches nested macro/widget bodies via
259
+ // InlineContext; a block element nested inside resets it.
260
+ let children: preact.ComponentChildren = undefined;
261
+ if (node.children.length > 0) {
262
+ if (inSvg || isSvgRoot) {
263
+ children = renderInlineNodes(node.children);
264
+ } else {
265
+ children = renderNodes(node.children, { nobr, locals, inline: isInline });
266
+ if (isInline !== parentInline) {
267
+ children = (
268
+ <InlineContext.Provider value={isInline}>
269
+ {children}
270
+ </InlineContext.Provider>
271
+ );
272
+ }
273
+ }
274
+ }
185
275
  const element = h(node.tag, attrs, children);
186
276
  return isSvgRoot ? (
187
277
  <SvgContext.Provider value={true}>{element}</SvgContext.Provider>
@@ -193,9 +283,10 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
193
283
  function ChildrenSlot() {
194
284
  const childrenAST = useContext(WidgetChildrenContext);
195
285
  const nobr = useContext(NobrContext);
286
+ const inline = useContext(InlineContext);
196
287
  const locals = useContext(LocalsValuesContext);
197
288
  if (!childrenAST || childrenAST.length === 0) return null;
198
- return <>{renderNodes(childrenAST, { nobr, locals })}</>;
289
+ return <>{renderNodes(childrenAST, { nobr, locals, inline })}</>;
199
290
  }
200
291
 
201
292
  /**
@@ -316,37 +407,6 @@ export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
316
407
  return nodes.map((node) => renderSingleNode(node));
317
408
  }
318
409
 
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
410
  /**
351
411
  * Characters/patterns that trigger CommonMark or GFM transformations.
352
412
  * Any match → fall through to the full micromark pipeline.
@@ -357,47 +417,42 @@ function getVariableTextValue(
357
417
  const MARKDOWN_SYNTAX_RE =
358
418
  /[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
359
419
  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;
420
+ const PLACEHOLDER_STRIP_RE = /<span data-tw="[0-9a-z]+:\d+"><\/span>/g;
363
421
 
364
422
  /**
365
423
  * Build Preact vnodes from a combined string that contains only plain text
366
- * and <span data-tw="N"></span> placeholders. No micromark, no innerHTML.
424
+ * and placeholders. No micromark, no innerHTML.
367
425
  */
368
426
  function buildPlainTextVnodes(
369
427
  combined: string,
370
- components: preact.ComponentChildren[],
371
- nobr?: boolean,
428
+ ph: Placeholders,
429
+ unwrapParagraphs?: boolean,
372
430
  ): 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);
431
+ const children = expandPlaceholderText(combined, ph).filter(
432
+ (part) => part !== '',
433
+ );
434
+ return unwrapParagraphs ? <>{children}</> : h('p', null, ...children);
384
435
  }
385
436
 
386
437
  /**
387
438
  * Render AST nodes with full CommonMark markdown support.
388
439
  *
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
440
+ * Combines all nodes into a single markdown document, using placeholder
441
+ * elements (see Placeholders) for non-text nodes (variables, macros, links,
442
+ * HTML). This allows
391
443
  * markdown syntax to span across Twine tokens — e.g., markdown tables can
392
444
  * contain {$variables} and {macros} in their cells.
393
445
  *
394
446
  * After micromark processes the combined string, the HTML is parsed back into
395
447
  * Preact VNodes with placeholders replaced by the real rendered components.
448
+ * Placeholders that micromark escaped as code text are swapped back too (see
449
+ * PLACEHOLDER_TEXT_RE), so variables in code stay live components.
396
450
  */
397
451
  export function renderNodes(
398
452
  nodes: ASTNode[],
399
453
  options?: {
400
454
  nobr?: boolean;
455
+ /** Unused: components read locals from LocalsValuesContext. Kept for API compatibility. */
401
456
  locals?: Record<string, unknown>;
402
457
  inline?: boolean;
403
458
  },
@@ -419,35 +474,38 @@ export function renderNodes(
419
474
 
420
475
  // Build combined markdown string with placeholders for non-text nodes
421
476
  const components: preact.ComponentChildren[] = [];
477
+ const ph: Placeholders = {
478
+ nonce: Math.random().toString(36).slice(2, 10) || '0',
479
+ components,
480
+ };
422
481
  let combined = '';
423
- const locals = options?.locals ?? {};
424
482
 
425
483
  for (let i = 0; i < nodes.length; i++) {
426
484
  const node = nodes[i]!;
427
485
  if (node.type === 'text') {
428
486
  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>`;
487
+ continue;
436
488
  }
489
+ const phIdx = components.length;
490
+ components.push(renderSingleNode(node));
491
+ if (combined.endsWith('\\')) combined += ESCAPE_GUARD;
492
+ combined += placeholderHtml(ph.nonce, phIdx);
437
493
  }
438
494
 
439
495
  // Fast path: skip micromark + innerHTML when text has no markdown syntax.
440
496
  // This eliminates ~655 innerHTML calls on plain UI text like "ALMA",
441
497
  // "▸ Crew", "Activate" that pass through the full pipeline only to
442
498
  // produce the same text they started with (issue #145).
499
+ // Inline content (inside <span> etc.) never gets <p> wrappers (#220).
500
+ const unwrapParagraphs = !!(options?.nobr || options?.inline);
443
501
  const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, '');
444
502
  if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) {
445
- return buildPlainTextVnodes(combined, components, options?.nobr);
503
+ return buildPlainTextVnodes(combined, ph, unwrapParagraphs);
446
504
  }
447
505
 
448
506
  // Run combined text through markdown
449
507
  const html = markdownToHtml(combined, { inline: options?.inline });
450
508
 
451
509
  // Convert HTML to Preact VNodes, replacing placeholders with components
452
- return htmlToPreact(html, components, options?.nobr);
510
+ return htmlToPreact(html, ph, unwrapParagraphs);
453
511
  }