@rohal12/spindle 0.29.1 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -53,6 +53,15 @@ export function registerAction(action: StoryAction): () => void {
53
53
  };
54
54
  }
55
55
 
56
+ export function updateAction(action: StoryAction): () => void {
57
+ actions.set(action.id, action);
58
+ notify();
59
+ return () => {
60
+ actions.delete(action.id);
61
+ notify();
62
+ };
63
+ }
64
+
56
65
  export function getActions(): StoryAction[] {
57
66
  return Array.from(actions.values());
58
67
  }
@@ -49,6 +49,24 @@ export function deepClone<T>(value: T): T {
49
49
  return arr;
50
50
  }
51
51
 
52
+ if (val instanceof Map) {
53
+ const copy = new Map();
54
+ seen.set(obj, copy);
55
+ for (const [k, v] of val) {
56
+ copy.set(clone(k), clone(v));
57
+ }
58
+ return copy;
59
+ }
60
+
61
+ if (val instanceof Set) {
62
+ const copy = new Set();
63
+ seen.set(obj, copy);
64
+ for (const v of val) {
65
+ copy.add(clone(v));
66
+ }
67
+ return copy;
68
+ }
69
+
52
70
  // Registered class instance
53
71
  const ctor = obj.constructor as Constructor;
54
72
  const name = ctorToName.get(ctor);
@@ -119,6 +137,18 @@ export function serialize<T>(value: T): T {
119
137
  return result;
120
138
  }
121
139
 
140
+ if (val instanceof Map) {
141
+ const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
142
+ seen.delete(obj);
143
+ return { [CLASS_TAG]: '__Map__', [DATA_TAG]: { entries } };
144
+ }
145
+
146
+ if (val instanceof Set) {
147
+ const entries = [...val].map((v) => ser(v));
148
+ seen.delete(obj);
149
+ return { [CLASS_TAG]: '__Set__', [DATA_TAG]: { entries } };
150
+ }
151
+
122
152
  // Registered class instance
123
153
  const ctor = obj.constructor as Constructor;
124
154
  const name = ctorToName.get(ctor);
