playable-lessons 0.6.4 → 0.8.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/README.md CHANGED
@@ -191,6 +191,20 @@ playable-lessons export -i story.ink -o dist/ -f html,json,ink
191
191
  | `html` | Self-contained player (`.html`) — works offline, share via LMS/email |
192
192
  | `json` | Compiled Ink story JSON |
193
193
  | `ink` | The raw Ink source (copied through) |
194
+ | `h5p` | H5P Branching Scenario package (`.h5p`) — imports into Moodle/Canvas/Lumi |
195
+ | `epub` | Playable EPUB 3 book (`.epub`) — each scene a chapter, each choice a link |
196
+ | `scorm` | SCORM 1.2 package (`.zip`) — reports completion to an LMS |
197
+
198
+ The desktop app also exports **Twee3/Twine** together with H5P, EPUB, and SCORM.
199
+ H5P, EPUB, and Twee represent a decision tree, so they require a pure-branching
200
+ story: generate with **“H5P / LMS compatible”** ticked (CLI `--style branching`).
201
+ Stateful stories are refused with a line-level explanation rather than being
202
+ silently flattened. SCORM wraps the full HTML player and works with either
203
+ story style.
204
+
205
+ All structural exports read the **compiled Ink JSON** (not a second regex parse
206
+ of the source), so choice text, tags, and divert targets match exactly what the
207
+ player executes.
194
208
 
195
209
  Run `playable-lessons --help` for all options.
196
210
 
@@ -207,6 +221,19 @@ npm run typecheck
207
221
  npm test
