@rohal12/spindle 0.49.1 → 0.50.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.49.1",
3
+ "version": "0.50.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -29,12 +29,17 @@
29
29
  "./tooling": {
30
30
  "types": "./types/tooling.d.ts",
31
31
  "import": "./dist/pkg/tooling.js"
32
+ },
33
+ "./headless": {
34
+ "types": "./types/headless.d.ts",
35
+ "import": "./dist/pkg/headless.js"
32
36
  }
33
37
  },
34
38
  "files": [
35
39
  "dist/pkg/index.js",
36
40
  "dist/pkg/format.js",
37
41
  "dist/pkg/tooling.js",
42
+ "dist/pkg/headless.js",
38
43
  "dist/pkg/story-variables.js",
39
44
  "dist/pkg/macro-registry.json",
40
45
  "types",
@@ -12,6 +12,7 @@ import { useStoryStore } from '../store';
12
12
  import type { Passage as PassageData } from '../parser';
13
13
  import { sourceLocationOf } from '../utils/source-location';
14
14
  import { emitFromRender } from '../event-emitter';
15
+ import { markPassageRendered } from '../passage-render-state';
15
16
 
16
17
  export function renderPassageContent(passage: PassageData) {
17
18
  const tokens = tokenize(passage.content);
@@ -102,6 +103,7 @@ export function Passage({ passage, dataTransition }: PassageProps) {
102
103
  // Passage so every navigation that shows a passage mounts a new one.
103
104
  const elRef = useRef<HTMLDivElement>(null);
104
105
  useLayoutEffect(() => {
106
+ markPassageRendered(passage.name);
105
107
  if (elRef.current) {
106
108
  emitFromRender('passagerender', passage.name, elRef.current);
107
109
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Node entry point (`@rohal12/spindle/headless`): boot a compiled story in
3
+ * the DOM the caller provides (a vitest `happy-dom`/`jsdom` environment, or
4
+ * a global registrator), so the automation API works without a browser.
5
+ *
6
+ * DOM shims don't execute the story format's `<script type="module">`, so
7
+ * loading the compiled HTML alone never starts Spindle. This entry runs the
8
+ * same boot sequence directly.
9
+ */
10
+ import { boot } from './index';
11
+ import { useStoryStore } from './store';
12
+ import type { StoryAPI } from './story-api';
13
+
14
+ export interface BootStoryOptions {
15
+ /**
16
+ * A compiled story: a full HTML file produced by Twine/twee-ts with the
17
+ * Spindle format, or any HTML containing its `<tw-storydata>` element.
18
+ */
19
+ html: string;
20
+ /**
21
+ * Keep passage transitions. Default `false`: the default transition is set
22
+ * to `none` before boot, because headless runs have nothing to animate and
23
+ * `fade-through` delays mounting the next passage (and its actions) by its
24
+ * duration. Passage `[transition:…]` tags and the story's own
25
+ * `Story.setTransition()` calls still apply.
26
+ */
27
+ transitions?: boolean;
28
+ }
29
+
30
+ let booted = false;
31
+
32
+ /**
33
+ * Boot the story in the current `document` and resolve with the `Story` API
34
+ * once the first passage is shown (`:storyready`; after `Story.ready()` if
35
+ * the story defers rendering).
36
+ *
37
+ * The document body is replaced by the story's `<tw-storydata>` element and a
38
+ * `<div id="root">`. Spindle keeps its state in module scope, so a module
39
+ * instance can boot one story; use a separate test file (vitest isolates
40
+ * modules per file) for each fresh story.
41
+ */
42
+ export function bootStory(options: BootStoryOptions): Promise<StoryAPI> {
43
+ if (typeof document === 'undefined') {
44
+ return Promise.reject(
45
+ new Error(
46
+ 'spindle: bootStory() needs a DOM. Run it in a happy-dom or jsdom environment.',
47
+ ),
48
+ );
49
+ }
50
+ if (booted) {
51
+ return Promise.reject(
52
+ new Error(
53
+ 'spindle: bootStory() was already called in this module instance; boot each story in its own test file.',
54
+ ),
55
+ );
56
+ }
57
+
58
+ const parsed = new DOMParser().parseFromString(options.html, 'text/html');
59
+ const storyData = parsed.querySelector('tw-storydata');
60
+ if (!storyData) {
61
+ return Promise.reject(
62
+ new Error('spindle: bootStory() found no <tw-storydata> in the HTML.'),
63
+ );
64
+ }
65
+ booted = true;
66
+
67
+ if (!options.transitions) {
68
+ useStoryStore.getState().setTransition({ type: 'none' });
69
+ }
70
+
71
+ const root = document.createElement('div');
72
+ root.id = 'root';
73
+ document.body.replaceChildren(document.importNode(storyData, true), root);
74
+
75
+ return new Promise<StoryAPI>((resolve, reject) => {
76
+ document.addEventListener(':storyready', () => resolve(window.Story), {
77
+ once: true,
78
+ });
79
+ try {
80
+ boot();
81
+ } catch (err) {
82
+ reject(err);
83
+ }
84
+ });
85
+ }
86
+
87
+ export type { StoryAPI };
package/src/index.tsx CHANGED
@@ -45,7 +45,14 @@ function renderErrors(root: HTMLElement, errors: string[]) {
45
45
  root.appendChild(container);
46
46
  }
47
47
 
48
- function boot() {
48
+ /**
49
+ * Boot Spindle in the current document: parse `<tw-storydata>`, install the
50
+ * `Story` API, run author JavaScript, validate `StoryVariables`, run
51
+ * `StoryInit`, and render into `#root`. Dispatches `:storyready` when the
52
+ * first passage is shown. Call once per page (module state is global).
53
+ * The browser story format calls it from `main.tsx`.
54
+ */
55
+ export function boot() {
49
56
  const storyData = parseStoryData();
50
57
 
51
58
  // Inject built-in styles with an id so they can be disabled at runtime
@@ -239,9 +246,3 @@ function boot() {
239
246
  document.dispatchEvent(new CustomEvent(':storyready'));
240
247
  }
241
248
  }
242
-
243
- if (document.readyState === 'loading') {
244
- document.addEventListener('DOMContentLoaded', boot);
245
- } else {
246
- boot();
247
- }
package/src/main.tsx ADDED
@@ -0,0 +1,8 @@
1
+ // Browser entry point of the story format: boot once the DOM is ready.
2
+ import { boot } from './index';
3
+
4
+ if (document.readyState === 'loading') {
5
+ document.addEventListener('DOMContentLoaded', boot);
6
+ } else {
7
+ boot();
8
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Name of the passage most recently mounted by <Passage> (null until the
3
+ * first render, e.g. when the story interface has no {passage}).
4
+ * Lets Story.waitForActions() tell whether the current passage is on screen.
5
+ */
6
+ let renderedPassage: string | null = null;
7
+
8
+ export function markPassageRendered(name: string): void {
9
+ renderedPassage = name;
10
+ }
11
+
12
+ export function getRenderedPassage(): string | null {
13
+ return renderedPassage;
14
+ }
package/src/story-api.ts CHANGED
@@ -29,6 +29,7 @@ import type { MacroDefinition } from './define-macro';
29
29
  import { getMacroRegistry as _getMacroRegistry } from './registry';
30
30
  import type { MacroMetadata } from './registry';
31
31
  import { getActions, getAction, type StoryAction } from './action-registry';
32
+ import { getRenderedPassage } from './passage-render-state';
32
33
  import {
33
34
  initPRNG,
34
35
  isPRNGEnabled,
@@ -539,14 +540,26 @@ function createStoryAPI(): StoryAPI {
539
540
  return unsub;
540
541
  },
541
542
 
542
- waitForActions(): Promise<StoryAction[]> {
543
- return new Promise((resolve) => {
544
- requestAnimationFrame(() => {
545
- requestAnimationFrame(() => {
546
- resolve(getActions());
543
+ async waitForActions(): Promise<StoryAction[]> {
544
+ await new Promise<void>((resolve) => {
545
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
546
+ });
547
+ // A navigation can still be rendering: the passage display mounts the
548
+ // new passage from an effect, after a fade-through's outgoing phase.
549
+ // Its actions exist only once it is mounted, so wait for that.
550
+ const { currentPassage, renderDeferred } = useStoryStore.getState();
551
+ const rendered = getRenderedPassage();
552
+ if (rendered !== null && rendered !== currentPassage && !renderDeferred) {
553
+ await new Promise<void>((resolve) => {
554
+ const off = emitterOn('passagerender', (name) => {
555
+ if (name === useStoryStore.getState().currentPassage) {
556
+ off();
557
+ resolve();
558
+ }
547
559
  });
548
560
  });
549
- });
561
+ }
562
+ return getActions();
550
563
  },
551
564
 
552
565
  watch(
@@ -91,3 +91,15 @@ const _exampleMacro: PublishedMacroDefinition = {
91
91
  );
92
92
  },
93
93
  };
94
+
95
+ // Headless entry point (`@rohal12/spindle/headless`): types/headless.d.ts must
96
+ // match src/headless.ts.
97
+ import type { bootStory as SourceBootStory } from './headless';
98
+ import type { bootStory as PublishedBootStory } from '../types/headless';
99
+
100
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
101
+ const _bootSourceToPublished: typeof PublishedBootStory =
102
+ {} as typeof SourceBootStory;
103
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
104
+ const _bootPublishedToSource: typeof SourceBootStory =
105
+ {} as typeof PublishedBootStory;
@@ -0,0 +1,27 @@
1
+ import type { StoryAPI } from './index';
2
+
3
+ /** Options for {@link bootStory}. */
4
+ export interface BootStoryOptions {
5
+ /**
6
+ * A compiled story: a full HTML file produced by Twine/twee-ts with the
7
+ * Spindle format, or any HTML containing its `<tw-storydata>` element.
8
+ */
9
+ html: string;
10
+ /**
11
+ * Keep passage transitions. Default `false`: the default transition is set
12
+ * to `none` before boot, because headless runs have nothing to animate and
13
+ * `fade-through` delays mounting the next passage (and its actions) by its
14
+ * duration. Passage `[transition:…]` tags and the story's own
15
+ * `Story.setTransition()` calls still apply.
16
+ */
17
+ transitions?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Boot a compiled story in the current `document` (provided by happy-dom,
22
+ * jsdom, ...) and resolve with the `Story` API once the first passage is
23
+ * shown. One story per module instance: boot each story in its own test file.
24
+ */
25
+ export function bootStory(options: BootStoryOptions): Promise<StoryAPI>;
26
+
27
+ export type { StoryAPI };