@rohal12/spindle 0.59.9 → 0.59.11
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 +639 -371
- package/dist/pkg/macro-registry.json +1 -0
- package/dist/pkg/story-variables.js +480 -319
- package/package.json +1 -1
- package/src/components/macros/If.tsx +2 -7
- package/src/components/macros/PassageDisplay.tsx +56 -10
- package/src/components/macros/SaveManager.tsx +5 -2
- package/src/components/macros/Switch.tsx +4 -2
- package/src/components/macros/WidgetInvocation.tsx +17 -1
- package/src/components/macros/branches.ts +20 -0
- package/src/components/macros/detached-body.tsx +22 -16
- package/src/components/macros/input-macro.ts +18 -3
- package/src/hooks/use-merged-locals.ts +5 -2
- package/src/hooks/use-modal-focus.ts +6 -1
- package/src/hooks/use-story-fields.ts +16 -1
- package/src/js-lexer.ts +18 -1
- package/src/markup/render.tsx +38 -14
- package/src/markup/spindle.peggy +11 -0
- package/src/saves/save-manager.ts +47 -3
- package/src/store.ts +17 -7
- package/src/styles.css +5 -0
- package/src/triggers.ts +4 -2
package/package.json
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
3
|
import type { Branch } from '../../markup/ast';
|
|
4
|
-
import { selectBranch } from './branches';
|
|
5
|
-
import { wrapContent } from './display';
|
|
4
|
+
import { selectBranch, renderBranch } from './branches';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* The {if}/{elseif}/{else} branch whose condition holds, else the {else}
|
|
@@ -37,11 +36,7 @@ defineMacro({
|
|
|
37
36
|
);
|
|
38
37
|
}
|
|
39
38
|
if (!branch) return null;
|
|
40
|
-
return
|
|
41
|
-
ctx.resolve!(branch.className),
|
|
42
|
-
ctx.resolve!(branch.id),
|
|
43
|
-
ctx.renderNodes(branch.children),
|
|
44
|
-
);
|
|
39
|
+
return renderBranch(branch, ctx);
|
|
45
40
|
},
|
|
46
41
|
text({ branches = [] }, ctx) {
|
|
47
42
|
const branch = selectIfBranch(branches, ctx.evaluate);
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
useRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useCallback,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'preact/hooks';
|
|
8
|
+
import { useStoryStore, type StoryState } from '../../store';
|
|
9
|
+
import {
|
|
10
|
+
useStoryFields,
|
|
11
|
+
FrozenStateContext,
|
|
12
|
+
} from '../../hooks/use-story-fields';
|
|
4
13
|
import { Passage, renderPassageContent } from '../Passage';
|
|
5
14
|
import { defineMacro } from '../../define-macro';
|
|
6
15
|
import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
@@ -126,6 +135,13 @@ defineMacro({
|
|
|
126
135
|
// Track previous history length to detect restart/load
|
|
127
136
|
const prevHistoryLenRef = useRef(0);
|
|
128
137
|
|
|
138
|
+
const displayedId = displayed.id;
|
|
139
|
+
const isOutgoing = useCallback(
|
|
140
|
+
(state: StoryState) => state.navigationId !== displayedId,
|
|
141
|
+
[displayedId],
|
|
142
|
+
);
|
|
143
|
+
const lastElement = useRef<preact.VNode | null>(null);
|
|
144
|
+
|
|
129
145
|
// Container ref for snapshot insertion
|
|
130
146
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
131
147
|
|
|
@@ -265,6 +281,22 @@ defineMacro({
|
|
|
265
281
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
266
282
|
}, [navigationId]);
|
|
267
283
|
|
|
284
|
+
// A new passage opens at its beginning, not where the previous one was
|
|
285
|
+
// scrolled to (#354). The page keeps its position on first display
|
|
286
|
+
// (a refresh restores it) and when the beginning is already in view.
|
|
287
|
+
const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
|
|
288
|
+
const lastShownId = useRef(shownId);
|
|
289
|
+
useLayoutEffect(() => {
|
|
290
|
+
if (shownId === null) return; // between passages of a transition
|
|
291
|
+
const previous = lastShownId.current;
|
|
292
|
+
lastShownId.current = shownId;
|
|
293
|
+
if (previous === null || previous === shownId) return;
|
|
294
|
+
const el = containerRef.current?.querySelector('.passage');
|
|
295
|
+
if (el && el.getBoundingClientRect().top < 0) {
|
|
296
|
+
el.scrollIntoView?.({ block: 'start' });
|
|
297
|
+
}
|
|
298
|
+
}, [shownId]);
|
|
299
|
+
|
|
268
300
|
// When render is deferred, show StoryLoading passage or nothing
|
|
269
301
|
const effectivePassage = renderDeferred
|
|
270
302
|
? (storyData?.passages.get('StoryLoading') ?? null)
|
|
@@ -282,6 +314,23 @@ defineMacro({
|
|
|
282
314
|
|
|
283
315
|
const readyPassage = storyData?.passages.get('PassageReady');
|
|
284
316
|
|
|
317
|
+
// Between a navigation and the commit of its passage, the old passage is
|
|
318
|
+
// still mounted. It must not execute against the destination's state, so
|
|
319
|
+
// it keeps the element and the state it last rendered with (#352).
|
|
320
|
+
const outgoing = !renderDeferred && displayed.id !== navigationId;
|
|
321
|
+
const passageElement =
|
|
322
|
+
outgoing && lastElement.current
|
|
323
|
+
? lastElement.current
|
|
324
|
+
: effectivePassage && (
|
|
325
|
+
<Passage
|
|
326
|
+
passage={effectivePassage}
|
|
327
|
+
key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
|
|
328
|
+
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
329
|
+
navigationId={renderDeferred ? undefined : displayed.id}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
lastElement.current = passageElement || null;
|
|
333
|
+
|
|
285
334
|
return (
|
|
286
335
|
<div
|
|
287
336
|
id={ctx.id ?? 'story'}
|
|
@@ -299,13 +348,10 @@ defineMacro({
|
|
|
299
348
|
class="passage-container"
|
|
300
349
|
ref={containerRef}
|
|
301
350
|
>
|
|
302
|
-
{
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
307
|
-
navigationId={renderDeferred ? undefined : displayed.id}
|
|
308
|
-
/>
|
|
351
|
+
{passageElement && (
|
|
352
|
+
<FrozenStateContext.Provider value={isOutgoing}>
|
|
353
|
+
{passageElement}
|
|
354
|
+
</FrozenStateContext.Provider>
|
|
309
355
|
)}
|
|
310
356
|
</div>
|
|
311
357
|
</div>
|
|
@@ -345,12 +345,15 @@ export function SaveManagerContent() {
|
|
|
345
345
|
class="playthrough-group"
|
|
346
346
|
key={group.playthrough.id}
|
|
347
347
|
>
|
|
348
|
-
<
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
349
350
|
class="playthrough-header"
|
|
351
|
+
aria-expanded={!isCollapsed}
|
|
350
352
|
onClick={() => toggleCollapse(group.playthrough.id)}
|
|
351
353
|
>
|
|
352
354
|
<span
|
|
353
355
|
class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
|
|
356
|
+
aria-hidden="true"
|
|
354
357
|
>
|
|
355
358
|
▶
|
|
356
359
|
</span>
|
|
@@ -361,7 +364,7 @@ export function SaveManagerContent() {
|
|
|
361
364
|
<span class="playthrough-date">
|
|
362
365
|
{formatDate(group.playthrough.createdAt)}
|
|
363
366
|
</span>
|
|
364
|
-
</
|
|
367
|
+
</button>
|
|
365
368
|
|
|
366
369
|
{!isCollapsed && (
|
|
367
370
|
<div class="playthrough-saves">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
3
|
import type { Branch } from '../../markup/ast';
|
|
4
|
-
import { selectBranch } from './branches';
|
|
4
|
+
import { selectBranch, renderBranch } from './branches';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The first {case} branch whose value equals `value`, else the {default}
|
|
@@ -20,6 +20,7 @@ defineMacro({
|
|
|
20
20
|
name: 'switch',
|
|
21
21
|
subMacros: ['case', 'default'],
|
|
22
22
|
parameters: [{ name: 'expression', type: 'expression', required: true }],
|
|
23
|
+
interpolate: true,
|
|
23
24
|
merged: true,
|
|
24
25
|
render({ rawArgs, branches = [] }, ctx) {
|
|
25
26
|
let switchValue: unknown;
|
|
@@ -46,7 +47,8 @@ defineMacro({
|
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
if (!branch) return null;
|
|
51
|
+
return renderBranch(branch, ctx);
|
|
50
52
|
},
|
|
51
53
|
text({ rawArgs, branches = [] }, ctx) {
|
|
52
54
|
const branch = selectCase(ctx.evaluate(rawArgs), branches, ctx.evaluate);
|
|
@@ -7,6 +7,8 @@ import { splitArgs } from './arg-utils';
|
|
|
7
7
|
import { createNamespace } from '../../utils/namespace';
|
|
8
8
|
import { useRenderOptions } from '../../hooks/use-render-options';
|
|
9
9
|
import { LocalsScope } from './locals-scope';
|
|
10
|
+
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
11
|
+
import { wrapContent } from './display';
|
|
10
12
|
|
|
11
13
|
export { splitArgs };
|
|
12
14
|
|
|
@@ -15,9 +17,23 @@ interface WidgetInvocationProps {
|
|
|
15
17
|
params: string[];
|
|
16
18
|
rawArgs?: string;
|
|
17
19
|
invocationChildren?: ASTNode[];
|
|
20
|
+
className?: string;
|
|
21
|
+
id?: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
export function WidgetInvocation({
|
|
24
|
+
export function WidgetInvocation(props: WidgetInvocationProps) {
|
|
25
|
+
const resolve = useInterpolate();
|
|
26
|
+
// Invocation selectors (`{.badge#hero Stats}`) need an element to land on;
|
|
27
|
+
// dynamic ones (`{.{$theme} Stats}`) follow the state like a built-in
|
|
28
|
+
// macro's.
|
|
29
|
+
return wrapContent(
|
|
30
|
+
resolve(props.className),
|
|
31
|
+
resolve(props.id),
|
|
32
|
+
<WidgetContent {...props} />,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function WidgetContent({
|
|
21
37
|
body,
|
|
22
38
|
params,
|
|
23
39
|
rawArgs,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { ComponentChildren, VNode } from 'preact';
|
|
1
2
|
import type { Branch } from '../../markup/ast';
|
|
3
|
+
import type { MacroContext } from '../../define-macro';
|
|
4
|
+
import { wrapContent } from './display';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* The branch a conditional block ({if}, {switch}) shows: the first of
|
|
@@ -16,3 +19,20 @@ export function selectBranch(
|
|
|
16
19
|
}
|
|
17
20
|
return fallback;
|
|
18
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A selected branch's content, in a wrapper if the branch has CSS selectors
|
|
25
|
+
* (`{.hot#id case 1}`). The macro must be defined with `interpolate`.
|
|
26
|
+
*/
|
|
27
|
+
export function renderBranch(
|
|
28
|
+
branch: Branch,
|
|
29
|
+
ctx: Pick<MacroContext, 'resolve'> & {
|
|
30
|
+
renderNodes: (nodes: Branch['children']) => ComponentChildren;
|
|
31
|
+
},
|
|
32
|
+
): VNode<any> {
|
|
33
|
+
return wrapContent(
|
|
34
|
+
ctx.resolve!(branch.className),
|
|
35
|
+
ctx.resolve!(branch.id),
|
|
36
|
+
ctx.renderNodes(branch.children),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -10,16 +10,20 @@ import {
|
|
|
10
10
|
} from '../../markup/render';
|
|
11
11
|
import type { ASTNode } from '../../markup/ast';
|
|
12
12
|
import { useRenderOptions } from '../../hooks/use-render-options';
|
|
13
|
+
import { FrozenStateContext } from '../../hooks/use-story-fields';
|
|
13
14
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
14
15
|
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
15
16
|
import { RepeatContext } from './Repeat';
|
|
16
17
|
import { DialogCloseContext } from '../PassageDialog';
|
|
17
18
|
|
|
19
|
+
const frozenAlways = () => true;
|
|
20
|
+
|
|
18
21
|
/**
|
|
19
22
|
* Return a function that runs a macro body once, outside the passage tree:
|
|
20
23
|
* it renders `children` into a detached node, so the body's macros ({set},
|
|
21
24
|
* {if}, {goto}, ...) fire their side effects through the normal Preact
|
|
22
|
-
* pipeline. Used by {button} and {link} on click. The body
|
|
25
|
+
* pipeline. Used by {button} and {link} on click. The body's state is frozen
|
|
26
|
+
* once it has rendered, so it runs once per click. It stays mounted
|
|
23
27
|
* until the owning macro unmounts (its passage changes, say), so work it
|
|
24
28
|
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
25
29
|
* finish; the passage leaving cancels it.
|
|
@@ -58,21 +62,23 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
58
62
|
const locals = liveLocalsView(updater.getValues);
|
|
59
63
|
const container = document.createElement('div');
|
|
60
64
|
render(
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
<FrozenStateContext.Provider value={frozenAlways}>
|
|
66
|
+
<LocalsUpdateContext.Provider value={updater}>
|
|
67
|
+
<LocalsValuesContext.Provider value={locals}>
|
|
68
|
+
<NobrContext.Provider value={nobr}>
|
|
69
|
+
<InlineContext.Provider value={inline}>
|
|
70
|
+
<WidgetChildrenContext.Provider value={widgetChildren}>
|
|
71
|
+
<RepeatContext.Provider value={repeat}>
|
|
72
|
+
<DialogCloseContext.Provider value={closeDialog}>
|
|
73
|
+
{renderNodes(children, { nobr, inline, locals })}
|
|
74
|
+
</DialogCloseContext.Provider>
|
|
75
|
+
</RepeatContext.Provider>
|
|
76
|
+
</WidgetChildrenContext.Provider>
|
|
77
|
+
</InlineContext.Provider>
|
|
78
|
+
</NobrContext.Provider>
|
|
79
|
+
</LocalsValuesContext.Provider>
|
|
80
|
+
</LocalsUpdateContext.Provider>
|
|
81
|
+
</FrozenStateContext.Provider>,
|
|
76
82
|
container,
|
|
77
83
|
);
|
|
78
84
|
mounted.current.push(container);
|
|
@@ -59,14 +59,29 @@ export function defineInputMacro(
|
|
|
59
59
|
perform: (v) => ctx.setValue!(toValue(v)),
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
// What the reader last typed. A number input reports an unfinished
|
|
63
|
+
// number ("-", "2e") as an empty value, so the text is kept for as
|
|
64
|
+
// long as it still converts to the variable's value, instead of being
|
|
65
|
+
// replaced by that value on every keystroke (#353).
|
|
66
|
+
const typed = ctx.hooks.useRef<string | null>(null);
|
|
67
|
+
const shown =
|
|
68
|
+
typed.current !== null &&
|
|
69
|
+
toValue(typed.current) === ctx.value &&
|
|
70
|
+
type === 'number'
|
|
71
|
+
? typed.current
|
|
72
|
+
: display(ctx.value);
|
|
73
|
+
|
|
62
74
|
return h(type ? 'input' : 'textarea', {
|
|
63
75
|
type,
|
|
64
76
|
id: ctx.id,
|
|
65
77
|
class: ctx.cls,
|
|
66
|
-
value:
|
|
78
|
+
value: shown,
|
|
67
79
|
placeholder,
|
|
68
|
-
onInput: (e: Event) =>
|
|
69
|
-
|
|
80
|
+
onInput: (e: Event) => {
|
|
81
|
+
const text = (e.target as HTMLInputElement).value;
|
|
82
|
+
typed.current = text;
|
|
83
|
+
ctx.setValue!(toValue(text));
|
|
84
|
+
},
|
|
70
85
|
});
|
|
71
86
|
},
|
|
72
87
|
});
|
|
@@ -12,14 +12,17 @@ export function useMergedLocals(): readonly [
|
|
|
12
12
|
Record<string, unknown>,
|
|
13
13
|
Record<string, unknown>,
|
|
14
14
|
] {
|
|
15
|
-
|
|
15
|
+
// renderCounts is read by rendered()/hasRendered() inside expressions, so
|
|
16
|
+
// it re-renders them (and renews the tuple) when an inclusion mounts.
|
|
17
|
+
const { variables, temporary, transient, renderCounts } = useStoryFields(
|
|
16
18
|
'variables',
|
|
17
19
|
'temporary',
|
|
18
20
|
'transient',
|
|
21
|
+
'renderCounts',
|
|
19
22
|
);
|
|
20
23
|
const localsValues = useContext(LocalsValuesContext);
|
|
21
24
|
|
|
22
25
|
return useMemo(() => {
|
|
23
26
|
return [variables, temporary, localsValues, transient] as const;
|
|
24
|
-
}, [variables, temporary, localsValues, transient]);
|
|
27
|
+
}, [variables, temporary, localsValues, transient, renderCounts]);
|
|
25
28
|
}
|
|
@@ -29,7 +29,12 @@ function isRendered(el: HTMLElement): boolean {
|
|
|
29
29
|
|
|
30
30
|
function focusables(root: HTMLElement): HTMLElement[] {
|
|
31
31
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
|
32
|
-
(el) =>
|
|
32
|
+
(el) =>
|
|
33
|
+
!el.hidden &&
|
|
34
|
+
!el.closest('[hidden], [inert]') &&
|
|
35
|
+
// Also covers controls disabled by a <fieldset disabled> ancestor
|
|
36
|
+
!el.matches(':disabled') &&
|
|
37
|
+
isRendered(el),
|
|
33
38
|
);
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createContext } from 'preact';
|
|
2
|
+
import { useContext, useRef } from 'preact/hooks';
|
|
2
3
|
import { shallow } from 'zustand/vanilla/shallow';
|
|
3
4
|
import { useStoryStore, type StoryState } from '../store';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Tells the components below when to keep the state they last rendered with,
|
|
8
|
+
* given the store's current state: an owner (a click body, see
|
|
9
|
+
* useDetachedBody; a passage on its way out, see PassageDisplay) provides it
|
|
10
|
+
* so that a branch skipped earlier cannot run against later state (#351,
|
|
11
|
+
* #352). It runs inside the store's own notification, before the owner could
|
|
12
|
+
* re-render, which is why it is a predicate and not a flag.
|
|
13
|
+
*/
|
|
14
|
+
export const FrozenStateContext = createContext<
|
|
15
|
+
((state: StoryState) => boolean) | null
|
|
16
|
+
>(null);
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
7
20
|
* useStoryStore() selector per field would). The result keeps its identity
|
|
@@ -11,7 +24,9 @@ export function useStoryFields<K extends keyof StoryState>(
|
|
|
11
24
|
...keys: K[]
|
|
12
25
|
): Pick<StoryState, K> {
|
|
13
26
|
const prev = useRef<Pick<StoryState, K>>();
|
|
27
|
+
const freeze = useContext(FrozenStateContext);
|
|
14
28
|
return useStoryStore((s) => {
|
|
29
|
+
if (prev.current && freeze?.(s)) return prev.current;
|
|
15
30
|
const next = {} as Pick<StoryState, K>;
|
|
16
31
|
for (const key of keys) next[key] = s[key];
|
|
17
32
|
return shallow(prev.current, next) ? prev.current! : (prev.current = next);
|
package/src/js-lexer.ts
CHANGED
|
@@ -128,7 +128,7 @@ const OPERAND_ENDS: ReadonlySet<TokenType> = new Set([
|
|
|
128
128
|
* whether a `{` opens a block or an object literal, which decides whether a
|
|
129
129
|
* `/` after its `}` opens a regex and a `%` a transient: after the `:` of a
|
|
130
130
|
* conditional it opens an object literal (`a ? b : {} / 2`), not a block as
|
|
131
|
-
* after a label; after a block's `}` it opens another block (`{}{} %n = 1`),
|
|
131
|
+
* after a label, and a `function` or `class` there is an expression; after a block's `}` it opens another block (`{}{} %n = 1`),
|
|
132
132
|
* and so it does on a new line after a statement (`p⏎{} %n = 1`).
|
|
133
133
|
*/
|
|
134
134
|
const SigilParser = class extends (Parser as unknown as Base) {
|
|
@@ -146,6 +146,8 @@ const SigilParser = class extends (Parser as unknown as Base) {
|
|
|
146
146
|
closedBlock = false;
|
|
147
147
|
/** The value of the token before the one being finished. */
|
|
148
148
|
prevValue: unknown;
|
|
149
|
+
/** The type of the token before the last one. */
|
|
150
|
+
typeBeforeLast: TokenType = tt.eof;
|
|
149
151
|
/** Look ahead after a `%name` starting a line (off in look-aheads). */
|
|
150
152
|
lookahead = true;
|
|
151
153
|
/**
|
|
@@ -222,6 +224,21 @@ const SigilParser = class extends (Parser as unknown as Base) {
|
|
|
222
224
|
(prevType === tt.dot || prevType === tt.questionDot)) ||
|
|
223
225
|
((type === tt._function || type === tt._class) &&
|
|
224
226
|
notABody(self.input, self.pos));
|
|
227
|
+
// acorn takes a `function` or `class` for a statement's (whose `}` a
|
|
228
|
+
// regex may follow) after the `:` of a conditional, and `async function`
|
|
229
|
+
// always. Both are expressions where an operand is (`a ? b : class {} / 2`,
|
|
230
|
+
// `x = async function () {} / 2`): read them as after what comes before.
|
|
231
|
+
if (!property && (type === tt._function || type === tt._class)) {
|
|
232
|
+
const afterAsync =
|
|
233
|
+
type === tt._function &&
|
|
234
|
+
prevType === tt.name &&
|
|
235
|
+
this.prevValue === 'async' &&
|
|
236
|
+
!this.breakHere;
|
|
237
|
+
const before = afterAsync ? this.typeBeforeLast : prevType;
|
|
238
|
+
self.type =
|
|
239
|
+
before === tt.colon && this.colonEndsTernary ? tt.parenL : before;
|
|
240
|
+
}
|
|
241
|
+
this.typeBeforeLast = prevType;
|
|
225
242
|
// @ts-expect-error acorn internals
|
|
226
243
|
super.finishToken(property ? tt.name : type, value);
|
|
227
244
|
// A variable named `of` (`const of of list`): acorn takes it for the
|
package/src/markup/render.tsx
CHANGED
|
@@ -524,25 +524,43 @@ function useDirectAttributes(
|
|
|
524
524
|
return direct.length > 0 ? elementRef : undefined;
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
const decodedText = new Map<string, string>();
|
|
527
528
|
const decodedAttributeText = new Map<string, string>();
|
|
528
529
|
|
|
529
530
|
/**
|
|
530
|
-
* Decode character references (`&`, `{`) in
|
|
531
|
-
*
|
|
531
|
+
* Decode character references (`&`, `{`) in `text` the way the HTML
|
|
532
|
+
* parser decodes them, by letting it parse them: in an attribute value, or
|
|
533
|
+
* else in element text.
|
|
532
534
|
*/
|
|
533
|
-
function
|
|
535
|
+
function decodeReferences(
|
|
536
|
+
text: string,
|
|
537
|
+
cache: Map<string, string>,
|
|
538
|
+
parse: (text: string) => string | null,
|
|
539
|
+
): string {
|
|
534
540
|
if (!text.includes('&')) return text;
|
|
535
|
-
let decoded =
|
|
541
|
+
let decoded = cache.get(text);
|
|
536
542
|
if (decoded === undefined) {
|
|
537
|
-
|
|
538
|
-
decoded
|
|
539
|
-
(parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
|
|
540
|
-
text;
|
|
541
|
-
decodedAttributeText.set(text, decoded);
|
|
543
|
+
decoded = parse(text) ?? text;
|
|
544
|
+
cache.set(text, decoded);
|
|
542
545
|
}
|
|
543
546
|
return decoded;
|
|
544
547
|
}
|
|
545
548
|
|
|
549
|
+
function decodeAttributeText(text: string): string {
|
|
550
|
+
return decodeReferences(text, decodedAttributeText, (t) => {
|
|
551
|
+
const html = `<i title="${t.replace(/"/g, '"')}"></i>`;
|
|
552
|
+
return (parseHtmlInert(html).firstChild as Element).getAttribute('title');
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Literal text outside markdown (preformatted and SVG elements). */
|
|
557
|
+
function decodeText(text: string): string {
|
|
558
|
+
return decodeReferences(text, decodedText, (t) => {
|
|
559
|
+
const html = `<i>${t.replace(/</g, '<')}</i>`;
|
|
560
|
+
return parseHtmlInert(html).firstChild!.textContent;
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
546
564
|
/** A `{` and a sigil starting a reference, in a code attribute. */
|
|
547
565
|
const SIGIL_REFERENCE = /\{[$_@%]\w/;
|
|
548
566
|
|
|
@@ -679,14 +697,21 @@ function nodeKey(node: ASTNode): string {
|
|
|
679
697
|
function renderMacro(node: MacroNode, key: string) {
|
|
680
698
|
if (isSubMacro(node.name)) return null;
|
|
681
699
|
|
|
700
|
+
// What an invocation passes on to a widget and to a built-in macro alike
|
|
701
|
+
const invocation = {
|
|
702
|
+
rawArgs: node.rawArgs,
|
|
703
|
+
className: node.className,
|
|
704
|
+
id: node.id,
|
|
705
|
+
};
|
|
706
|
+
|
|
682
707
|
const widget = getWidget(node.name);
|
|
683
708
|
if (widget) {
|
|
684
709
|
return (
|
|
685
710
|
<WidgetInvocation
|
|
686
711
|
key={key}
|
|
712
|
+
{...invocation}
|
|
687
713
|
body={widget.body}
|
|
688
714
|
params={widget.params}
|
|
689
|
-
rawArgs={node.rawArgs}
|
|
690
715
|
invocationChildren={node.children}
|
|
691
716
|
/>
|
|
692
717
|
);
|
|
@@ -697,9 +722,7 @@ function renderMacro(node: MacroNode, key: string) {
|
|
|
697
722
|
return (
|
|
698
723
|
<Component
|
|
699
724
|
key={key}
|
|
700
|
-
|
|
701
|
-
className={node.className}
|
|
702
|
-
id={node.id}
|
|
725
|
+
{...invocation}
|
|
703
726
|
children={node.children}
|
|
704
727
|
branches={node.branches}
|
|
705
728
|
/>
|
|
@@ -720,7 +743,8 @@ function renderMacro(node: MacroNode, key: string) {
|
|
|
720
743
|
* Render a non-text AST node to a Preact element.
|
|
721
744
|
*/
|
|
722
745
|
function renderSingleNode(node: ASTNode): preact.ComponentChildren {
|
|
723
|
-
|
|
746
|
+
// Authored text outside markdown still has its character references decoded
|
|
747
|
+
if (node.type === 'text') return decodeText(node.value);
|
|
724
748
|
const key = nodeKey(node);
|
|
725
749
|
switch (node.type) {
|
|
726
750
|
case 'variable':
|
package/src/markup/spindle.peggy
CHANGED
|
@@ -233,6 +233,7 @@ Content
|
|
|
233
233
|
Item
|
|
234
234
|
= TextRun
|
|
235
235
|
/ Escape
|
|
236
|
+
/ Comment
|
|
236
237
|
/ LinkLexeme
|
|
237
238
|
/ HtmlElement
|
|
238
239
|
/ VoidCloser
|
|
@@ -291,6 +292,7 @@ Tokens
|
|
|
291
292
|
TokenItem
|
|
292
293
|
= TextRun
|
|
293
294
|
/ Escape
|
|
295
|
+
/ Comment
|
|
294
296
|
/ LinkLexeme
|
|
295
297
|
/ &{ return !textMode; } tag:HtmlOpen { return tag; }
|
|
296
298
|
/ &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
|
|
@@ -317,6 +319,15 @@ Here
|
|
|
317
319
|
TextRun
|
|
318
320
|
= value:$[^\\{[<]+ { return lexeme('text', location(), { value }); }
|
|
319
321
|
|
|
322
|
+
/**
|
|
323
|
+
* A closed HTML comment, opaque: its tags and macros are not markup, and
|
|
324
|
+
* rendering drops it. (An unclosed `<!--` is ordinary text.)
|
|
325
|
+
*/
|
|
326
|
+
Comment
|
|
327
|
+
= value:$("<!--" (">" / "->" / (!"-->" .)* "-->")) {
|
|
328
|
+
return lexeme('text', location(), { value });
|
|
329
|
+
}
|
|
330
|
+
|
|
320
331
|
/**
|
|
321
332
|
* A backslash run. Before a brace, an odd run escapes it; in text mode the
|
|
322
333
|
* run is also paired up (`\\` is one backslash), in passage text markdown
|