@elvishscout/mdstory 0.1.4 → 0.2.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +323 -438
  3. package/README.zh-CN.md +323 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/cli/commands/build.js +33 -0
  6. package/dist/cli/commands/play.js +9 -0
  7. package/dist/cli/index.js +44 -0
  8. package/dist/cli/markdown.js +27 -0
  9. package/dist/cli/prompt.js +44 -0
  10. package/dist/core/chapter.js +31 -0
  11. package/dist/core/definitions.js +2 -0
  12. package/dist/{base → core}/index.js +2 -1
  13. package/dist/core/parser.js +228 -0
  14. package/dist/{base/chapter.js → core/render.js} +45 -63
  15. package/dist/core/scene.js +28 -0
  16. package/dist/core/schema.js +43 -0
  17. package/dist/core/story.js +247 -0
  18. package/dist/core/utils.js +66 -0
  19. package/dist/index.js +1 -7
  20. package/dist/tools/count-words.js +22 -0
  21. package/html-template/dist/index.html +73 -0
  22. package/package.json +31 -10
  23. package/types/cli/commands/build.d.ts +6 -0
  24. package/types/cli/commands/play.d.ts +4 -0
  25. package/types/cli/index.d.ts +2 -0
  26. package/types/cli/markdown.d.ts +2 -0
  27. package/types/cli/prompt.d.ts +3 -0
  28. package/types/core/chapter.d.ts +19 -0
  29. package/types/core/definitions.d.ts +83 -0
  30. package/types/{base → core}/index.d.ts +2 -1
  31. package/types/core/parser.d.ts +39 -0
  32. package/types/core/render.d.ts +46 -0
  33. package/types/core/scene.d.ts +20 -0
  34. package/types/core/schema.d.ts +92 -0
  35. package/types/core/story.d.ts +54 -0
  36. package/types/core/utils.d.ts +7 -0
  37. package/types/index.d.ts +1 -5
  38. package/types/tools/count-words.d.ts +1 -0
  39. package/dist/base/definitions.js +0 -29
  40. package/dist/base/error.js +0 -30
  41. package/dist/base/parser.js +0 -86
  42. package/dist/base/story.js +0 -82
  43. package/types/base/chapter.d.ts +0 -50
  44. package/types/base/definitions.d.ts +0 -113
  45. package/types/base/error.d.ts +0 -20
  46. package/types/base/parser.d.ts +0 -2
  47. package/types/base/story.d.ts +0 -19
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { playCommand } from "./commands/play.js";
4
+ import { buildCommand } from "./commands/build.js";
5
+ const program = new Command();
6
+ program
7
+ .name("mdstory")
8
+ .description("An interactive fiction scripting format based on Markdown and Handlebars.")
9
+ .version("0.1.4");
10
+ program
11
+ .command("play")
12
+ .description("Play a story interactively in the terminal")
13
+ .argument("<story>", "Path to the story .md file")
14
+ .option("--debug", "Enable debug output")
15
+ .action(async (storyPath, options) => {
16
+ try {
17
+ await playCommand(storyPath, { debug: options.debug ?? false });
18
+ }
19
+ catch (err) {
20
+ console.error(err instanceof Error ? err.message : err);
21
+ process.exit(1);
22
+ }
23
+ });
24
+ program
25
+ .command("build")
26
+ .description("Generate a complete HTML page from a story and open it in the browser")
27
+ .argument("<story>", "Path to the story .md file")
28
+ .option("-o, --output <path>", "Output HTML file path")
29
+ .option("--no-open", "Do not open the generated HTML in the browser")
30
+ .option("--debug", "Print debug output to the browser console")
31
+ .action(async (storyPath, options) => {
32
+ try {
33
+ await buildCommand(storyPath, {
34
+ output: options.output,
35
+ open: options.open,
36
+ debug: options.debug ?? false,
37
+ });
38
+ }
39
+ catch (err) {
40
+ console.error(err instanceof Error ? err.message : err);
41
+ process.exit(1);
42
+ }
43
+ });
44
+ program.parse();
@@ -0,0 +1,27 @@
1
+ import MarkdownIt from "markdown-it";
2
+ import pluginAttrs from "markdown-it-attrs";
3
+ import pluginTerminal from "markdown-it-terminal";
4
+ import pluginMark from "markdown-it-mark";
5
+ export function createMarkdownRenderer() {
6
+ const md = new MarkdownIt({ html: true }).use(pluginAttrs).use(pluginTerminal).use(pluginMark);
7
+ // markdown-it-terminal doesn't support mark or <u> tags
8
+ const defaultHtmlInline = md.renderer.rules.html_inline;
9
+ md.renderer.rules.html_inline = (tokens, idx, ...args) => {
10
+ const tag = tokens[idx].content;
11
+ if (tag === "<u>")
12
+ return "\x1b[4m";
13
+ if (tag === "</u>")
14
+ return "\x1b[24m";
15
+ return defaultHtmlInline(tokens, idx, ...args);
16
+ };
17
+ const defaultHeadingClose = md.renderer.rules.heading_close;
18
+ md.renderer.rules.heading_close = (...args) => {
19
+ return (defaultHeadingClose?.(...args) ?? "") + "\n";
20
+ };
21
+ md.renderer.rules.mark_open = () => "\x1b[7m";
22
+ md.renderer.rules.mark_close = () => "\x1b[27m";
23
+ // markdown-it-terminal has a bug: blockquote_open/close don't declare (tokens, idx) params
24
+ md.renderer.rules.blockquote_open = () => "";
25
+ md.renderer.rules.blockquote_close = () => "\n";
26
+ return md;
27
+ }
@@ -0,0 +1,44 @@
1
+ import inquirer from "inquirer";
2
+ export function createPrompt(md) {
3
+ return async ({ text, inputs: fields, navs }) => {
4
+ console.log(md.render(text).trim());
5
+ console.log();
6
+ let inputReplies;
7
+ let targetReplies;
8
+ try {
9
+ inputReplies = await inquirer.prompt(fields.map(({ name, type, value }) => {
10
+ if (type === "number") {
11
+ return { type: "number", name, message: name, default: Number(value) };
12
+ }
13
+ else if (type === "boolean") {
14
+ return { type: "confirm", name, message: name, default: Boolean(value) };
15
+ }
16
+ else {
17
+ return { type: "input", name, message: name, default: String(value) };
18
+ }
19
+ }));
20
+ if (navs.length) {
21
+ targetReplies = await inquirer.prompt([
22
+ {
23
+ type: "list",
24
+ name: "target",
25
+ message: "Choose target",
26
+ choices: navs.map(({ text, target }) => ({ name: text, value: target })),
27
+ },
28
+ ]);
29
+ }
30
+ else {
31
+ targetReplies = null;
32
+ }
33
+ }
34
+ catch (err) {
35
+ if (err instanceof Error && err.name === "ExitPromptError") {
36
+ process.exit(0);
37
+ }
38
+ throw err;
39
+ }
40
+ const { target } = targetReplies ?? { target: null };
41
+ const inputs = Object.fromEntries(fields.map(({ name }) => [name, inputReplies[name]]));
42
+ return { target, inputs };
43
+ };
44
+ }
@@ -0,0 +1,31 @@
1
+ import { Scene } from "./scene.js";
2
+ import { renderTemplate } from "./render.js";
3
+ import { getScriptModuleId, importScriptModule } from "./utils.js";
4
+ /** A chapter grouping scenes with shared hooks and local variables. */
5
+ export class Chapter {
6
+ constructor({ id, title, template, hooks, locals, scenes }) {
7
+ this.id = id;
8
+ this.title = title ?? "";
9
+ this.template = template ?? "";
10
+ this.hooks = hooks ?? {};
11
+ this.locals = locals ?? {};
12
+ this.scenes = scenes;
13
+ }
14
+ static async fromParsed(chapter) {
15
+ return new Chapter({
16
+ id: chapter.id,
17
+ title: chapter.title,
18
+ template: chapter.template,
19
+ scenes: await Promise.all(chapter.scenes.map((scene) => Scene.fromParsed(scene, chapter.id))),
20
+ hooks: await importScriptModule(chapter.script, getScriptModuleId(chapter.id)),
21
+ });
22
+ }
23
+ /** Renders the chapter template with the given scope and render options. */
24
+ render(scope, assets, options) {
25
+ return renderTemplate(this.template, scope, assets, options);
26
+ }
27
+ /** Get scene by scene id */
28
+ getScene(id) {
29
+ return this.scenes.find((scene) => scene.id === id) ?? null;
30
+ }
31
+ }
@@ -0,0 +1,2 @@
1
+ /** Symbol key for the implicit default chapter holding orphan scenes. */
2
+ export const DEFAULT_CHAPTER = Symbol("default");
@@ -1,5 +1,6 @@
1
1
  export * from "./definitions.js";
