@rohal12/spindle 0.52.7 → 0.52.9
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/README.md +26 -0
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +986 -854
- package/dist/pkg/macro-registry.json +18 -0
- package/package.json +1 -1
- package/src/action-registry.ts +9 -0
- package/src/class-registry.ts +1 -5
- package/src/components/Passage.tsx +35 -39
- package/src/components/PassageDialog.tsx +2 -2
- package/src/components/StoryInterface.tsx +2 -2
- package/src/components/macros/Checkbox.tsx +3 -8
- package/src/components/macros/Computed.tsx +1 -1
- package/src/components/macros/Cycle.tsx +3 -6
- package/src/components/macros/ExprDisplay.tsx +12 -29
- package/src/components/macros/If.tsx +35 -41
- package/src/components/macros/Include.tsx +2 -1
- package/src/components/macros/Listbox.tsx +5 -7
- package/src/components/macros/Print.tsx +3 -5
- package/src/components/macros/Radiobutton.tsx +3 -5
- package/src/components/macros/Repeat.tsx +2 -1
- package/src/components/macros/Switch.tsx +6 -13
- package/src/components/macros/Timed.tsx +14 -18
- package/src/components/macros/Type.tsx +2 -1
- package/src/components/macros/VarDisplay.tsx +12 -32
- package/src/components/macros/Widget.tsx +49 -20
- package/src/components/macros/branches.ts +18 -0
- package/src/components/macros/display.ts +19 -0
- package/src/components/macros/input-macro.ts +33 -11
- package/src/components/macros/macro-args.ts +7 -0
- package/src/components/macros/option-utils.ts +10 -7
- package/src/define-macro.ts +4 -6
- package/src/expression.ts +58 -40
- package/src/hooks/use-action.ts +7 -15
- package/src/index.tsx +5 -15
- package/src/interpolation.ts +34 -41
- package/src/markup/ast.ts +4 -3
- package/src/markup/render.tsx +121 -65
- package/src/parser.ts +119 -13
- package/src/registry.ts +13 -12
- package/src/saves/save-manager.ts +18 -13
- package/src/saves/storage.ts +19 -20
- package/src/story-api.ts +30 -73
- package/src/structural.ts +1 -18
- package/src/types-drift-check.ts +37 -50
- package/src/utils/macro-names.ts +40 -0
- package/src/utils/namespace.ts +17 -0
- package/src/widgets/widget-registry.ts +5 -4
package/src/interpolation.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { getMacro, getMacroText, isSubMacro } from './registry';
|
|
|
27
27
|
import type { MacroTextContext } from './registry';
|
|
28
28
|
import { getWidget } from './widgets/widget-registry';
|
|
29
29
|
import { splitArgs } from './components/macros/arg-utils';
|
|
30
|
+
import { display } from './components/macros/display';
|
|
30
31
|
import { splitSigilTemplate } from './markup/code-attributes';
|
|
31
32
|
import {
|
|
32
33
|
checkVariableName,
|
|
@@ -118,10 +119,6 @@ export function mapTextNodes(
|
|
|
118
119
|
/** Deep enough for any real nesting; stops a widget that includes itself. */
|
|
119
120
|
const MAX_DEPTH = 100;
|
|
120
121
|
|
|
121
|
-
function display(value: unknown): string {
|
|
122
|
-
return value == null ? '' : String(value);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
122
|
/**
|
|
126
123
|
* The value of a variable reference (`name` without its sigil, with an
|
|
127
124
|
* optional dot path), as `{$name}` shows it in passage text (see
|
|
@@ -154,6 +151,17 @@ function resolveVariable(
|
|
|
154
151
|
return value;
|
|
155
152
|
}
|
|
156
153
|
|
|
154
|
+
/** Evaluate an expression in `scope`. */
|
|
155
|
+
function evaluateIn(expr: string, scope: TextScope): unknown {
|
|
156
|
+
return evaluate(
|
|
157
|
+
expr,
|
|
158
|
+
scope.variables,
|
|
159
|
+
scope.temporary,
|
|
160
|
+
scope.locals,
|
|
161
|
+
scope.transient,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
class TextEvaluator {
|
|
158
166
|
readonly errors: TextError[] = [];
|
|
159
167
|
|
|
@@ -163,16 +171,6 @@ class TextEvaluator {
|
|
|
163
171
|
return text;
|
|
164
172
|
}
|
|
165
173
|
|
|
166
|
-
private evaluate(expr: string, scope: TextScope): unknown {
|
|
167
|
-
return evaluate(
|
|
168
|
-
expr,
|
|
169
|
-
scope.variables,
|
|
170
|
-
scope.temporary,
|
|
171
|
-
scope.locals,
|
|
172
|
-
scope.transient,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
174
|
private node(node: ASTNode, scope: TextScope, depth: number): string {
|
|
177
175
|
switch (node.type) {
|
|
178
176
|
case 'text':
|
|
@@ -190,7 +188,7 @@ class TextEvaluator {
|
|
|
190
188
|
return this.variable(node.scope, node.name, scope);
|
|
191
189
|
case 'expression':
|
|
192
190
|
try {
|
|
193
|
-
return display(
|
|
191
|
+
return display(evaluateIn(node.expression, scope));
|
|
194
192
|
} catch (error) {
|
|
195
193
|
this.errors.push({ macro: 'expression', error });
|
|
196
194
|
return '';
|
|
@@ -255,7 +253,7 @@ class TextEvaluator {
|
|
|
255
253
|
const expr = argExprs[i];
|
|
256
254
|
if (expr !== undefined) {
|
|
257
255
|
try {
|
|
258
|
-
value =
|
|
256
|
+
value = evaluateIn(expr, scope);
|
|
259
257
|
} catch {
|
|
260
258
|
value = undefined;
|
|
261
259
|
}
|
|
@@ -279,7 +277,7 @@ class TextEvaluator {
|
|
|
279
277
|
}
|
|
280
278
|
|
|
281
279
|
const ctx: MacroTextContext = {
|
|
282
|
-
evaluate: (expr) =>
|
|
280
|
+
evaluate: (expr) => evaluateIn(expr, scope),
|
|
283
281
|
renderText: (nodes, locals) =>
|
|
284
282
|
this.nested(
|
|
285
283
|
node.name,
|
|
@@ -369,15 +367,7 @@ export function interpolateCode(
|
|
|
369
367
|
}
|
|
370
368
|
} else {
|
|
371
369
|
try {
|
|
372
|
-
text += display(
|
|
373
|
-
evaluate(
|
|
374
|
-
part.expr,
|
|
375
|
-
scope.variables,
|
|
376
|
-
scope.temporary,
|
|
377
|
-
scope.locals,
|
|
378
|
-
scope.transient,
|
|
379
|
-
),
|
|
380
|
-
);
|
|
370
|
+
text += display(evaluateIn(part.expr, scope));
|
|
381
371
|
} catch (error) {
|
|
382
372
|
errors.push({ macro: 'expression', error });
|
|
383
373
|
}
|
|
@@ -386,22 +376,28 @@ export function interpolateCode(
|
|
|
386
376
|
return { text, errors };
|
|
387
377
|
}
|
|
388
378
|
|
|
379
|
+
/** The namespaces of a TextScope, passed one by one. */
|
|
380
|
+
type ScopeArgs = [
|
|
381
|
+
variables: Record<string, unknown>,
|
|
382
|
+
temporary: Record<string, unknown>,
|
|
383
|
+
locals: Record<string, unknown>,
|
|
384
|
+
transient?: Record<string, unknown>,
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
function scopeOf(
|
|
388
|
+
...[variables, temporary, locals, transient = {}]: ScopeArgs
|
|
389
|
+
): TextScope {
|
|
390
|
+
return { variables, temporary, locals, transient };
|
|
391
|
+
}
|
|
392
|
+
|
|
389
393
|
/**
|
|
390
394
|
* Evaluate text-only markup to a string, throwing the first error met.
|
|
391
395
|
*/
|
|
392
396
|
export function interpolate(
|
|
393
397
|
template: string,
|
|
394
|
-
|
|
395
|
-
temporary: Record<string, unknown>,
|
|
396
|
-
locals: Record<string, unknown>,
|
|
397
|
-
transient: Record<string, unknown> = {},
|
|
398
|
+
...namespaces: ScopeArgs
|
|
398
399
|
): string {
|
|
399
|
-
const { text, errors } = interpolateText(template,
|
|
400
|
-
variables,
|
|
401
|
-
temporary,
|
|
402
|
-
locals,
|
|
403
|
-
transient,
|
|
404
|
-
});
|
|
400
|
+
const { text, errors } = interpolateText(template, scopeOf(...namespaces));
|
|
405
401
|
if (errors.length > 0) throw errors[0]!.error;
|
|
406
402
|
return text;
|
|
407
403
|
}
|
|
@@ -412,10 +408,7 @@ export function interpolate(
|
|
|
412
408
|
*/
|
|
413
409
|
export function interpolateExpression(
|
|
414
410
|
expr: string,
|
|
415
|
-
|
|
416
|
-
temporary: Record<string, unknown>,
|
|
417
|
-
locals: Record<string, unknown>,
|
|
418
|
-
transient: Record<string, unknown> = {},
|
|
411
|
+
...namespaces: ScopeArgs
|
|
419
412
|
): string {
|
|
420
|
-
return display(
|
|
413
|
+
return display(evaluateIn(expr, scopeOf(...namespaces)));
|
|
421
414
|
}
|
package/src/markup/ast.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type Token,
|
|
7
7
|
type VariableScope,
|
|
8
8
|
} from './tokenizer';
|
|
9
|
+
import { NameSet } from '../utils/macro-names';
|
|
9
10
|
|
|
10
11
|
export interface TextNode {
|
|
11
12
|
type: 'text';
|
|
@@ -51,7 +52,7 @@ export type ASTNode =
|
|
|
51
52
|
| HtmlNode;
|
|
52
53
|
|
|
53
54
|
/** Macros that require a closing tag and can contain children */
|
|
54
|
-
const BLOCK_MACROS = new
|
|
55
|
+
const BLOCK_MACROS = new NameSet([
|
|
55
56
|
'if',
|
|
56
57
|
'for',
|
|
57
58
|
'do',
|
|
@@ -70,12 +71,12 @@ const BLOCK_MACROS = new Set([
|
|
|
70
71
|
|
|
71
72
|
/** Register a custom macro as a block macro so the AST builder nests children. */
|
|
72
73
|
export function registerBlockMacro(name: string): void {
|
|
73
|
-
BLOCK_MACROS.add(name
|
|
74
|
+
BLOCK_MACROS.add(name);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** Unregister a custom block macro (for test cleanup). */
|
|
77
78
|
export function unregisterBlockMacro(name: string): void {
|
|
78
|
-
BLOCK_MACROS.delete(name
|
|
79
|
+
BLOCK_MACROS.delete(name);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** Map from branch macro name → required parent macro name */
|
package/src/markup/render.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext } from 'preact';
|
|
1
|
+
import { createContext, type RefObject } from 'preact';
|
|
2
2
|
import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
|
|
3
3
|
import { VarDisplay } from '../components/macros/VarDisplay';
|
|
4
4
|
import { ExprDisplay } from '../components/macros/ExprDisplay';
|
|
@@ -63,6 +63,8 @@ export const RawTextContext = createContext(false);
|
|
|
63
63
|
*/
|
|
64
64
|
const SvgContext = createContext(false);
|
|
65
65
|
|
|
66
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
67
|
+
|
|
66
68
|
/** Elements whose content is literal text, not markdown. */
|
|
67
69
|
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
|
|
68
70
|
export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
|
|
@@ -139,31 +141,59 @@ function expandPlaceholderText(
|
|
|
139
141
|
);
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
/** An attribute: its name, value and value as written (see splitAttributes). */
|
|
145
|
+
type Attribute = [name: string, value: string, written: string];
|
|
146
|
+
|
|
142
147
|
/**
|
|
143
|
-
* An element whose attributes contain placeholders (a
|
|
144
|
-
* text or a link title). Each such attribute is
|
|
145
|
-
* the store and locals, so it follows its
|
|
148
|
+
* An element of markdown output whose attributes contain placeholders (a
|
|
149
|
+
* variable in image alt text or a link title). Each such attribute is
|
|
150
|
+
* evaluated as text against the store and locals, so it follows its
|
|
151
|
+
* variables as the component would.
|
|
146
152
|
*/
|
|
147
153
|
function PlaceholderAttributes({
|
|
148
154
|
tag,
|
|
149
|
-
props,
|
|
150
155
|
attributes,
|
|
156
|
+
placeholders,
|
|
157
|
+
svg,
|
|
151
158
|
children,
|
|
152
159
|
}: {
|
|
153
160
|
tag: string;
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
attributes: Attribute[];
|
|
162
|
+
placeholders: Record<string, ASTNode[]>;
|
|
163
|
+
svg: boolean;
|
|
156
164
|
children: preact.ComponentChildren[];
|
|
157
165
|
}) {
|
|
158
166
|
const scope = useTextScope();
|
|
159
|
-
const resolved: Record<string, string> = { ...props };
|
|
160
167
|
const errors: AttributeError[] = [];
|
|
161
|
-
|
|
168
|
+
const resolved = attributes.map(([name, value, written]): Attribute => {
|
|
169
|
+
const nodes = placeholders[name];
|
|
170
|
+
if (!nodes) return [name, value, written];
|
|
162
171
|
const result = renderText(nodes, scope);
|
|
163
|
-
resolved[name] = result.text;
|
|
164
172
|
for (const error of result.errors) errors.push([name, error]);
|
|
165
|
-
|
|
166
|
-
|
|
173
|
+
return [name, result.text, written];
|
|
174
|
+
});
|
|
175
|
+
const { props, direct } = splitAttributes(resolved, svg);
|
|
176
|
+
const ref = useDirectAttributes(direct);
|
|
177
|
+
return withAttributeErrors(
|
|
178
|
+
errors,
|
|
179
|
+
h(tag, ref ? { ...props, ref } : props, ...children),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** An element of markdown output with attributes to set directly. */
|
|
184
|
+
function DirectAttributes({
|
|
185
|
+
tag,
|
|
186
|
+
props,
|
|
187
|
+
direct,
|
|
188
|
+
children,
|
|
189
|
+
}: {
|
|
190
|
+
tag: string;
|
|
191
|
+
props: Record<string, unknown>;
|
|
192
|
+
direct: [string, string][];
|
|
193
|
+
children: preact.ComponentChildren[];
|
|
194
|
+
}) {
|
|
195
|
+
const ref = useDirectAttributes(direct);
|
|
196
|
+
return h(tag, { ...props, ref }, ...children);
|
|
167
197
|
}
|
|
168
198
|
|
|
169
199
|
/** An error met while evaluating the named attribute. */
|
|
@@ -201,6 +231,19 @@ function placeholderIndex(node: Node | null, ph: Placeholders): number {
|
|
|
201
231
|
return parseInt(value.slice(prefix.length), 10);
|
|
202
232
|
}
|
|
203
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Parse HTML into nodes without side effects. The content of a `<template>`
|
|
236
|
+
* belongs to a document with no browsing context, so parsing it fetches no
|
|
237
|
+
* images and media, runs no inline event handlers (`<img onerror>`) and
|
|
238
|
+
* constructs no custom elements. A detached `<div>` belongs to the page's
|
|
239
|
+
* document, which does all of these while parsing.
|
|
240
|
+
*/
|
|
241
|
+
export function parseHtmlInert(html: string): DocumentFragment {
|
|
242
|
+
const template = document.createElement('template');
|
|
243
|
+
template.innerHTML = html;
|
|
244
|
+
return template.content;
|
|
245
|
+
}
|
|
246
|
+
|
|
204
247
|
/**
|
|
205
248
|
* Convert an HTML string (from micromark) to Preact VNodes,
|
|
206
249
|
* replacing placeholder elements with pre-rendered components.
|
|
@@ -212,14 +255,15 @@ function htmlToPreact(
|
|
|
212
255
|
ph: Placeholders,
|
|
213
256
|
unwrapParagraphs = false,
|
|
214
257
|
): preact.ComponentChildren {
|
|
215
|
-
const
|
|
216
|
-
temp.innerHTML = html.trim();
|
|
258
|
+
const content = parseHtmlInert(html.trim());
|
|
217
259
|
if (unwrapParagraphs) {
|
|
218
|
-
for (const
|
|
219
|
-
|
|
260
|
+
for (const child of Array.from(content.childNodes)) {
|
|
261
|
+
if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === 'P') {
|
|
262
|
+
(child as Element).replaceWith(...Array.from(child.childNodes));
|
|
263
|
+
}
|
|
220
264
|
}
|
|
221
265
|
}
|
|
222
|
-
const children = Array.from(
|
|
266
|
+
const children = Array.from(content.childNodes).map((child, i) =>
|
|
223
267
|
convertDomNode(child, i, ph),
|
|
224
268
|
);
|
|
225
269
|
return <>{children}</>;
|
|
@@ -252,17 +296,17 @@ function convertDomNode(
|
|
|
252
296
|
return ph.components[idx];
|
|
253
297
|
}
|
|
254
298
|
|
|
255
|
-
// Convert attributes
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
299
|
+
// Convert attributes, as author HTML's (see splitAttributes): markdown
|
|
300
|
+
// output holds raw HTML the passage tokenizer didn't take as a tag
|
|
301
|
+
const svg = el.namespaceURI === SVG_NAMESPACE;
|
|
302
|
+
const attributes: Attribute[] = [];
|
|
303
|
+
let placeholders: Record<string, ASTNode[]> | undefined;
|
|
304
|
+
for (const { name, value } of Array.from(el.attributes)) {
|
|
305
|
+
attributes.push([name, value, value]);
|
|
306
|
+
const parts = splitPlaceholderText(value, ph);
|
|
307
|
+
if (parts.every((part) => typeof part === 'string')) continue;
|
|
308
|
+
placeholders ??= {};
|
|
309
|
+
placeholders[name] = parts.map(
|
|
266
310
|
(part): ASTNode =>
|
|
267
311
|
typeof part === 'string'
|
|
268
312
|
? { type: 'text', value: part }
|
|
@@ -275,13 +319,26 @@ function convertDomNode(
|
|
|
275
319
|
convertDomNode(child, i, ph),
|
|
276
320
|
);
|
|
277
321
|
|
|
278
|
-
if (
|
|
322
|
+
if (placeholders) {
|
|
279
323
|
return (
|
|
280
324
|
<PlaceholderAttributes
|
|
325
|
+
key={key}
|
|
326
|
+
tag={tag}
|
|
327
|
+
attributes={attributes}
|
|
328
|
+
placeholders={placeholders}
|
|
329
|
+
svg={svg}
|
|
330
|
+
children={children}
|
|
331
|
+
/>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const { props, direct } = splitAttributes(attributes, svg);
|
|
335
|
+
if (direct.length > 0) {
|
|
336
|
+
return (
|
|
337
|
+
<DirectAttributes
|
|
281
338
|
key={key}
|
|
282
339
|
tag={tag}
|
|
283
340
|
props={props}
|
|
284
|
-
|
|
341
|
+
direct={direct}
|
|
285
342
|
children={children}
|
|
286
343
|
/>
|
|
287
344
|
);
|
|
@@ -395,9 +452,8 @@ function setRawAttribute(el: Element, name: string, value: string) {
|
|
|
395
452
|
el.setAttribute(name, value);
|
|
396
453
|
return;
|
|
397
454
|
}
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
const parsed = (template.content.firstChild as Element).attributes[0];
|
|
455
|
+
const parsed = (parseHtmlInert(`<i ${name}=""></i>`).firstChild as Element)
|
|
456
|
+
.attributes[0];
|
|
401
457
|
if (!parsed) return;
|
|
402
458
|
const attr = parsed.cloneNode() as Attr;
|
|
403
459
|
attr.value = value;
|
|
@@ -422,7 +478,7 @@ function setRawAttribute(el: Element, name: string, value: string) {
|
|
|
422
478
|
* `className` into `class`) are set directly instead.
|
|
423
479
|
*/
|
|
424
480
|
function splitAttributes(
|
|
425
|
-
attributes: [
|
|
481
|
+
attributes: Attribute[],
|
|
426
482
|
svg: boolean,
|
|
427
483
|
): { props: Record<string, unknown>; direct: [string, string][] } {
|
|
428
484
|
const props: Record<string, unknown> = {};
|
|
@@ -447,6 +503,27 @@ function splitAttributes(
|
|
|
447
503
|
return { props, direct };
|
|
448
504
|
}
|
|
449
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Set the attributes `splitAttributes` leaves to set directly on the element
|
|
508
|
+
* given the returned ref (none if there are none), and remove them again
|
|
509
|
+
* when they change.
|
|
510
|
+
*/
|
|
511
|
+
function useDirectAttributes(
|
|
512
|
+
direct: [string, string][],
|
|
513
|
+
): RefObject<Element> | undefined {
|
|
514
|
+
const elementRef = useRef<Element>(null);
|
|
515
|
+
const directKey = JSON.stringify(direct);
|
|
516
|
+
useLayoutEffect(() => {
|
|
517
|
+
const el = elementRef.current;
|
|
518
|
+
if (!el || direct.length === 0) return;
|
|
519
|
+
for (const [name, value] of direct) setRawAttribute(el, name, value);
|
|
520
|
+
return () => {
|
|
521
|
+
for (const [name] of direct) el.removeAttribute(name);
|
|
522
|
+
};
|
|
523
|
+
}, [directKey]);
|
|
524
|
+
return direct.length > 0 ? elementRef : undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
450
527
|
const decodedAttributeText = new Map<string, string>();
|
|
451
528
|
|
|
452
529
|
/**
|
|
@@ -457,10 +534,10 @@ function decodeAttributeText(text: string): string {
|
|
|
457
534
|
if (!text.includes('&')) return text;
|
|
458
535
|
let decoded = decodedAttributeText.get(text);
|
|
459
536
|
if (decoded === undefined) {
|
|
460
|
-
const
|
|
461
|
-
template.innerHTML = `<i title="${text.replace(/"/g, '"')}"></i>`;
|
|
537
|
+
const html = `<i title="${text.replace(/"/g, '"')}"></i>`;
|
|
462
538
|
decoded =
|
|
463
|
-
(
|
|
539
|
+
(parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
|
|
540
|
+
text;
|
|
464
541
|
decodedAttributeText.set(text, decoded);
|
|
465
542
|
}
|
|
466
543
|
return decoded;
|
|
@@ -532,24 +609,11 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
532
609
|
const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
|
|
533
610
|
const errors: AttributeError[] = [];
|
|
534
611
|
const resolved = Object.entries(node.attributes).map(
|
|
535
|
-
([k, v]): [
|
|
536
|
-
k,
|
|
537
|
-
resolveAttributeValue(k, v, scope, errors),
|
|
538
|
-
v,
|
|
539
|
-
],
|
|
612
|
+
([k, v]): Attribute => [k, resolveAttributeValue(k, v, scope, errors), v],
|
|
540
613
|
);
|
|
541
614
|
const { props, direct } = splitAttributes(resolved, inSvg || isSvgRoot);
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
useLayoutEffect(() => {
|
|
545
|
-
const el = elementRef.current;
|
|
546
|
-
if (!el || direct.length === 0) return;
|
|
547
|
-
for (const [name, value] of direct) setRawAttribute(el, name, value);
|
|
548
|
-
return () => {
|
|
549
|
-
for (const [name] of direct) el.removeAttribute(name);
|
|
550
|
-
};
|
|
551
|
-
}, [directKey]);
|
|
552
|
-
if (direct.length > 0) props.ref = elementRef;
|
|
615
|
+
const ref = useDirectAttributes(direct);
|
|
616
|
+
if (ref) props.ref = ref;
|
|
553
617
|
const isInline = INLINE_ELEMENTS.has(tag);
|
|
554
618
|
// Inside SVG and preformatted elements, skip markdown processing entirely
|
|
555
619
|
// (see RawTextContext).
|
|
@@ -588,12 +652,9 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
588
652
|
|
|
589
653
|
function ChildrenSlot() {
|
|
590
654
|
const childrenAST = useContext(WidgetChildrenContext);
|
|
591
|
-
const
|
|
592
|
-
const inline = useContext(InlineContext);
|
|
593
|
-
const raw = useContext(RawTextContext);
|
|
594
|
-
const locals = useContext(LocalsValuesContext);
|
|
655
|
+
const renderOptions = useRenderOptions();
|
|
595
656
|
if (!childrenAST || childrenAST.length === 0) return null;
|
|
596
|
-
return <>{renderNodes(childrenAST,
|
|
657
|
+
return <>{renderNodes(childrenAST, renderOptions)}</>;
|
|
597
658
|
}
|
|
598
659
|
|
|
599
660
|
/**
|
|
@@ -669,10 +730,7 @@ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
|
|
|
669
730
|
return (
|
|
670
731
|
<VarDisplay
|
|
671
732
|
key={key}
|
|
672
|
-
|
|
673
|
-
scope={node.scope}
|
|
674
|
-
className={node.className}
|
|
675
|
-
id={node.id}
|
|
733
|
+
node={node}
|
|
676
734
|
/>
|
|
677
735
|
);
|
|
678
736
|
|
|
@@ -680,9 +738,7 @@ function renderSingleNode(node: ASTNode): preact.ComponentChildren {
|
|
|
680
738
|
return (
|
|
681
739
|
<ExprDisplay
|
|
682
740
|
key={key}
|
|
683
|
-
|
|
684
|
-
className={node.className}
|
|
685
|
-
id={node.id}
|
|
741
|
+
node={node}
|
|
686
742
|
/>
|
|
687
743
|
);
|
|
688
744
|
|
package/src/parser.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { tokenize } from './markup/tokenizer';
|
|
2
|
+
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
3
|
+
|
|
1
4
|
export interface Passage {
|
|
2
5
|
pid: number;
|
|
3
6
|
name: string;
|
|
@@ -18,25 +21,128 @@ export interface StoryData {
|
|
|
18
21
|
userScript: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
25
|
+
|
|
26
|
+
/** HTML elements written without content or end tag, as innerHTML does. */
|
|
27
|
+
const VOID_ELEMENTS = new Set([
|
|
28
|
+
'area',
|
|
29
|
+
'base',
|
|
30
|
+
'basefont',
|
|
31
|
+
'bgsound',
|
|
32
|
+
'br',
|
|
33
|
+
'col',
|
|
34
|
+
'embed',
|
|
35
|
+
'frame',
|
|
36
|
+
'hr',
|
|
37
|
+
'img',
|
|
38
|
+
'input',
|
|
39
|
+
'keygen',
|
|
40
|
+
'link',
|
|
41
|
+
'meta',
|
|
42
|
+
'param',
|
|
43
|
+
'source',
|
|
44
|
+
'track',
|
|
45
|
+
'wbr',
|
|
46
|
+
]);
|
|
47
|
+
|
|
21
48
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
49
|
+
* The passage markup in the nodes of passage data.
|
|
50
|
+
*
|
|
51
|
+
* A compiler that HTML-escapes passage text (Twine, Tweego) leaves a text
|
|
52
|
+
* node whose text is the markup; a no-break space in it is U+00A0, which
|
|
53
|
+
* innerHTML writes as ` `. One that doesn't escape it leaves the
|
|
54
|
+
* browser's parse of it, whose elements are written back as markup here.
|
|
55
|
+
* innerHTML did that too, but its escaping can't be undone: a `"` in an
|
|
56
|
+
* attribute value is written `"`, and decoding that ends the value
|
|
57
|
+
* early, while text it leaves unescaped (in `<style>`, comments) would be
|
|
58
|
+
* decoded wrongly.
|
|
24
59
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
.
|
|
31
|
-
|
|
60
|
+
function passageMarkup(parent: ParentNode): string {
|
|
61
|
+
let markup = '';
|
|
62
|
+
for (const node of Array.from(parent.childNodes)) {
|
|
63
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
64
|
+
markup += (node as Text).data;
|
|
65
|
+
} else if (node.nodeType === Node.COMMENT_NODE) {
|
|
66
|
+
markup += `<!--${(node as Comment).data}-->`;
|
|
67
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
68
|
+
markup += elementMarkup(node as Element);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return markup;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An element of passage data as markup (see passageMarkup). */
|
|
75
|
+
function elementMarkup(el: Element): string {
|
|
76
|
+
const name = el.localName;
|
|
77
|
+
let markup = `<${name}`;
|
|
78
|
+
for (const attr of Array.from(el.attributes)) {
|
|
79
|
+
markup += ` ${attr.name}=${quoteAttribute(attr.name, attr.value)}`;
|
|
80
|
+
}
|
|
81
|
+
markup += '>';
|
|
82
|
+
if (el.namespaceURI === HTML_NAMESPACE && VOID_ELEMENTS.has(name)) {
|
|
83
|
+
return markup;
|
|
84
|
+
}
|
|
85
|
+
const content =
|
|
86
|
+
name === 'template' ? (el as HTMLTemplateElement).content : el;
|
|
87
|
+
return `${markup}${passageMarkup(content)}</${name}>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* An attribute value quoted so that the passage tokenizer reads it back:
|
|
92
|
+
* in double quotes, else single ones, else with the double quotes in its
|
|
93
|
+
* text written as `"`, which rendering decodes. Quotes inside markup in
|
|
94
|
+
* the value (`{print "a"}`) are code, and don't end it.
|
|
95
|
+
*/
|
|
96
|
+
function quoteAttribute(name: string, value: string): string {
|
|
97
|
+
const code = isCodeAttribute(name);
|
|
98
|
+
const probe = code ? 'on' : 'a';
|
|
99
|
+
const readsBack = (quoted: string) => {
|
|
100
|
+
const tokens = tokenize(`<i ${probe}=${quoted}>`);
|
|
101
|
+
const tag = tokens[0];
|
|
102
|
+
return (
|
|
103
|
+
tokens.length === 1 &&
|
|
104
|
+
tag?.type === 'html' &&
|
|
105
|
+
tag.attributes[probe] === quoted.slice(1, -1)
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
return (
|
|
109
|
+
[`"${value}"`, `'${value}'`].find(readsBack) ??
|
|
110
|
+
`"${escapeTextQuotes(value, code)}"`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `value` with the double quotes in its text written as `"`, but not
|
|
116
|
+
* those in markup (`{print "a"}`) or, in a code attribute, in sigil
|
|
117
|
+
* references (see splitSigilTemplate), which are code.
|
|
118
|
+
*/
|
|
119
|
+
function escapeTextQuotes(value: string, code: boolean): string {
|
|
120
|
+
const escape = (text: string) => text.replace(/"/g, '"');
|
|
121
|
+
if (code) {
|
|
122
|
+
return splitSigilTemplate(value)
|
|
123
|
+
.map((part) =>
|
|
124
|
+
'text' in part
|
|
125
|
+
? escape(part.text)
|
|
126
|
+
: 'expr' in part
|
|
127
|
+
? `{${part.expr}}`
|
|
128
|
+
: part.verbatim,
|
|
129
|
+
)
|
|
130
|
+
.join('');
|
|
131
|
+
}
|
|
132
|
+
return tokenize(value, { text: true })
|
|
133
|
+
.map((token) => {
|
|
134
|
+
const source = value.slice(token.start, token.end);
|
|
135
|
+
return token.type === 'text' ? escape(source) : source;
|
|
136
|
+
})
|
|
137
|
+
.join('');
|
|
32
138
|
}
|
|
33
139
|
|
|
34
140
|
/**
|
|
35
141
|
* Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
|
|
36
142
|
*
|
|
37
|
-
*
|
|
38
|
-
* tags inside passage markup (e.g. <div> inside {button}) are
|
|
39
|
-
*
|
|
143
|
+
* Passage content is read with passageMarkup instead of textContent, so
|
|
144
|
+
* that HTML tags inside passage markup (e.g. <div> inside {button}) are
|
|
145
|
+
* preserved whether or not the compiler HTML-encoded them.
|
|
40
146
|
*/
|
|
41
147
|
export function parseStoryData(): StoryData {
|
|
42
148
|
const storyEl = document.querySelector('tw-storydata');
|
|
@@ -67,7 +173,7 @@ export function parseStoryData(): StoryData {
|
|
|
67
173
|
const tags = (el.getAttribute('tags') || '')
|
|
68
174
|
.split(/\s+/)
|
|
69
175
|
.filter((t) => t.length > 0);
|
|
70
|
-
const content =
|
|
176
|
+
const content = passageMarkup(el);
|
|
71
177
|
|
|
72
178
|
const metadata: Record<string, string> = {};
|
|
73
179
|
const skipAttrs = new Set(['pid', 'name', 'tags']);
|