@rohal12/spindle 0.52.4 → 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 (47) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +1502 -1521
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/class-registry.ts +6 -182
  6. package/src/components/macros/Button.tsx +2 -4
  7. package/src/components/macros/Checkbox.tsx +6 -13
  8. package/src/components/macros/Computed.tsx +38 -55
  9. package/src/components/macros/Cycle.tsx +1 -0
  10. package/src/components/macros/Dialog.tsx +7 -8
  11. package/src/components/macros/Do.tsx +2 -1
  12. package/src/components/macros/For.tsx +27 -78
  13. package/src/components/macros/Goto.tsx +6 -9
  14. package/src/components/macros/Include.tsx +10 -57
  15. package/src/components/macros/Listbox.tsx +1 -0
  16. package/src/components/macros/MacroError.tsx +37 -0
  17. package/src/components/macros/MacroLink.tsx +9 -24
  18. package/src/components/macros/Meter.tsx +19 -37
  19. package/src/components/macros/Numberbox.tsx +5 -32
  20. package/src/components/macros/PassageDisplay.tsx +37 -47
  21. package/src/components/macros/Print.tsx +1 -0
  22. package/src/components/macros/Radiobutton.tsx +8 -38
  23. package/src/components/macros/Repeat.tsx +3 -3
  24. package/src/components/macros/Set.tsx +3 -29
  25. package/src/components/macros/Switch.tsx +36 -37
  26. package/src/components/macros/Textarea.tsx +2 -28
  27. package/src/components/macros/Textbox.tsx +2 -29
  28. package/src/components/macros/Type.tsx +3 -3
  29. package/src/components/macros/Unset.tsx +17 -40
  30. package/src/components/macros/Watch.tsx +23 -91
  31. package/src/components/macros/WidgetInvocation.tsx +9 -71
  32. package/src/components/macros/detached-body.tsx +2 -2
  33. package/src/components/macros/input-macro.ts +48 -0
  34. package/src/components/macros/locals-scope.tsx +60 -0
  35. package/src/components/macros/macro-args.ts +280 -0
  36. package/src/components/macros/option-utils.ts +7 -12
  37. package/src/define-macro.ts +47 -30
  38. package/src/execute-mutation.ts +17 -97
  39. package/src/hooks/use-render-options.ts +26 -0
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/src/saves/save-manager.ts +190 -259
  43. package/src/saves/storage.ts +229 -402
  44. package/src/store.ts +244 -310
  45. package/src/structural.ts +388 -0
  46. package/types/index.d.ts +69 -6
  47. package/types/tooling.d.ts +33 -1
@@ -11,12 +11,9 @@ import {
11
11
  } from 'preact/hooks';
12
12
  import { useInterpolate } from './hooks/use-interpolate';
13
13
  import { useMergedLocals } from './hooks/use-merged-locals';
14
+ import { useRenderOptions } from './hooks/use-render-options';
14
15
  import {
15
16
  LocalsUpdateContext,
16
- LocalsValuesContext,
17
- NobrContext,
18
- InlineContext,
19
- RawTextContext,
20
17
  renderNodes as _renderNodes,
21
18
  renderInlineNodes,
22
19
  } from './markup/render';
@@ -31,23 +28,34 @@ import type { UseActionOptions } from './hooks/use-action';
31
28
  import { collectText } from './utils/extract-text';
32
29
  import { currentSourceLocation } from './utils/source-location';
33
30
  import { parseVarArgs, extractOptions } from './components/macros/option-utils';
31
+ import {
32
+ parseMacroArgs,
33
+ readBoundVariable,
34
+ } from './components/macros/macro-args';
34
35
  import {
35
36
  registerMacro,
36
37
  registerMacroText,
37
38
  registerSubMacro,
38
39
  registerMacroMetadata,
39
40
  } from './registry';
