@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.
Files changed (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. 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
- * Transform expression: $var → variables["var"], _var → temporary["var"],
34
- * @var → locals["var"]
35
- * Only transforms when sigils appear as a word boundary (not inside strings naively,
36
- * but authors already have full JS access so this is acceptable).
37
- * `%var` → transient["var"] is handled by `transform` itself, because `%` is
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
- function transformSegment(segment: string): string {
69
- return segment
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
- * String-aware expression transformer. Walks the expression character by
77
- * character so that variable sigils ($, _, @, %) inside string literals are
78
- * left untouched while code — including expressions inside template-literal
79
- * `${…}` interpolations — is transformed.
80
- *
81
- * It also tracks whether the next token is an operand or an operator, so that
82
- * `%name` in operand position is a transient reference while `%` after an
83
- * operand — `($n)%3`, `$a[i] %2`, `$a%$b` — stays the modulo operator.
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
- let code = ''; // accumulates code characters to be transformed
88
- let i = 0;
89
-
90
- let operandNext = true; // an operand (not an operator) comes next
91
- let word = ''; // identifier/number currently being read
92
- let afterDot = false; // `word` is a property name, never a keyword
93
- let afterHeaderKeyword = false; // last token was if/while/for/with
94
- let lastPunct = '';
95
- const parenIsHeader: boolean[] = []; // per open `(`: closes a header?
96
-
97
- function flushCode() {
98
- if (code) {
99
- result += transformSegment(code);
100
- code = '';
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
- endOperand();
275
- continue;
276
- }
277
-
278
- // Transient reference — only where an operand is expected
279
- if (ch === '%') {
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: Record<string, unknown>,
399
- temporary: Record<string, unknown>,
400
- locals: Record<string, unknown> = {},
401
- transient: Record<string, unknown> = {},
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
- const body = `return (${transformed});`;
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(variables, temporary, locals, buildExpressionFns(), transient);
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: Record<string, unknown>,
416
- temporary: Record<string, unknown>,
417
- locals: Record<string, unknown> = {},
418
- transient: Record<string, unknown> = {},
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(variables, temporary, locals, buildExpressionFns(), transient);
232
+ fn(
233
+ namespaceArg(variables),
234
+ namespaceArg(temporary),
235
+ namespaceArg(locals),
236
+ buildExpressionFns(),
237
+ namespaceArg(transient),
238
+ );
423
239
  }
424
240
 
425
241
  /**
@@ -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
- useLayoutEffect(() => {
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
- return registerAction(action);
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 { hasInterpolation, interpolate } from '../interpolation';
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 [variables, temporary, locals, transient] = useMergedLocals();
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
- return interpolate(s, variables, temporary, locals, transient);
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
- [variables, temporary, locals, transient],
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
- registerWidget(widgetName, children, params, isBlock);
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
  }