@rohal12/spindle 0.52.5 → 0.52.6

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 (41) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +651 -544
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/components/macros/Button.tsx +2 -4
  6. package/src/components/macros/Checkbox.tsx +6 -13
  7. package/src/components/macros/Computed.tsx +38 -55
  8. package/src/components/macros/Cycle.tsx +1 -0
  9. package/src/components/macros/Dialog.tsx +7 -8
  10. package/src/components/macros/Do.tsx +2 -1
  11. package/src/components/macros/For.tsx +27 -78
  12. package/src/components/macros/Goto.tsx +6 -9
  13. package/src/components/macros/Include.tsx +10 -57
  14. package/src/components/macros/Listbox.tsx +1 -0
  15. package/src/components/macros/MacroError.tsx +37 -0
  16. package/src/components/macros/MacroLink.tsx +9 -24
  17. package/src/components/macros/Meter.tsx +19 -37
  18. package/src/components/macros/Numberbox.tsx +5 -32
  19. package/src/components/macros/PassageDisplay.tsx +37 -47
  20. package/src/components/macros/Print.tsx +1 -0
  21. package/src/components/macros/Radiobutton.tsx +8 -38
  22. package/src/components/macros/Repeat.tsx +3 -3
  23. package/src/components/macros/Set.tsx +3 -29
  24. package/src/components/macros/Switch.tsx +36 -37
  25. package/src/components/macros/Textarea.tsx +2 -28
  26. package/src/components/macros/Textbox.tsx +2 -29
  27. package/src/components/macros/Type.tsx +3 -3
  28. package/src/components/macros/Unset.tsx +17 -40
  29. package/src/components/macros/Watch.tsx +23 -91
  30. package/src/components/macros/WidgetInvocation.tsx +9 -71
  31. package/src/components/macros/detached-body.tsx +2 -2
  32. package/src/components/macros/input-macro.ts +48 -0
  33. package/src/components/macros/locals-scope.tsx +60 -0
  34. package/src/components/macros/macro-args.ts +280 -0
  35. package/src/components/macros/option-utils.ts +7 -12
  36. package/src/define-macro.ts +47 -30
  37. package/src/hooks/use-render-options.ts +26 -0
  38. package/src/markup/render.tsx +2 -4
  39. package/src/registry.ts +48 -0
  40. package/types/index.d.ts +69 -6
  41. package/types/tooling.d.ts +33 -1
@@ -1,36 +1,10 @@
1
1
  import { defineMacro } from '../../define-macro';
