@rohal12/spindle 0.52.5 → 0.52.7
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 +705 -576
- package/dist/pkg/macro-registry.json +255 -0
- package/dist/pkg/story-variables.js +54 -32
- package/package.json +1 -1
- package/src/components/macros/Button.tsx +2 -4
- package/src/components/macros/Checkbox.tsx +6 -13
- package/src/components/macros/Computed.tsx +38 -55
- package/src/components/macros/Cycle.tsx +1 -0
- package/src/components/macros/Dialog.tsx +7 -8
- package/src/components/macros/Do.tsx +2 -1
- package/src/components/macros/For.tsx +27 -78
- package/src/components/macros/Goto.tsx +6 -9
- package/src/components/macros/Include.tsx +10 -57
- package/src/components/macros/Listbox.tsx +1 -0
- package/src/components/macros/MacroError.tsx +37 -0
- package/src/components/macros/MacroLink.tsx +9 -24
- package/src/components/macros/Meter.tsx +19 -37
- package/src/components/macros/Numberbox.tsx +5 -32
- package/src/components/macros/PassageDisplay.tsx +37 -47
- package/src/components/macros/Print.tsx +1 -0
- package/src/components/macros/Radiobutton.tsx +8 -38
- package/src/components/macros/Repeat.tsx +3 -3
- package/src/components/macros/Set.tsx +3 -29
- package/src/components/macros/Switch.tsx +36 -37
- package/src/components/macros/Textarea.tsx +2 -28
- package/src/components/macros/Textbox.tsx +2 -29
- package/src/components/macros/Type.tsx +3 -3
- package/src/components/macros/Unset.tsx +17 -40
- package/src/components/macros/Watch.tsx +23 -91
- package/src/components/macros/WidgetInvocation.tsx +9 -71
- package/src/components/macros/detached-body.tsx +2 -2
- package/src/components/macros/input-macro.ts +48 -0
- package/src/components/macros/locals-scope.tsx +60 -0
- package/src/components/macros/macro-args.ts +280 -0
- package/src/components/macros/option-utils.ts +7 -12
- package/src/define-macro.ts +47 -30
- package/src/hooks/use-render-options.ts +26 -0
- package/src/js-lexer.ts +80 -45
- package/src/markup/render.tsx +2 -4
- package/src/registry.ts +48 -0
- package/types/index.d.ts +69 -6
- package/types/tooling.d.ts +33 -1
|
@@ -2,9 +2,8 @@ import { useStoryStore } from '../../store';
|
|
|
2
2
|
import { evaluate } from '../../expression';
|
|
3
3
|
import { readState } from '../../execute-mutation';
|
|
4
4
|
import { deepEqual } from '../../class-registry';
|
|
5
|
-
import { currentSourceLocation } from '../../utils/source-location';
|
|
6
5
|
import { defineMacro } from '../../define-macro';
|
|
7
|
-
import { MacroError } from './MacroError';
|
|
6
|
+
import { MacroError, logMacroError } from './MacroError';
|
|
8
7
|
import { checkVariableName } from '../../utils/namespace';
|
|
9
8
|
|
|
10
9
|
/**
|
|
@@ -14,38 +13,26 @@ import { checkVariableName } from '../../utils/namespace';
|
|
|
14
13
|
*/
|
|
15
14
|
const UNSET: unique symbol = Symbol('unset');
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
i++;
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
if (i > 0 && trimmed[i - 1] === '!') continue;
|
|
31
|
-
|
|
32
|
-
const target = trimmed.slice(0, i).trim();
|
|
33
|
-
const expr = trimmed.slice(i + 1).trim();
|
|
34
|
-
|
|
35
|
-
if (!target.match(/^[$_@]\w+$/)) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
`{computed}: target must be $name, _name, or @name, got "${target}"`,
|
|
38
|
-
);
|
|
39
|
-
}
|
|
16
|
+
/** Check the arguments of "target = expression". */
|
|
17
|
+
function assignmentArgs(
|
|
18
|
+
rawArgs: string,
|
|
19
|
+
args: { target?: string; '=': boolean; expression?: string },
|
|
20
|
+
): { target: string; expr: string } {
|
|
21
|
+
if (!args['=']) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`{computed}: expected "target = expression", got "${rawArgs}"`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
40
26
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
27
|
+
const { target = '', expression: expr = '' } = args;
|
|
28
|
+
if (!target.match(/^[$_@]\w+$/)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`{computed}: target must be $name, _name, or @name, got "${target}"`,
|
|
31
|
+
);
|
|
44
32
|
}
|
|
45
33
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
34
|
+
checkVariableName(target.slice(1), target);
|
|
35
|
+
return { target, expr };
|
|
49
36
|
}
|
|
50
37
|
|
|
51
38
|
/**
|
|
@@ -73,10 +60,7 @@ function computeAndApply(
|
|
|
73
60
|
const { variables, temporary, transient } = readState();
|
|
74
61
|
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
75
62
|
} catch (err) {
|
|
76
|
-
|
|
77
|
-
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
|
|
78
|
-
err,
|
|
79
|
-
);
|
|
63
|
+
logMacroError(`computed ${rawArgs}`, err);
|
|
80
64
|
return;
|
|
81
65
|
}
|
|
82
66
|
|
|
@@ -86,10 +70,7 @@ function computeAndApply(
|
|
|
86
70
|
try {
|
|
87
71
|
localsUpdate!(name, newValue);
|
|
88
72
|
} catch (err) {
|
|
89
|
-
|
|
90
|
-
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
|
|
91
|
-
err,
|
|
92
|
-
);
|
|
73
|
+
logMacroError(`computed ${rawArgs}`, err);
|
|
93
74
|
}
|
|
94
75
|
} else {
|
|
95
76
|
const state = useStoryStore.getState();
|
|
@@ -102,13 +83,18 @@ function computeAndApply(
|
|
|
102
83
|
defineMacro({
|
|
103
84
|
name: 'computed',
|
|
104
85
|
merged: true,
|
|
86
|
+
parameters: [
|
|
87
|
+
{ name: 'target', type: 'variable', required: true },
|
|
88
|
+
{ name: '=', type: 'separator' },
|
|
89
|
+
{ name: 'expression', type: 'expression', required: true },
|
|
90
|
+
],
|
|
105
91
|
render({ rawArgs }, ctx) {
|
|
106
92
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
|
|
107
93
|
|
|
108
94
|
let target: string;
|
|
109
95
|
let expr: string;
|
|
110
96
|
try {
|
|
111
|
-
({ target, expr } =
|
|
97
|
+
({ target, expr } = assignmentArgs(rawArgs, ctx.args));
|
|
112
98
|
} catch (err) {
|
|
113
99
|
return (
|
|
114
100
|
<MacroError
|
|
@@ -124,9 +110,7 @@ defineMacro({
|
|
|
124
110
|
|
|
125
111
|
const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
|
|
126
112
|
|
|
127
|
-
const
|
|
128
|
-
if (!ran.current) {
|
|
129
|
-
ran.current = true;
|
|
113
|
+
const compute = () =>
|
|
130
114
|
computeAndApply(
|
|
131
115
|
expr,
|
|
132
116
|
name,
|
|
@@ -137,22 +121,21 @@ defineMacro({
|
|
|
137
121
|
prevOutput,
|
|
138
122
|
localsUpdate,
|
|
139
123
|
);
|
|
124
|
+
|
|
125
|
+
const ran = ctx.hooks.useRef(false);
|
|
126
|
+
if (!ran.current) {
|
|
127
|
+
ran.current = true;
|
|
128
|
+
compute();
|
|
140
129
|
}
|
|
141
130
|
|
|
142
131
|
// The merged values only decide when to recompute; computeAndApply reads
|
|
143
132
|
// the current ones.
|
|
144
|
-
ctx.hooks.useLayoutEffect(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
ctx.getValues,
|
|
151
|
-
rawArgs,
|
|
152
|
-
prevOutput,
|
|
153
|
-
localsUpdate,
|
|
154
|
-
);
|
|
155
|
-
}, [mergedVars, mergedTemps, mergedLocals, mergedTrans]);
|
|
133
|
+
ctx.hooks.useLayoutEffect(compute, [
|
|
134
|
+
mergedVars,
|
|
135
|
+
mergedTemps,
|
|
136
|
+
mergedLocals,
|
|
137
|
+
mergedTrans,
|
|
138
|
+
]);
|
|
156
139
|
|
|
157
140
|
return null;
|
|
158
141
|
},
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { PassageDialog } from '../PassageDialog';
|
|
3
|
-
import { readWholeQuoted } from './arg-utils';
|
|
4
3
|
|
|
5
4
|
defineMacro({
|
|
6
5
|
name: 'dialog',
|
|
7
6
|
block: true,
|
|
8
7
|
interpolate: true,
|
|
9
|
-
|
|
8
|
+
parameters: [
|
|
9
|
+
{ name: 'label', type: 'text', required: true },
|
|
10
|
+
{ name: 'noclose', type: 'flag' },
|
|
11
|
+
],
|
|
12
|
+
render({ children = [] }, ctx) {
|
|
10
13
|
const [open, setOpen] = ctx.hooks.useState(false);
|
|
11
14
|
|
|
12
|
-
const noclose =
|
|
13
|
-
const
|
|
14
|
-
const label =
|
|
15
|
-
ctx.resolve?.(
|
|
16
|
-
readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, ''),
|
|
17
|
-
) ?? labelRaw;
|
|
15
|
+
const { noclose } = ctx.args;
|
|
16
|
+
const label = ctx.resolve!(ctx.args.label ?? '');
|
|
18
17
|
const passageName = ctx
|
|
19
18
|
.collectText(children)
|
|
20
19
|
.trim()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { logMacroError } from './MacroError';
|
|
2
3
|
|
|
3
4
|
defineMacro({
|
|
4
5
|
name: 'do',
|
|
@@ -11,7 +12,7 @@ defineMacro({
|
|
|
11
12
|
try {
|
|
12
13
|
ctx.mutate(code);
|
|
13
14
|
} catch (err) {
|
|
14
|
-
|
|
15
|
+
logMacroError('do', err);
|
|
15
16
|
}
|
|
16
17
|
}, []);
|
|
17
18
|
|
|
@@ -1,47 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
useState,
|
|
4
|
-
useCallback,
|
|
5
|
-
useRef,
|
|
6
|
-
useMemo,
|
|
7
|
-
} from 'preact/hooks';
|
|
8
|
-
import {
|
|
9
|
-
LocalsValuesContext,
|
|
10
|
-
LocalsUpdateContext,
|
|
11
|
-
NobrContext,
|
|
12
|
-
InlineContext,
|
|
13
|
-
RawTextContext,
|
|
14
|
-
renderNodes,
|
|
15
|
-
} from '../../markup/render';
|
|
1
|
+
import { useContext, useMemo } from 'preact/hooks';
|
|
2
|
+
import { LocalsValuesContext } from '../../markup/render';
|
|
16
3
|
import { defineMacro } from '../../define-macro';
|
|
17
4
|
import { MacroError } from './MacroError';
|
|
18
5
|
import { stableKey } from '../../utils/stable-key';
|
|
19
6
|
import type { ASTNode } from '../../markup/ast';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
createNamespace,
|
|
23
|
-
withEntry,
|
|
24
|
-
} from '../../utils/namespace';
|
|
7
|
+
import { checkVariableName } from '../../utils/namespace';
|
|
8
|
+
import { LocalsScope } from './locals-scope';
|
|
25
9
|
|
|
26
10
|
/**
|
|
27
|
-
*
|
|
11
|
+
* Check the arguments of "@item, @i of $list" or "@item of $list".
|
|
28
12
|
*/
|
|
29
|
-
function
|
|
13
|
+
function loopArgs(
|
|
14
|
+
rawArgs: string,
|
|
15
|
+
args: { variables?: string[]; of: boolean; list?: string },
|
|
16
|
+
): {
|
|
30
17
|
itemVar: string;
|
|
31
18
|
indexVar: string | null;
|
|
32
19
|
listExpr: string;
|
|
33
20
|
} {
|
|
34
|
-
|
|
35
|
-
if (ofIdx === -1) {
|
|
21
|
+
if (!args.of) {
|
|
36
22
|
throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
|
|
37
23
|
}
|
|
38
24
|
|
|
39
|
-
const
|
|
40
|
-
const listExpr =
|
|
41
|
-
|
|
42
|
-
const vars = varsPart.split(',').map((v) => v.trim());
|
|
43
|
-
const itemVar = vars[0]!;
|
|
44
|
-
const indexVar = vars.length > 1 ? vars[1]! : null;
|
|
25
|
+
const [itemVar = '', indexVar = null] = args.variables ?? [];
|
|
26
|
+
const listExpr = args.list ?? '';
|
|
45
27
|
|
|
46
28
|
if (!itemVar.startsWith('@')) {
|
|
47
29
|
throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
|
|
@@ -77,10 +59,6 @@ function ForIteration({
|
|
|
77
59
|
indexValue: number;
|
|
78
60
|
children: ASTNode[];
|
|
79
61
|
}) {
|
|
80
|
-
const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
|
|
81
|
-
() => ({}),
|
|
82
|
-
);
|
|
83
|
-
|
|
84
62
|
const ownKeys = useMemo(
|
|
85
63
|
() => ({
|
|
86
64
|
[itemVar]: itemValue,
|
|
@@ -89,35 +67,12 @@ function ForIteration({
|
|
|
89
67
|
[itemVar, itemValue, indexVar, indexValue],
|
|
90
68
|
);
|
|
91
69
|
|
|
92
|
-
const localState = useMemo(
|
|
93
|
-
() => createNamespace(parentValues, ownKeys, localMutations),
|
|
94
|
-
[parentValues, ownKeys, localMutations],
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const valuesRef = useRef(localState);
|
|
98
|
-
valuesRef.current = localState;
|
|
99
|
-
|
|
100
|
-
const getValues = useCallback(() => valuesRef.current, []);
|
|
101
|
-
const update = useCallback((key: string, value: unknown) => {
|
|
102
|
-
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
103
|
-
// second {set}) read the new value via getValues(); the state update
|
|
104
|
-
// then re-renders consumers of LocalsValuesContext.
|
|
105
|
-
checkVariableName(key, `@${key}`);
|
|
106
|
-
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
107
|
-
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
108
|
-
}, []);
|
|
109
|
-
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
110
|
-
|
|
111
|
-
const nobr = useContext(NobrContext);
|
|
112
|
-
const inline = useContext(InlineContext);
|
|
113
|
-
const raw = useContext(RawTextContext);
|
|
114
|
-
|
|
115
70
|
return (
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
71
|
+
<LocalsScope
|
|
72
|
+
body={children}
|
|
73
|
+
parentValues={parentValues}
|
|
74
|
+
ownKeys={ownKeys}
|
|
75
|
+
/>
|
|
121
76
|
);
|
|
122
77
|
}
|
|
123
78
|
|
|
@@ -126,26 +81,19 @@ defineMacro({
|
|
|
126
81
|
block: true,
|
|
127
82
|
interpolate: true,
|
|
128
83
|
merged: true,
|
|
84
|
+
parameters: [
|
|
85
|
+
{ name: 'variables', type: 'names', required: true },
|
|
86
|
+
{ name: 'of', type: 'separator' },
|
|
87
|
+
{ name: 'list', type: 'expression', required: true },
|
|
88
|
+
],
|
|
129
89
|
render({ rawArgs, children = [] }, ctx) {
|
|
130
90
|
const parentValues = useContext(LocalsValuesContext);
|
|
131
91
|
|
|
132
|
-
let
|
|
133
|
-
try {
|
|
134
|
-
parsed = parseForArgs(rawArgs);
|
|
135
|
-
} catch (err) {
|
|
136
|
-
return (
|
|
137
|
-
<MacroError
|
|
138
|
-
macro="for"
|
|
139
|
-
error={err}
|
|
140
|
-
/>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const { itemVar, indexVar, listExpr } = parsed;
|
|
145
|
-
|
|
92
|
+
let loop: ReturnType<typeof loopArgs>;
|
|
146
93
|
let list: unknown[];
|
|
147
94
|
try {
|
|
148
|
-
|
|
95
|
+
loop = loopArgs(rawArgs, ctx.args);
|
|
96
|
+
const result = ctx.evaluate!(loop.listExpr);
|
|
149
97
|
if (!Array.isArray(result)) {
|
|
150
98
|
return (
|
|
151
99
|
<span class="error">
|
|
@@ -163,6 +111,7 @@ defineMacro({
|
|
|
163
111
|
);
|
|
164
112
|
}
|
|
165
113
|
|
|
114
|
+
const { itemVar, indexVar } = loop;
|
|
166
115
|
const content = list.map((item, i) => (
|
|
167
116
|
<ForIteration
|
|
168
117
|
key={`${i}-${stableKey(item)}`}
|
|
@@ -178,7 +127,7 @@ defineMacro({
|
|
|
178
127
|
return ctx.wrap(content);
|
|
179
128
|
},
|
|
180
129
|
text({ rawArgs, children = [] }, ctx) {
|
|
181
|
-
const { itemVar, indexVar, listExpr } =
|
|
130
|
+
const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
|
|
182
131
|
const list = ctx.evaluate(listExpr);
|
|
183
132
|
if (!Array.isArray(list)) {
|
|
184
133
|
throw new Error('expression did not evaluate to an array');
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
+
import { evaluatePassageName } from './macro-args';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
5
6
|
name: 'goto',
|
|
6
7
|
merged: true,
|
|
7
|
-
|
|
8
|
+
parameters: [{ name: 'passage', type: 'expression', required: true }],
|
|
9
|
+
render(_props, ctx) {
|
|
8
10
|
ctx.hooks.useLayoutEffect(() => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
passageName = String(result);
|
|
13
|
-
} catch {
|
|
14
|
-
passageName = rawArgs.replace(/^["']|["']$/g, '');
|
|
15
|
-
}
|
|
16
|
-
useStoryStore.getState().navigate(passageName);
|
|
11
|
+
useStoryStore
|
|
12
|
+
.getState()
|
|
13
|
+
.navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
|
|
17
14
|
}, []);
|
|
18
15
|
|
|
19
16
|
return null;
|
|
@@ -3,70 +3,23 @@ import { tokenize } from '../../markup/tokenizer';
|
|
|
3
3
|
import { buildAST } from '../../markup/ast';
|
|
4
4
|
import { NobrContext } from '../../markup/render';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
const FLAG = 'inline';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Split a standalone `inline` flag off the start or end of the include
|
|
12
|
-
* arguments. The flag only counts outside quotes, brackets, parentheses and
|
|
13
|
-
* braces, separated by whitespace from the target expression, so passage
|
|
14
|
-
* names and expressions containing the word stay intact (#201).
|
|
15
|
-
*/
|
|
16
|
-
export function parseIncludeArgs(rawArgs: string): {
|
|
17
|
-
nameExpr: string;
|
|
18
|
-
inline: boolean;
|
|
19
|
-
} {
|
|
20
|
-
const trimmed = rawArgs.trim();
|
|
21
|
-
|
|
22
|
-
// First and last whitespace runs at depth 0, as [start, end) offsets.
|
|
23
|
-
const spaces = topLevelIndices(trimmed, isWhitespace);
|
|
24
|
-
let first: [number, number] | null = null;
|
|
25
|
-
let last: [number, number] | null = null;
|
|
26
|
-
if (spaces.length > 0) {
|
|
27
|
-
let s = 0;
|
|
28
|
-
while (s + 1 < spaces.length && spaces[s + 1] === spaces[s]! + 1) s++;
|
|
29
|
-
first = [spaces[0]!, spaces[s]! + 1];
|
|
30
|
-
let e = spaces.length - 1;
|
|
31
|
-
while (e > 0 && spaces[e - 1] === spaces[e]! - 1) e--;
|
|
32
|
-
last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Next to a binary operator the word is an operand (`"a" + inline`);
|
|
36
|
-
// the closing `/` of a regex literal is not one.
|
|
37
|
-
if (
|
|
38
|
-
last &&
|
|
39
|
-
last[1] === trimmed.length - FLAG.length &&
|
|
40
|
-
trimmed.endsWith(FLAG)
|
|
41
|
-
) {
|
|
42
|
-
const rest = trimmed.slice(0, last[0]);
|
|
43
|
-
if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
|
|
44
|
-
}
|
|
45
|
-
if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
|
|
46
|
-
const rest = trimmed.slice(first[1]);
|
|
47
|
-
// `inline %name` reads a transient variable; `inline % 2` is modulo.
|
|
48
|
-
if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
|
|
49
|
-
return { nameExpr: rest, inline: true };
|
|
50
|
-
}
|
|
51
|
-
return { nameExpr: trimmed, inline: false };
|
|
52
|
-
}
|
|
6
|
+
import { evaluatePassageName } from './macro-args';
|
|
53
7
|
|
|
54
8
|
defineMacro({
|
|
55
9
|
name: 'include',
|
|
56
10
|
interpolate: true,
|
|
57
11
|
merged: true,
|
|
58
|
-
|
|
12
|
+
// A standalone `inline` flag only counts outside quotes and brackets, so
|
|
13
|
+
// passage names and expressions containing the word stay intact (#201).
|
|
14
|
+
parameters: [
|
|
15
|
+
{ name: 'inline', type: 'flag' },
|
|
16
|
+
{ name: 'passage', type: 'expression', required: true },
|
|
17
|
+
],
|
|
18
|
+
render(_props, ctx) {
|
|
59
19
|
const storyData = useStoryStore((s) => s.storyData);
|
|
60
20
|
|
|
61
|
-
const {
|
|
62
|
-
|
|
63
|
-
let passageName: string;
|
|
64
|
-
try {
|
|
65
|
-
const result = ctx.evaluate!(nameExpr);
|
|
66
|
-
passageName = String(result);
|
|
67
|
-
} catch {
|
|
68
|
-
passageName = nameExpr.replace(/^["']|["']$/g, '');
|
|
69
|
-
}
|
|
21
|
+
const { inline } = ctx.args;
|
|
22
|
+
const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate!);
|
|
70
23
|
|
|
71
24
|
const passage = storyData?.passages.get(passageName);
|
|
72
25
|
// Parse once per included passage: the renderer keys children by AST
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
1
2
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
2
3
|
import { errorMessage } from '../../utils/error-message';
|
|
3
4
|
|
|
@@ -17,3 +18,39 @@ export function MacroError({
|
|
|
17
18
|
</span>
|
|
18
19
|
);
|
|
19
20
|
}
|
|
21
|
+
|
|
22
|
+
/** Log an error in the macro `{label}` (its name, or name and arguments). */
|
|
23
|
+
export function logMacroError(label: string, error: unknown): void {
|
|
24
|
+
console.error(
|
|
25
|
+
`spindle: Error in {${label}}${currentSourceLocation()}:`,
|
|
26
|
+
error,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `effect` once, during the macro's first render. If it throws, the
|
|
32
|
+
* error is logged under `{macro rawArgs}` and shown in place of the macro
|
|
33
|
+
* from then on; otherwise the macro renders nothing.
|
|
34
|
+
*/
|
|
35
|
+
export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
|
|
36
|
+
// Unset until the effect has run. Boxed: anything can be thrown,
|
|
37
|
+
// including null and other falsy values.
|
|
38
|
+
const failure = useRef<{ error: unknown } | null>();
|
|
39
|
+
if (failure.current === undefined) {
|
|
40
|
+
failure.current = null;
|
|
41
|
+
try {
|
|
42
|
+
effect();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
failure.current = { error };
|
|
45
|
+
logMacroError(`${macro} ${rawArgs}`, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!failure.current) return null;
|
|
50
|
+
return (
|
|
51
|
+
<MacroError
|
|
52
|
+
macro={macro}
|
|
53
|
+
error={failure.current.error}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,36 +1,21 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
-
import { unescapeQuoted } from './arg-utils';
|
|
4
3
|
import { useDetachedBody } from './detached-body';
|
|
5
4
|
|
|
6
|
-
export function parseLinkArgs(rawArgs: string): {
|
|
7
|
-
display: string;
|
|
8
|
-
passage: string | null;
|
|
9
|
-
} {
|
|
10
|
-
// {link "text" "Passage"} or {link "text"}. A backslash escapes a quote
|
|
11
|
-
// or another backslash inside a quoted argument (#200).
|
|
12
|
-
const parts: string[] = [];
|
|
13
|
-
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
14
|
-
let m;
|
|
15
|
-
while ((m = re.exec(rawArgs)) !== null) {
|
|
16
|
-
parts.push(unescapeQuoted(m[2]!));
|
|
17
|
-
}
|
|
18
|
-
if (parts.length >= 2) {
|
|
19
|
-
return { display: parts[0]!, passage: parts[1]! };
|
|
20
|
-
}
|
|
21
|
-
if (parts.length === 1) {
|
|
22
|
-
return { display: parts[0]!, passage: null };
|
|
23
|
-
}
|
|
24
|
-
// Fallback: treat entire rawArgs as display text
|
|
25
|
-
return { display: rawArgs.trim(), passage: null };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
5
|
defineMacro({
|
|
29
6
|
name: 'link',
|
|
30
7
|
block: true,
|
|
31
8
|
interpolate: true,
|
|
9
|
+
// {link "text" "Passage"} or {link "text"}; unquoted, the whole arguments
|
|
10
|
+
// are the text.
|
|
11
|
+
parameters: [
|
|
12
|
+
{ name: 'text', type: 'string', required: true },
|
|
13
|
+
{ name: 'passage', type: 'string' },
|
|
14
|
+
],
|
|
32
15
|
render({ rawArgs, children = [] }, ctx) {
|
|
33
|
-
const {
|
|
16
|
+
const { text } = ctx.args;
|
|
17
|
+
const display = text ?? rawArgs.trim();
|
|
18
|
+
const passage = text === undefined ? null : (ctx.args.passage ?? null);
|
|
34
19
|
const runBody = useDetachedBody();
|
|
35
20
|
|
|
36
21
|
const perform = () => {
|
|
@@ -1,46 +1,21 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
|
-
import {
|
|
4
|
-
endsWithOperator,
|
|
5
|
-
isWhitespace,
|
|
6
|
-
readWholeQuoted,
|
|
7
|
-
splitTopLevel,
|
|
8
|
-
} from './arg-utils';
|
|
9
3
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export function parseMeterArgs(rawArgs: string): {
|
|
18
|
-
currentExpr: string;
|
|
19
|
-
maxExpr: string;
|
|
20
|
-
labelMode: string;
|
|
21
|
-
} {
|
|
22
|
-
const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
|
|
23
|
-
|
|
24
|
-
let labelMode = '';
|
|
25
|
-
const label =
|
|
26
|
-
tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(' '))
|
|
27
|
-
? readWholeQuoted(tokens[tokens.length - 1]!)
|
|
28
|
-
: null;
|
|
29
|
-
if (label !== null) {
|
|
30
|
-
labelMode = label;
|
|
31
|
-
tokens.pop();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (tokens.length < 2) {
|
|
4
|
+
/** Check the arguments of `{meter currentExpr maxExpr ["label"]}`. */
|
|
5
|
+
export function meterArgs(args: {
|
|
6
|
+
current?: string;
|
|
7
|
+
max?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
}): { currentExpr: string; maxExpr: string; labelMode: string } {
|
|
10
|
+
if (args.current === undefined || args.max === undefined) {
|
|
35
11
|
throw new Error(
|
|
36
12
|
'meter requires two arguments: {meter currentExpr maxExpr}',
|
|
37
13
|
);
|
|
38
14
|
}
|
|
39
|
-
|
|
40
15
|
return {
|
|
41
|
-
currentExpr:
|
|
42
|
-
maxExpr:
|
|
43
|
-
labelMode,
|
|
16
|
+
currentExpr: args.current,
|
|
17
|
+
maxExpr: args.max,
|
|
18
|
+
labelMode: args.label ?? '',
|
|
44
19
|
};
|
|
45
20
|
}
|
|
46
21
|
|
|
@@ -60,9 +35,16 @@ defineMacro({
|
|
|
60
35
|
name: 'meter',
|
|
61
36
|
interpolate: true,
|
|
62
37
|
merged: true,
|
|
63
|
-
|
|
38
|
+
// {meter currentExpr maxExpr ["label"]}. A string after an operator
|
|
39
|
+
// (`$a ?? "5"`) is an operand of the max expression, not the label.
|
|
40
|
+
parameters: [
|
|
41
|
+
{ name: 'current', type: 'expression', required: true },
|
|
42
|
+
{ name: 'max', type: 'expression', required: true },
|
|
43
|
+
{ name: 'label', type: 'string' },
|
|
44
|
+
],
|
|
45
|
+
render(_props, ctx) {
|
|
64
46
|
try {
|
|
65
|
-
const { currentExpr, maxExpr, labelMode } =
|
|
47
|
+
const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
|
|
66
48
|
const current = Number(ctx.evaluate!(currentExpr));
|
|
67
49
|
const max = Number(ctx.evaluate!(maxExpr));
|
|
68
50
|
const pct =
|