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