2
- import { MacroError } from './MacroError';
2
+ import { useRunOnce } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'set',
6
+ parameters: [{ name: 'code', type: 'expression', required: true }],
6
7
  render({ rawArgs }, ctx) {
7
- const ran = ctx.hooks.useRef(false);
8
- // Boxed: anything can be thrown, including null and other falsy values
9
- const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
10
-
11
- if (!ran.current) {
12
- ran.current = true;
13
-
14
- try {
15
- ctx.mutate(rawArgs);
16
- } catch (err) {
17
- failure.current = { error: err };
18
- console.error(
19
- `spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
20
- err,
21
- );
22
- }
23
- }
24
-
25
- if (failure.current) {
26
- return (
27
- <MacroError
28
- macro="set"
29
- error={failure.current.error}
30
- />
31
- );
32
- }
33
-
34
- return null;
8
+ return useRunOnce('set', rawArgs, () => ctx.mutate(rawArgs));
35
9
  },
36
10
  });
@@ -1,9 +1,32 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
+ import type { Branch } from '../../markup/ast';
4
+
5
+ /**
6
+ * The first {case} branch whose value equals `value`, else the {default}
7
+ * branch, else null. Throws what a case expression throws.
8
+ */
9
+ function selectBranch(
10
+ value: unknown,
11
+ branches: Branch[],
12
+ evaluate: (expr: string) => unknown,
13
+ ): Branch | null {
14
+ let defaultBranch: Branch | null = null;
15
+ for (let i = 1; i < branches.length; i++) {
16
+ const branch = branches[i]!;
17
+ if (branch.rawArgs === '') {
18
+ defaultBranch = branch;
19
+ } else if (value === evaluate(branch.rawArgs)) {
20
+ return branch;
21
+ }
22
+ }
23
+ return defaultBranch;
24
+ }
3
25
 
4
26
  defineMacro({
5
27
  name: 'switch',
6
28
  subMacros: ['case', 'default'],
29
+ parameters: [{ name: 'expression', type: 'expression', required: true }],
7
30
  merged: true,
8
31
  render({ rawArgs, branches = [] }, ctx) {
9
32
  let switchValue: unknown;
@@ -18,46 +41,22 @@ defineMacro({
18
41
  );
19
42
  }
20
43
 
21
- let defaultBranch: (typeof branches)[number] | null = null;
22
- for (let i = 1; i < branches.length; i++) {
23
- const branch = branches[i]!;
24
- if (branch.rawArgs === '') {
25
- defaultBranch = branch;
26
- continue;
27
- }
28
-
29
- try {
30
- const caseValue = ctx.evaluate!(branch.rawArgs);
31
- if (switchValue === caseValue) {
32
- return <>{ctx.renderNodes(branch.children)}</>;
33
- }
34
- } catch (err) {
35
- return (
36
- <MacroError
37
- macro="case"
38
- error={err}
39
- />
40
- );
41
- }
42
- }
43
-
44
- if (defaultBranch) {
45
- return <>{ctx.renderNodes(defaultBranch.children)}</>;
44
+ let branch: Branch | null;
45
+ try {
46
+ branch = selectBranch(switchValue, branches, ctx.evaluate!);
47
+ } catch (err) {
48
+ return (
49
+ <MacroError
50
+ macro="case"
51
+ error={err}
52
+ />
53
+ );
46
54
  }
47
55
 
48
- return null;
56
+ return branch && <>{ctx.renderNodes(branch.children)}</>;
49
57
  },
50
58
  text({ rawArgs, branches = [] }, ctx) {
51
- const switchValue = ctx.evaluate(rawArgs);
52
- let defaultBranch: (typeof branches)[number] | null = null;
53
- for (let i = 1; i < branches.length; i++) {
54
- const branch = branches[i]!;
55
- if (branch.rawArgs === '') {
56
- defaultBranch = branch;
57
- } else if (switchValue === ctx.evaluate(branch.rawArgs)) {
58
- return ctx.renderText(branch.children);
59
- }
60
- }
61
- return defaultBranch ? ctx.renderText(defaultBranch.children) : '';
59
+ const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
60
+ return branch ? ctx.renderText(branch.children) : '';
62
61
  },
63
62
  });
@@ -1,29 +1,3 @@
1
- import { defineMacro } from '../../define-macro';
1
+ import { defineInputMacro } from './input-macro';
2
2
 
3
- defineMacro({
4
- name: 'textarea',
5
- storeVar: true,
6
- render({ rawArgs }, ctx) {
7
- const { placeholder } = ctx.parseVarArgs(rawArgs);
8
-
9
- ctx.useAction({
10
- type: 'textarea',
11
- key: `$${ctx.varName}`,
12
- authorId: ctx.id,
13
- label: placeholder || ctx.varName!,
14
- variable: ctx.varName,
15
- value: ctx.value,
16
- perform: (v) => ctx.setValue!(v !== undefined ? String(v) : ''),
17
- });
18
-
19
- return (
20
- <textarea
21
- id={ctx.id}
22
- class={ctx.cls}
23
- value={ctx.value == null ? '' : String(ctx.value)}
24
- placeholder={placeholder}
25
- onInput={(e) => ctx.setValue!((e.target as HTMLTextAreaElement).value)}
26
- />
27
- );
28
- },
29
- });
3
+ defineInputMacro('textarea');
@@ -1,30 +1,3 @@
1
- import { defineMacro } from '../../define-macro';
1
+ import { defineInputMacro } from './input-macro';
2
2
 
3
- defineMacro({
4
- name: 'textbox',
5
- storeVar: true,
6
- render({ rawArgs }, ctx) {
7
- const { placeholder } = ctx.parseVarArgs(rawArgs);
8
-
9
- ctx.useAction({
10
- type: 'textbox',
11
- key: `$${ctx.varName}`,
12
- authorId: ctx.id,
13
- label: placeholder || ctx.varName!,
14
- variable: ctx.varName,
15
- value: ctx.value,
16
- perform: (v) => ctx.setValue!(v !== undefined ? String(v) : ''),
17
- });
18
-
19
- return (
20
- <input
21
- type="text"
22
- id={ctx.id}
23
- class={ctx.cls}
24
- value={ctx.value == null ? '' : String(ctx.value)}
25
- placeholder={placeholder}
26
- onInput={(e) => ctx.setValue!((e.target as HTMLInputElement).value)}
27
- />
28
- );
29
- },
30
- });
3
+ defineInputMacro('textbox', 'text');
@@ -1,13 +1,13 @@
1
- import { parseDelay } from '../../utils/parse-delay';
2
1
  import { defineMacro } from '../../define-macro';
3
2
 
4
3
  defineMacro({
5
4
  name: 'type',
6
5
  block: true,
7
6
  interpolate: true,
8
- render({ rawArgs, children = [] }, ctx) {
7
+ parameters: [{ name: 'delay', type: 'delay', required: true }],
8
+ render({ children = [] }, ctx) {
9
9
  const { useState, useEffect, useRef } = ctx.hooks;
10
- const speed = parseDelay(rawArgs);
10
+ const speed = ctx.args.delay ?? 0;
11
11
  const containerRef = useRef<HTMLSpanElement>(null);
12
12
  const [totalChars, setTotalChars] = useState(0);
13
13
  const [visibleChars, setVisibleChars] = useState(0);
@@ -1,52 +1,29 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
- import { MacroError } from './MacroError';
3
+ import { useRunOnce } from './MacroError';
4
4
 
5
5
  defineMacro({
6
6
  name: 'unset',
7
+ parameters: [{ name: 'variable', type: 'variable', required: true }],
7
8
  render({ rawArgs }, ctx) {
8
- const ran = ctx.hooks.useRef(false);
9
- // Boxed: anything can be thrown, including null and other falsy values
10
- const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
11
-
12
- if (!ran.current) {
13
- ran.current = true;
9
+ return useRunOnce('unset', rawArgs, () => {
14
10
  const state = useStoryStore.getState();
15
- const name = rawArgs.trim();
11
+ const name = ctx.args.variable ?? '';
16
12
 
17
- try {
18
- if (name.startsWith('$')) {
19
- state.deleteVariable(name.slice(1));
20
- } else if (name.startsWith('_')) {
21
- state.deleteTemporary(name.slice(1));
22
- } else if (name.startsWith('%')) {
23
- state.deleteTransient(name.slice(1));
24
- } else if (name.startsWith('@')) {
25
- // Throws outside a locals scope ({for}, widget, {link}, {button})
26
- ctx.update(name.slice(1), undefined);
27
- } else {
28
- throw new Error(
29
- `{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
30
- );
31
- }
32
- } catch (err) {
33
- failure.current = { error: err };
34
- console.error(
35
- `spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
36
- err,
13
+ if (name.startsWith('$')) {
14
+ state.deleteVariable(name.slice(1));
15
+ } else if (name.startsWith('_')) {
16
+ state.deleteTemporary(name.slice(1));
17
+ } else if (name.startsWith('%')) {
18
+ state.deleteTransient(name.slice(1));
19
+ } else if (name.startsWith('@')) {
20
+ // Throws outside a locals scope ({for}, widget, {link}, {button})
21
+ ctx.update(name.slice(1), undefined);
22
+ } else {
23
+ throw new Error(
24
+ `{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
37
25
  );
38
26
  }
39
- }
40
-
41
- if (failure.current) {
42
- return (
43
- <MacroError
44
- macro="unset"
45
- error={failure.current.error}
46
- />
47
- );
48
- }
49
-
50
- return null;
27
+ });
51
28
  },
