@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1294 -737
- package/dist/pkg/story-variables.js +22 -12
- package/package.json +1 -1
- package/src/class-registry.ts +76 -0
- package/src/components/macros/Button.tsx +4 -1
- package/src/components/macros/Checkbox.tsx +5 -2
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +5 -2
- package/src/components/macros/Include.tsx +9 -19
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +18 -40
- package/src/components/macros/Radiobutton.tsx +22 -0
- package/src/components/macros/SaveManager.tsx +14 -6
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/WidgetInvocation.tsx +14 -94
- package/src/components/macros/arg-utils.ts +120 -0
- package/src/components/macros/option-utils.ts +9 -5
- package/src/define-macro.ts +13 -34
- package/src/execute-mutation.ts +294 -26
- package/src/expression.ts +22 -237
- package/src/js-lexer.ts +326 -0
- package/src/markup/render.tsx +139 -81
- package/src/markup/tokenizer.ts +23 -9
- package/src/saves/save-manager.ts +37 -3
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +70 -25
- package/src/story-api.ts +51 -50
- package/src/triggers.ts +5 -2
- package/src/utils/object-path.ts +109 -0
- package/src/utils/stable-key.ts +77 -0
package/src/js-lexer.ts
ADDED
|
@@ -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
|
+
}
|
package/src/markup/render.tsx
CHANGED
|
@@ -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
|
|
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 `<span data-tw=...>`) 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
|
|
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
|
-
|
|
41
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
128
|
+
ph: Placeholders,
|
|
60
129
|
): preact.ComponentChildren {
|
|
61
130
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
62
|
-
|
|
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
|
|
70
|
-
if (
|
|
71
|
-
return components[
|
|
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,
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
|
424
|
+
* and placeholders. No micromark, no innerHTML.
|
|
367
425
|
*/
|
|
368
426
|
function buildPlainTextVnodes(
|
|
369
427
|
combined: string,
|
|
370
|
-
|
|
371
|
-
|
|
428
|
+
ph: Placeholders,
|
|
429
|
+
unwrapParagraphs?: boolean,
|
|
372
430
|
): preact.ComponentChildren {
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
390
|
-
* elements for non-text nodes (variables, macros, links,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
510
|
+
return htmlToPreact(html, ph, unwrapParagraphs);
|
|
453
511
|
}
|