@@ -167,6 +197,14 @@ export function deserialize<T>(value: T): T {
167
197
  if (name === '__RegExp__') {
168
198
  return new RegExp(data.source as string, data.flags as string);
169
199
  }
200
+ if (name === '__Map__') {
201
+ const entries = data.entries as [unknown, unknown][];
202
+ return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
203
+ }
204
+ if (name === '__Set__') {
205
+ const entries = data.entries as unknown[];
206
+ return new Set(entries.map((v) => deser(v)));
207
+ }
170
208
 
171
209
  const ctor = registry.get(name);
172
210
  if (!ctor) {
@@ -1,5 +1,5 @@
1
1
  import { createContext } from 'preact';
2
- import { useMemo } from 'preact/hooks';
2
+ import { useCallback, useMemo, useRef } from 'preact/hooks';
3
3
  import { tokenize } from '../markup/tokenizer';
4
4
  import { buildAST } from '../markup/ast';
5
5
  import { renderNodes } from '../markup/render';
@@ -22,6 +22,11 @@ export function PassageDialog({
22
22
  onClose,
23
23
  showCloseButton = true,
24
24
  }: PassageDialogProps) {
25
+ // Stabilize onClose so DialogCloseContext value doesn't change identity
26
+ const onCloseRef = useRef(onClose);
27
+ onCloseRef.current = onClose;
28
+ const stableOnClose = useCallback(() => onCloseRef.current(), []);
29
+
25
30
  const storyData = useStoryStore((s) => s.storyData);
26
31
 
27
32
  const passage = passageName
@@ -48,14 +53,14 @@ export function PassageDialog({
48
53
 
49
54
  const handleBackdrop = (e: MouseEvent) => {
50
55
  if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
51
- onClose();
56
+ stableOnClose();
52
57
  }
53
58
  };
54
59
 
55
60
  const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
56
61
 
57
62
  return (
58
- <DialogCloseContext.Provider value={onClose}>
63
+ <DialogCloseContext.Provider value={stableOnClose}>
59
64
  <div
60
65
  class="dialog-overlay"
61
66
  onClick={handleBackdrop}
@@ -64,7 +69,7 @@ export function PassageDialog({
64
69
  {showCloseButton && (
65
70
  <button
66
71
  class="dialog-close"
67
- onClick={onClose}
72
+ onClick={stableOnClose}
68
73
  >
69
74
  ✕
70
75
  </button>
@@ -15,9 +15,11 @@ defineMacro({
15
15
  state.deleteVariable(name.slice(1));
16
16
  } else if (name.startsWith('_')) {
17
17
  state.deleteTemporary(name.slice(1));
18
+ } else if (name.startsWith('@')) {
19
+ ctx.update(name.slice(1), undefined);
18
20
  } else {
19
21
  console.error(
20
- `spindle: {unset} expects a variable ($name or _name), got "${name}"`,
22
+ `spindle: {unset} expects a variable ($name, _name, or @name), got "${name}"`,
21
23
  );
22
24
  }
23
25
  }
@@ -130,7 +130,11 @@ export function defineMacro(config: MacroDefinition): void {
130
130
  mutate: (code: string) => executeMutation(code, getValues(), update),
131
131
  update,
132
132
  getValues,
133
- wrap: undefined as any,
133
+ wrap: (content: ComponentChildren): VNode<any> => {
134
+ if (className || id)
135
+ return h('span', { id, class: className }, content);
136
+ return h(Fragment, null, content);
137
+ },
134
138
  };
135
139
 
136
140
  if (config.merged) {
@@ -149,12 +153,6 @@ export function defineMacro(config: MacroDefinition): void {
149
153
  ctx.setValue = (value: unknown) => setVariable(varName, value);
150
154
  }
151
155
 
152
- ctx.wrap = (content: ComponentChildren): VNode<any> => {
153
- if (ctx.className || ctx.id)
154
- return h('span', { id: ctx.id, class: ctx.className }, content);
155
- return h(Fragment, null, content);
156
- };
157
-
158
156
  return config.render(props, ctx);
159
157
  }
160
158
 
package/src/expression.ts CHANGED
@@ -323,6 +323,14 @@ export function execute(
323
323
  /**
324
324
  * Convenience: evaluate using store state directly.
325
325
  */
326
+ /** Clear the compiled expression cache. Useful for testing and HMR. */
327
+ export function clearExpressionCache(): void {
328
+ fnCache.clear();
329
+ cachedFns = null;
330
+ cachedVisitCounts = null;
331
+ cachedRenderCounts = null;
332
+ }
333
+
326
334
  export function evaluateWithState(expr: string, state: StoryState): unknown {
327
335
  return evaluate(expr, state.variables, state.temporary, {});
328
336
  }
@@ -21,6 +21,8 @@ export interface UseActionOptions {
21
21
 
22
22
  export function useAction(opts: UseActionOptions): string {
23
23
  const idRef = useRef<string>('');
24
+ const performRef = useRef(opts.perform);
25
+ performRef.current = opts.perform;
24
26
 
25
27
  // Generate ID only once on first call
26
28
  if (!idRef.current) {
@@ -34,7 +36,7 @@ export function useAction(opts: UseActionOptions): string {
34
36
  id,
35
37
  type: opts.type,
36
38
  label: opts.label,
37
- perform: opts.perform,
39
+ perform: (...args) => performRef.current(...args),
38
40
  };
39
41
  if (opts.target !== undefined) action.target = opts.target;
40
42
  if (opts.variable !== undefined) action.variable = opts.variable;
@@ -43,7 +45,16 @@ export function useAction(opts: UseActionOptions): string {
43
45
  if (opts.disabled !== undefined) action.disabled = opts.disabled;
44
46
 
45
47
  return registerAction(action);
46
- });
48
+ }, [
49
+ id,
50
+ opts.type,
51
+ opts.label,
52
+ opts.target,
53
+ opts.variable,
54
+ opts.disabled,
55
+ opts.value,
56
+ JSON.stringify(opts.options),
57
+ ]);
47
58
 
48
59
  return id;
49
60
  }
package/src/index.tsx CHANGED
@@ -108,7 +108,27 @@ function boot() {
108
108
  useStoryStore.getState().loadFromPayload(sessionPayload);
109
109
  }
110
110
 
111
- // Register widgets from passages tagged "widget"
111
+ // Pass 1: Pre-scan all widget passages to discover block widgets.
112
+ // Register them as block macros BEFORE any tokenize/buildAST calls,
113
+ // so that widget bodies using other block widgets parse correctly
114
+ // regardless of passage order.
115
+ const blockWidgetPattern =
116
+ /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
117
+ for (const [, passage] of storyData.passages) {
118
+ if (passage.tags.includes('widget')) {
119
+ let match;
120
+ while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
121
+ const name = match[1]!;
122
+ const body = match[2]!;
123
+ if (/\{@children\}/.test(body)) {
124
+ registerBlockMacro(name);
125
+ }
126
+ }
127
+ blockWidgetPattern.lastIndex = 0;
128
+ }
129
+ }
130
+
131
+ // Pass 2: Full parse and register widgets from passages tagged "widget"
112
132
  for (const [, passage] of storyData.passages) {
113
133
  if (passage.tags.includes('widget')) {
114
134
  const widgetTokens = tokenize(passage.content);
package/src/story-api.ts CHANGED
@@ -383,6 +383,12 @@ function createStoryAPI(): StoryAPI {
383
383
  };
384
384
  }
385
385
 
386
+ declare global {
387
+ interface Window {
388
+ Story: StoryAPI;
389
+ }
390
+ }
391
+
386
392
  export function installStoryAPI(): void {
387
- (window as any).Story = createStoryAPI();
393
+ window.Story = createStoryAPI();
388
394
  }