@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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6811 -0
- package/package.json +6 -1
- package/src/components/Passage.tsx +2 -0
- package/src/headless.ts +87 -0
- package/src/index.tsx +8 -7
- package/src/main.tsx +8 -0
- package/src/passage-render-state.ts +14 -0
- package/src/story-api.ts +19 -6
- package/src/types-drift-check.ts +12 -0
- package/types/headless.d.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
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
|
}
|
package/src/headless.ts
ADDED
|
@@ -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
|
-
|
|
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,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
|
-
|
|
544
|
-
requestAnimationFrame(() =>
|
|
545
|
-
|
|
546
|
-
|
|
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(
|
package/src/types-drift-check.ts
CHANGED
|
@@ -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 };
|