@elvishscout/mdstory 0.1.4 → 0.2.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/LICENSE +21 -21
- package/README.md +323 -438
- package/README.zh-CN.md +323 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli/commands/build.js +33 -0
- package/dist/cli/commands/play.js +9 -0
- package/dist/cli/index.js +44 -0
- package/dist/cli/markdown.js +27 -0
- package/dist/cli/prompt.js +44 -0
- package/dist/core/chapter.js +31 -0
- package/dist/core/definitions.js +2 -0
- package/dist/{base → core}/index.js +2 -1
- package/dist/core/parser.js +228 -0
- package/dist/{base/chapter.js → core/render.js} +45 -63
- package/dist/core/scene.js +28 -0
- package/dist/core/schema.js +43 -0
- package/dist/core/story.js +247 -0
- package/dist/core/utils.js +66 -0
- package/dist/index.js +1 -7
- package/dist/tools/count-words.js +22 -0
- package/html-template/dist/index.html +73 -0
- package/package.json +30 -10
- package/types/cli/commands/build.d.ts +6 -0
- package/types/cli/commands/play.d.ts +4 -0
- package/types/cli/index.d.ts +2 -0
- package/types/cli/markdown.d.ts +2 -0
- package/types/cli/prompt.d.ts +3 -0
- package/types/core/chapter.d.ts +19 -0
- package/types/core/definitions.d.ts +83 -0
- package/types/{base → core}/index.d.ts +2 -1
- package/types/core/parser.d.ts +39 -0
- package/types/core/render.d.ts +46 -0
- package/types/core/scene.d.ts +20 -0
- package/types/core/schema.d.ts +92 -0
- package/types/core/story.d.ts +54 -0
- package/types/core/utils.d.ts +7 -0
- package/types/index.d.ts +1 -5
- package/types/tools/count-words.d.ts +1 -0
- package/dist/base/definitions.js +0 -29
- package/dist/base/error.js +0 -30
- package/dist/base/parser.js +0 -86
- package/dist/base/story.js +0 -82
- package/types/base/chapter.d.ts +0 -50
- package/types/base/definitions.d.ts +0 -113
- package/types/base/error.d.ts +0 -20
- package/types/base/parser.d.ts +0 -2
- package/types/base/story.d.ts +0 -19
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Chapter } from "./chapter.js";
|
|
2
|
+
import { renderTemplate } from "./render.js";
|
|
3
|
+
import { parseStorySource, resolveParseOptions } from "./parser.js";
|
|
4
|
+
import { getScriptModuleId, importScriptModule, normalizePath } from "./utils.js";
|
|
5
|
+
function parstInput(type, text) {
|
|
6
|
+
switch (type) {
|
|
7
|
+
case "boolean": {
|
|
8
|
+
return text === "on";
|
|
9
|
+
}
|
|
10
|
+
case "number": {
|
|
11
|
+
return text ? Number(text) : null;
|
|
12
|
+
}
|
|
13
|
+
default: {
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function parseFormData(formData, { inputs }) {
|
|
19
|
+
const target = formData.get("@target") || null;
|
|
20
|
+
const parsedInputs = Object.fromEntries(inputs.map(({ name, type }) => {
|
|
21
|
+
const value = formData.get(name);
|
|
22
|
+
try {
|
|
23
|
+
return [name, parstInput(type, value)];
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`Invalid input from FormData: ${name}, ${value}`);
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
return { target, inputs: parsedInputs };
|
|
30
|
+
}
|
|
31
|
+
function applyInputScopes(targets, inputs) {
|
|
32
|
+
for (const [name, value] of Object.entries(inputs)) {
|
|
33
|
+
if (name.startsWith("$")) {
|
|
34
|
+
targets.globals[name.slice(1)] = value;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
targets.locals[name] = value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Story runtime containing core playback logic.
|
|
43
|
+
* Construct via `fromSource(source)`, `fromPath(path)`,
|
|
44
|
+
* `fromParsed(parsedStory)`, or manually with a parsed StoryInit.
|
|
45
|
+
*/
|
|
46
|
+
export class Story {
|
|
47
|
+
constructor(init) {
|
|
48
|
+
/** @internal */ this._storyTitleShown = false;
|
|
49
|
+
this.title = (init.title || init.metadata?.title) ?? "";
|
|
50
|
+
this.template = init.template ?? "";
|
|
51
|
+
this.chapters = init.chapters;
|
|
52
|
+
this.metadata = init.metadata ?? {};
|
|
53
|
+
this.globals = this.metadata.globals ?? {};
|
|
54
|
+
this.assets = this.metadata.assets ?? {};
|
|
55
|
+
this.hooks = init.hooks ?? {};
|
|
56
|
+
this.stylesheet = init.stylesheet ?? "";
|
|
57
|
+
this.debug = init.debug ?? false;
|
|
58
|
+
}
|
|
59
|
+
/** Get chapter by chapter id */
|
|
60
|
+
getChapter(id) {
|
|
61
|
+
return this.chapters.find((chapter) => chapter.id === id) ?? null;
|
|
62
|
+
}
|
|
63
|
+
resolveTarget(target, currentChapter) {
|
|
64
|
+
const dot = target.indexOf(".");
|
|
65
|
+
if (dot !== -1) {
|
|
66
|
+
// Cross-chapter scene: "chapterId.sceneId"
|
|
67
|
+
const chapterId = target.slice(0, dot);
|
|
68
|
+
const sceneId = target.slice(dot + 1);
|
|
69
|
+
const chapter = this.getChapter(chapterId);
|
|
70
|
+
if (chapter) {
|
|
71
|
+
const scene = chapter.getScene(sceneId);
|
|
72
|
+
if (scene) {
|
|
73
|
+
return { chapter, scene };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// Local scene in current chapter
|
|
79
|
+
{
|
|
80
|
+
const chapter = currentChapter.getScene(target);
|
|
81
|
+
if (chapter) {
|
|
82
|
+
return { chapter: currentChapter, scene: chapter };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Global scene lookup across all chapters
|
|
86
|
+
for (const chapter of this.chapters) {
|
|
87
|
+
const scene = chapter.getScene(target);
|
|
88
|
+
if (scene) {
|
|
89
|
+
return { chapter: chapter, scene: scene };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Chapter id → entry scene
|
|
93
|
+
{
|
|
94
|
+
const chapter = this.getChapter(target);
|
|
95
|
+
if (chapter && chapter.scenes.length > 0) {
|
|
96
|
+
return { chapter, scene: chapter.scenes[0] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/** Renders the story template with the given scope and render options. */
|
|
102
|
+
render(scope, assets, options) {
|
|
103
|
+
return renderTemplate(this.template, scope, assets, options);
|
|
104
|
+
}
|
|
105
|
+
async enterChapter(chapter) {
|
|
106
|
+
chapter.locals = {};
|
|
107
|
+
if (chapter.hooks.locals) {
|
|
108
|
+
const result = await chapter.hooks.locals({ globals: this.globals });
|
|
109
|
+
if (result) {
|
|
110
|
+
Object.assign(chapter.locals, result);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (chapter.hooks.onEnter) {
|
|
114
|
+
await chapter.hooks.onEnter({ globals: this.globals, locals: chapter.locals });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async leaveChapter(chapter, target) {
|
|
118
|
+
if (chapter.hooks.onLeave) {
|
|
119
|
+
await chapter.hooks.onLeave({
|
|
120
|
+
globals: this.globals,
|
|
121
|
+
locals: chapter.locals,
|
|
122
|
+
target,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Starts playing the story, looping through chapters and scenes until navigation ends. */
|
|
127
|
+
async play(prompt, options) {
|
|
128
|
+
const debug = options.debug ?? this.debug;
|
|
129
|
+
if (this.hooks.globals) {
|
|
130
|
+
const result = await this.hooks.globals();
|
|
131
|
+
if (result) {
|
|
132
|
+
Object.assign(this.globals, result);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (this.hooks.onStart) {
|
|
136
|
+
await this.hooks.onStart({ globals: this.globals });
|
|
137
|
+
}
|
|
138
|
+
const assetUrlMap = Object.fromEntries(Object.entries(this.assets).map(([name, { url }]) => [name, url]));
|
|
139
|
+
const entryChapter = this.chapters[0];
|
|
140
|
+
const entryScene = entryChapter?.scenes[0];
|
|
141
|
+
if (!entryChapter || !entryScene) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let chapter = entryChapter;
|
|
145
|
+
let scene = entryScene;
|
|
146
|
+
let currentChapterId = null;
|
|
147
|
+
await this.enterChapter(chapter);
|
|
148
|
+
while (true) {
|
|
149
|
+
// Scene onEnter + render-only view data
|
|
150
|
+
if (scene.hooks.onEnter) {
|
|
151
|
+
await scene.hooks.onEnter({ globals: this.globals, locals: chapter.locals });
|
|
152
|
+
}
|
|
153
|
+
let sceneOverrides = {};
|
|
154
|
+
if (scene.hooks.view) {
|
|
155
|
+
const result = await scene.hooks.view({
|
|
156
|
+
globals: this.globals,
|
|
157
|
+
locals: chapter.locals,
|
|
158
|
+
});
|
|
159
|
+
if (result) {
|
|
160
|
+
Object.assign(sceneOverrides, result);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (debug) {
|
|
164
|
+
console.log("--- [debug] scene:", scene.id);
|
|
165
|
+
console.log("--- [debug] globals:", JSON.stringify(this.globals, null, 2));
|
|
166
|
+
console.log("--- [debug] locals:", JSON.stringify(chapter.locals, null, 2));
|
|
167
|
+
}
|
|
168
|
+
const renderContext = { ...this.globals, ...assetUrlMap, ...chapter.locals, ...sceneOverrides };
|
|
169
|
+
const renderResult = scene.render(renderContext, this.assets, options);
|
|
170
|
+
// Prepend chapter and story templates to rendered text
|
|
171
|
+
let { text } = renderResult;
|
|
172
|
+
let prefix = "";
|
|
173
|
+
if (!this._storyTitleShown) {
|
|
174
|
+
const rendered = this.render(renderContext, this.assets, options);
|
|
175
|
+
if (rendered.text) {
|
|
176
|
+
prefix += rendered.text;
|
|
177
|
+
}
|
|
178
|
+
this._storyTitleShown = true;
|
|
179
|
+
}
|
|
180
|
+
if (chapter.id !== currentChapterId) {
|
|
181
|
+
const rendered = chapter.render(renderContext, this.assets, options);
|
|
182
|
+
if (rendered.text) {
|
|
183
|
+
prefix += rendered.text;
|
|
184
|
+
}
|
|
185
|
+
currentChapterId = chapter.id;
|
|
186
|
+
}
|
|
187
|
+
text = prefix + text;
|
|
188
|
+
const promptResult = await prompt({ scene, ...renderResult, text });
|
|
189
|
+
const normalizedPromptResult = promptResult instanceof FormData ? parseFormData(promptResult, renderResult) : promptResult;
|
|
190
|
+
applyInputScopes({
|
|
191
|
+
globals: this.globals,
|
|
192
|
+
locals: chapter.locals,
|
|
193
|
+
}, normalizedPromptResult.inputs);
|
|
194
|
+
// Scene onLeave (side effect only)
|
|
195
|
+
if (scene.hooks.onLeave) {
|
|
196
|
+
await scene.hooks.onLeave({
|
|
197
|
+
globals: this.globals,
|
|
198
|
+
locals: chapter.locals,
|
|
199
|
+
target: normalizedPromptResult.target,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const finalTarget = normalizedPromptResult.target;
|
|
203
|
+
if (finalTarget === null) {
|
|
204
|
+
await this.leaveChapter(chapter, finalTarget);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
// Resolve target
|
|
208
|
+
const resolved = this.resolveTarget(finalTarget, chapter);
|
|
209
|
+
if (!resolved) {
|
|
210
|
+
throw new Error(`Target not found: ${finalTarget}`);
|
|
211
|
+
}
|
|
212
|
+
// Chapter transition
|
|
213
|
+
if (resolved.chapter !== chapter) {
|
|
214
|
+
await this.leaveChapter(chapter, finalTarget);
|
|
215
|
+
await this.enterChapter(resolved.chapter);
|
|
216
|
+
}
|
|
217
|
+
chapter = resolved.chapter;
|
|
218
|
+
scene = resolved.scene;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Creates a Story instance from a parsed story object. */
|
|
223
|
+
export async function fromParsed(story) {
|
|
224
|
+
return new Story({
|
|
225
|
+
metadata: story.metadata,
|
|
226
|
+
title: story.title,
|
|
227
|
+
template: story.template,
|
|
228
|
+
chapters: await Promise.all(story.chapters.map((chapter) => Chapter.fromParsed(chapter))),
|
|
229
|
+
stylesheet: story.stylesheet,
|
|
230
|
+
hooks: await importScriptModule(story.script, getScriptModuleId()),
|
|
231
|
+
debug: story.debug,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/** Parses a story source string and creates a Story instance. */
|
|
235
|
+
export async function fromSource(source, options) {
|
|
236
|
+
const parseOptions = await resolveParseOptions(options);
|
|
237
|
+
const parsedStory = await parseStorySource(source, parseOptions);
|
|
238
|
+
return fromParsed(parsedStory);
|
|
239
|
+
}
|
|
240
|
+
/** Loads a story from a path or URL and resolves includes relative to each containing resource. */
|
|
241
|
+
export async function fromPath(path, options) {
|
|
242
|
+
const normalizedPath = await normalizePath(path, options?.base);
|
|
243
|
+
const parseOptions = await resolveParseOptions({ ...options, base: normalizedPath });
|
|
244
|
+
const source = await parseOptions.resolveInclude(normalizedPath);
|
|
245
|
+
const parsedStory = await parseStorySource(source, parseOptions);
|
|
246
|
+
return fromParsed(parsedStory);
|
|
247
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DEFAULT_CHAPTER } from "./definitions.js";
|
|
2
|
+
function dynamicImport(specifier) {
|
|
3
|
+
return import(/* @vite-ignore */ specifier);
|
|
4
|
+
}
|
|
5
|
+
function isBrowser() {
|
|
6
|
+
return typeof window !== "undefined" && typeof window.location?.href === "string";
|
|
7
|
+
}
|
|
8
|
+
async function nodePath() {
|
|
9
|
+
return isBrowser() ? null : dynamicImport("node:path");
|
|
10
|
+
}
|
|
11
|
+
async function nodeFs() {
|
|
12
|
+
return isBrowser() ? null : dynamicImport("node:fs/promises");
|
|
13
|
+
}
|
|
14
|
+
function isUrl(path) {
|
|
15
|
+
return /^https?:\/\//.test(path);
|
|
16
|
+
}
|
|
17
|
+
export function getScriptModuleId(chapterId, sceneId) {
|
|
18
|
+
let moduleId = "story";
|
|
19
|
+
if (chapterId) {
|
|
20
|
+
const chapterIdStr = chapterId === DEFAULT_CHAPTER ? "" : chapterId;
|
|
21
|
+
moduleId += `.chapter.${chapterIdStr}`;
|
|
22
|
+
}
|
|
23
|
+
if (sceneId) {
|
|
24
|
+
moduleId += `.scene.${sceneId}`;
|
|
25
|
+
}
|
|
26
|
+
return moduleId;
|
|
27
|
+
}
|
|
28
|
+
export async function importScriptModule(script, id) {
|
|
29
|
+
if (!script.trim()) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
const uint8 = new TextEncoder().encode(script);
|
|
33
|
+
const binary = String.fromCharCode(...uint8);
|
|
34
|
+
const url = "data:text/javascript;charset=utf-8;base64," + btoa(binary) + (id ? `#id_${id}` : "");
|
|
35
|
+
const module = await import(/* @vite-ignore */ url);
|
|
36
|
+
return module.default ?? {};
|
|
37
|
+
}
|
|
38
|
+
export async function parseScript(script, schema, id) {
|
|
39
|
+
return schema.parse(await importScriptModule(script, id));
|
|
40
|
+
}
|
|
41
|
+
export async function normalizePath(path, base) {
|
|
42
|
+
if (isUrl(path)) {
|
|
43
|
+
return new URL(path).toString();
|
|
44
|
+
}
|
|
45
|
+
if (base && isUrl(base)) {
|
|
46
|
+
return new URL(path, base).toString();
|
|
47
|
+
}
|
|
48
|
+
if (isBrowser()) {
|
|
49
|
+
return new URL(path, base ?? globalThis.location.href).toString();
|
|
50
|
+
}
|
|
51
|
+
const pathModule = (await nodePath());
|
|
52
|
+
if (base) {
|
|
53
|
+
const baseDir = /[/\\]$/.test(base) ? base : pathModule.dirname(base);
|
|
54
|
+
return pathModule.resolve(baseDir, path);
|
|
55
|
+
}
|
|
56
|
+
return pathModule.resolve(path);
|
|
57
|
+
}
|
|
58
|
+
export async function loadSource(normalizedPath) {
|
|
59
|
+
if (isUrl(normalizedPath)) {
|
|
60
|
+
return await (await fetch(normalizedPath)).text();
|
|
61
|
+
}
|
|
62
|
+
return (await (await nodeFs()).readFile(normalizedPath, { encoding: "utf-8" }));
|
|
63
|
+
}
|
|
64
|
+
export function escapeHtml(text) {
|
|
65
|
+
return text.replace(/[<>&'"]/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
66
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { fromPath } from "../index.js";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const { wordsCount } = require("words-count");
|
|
5
|
+
function countWords(text) {
|
|
6
|
+
const cleanText = text.replace(/\{\{\{.*?\}\}\}/g, "").replace(/\{\{.*?\}\}/g, "");
|
|
7
|
+
return wordsCount(cleanText);
|
|
8
|
+
}
|
|
9
|
+
async function main() {
|
|
10
|
+
const entryPath = process.argv[2];
|
|
11
|
+
const story = await fromPath(entryPath);
|
|
12
|
+
let count = 0;
|
|
13
|
+
count += countWords(story.title);
|
|
14
|
+
for (const chapter of Object.values(story.chapters)) {
|
|
15
|
+
count += countWords(chapter.title);
|
|
16
|
+
for (const scene of Object.values(chapter.scenes)) {
|
|
17
|
+
count += countWords(scene.template);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
console.log("Total words:", count);
|
|
21
|
+
}
|
|
22
|
+
main();
|