@rohal12/spindle 0.59.10 → 0.59.12
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 +7727 -7471
- 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/Computed.tsx +3 -9
- package/src/components/macros/For.tsx +34 -1
- package/src/components/macros/If.tsx +2 -7
- package/src/components/macros/SaveManager.tsx +5 -2
- package/src/components/macros/Switch.tsx +4 -2
- package/src/components/macros/Widget.tsx +1 -19
- package/src/components/macros/WidgetInvocation.tsx +13 -23
- package/src/components/macros/branches.ts +20 -0
- package/src/define-macro.ts +2 -0
- package/src/hooks/use-modal-focus.ts +50 -3
- package/src/index.tsx +9 -22
- package/src/js-lexer.ts +18 -1
- package/src/markup/render.tsx +44 -11
- package/src/markup/spindle.peggy +11 -0
- package/src/saves/save-manager.ts +9 -0
- package/src/story-init.ts +4 -0
- package/src/styles.css +5 -0
- package/src/triggers.ts +4 -2
- package/src/utils/control-edits.ts +15 -0
- package/src/widgets/register-widget-def.ts +44 -0
package/package.json
CHANGED
|
@@ -93,8 +93,6 @@ defineMacro({
|
|
|
93
93
|
{ name: 'expression', type: 'expression', required: true },
|
|
94
94
|
],
|
|
95
95
|
render({ rawArgs }, ctx) {
|
|
96
|
-
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
|
|
97
|
-
|
|
98
96
|
let target: string;
|
|
99
97
|
let expr: string;
|
|
100
98
|
try {
|
|
@@ -133,13 +131,9 @@ defineMacro({
|
|
|
133
131
|
}
|
|
134
132
|
|
|
135
133
|
// The merged values only decide when to recompute; computeAndApply reads
|
|
136
|
-
// the current ones.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
mergedTemps,
|
|
140
|
-
mergedLocals,
|
|
141
|
-
mergedTrans,
|
|
142
|
-
]);
|
|
134
|
+
// the current ones. The tuple is renewed with them and with the render
|
|
135
|
+
// counts, which rendered() / hasRendered() read.
|
|
136
|
+
ctx.hooks.useLayoutEffect(compute, [ctx.merged]);
|
|
143
137
|
|
|
144
138
|
return null;
|
|
145
139
|
},
|
|
@@ -3,6 +3,7 @@ import { LocalsValuesContext } from '../../markup/render';
|
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
4
|
import { MacroError } from './MacroError';
|
|
5
5
|
import { stableKey } from '../../utils/stable-key';
|
|
6
|
+
import { controlEditVersion } from '../../utils/control-edits';
|
|
6
7
|
import type { ASTNode } from '../../markup/ast';
|
|
7
8
|
import { checkVariableName } from '../../utils/namespace';
|
|
8
9
|
import { LocalsScope } from './locals-scope';
|
|
@@ -76,6 +77,14 @@ function ForIteration({
|
|
|
76
77
|
);
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** What {for} remembers of its iterations between renders. */
|
|
81
|
+
interface IterationKeys {
|
|
82
|
+
contents: string[];
|
|
83
|
+
generations: number[];
|
|
84
|
+
next: number;
|
|
85
|
+
edits: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
defineMacro({
|
|
80
89
|
name: 'for',
|
|
81
90
|
block: true,
|
|
@@ -88,6 +97,12 @@ defineMacro({
|
|
|
88
97
|
],
|
|
89
98
|
render({ rawArgs, children = [] }, ctx) {
|
|
90
99
|
const parentValues = useContext(LocalsValuesContext);
|
|
100
|
+
const keys = ctx.hooks.useRef<IterationKeys>({
|
|
101
|
+
contents: [],
|
|
102
|
+
generations: [],
|
|
103
|
+
next: 0,
|
|
104
|
+
edits: controlEditVersion(),
|
|
105
|
+
}).current;
|
|
91
106
|
|
|
92
107
|
let loop: ReturnType<typeof loopArgs>;
|
|
93
108
|
let list: unknown[];
|
|
@@ -112,9 +127,27 @@ defineMacro({
|
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
const { itemVar, indexVar } = loop;
|
|
130
|
+
// An iteration is remounted when its item's contents change, so mount-only
|
|
131
|
+
// macros run again for the new item (#45) -- unless the change is a
|
|
132
|
+
// reader editing the item through a control: remounting would drop the
|
|
133
|
+
// control's focus after the first keystroke.
|
|
134
|
+
const edited = controlEditVersion() !== keys.edits;
|
|
135
|
+
keys.edits = controlEditVersion();
|
|
136
|
+
const generations = list.map((item, i) => {
|
|
137
|
+
const contents = stableKey(item);
|
|
138
|
+
if (
|
|
139
|
+
keys.generations[i] === undefined ||
|
|
140
|
+
(contents !== keys.contents[i] && !edited)
|
|
141
|
+
) {
|
|
142
|
+
keys.generations[i] = keys.next++;
|
|
143
|
+
}
|
|
144
|
+
keys.contents[i] = contents;
|
|
145
|
+
return keys.generations[i]!;
|
|
146
|
+
});
|
|
147
|
+
keys.contents.length = keys.generations.length = list.length;
|
|
115
148
|
const content = list.map((item, i) => (
|
|
116
149
|
<ForIteration
|
|
117
|
-
key={`${i}-${
|
|
150
|
+
key={`${i}-${generations[i]}`}
|
|
118
151
|
parentValues={parentValues}
|
|
119
152
|
itemVar={itemVar}
|
|
120
153
|
itemValue={item}
|
|
@@ -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);
|
|
@@ -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);
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { astContainsChildren } from '../../widgets/ast-scanner';
|
|
3
|
-
import { registerBlockMacro, type ASTNode } from '../../markup/ast';
|
|
1
|
+
import { registerWidgetDef } from '../../widgets/register-widget-def';
|
|
4
2
|
import { defineMacro } from '../../define-macro';
|
|
5
3
|
import { checkVariableName } from '../../utils/namespace';
|
|
6
4
|
import { MacroError } from './MacroError';
|
|
@@ -10,22 +8,6 @@ import {
|
|
|
10
8
|
type WidgetDef,
|
|
11
9
|
} from '../../widgets/widget-def';
|
|
12
10
|
|
|
13
|
-
/**
|
|
14
|
-
* Register the widget a definition declares, with its body. Widgets whose
|
|
15
|
-
* body renders {@children} take a closing tag, so they are registered as
|
|
16
|
-
* block macros too: passages parsed later nest their content. A parameter
|
|
17
|
-
* that no namespace can hold throws (see registerWidget), registering
|
|
18
|
-
* nothing.
|
|
19
|
-
*/
|
|
20
|
-
export function registerWidgetDef(
|
|
21
|
-
{ name, params }: WidgetDef,
|
|
22
|
-
body: ASTNode[],
|
|
23
|
-
): void {
|
|
24
|
-
const isBlock = astContainsChildren(body);
|
|
25
|
-
registerWidget(name, body, params, isBlock);
|
|
26
|
-
if (isBlock) registerBlockMacro(name);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
11
|
defineMacro({
|
|
30
12
|
name: 'widget',
|
|
31
13
|
block: true,
|
|
@@ -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
|
|
|
@@ -20,17 +22,14 @@ interface WidgetInvocationProps {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function WidgetInvocation(props: WidgetInvocationProps) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
>
|
|
32
|
-
{content}
|
|
33
|
-
</span>
|
|
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} />,
|
|
34
33
|
);
|
|
35
34
|
}
|
|
36
35
|
|
|
@@ -39,8 +38,6 @@ function WidgetContent({
|
|
|
39
38
|
params,
|
|
40
39
|
rawArgs,
|
|
41
40
|
invocationChildren,
|
|
42
|
-
className,
|
|
43
|
-
id,
|
|
44
41
|
}: WidgetInvocationProps) {
|
|
45
42
|
const renderOptions = useRenderOptions();
|
|
46
43
|
const parentValues = renderOptions.locals;
|
|
@@ -49,16 +46,9 @@ function WidgetContent({
|
|
|
49
46
|
|
|
50
47
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
51
48
|
|
|
52
|
-
//
|
|
53
|
-
// without arguments: missing parameters shadow
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
57
|
-
{renderNodes(body, renderOptions)}
|
|
58
|
-
</WidgetChildrenContext.Provider>
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
49
|
+
// Every widget gets its own local scope, even when it declares no
|
|
50
|
+
// parameters or is invoked without arguments: missing parameters shadow
|
|
51
|
+
// outer locals as undefined, and the body's own locals stay in the widget.
|
|
62
52
|
const argExprs = rawArgs ? splitArgs(rawArgs) : [];
|
|
63
53
|
const values: unknown[] = [];
|
|
64
54
|
|
|
@@ -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
|
+
}
|
package/src/define-macro.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
useMemo,
|
|
11
11
|
} from 'preact/hooks';
|
|
12
12
|
import { useInterpolate } from './hooks/use-interpolate';
|
|
13
|
+
import { noteControlEdit } from './utils/control-edits';
|
|
13
14
|
import { useMergedLocals } from './hooks/use-merged-locals';
|
|
14
15
|
import { useRenderOptions } from './hooks/use-render-options';
|
|
15
16
|
import {
|
|
@@ -228,6 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
228
229
|
// In program order, also when mutation code performs the input
|
|
229
230
|
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
230
231
|
ctx.setValue = (value: unknown) => {
|
|
232
|
+
noteControlEdit();
|
|
231
233
|
// The commit mutation code uses, which keeps the references among
|
|
232
234
|
// variables: a write to a draft copies only the path written, and
|
|
233
235
|
// other variables sharing the object would keep the old one.
|
|
@@ -10,7 +10,7 @@ const FOCUSABLE = [
|
|
|
10
10
|
'textarea:not([disabled])',
|
|
11
11
|
'iframe',
|
|
12
12
|
'[contenteditable="true"]',
|
|
13
|
-
'[tabindex]
|
|
13
|
+
'[tabindex]',
|
|
14
14
|
].join(',');
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -27,10 +27,57 @@ function isRendered(el: HTMLElement): boolean {
|
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The `tabindex` of `el` as an integer, or null when absent or invalid. */
|
|
31
|
+
function tabIndexAttr(el: HTMLElement): number | null {
|
|
32
|
+
const raw = el.getAttribute('tabindex');
|
|
33
|
+
if (raw === null || !/^\s*[+-]?\d+\s*$/.test(raw)) return null;
|
|
34
|
+
return parseInt(raw, 10);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the tab key can reach `el`: a negative tabindex only allows
|
|
39
|
+
* scripted focus, and of a radio group the browser tabs to the checked
|
|
40
|
+
* member only (the first one when none is checked).
|
|
41
|
+
*/
|
|
42
|
+
function isTabStop(el: HTMLElement): boolean {
|
|
43
|
+
const tabIndex = tabIndexAttr(el);
|
|
44
|
+
if (tabIndex !== null && tabIndex < 0) return false;
|
|
45
|
+
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
|
46
|
+
const group = Array.from(
|
|
47
|
+
(
|
|
48
|
+
el.form ?? (el.getRootNode() as ParentNode)
|
|
49
|
+
).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
|
|
50
|
+
).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
|
|
51
|
+
const stop = group.find((r) => r.checked) ?? group[0];
|
|
52
|
+
return el === stop;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The elements of `root` in the order Tab visits them: positive tabindexes
|
|
59
|
+
* first, ascending, then the others in document order.
|
|
60
|
+
*/
|
|
30
61
|
function focusables(root: HTMLElement): HTMLElement[] {
|
|
31
|
-
|
|
32
|
-
|
|
62
|
+
const stops = Array.from(
|
|
63
|
+
root.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
64
|
+
).filter(
|
|
65
|
+
(el) =>
|
|
66
|
+
!el.hidden &&
|
|
67
|
+
!el.closest('[hidden], [inert]') &&
|
|
68
|
+
// Also covers controls disabled by a <fieldset disabled> ancestor
|
|
69
|
+
!el.matches(':disabled') &&
|
|
70
|
+
isRendered(el) &&
|
|
71
|
+
isTabStop(el),
|
|
33
72
|
);
|
|
73
|
+
const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
|
|
74
|
+
// Array.prototype.sort is stable: equal tabindexes keep document order
|
|
75
|
+
return [
|
|
76
|
+
...stops
|
|
77
|
+
.filter((el) => positive(el) > 0)
|
|
78
|
+
.sort((a, b) => positive(a) - positive(b)),
|
|
79
|
+
...stops.filter((el) => positive(el) === 0),
|
|
80
|
+
];
|
|
34
81
|
}
|
|
35
82
|
|
|
36
83
|
interface ModalEntry {
|
package/src/index.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { blockWidgetNames, parseWidgetDef } from './widgets/widget-def';
|
|
28
28
|
import { parseMarkup } from './markup/parse';
|
|
29
29
|
import { registerBlockMacro } from './markup/ast';
|
|
30
|
-
import {
|
|
30
|
+
import { registerWidgetDefinitions } from './widgets/register-widget-def';
|
|
31
31
|
import { errorMessage } from './utils/error-message';
|
|
32
32
|
import type { ASTNode } from './markup/ast';
|
|
33
33
|
import './macros/register-builtins';
|
|
@@ -193,6 +193,14 @@ export function boot() {
|
|
|
193
193
|
// Enter runtime phase — handlers registered from here on are cleaned on restart
|
|
194
194
|
enterRuntimePhase();
|
|
195
195
|
|
|
196
|
+
// Pass 2: Full parse and register widgets from passages tagged "widget",
|
|
197
|
+
// before StoryInit runs so that it can invoke them
|
|
198
|
+
for (const [, passage] of storyData.passages) {
|
|
199
|
+
if (passage.tags.includes('widget')) {
|
|
200
|
+
registerWidgetDefinitions(parseMarkup(passage.content), passage.name);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
196
204
|
// Run StoryInit, restore the session if the page was refreshed, and fire
|
|
197
205
|
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
198
206
|
initializeStory(loadSession(storyData.ifid));
|
|
@@ -205,27 +213,6 @@ export function boot() {
|
|
|
205
213
|
];
|
|
206
214
|
if (markup.length > 0) stopWithErrors(markup);
|
|
207
215
|
|
|
208
|
-
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
209
|
-
for (const [, passage] of storyData.passages) {
|
|
210
|
-
if (passage.tags.includes('widget')) {
|
|
211
|
-
const widgetAST = parseMarkup(passage.content);
|
|
212
|
-
for (const node of widgetAST) {
|
|
213
|
-
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
214
|
-
// Read and registered as the {widget} macro does
|
|
215
|
-
const def = parseWidgetDef(node.rawArgs);
|
|
216
|
-
try {
|
|
217
|
-
registerWidgetDef(def, node.children as ASTNode[]);
|
|
218
|
-
} catch (err) {
|
|
219
|
-
// As the {widget} macro refuses it: the others still register
|
|
220
|
-
console.error(
|
|
221
|
-
`spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
216
|
// Reset action ID counters on every navigation (the passage remounts even
|
|
230
217
|
// when its name is unchanged). Controls that stay mounted, e.g. in
|
|
231
218
|
// StoryInterface, keep their IDs: allocation skips IDs still registered.
|
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
|
@@ -348,7 +348,11 @@ function convertDomNode(
|
|
|
348
348
|
return null;
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
/**
|
|
351
|
+
/**
|
|
352
|
+
* Elements whose content is phrasing content only, so block-level markdown
|
|
353
|
+
* (lists, headings, paragraphs) is invalid in them: inline elements, and
|
|
354
|
+
* block elements such as headings and buttons that only take inline children.
|
|
355
|
+
*/
|
|
352
356
|
const INLINE_ELEMENTS = new Set([
|
|
353
357
|
'a',
|
|
354
358
|
'abbr',
|
|
@@ -356,17 +360,27 @@ const INLINE_ELEMENTS = new Set([
|
|
|
356
360
|
'bdi',
|
|
357
361
|
'bdo',
|
|
358
362
|
'br',
|
|
363
|
+
'button',
|
|
359
364
|
'cite',
|
|
360
365
|
'code',
|
|
361
366
|
'data',
|
|
362
367
|
'dfn',
|
|
363
368
|
'em',
|
|
364
369
|
'i',
|
|
370
|
+
'h1',
|
|
371
|
+
'h2',
|
|
372
|
+
'h3',
|
|
373
|
+
'h4',
|
|
374
|
+
'h5',
|
|
375
|
+
'h6',
|
|
365
376
|
'kbd',
|
|
366
377
|
'label',
|
|
378
|
+
'legend',
|
|
367
379
|
'mark',
|
|
368
380
|
'meter',
|
|
381
|
+
'option',
|
|
369
382
|
'output',
|
|
383
|
+
'p',
|
|
370
384
|
'progress',
|
|
371
385
|
'q',
|
|
372
386
|
'rp',
|
|
@@ -524,25 +538,43 @@ function useDirectAttributes(
|
|
|
524
538
|
return direct.length > 0 ? elementRef : undefined;
|
|
525
539
|
}
|
|
526
540
|
|
|
541
|
+
const decodedText = new Map<string, string>();
|
|
527
542
|
const decodedAttributeText = new Map<string, string>();
|
|
528
543
|
|
|
529
544
|
/**
|
|
530
|
-
* Decode character references (`&`, `{`) in
|
|
531
|
-
*
|
|
545
|
+
* Decode character references (`&`, `{`) in `text` the way the HTML
|
|
546
|
+
* parser decodes them, by letting it parse them: in an attribute value, or
|
|
547
|
+
* else in element text.
|
|
532
548
|
*/
|
|
533
|
-
function
|
|
549
|
+
function decodeReferences(
|
|
550
|
+
text: string,
|
|
551
|
+
cache: Map<string, string>,
|
|
552
|
+
parse: (text: string) => string | null,
|
|
553
|
+
): string {
|
|
534
554
|
if (!text.includes('&')) return text;
|
|
535
|
-
let decoded =
|
|
555
|
+
let decoded = cache.get(text);
|
|
536
556
|
if (decoded === undefined) {
|
|
537
|
-
|
|
538
|
-
decoded
|
|
539
|
-
(parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
|
|
540
|
-
text;
|
|
541
|
-
decodedAttributeText.set(text, decoded);
|
|
557
|
+
decoded = parse(text) ?? text;
|
|
558
|
+
cache.set(text, decoded);
|
|
542
559
|
}
|
|
543
560
|
return decoded;
|
|
544
561
|
}
|
|
545
562
|
|
|
563
|
+
function decodeAttributeText(text: string): string {
|
|
564
|
+
return decodeReferences(text, decodedAttributeText, (t) => {
|
|
565
|
+
const html = `<i title="${t.replace(/"/g, '"')}"></i>`;
|
|
566
|
+
return (parseHtmlInert(html).firstChild as Element).getAttribute('title');
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Literal text outside markdown (preformatted and SVG elements). */
|
|
571
|
+
function decodeText(text: string): string {
|
|
572
|
+
return decodeReferences(text, decodedText, (t) => {
|
|
573
|
+
const html = `<i>${t.replace(/</g, '<')}</i>`;
|
|
574
|
+
return parseHtmlInert(html).firstChild!.textContent;
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
546
578
|
/** A `{` and a sigil starting a reference, in a code attribute. */
|
|
547
579
|
const SIGIL_REFERENCE = /\{[$_@%]\w/;
|
|
548
580
|
|
|
@@ -725,7 +757,8 @@ function renderMacro(node: MacroNode, key: string) {
|
|
|
725
757
|
* Render a non-text AST node to a Preact element.
|
|
726
758
|
*/
|
|
727
759
|
function renderSingleNode(node: ASTNode): preact.ComponentChildren {
|
|
728
|
-
|
|
760
|
+
// Authored text outside markdown still has its character references decoded
|
|
761
|
+
if (node.type === 'text') return decodeText(node.value);
|
|
729
762
|
const key = nodeKey(node);
|
|
730
763
|
switch (node.type) {
|
|
731
764
|
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
|
|
@@ -36,7 +36,16 @@ export function setSaveTitlePassage(content: string): void {
|
|
|
36
36
|
saveTitlePassageContent = content;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The title of a save. The SaveTitle passage and title generators may draw
|
|
41
|
+
* random numbers; like the save hooks they must not advance the story's PRNG
|
|
42
|
+
* (see withoutDraws()).
|
|
43
|
+
*/
|
|
39
44
|
function generateTitle(payload: SavePayload): string {
|
|
45
|
+
return withoutDraws(() => generateTitleNow(payload));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function generateTitleNow(payload: SavePayload): string {
|
|
40
49
|
// SaveTitle passage takes precedence
|
|
41
50
|
if (saveTitlePassageContent) {
|
|
42
51
|
try {
|
package/src/story-init.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
2
|
import { useStoryStore, recordStoryInitState, missingPassage } from './store';
|
|
3
3
|
import { parseMarkup } from './markup/parse';
|
|
4
|
+
import { registerWidgetDefinitions } from './widgets/register-widget-def';
|
|
4
5
|
import { renderNodes } from './markup/render';
|
|
5
6
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
6
7
|
import { emit } from './event-emitter';
|
|
@@ -35,6 +36,9 @@ export function executeStoryInit() {
|
|
|
35
36
|
const storyInit = state.storyData.passages.get('StoryInit');
|
|
36
37
|
if (storyInit) {
|
|
37
38
|
const ast = parseMarkup(storyInit.content);
|
|
39
|
+
// Its own widgets are usable from the passage itself: the macros render
|
|
40
|
+
// before the {widget} definitions' effects run.
|
|
41
|
+
registerWidgetDefinitions(ast, 'StoryInit');
|
|
38
42
|
|
|
39
43
|
// Mount into a persistent hidden container. It stays mounted until the
|
|
40
44
|
// next execution (restart) — this lets async effects (useEffect,
|
package/src/styles.css
CHANGED