@rohal12/spindle 0.46.0 → 0.48.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.46.0",
3
+ "version": "0.48.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,10 +1,17 @@
1
- import { useMemo, useEffect, useState } from 'preact/hooks';
1
+ import {
2
+ useMemo,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from 'preact/hooks';
2
8
  import { tokenize } from '../markup/tokenizer';
3
9
  import { buildAST } from '../markup/ast';
4
10
  import { renderNodes, NobrContext } from '../markup/render';
5
11
  import { useStoryStore } from '../store';
6
12
  import type { Passage as PassageData } from '../parser';
7
13
  import { sourceLocationOf } from '../utils/source-location';
14
+ import { emitFromRender } from '../event-emitter';
8
15
 
9
16
  export function renderPassageContent(passage: PassageData) {
10
17
  const tokens = tokenize(passage.content);
@@ -90,10 +97,21 @@ export function Passage({ passage, dataTransition }: PassageProps) {
90
97
  }
91
98
  }, [doneReady, donePassage?.content]);
92
99
 
100
+ // Signal that this passage's DOM is committed (after descendants' layout
101
+ // effects, before paint). Runs once per mount: PassageDisplay keys the
102
+ // Passage so every navigation that shows a passage mounts a new one.
103
+ const elRef = useRef<HTMLDivElement>(null);
104
+ useLayoutEffect(() => {
105
+ if (elRef.current) {
106
+ emitFromRender('passagerender', passage.name, elRef.current);
107
+ }
108
+ }, [passage.name]);
109
+
93
110
  const nobr = passage.tags.includes('nobr');
94
111
 
