@rohal12/spindle 0.51.4 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- 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 +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- 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 +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- 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 +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- 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 +99 -14
- package/src/utils/stable-key.ts +14 -9
- 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';
|
|
@@ -8,7 +8,21 @@ import { getMacro, isSubMacro } from '../registry';
|
|
|
8
8
|
import { markdownToHtml } from './markdown';
|
|
9
9
|
import { h } from 'preact';
|
|
10
10
|
import type { ASTNode, HtmlNode, MacroNode } from './ast';
|
|
11
|
-
import {
|
|
11
|
+
import { useTextScope } from '../hooks/use-interpolate';
|
|
12
|
+
import {
|
|
13
|
+
hasInterpolation,
|
|
14
|
+
interpolateCode,
|
|
15
|
+
mapTextNodes,
|
|
16
|
+
parseText,
|
|
17
|
+
renderText,
|
|
18
|
+
type ParsedText,
|
|
19
|
+
type TextError,
|
|
20
|
+
type TextScope,
|
|
21
|
+
} from '../interpolation';
|
|
22
|
+
import { MacroError } from '../components/macros/MacroError';
|
|
23
|
+
import { isCodeAttribute } from './code-attributes';
|
|
24
|
+
import { errorMessage } from '../utils/error-message';
|
|
25
|
+
import { EMPTY_NAMESPACE } from '../utils/namespace';
|
|
12
26
|
|
|
13
27
|
export interface LocalsUpdater {
|
|
14
28
|
update: (key: string, value: unknown) => void;
|
|
@@ -21,10 +35,11 @@ const defaultUpdater: LocalsUpdater = {
|
|
|
21
35
|
`Cannot set @${key} — local variables require a {for}, widget, {link}, or {button} scope`,
|
|
22
36
|
);
|
|
23
37
|
},
|
|
24
|
-
getValues: () =>
|
|
38
|
+
getValues: () => EMPTY_NAMESPACE,
|
|
25
39
|
};
|
|
26
40
|
|
|
27
|
-
export const LocalsValuesContext =
|
|
41
|
+
export const LocalsValuesContext =
|
|
42
|
+
createContext<Record<string, unknown>>(EMPTY_NAMESPACE);
|
|
28
43
|
export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
|
|
29
44
|
export const NobrContext = createContext(false);
|
|
30
45
|
/**
|
|
@@ -33,23 +48,44 @@ export const NobrContext = createContext(false);
|
|
|
33
48
|
* Macro and widget bodies read it so their content stays inline too.
|
|
34
49
|
*/
|
|
35
50
|
export const InlineContext = createContext(false);
|
|
36
|
-
|
|
51
|
+
/**
|
|
52
|
+
* True while rendering inside an element whose content is not markdown: SVG
|
|
53
|
+
* (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
|
|
54
|
+
* and `<textarea>`, whose text (indentation, `#`, `*`, ...) is literal.
|
|
55
|
+
* Macro and widget bodies read it so their content stays literal too.
|
|
56
|
+
*/
|
|
57
|
+
export const RawTextContext = createContext(false);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True inside an `<svg>` element. SVG attributes are case-sensitive and have
|
|
61
|
+
* no live form properties, so they are set as written (see splitAttributes).
|
|
62
|
+
*/
|
|
63
|
+
const SvgContext = createContext(false);
|
|
64
|
+
|
|
65
|
+
/** Elements whose content is literal text, not markdown. */
|
|
66
|
+
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
|
|
37
67
|
export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
|
|
38
68
|
|
|
39
69
|
/**
|
|
40
70
|
* Components rendered for the non-text nodes of one renderNodes() call. Each
|
|
41
|
-
* stands in the markdown source as `<span data-tw=
|
|
71
|
+
* stands in the markdown source as `<span data-tw=NONCE:INDEX></span>`.
|
|
42
72
|
* The per-call random nonce means author text that merely looks like a
|
|
43
73
|
* placeholder (e.g. decoded from `<span data-tw=...>`) is never
|
|
44
|
-
* swapped for a component.
|
|
74
|
+
* swapped for a component. The attribute value is unquoted so a placeholder
|
|
75
|
+
* can sit inside a quoted markdown link title or image alt text.
|
|
45
76
|
*/
|
|
46
77
|
interface Placeholders {
|
|
47
78
|
nonce: string;
|
|
48
79
|
components: preact.ComponentChildren[];
|
|
80
|
+
/**
|
|
81
|
+
* Per placeholder, its node, for use in an attribute (image alt text, a
|
|
82
|
+
* link title), where it stands for its text (see interpolation.ts).
|
|
83
|
+
*/
|
|
84
|
+
nodes: ASTNode[];
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
function placeholderHtml(nonce: string, index: number): string {
|
|
52
|
-
return `<span data-tw
|
|
88
|
+
return `<span data-tw=${nonce}:${index}></span>`;
|
|
53
89
|
}
|
|
54
90
|
|
|
55
91
|
/**
|
|
@@ -61,7 +97,7 @@ function placeholderHtml(nonce: string, index: number): string {
|
|
|
61
97
|
* ESCAPE_GUARD in front of the placeholder.
|
|
62
98
|
*/
|
|
63
99
|
const PLACEHOLDER_TEXT_RE =
|
|
64
|
-
/(?:(?<=\\)\uE000)?<span data-tw=
|
|
100
|
+
/(?:(?<=\\)\uE000)?<span data-tw=([0-9a-z]+):(\d+)><\/span>/g;
|
|
65
101
|
|
|
66
102
|
/**
|
|
67
103
|
* Emitted between author text ending in a backslash and a placeholder, so
|
|
@@ -71,24 +107,90 @@ const PLACEHOLDER_TEXT_RE =
|
|
|
71
107
|
*/
|
|
72
108
|
const ESCAPE_GUARD = '\uE000';
|
|
73
109
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Split text into literal strings and the indexes of this call's
|
|
112
|
+
* placeholders in it.
|
|
113
|
+
*/
|
|
114
|
+
function splitPlaceholderText(
|
|
76
115
|
text: string,
|
|
77
116
|
ph: Placeholders,
|
|
78
|
-
):
|
|
79
|
-
if (!text.includes('<span data-tw=
|
|
80
|
-
const parts:
|
|
117
|
+
): (string | number)[] {
|
|
118
|
+
if (!text.includes('<span data-tw=')) return [text];
|
|
119
|
+
const parts: (string | number)[] = [];
|
|
81
120
|
let last = 0;
|
|
82
121
|
for (const m of text.matchAll(PLACEHOLDER_TEXT_RE)) {
|
|
83
122
|
if (m[1] !== ph.nonce) continue;
|
|
84
123
|
if (m.index > last) parts.push(text.slice(last, m.index));
|
|
85
|
-
parts.push(
|
|
124
|
+
parts.push(parseInt(m[2]!, 10));
|
|
86
125
|
last = m.index + m[0].length;
|
|
87
126
|
}
|
|
88
127
|
if (last < text.length) parts.push(text.slice(last));
|
|
89
128
|
return parts;
|
|
90
129
|
}
|
|
91
130
|
|
|
131
|
+
/** Split text into literal parts and the components its placeholders name. */
|
|
132
|
+
function expandPlaceholderText(
|
|
133
|
+
text: string,
|
|
134
|
+
ph: Placeholders,
|
|
135
|
+
): preact.ComponentChildren[] {
|
|
136
|
+
return splitPlaceholderText(text, ph).map((part) =>
|
|
137
|
+
typeof part === 'number' ? ph.components[part] : part,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* An element whose attributes contain placeholders (a variable in image alt
|
|
143
|
+
* text or a link title). Each such attribute is evaluated as text against
|
|
144
|
+
* the store and locals, so it follows its variables as the component would.
|
|
145
|
+
*/
|
|
146
|
+
function PlaceholderAttributes({
|
|
147
|
+
tag,
|
|
148
|
+
props,
|
|
149
|
+
attributes,
|
|
150
|
+
children,
|
|
151
|
+
}: {
|
|
152
|
+
tag: string;
|
|
153
|
+
props: Record<string, string>;
|
|
154
|
+
attributes: Record<string, ASTNode[]>;
|
|
155
|
+
children: preact.ComponentChildren[];
|
|
156
|
+
}) {
|
|
157
|
+
const scope = useTextScope();
|
|
158
|
+
const resolved: Record<string, string> = { ...props };
|
|
159
|
+
const errors: AttributeError[] = [];
|
|
160
|
+
for (const [name, nodes] of Object.entries(attributes)) {
|
|
161
|
+
const result = renderText(nodes, scope);
|
|
162
|
+
resolved[name] = result.text;
|
|
163
|
+
for (const error of result.errors) errors.push([name, error]);
|
|
164
|
+
}
|
|
165
|
+
return withAttributeErrors(errors, h(tag, resolved, ...children));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An error met while evaluating the named attribute. */
|
|
169
|
+
type AttributeError = [attribute: string, error: TextError];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Show the errors met in an element's attributes in front of it, the way a
|
|
173
|
+
* failing macro shows its error in passage text.
|
|
174
|
+
*/
|
|
175
|
+
function withAttributeErrors(
|
|
176
|
+
errors: AttributeError[],
|
|
177
|
+
element: preact.ComponentChildren,
|
|
178
|
+
): preact.ComponentChildren {
|
|
179
|
+
if (errors.length === 0) return element;
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
{errors.map(([name, { macro, error }], i) => (
|
|
183
|
+
<MacroError
|
|
184
|
+
key={i}
|
|
185
|
+
macro={macro}
|
|
186
|
+
error={new Error(`in attribute "${name}": ${errorMessage(error)}`)}
|
|
187
|
+
/>
|
|
188
|
+
))}
|
|
189
|
+
{element}
|
|
190
|
+
</>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
92
194
|
/** The component index of a placeholder element of this call, or -1. */
|
|
93
195
|
function placeholderIndex(node: Node | null, ph: Placeholders): number {
|
|
94
196
|
if (node == null || node.nodeType !== Node.ELEMENT_NODE) return -1;
|
|
@@ -150,9 +252,21 @@ function convertDomNode(
|
|
|
150
252
|
}
|
|
151
253
|
|
|
152
254
|
// Convert attributes
|
|
153
|
-
const props: Record<string, string
|
|
255
|
+
const props: Record<string, string> = {};
|
|
256
|
+
let withPlaceholders: Record<string, ASTNode[]> | undefined;
|
|
154
257
|
for (const attr of Array.from(el.attributes)) {
|
|
155
|
-
|
|
258
|
+
const parts = splitPlaceholderText(attr.value, ph);
|
|
259
|
+
if (parts.every((part) => typeof part === 'string')) {
|
|
260
|
+
props[attr.name] = attr.value;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
withPlaceholders ??= {};
|
|
264
|
+
withPlaceholders[attr.name] = parts.map(
|
|
265
|
+
(part): ASTNode =>
|
|
266
|
+
typeof part === 'string'
|
|
267
|
+
? { type: 'text', value: part }
|
|
268
|
+
: ph.nodes[part]!,
|
|
269
|
+
);
|
|
156
270
|
}
|
|
157
271
|
|
|
158
272
|
// Convert children recursively
|
|
@@ -160,7 +274,18 @@ function convertDomNode(
|
|
|
160
274
|
convertDomNode(child, i, ph),
|
|
161
275
|
);
|
|
162
276
|
|
|
163
|
-
|
|
277
|
+
if (withPlaceholders) {
|
|
278
|
+
return (
|
|
279
|
+
<PlaceholderAttributes
|
|
280
|
+
key={key}
|
|
281
|
+
tag={tag}
|
|
282
|
+
props={props}
|
|
283
|
+
attributes={withPlaceholders}
|
|
284
|
+
children={children}
|
|
285
|
+
/>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return h(tag, { ...props, key }, ...children);
|
|
164
289
|
}
|
|
165
290
|
return null;
|
|
166
291
|
}
|
|
@@ -203,9 +328,19 @@ const INLINE_ELEMENTS = new Set([
|
|
|
203
328
|
]);
|
|
204
329
|
|
|
205
330
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
331
|
+
* Attributes that Preact keeps as DOM properties on author HTML elements:
|
|
332
|
+
* the live state of form controls and media, which then follows its
|
|
333
|
+
* variable even after the reader changed it. Boolean ones written bare or
|
|
334
|
+
* as `=""` are passed as `true`, since '' is falsy (#177); one that only
|
|
335
|
+
* resolves to '' through an interpolation stays ''.
|
|
336
|
+
*/
|
|
337
|
+
const LIVE_PROPERTIES = new Set(['value', 'checked', 'selected', 'muted']);
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* HTML boolean attributes: present means on. Written bare or with a value
|
|
341
|
+
* they are present, as in HTML; one whose interpolation resolves to ''
|
|
342
|
+
* (`disabled="{$locked ? 'disabled' : ''}"`) is left out, so a variable can
|
|
343
|
+
* switch it off.
|
|
209
344
|
*/
|
|
210
345
|
const BOOLEAN_ATTRIBUTES = new Set([
|
|
211
346
|
'allowfullscreen',
|
|
@@ -235,31 +370,198 @@ const BOOLEAN_ATTRIBUTES = new Set([
|
|
|
235
370
|
'selected',
|
|
236
371
|
]);
|
|
237
372
|
|
|
238
|
-
|
|
239
|
-
|
|
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;
|
|
240
523
|
}
|
|
241
524
|
|
|
242
525
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
243
|
-
const
|
|
526
|
+
const scope = useTextScope();
|
|
244
527
|
const nobr = useContext(NobrContext);
|
|
245
528
|
const locals = useContext(LocalsValuesContext);
|
|
529
|
+
const inRaw = useContext(RawTextContext);
|
|
246
530
|
const inSvg = useContext(SvgContext);
|
|
247
531
|
const parentInline = useContext(InlineContext);
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
532
|
+
const tag = node.tag.toLowerCase();
|
|
533
|
+
const isSvgRoot = tag === 'svg';
|
|
534
|
+
const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
|
|
535
|
+
const errors: AttributeError[] = [];
|
|
536
|
+
const resolved = Object.entries(node.attributes).map(
|
|
537
|
+
([k, v]): [string, string, string] => [
|
|
538
|
+
k,
|
|
539
|
+
resolveAttributeValue(k, v, scope, errors),
|
|
540
|
+
v,
|
|
541
|
+
],
|
|
542
|
+
);
|
|
543
|
+
const { props, direct } = splitAttributes(resolved, inSvg || isSvgRoot);
|
|
544
|
+
const elementRef = useRef<Element>(null);
|
|
545
|
+
const directKey = JSON.stringify(direct);
|
|
546
|
+
useLayoutEffect(() => {
|
|
547
|
+
const el = elementRef.current;
|
|
548
|
+
if (!el || direct.length === 0) return;
|
|
549
|
+
for (const [name, value] of direct) setRawAttribute(el, name, value);
|
|
550
|
+
return () => {
|
|
551
|
+
for (const [name] of direct) el.removeAttribute(name);
|
|
552
|
+
};
|
|
553
|
+
}, [directKey]);
|
|
554
|
+
if (direct.length > 0) props.ref = elementRef;
|
|
555
|
+
const isInline = INLINE_ELEMENTS.has(tag);
|
|
556
|
+
// Inside SVG and preformatted elements, skip markdown processing entirely
|
|
557
|
+
// (see RawTextContext).
|
|
256
558
|
// Inside inline elements, disable block-level markdown (lists, headings,
|
|
257
559
|
// blockquotes) and <p> wrappers since those produce invalid HTML inside
|
|
258
560
|
// inline containers. The inline flag reaches nested macro/widget bodies via
|
|
259
561
|
// InlineContext; a block element nested inside resets it.
|
|
260
562
|
let children: preact.ComponentChildren = undefined;
|
|
261
563
|
if (node.children.length > 0) {
|
|
262
|
-
if (
|
|
564
|
+
if (inRaw || isRawRoot) {
|
|
263
565
|
children = renderInlineNodes(node.children);
|
|
264
566
|
} else {
|
|
265
567
|
children = renderNodes(node.children, { nobr, locals, inline: isInline });
|
|
@@ -272,11 +574,17 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
272
574
|
}
|
|
273
575
|
}
|
|
274
576
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
<SvgContext.Provider value={true}>{element}</SvgContext.Provider
|
|
278
|
-
|
|
279
|
-
|
|
577
|
+
let element = h(node.tag, props, children);
|
|
578
|
+
if (isSvgRoot && !inSvg) {
|
|
579
|
+
element = <SvgContext.Provider value={true}>{element}</SvgContext.Provider>;
|
|
580
|
+
}
|
|
581
|
+
return withAttributeErrors(
|
|
582
|
+
errors,
|
|
583
|
+
isRawRoot ? (
|
|
584
|
+
<RawTextContext.Provider value={true}>{element}</RawTextContext.Provider>
|
|
585
|
+
) : (
|
|
586
|
+
element
|
|
587
|
+
),
|
|
280
588
|
);
|
|
281
589
|
}
|
|
282
590
|
|
|
@@ -284,9 +592,10 @@ function ChildrenSlot() {
|
|
|
284
592
|
const childrenAST = useContext(WidgetChildrenContext);
|
|
285
593
|
const nobr = useContext(NobrContext);
|
|
286
594
|
const inline = useContext(InlineContext);
|
|
595
|
+
const raw = useContext(RawTextContext);
|
|
287
596
|
const locals = useContext(LocalsValuesContext);
|
|
288
597
|
if (!childrenAST || childrenAST.length === 0) return null;
|
|
289
|
-
return <>{renderNodes(childrenAST, { nobr, locals, inline })}</>;
|
|
598
|
+
return <>{renderNodes(childrenAST, { nobr, locals, inline, raw })}</>;
|
|
290
599
|
}
|
|
291
600
|
|
|
292
601
|
/**
|
|
@@ -412,23 +721,68 @@ export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
|
|
|
412
721
|
* Any match → fall through to the full micromark pipeline.
|
|
413
722
|
* False positives (e.g. `-` used as text, not list) just use the slower path.
|
|
414
723
|
* Includes character references (`&`, `{`) and two-space hard line
|
|
415
|
-
* breaks, which micromark decodes / turns into <br> (#171)
|
|
724
|
+
* breaks, which micromark decodes / turns into <br> (#171), and the openers
|
|
725
|
+
* of raw HTML comments, processing instructions, CDATA sections and
|
|
726
|
+
* declarations (`<!`, `<?`), which micromark passes through as HTML. A CR
|
|
727
|
+
* line ending (`\r\n` or `\r`) also takes the full pipeline, which reads
|
|
728
|
+
* it as micromark does.
|
|
416
729
|
*/
|
|
417
730
|
const MARKDOWN_SYNTAX_RE =
|
|
418
|
-
/[*_`#|~\[
|
|
419
|
-
|
|
420
|
-
const
|
|
731
|
+
/[*_`#|~\[>\\\-+=\r]|!\[|\d+[.)]|&#?[a-zA-Z0-9]+;| {2}\n|<[!?]/;
|
|
732
|
+
/** Two line endings (LF, CRLF or CR) with only whitespace between them. */
|
|
733
|
+
const BLANK_LINE_RE = /(?:\r\n|\r(?!\n)|\n)\s*[\r\n]/;
|
|
734
|
+
const PLACEHOLDER_STRIP_RE = /<span data-tw=[0-9a-z]+:\d+><\/span>/g;
|
|
735
|
+
|
|
736
|
+
/** Whitespace that markdown strips at the start and end of a paragraph. */
|
|
737
|
+
const EDGE_WS = ' \t\r\n';
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Index of the first character from `from` on (`step` 1) or before `from`
|
|
741
|
+
* (`step` -1) that is not in `chars`, or where the run of them ends. A loop,
|
|
742
|
+
* not a regex: `/[ \t]*$/` and the like try each position of a whitespace
|
|
743
|
+
* run, taking quadratic time on a long run in the middle of a passage.
|
|
744
|
+
*/
|
|
745
|
+
function skipChars(s: string, from: number, step: 1 | -1, chars: string) {
|
|
746
|
+
let i = from;
|
|
747
|
+
if (step === 1) {
|
|
748
|
+
while (i < s.length && chars.includes(s[i]!)) i++;
|
|
749
|
+
} else {
|
|
750
|
+
while (i > 0 && chars.includes(s[i - 1]!)) i--;
|
|
751
|
+
}
|
|
752
|
+
return i;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Drop the spaces and tabs around line endings, as markdown does within a
|
|
757
|
+
* paragraph.
|
|
758
|
+
*/
|
|
759
|
+
function trimLineEdges(text: string): string {
|
|
760
|
+
if (!text.includes('\n')) return text;
|
|
761
|
+
return text
|
|
762
|
+
.split('\n')
|
|
763
|
+
.map((line, k, lines) => {
|
|
764
|
+
const start = k === 0 ? 0 : skipChars(line, 0, 1, ' \t');
|
|
765
|
+
const end =
|
|
766
|
+
k === lines.length - 1
|
|
767
|
+
? line.length
|
|
768
|
+
: skipChars(line, line.length, -1, ' \t');
|
|
769
|
+
return line.slice(start, Math.max(start, end));
|
|
770
|
+
})
|
|
771
|
+
.join('\n');
|
|
772
|
+
}
|
|
421
773
|
|
|
422
774
|
/**
|
|
423
775
|
* Build Preact vnodes from a combined string that contains only plain text
|
|
424
|
-
* and placeholders. No micromark, no innerHTML.
|
|
776
|
+
* and placeholders. No micromark, no innerHTML. The text is the content of
|
|
777
|
+
* one paragraph, so like micromark it drops spaces and tabs around line
|
|
778
|
+
* endings; its edges are already split off by the caller.
|
|
425
779
|
*/
|
|
426
780
|
function buildPlainTextVnodes(
|
|
427
|
-
|
|
781
|
+
core: string,
|
|
428
782
|
ph: Placeholders,
|
|
429
783
|
unwrapParagraphs?: boolean,
|
|
430
784
|
): preact.ComponentChildren {
|
|
431
|
-
const children = expandPlaceholderText(
|
|
785
|
+
const children = expandPlaceholderText(trimLineEdges(core), ph).filter(
|
|
432
786
|
(part) => part !== '',
|
|
433
787
|
);
|
|
434
788
|
return unwrapParagraphs ? <>{children}</> : h('p', null, ...children);
|
|
@@ -455,9 +809,12 @@ export function renderNodes(
|
|
|
455
809
|
/** Unused: components read locals from LocalsValuesContext. Kept for API compatibility. */
|
|
456
810
|
locals?: Record<string, unknown>;
|
|
457
811
|
inline?: boolean;
|
|
812
|
+
/** Literal content (see RawTextContext): no markdown processing. */
|
|
813
|
+
raw?: boolean;
|
|
458
814
|
},
|
|
459
815
|
): preact.ComponentChildren {
|
|
460
816
|
if (nodes.length === 0) return null;
|
|
817
|
+
if (options?.raw) return renderInlineNodes(nodes);
|
|
461
818
|
|
|
462
819
|
// Skip the markdown pipeline when text nodes contain only whitespace.
|
|
463
820
|
// This eliminates ~97 redundant micromark + innerHTML calls per render
|
|
@@ -466,7 +823,8 @@ export function renderNodes(
|
|
|
466
823
|
// have markdown semantics (paragraph separation).
|
|
467
824
|
const needsMarkdown = nodes.some(
|
|
468
825
|
(n) =>
|
|
469
|
-
n.type === 'text' &&
|
|
826
|
+
n.type === 'text' &&
|
|
827
|
+
(n.value.trim() !== '' || BLANK_LINE_RE.test(n.value)),
|
|
470
828
|
);
|
|
471
829
|
if (!needsMarkdown) {
|
|
472
830
|
return nodes.map((node) => renderSingleNode(node));
|
|
@@ -477,6 +835,7 @@ export function renderNodes(
|
|
|
477
835
|
const ph: Placeholders = {
|
|
478
836
|
nonce: Math.random().toString(36).slice(2, 10) || '0',
|
|
479
837
|
components,
|
|
838
|
+
nodes: [],
|
|
480
839
|
};
|
|
481
840
|
let combined = '';
|
|
482
841
|
|
|
@@ -488,6 +847,7 @@ export function renderNodes(
|
|
|
488
847
|
}
|
|
489
848
|
const phIdx = components.length;
|
|
490
849
|
components.push(renderSingleNode(node));
|
|
850
|
+
ph.nodes.push(node);
|
|
491
851
|
if (combined.endsWith('\\')) combined += ESCAPE_GUARD;
|
|
492
852
|
combined += placeholderHtml(ph.nonce, phIdx);
|
|
493
853
|
}
|
|
@@ -498,14 +858,35 @@ export function renderNodes(
|
|
|
498
858
|
// produce the same text they started with (issue #145).
|
|
499
859
|
// Inline content (inside <span> etc.) never gets <p> wrappers (#220).
|
|
500
860
|
const unwrapParagraphs = !!(options?.nobr || options?.inline);
|
|
861
|
+
|
|
862
|
+
// Markdown strips whitespace at paragraph edges. Without <p> wrappers that
|
|
863
|
+
// whitespace separates this content from its neighbours (as in
|
|
864
|
+
// `<span>*HP*: </span>{$hp}`), so it is kept around the output there.
|
|
865
|
+
const lead = combined.slice(0, skipChars(combined, 0, 1, EDGE_WS));
|
|
866
|
+
const trail =
|
|
867
|
+
lead.length === combined.length
|
|
868
|
+
? ''
|
|
869
|
+
: combined.slice(skipChars(combined, combined.length, -1, EDGE_WS));
|
|
870
|
+
const core = combined.slice(lead.length, combined.length - trail.length);
|
|
871
|
+
const edges = (content: preact.ComponentChildren) =>
|
|
872
|
+
unwrapParagraphs && (lead || trail) ? (
|
|
873
|
+
<>
|
|
874
|
+
{lead}
|
|
875
|
+
{content}
|
|
876
|
+
{trail}
|
|
877
|
+
</>
|
|
878
|
+
) : (
|
|
879
|
+
content
|
|
880
|
+
);
|
|
881
|
+
|
|
501
882
|
const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, '');
|
|
502
883
|
if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) {
|
|
503
|
-
return buildPlainTextVnodes(
|
|
884
|
+
return edges(buildPlainTextVnodes(core, ph, unwrapParagraphs));
|
|
504
885
|
}
|
|
505
886
|
|
|
506
887
|
// Run combined text through markdown
|
|
507
888
|
const html = markdownToHtml(combined, { inline: options?.inline });
|
|
508
889
|
|
|
509
890
|
// Convert HTML to Preact VNodes, replacing placeholders with components
|
|
510
|
-
return htmlToPreact(html, ph, unwrapParagraphs);
|
|
891
|
+
return edges(htmlToPreact(html, ph, unwrapParagraphs));
|
|
511
892
|
}
|