@rohal12/spindle 0.51.4 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
package/src/hooks/use-action.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
|
2
2
|
import {
|
|
3
3
|
registerAction,
|
|
4
4
|
generateActionId,
|
|
5
|
+
type ActionRegistration,
|
|
5
6
|
type ActionType,
|
|
6
7
|
type StoryAction,
|
|
7
8
|
} from '../action-registry';
|
|
@@ -31,7 +32,7 @@ export function useAction(opts: UseActionOptions): string {
|
|
|
31
32
|
|
|
32
33
|
const id = idRef.current;
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
const buildAction = (): StoryAction => {
|
|
35
36
|
const action: StoryAction = {
|
|
36
37
|
id,
|
|
37
38
|
type: opts.type,
|
|
@@ -43,10 +44,30 @@ export function useAction(opts: UseActionOptions): string {
|
|
|
43
44
|
if (opts.options !== undefined) action.options = opts.options;
|
|
44
45
|
if (opts.value !== undefined) action.value = opts.value;
|
|
45
46
|
if (opts.disabled !== undefined) action.disabled = opts.disabled;
|
|
47
|
+
return action;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Registered for the component's lifetime. Unregistering removes only this
|
|
51
|
+
// registration, never another control's holding the same ID (#233).
|
|
52
|
+
const registration = useRef<ActionRegistration | null>(null);
|
|
53
|
+
useLayoutEffect(() => {
|
|
54
|
+
const own = registerAction(buildAction());
|
|
55
|
+
registration.current = own;
|
|
56
|
+
return () => own.unregister();
|
|
57
|
+
}, [id]);
|
|
46
58
|
|
|
47
|
-
|
|
59
|
+
// When what the action exposes changes (e.g. an input's value), replace it
|
|
60
|
+
// in place: one `actionsChanged` notification, and it keeps its position in
|
|
61
|
+
// getActions(). Unregistering and registering again would notify twice and
|
|
62
|
+
// move it to the end.
|
|
63
|
+
const registered = useRef(false);
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
if (!registered.current) {
|
|
66
|
+
registered.current = true; // the registration above is current
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
registration.current?.update(buildAction());
|
|
48
70
|
}, [
|
|
49
|
-
id,
|
|
50
71
|
opts.type,
|
|
51
72
|
opts.label,
|
|
52
73
|
opts.target,
|
|
@@ -1,17 +1,48 @@
|
|
|
1
|
-
import { useCallback } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useContext, useMemo } from 'preact/hooks';
|
|
2
2
|
import { useMergedLocals } from './use-merged-locals';
|
|
3
|
-
import {
|
|
3
|
+
import { WidgetChildrenContext } from '../markup/render';
|
|
4
|
+
import {
|
|
5
|
+
hasInterpolation,
|
|
6
|
+
interpolateText,
|
|
7
|
+
type TextScope,
|
|
8
|
+
} from '../interpolation';
|
|
9
|
+
import { errorMessage } from '../utils/error-message';
|
|
4
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The scope text-only markup (attribute values, labels) is evaluated in:
|
|
13
|
+
* store variables, locals and the enclosing block widget's children. The
|
|
14
|
+
* caller re-renders whenever one of them changes.
|
|
15
|
+
*/
|
|
16
|
+
export function useTextScope(): TextScope {
|
|
17
|
+
const [variables, temporary, locals, transient] = useMergedLocals();
|
|
18
|
+
const widgetChildren = useContext(WidgetChildrenContext);
|
|
19
|
+
return useMemo(
|
|
20
|
+
() => ({ variables, temporary, locals, transient, widgetChildren }),
|
|
21
|
+
[variables, temporary, locals, transient, widgetChildren],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return a function resolving the markup in a string (a macro label, class
|
|
27
|
+
* or id) to text. Errors (a macro with no text form, a failing expression)
|
|
28
|
+
* are logged, and their part of the string is left empty.
|
|
29
|
+
*/
|
|
5
30
|
export function useInterpolate(): (
|
|
6
31
|
s: string | undefined,
|
|
7
32
|
) => string | undefined {
|
|
8
|
-
const
|
|
33
|
+
const scope = useTextScope();
|
|
9
34
|
|
|
10
35
|
return useCallback(
|
|
11
36
|
(s: string | undefined): string | undefined => {
|
|
12
37
|
if (s === undefined || !hasInterpolation(s)) return s;
|
|
13
|
-
|
|
38
|
+
const { text, errors } = interpolateText(s, scope);
|
|
39
|
+
for (const { macro, error } of errors) {
|
|
40
|
+
console.error(
|
|
41
|
+
`spindle: {${macro}} error in "${s}": ${errorMessage(error)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return text;
|
|
14
45
|
},
|
|
15
|
-
[
|
|
46
|
+
[scope],
|
|
16
47
|
);
|
|
17
48
|
}
|
package/src/index.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import { tokenize } from './markup/tokenizer';
|
|
|
21
21
|
import { buildAST, registerBlockMacro } from './markup/ast';
|
|
22
22
|
import { registerWidget } from './widgets/widget-registry';
|
|
23
23
|
import { astContainsChildren } from './widgets/ast-scanner';
|
|
24
|
+
import { errorMessage } from './utils/error-message';
|
|
24
25
|
import type { ASTNode } from './markup/ast';
|
|
25
26
|
import './macros/register-builtins';
|
|
26
27
|
import builtinCSS from './styles.css?inline';
|
|
@@ -182,7 +183,15 @@ export function boot() {
|
|
|
182
183
|
);
|
|
183
184
|
const children = node.children as ASTNode[];
|
|
184
185
|
const isBlock = astContainsChildren(children);
|
|
185
|
-
|
|
186
|
+
try {
|
|
187
|
+
registerWidget(widgetName, children, params, isBlock);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// As the {widget} macro refuses it: the others still register
|
|
190
|
+
console.error(
|
|
191
|
+
`spindle: widget "${widgetName}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
|
|
192
|
+
);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
186
195
|
if (isBlock) {
|
|
187
196
|
registerBlockMacro(widgetName);
|
|
188
197
|
}
|
|
@@ -192,7 +201,8 @@ export function boot() {
|
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
// Reset action ID counters on every navigation (the passage remounts even
|
|
195
|
-
// when its name is unchanged)
|
|
204
|
+
// when its name is unchanged). Controls that stay mounted, e.g. in
|
|
205
|
+
// StoryInterface, keep their IDs: allocation skips IDs still registered.
|
|
196
206
|
let prevNavigationId = useStoryStore.getState().navigationId;
|
|
197
207
|
useStoryStore.subscribe((state) => {
|
|
198
208
|
if (state.navigationId !== prevNavigationId) {
|
package/src/interpolation.ts
CHANGED
|
@@ -1,129 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-only markup: HTML attribute values, image alt text and link titles,
|
|
3
|
+
* and macro labels resolve the inline markup of passage text (variables,
|
|
4
|
+
* expressions, macros, widgets) to a string.
|
|
5
|
+
*
|
|
6
|
+
* Such a value is parsed with the passage tokenizer in text mode, so `{…}`
|
|
7
|
+
* means the same as in passage text, and its AST is evaluated directly to a
|
|
8
|
+
* string rather than rendered: the result is ready during the render that
|
|
9
|
+
* needs it (so it goes through the usual attribute handling, boolean
|
|
10
|
+
* attributes and live properties included), costs no DOM work, and is
|
|
11
|
+
* recomputed whenever the variables or locals the caller subscribes to
|
|
12
|
+
* change. Macros take part through their text form (MacroDefinition.text);
|
|
13
|
+
* one without a text form (inputs, links, {set}, …) has nothing to put in a
|
|
14
|
+
* string, so it is reported instead of being run or shown as source.
|
|
15
|
+
*/
|
|
1
16
|
import { evaluate } from './expression';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
17
|
+
import { tokenize } from './markup/tokenizer';
|
|
18
|
+
import { buildAST } from './markup/ast';
|
|
19
|
+
import type { ASTNode, MacroNode, TextNode } from './markup/ast';
|
|
20
|
+
import { getMacro, getMacroText, isSubMacro } from './registry';
|
|
21
|
+
import type { MacroTextContext } from './registry';
|
|
22
|
+
import { getWidget } from './widgets/widget-registry';
|
|
23
|
+
import { splitArgs } from './components/macros/arg-utils';
|
|
24
|
+
import { splitSigilTemplate } from './markup/code-attributes';
|
|
25
|
+
import {
|
|
26
|
+
checkVariableName,
|
|
27
|
+
createNamespace,
|
|
28
|
+
ownValue,
|
|
29
|
+
type Namespace,
|
|
30
|
+
} from './utils/namespace';
|
|
6
31
|
|
|
32
|
+
/** Whether a string may contain markup (anything but plain text). */
|
|
7
33
|
export function hasInterpolation(s: string): boolean {
|
|
8
|
-
return
|
|
34
|
+
return s.includes('{') || s.includes('}');
|
|
9
35
|
}
|
|
10
36
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
37
|
+
/** The scope text-only markup is evaluated in. */
|
|
38
|
+
export interface TextScope {
|
|
39
|
+
variables: Record<string, unknown>;
|
|
40
|
+
temporary: Record<string, unknown>;
|
|
41
|
+
locals: Record<string, unknown>;
|
|
42
|
+
transient: Record<string, unknown>;
|
|
43
|
+
/** Invocation children of the enclosing block widget, for `{@children}`. */
|
|
44
|
+
widgetChildren?: ASTNode[] | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A macro, expression or markup error met while evaluating text. */
|
|
48
|
+
export interface TextError {
|
|
49
|
+
/** The macro name, or `expression` / `markup`. */
|
|
50
|
+
macro: string;
|
|
51
|
+
error: unknown;
|
|
19
52
|
}
|
|
20
53
|
|
|
54
|
+
export interface TextResult {
|
|
55
|
+
text: string;
|
|
56
|
+
errors: TextError[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ParsedText = { nodes: ASTNode[] } | { error: unknown };
|
|
60
|
+
|
|
61
|
+
const parseCache = new Map<string, ParsedText>();
|
|
62
|
+
const PARSE_CACHE_LIMIT = 2000;
|
|
63
|
+
|
|
21
64
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
65
|
+
* Parse text-only markup into AST nodes, or the error the AST builder
|
|
66
|
+
* reports for it (an unclosed or mismatched macro). Results are cached.
|
|
24
67
|
*/
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
export function parseText(template: string): ParsedText {
|
|
69
|
+
let parsed = parseCache.get(template);
|
|
70
|
+
if (parsed === undefined) {
|
|
71
|
+
try {
|
|
72
|
+
parsed = { nodes: buildAST(tokenize(template, { text: true })) };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
parsed = { error };
|
|
75
|
+
}
|
|
76
|
+
if (parseCache.size >= PARSE_CACHE_LIMIT) parseCache.clear();
|
|
77
|
+
parseCache.set(template, parsed);
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Map the text nodes of an AST (macro bodies and branches included). */
|
|
83
|
+
export function mapTextNodes(
|
|
84
|
+
nodes: ASTNode[],
|
|
85
|
+
fn: (value: string) => string,
|
|
86
|
+
): ASTNode[] {
|
|
87
|
+
return nodes.map((node): ASTNode => {
|
|
88
|
+
switch (node.type) {
|
|
89
|
+
case 'text':
|
|
90
|
+
return { type: 'text', value: fn(node.value) } satisfies TextNode;
|
|
91
|
+
case 'html':
|
|
92
|
+
return { ...node, children: mapTextNodes(node.children, fn) };
|
|
93
|
+
case 'macro': {
|
|
94
|
+
const copy: MacroNode = {
|
|
95
|
+
...node,
|
|
96
|
+
children: mapTextNodes(node.children, fn),
|
|
97
|
+
};
|
|
98
|
+
if (node.branches) {
|
|
99
|
+
copy.branches = node.branches.map((b) => ({
|
|
100
|
+
...b,
|
|
101
|
+
children: mapTextNodes(b.children, fn),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
return copy;
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
return node;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Deep enough for any real nesting; stops a widget that includes itself. */
|
|
113
|
+
const MAX_DEPTH = 100;
|
|
114
|
+
|
|
115
|
+
function display(value: unknown): string {
|
|
33
116
|
return value == null ? '' : String(value);
|
|
34
117
|
}
|
|
35
118
|
|
|
36
|
-
|
|
37
|
-
ref: string,
|
|
38
|
-
variables: Record<string, unknown>,
|
|
39
|
-
temporary: Record<string, unknown>,
|
|
40
|
-
locals: Record<string, unknown>,
|
|
41
|
-
transient: Record<string, unknown>,
|
|
42
|
-
): string {
|
|
43
|
-
const prefix = ref[0]!;
|
|
44
|
-
const path = ref.slice(1);
|
|
45
|
-
const parts = path.split('.');
|
|
46
|
-
const root = parts[0]!;
|
|
119
|
+
type VariableKind = 'variable' | 'temporary' | 'local' | 'transient';
|
|
47
120
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
121
|
+
const KIND_SIGILS = {
|
|
122
|
+
variable: '$',
|
|
123
|
+
temporary: '_',
|
|
124
|
+
local: '@',
|
|
125
|
+
transient: '%',
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The value of a variable reference (`name` without its sigil, with an
|
|
130
|
+
* optional dot path), as `{$name}` shows it in passage text (see
|
|
131
|
+
* VarDisplay): the variable is read from its namespace's own entries only,
|
|
132
|
+
* so an unset `$toString` is undefined, and one named `__proto__` throws.
|
|
133
|
+
*/
|
|
134
|
+
function resolveVariable(
|
|
135
|
+
scope: TextScope,
|
|
136
|
+
kind: VariableKind,
|
|
137
|
+
name: string,
|
|
138
|
+
): unknown {
|
|
139
|
+
const parts = name.split('.');
|
|
140
|
+
const root = parts[0]!;
|
|
141
|
+
checkVariableName(root, KIND_SIGILS[kind] + root);
|
|
142
|
+
const store =
|
|
143
|
+
kind === 'variable'
|
|
144
|
+
? scope.variables
|
|
145
|
+
: kind === 'temporary'
|
|
146
|
+
? scope.temporary
|
|
147
|
+
: kind === 'transient'
|
|
148
|
+
? scope.transient
|
|
149
|
+
: scope.locals;
|
|
150
|
+
// Own entries only, as the expression engine reads them
|
|
151
|
+
let value = ownValue(store, root);
|
|
152
|
+
// Primitives box on access, so `{$name.length}` works too.
|
|
153
|
+
for (let i = 1; i < parts.length; i++) {
|
|
154
|
+
if (value == null) return undefined;
|
|
155
|
+
value = (value as Record<string, unknown>)[parts[i]!];
|
|
57
156
|
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
58
159
|
|
|
59
|
-
|
|
60
|
-
|
|
160
|
+
class TextEvaluator {
|
|
161
|
+
readonly errors: TextError[] = [];
|
|
162
|
+
|
|
163
|
+
nodes(nodes: ASTNode[], scope: TextScope, depth: number): string {
|
|
164
|
+
let text = '';
|
|
165
|
+
for (const node of nodes) text += this.node(node, scope, depth);
|
|
166
|
+
return text;
|
|
61
167
|
}
|
|
62
168
|
|
|
63
|
-
|
|
64
|
-
|
|
169
|
+
private evaluate(expr: string, scope: TextScope): unknown {
|
|
170
|
+
return evaluate(
|
|
171
|
+
expr,
|
|
172
|
+
scope.variables,
|
|
173
|
+
scope.temporary,
|
|
174
|
+
scope.locals,
|
|
175
|
+
scope.transient,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
65
178
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
179
|
+
private node(node: ASTNode, scope: TextScope, depth: number): string {
|
|
180
|
+
switch (node.type) {
|
|
181
|
+
case 'text':
|
|
182
|
+
return node.value;
|
|
183
|
+
case 'variable':
|
|
184
|
+
if (node.scope === 'local' && node.name === 'children') {
|
|
185
|
+
// As {@children} in a widget body: the invocation's children, in
|
|
186
|
+
// the scope of the body.
|
|
187
|
+
const children = scope.widgetChildren;
|
|
188
|
+
if (!children) return '';
|
|
189
|
+
return this.nested('children', children, scope, depth, {
|
|
190
|
+
widgetChildren: null,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return this.variable(node.scope, node.name, scope);
|
|
194
|
+
case 'expression':
|
|
195
|
+
try {
|
|
196
|
+
return display(this.evaluate(node.expression, scope));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.errors.push({ macro: 'expression', error });
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
case 'html':
|
|
202
|
+
return this.nodes(node.children, scope, depth);
|
|
203
|
+
case 'macro':
|
|
204
|
+
return this.macro(node, scope, depth);
|
|
205
|
+
default: {
|
|
206
|
+
const _exhaustive: never = node;
|
|
207
|
+
return _exhaustive;
|
|
86
208
|
}
|
|
87
|
-
result += template.slice(i, nextBrace);
|
|
88
|
-
i = nextBrace;
|
|
89
|
-
continue;
|
|
90
209
|
}
|
|
210
|
+
}
|
|
91
211
|
|
|
92
|
-
|
|
93
|
-
|
|
212
|
+
/** A variable reference, or '' and an error if it can't be one. */
|
|
213
|
+
private variable(kind: VariableKind, name: string, scope: TextScope): string {
|
|
214
|
+
try {
|
|
215
|
+
return display(resolveVariable(scope, kind, name));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Reported as passage text reports it: `{$__proto__ error: …}`
|
|
218
|
+
this.errors.push({ macro: KIND_SIGILS[kind] + name, error });
|
|
219
|
+
return '';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
94
222
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
223
|
+
private nested(
|
|
224
|
+
macro: string,
|
|
225
|
+
nodes: ASTNode[],
|
|
226
|
+
scope: TextScope,
|
|
227
|
+
depth: number,
|
|
228
|
+
changes: Partial<TextScope> = {},
|
|
229
|
+
): string {
|
|
230
|
+
if (depth >= MAX_DEPTH) {
|
|
231
|
+
this.errors.push({
|
|
232
|
+
macro,
|
|
233
|
+
error: new Error('markup nested too deeply (a widget using itself?)'),
|
|
234
|
+
});
|
|
235
|
+
return '';
|
|
100
236
|
}
|
|
237
|
+
return this.nodes(nodes, { ...scope, ...changes }, depth + 1);
|
|
238
|
+
}
|
|
101
239
|
|
|
102
|
-
|
|
103
|
-
|
|
240
|
+
private macro(node: MacroNode, scope: TextScope, depth: number): string {
|
|
241
|
+
// Rendering ignores a stray sub-macro too (see renderMacro).
|
|
242
|
+
if (isSubMacro(node.name)) return '';
|
|
104
243
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
244
|
+
const widget = getWidget(node.name);
|
|
245
|
+
if (widget) {
|
|
246
|
+
let locals = scope.locals;
|
|
247
|
+
// Parameterized widgets get their own scope (see WidgetInvocation):
|
|
248
|
+
// a missing or failing argument is undefined.
|
|
249
|
+
if (widget.params.length > 0) {
|
|
250
|
+
const argExprs = node.rawArgs ? splitArgs(node.rawArgs) : [];
|
|
251
|
+
locals = createNamespace(scope.locals);
|
|
252
|
+
widget.params.forEach((param, i) => {
|
|
253
|
+
let value: unknown;
|
|
254
|
+
const expr = argExprs[i];
|
|
255
|
+
if (expr !== undefined) {
|
|
256
|
+
try {
|
|
257
|
+
value = this.evaluate(expr, scope);
|
|
258
|
+
} catch {
|
|
259
|
+
value = undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
locals[param.startsWith('@') ? param.slice(1) : param] = value;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return this.nested(node.name, widget.body, scope, depth, {
|
|
266
|
+
locals,
|
|
267
|
+
widgetChildren: node.children.length > 0 ? node.children : null,
|
|
268
|
+
});
|
|
109
269
|
}
|
|
110
270
|
|
|
111
|
-
const
|
|
112
|
-
|
|
271
|
+
const text = getMacroText(node.name);
|
|
272
|
+
if (!text) {
|
|
273
|
+
const message = getMacro(node.name)
|
|
274
|
+
? `{${node.name}} has no text form, so it can't be used in an attribute value or label`
|
|
275
|
+
: `unknown macro {${node.name}}`;
|
|
276
|
+
this.errors.push({ macro: node.name, error: new Error(message) });
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
113
279
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
280
|
+
const ctx: MacroTextContext = {
|
|
281
|
+
evaluate: (expr) => this.evaluate(expr, scope),
|
|
282
|
+
renderText: (nodes, locals) =>
|
|
283
|
+
this.nested(
|
|
284
|
+
node.name,
|
|
285
|
+
nodes,
|
|
286
|
+
scope,
|
|
287
|
+
depth,
|
|
288
|
+
locals ? { locals: withLocals(scope.locals, locals) } : {},
|
|
289
|
+
),
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
return text(
|
|
293
|
+
{
|
|
294
|
+
rawArgs: node.rawArgs,
|
|
295
|
+
className: node.className,
|
|
296
|
+
id: node.id,
|
|
297
|
+
children: node.children,
|
|
298
|
+
branches: node.branches,
|
|
299
|
+
},
|
|
300
|
+
ctx,
|
|
124
301
|
);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
this.errors.push({ macro: node.name, error });
|
|
304
|
+
return '';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The locals `ns` with `added` (keys without `@`) on top, as a namespace
|
|
311
|
+
* (see utils/namespace.ts). A name no namespace can hold throws.
|
|
312
|
+
*/
|
|
313
|
+
function withLocals(ns: Namespace, added: Record<string, unknown>): Namespace {
|
|
314
|
+
for (const key of Object.keys(added)) checkVariableName(key, `@${key}`);
|
|
315
|
+
return createNamespace(ns, added);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Evaluate parsed text-only markup to a string. */
|
|
319
|
+
export function renderText(nodes: ASTNode[], scope: TextScope): TextResult {
|
|
320
|
+
const evaluator = new TextEvaluator();
|
|
321
|
+
const text = evaluator.nodes(nodes, scope, 0);
|
|
322
|
+
return { text, errors: evaluator.errors };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Evaluate text-only markup. Markup that doesn't parse (an unclosed
|
|
327
|
+
* `{if}`) is reported and kept as written.
|
|
328
|
+
*/
|
|
329
|
+
export function interpolateText(
|
|
330
|
+
template: string,
|
|
331
|
+
scope: TextScope,
|
|
332
|
+
): TextResult {
|
|
333
|
+
if (!hasInterpolation(template)) return { text: template, errors: [] };
|
|
334
|
+
const parsed = parseText(template);
|
|
335
|
+
if ('error' in parsed) {
|
|
336
|
+
return {
|
|
337
|
+
text: template,
|
|
338
|
+
errors: [{ macro: 'markup', error: parsed.error }],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return renderText(parsed.nodes, scope);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const SIGIL_KINDS = {
|
|
345
|
+
$: 'variable',
|
|
346
|
+
_: 'temporary',
|
|
347
|
+
'@': 'local',
|
|
348
|
+
'%': 'transient',
|
|
349
|
+
} as const;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolve the sigil references in the value of a code attribute (see
|
|
353
|
+
* isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
|
|
354
|
+
* braces included, is literal text, passed through `decode` when given.
|
|
355
|
+
* A reference whose expression fails is reported and left empty.
|
|
356
|
+
*/
|
|
357
|
+
export function interpolateCode(
|
|
358
|
+
template: string,
|
|
359
|
+
scope: TextScope,
|
|
360
|
+
decode?: (text: string) => string,
|
|
361
|
+
): TextResult {
|
|
362
|
+
const errors: TextError[] = [];
|
|
363
|
+
let text = '';
|
|
364
|
+
for (const part of splitSigilTemplate(template)) {
|
|
365
|
+
if ('text' in part) {
|
|
366
|
+
text += decode ? decode(part.text) : part.text;
|
|
367
|
+
} else if ('verbatim' in part) {
|
|
368
|
+
text += part.verbatim;
|
|
369
|
+
} else if (/^[$_@%][\w.]+$/.test(part.expr)) {
|
|
370
|
+
const kind = SIGIL_KINDS[part.expr[0] as keyof typeof SIGIL_KINDS];
|
|
371
|
+
try {
|
|
372
|
+
text += display(resolveVariable(scope, kind, part.expr.slice(1)));
|
|
373
|
+
} catch (error) {
|
|
374
|
+
errors.push({ macro: part.expr, error });
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
try {
|
|
378
|
+
text += display(
|
|
379
|
+
evaluate(
|
|
380
|
+
part.expr,
|
|
381
|
+
scope.variables,
|
|
382
|
+
scope.temporary,
|
|
383
|
+
scope.locals,
|
|
384
|
+
scope.transient,
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
errors.push({ macro: 'expression', error });
|
|
389
|
+
}
|
|
125
390
|
}
|
|
126
391
|
}
|
|
392
|
+
return { text, errors };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Evaluate text-only markup to a string, throwing the first error met.
|
|
397
|
+
*/
|
|
398
|
+
export function interpolate(
|
|
399
|
+
template: string,
|
|
400
|
+
variables: Record<string, unknown>,
|
|
401
|
+
temporary: Record<string, unknown>,
|
|
402
|
+
locals: Record<string, unknown>,
|
|
403
|
+
transient: Record<string, unknown> = {},
|
|
404
|
+
): string {
|
|
405
|
+
const { text, errors } = interpolateText(template, {
|
|
406
|
+
variables,
|
|
407
|
+
temporary,
|
|
408
|
+
locals,
|
|
409
|
+
transient,
|
|
410
|
+
});
|
|
411
|
+
if (errors.length > 0) throw errors[0]!.error;
|
|
412
|
+
return text;
|
|
413
|
+
}
|
|
127
414
|
|
|
128
|
-
|
|
415
|
+
/**
|
|
416
|
+
* Evaluate an expression string and return its stringified result.
|
|
417
|
+
* Uses the full expression evaluator from expression.ts.
|
|
418
|
+
*/
|
|
419
|
+
export function interpolateExpression(
|
|
420
|
+
expr: string,
|
|
421
|
+
variables: Record<string, unknown>,
|
|
422
|
+
temporary: Record<string, unknown>,
|
|
423
|
+
locals: Record<string, unknown>,
|
|
424
|
+
transient: Record<string, unknown> = {},
|
|
425
|
+
): string {
|
|
426
|
+
return display(evaluate(expr, variables, temporary, locals, transient));
|
|
129
427
|
}
|