2
2
  export * from "./story.js";
3
3
  export * from "./parser.js";
4
+ export * from "./scene.js";
4
5
  export * from "./chapter.js";
5
- export * from "./error.js";
6
+ export * from "./render.js";
@@ -0,0 +1,228 @@
1
+ import yaml from "js-yaml";
2
+ import MarkdownIt from "markdown-it";
3
+ import pluginFrontMatter from "markdown-it-front-matter";
4
+ import pluginAttrs from "markdown-it-attrs";
5
+ import { nanoid } from "nanoid";
6
+ import { MetadataSchema, SceneHooksSchema, ChapterHooksSchema, StoryHooksSchema } from "./schema.js";
7
+ import { DEFAULT_CHAPTER } from "./definitions.js";
8
+ import { getScriptModuleId, loadSource, normalizePath, parseScript } from "./utils.js";
9
+ async function expandIncludes(source, options, stack = []) {
10
+ const lines = source.split("\n");
11
+ const expanded = [];
12
+ for (const line of lines) {
13
+ const match = /^!include\(\s*(?:"([^"]+)"|'([^']+)')\s*\)\s*$/.exec(line.trim());
14
+ if (!match) {
15
+ expanded.push(line);
16
+ continue;
17
+ }
18
+ const target = match[1] ?? match[2];
19
+ const normalizedPath = await normalizePath(target, options.base);
20
+ if (stack.includes(normalizedPath)) {
21
+ throw new Error(`Circular include detected: ${[...stack, normalizedPath].join(" -> ")}`);
22
+ }
23
+ const source = await options.resolveInclude(normalizedPath);
24
+ expanded.push(await expandIncludes(source, { ...options, base: normalizedPath }, [...stack, normalizedPath]));
25
+ }
26
+ return expanded.join("\n");
27
+ }
28
+ export async function resolveParseOptions(options) {
29
+ return {
30
+ base: options?.base ?? (await normalizePath("./")),
31
+ resolveInclude: options?.resolveInclude ?? ((path) => loadSource(path)),
32
+ };
33
+ }
34
+ /**
35
+ * Parses a Markdown-formatted story source string into a structured StoryInit.
36
+ *
37
+ * Document structure:
38
+ * - `#` (h1): Optional story title — its `<script>` is story hooks
39
+ * - `##` (h2): Chapters — with chapter hooks, contain scenes
40
+ * - `###` (h3): Scenes — with scene hooks and Handlebars templates
41
+ */
42
+ export async function parseStorySource(source, options) {
43
+ const parseOptions = await resolveParseOptions(options);
44
+ source = await expandIncludes(source, parseOptions);
45
+ source = source.replace(/\r\n?/g, "\n");
46
+ const md = new MarkdownIt({ html: true }).use(pluginAttrs).use(pluginFrontMatter, () => { });
47
+ const tokens = md.parse(source, {});
48
+ let metadata = MetadataSchema.parse({});
49
+ let stylesheet = "";
50
+ const headings = [];
51
+ const scripts = [];
52
+ const styleRanges = [];
53
+ tokens.forEach((token, i) => {
54
+ if (token.type === "front_matter" && token.meta) {
55
+ const frontMatter = MetadataSchema.parse(yaml.load(token.meta));
56
+ Object.assign(metadata, frontMatter);
57
+ }
58
+ else if (token.type === "heading_open" &&
59
+ ["h1", "h2", "h3"].includes(token.tag) &&
60
+ token.level === 0 &&
61
+ token.map) {
62
+ let id = token.attrs?.find(([key]) => key === "id")?.[1];
63
+ let title = "";
64
+ const nextToken = tokens[i + 1];
65
+ if (nextToken && nextToken.type === "inline") {
66
+ const content = nextToken.content.trim();
67
+ title = content.replace(/(\s*\{[^{}]*\})+$/, "").trim();
68
+ id || (id = title);
69
+ }
70
+ id || (id = nanoid());
71
+ if (id.includes(".")) {
72
+ throw new Error(`Chapter or scene id must not contain "." to avoid ambiguity: ${id}`);
73
+ }
74
+ {
75
+ let chapterId = null;
76
+ const chapterIdSet = new Set();
77
+ const fullSceneIdSet = new Set();
78
+ for (const heading of headings) {
79
+ if (heading.tag === "h2") {
80
+ if (chapterIdSet.has(heading.id)) {
81
+ throw new Error(`Duplicated chapter id found: ${heading.id}`);
82
+ }
83
+ chapterId = heading.id;
84
+ chapterIdSet.add(chapterId);
85
+ }
86
+ else if (heading.tag === "h3") {
87
+ const fullSceneId = `${chapterId ?? ""}.${heading.id}`;
88
+ if (fullSceneIdSet.has(fullSceneId)) {
89
+ throw new Error(`Duplicated scene id found: ${fullSceneId}`);
90
+ }
91
+ fullSceneIdSet.add(fullSceneId);
92
+ }
93
+ }
94
+ }
95
+ headings.push({ tag: token.tag, id, title, lineno: token.map[0] });
96
+ }
97
+ else if (token.type === "html_block" && token.map) {
98
+ let match;
99
+ if ((match = /^[\s]*<script>(.*)<\/script>[\s]*$/s.exec(token.content))) {
100
+ const script = match[1].trim();
101
+ if (script) {
102
+ scripts.push({ from: token.map[0], to: token.map[1], content: script });
103
+ }
104
+ }
105
+ else if ((match = /^[\s]*<style>(.*)<\/style>[\s]*$/s.exec(token.content))) {
106
+ const style = match[1].trim();
107
+ if (style) {
108
+ stylesheet += style;
109
+ styleRanges.push(token.map);
110
+ }
111
+ }
112
+ }
113
+ });
114
+ const storyHeading = headings.find((h) => h.tag === "h1");
115
+ const chapterHeadings = headings.filter((h) => h.tag === "h2");
116
+ const sceneHeadings = headings.filter((h) => h.tag === "h3");
117
+ // Collect all script ranges and style ranges into a set for filtering
118
+ const ignoredLines = new Set();
119
+ for (const [from, to] of [...scripts.map(({ from, to }) => [from, to]), ...styleRanges]) {
120
+ for (let i = from; i < to; i++)
121
+ ignoredLines.add(i);
122
+ }
123
+ const lines = source.split("\n");
124
+ const storyEnd = chapterHeadings[0]?.lineno ?? sceneHeadings[0]?.lineno ?? lines.length;
125
+ // Story template from h1 heading to the first h2 or h3 (whichever comes first)
126
+ const storyTemplateEnd = Math.min(chapterHeadings[0]?.lineno ?? Infinity, sceneHeadings[0]?.lineno ?? Infinity);
127
+ const storyTemplate = (() => {
128
+ if (!isFinite(storyTemplateEnd))
129
+ return "";
130
+ const start = storyHeading?.lineno ?? 0;
131
+ return lines
132
+ .slice(start, storyTemplateEnd)
133
+ .filter((_, i) => !ignoredLines.has(start + i))
134
+ .join("\n")
135
+ .replace(/^\n+/, "");
136
+ })();
137
+ // Orphan h3s before the first h2 get a default chapter
138
+ const firstChapterLine = chapterHeadings[0]?.lineno ?? Infinity;
139
+ const defaultScenes = sceneHeadings.filter((sh) => sh.lineno < firstChapterLine);
140
+ // Build all chapters (default first, then parsed ones)
141
+ const chapters = [];
142
+ let chapterOrder = [];
143
+ if (defaultScenes.length > 0) {
144
+ const scenes = [];
145
+ let entryScene = null;
146
+ for (let si = 0; si < defaultScenes.length; si++) {
147
+ const sh = defaultScenes[si];
148
+ const seEnd = defaultScenes[si + 1]?.lineno ?? firstChapterLine;
149
+ const scScript = getScriptInScope(scripts, sh.lineno, seEnd, `scene "${sh.id}"`);
150
+ await parseScript(scScript, SceneHooksSchema, getScriptModuleId(DEFAULT_CHAPTER, sh.id));
151
+ const templateStart = sh.title ? sh.lineno : sh.lineno + 1;
152
+ const template = lines
153
+ .slice(templateStart, seEnd)
154
+ .filter((_, i) => !ignoredLines.has(templateStart + i))
155
+ .join("\n")
156
+ .replace(/^\n+/, "");
157
+ scenes.push({ id: sh.id, title: sh.title, template, script: scScript });
158
+ if (entryScene === null) {
159
+ entryScene = sh.id;
160
+ }
161
+ }
162
+ chapters.push({
163
+ id: DEFAULT_CHAPTER,
164
+ title: "",
165
+ template: "",
166
+ script: "",
167
+ scenes,
168
+ });
169
+ }
170
+ for (let ci = 0; ci < chapterHeadings.length; ci++) {
171
+ const ch = chapterHeadings[ci];
172
+ const chEnd = chapterHeadings[ci + 1]?.lineno ?? lines.length;
173
+ const chapterScenes = sceneHeadings.filter((sh) => sh.lineno > ch.lineno && sh.lineno < chEnd);
174
+ // Chapter script ends before the first scene heading
175
+ const chScriptEnd = chapterScenes[0]?.lineno ?? chEnd;
176
+ const chTemplateStart = ch.title ? ch.lineno : ch.lineno + 1;
177
+ const chTemplate = lines
178
+ .slice(chTemplateStart, chScriptEnd)
179
+ .filter((_, i) => !ignoredLines.has(chTemplateStart + i))
180
+ .join("\n")
181
+ .replace(/^\n+/, "");
182
+ const chScript = getScriptInScope(scripts, ch.lineno, chScriptEnd, `chapter "${ch.id}"`);
183
+ await parseScript(chScript, ChapterHooksSchema, getScriptModuleId(ch.id));
184
+ const scenes = [];
185
+ let entryScene = null;
186
+ for (let si = 0; si < chapterScenes.length; si++) {
187
+ const sh = chapterScenes[si];
188
+ const seEnd = chapterScenes[si + 1]?.lineno ?? chEnd;
189
+ const scScript = getScriptInScope(scripts, sh.lineno, seEnd, `scene "${sh.id}"`);
190
+ await parseScript(scScript, SceneHooksSchema, getScriptModuleId(ch.id, sh.id));
191
+ const templateStart = sh.title ? sh.lineno : sh.lineno + 1;
192
+ const template = lines
193
+ .slice(templateStart, seEnd)
194
+ .filter((_, i) => !ignoredLines.has(templateStart + i))
195
+ .join("\n")
196
+ .replace(/^\n+/, "");
197
+ scenes.push({ id: sh.id, title: sh.title, template, script: scScript });
198
+ if (entryScene === null) {
199
+ entryScene = sh.id;
200
+ }
201
+ }
202
+ chapters.push({
203
+ id: ch.id,
204
+ title: ch.title,
205
+ template: chTemplate,
206
+ script: chScript,
207
+ scenes,
208
+ });
209
+ chapterOrder.push(ch);
210
+ }
211
+ const storyScript = getScriptInScope(scripts, storyHeading?.lineno ?? 0, storyEnd, "story");
212
+ await parseScript(storyScript, StoryHooksSchema, getScriptModuleId());
213
+ return {
214
+ metadata,
215
+ title: storyHeading?.title ?? "",
216
+ template: storyTemplate,
217
+ chapters,
218
+ stylesheet,
219
+ script: storyScript,
220
+ };
221
+ }
222
+ function getScriptInScope(scripts, from, to, scope) {
223
+ const scopedScripts = scripts.filter((script) => script.from >= from && script.to <= to);
224
+ if (scopedScripts.length > 1) {
225
+ throw new Error(`More than one script block found in ${scope}`);
226
+ }
227
+ return scopedScripts[0]?.content ?? "";
228
+ }
@@ -1,19 +1,8 @@
1
1
  import Handlebars from "handlebars";