52
29
  });
@@ -1,99 +1,30 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { addMacroTrigger, removeTrigger } from '../../triggers';
3
- import type { WatchOptions } from '../../triggers';
4
- import { isWhitespace, readQuoted, readWholeQuoted } from './arg-utils';
5
-
6
- const WORD_RE = /\w+/y;
7
- const DIGITS_RE = /\d+/y;
8
-
9
- /** Match the sticky regex `re` at `pos`, returning the matched text. */
10
- function matchAt(re: RegExp, src: string, pos: number): string | null {
11
- re.lastIndex = pos;
12
- return re.exec(src)?.[0] ?? null;
13
- }
14
-
15
- /**
16
- * Parse `{watch "condition" keyword value …}`: a quoted condition followed
17
- * by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
18
- * `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
19
- * Returns `null` when the arguments do not start with a quoted condition.
20
- */
21
- export function parseWatchArgs(
22
- rawArgs: string,
23
- ): { condition: string; options: WatchOptions } | null {
24
- const raw = rawArgs.trim();
25
- const cond = readQuoted(raw, 0);
26
- if (!cond) return null;
27
-
28
- const options: WatchOptions = {};
29
- let i = cond.end;
30
- while (i < raw.length) {
31
- const key = matchAt(WORD_RE, raw, i);
32
- if (!key) {
33
- // Skip whitespace and stray characters, or a stray quoted string.
34
- i = readQuoted(raw, i)?.end ?? i + 1;
35
- continue;
36
- }
37
- i += key.length;
38
-
39
- // A value is a quoted string or a digit run, after whitespace.
40
- let val: string | undefined;
41
- let j = i;
42
- while (j < raw.length && isWhitespace(raw[j]!)) j++;
43
- if (j > i) {
44
- const quoted = readQuoted(raw, j);
45
- const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
46
- if (quoted) {
47
- val = quoted.value;
48
- i = quoted.end;
49
- } else if (digits) {
50
- val = digits;
51
- i = j + digits.length;
52
- }
53
- }
54
-
55
- switch (key) {
56
- case 'goto':
57
- options.goto = val;
58
- break;
59
- case 'dialog':
60
- options.dialog = val;
61
- break;
62
- case 'run':
63
- options.run = val;
64
- break;
65
- case 'name':
66
- options.name = val;
67
- break;
68
- case 'priority':
69
- options.priority = val ? Number(val) : 0;
70
- break;
71
- case 'once':
72
- options.once = true;
73
- break;
74
- }
75
- }
76
-
77
- return { condition: cond.value, options };
78
- }
79
-
80
- /**
81
- * Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
82
- * same escapes as `{watch}`, or a bare name.
83
- */
84
- export function parseUnwatchName(rawArgs: string): string {
85
- const raw = rawArgs.trim();
86
- return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, '');
87
- }
88
3
 