95
112
  const inner = (
96
113
  <div
114
+ ref={elRef}
97
115
  class="passage"
98
116
  data-passage={passage.name}
99
117
  data-tags={passage.tags.join(' ')}
@@ -1,9 +1,10 @@
1
1
  import { createContext } from 'preact';
2
- import { useCallback, useMemo, useRef } from 'preact/hooks';
2
+ import { useCallback, useLayoutEffect, 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';
6
6
  import { useStoryStore } from '../store';
7
+ import { emitFromRender } from '../event-emitter';
7
8
 
8
9
  export const DialogCloseContext = createContext<(() => void) | null>(null);
9
10
 
@@ -59,6 +60,14 @@ export function PassageDialog({
59
60
  }
60
61
  }, [markup]);
61
62
 
63
+ // Signal that the dialog's DOM is committed (once per open).
64
+ const panelRef = useRef<HTMLDivElement>(null);
65
+ useLayoutEffect(() => {
66
+ if (panelRef.current) {
67
+ emitFromRender('dialogrender', passageName ?? '', panelRef.current);
68
+ }
69
+ }, []);
70
+
62
71
  const handleBackdrop = (e: MouseEvent) => {
63
72
  if (!dismissible) return;
64
73
  if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
@@ -74,7 +83,10 @@ export function PassageDialog({
74
83
  class="dialog-overlay"
75
84
  onClick={handleBackdrop}
76
85
  >
77
- <div class={cls}>
86
+ <div
87
+ ref={panelRef}
88
+ class={cls}
89
+ >
78
90
  {showCloseButton && (
79
91
  <button
80
92
  class="dialog-close"
@@ -14,6 +14,8 @@ type EventMap = {
14
14
  afterload: (slot: string | undefined) => void;
15
15
  beforenavigate: (passageName: string) => void;
16
16
  afternavigate: (to: string, from: string) => void;
17
+ passagerender: (passage: string, element: HTMLElement) => void;
18
+ dialogrender: (passage: string, element: HTMLElement) => void;
17
19
  };
18
20
 
19
21
  export type StoryEvent = keyof EventMap;
@@ -30,6 +32,8 @@ const VALID_EVENTS = new Set<string>([
30
32
  'afterload',
31
33
  'beforenavigate',
32
34
  'afternavigate',
35
+ 'passagerender',
36
+ 'dialogrender',
33
37
  ]);
34
38
 
35
39
  // Each event key maps to a Set of callbacks.
@@ -65,6 +69,26 @@ export function emit<E extends StoryEvent>(
65
69
  }
66
70
  }
67
71
 
72
+ /**
73
+ * Emit an event from inside a render commit (layout effect). A throwing
74
+ * handler is logged instead of propagating into Preact and breaking the
75
+ * render; the remaining handlers still run.
76
+ */
77
+ export function emitFromRender<E extends StoryEvent>(
78
+ event: E,
79
+ ...args: Parameters<EventMap[E]>
80
+ ): void {
81
+ const set = listeners.get(event);
82
+ if (!set) return;
83
+ for (const cb of [...set]) {
84
+ try {
85
+ (cb as Function)(...args);
86
+ } catch (err) {
87
+ console.error(`spindle: Error in ${event} handler:`, err);
88
+ }
89
+ }
90
+ }
91
+
68
92
  /** Test-only: clear all listeners. */
69
93
  export function resetEmitter(): void {
70
94
  listeners = new Map();
package/src/index.tsx CHANGED
@@ -3,7 +3,11 @@ import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
4
  import { useStoryStore, enterRuntimePhase } from './store';
5
5
  import { emit } from './event-emitter';
6
- import { installStoryAPI, getReadyPromise } from './story-api';
6
+ import {
7
+ installStoryAPI,
8
+ getReadyPromise,
9
+ setDeclaredVariables,
10
+ } from './story-api';
7
11
  import { resetIdCounters } from './action-registry';
8
12
  import { executeStoryInit } from './story-init';
9
13
  import { checkTriggers, reinitTriggerState } from './triggers';
@@ -118,6 +122,7 @@ function boot() {
118
122
  }
119
123
 
120
124
  defaults = extractDefaults(schema);
125
+ setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
121
126
 
122
127
  useStoryStore.getState().init(storyData, defaults, transientDefaults);
123
128
 
package/src/story-api.ts CHANGED
@@ -232,11 +232,63 @@ export interface StoryAPI {
232
232
  };
233
233
  }
234
234
 
235
+ // Names declared in StoryVariables / StoryTransients, registered at boot.
236
+ // null until a schema is registered (e.g. unit tests that init the store
237
+ // directly), in which case Story.set() does not check names.
238
+ let declaredVariables: ReadonlySet<string> | null = null;
239
+ let declaredTransients: ReadonlySet<string> | null = null;
240
+ const warnedUndeclared = new Set<string>();
241
+
242
+ /** Register the declared variable names so Story.set() can flag typos. */
243
+ export function setDeclaredVariables(
244
+ variables: Iterable<string>,
245
+ transients: Iterable<string> = [],
246
+ ): void {
247
+ declaredVariables = new Set(variables);
248
+ declaredTransients = new Set(transients);
249
+ warnedUndeclared.clear();
250
+ }
251
+
252
+ /** Test-only: forget the registered declarations. */
253
+ export function _resetDeclaredVariables(): void {
254
+ declaredVariables = null;
255
+ declaredTransients = null;
256
+ warnedUndeclared.clear();
257
+ }
258
+
259
+ /**
260
+ * Split an API variable name into namespace and key. Accepts the bare name
261
+ * (`hp`), the `$` sigil authors use in passages (`$hp`), and `%` for
262
+ * transients (`%npcs`). Dot-paths are kept in the key.
263
+ */
264
+ function parseName(name: string): {
265
+ isTransient: boolean;
266
+ key: string;
267
+ } {
268
+ if (name.startsWith('%')) return { isTransient: true, key: name.slice(1) };
269
+ if (name.startsWith('$')) return { isTransient: false, key: name.slice(1) };
270
+ return { isTransient: false, key: name };
271
+ }
272
+
273
+ function warnIfUndeclared(isTransient: boolean, key: string): void {
274
+ const declared = isTransient ? declaredTransients : declaredVariables;
275
+ if (!declared) return;
276
+ const root = key.split('.')[0]!;
277
+ if (declared.has(root)) return;
278
+ const label = (isTransient ? '%' : '$') + root;
279
+ if (warnedUndeclared.has(label)) return;
280
+ warnedUndeclared.add(label);
281
+ const where = isTransient ? 'StoryTransients' : 'StoryVariables';
282
+ console.warn(
283
+ `spindle: Story.set() wrote ${label}, which is not declared in ${where}. Passages cannot reference it; check the name or declare it.`,
284
+ );
285
+ }
286
+
235
287
  /** Set a single variable, resolving dot-paths if present. */
236
288
  function setOne(name: string, value: unknown): void {
237
- const isTransient = name.startsWith('%');
238
- const key = isTransient ? name.slice(1) : name;
289
+ const { isTransient, key } = parseName(name);
239
290
  const namespace = isTransient ? 'transient' : 'variables';
291
+ warnIfUndeclared(isTransient, key);
240
292
 
241
293
  if (key.includes('.')) {
242
294
  useStoryStore.setState((state) => {
@@ -255,8 +307,7 @@ function setOne(name: string, value: unknown): void {
255
307
  function createStoryAPI(): StoryAPI {
256
308
  return {
257
309
  get(name: string): unknown {
258
- const isTransient = name.startsWith('%');
259
- const key = isTransient ? name.slice(1) : name;
310
+ const { isTransient, key } = parseName(name);
260
311
  const store = isTransient
261
312
  ? useStoryStore.getState().transient
262
313
  : useStoryStore.getState().variables;
package/types/index.d.ts CHANGED
@@ -138,6 +138,18 @@ export interface StoryEventMap {
138
138
  afterload: (slot: string | undefined) => void;
139
139
  beforenavigate: (passageName: string) => void;
140
140
  afternavigate: (to: string, from: string) => void;
141
+ /**
142
+ * A passage's `.passage` element was committed to the DOM (first render,
143
+ * navigation, back/forward, restart, load). Fires after the transition
144
+ * mounts the new passage, before paint.
145
+ */
146
+ passagerender: (passage: string, element: HTMLElement) => void;
147
+ /**
148
+ * A dialog was opened and its `.dialog-panel` element committed to the DOM.
149
+ * `passage` is the dialog's passage name (empty for built-in dialogs
150
+ * without one).
151
+ */
152
+ dialogrender: (passage: string, element: HTMLElement) => void;
141
153
  }
142
154
 
143
155
  /** Event name that can be passed to `Story.on()`. */