@rohal12/spindle 0.51.3 → 0.52.0
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 +4833 -1603
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1658 -189
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +277 -90
- 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 +9 -32
- package/src/components/macros/Checkbox.tsx +10 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +33 -4
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +15 -24
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +14 -46
- package/src/components/macros/Meter.tsx +26 -40
- 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 +27 -2
- package/src/components/macros/SaveManager.tsx +39 -14
- 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 +30 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +20 -158
- package/src/components/macros/arg-utils.ts +226 -0
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +10 -5
- package/src/define-macro.ts +44 -38
- package/src/execute-mutation.ts +499 -28
- package/src/expression.ts +88 -272
- package/src/hooks/use-action.ts +18 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +10 -1
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1460 -0
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +552 -113
- package/src/markup/tokenizer.ts +601 -119
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +368 -153
- package/src/saves/storage.ts +24 -7
- package/src/saves/types.ts +20 -6
- package/src/store.ts +549 -137
- package/src/story-api.ts +46 -81
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -2
- 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 +194 -0
- package/src/utils/stable-key.ts +82 -0
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
package/src/expression.ts
CHANGED
|
@@ -2,6 +2,14 @@ import type { StoryState } from './store';
|
|
|
2
2
|
import { useStoryStore } from './store';
|
|
3
3
|
import type { Passage } from './parser';
|
|
4
4
|
import { random, randomInt } from './prng';
|
|
5
|
+
import { lexJs, type JsGoal, type Sigil } from './js-lexer';
|
|
6
|
+
import {
|
|
7
|
+
EMPTY_NAMESPACE,
|
|
8
|
+
RESERVED_NAME,
|
|
9
|
+
asNamespace,
|
|
10
|
+
isNamespace,
|
|
11
|
+
type Namespace,
|
|
12
|
+
} from './utils/namespace';
|
|
5
13
|
|
|
6
14
|
interface ExpressionFns {
|
|
7
15
|
currentPassage: () => Passage | undefined;
|
|
@@ -29,272 +37,53 @@ type CompiledExpression = (
|
|
|
29
37
|
const FN_CACHE_MAX = 500;
|
|
30
38
|
const fnCache = new Map<string, CompiledExpression>();
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* also the modulo operator and only an operand position makes it a sigil.
|
|
39
|
-
*/
|
|
40
|
-
const VAR_RE = /\$(\w+)/g;
|
|
41
|
-
const TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
42
|
-
const LOCAL_RE = /@(\w+)/g;
|
|
43
|
-
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
44
|
-
const TRANS_NAME_RE = /[A-Za-z_]\w*/y;
|
|
45
|
-
/** Characters of identifiers, numbers and sigil variable references. */
|
|
46
|
-
const WORD_CHAR_RE = /[\w$@]/;
|
|
47
|
-
const SPACE_RE = /\s/;
|
|
48
|
-
/** Keywords followed by an operand rather than an operator. */
|
|
49
|
-
const OPERAND_KEYWORDS = new Set([
|
|
50
|
-
'await',
|
|
51
|
-
'case',
|
|
52
|
-
'delete',
|
|
53
|
-
'do',
|
|
54
|
-
'else',
|
|
55
|
-
'in',
|
|
56
|
-
'instanceof',
|
|
57
|
-
'new',
|
|
58
|
-
'of',
|
|
59
|
-
'return',
|
|
60
|
-
'throw',
|
|
61
|
-
'typeof',
|
|
62
|
-
'void',
|
|
63
|
-
'yield',
|
|
64
|
-
]);
|
|
65
|
-
/** Keywords whose parenthesised header is followed by a statement. */
|
|
66
|
-
const HEADER_KEYWORDS = new Set(['for', 'if', 'while', 'with']);
|
|
40
|
+
const NAMESPACES: Record<Sigil, string> = {
|
|
41
|
+
$: 'variables',
|
|
42
|
+
_: 'temporary',
|
|
43
|
+
'@': 'locals',
|
|
44
|
+
'%': 'transient',
|
|
45
|
+
};
|
|
67
46
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.replace(VAR_RE, 'variables["$1"]')
|
|
71
|
-
.replace(TEMP_RE, 'temporary["$1"]')
|
|
72
|
-
.replace(LOCAL_RE, 'locals["$1"]');
|
|
73
|
-
}
|
|
47
|
+
/** Ends with an identifier character. */
|
|
48
|
+
const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
|
|
74
49
|
|
|
75
50
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
51
|
+
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
52
|
+
* @var → locals["var"], %var → transient["var"].
|
|
53
|
+
* The lexer (`lexJs`) finds the references: only in code (string, template
|
|
54
|
+
* and regex literal text and comments are left untouched), only where an
|
|
55
|
+
* identifier starts (not in `a$b`, nor as the property name in `obj._x`),
|
|
56
|
+
* and `%var` only where an operand is expected, since `%` is also the
|
|
57
|
+
* modulo operator. `goal` tells whether `expr` is an expression or a list of
|
|
58
|
+
* statements, as that decides whether a leading `{` opens an object literal
|
|
59
|
+
* or a block. A reference to a variable named `__proto__` throws a
|
|
60
|
+
* SyntaxError: no namespace can hold one (see utils/namespace.ts).
|
|
61
|
+
* Exported for tests.
|
|
84
62
|
*/
|
|
85
|
-
function transform(expr: string): string {
|
|
63
|
+
export function transform(expr: string, goal: JsGoal = 'expression'): string {
|
|
86
64
|
let result = '';
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function endWord() {
|
|
105
|
-
if (!word) return;
|
|
106
|
-
operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
|
|
107
|
-
afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
|
|
108
|
-
afterDot = false;
|
|
109
|
-
lastPunct = '';
|
|
110
|
-
word = '';
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** A string or template literal, or a transient reference, just ended. */
|
|
114
|
-
function endOperand() {
|
|
115
|
-
endWord();
|
|
116
|
-
operandNext = false;
|
|
117
|
-
afterHeaderKeyword = false;
|
|
118
|
-
afterDot = false;
|
|
119
|
-
lastPunct = '';
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function trackCode(c: string) {
|
|
123
|
-
if (WORD_CHAR_RE.test(c)) {
|
|
124
|
-
word += c;
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
endWord();
|
|
128
|
-
// A line may start a new statement such as `%x = 1`.
|
|
129
|
-
if (c === '\n') operandNext = true;
|
|
130
|
-
if (SPACE_RE.test(c)) return;
|
|
131
|
-
if (c === '(') parenIsHeader.push(afterHeaderKeyword);
|
|
132
|
-
if (c === ')') {
|
|
133
|
-
// `if (…) %x = 1` vs `($n)%3`
|
|
134
|
-
operandNext = parenIsHeader.pop() ?? false;
|
|
135
|
-
} else if (c === '.') {
|
|
136
|
-
// Property access, unless it is the spread `...`
|
|
137
|
-
operandNext = lastPunct === '.';
|
|
138
|
-
} else {
|
|
139
|
-
// `]` ends an operand; `}` closes a block, so a statement may follow.
|
|
140
|
-
operandNext = c !== ']';
|
|
141
|
-
}
|
|
142
|
-
afterDot = c === '.';
|
|
143
|
-
afterHeaderKeyword = false;
|
|
144
|
-
lastPunct = c;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
while (i < expr.length) {
|
|
148
|
-
const ch = expr.charAt(i);
|
|
149
|
-
|
|
150
|
-
// Single or double quoted string — skip entirely
|
|
151
|
-
if (ch === '"' || ch === "'") {
|
|
152
|
-
flushCode();
|
|
153
|
-
const quote = ch;
|
|
154
|
-
let str = quote;
|
|
155
|
-
i++;
|
|
156
|
-
while (i < expr.length) {
|
|
157
|
-
const c = expr.charAt(i);
|
|
158
|
-
if (c === '\\' && i + 1 < expr.length) {
|
|
159
|
-
str += c + expr.charAt(i + 1);
|
|
160
|
-
i += 2;
|
|
161
|
-
} else if (c === quote) {
|
|
162
|
-
str += quote;
|
|
163
|
-
i++;
|
|
164
|
-
break;
|
|
165
|
-
} else {
|
|
166
|
-
str += c;
|
|
167
|
-
i++;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
result += str;
|
|
171
|
-
endOperand();
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Template literal — preserve literal parts, transform interpolations
|
|
176
|
-
if (ch === '`') {
|
|
177
|
-
flushCode();
|
|
178
|
-
result += '`';
|
|
179
|
-
i++;
|
|
180
|
-
while (i < expr.length) {
|
|
181
|
-
const c = expr.charAt(i);
|
|
182
|
-
if (c === '\\' && i + 1 < expr.length) {
|
|
183
|
-
result += c + expr.charAt(i + 1);
|
|
184
|
-
i += 2;
|
|
185
|
-
} else if (c === '$' && expr.charAt(i + 1) === '{') {
|
|
186
|
-
// Template interpolation — collect the inner expression and
|
|
187
|
-
// recursively transform it
|
|
188
|
-
result += '${';
|
|
189
|
-
i += 2;
|
|
190
|
-
let depth = 1;
|
|
191
|
-
let inner = '';
|
|
192
|
-
while (i < expr.length && depth > 0) {
|
|
193
|
-
const ic = expr.charAt(i);
|
|
194
|
-
if (ic === '{') {
|
|
195
|
-
depth++;
|
|
196
|
-
inner += ic;
|
|
197
|
-
} else if (ic === '}') {
|
|
198
|
-
depth--;
|
|
199
|
-
if (depth === 0) break;
|
|
200
|
-
inner += ic;
|
|
201
|
-
} else if (ic === '\\' && i + 1 < expr.length) {
|
|
202
|
-
inner += ic + expr.charAt(i + 1);
|
|
203
|
-
i++;
|
|
204
|
-
} else if (ic === '"' || ic === "'") {
|
|
205
|
-
// Skip quoted strings — braces inside are not depth-relevant
|
|
206
|
-
inner += ic;
|
|
207
|
-
i++;
|
|
208
|
-
while (i < expr.length) {
|
|
209
|
-
const sc = expr.charAt(i);
|
|
210
|
-
if (sc === '\\' && i + 1 < expr.length) {
|
|
211
|
-
inner += sc + expr.charAt(i + 1);
|
|
212
|
-
i += 2;
|
|
213
|
-
} else if (sc === ic) {
|
|
214
|
-
inner += sc;
|
|
215
|
-
i++;
|
|
216
|
-
break;
|
|
217
|
-
} else {
|
|
218
|
-
inner += sc;
|
|
219
|
-
i++;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
continue; // skip the i++ at the end
|
|
223
|
-
} else if (ic === '`') {
|
|
224
|
-
// Nested template literal — consume entirely
|
|
225
|
-
inner += ic;
|
|
226
|
-
i++;
|
|
227
|
-
while (i < expr.length) {
|
|
228
|
-
const tc = expr.charAt(i);
|
|
229
|
-
if (tc === '\\' && i + 1 < expr.length) {
|
|
230
|
-
inner += tc + expr.charAt(i + 1);
|
|
231
|
-
i += 2;
|
|
232
|
-
} else if (tc === '$' && expr.charAt(i + 1) === '{') {
|
|
233
|
-
// Nested interpolation — track brace depth
|
|
234
|
-
inner += '${';
|
|
235
|
-
i += 2;
|
|
236
|
-
let nestedDepth = 1;
|
|
237
|
-
while (i < expr.length && nestedDepth > 0) {
|
|
238
|
-
const nc = expr.charAt(i);
|
|
239
|
-
if (nc === '{') nestedDepth++;
|
|
240
|
-
else if (nc === '}') nestedDepth--;
|
|
241
|
-
if (nestedDepth > 0) inner += nc;
|
|
242
|
-
i++;
|
|
243
|
-
}
|
|
244
|
-
inner += '}';
|
|
245
|
-
} else if (tc === '`') {
|
|
246
|
-
inner += tc;
|
|
247
|
-
i++;
|
|
248
|
-
break;
|
|
249
|
-
} else {
|
|
250
|
-
inner += tc;
|
|
251
|
-
i++;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
continue; // skip the i++ at the end
|
|
255
|
-
} else {
|
|
256
|
-
inner += ic;
|
|
257
|
-
}
|
|
258
|
-
i++;
|
|
259
|
-
}
|
|
260
|
-
result += transform(inner); // recursive transform
|
|
261
|
-
if (i < expr.length && expr.charAt(i) === '}') {
|
|
262
|
-
result += '}';
|
|
263
|
-
i++;
|
|
264
|
-
}
|
|
265
|
-
} else if (c === '`') {
|
|
266
|
-
result += '`';
|
|
267
|
-
i++;
|
|
268
|
-
break;
|
|
269
|
-
} else {
|
|
270
|
-
result += c;
|
|
271
|
-
i++;
|
|
65
|
+
lexJs(
|
|
66
|
+
expr,
|
|
67
|
+
{
|
|
68
|
+
code(ch) {
|
|
69
|
+
result += ch;
|
|
70
|
+
},
|
|
71
|
+
literal(text) {
|
|
72
|
+
result += text;
|
|
73
|
+
},
|
|
74
|
+
variable(sigil, name) {
|
|
75
|
+
if (name === RESERVED_NAME) {
|
|
76
|
+
throw new SyntaxError(
|
|
77
|
+
`spindle: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
|
|
78
|
+
);
|
|
272
79
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
endWord();
|
|
281
|
-
TRANS_NAME_RE.lastIndex = i + 1;
|
|
282
|
-
const name = operandNext ? TRANS_NAME_RE.exec(expr)?.[0] : undefined;
|
|
283
|
-
if (name) {
|
|
284
|
-
flushCode();
|
|
285
|
-
result += `transient["${name}"]`;
|
|
286
|
-
i += 1 + name.length;
|
|
287
|
-
endOperand();
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Regular code character
|
|
293
|
-
trackCode(ch);
|
|
294
|
-
code += ch;
|
|
295
|
-
i++;
|
|
296
|
-
}
|
|
297
|
-
flushCode();
|
|
80
|
+
// `typeof%x` needs a space once `%x` turns into an identifier.
|
|
81
|
+
if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
|
|
82
|
+
result += `${NAMESPACES[sigil]}["${name}"]`;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
goal,
|
|
86
|
+
);
|
|
298
87
|
return result;
|
|
299
88
|
}
|
|
300
89
|
|
|
@@ -389,21 +178,42 @@ export function buildExpressionFns() {
|
|
|
389
178
|
return cachedFns;
|
|
390
179
|
}
|
|
391
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The namespaces compiled code reads and writes are records without a
|
|
183
|
+
* prototype (story state keeps them that way, see utils/namespace.ts), so
|
|
184
|
+
* `$toString` is the variable, not the method. One that has a prototype
|
|
185
|
+
* loses it, in place so that writes still reach it; one that cannot change
|
|
186
|
+
* is passed as a copy (writes to it are lost either way).
|
|
187
|
+
*/
|
|
188
|
+
function namespaceArg(ns: Namespace): Namespace {
|
|
189
|
+
if (isNamespace(ns)) return ns;
|
|
190
|
+
if (!Object.isExtensible(ns)) return asNamespace(ns);
|
|
191
|
+
Object.setPrototypeOf(ns, null);
|
|
192
|
+
return ns;
|
|
193
|
+
}
|
|
194
|
+
|
|
392
195
|
/**
|
|
393
196
|
* Evaluate an expression and return its value.
|
|
394
197
|
* e.g. evaluate("$health + 10", variables, temporary) → number
|
|
395
198
|
*/
|
|
396
199
|
export function evaluate(
|
|
397
200
|
expr: string,
|
|
398
|
-
variables:
|
|
399
|
-
temporary:
|
|
400
|
-
locals:
|
|
401
|
-
transient:
|
|
201
|
+
variables: Namespace,
|
|
202
|
+
temporary: Namespace,
|
|
203
|
+
locals: Namespace = EMPTY_NAMESPACE,
|
|
204
|
+
transient: Namespace = EMPTY_NAMESPACE,
|
|
402
205
|
): unknown {
|
|
403
206
|
const transformed = transform(expr);
|
|
404
|
-
|
|
207
|
+
// The line break keeps a trailing `// comment` from swallowing the `)`.
|
|
208
|
+
const body = `return (${transformed}\n);`;
|
|
405
209
|
const fn = getOrCompile(body, body);
|
|
406
|
-
return fn(
|
|
210
|
+
return fn(
|
|
211
|
+
namespaceArg(variables),
|
|
212
|
+
namespaceArg(temporary),
|
|
213
|
+
namespaceArg(locals),
|
|
214
|
+
buildExpressionFns(),
|
|
215
|
+
namespaceArg(transient),
|
|
216
|
+
);
|
|
407
217
|
}
|
|
408
218
|
|
|
409
219
|
/**
|
|
@@ -412,14 +222,20 @@ export function evaluate(
|
|
|
412
222
|
*/
|
|
413
223
|
export function execute(
|
|
414
224
|
code: string,
|
|
415
|
-
variables:
|
|
416
|
-
temporary:
|
|
417
|
-
locals:
|
|
418
|
-
transient:
|
|
225
|
+
variables: Namespace,
|
|
226
|
+
temporary: Namespace,
|
|
227
|
+
locals: Namespace = EMPTY_NAMESPACE,
|
|
228
|
+
transient: Namespace = EMPTY_NAMESPACE,
|
|
419
229
|
): void {
|
|
420
|
-
const transformed = transform(code);
|
|
230
|
+
const transformed = transform(code, 'statements');
|
|
421
231
|
const fn = getOrCompile('exec:' + transformed, transformed);
|
|
422
|
-
fn(
|
|
232
|
+
fn(
|
|
233
|
+
namespaceArg(variables),
|
|
234
|
+
namespaceArg(temporary),
|
|
235
|
+
namespaceArg(locals),
|
|
236
|
+
buildExpressionFns(),
|
|
237
|
+
namespaceArg(transient),
|
|
238
|
+
);
|
|
423
239
|
}
|
|
424
240
|
|
|
425
241
|
/**
|
package/src/hooks/use-action.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
2
|
import {
|
|
3
3
|
registerAction,
|
|
4
|
+
updateAction,
|
|
4
5
|
generateActionId,
|
|
5
6
|
type ActionType,
|
|
6
7
|
type StoryAction,
|
|
@@ -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,24 @@ 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.
|
|
51
|
+
useLayoutEffect(() => registerAction(buildAction()), [id]);
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
// When what the action exposes changes (e.g. an input's value), replace it
|
|
54
|
+
// in place: one `actionsChanged` notification, and it keeps its position in
|
|
55
|
+
// getActions(). Unregistering and registering again would notify twice and
|
|
56
|
+
// move it to the end.
|
|
57
|
+
const registered = useRef(false);
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!registered.current) {
|
|
60
|
+
registered.current = true; // the registration above is current
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
updateAction(buildAction());
|
|
48
64
|
}, [
|
|
49
|
-
id,
|
|
50
65
|
opts.type,
|
|
51
66
|
opts.label,
|
|
52
67
|
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
|
}
|