208
222
  ```
209
223
 
224
+ ### Updates
225
+
226
+ Packaged desktop builds check GitHub Releases shortly after launch and every six
227
+ hours, download an update in the background, and prompt to restart when it is
228
+ ready. Updates are verified against the SHA-512 in `latest*.yml`; macOS builds
229
+ are code-signed in CI, which Squirrel.Mac also verifies.
230
+
231
+ Two caveats: versions before **0.8.0** did not contain the updater, so those
232
+ users must install 0.8.0 manually once — every release after that updates
233
+ itself. Windows installers are currently unsigned, so the first install may
234
+ show a SmartScreen warning (updates still work). On Linux the app must be run
235
+ as an AppImage for AppImage updates to apply.
236
+
210
237
  ### AI providers
211
238
 
212
239
  The desktop app supports **Claude**, **OpenAI**, a local **Ollama**, and any **OpenAI-compatible endpoint** (remote Ollama, OpenRouter, LiteLLM, vLLM, …) via a base URL + bearer token. Model IDs are editable, with live model listing and a connection test. API keys are stored in your OS keychain (via Electron `safeStorage`), not in plain config.
@@ -283,6 +310,24 @@ Notes for operators:
283
310
 
284
311
  ---
285
312
 
313
+ ## Security
314
+
315
+ - **Desktop app:** sandboxed renderer with context isolation, cross-origin
316
+ navigation blocked, IPC sender + project-id validation, device permissions
317
+ denied, producer CSP, and API keys/GitHub tokens in the OS keychain.
318
+ - **Web server:** Helmet security headers, constant-time access-code check,
319
+ typed/clamped inputs, a bounded job queue (`MAX_JOBS` / `MAX_RESULT_MB`), and
320
+ per-IP rate limiting. `/api/health` is public so the UI can discover whether
321
+ a code is required.
322
+ - **Supply chain:** `npm audit --omit=dev` is clean and CI enforces it
323
+ (`.github/workflows/security.yml`), while blocking any high/critical advisory
324
+ in the build toolchain. The remaining moderate advisories are build-time-only
325
+ (`glob`/`minimatch` inside ESLint and electron-builder) and are tracked by
326
+ Dependabot. GitHub Actions and the build/packaging images are pinned, with Dependabot
327
+ and CodeQL running weekly.
328
+
329
+ ---
330
+
286
331
  ## Tech stack
287
332
 
288
333
  Electron + electron-vite · React 19 + TypeScript · Zustand · [inkjs](https://github.com/y-lohse/inkjs) (Ink compiler + runtime) · @xyflow/react (graph editor) · yargs (CLI) · Express (self-hosted server).
package/out/cli/index.js CHANGED
@@ -18,6 +18,8 @@ const aiTaskExport_js_1 = require("../shared/aiTaskExport.js");
18
18
  const caseStudyExport_js_1 = require("../shared/caseStudyExport.js");
19
19
  const planExport_js_1 = require("../shared/planExport.js");
20
20
  const h5pExporter_js_1 = require("../shared/h5pExporter.js");
21
+ const epubExporter_js_1 = require("../shared/epubExporter.js");
22
+ const scormExporter_js_1 = require("../shared/scormExporter.js");
21
23
  const version_generated_js_1 = require("../shared/version.generated.js");
22
24
  /** Pick a provider from the environment when --provider isn't given. */
23
25
  function resolveProviderFromEnv() {
@@ -140,7 +142,7 @@ async function readStdin() {
140
142
  return yargs
141
143
  .option('input', { alias: 'i', type: 'string', demandOption: true, describe: 'Path to .ink file' })
142
144
  .option('output', { alias: 'o', type: 'string', demandOption: true, describe: 'Output directory' })
143
- .option('format', { alias: 'f', type: 'string', default: 'html', describe: 'Export format: html, ink, json, h5p (h5p requires a story with no variables/conditionals)' })
145
+ .option('format', { alias: 'f', type: 'string', default: 'html', describe: 'Export format(s): html, ink, json, h5p, epub, scorm (h5p/epub require a story with no variables/conditionals; scorm works with any story)' })
144
146
  .option('title', { alias: 't', type: 'string', default: 'Story', describe: 'Story title' });
145
147
  }, async (argv) => {
146
148
  try {
@@ -151,36 +153,57 @@ async function readStdin() {
151
153
  const title = argv.title;
152
154
  const baseName = (0, path_1.basename)(argv.input, '.ink');
153
155
  for (const format of formats) {
154
- switch (format.trim()) {
155
- case 'html': {
156
- const html = await (0, storyExport_js_1.exportStandaloneHTML)(source, title);
157
- const outPath = (0, path_1.join)(outputDir, `${baseName}.html`);
158
- await (0, promises_1.writeFile)(outPath, html);
159
- console.log(`Exported HTML: ${outPath}`);
160
- break;
161
- }
162
- case 'json': {
163
- const json = await (0, storyExport_js_1.compileInk)(source);
164
- const outPath = (0, path_1.join)(outputDir, `${baseName}.ink.json`);
165
- await (0, promises_1.writeFile)(outPath, json);
166
- console.log(`Exported JSON: ${outPath}`);
167
- break;
168
- }
169
- case 'ink': {
170
- const outPath = (0, path_1.join)(outputDir, `${baseName}.ink`);
171
- await (0, promises_1.writeFile)(outPath, source);
172
- console.log(`Exported Ink: ${outPath}`);
173
- break;
174
- }
175
- case 'h5p': {
176
- const bytes = (0, h5pExporter_js_1.exportH5P)(source, title);
177
- const outPath = (0, path_1.join)(outputDir, `${baseName}.h5p`);
178
- await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
179
- console.log(`Exported H5P: ${outPath}`);
180
- break;
156
+ try {
157
+ switch (format.trim()) {
158
+ case 'html': {
159
+ const html = await (0, storyExport_js_1.exportStandaloneHTML)(source, title);
160
+ const outPath = (0, path_1.join)(outputDir, `${baseName}.html`);
161
+ await (0, promises_1.writeFile)(outPath, html);
162
+ console.log(`Exported HTML: ${outPath}`);
163
+ break;
164
+ }
165
+ case 'json': {
166
+ const json = await (0, storyExport_js_1.compileInk)(source);
167
+ const outPath = (0, path_1.join)(outputDir, `${baseName}.ink.json`);
168
+ await (0, promises_1.writeFile)(outPath, json);
169
+ console.log(`Exported JSON: ${outPath}`);
170
+ break;
171
+ }
172
+ case 'ink': {
173
+ const outPath = (0, path_1.join)(outputDir, `${baseName}.ink`);
174
+ await (0, promises_1.writeFile)(outPath, source);
175
+ console.log(`Exported Ink: ${outPath}`);
176
+ break;
177
+ }
178
+ case 'h5p': {
179
+ const bytes = await (0, h5pExporter_js_1.exportH5P)(source, title);
180
+ const outPath = (0, path_1.join)(outputDir, `${baseName}.h5p`);
181
+ await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
182
+ console.log(`Exported H5P: ${outPath}`);
183
+ break;
184
+ }
185
+ case 'epub': {
186
+ const bytes = await (0, epubExporter_js_1.exportEpub)(source, title);
187
+ const outPath = (0, path_1.join)(outputDir, `${baseName}.epub`);
188
+ await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
189
+ console.log(`Exported EPUB: ${outPath}`);
190
+ break;
191
+ }
192
+ case 'scorm': {
193
+ const bytes = await (0, scormExporter_js_1.exportScorm)(source, title);
194
+ const outPath = (0, path_1.join)(outputDir, `${baseName}-scorm.zip`);
195
+ await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
196
+ console.log(`Exported SCORM: ${outPath}`);
197
+ break;
198
+ }
199
+ default:
200
+ console.warn(`Unknown format: ${format}`);
181
201
  }
182
- default:
183
- console.warn(`Unknown format: ${format}`);
202
+ }
203
+ catch (err) {
204
+ // One impossible format (e.g. epub for a stateful story) should not
205
+ // abort the formats that can be produced.
206
+ console.warn(`Skipped ${format}: ${err instanceof Error ? err.message : err}`);
184
207
  }
185
208
  }
186
209
  }
@@ -213,7 +236,7 @@ async function readStdin() {
213
236
  .option('protagonist', { type: 'string', default: 'the reader', describe: 'Protagonist type' })
214
237
  .option('answers', { type: 'string', describe: 'Optional answers to clarifying questions' })
215
238
  .option('retries', { type: 'number', default: 3, describe: 'Max compile attempts, with AI-assisted fixes between them (raise for weaker local models)' })
216
- .option('format', { alias: 'f', type: 'string', describe: 'Output formats. story: ink|html|json|h5p (default ink,html; h5p requires --style branching). flashcards: csv|html|anki|json (default csv,html). quiz: html|txt|json (default html,json). summary: html|txt|json (default html,txt). ai-task: html|txt|json (default html,txt). case-study: html|txt|json (default html,txt).' })
239
+ .option('format', { alias: 'f', type: 'string', describe: 'Output formats. story: ink|html|json|h5p|epub|scorm (default ink,html; h5p/epub require --style branching; scorm works with any story). flashcards: csv|html|anki|json (default csv,html). quiz: html|txt|json (default html,json). summary: html|txt|json (default html,txt). ai-task: html|txt|json (default html,txt). case-study: html|txt|json (default html,txt).' })
217
240
  .option('title', { alias: 't', type: 'string', describe: 'Title for the story, flashcard deck, quiz, summary, AI-task set, or case study (defaults to the input/topic name)' });
218
241
  }, async (argv) => {
219
242
  try {
@@ -497,7 +520,7 @@ async function readStdin() {
497
520
  }
498
521
  case 'h5p': {
499
522
  try {
500
- const bytes = (0, h5pExporter_js_1.exportH5P)(result.inkSource, title);
523
+ const bytes = await (0, h5pExporter_js_1.exportH5P)(result.inkSource, title, sceneImages);
501
524
  const outPath = (0, path_1.join)(outputDir, `${base}.h5p`);
502
525
  await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
503
526
  console.log(`Wrote ${outPath}`);
@@ -507,6 +530,30 @@ async function readStdin() {
507
530
  }
508
531
  break;
509
532
  }
533
+ case 'epub': {
534
+ try {
535
+ const bytes = await (0, epubExporter_js_1.exportEpub)(result.inkSource, title, sceneImages);
536
+ const outPath = (0, path_1.join)(outputDir, `${base}.epub`);
537
+ await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
538
+ console.log(`Wrote ${outPath}`);
539
+ }
540
+ catch (err) {
541
+ console.warn(`Skipped epub: ${err instanceof Error ? err.message : err}`);
542
+ }
543
+ break;
544
+ }
545
+ case 'scorm': {
546
+ try {
547
+ const bytes = await (0, scormExporter_js_1.exportScorm)(result.inkSource, title, sceneImages);
548
+ const outPath = (0, path_1.join)(outputDir, `${base}-scorm.zip`);
549
+ await (0, promises_1.writeFile)(outPath, Buffer.from(bytes));
550
+ console.log(`Wrote ${outPath}`);
551
+ }
552
+ catch (err) {
553
+ console.warn(`Skipped scorm: ${err instanceof Error ? err.message : err}`);
554
+ }
555
+ break;
556
+ }
510
557
  default:
511
558
  console.warn(`Unknown format: ${format}`);
512
559
  }
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ /**
3
+ * EPUB 3 export: a self-contained, offline "reader" edition of a
4
+ * pure-branching story. Each knot becomes an XHTML chapter and each choice a
5
+ * hyperlink, so the book is still playable in any EPUB 3 reader.
6
+ *
7
+ * Stateful stories are refused (same gate as H5P/Twee): Ink variables and
8
+ * conditional text cannot be evaluated by an e-reader. The graph comes from
9
+ * the compiled Ink JSON.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.exportEpub = exportEpub;
13
+ exports.exportEpubFromCompiled = exportEpubFromCompiled;
14
+ const storyExport_js_1 = require("./storyExport.js");
15
+ const inkGraph_js_1 = require("./inkGraph.js");
16
+ const h5pExporter_js_1 = require("./h5pExporter.js");
17
+ const zip_js_1 = require("./zip.js");
18
+ const IMAGE_EXT = {
19
+ 'image/png': 'png',
20
+ 'image/jpeg': 'jpg',
21
+ 'image/webp': 'webp',
22
+ 'image/gif': 'gif',
23
+ 'image/svg+xml': 'svg'
24
+ };
25
+ /** Decode a `data:` URL into bytes + media type; null for anything else. */
26
+ function decodeDataUrl(value) {
27
+ const match = value.match(/^data:([^;,]+);base64,(.*)$/s);
28
+ if (!match)
29
+ return null;
30
+ return { mediaType: match[1], bytes: (0, zip_js_1.base64ToBytes)(match[2]) };
31
+ }
32
+ function imageForKnot(knot, sceneImages, index, images) {
33
+ const source = (knot.imagePrompt && sceneImages?.[knot.imagePrompt]) ||
34
+ (knot.imagePath && /^data:/.test(knot.imagePath) ? knot.imagePath : null);
35
+ if (!source)
36
+ return null;
37
+ const decoded = decodeDataUrl(source);
38
+ if (!decoded)
39
+ return null;
40
+ const ext = IMAGE_EXT[decoded.mediaType];
41
+ if (!ext)
42
+ return null;
43
+ const image = {
44
+ href: `images/scene-${index}.${ext}`,
45
+ bytes: decoded.bytes,
46
+ mediaType: decoded.mediaType,
47
+ alt: knot.imagePrompt || knot.id.replace(/_/g, ' ')
48
+ };
49
+ images.push(image);
50
+ return image;
51
+ }
52
+ function chapterXhtml(knot, image, title, isStart) {
53
+ const body = [];
54
+ if (isStart)
55
+ body.push(`<h1>${(0, storyExport_js_1.escapeHtml)(title)}</h1>`);
56
+ if (image) {
57
+ body.push(`<p class="scene"><img src="${image.href}" alt="${(0, storyExport_js_1.escapeHtml)(image.alt)}"/></p>`);
58
+ }
59
+ for (const paragraph of knot.paragraphs)
60
+ body.push(`<p>${(0, storyExport_js_1.escapeHtml)(paragraph)}</p>`);
61
+ if (knot.choices.length > 0) {
62
+ body.push('<ul class="choices">');
63
+ for (const choice of knot.choices) {
64
+ const href = choice.target ? `${choice.target}.xhtml` : 'end.xhtml';
65
+ body.push(`<li><a href="${href}">${(0, storyExport_js_1.escapeHtml)(choice.text)}</a></li>`);
66
+ }
67
+ body.push('</ul>');
68
+ }
69
+ else if (knot.nextTarget) {
70
+ body.push(`<p class="continue"><a href="${knot.nextTarget}.xhtml">Continue &#8594;</a></p>`);
71
+ }
72
+ else {
73
+ body.push('<p class="end">&#8212; End of Story &#8212;</p>');
74
+ }
75
+ return `<?xml version="1.0" encoding="utf-8"?>
76
+ <!DOCTYPE html>
77
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
78
+ <head>
79
+ <meta charset="utf-8"/>
80
+ <title>${(0, storyExport_js_1.escapeHtml)(title)}</title>
81
+ <link rel="stylesheet" href="style.css" type="text/css"/>
82
+ </head>
83
+ <body>
84
+ <section epub:type="chapter">
85
+ ${body.join('\n')}
86
+ </section>
87
+ </body>
88
+ </html>
89
+ `;
90
+ }
91
+ function endXhtml(title) {
92
+ return `<?xml version="1.0" encoding="utf-8"?>
93
+ <!DOCTYPE html>
94
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
95
+ <head>
96
+ <meta charset="utf-8"/>
97
+ <title>${(0, storyExport_js_1.escapeHtml)(title)}</title>
98
+ <link rel="stylesheet" href="style.css" type="text/css"/>
99
+ </head>
100
+ <body>
101
+ <section epub:type="chapter">
102
+ <p class="end">&#8212; End of Story &#8212;</p>
103
+ <p><a href="__START__.xhtml">Play again</a></p>
104
+ </section>
105
+ </body>
106
+ </html>
107
+ `;
108
+ }
109
+ const STYLESHEET = `body { font-family: Georgia, "Times New Roman", serif; line-height: 1.6; margin: 5%; color: #222; }
110
+ h1 { font-size: 1.6em; margin-bottom: 1em; }
111
+ p { margin: 0 0 1em; }
112
+ .scene img { max-width: 100%; height: auto; display: block; margin: 0 auto 1.2em; }
113
+ .choices { list-style: none; padding: 0; }
114
+ .choices li { margin: 0 0 0.6em; }
115
+ .choices a, .continue a { display: block; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; color: #3b2a80; }
116
+ .choices a:hover, .continue a:hover { background: #f3f0ff; }
117
+ .end { text-align: center; color: #666; font-style: italic; margin-top: 2em; }
118
+ `;
119
+ function contentOpf(title, chapters, images, startId) {
120
+ const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
121
+ const r = (Math.random() * 16) | 0;
122
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
123
+ return v.toString(16).toUpperCase();
124
+ });
125
+ const modified = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
126
+ const items = [
127
+ '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
128
+ '<item id="css" href="style.css" media-type="text/css"/>',
129
+ '<item id="end" href="end.xhtml" media-type="application/xhtml+xml"/>',
130
+ ...chapters.map((c) => `<item id="ch-${c.id}" href="${c.href}" media-type="application/xhtml+xml"/>`),
131
+ ...images.map((img, i) => `<item id="img-${i}" href="${img.href}" media-type="${img.mediaType}"/>`)
132
+ ];
133
+ const spine = [
134
+ `<itemref idref="ch-${startId}"/>`,
135
+ ...chapters
136
+ .filter((c) => c.id !== startId)
137
+ .map((c) => `<itemref idref="ch-${c.id}" linear="no"/>`),
138
+ '<itemref idref="end" linear="no"/>'
139
+ ];
140
+ return `<?xml version="1.0" encoding="utf-8"?>
141
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="en">
142
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
143
+ <dc:identifier id="bookid">urn:uuid:${uuid}</dc:identifier>
144
+ <dc:title>${(0, storyExport_js_1.escapeHtml)(title)}</dc:title>
145
+ <dc:language>en</dc:language>
146
+ <meta property="dcterms:modified">${modified}</meta>
147
+ </metadata>
148
+ <manifest>
149
+ ${items.join('\n')}
150
+ </manifest>
151
+ <spine>
152
+ ${spine.join('\n')}
153
+ </spine>
154
+ </package>
155
+ `;
156
+ }
157
+ function navXhtml(title, startId) {
158
+ return `<?xml version="1.0" encoding="utf-8"?>
159
+ <!DOCTYPE html>
160
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
161
+ <head><meta charset="utf-8"/><title>${(0, storyExport_js_1.escapeHtml)(title)}</title></head>
162
+ <body>
163
+ <nav epub:type="toc" id="toc">
164
+ <h1>${(0, storyExport_js_1.escapeHtml)(title)}</h1>
165
+ <ol><li><a href="${startId}.xhtml">Start</a></li></ol>
166
+ </nav>
167
+ <nav epub:type="landmarks" hidden="hidden">
168
+ <ol><li><a epub:type="bodymatter" href="${startId}.xhtml">Start</a></li></ol>
169
+ </nav>
170
+ </body>
171
+ </html>
172
+ `;
173
+ }
174
+ const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
175
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
176
+ <rootfiles>
177
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
178
+ </rootfiles>
179
+ </container>
180
+ `;
181
+ function buildPackage(graph, title, sceneImages) {
182
+ if (graph.knots.length === 0)
183
+ throw new Error('No knots found in the Ink source — nothing to export.');
184
+ const startId = graph.start && graph.knots.some((k) => k.id === graph.start)
185
+ ? graph.start
186
+ : graph.knots[0].id;
187
+ const knots = [
188
+ ...graph.knots.filter((k) => k.id === startId),
189
+ ...graph.knots.filter((k) => k.id !== startId)
190
+ ];
191
+ const images = [];
192
+ const entries = [
193
+ { name: 'mimetype', data: 'application/epub+zip' },
194
+ { name: 'META-INF/container.xml', data: CONTAINER_XML },
195
+ { name: 'OEBPS/style.css', data: STYLESHEET },
196
+ { name: 'OEBPS/nav.xhtml', data: navXhtml(title, startId) },
197
+ { name: 'OEBPS/end.xhtml', data: endXhtml(title).replace(/__START__/g, startId) }
198
+ ];
199
+ knots.forEach((knot, i) => {
200
+ const image = imageForKnot(knot, sceneImages, i, images);
201
+ entries.push({
202
+ name: `OEBPS/${knot.id}.xhtml`,
203
+ data: chapterXhtml(knot, image, title, knot.id === startId)
204
+ });
205
+ });
206
+ for (const image of images) {
207
+ entries.push({ name: `OEBPS/${image.href}`, data: image.bytes });
208
+ }
209
+ entries.push({
210
+ name: 'OEBPS/content.opf',
211
+ data: contentOpf(title, graph.knots.map((k) => ({ id: k.id, href: `${k.id}.xhtml` })), images, startId)
212
+ });
213
+ return (0, zip_js_1.zipStore)(entries);
214
+ }
215
+ /**
216
+ * Build an EPUB 3 package from a pure-branching story. Throws with a
217
+ * construct-by-construct explanation when the story uses state.
218
+ */
219
+ async function exportEpub(inkSource, title, sceneImages) {
220
+ const issues = (0, h5pExporter_js_1.findStateConstructs)(inkSource);
221
+ if (issues.length > 0)
222
+ throw new Error((0, h5pExporter_js_1.describeStateConstructs)(issues, 'EPUB 3 readers'));
223
+ const graph = (0, inkGraph_js_1.parseCompiledInk)(await (0, storyExport_js_1.compileInk)(inkSource));
224
+ return buildPackage(graph, title, sceneImages);
225
+ }
226
+ /** Same as `exportEpub` when the caller already has compiled JSON. */
227
+ function exportEpubFromCompiled(compiledJson, title, sceneImages) {
228
+ return buildPackage((0, inkGraph_js_1.parseCompiledInk)(compiledJson), title, sceneImages);
229
+ }