89
4
  defineMacro({
90
5
  name: 'watch',
91
- render(props, ctx) {
6
+ // {watch "condition" goto "X" dialog "X" run "X" name "X" priority N once}
7
+ parameters: [
8
+ { name: 'condition', type: 'string', required: true },
9
+ {
10
+ name: 'options',
11
+ type: 'options',
12
+ parameters: [
13
+ { name: 'goto', type: 'string' },
14
+ { name: 'dialog', type: 'string' },
15
+ { name: 'run', type: 'string' },
16
+ { name: 'name', type: 'string' },
17
+ { name: 'priority', type: 'number' },
18
+ { name: 'once', type: 'flag' },
19
+ ],
20
+ },
21
+ ],
22
+ render(_props, ctx) {
92
23
  const { hooks } = ctx;
93
24
 
94
- const parsed = parseWatchArgs(props.rawArgs);
95
- if (!parsed) return null;
96
- const { condition, options } = parsed;
25
+ // Without a quoted condition there is nothing to watch.
26
+ const { condition, options } = ctx.args;
27
+ if (condition === undefined) return null;
97
28
 
98
29
  // Register during render (like {set}) — triggers survive navigation
99
30
  // and are cleaned up via resetTriggers() on restart or {unwatch}.
@@ -110,8 +41,9 @@ defineMacro({
110
41
 
111
42
  defineMacro({
112
43
  name: 'unwatch',
113
- render(props, ctx) {
114
- const name = parseUnwatchName(props.rawArgs);
44
+ parameters: [{ name: 'name', type: 'text', required: true }],
45
+ render(_props, ctx) {
46
+ const name = ctx.args.name ?? '';
115
47
 
116
48
  const ran = ctx.hooks.useRef(false);
117
49
  if (!ran.current) {
@@ -1,28 +1,12 @@
1
- import {
2
- useContext,
3
- useState,
4
- useCallback,
5
- useRef,
6
- useMemo,
7
- } from 'preact/hooks';
8
- import {
9
- LocalsValuesContext,
10
- LocalsUpdateContext,
11
- NobrContext,
12
- InlineContext,
13
- WidgetChildrenContext,
14
- RawTextContext,
15
- renderNodes,
16
- } from '../../markup/render';
1
+ import { useMemo } from 'preact/hooks';
2
+ import { WidgetChildrenContext, renderNodes } from '../../markup/render';
17
3
  import { useMergedLocals } from '../../hooks/use-merged-locals';
18
4
  import { evaluate } from '../../expression';
19
5
  import type { ASTNode } from '../../markup/ast';
20
6
  import { splitArgs } from './arg-utils';
21
- import {
22
- checkVariableName,
23
- createNamespace,
24
- withEntry,
25
- } from '../../utils/namespace';
7
+ import { createNamespace } from '../../utils/namespace';
8
+ import { useRenderOptions } from '../../hooks/use-render-options';
9
+ import { LocalsScope } from './locals-scope';
26
10
 
27
11
  export { splitArgs };
28
12
 
@@ -33,60 +17,14 @@ interface WidgetInvocationProps {
33
17
  invocationChildren?: ASTNode[];
34
18
  }
35
19
 
36
- function WidgetBody({
37
- body,
38
- parentValues,
39
- ownKeys,
40
- }: {
41
- body: ASTNode[];
42
- parentValues: Record<string, unknown>;
43
- ownKeys: Record<string, unknown>;
44
- }) {
45
- const nobr = useContext(NobrContext);
46
- const inline = useContext(InlineContext);
47
- const raw = useContext(RawTextContext);
48
- const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
49
- {},
50
- );
51
-
52
- const localState = useMemo(
53
- () => createNamespace(parentValues, ownKeys, localMutations),
54
- [parentValues, ownKeys, localMutations],
55
- );
56
-
57
- const valuesRef = useRef(localState);
58
- valuesRef.current = localState;
59
-
60
- const getValues = useCallback(() => valuesRef.current, []);
61
- const update = useCallback((key: string, value: unknown) => {
62
- // Apply synchronously so later macros in the same render pass (e.g. a
63
- // second {set}) read the new value via getValues(); the state update
64
- // then re-renders consumers of LocalsValuesContext.
65
- checkVariableName(key, `@${key}`);
66
- valuesRef.current = withEntry(valuesRef.current, key, value);
67
- setLocalMutations((prev) => ({ ...prev, [key]: value }));
68
- }, []);
69
- const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
70
-
71
- return (
72
- <LocalsUpdateContext.Provider value={updater}>
73
- <LocalsValuesContext.Provider value={localState}>
74
- {renderNodes(body, { nobr, inline, raw, locals: localState })}
75
- </LocalsValuesContext.Provider>
76
- </LocalsUpdateContext.Provider>
77
- );
78
- }
79
-
80
20
  export function WidgetInvocation({
81
21
  body,
82
22
  params,
83
23
  rawArgs,
84
24
  invocationChildren,
85
25
  }: WidgetInvocationProps) {
86
- const parentValues = useContext(LocalsValuesContext);
87
- const nobr = useContext(NobrContext);
88
- const inline = useContext(InlineContext);
89
- const raw = useContext(RawTextContext);
26
+ const renderOptions = useRenderOptions();
27
+ const parentValues = renderOptions.locals;
90
28
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
91
29
  useMergedLocals();
92
30
 
@@ -97,7 +35,7 @@ export function WidgetInvocation({
97
35
  if (params.length === 0) {
98
36
  return (
99
37
  <WidgetChildrenContext.Provider value={childrenValue}>
100
- {renderNodes(body, { nobr, inline, raw, locals: parentValues })}
38
+ {renderNodes(body, renderOptions)}
101
39
  </WidgetChildrenContext.Provider>
102
40
  );
103
41
  }
@@ -137,7 +75,7 @@ export function WidgetInvocation({
137
75
 
138
76
  return (
139
77
  <WidgetChildrenContext.Provider value={childrenValue}>
140
- <WidgetBody
78
+ <LocalsScope
141
79
  body={body}
142
80
  parentValues={parentValues}
143
81
  ownKeys={ownKeys}
@@ -9,6 +9,7 @@ import {
9
9
  WidgetChildrenContext,
10
10
  } from '../../markup/render';
11
11
  import type { ASTNode } from '../../markup/ast';
12
+ import { useRenderOptions } from '../../hooks/use-render-options';
12
13
  import { liveLocalsView } from '../../utils/live-locals';
13
14
  import { runWithCommittedMutations } from '../../execute-mutation';
14
15
  import { RepeatContext } from './Repeat';
@@ -35,8 +36,7 @@ import { DialogCloseContext } from '../PassageDialog';
35
36
  */
36
37
  export function useDetachedBody(): (children: ASTNode[]) => void {
37
38
  const updater = useContext(LocalsUpdateContext);
38
- const nobr = useContext(NobrContext);
39
- const inline = useContext(InlineContext);
39
+ const { nobr, inline } = useRenderOptions();
40
40
  const widgetChildren = useContext(WidgetChildrenContext);
41
41
  const repeat = useContext(RepeatContext);
42
42
  const closeDialog = useContext(DialogCloseContext);
@@ -0,0 +1,48 @@
1
+ import { h } from 'preact';
2
+ import { defineMacro } from '../../define-macro';
3
+
4
+ /**
5
+ * Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
6
+ * `type` (or, without one, a `<textarea>`) bound to the story variable.
7
+ * `toValue` turns what the reader types, or an automation action's value
8
+ * (`undefined` when it has none), into the variable's value: by default,
9
+ * the text.
10
+ */
11
+ export function defineInputMacro(
12
+ name: 'textbox' | 'numberbox' | 'textarea',
13
+ type?: 'text' | 'number',
14
+ toValue = (input: unknown): unknown =>
15
+ input !== undefined ? String(input) : '',
16
+ ): void {
17
+ defineMacro({
18
+ name,
19
+ storeVar: true,
20
+ parameters: [
21
+ { name: 'variable', type: 'variable', required: true },
22
+ { name: 'placeholder', type: 'string' },
23
+ ],
24
+ render(_props, ctx) {
25
+ const placeholder = ctx.args.placeholder ?? '';
26
+
27
+ ctx.useAction({
28
+ type: name,
29
+ key: `$${ctx.varName}`,
30
+ authorId: ctx.id,
31
+ label: placeholder || ctx.varName!,
32
+ variable: ctx.varName,
33
+ value: ctx.value,
34
+ perform: (v) => ctx.setValue!(toValue(v)),
35
+ });
36
+
37
+ return h(type ? 'input' : 'textarea', {
38
+ type,
39
+ id: ctx.id,
40
+ class: ctx.cls,
41
+ value: ctx.value == null ? '' : String(ctx.value),
42
+ placeholder,
43
+ onInput: (e: Event) =>
44
+ ctx.setValue!(toValue((e.target as HTMLInputElement).value)),
45
+ });
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,60 @@
1
+ import { useState, useCallback, useRef, useMemo } from 'preact/hooks';
2
+ import {
3
+ LocalsValuesContext,
4
+ LocalsUpdateContext,
5
+ renderNodes,
6
+ } from '../../markup/render';
7
+ import type { ASTNode } from '../../markup/ast';
8
+ import { useRenderOptions } from '../../hooks/use-render-options';
9
+ import {
10
+ checkVariableName,
11
+ createNamespace,
12
+ withEntry,
13
+ } from '../../utils/namespace';
14
+
15
+ /**
16
+ * Render `body` in a new locals scope: the locals of `parentValues`, with
17
+ * `ownKeys` (an iteration's loop variables, a widget's parameters) on top.
18
+ * Locals the body assigns are kept in this scope.
19
+ */
20
+ export function LocalsScope({
21
+ body,
22
+ parentValues,
23
+ ownKeys,
24
+ }: {
25
+ body: ASTNode[];
26
+ parentValues: Record<string, unknown>;
27
+ ownKeys: Record<string, unknown>;
28
+ }) {
29
+ const options = useRenderOptions();
30
+ const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
31
+ {},
32
+ );
33
+
34
+ const localState = useMemo(
35
+ () => createNamespace(parentValues, ownKeys, localMutations),
36
+ [parentValues, ownKeys, localMutations],
37
+ );
38
+
39
+ const valuesRef = useRef(localState);
40
+ valuesRef.current = localState;
41
+
42
+ const getValues = useCallback(() => valuesRef.current, []);
43
+ const update = useCallback((key: string, value: unknown) => {
44
+ // Apply synchronously so later macros in the same render pass (e.g. a
45
+ // second {set}) read the new value via getValues(); the state update
46
+ // then re-renders consumers of LocalsValuesContext.
47
+ checkVariableName(key, `@${key}`);
48
+ valuesRef.current = withEntry(valuesRef.current, key, value);
49
+ setLocalMutations((prev) => ({ ...prev, [key]: value }));
50
+ }, []);
51
+ const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
52
+
53
+ return (
54
+ <LocalsUpdateContext.Provider value={updater}>
55
+ <LocalsValuesContext.Provider value={localState}>
56
+ {renderNodes(body, { ...options, locals: localState })}
57
+ </LocalsValuesContext.Provider>
58
+ </LocalsUpdateContext.Provider>
59
+ );
60
+ }