40
- import type { MacroProps, MacroTextContext, ParameterDef } from './registry';
41
+ import type {
42
+ MacroArgs,
43
+ MacroProps,
44
+ MacroTextContext,
45
+ ParameterDef,
46
+ } from './registry';
41
47
  import { registerBlockMacro } from './markup/ast';
42
48
 
43
- export type { MacroTextContext };
49
+ export type { MacroArgs, MacroTextContext, ParameterDef };
44
50
 
45
51
  export function macroClass(type: string, className?: string): string {
46
52
  const base = `macro-${type}`;
47
53
  return className ? `${base} ${className}` : base;
48
54
  }
49
55
 
50
- export interface MacroContext {
56
+ export interface MacroContext<A = MacroArgs> {
57
+ /** The arguments, read into the declared parameters (see ParameterDef). */
58
+ args: A;
51
59
  className?: string;
52
60
  id?: string;
53
61
  resolve?: (s: string | undefined) => string | undefined;
@@ -86,7 +94,9 @@ export interface MacroContext {
86
94
  };
87
95
  }
88
96
 
89
- export interface MacroDefinition {
97
+ export interface MacroDefinition<
98
+ P extends readonly ParameterDef[] = ParameterDef[],
99
+ > {
90
100
  name: string;
91
101
  subMacros?: string[];
92
102
  block?: boolean;
@@ -94,14 +104,20 @@ export interface MacroDefinition {
94
104
  merged?: boolean;
95
105
  storeVar?: boolean;
96
106
  description?: string;
97
- parameters?: ParameterDef[];
98
- render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
107
+ parameters?: P;
108
+ render: (
109
+ props: MacroProps,
110
+ ctx: MacroContext<MacroArgs<P>>,
111
+ ) => ComponentChildren;
99
112
  /**
100
113
  * The macro's text form, used where markup becomes a string: HTML
101
114
  * attribute values, image alt text and link titles, macro labels. Without
102
115
  * one, the macro can't be used there and is reported as an error.
103
116
  */
104
- text?: (props: MacroProps, ctx: MacroTextContext) => string;
117
+ text?: (
118
+ props: MacroProps,
119
+ ctx: MacroTextContext & { args: MacroArgs<P> },
120
+ ) => string;
105
121
  }
106
122
 
107
123
  const sharedHooks = {
@@ -114,10 +130,12 @@ const sharedHooks = {
114
130
  useContext,
115
131
  };
116
132
 
117
- export function defineMacro(
118
- config: MacroDefinition,
133
+ export function defineMacro<const P extends readonly ParameterDef[] = []>(
134
+ config: MacroDefinition<P>,
119
135
  source: 'builtin' | 'user' = 'builtin',
120
136
  ): void {
137
+ const parameters: readonly ParameterDef[] = config.parameters ?? [];
138
+
121
139
  function Wrapper(props: MacroProps) {
122
140
  // className/id resolved first (interpolate may transform them)
123
141
  let className = props.className;
@@ -131,10 +149,7 @@ export function defineMacro(
131
149
 
132
150
  // Always-on: cssClass + mutation
133
151
  const { update, getValues } = useContext(LocalsUpdateContext);
134
- const nobr = useContext(NobrContext);
135
- const inline = useContext(InlineContext);
136
- const raw = useContext(RawTextContext);
137
- const localsValues = useContext(LocalsValuesContext);
152
+ const renderOptions = useRenderOptions();
138
153
  const renderNodes = (
139
154
  nodes: ASTNode[],
140
155
  options?: {
@@ -143,15 +158,9 @@ export function defineMacro(
143
158
  inline?: boolean;
144
159
  raw?: boolean;
145
160
  },
146
- ) =>
147
- _renderNodes(nodes, {
148
- nobr,
149
- inline,
150
- raw,
151
- locals: localsValues,
152
- ...options,
153
- });
154
- const ctx: MacroContext = {
161
+ ) => _renderNodes(nodes, { ...renderOptions, ...options });
162
+ const ctx: MacroContext<MacroArgs<P>> = {
163
+ args: parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>,
155
164
  collectText,
156
165
  sourceLocation: currentSourceLocation,
157
166
  parseVarArgs,
@@ -183,8 +192,7 @@ export function defineMacro(
183
192
  }
184
193
 
185
194
  if (config.storeVar) {
186
- const firstToken =
187
- props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, '') ?? '';
195
+ const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
188
196
 
189
197
  if (firstToken.startsWith('%')) {
190
198
  return h(
@@ -220,7 +228,16 @@ export function defineMacro(
220
228
  }
221
229
 
222
230
  registerMacro(config.name, Wrapper);
223
- registerMacroText(config.name, config.text);
231
+ const text = config.text;
232
+ registerMacroText(
233
+ config.name,
234
+ text &&
235
+ ((props, ctx) =>
236
+ text(props, {
237
+ ...ctx,
238
+ args: parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>,
239
+ })),
240
+ );
224
241
 
225
242
  // Store metadata for tooling API
226
243
  const isBlock =
@@ -234,7 +251,7 @@ export function defineMacro(
234
251
  interpolate: config.interpolate,
235
252
  merged: config.merged,
236
253
  description: config.description,
237
- parameters: config.parameters,
254
+ parameters: config.parameters && [...config.parameters],
238
255
  source,
239
256
  });
240
257
 
@@ -9,9 +9,18 @@ import {
9
9
  import { useStoryStore } from './store';
10
10
  import type { StoryState, VariableNamespaces } from './store';
11
11
  import { execute } from './expression';
12
- import { deepClone, deepEqual } from './class-registry';
13
- import { deleteByPath, getByPath, setByPath } from './utils/object-path';
14
- import { asNamespace } from './utils/namespace';
12
+ import {
13
+ applyChange,
14
+ deepClone,
15
+ deepEqual,
16
+ diffPaths,
17
+ isApplied,
18
+ isMergeable,
19
+ mergeKeys,
20
+ type PathChange,
21
+ } from './structural';
22
+ import { getByPath } from './utils/object-path';
23
+ import { asNamespace, hasOwn } from './utils/namespace';
15
24
 
16
25
  type NamespaceName = keyof VariableNamespaces;
17
26
 
@@ -21,11 +30,6 @@ const NAMESPACES: readonly NamespaceName[] = [
21
30
  'transient',
22
31
  ];
23
32
 
24
- /** A write to one property path of a namespace, or its deletion. */
25
- type PathChange =
26
- | { path: string[]; deleted: false; value: unknown }
27
- | { path: string[]; deleted: true };
28
-
29
33
  /**
30
34
  * The state of one executing mutation. The code reads and writes `work`.
31
35
  * `base` is what `work` started as, with every write made elsewhere during
@@ -76,80 +80,6 @@ export function frozenCopy<T>(value: T): T {
76
80
  return freeze(cloneValue(value), true);
77
81
  }
78
82
 
79
- /**
80
- * Objects merged property by property: plain objects and (registered)
81
- * class instances. Arrays, Map, Set, Date and RegExp are values that are
82
- * replaced as a whole, since their elements have no stable identity to
83
- * merge by (a shift moves every index).
84
- */
85
- export function isMergeable(value: unknown): value is Record<string, unknown> {
86
- return (
87
- typeof value === 'object' &&
88
- value !== null &&
89
- !Array.isArray(value) &&
90
- !(value instanceof Map) &&
91
- !(value instanceof Set) &&
92
- !(value instanceof Date) &&
93
- !(value instanceof RegExp)
94
- );
95
- }
96
-
97
- const hasOwn = (obj: object, key: string): boolean =>
98
- Object.prototype.hasOwnProperty.call(obj, key);
99
-
100
- /** Collect the property paths where `work` differs from `base`. */
101
- function diff(
102
- base: Record<string, unknown>,
103
- work: Record<string, unknown>,
104
- path: string[],
105
- changes: PathChange[],
106
- ancestors: Set<object>,
107
- ): void {
108
- // Stop at cycles; shared (non-cyclic) references are visited per path.
109
- if (ancestors.has(work)) return;
110
- ancestors.add(work);
111
- for (const key of Object.keys(work)) {
112
- const b = base[key];
113
- const w = work[key];
114
- if (!hasOwn(base, key)) {
115
- changes.push({ path: [...path, key], deleted: false, value: w });
116
- } else if (Object.is(b, w)) {
117
- // The same value (an untouched subtree of an Immer update)
118
- continue;
119
- } else if (
120
- isMergeable(b) &&
121
- isMergeable(w) &&
122
- Object.getPrototypeOf(b) === Object.getPrototypeOf(w)
123
- ) {
124
- diff(b, w, [...path, key], changes, ancestors);
125
- } else if (!deepEqual(b, w)) {
126
- changes.push({ path: [...path, key], deleted: false, value: w });
127
- }
128
- }
129
- for (const key of Object.keys(base)) {
130
- if (!hasOwn(work, key))
131
- changes.push({ path: [...path, key], deleted: true });
132
- }
133
- ancestors.delete(work);
134
- }
135
-
136
- /** Whether `ns` already holds what `change` would write. */
137
- function isApplied(ns: Record<string, unknown>, change: PathChange): boolean {
138
- const parent = getByPath(ns, change.path.slice(0, -1));
139
- if (parent === null || typeof parent !== 'object') return change.deleted;
140
- const key = change.path[change.path.length - 1]!;
141
- if (!hasOwn(parent, key)) return change.deleted;
142
- return (
143
- !change.deleted &&
144
- deepEqual((parent as Record<string, unknown>)[key], change.value)
145
- );
146
- }
147
-
148
- function applyChange(ns: Record<string, unknown>, change: PathChange): void {
149
- if (change.deleted) deleteByPath(ns, change.path);
150
- else setByPath(ns, change.path, change.value);
151
- }
152
-
153
83
  /**
154
84
  * Apply a write that reached the store while mutations execute to their
155
85
  * `work` and `base` copies (see MutationScope), so the code sees it and
@@ -381,11 +311,9 @@ function commitScopes(commits: readonly Commit[]): void {
381
311
  const all = commits.map(({ scope, keepRunning }) => ({
382
312
  scope,
383
313
  keepRunning,
384
- changes: NAMESPACES.map((ns) => {
385
- const list: PathChange[] = [];
386
- diff(scope.base[ns], scope.work[ns], [], list, new Set());
387
- return [ns, list] as const;
388
- }),
314
+ changes: NAMESPACES.map(
315
+ (ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])] as const,
316
+ ),
389
317
  }));
390
318
  const changed = all.filter(({ changes }) =>
391
319
  changes.some(([, list]) => list.length > 0),
@@ -450,16 +378,8 @@ const running = (): Commit[] =>
450
378
  function resync(scope: MutationScope): void {
451
379
  const state = useStoryStore.getState();
452
380
  for (const ns of NAMESPACES) {
453
- const work = scope.work[ns];
454
- const stored = state[ns];
455
- for (const key of Object.keys(work)) {
456
- if (!hasOwn(stored, key)) delete work[key];
457
- }
458
- for (const key of Object.keys(stored)) {
459
- if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) {
460
- work[key] = deepClone(stored[key]);
461
- }
462
- }
381
+ // Root by root: a root that differs is replaced with a copy of the store's
382
+ mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
463
383
  }
464
384
  scope.base = cloneNamespaces(state);
465
385
  }
@@ -0,0 +1,26 @@
1
+ import { useContext } from 'preact/hooks';
2
+ import {
3
+ InlineContext,
4
+ LocalsValuesContext,
5
+ NobrContext,
6
+ RawTextContext,
7
+ } from '../markup/render';
8
+
9
+ /**
10
+ * The renderNodes() options of the enclosing content: nobr, inline and raw
11
+ * rendering and the locals in scope. A macro or widget body rendered with
12
+ * them renders as the content around it does.
13
+ */
14
+ export function useRenderOptions(): {
15
+ nobr: boolean;
16
+ inline: boolean;
17
+ raw: boolean;
18
+ locals: Record<string, unknown>;
19
+ } {
20
+ return {
21
+ nobr: useContext(NobrContext),
22
+ inline: useContext(InlineContext),
23
+ raw: useContext(RawTextContext),
24
+ locals: useContext(LocalsValuesContext),
25
+ };
26
+ }
@@ -9,6 +9,7 @@ import { markdownToHtml } from './markdown';
9
9
  import { h } from 'preact';
10
10
  import type { ASTNode, HtmlNode, MacroNode } from './ast';
11
11
  import { useTextScope } from '../hooks/use-interpolate';
12
+ import { useRenderOptions } from '../hooks/use-render-options';
12
13
  import {
13
14
  hasInterpolation,
14
15
  interpolateCode,
@@ -524,11 +525,8 @@ function resolveAttributeValue(
524
525
 
525
526
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
526
527
  const scope = useTextScope();
527
- const nobr = useContext(NobrContext);
528
- const locals = useContext(LocalsValuesContext);
529
- const inRaw = useContext(RawTextContext);
528
+ const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
530
529
  const inSvg = useContext(SvgContext);
531
- const parentInline = useContext(InlineContext);
532
530
  const tag = node.tag.toLowerCase();
533
531
  const isSvgRoot = tag === 'svg';
534
532
  const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
package/src/registry.ts CHANGED
@@ -67,12 +67,60 @@ export function isSubMacro(name: string): boolean {
67
67
  return subMacros.has(name.toLowerCase());
68
68
  }
69
69
 
70
+ /**
71
+ * How a macro argument is read (see components/macros/macro-args.ts).
72
+ * Quoted strings accept `\"`, `\'` and `\\` escapes.
73
+ * - `expression`: code, as written (the default).
74
+ * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
75
+ * - `string`: one quoted string; anything else leaves the argument unset.
76
+ * - `text`: one quoted string, or text with any loose quotes stripped.
77
+ * - `names`: a comma-separated list of names (`@item, @i`).
78
+ * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
79
+ * - `number`: a number.
80
+ * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
81
+ * - `separator`: a word (`of`) or `=` separating the parameters before it
82
+ * from those after it; a boolean.
83
+ * - `options`: keywords, the names of its `parameters`, each followed by a
84
+ * quoted string or a number unless it is a flag.
85
+ */
86
+ export type ParameterType =
87
+ | 'expression'
88
+ | 'variable'
89
+ | 'string'
90
+ | 'text'
91
+ | 'names'
92
+ | 'delay'
93
+ | 'number'
94
+ | 'flag'
95
+ | 'separator'
96
+ | 'options';
97
+
70
98
  export interface ParameterDef {
71
99
  name: string;
72
100
  required?: boolean;
73
101
  description?: string;
102
+ type?: ParameterType;
103
+ /** The options of an `options` parameter. */
104
+ parameters?: readonly ParameterDef[];
74
105
  }
75
106
 
107
+ type ArgValue<T, D> = T extends 'flag' | 'separator'
108
+ ? boolean
109
+ : T extends 'names'
110
+ ? string[] | undefined
111
+ : T extends 'delay' | 'number'
112
+ ? number | undefined
113
+ : T extends 'options'
114
+ ? D extends { parameters: infer Q extends readonly ParameterDef[] }
115
+ ? Partial<MacroArgs<Q>>
116
+ : Partial<MacroArgs>
117
+ : string | undefined;
118
+
119
+ /** The arguments of a macro declaring `parameters`, by parameter name. */
120
+ export type MacroArgs<P extends readonly ParameterDef[] = ParameterDef[]> = {
121
+ [D in P[number] as D['name']]: ArgValue<D['type'], D>;
122
+ };
123
+
76
124
  export interface MacroMetadata {
77
125
  name: string;
78
126
  block: boolean;