2
2
  import MarkdownIt from "markdown-it";
3
3
  import pluginAttrs from "markdown-it-attrs";
4
- const escapeHtml = (text) => text.replace(/[<>&'"]/g, (ch) => `&#${ch.charCodeAt(0)};`);
5
- const valueType = (value) => {
6
- if (typeof value === "string") {
7
- return "string";
8
- }
9
- if (typeof value === "number" || value === null) {
10
- return "number";
11
- }
12
- if (typeof value === "boolean") {
13
- return "boolean";
14
- }
15
- return "object";
16
- };
4
+ import pluginMark from "markdown-it-mark";
5
+ import { escapeHtml } from "./utils.js";
17
6
  const createElementHtml = (tag, attrs, children) => {
18
7
  // prettier-ignore
19
8
  const voidTags = [
@@ -48,7 +37,7 @@ const createInputHtml = ({ name, type, value }) => {
48
37
  const inputAttrs = {
49
38
  name,
50
39
  type: inputType,
51
- value: inputType !== "checkbox" ? (type === "object" ? JSON.stringify(value) : String(value)) : undefined,
40
+ value: inputType !== "checkbox" ? String(value) : undefined,
52
41
  checked: inputType === "checkbox" && value ? "" : undefined,
53
42
  "aria-label": name,
54
43
  };
@@ -63,49 +52,49 @@ const createSubmitButtonHtml = ({ target, children }) => {
63
52
  return createElementHtml("button", buttonAttrs, children);
64
53
  };
65
54
  const markdownRenderer = {
55
+ html: false,
66
56
  input({ name, type }) {
67
57
  if (type === "boolean") {
68
- return `[? _${name}_]`;
58
+ return `<u>[? ${name}]</u>`;
69
59
  }
70
60
  else {
71
- return `[> _${name}_]`;
61
+ return `<u>[> ${name}]</u>`;
72
62
  }
73
63
  },
74
64
  nav({ children }) {
75
- return `[@ __${children}__]`;
65
+ return `<u>[@ ${children}]</u>`;
66
+ },
67
+ linebreak({ n }) {
68
+ return "\n".repeat(n ?? 1);
76
69
  },
77
70
  };
78
71
  const htmlRenderer = {
72
+ html: true,
79
73
  input({ type, name, value }) {
80
74
  return createInputHtml({ name, type, value });
81
75
  },
82
76
  nav({ target, children }) {
83
77
  return createSubmitButtonHtml({ target: target ?? "", children });
84
78
  },
79
+ linebreak({ n }) {
80
+ return "<br>".repeat(n ?? 1);
81
+ },
85
82
  };
86
- const useHelper = ({ inputs, sets, navs }, assets, renderer) => {
83
+ function useHelper({ inputs, navs }, assets, renderer) {
87
84
  return {
88
85
  input(type, opt) {
89
86
  for (const name in opt.hash) {
90
87
  const value = opt.hash[name];
91
88
  inputs.push({ name, type, value });
92
- const result = renderer.input ? renderer.input({ name, type, value }) : "";
89
+ const result = renderer.input?.({ name, type, value }) ?? "";
93
90
  return new Handlebars.SafeString(result);
94
91
  }
95
92
  return "";
96
93
  },
97
- set(opt) {
98
- for (const name in opt.hash) {
99
- const value = opt.hash[name];
100
- const type = valueType(value);
101
- sets.push({ name, type, value });
102
- }
103
- return "";
104
- },
105
94
  nav(target, opt) {
106
95
  const text = opt.fn(this).trim();
107
96
  navs.push({ text, target });
108
- const result = renderer.nav ? renderer.nav({ target, children: text }) : "";
97
+ const result = renderer.nav?.({ target, children: text }) ?? "";
109
98
  return new Handlebars.SafeString(result);
110
99
  },
111
100
  asset(name) {
@@ -115,43 +104,36 @@ const useHelper = ({ inputs, sets, navs }, assets, renderer) => {
115
104
  return new Handlebars.SafeString(assets[name]?.mime ?? "");
116
105
  },
117
106
  linebreak(n) {
118
- return new Handlebars.SafeString("<br>".repeat(n ?? 1));
107
+ const result = renderer.linebreak?.({ n }) ?? "";
108
+ return new Handlebars.SafeString(result);
119
109
  },
120
110
  };
121
- };
122
- export class Chapter {
123
- constructor({ id, title, template, hooks }) {
124
- this.id = id;
125
- this.title = title;
126
- this.template = template;
127
- this.hooks = hooks;
111
+ }
112
+ /**
113
+ * Compiles a Handlebars template with built-in helpers, optionally renders
114
+ * through MarkdownIt, and returns the rendered text with extracted fields.
115
+ */
116
+ export function renderTemplate(template, scope, assets, options) {
117
+ let resolvedRenderer;
118
+ if (options.renderer === "markdown") {
119
+ resolvedRenderer = markdownRenderer;
128
120
  }
129
- render(scope, assets = {}, { format, html }) {
130
- let renderer;
131
- if (format === "markdown") {
132
- renderer = markdownRenderer;
133
- }
134
- else if (format === "html") {
135
- renderer = htmlRenderer;
136
- }
137
- else {
138
- renderer = format;
139
- }
140
- const fields = {
141
- inputs: [],
142
- sets: [],
143
- navs: [],
144
- };
145
- const helpers = useHelper(fields, assets, renderer);
146
- let text;
147
- if (html) {
148
- const md = new MarkdownIt({ html: true }).use(pluginAttrs);
149
- text = Handlebars.compile(this.template)(scope, { helpers });
150
- text = md.render(text);
151
- }
152
- else {
153
- text = Handlebars.compile(this.template, { noEscape: true })(scope, { helpers });
154
- }
155
- return { text, ...fields };
121
+ else if (options.renderer === "html") {
122
+ resolvedRenderer = htmlRenderer;
123
+ }
124
+ else {
125
+ resolvedRenderer = options.renderer;
126
+ }
127
+ const fields = { inputs: [], navs: [] };
128
+ const helpers = useHelper(fields, assets, resolvedRenderer);
129
+ let text;
130
+ if (resolvedRenderer.html) {
131
+ const md = new MarkdownIt({ html: true }).use(pluginAttrs).use(pluginMark);
132
+ text = Handlebars.compile(template)(scope, { helpers });
133
+ text = md.render(text);
134
+ }
135
+ else {
136
+ text = Handlebars.compile(template, { noEscape: true })(scope, { helpers });
156
137
  }
138
+ return { text, ...fields };
157
139
  }
@@ -0,0 +1,28 @@
1
+ import { renderTemplate } from "./render.js";
2
+ import { getScriptModuleId, importScriptModule } from "./utils.js";
3
+ /** Defines a scene with a Handlebars template and lifecycle hooks. */
4
+ export class Scene {
5
+ constructor({ id, title, template, hooks }) {
6
+ this.id = id;
7
+ this.title = title ?? "";
8
+ this.template = template;
9
+ this.hooks = hooks ?? {};
10
+ }
11
+ static async fromParsed(scene, chapterId) {
12
+ return new Scene({
13
+ id: scene.id,
14
+ title: scene.title,
15
+ template: scene.template,
16
+ hooks: await importScriptModule(scene.script, getScriptModuleId(chapterId, scene.id)),
17
+ });
18
+ }
19
+ /**
20
+ * Renders the scene content using the given scope and render options.
21
+ * @param scope - Variables available to the Handlebars template.
22
+ * @param assets - Asset objects keyed by name.
23
+ * @param options - Rendering options (format, html).
24
+ */
25
+ render(scope, assets, options) {
26
+ return renderTemplate(this.template, scope, assets, options);
27
+ }
28
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ function PromiseLikeSchema(schema) {
3
+ return schema.or(schema.promise());
4
+ }
5
+ export const VariableSchema = z.any().transform((v) => v);
6
+ export const ScopeSchema = z.record(VariableSchema);
7
+ const AssetObjectSchema = z.object({
8
+ url: z.string(),
9
+ mime: z.string().optional(),
10
+ alt: z.string().optional(),
11
+ });
12
+ export const AssetSchema = z.union([
13
+ z.string().transform((url) => AssetObjectSchema.parse({ url, mime: undefined })),
14
+ AssetObjectSchema,
15
+ ]);
16
+ export const AssetsSchema = z.record(AssetSchema);
17
+ export const MetadataSchema = z.object({
18
+ title: z.string().optional(),
19
+ author: z.string().optional(),
20
+ email: z.string().optional(),
21
+ globals: ScopeSchema.optional(),
22
+ assets: AssetsSchema.optional(),
23
+ });
24
+ export const StoryHooksSchema = z
25
+ .object({
26
+ globals: z.function().returns(PromiseLikeSchema(ScopeSchema.optional())),
27
+ onStart: z.function(),
28
+ })
29
+ .partial();
30
+ export const ChapterHooksSchema = z
31
+ .object({
32
+ locals: z.function().returns(PromiseLikeSchema(ScopeSchema.optional())),
33
+ onEnter: z.function(),
34
+ onLeave: z.function(),
35
+ })
36
+ .partial();
37
+ export const SceneHooksSchema = z
38
+ .object({
39
+ view: z.function().returns(PromiseLikeSchema(ScopeSchema.optional())),
40
+ onEnter: z.function(),
41
+ onLeave: z.function(),
42
+ })
43
+ .partial();