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 +45 -0
- package/out/cli/index.js +79 -32
- package/out/shared/epubExporter.js +229 -0
- package/out/shared/h5pExporter.js +72 -121
- package/out/shared/imageClient.js +36 -5
- package/out/shared/inkGraph.js +188 -0
- package/out/shared/project.js +4 -10
- package/out/shared/projectId.js +24 -0
- package/out/shared/scormExporter.js +114 -0
- package/out/shared/storyExport.js +11 -9
- package/out/shared/version.generated.js +1 -2
- package/out/shared/zip.js +86 -0
- package/package.json +11 -8
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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 →</a></p>`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
body.push('<p class="end">— End of Story —</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">— End of Story —</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
|
+
}
|