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
|
@@ -9,19 +9,27 @@
|
|
|
9
9
|
* a useful message) rather than silently flatten; `exportH5P` throws on
|
|
10
10
|
* stateful sources for the same reason.
|
|
11
11
|
*
|
|
12
|
+
* The graph comes from the compiled Ink JSON (`parseCompiledInk`), so choice
|
|
13
|
+
* text and divert targets match what the player actually executes.
|
|
14
|
+
*
|
|
12
15
|
* The package is "content-only": it declares its library dependencies in
|
|
13
16
|
* h5p.json but does not bundle the libraries themselves. It imports into any
|
|
14
17
|
* host that already has Branching Scenario installed (an LMS with H5P, Lumi
|
|
15
18
|
* via the H5P Hub). Targets H5P.BranchingScenario 1.x semantics.
|
|
16
19
|
*/
|
|
17
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.bytesToBase64 = void 0;
|
|
18
22
|
exports.findStateConstructs = findStateConstructs;
|
|
19
23
|
exports.describeStateConstructs = describeStateConstructs;
|
|
24
|
+
exports.toBranchingScenarioContentFromCompiled = toBranchingScenarioContentFromCompiled;
|
|
20
25
|
exports.toBranchingScenarioContent = toBranchingScenarioContent;
|
|
21
26
|
exports.exportH5P = exportH5P;
|
|
22
|
-
exports.
|
|
23
|
-
const inkParser_js_1 = require("./inkParser.js");
|
|
27
|
+
exports.exportH5PFromCompiled = exportH5PFromCompiled;
|
|
24
28
|
const storyExport_js_1 = require("./storyExport.js");
|
|
29
|
+
const inkGraph_js_1 = require("./inkGraph.js");
|
|
30
|
+
const zip_js_1 = require("./zip.js");
|
|
31
|
+
var zip_js_2 = require("./zip.js");
|
|
32
|
+
Object.defineProperty(exports, "bytesToBase64", { enumerable: true, get: function () { return zip_js_2.bytesToBase64; } });
|
|
25
33
|
/**
|
|
26
34
|
* Scan Ink source for constructs Branching Scenario cannot represent:
|
|
27
35
|
* VAR declarations, ~ assignments/expressions, and any { ... } logic
|
|
@@ -49,46 +57,38 @@ function findStateConstructs(source) {
|
|
|
49
57
|
return issues;
|
|
50
58
|
}
|
|
51
59
|
/** One-line human summary of why a story can't convert, for error messages. */
|
|
52
|
-
function describeStateConstructs(issues) {
|
|
60
|
+
function describeStateConstructs(issues, target = 'H5P Branching Scenario') {
|
|
53
61
|
const shown = issues
|
|
54
62
|
.slice(0, 5)
|
|
55
63
|
.map((c) => ` line ${c.line} (${c.kind}): ${c.snippet}`)
|
|
56
64
|
.join('\n');
|
|
57
65
|
const more = issues.length > 5 ? `\n …and ${issues.length - 5} more` : '';
|
|
58
|
-
return (`This story uses state that
|
|
66
|
+
return (`This story uses state that ${target} cannot represent:\n${shown}${more}\n` +
|
|
59
67
|
`Regenerate the story with "H5P / LMS compatible" enabled to get a convertible version.`);
|
|
60
68
|
}
|
|
61
69
|
// ─── Ink graph → Branching Scenario content ──────────────────────────────────
|
|
62
70
|
/** Divert targets that mean "the story ends here". */
|
|
63
71
|
const END_TARGETS = new Set(['END', 'DONE']);
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const paragraphs = [];
|
|
71
|
-
for (const raw of knot.rawContent.split('\n')) {
|
|
72
|
-
const line = raw.trim();
|
|
73
|
-
if (!line)
|
|
74
|
-
continue;
|
|
75
|
-
if (line.startsWith('*') || line.startsWith('+'))
|
|
76
|
-
break; // choices begin
|
|
77
|
-
if (line.startsWith('#') || line.startsWith('->') || line.startsWith('='))
|
|
78
|
-
continue;
|
|
79
|
-
paragraphs.push(`<p>${(0, storyExport_js_1.escapeHtml)(line)}</p>`);
|
|
72
|
+
/** The passage prose for a knot, as escaped HTML, plus any resolved image. */
|
|
73
|
+
function passageHtml(knot, sceneImages) {
|
|
74
|
+
const paragraphs = knot.paragraphs.map((p) => `<p>${(0, storyExport_js_1.escapeHtml)(p)}</p>`);
|
|
75
|
+
const image = resolvedImage(knot, sceneImages);
|
|
76
|
+
if (image) {
|
|
77
|
+
paragraphs.unshift(`<img src="${(0, storyExport_js_1.escapeHtml)(image.src)}" alt="${(0, storyExport_js_1.escapeHtml)(image.alt)}" style="max-width:100%">`);
|
|
80
78
|
}
|
|
81
79
|
return paragraphs.join('\n') || '<p></p>';
|
|
82
80
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Pick the image for a knot. `# IMAGE:` must already be a data URL or an
|
|
83
|
+
* absolute http(s) URL (H5P has no access to the project's filesystem);
|
|
84
|
+
* `# IMAGE_PROMPT:` resolves through the generated scene-image map.
|
|
85
|
+
*/
|
|
86
|
+
function resolvedImage(knot, sceneImages) {
|
|
87
|
+
if (knot.imagePrompt && sceneImages?.[knot.imagePrompt]) {
|
|
88
|
+
return { src: sceneImages[knot.imagePrompt], alt: knot.imagePrompt };
|
|
89
|
+
}
|
|
90
|
+
if (knot.imagePath && /^(data:|https?:)/.test(knot.imagePath)) {
|
|
91
|
+
return { src: knot.imagePath, alt: knot.id.replace(/_/g, ' ') };
|
|
92
92
|
}
|
|
93
93
|
return null;
|
|
94
94
|
}
|
|
@@ -98,30 +98,35 @@ function subContentId(n) {
|
|
|
98
98
|
return `00000000-0000-4000-8000-${hex}`;
|
|
99
99
|
}
|
|
100
100
|
/**
|
|
101
|
-
* Build the Branching Scenario content.json object from
|
|
102
|
-
* Throws if the
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
* Build the Branching Scenario content.json object from a compiled story.
|
|
102
|
+
* Throws if the source uses state (variables/conditionals).
|
|
103
|
+
*/
|
|
104
|
+
function toBranchingScenarioContentFromCompiled(compiledJson, title, sceneImages) {
|
|
105
|
+
const graph = (0, inkGraph_js_1.parseCompiledInk)(compiledJson);
|
|
106
|
+
return graphToContent(graph, title, sceneImages);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Compile, gate, and build the Branching Scenario content object.
|
|
110
|
+
* Throws with a construct-by-construct explanation if the story uses state.
|
|
108
111
|
*/
|
|
109
|
-
function toBranchingScenarioContent(source, title) {
|
|
112
|
+
async function toBranchingScenarioContent(source, title, sceneImages) {
|
|
110
113
|
const issues = findStateConstructs(source);
|
|
111
|
-
if (issues.length > 0)
|
|
114
|
+
if (issues.length > 0)
|
|
112
115
|
throw new Error(describeStateConstructs(issues));
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
const compiled = await (0, storyExport_js_1.compileInk)(source);
|
|
117
|
+
return toBranchingScenarioContentFromCompiled(compiled, title, sceneImages);
|
|
118
|
+
}
|
|
119
|
+
function graphToContent(graph, title, sceneImages) {
|
|
120
|
+
if (graph.knots.length === 0) {
|
|
116
121
|
throw new Error('No knots found in the Ink source — nothing to export.');
|
|
117
122
|
}
|
|
118
123
|
// Order knots start-first: content[0] is where Branching Scenario begins.
|
|
119
|
-
const startId =
|
|
120
|
-
?
|
|
121
|
-
:
|
|
124
|
+
const startId = graph.start && graph.knots.some((k) => k.id === graph.start)
|
|
125
|
+
? graph.start
|
|
126
|
+
: graph.knots[0].id;
|
|
122
127
|
const knots = [
|
|
123
|
-
...
|
|
124
|
-
...
|
|
128
|
+
...graph.knots.filter((k) => k.id === startId),
|
|
129
|
+
...graph.knots.filter((k) => k.id !== startId)
|
|
125
130
|
];
|
|
126
131
|
const idOf = new Map();
|
|
127
132
|
knots.forEach((k, i) => idOf.set(k.id, i));
|
|
@@ -140,7 +145,7 @@ function toBranchingScenarioContent(source, title) {
|
|
|
140
145
|
};
|
|
141
146
|
if (knot.choices.length > 0) {
|
|
142
147
|
const alternatives = knot.choices.map((c) => ({
|
|
143
|
-
text: c.text,
|
|
148
|
+
text: (0, storyExport_js_1.escapeHtml)(c.text),
|
|
144
149
|
nextContentId: resolve(c.target),
|
|
145
150
|
addFeedback: false,
|
|
146
151
|
feedback: { title: '', subtitle: '' }
|
|
@@ -149,9 +154,13 @@ function toBranchingScenarioContent(source, title) {
|
|
|
149
154
|
...common,
|
|
150
155
|
type: {
|
|
151
156
|
library: 'H5P.BranchingQuestion 1.0',
|
|
152
|
-
params: { branchingQuestion: { question: passageHtml(knot), alternatives } },
|
|
157
|
+
params: { branchingQuestion: { question: passageHtml(knot, sceneImages), alternatives } },
|
|
153
158
|
subContentId: subContentId(i),
|
|
154
|
-
metadata: {
|
|
159
|
+
metadata: {
|
|
160
|
+
contentType: 'Branching Question',
|
|
161
|
+
license: 'U',
|
|
162
|
+
title: knot.id.replace(/_/g, ' ')
|
|
163
|
+
}
|
|
155
164
|
},
|
|
156
165
|
nextContentId: -1
|
|
157
166
|
};
|
|
@@ -160,11 +169,11 @@ function toBranchingScenarioContent(source, title) {
|
|
|
160
169
|
...common,
|
|
161
170
|
type: {
|
|
162
171
|
library: 'H5P.AdvancedText 1.1',
|
|
163
|
-
params: { text: passageHtml(knot) },
|
|
172
|
+
params: { text: passageHtml(knot, sceneImages) },
|
|
164
173
|
subContentId: subContentId(i),
|
|
165
|
-
metadata: { contentType: 'Text', license: 'U', title: knot.
|
|
174
|
+
metadata: { contentType: 'Text', license: 'U', title: knot.id.replace(/_/g, ' ') }
|
|
166
175
|
},
|
|
167
|
-
nextContentId: resolve(
|
|
176
|
+
nextContentId: resolve(knot.nextTarget ?? '')
|
|
168
177
|
};
|
|
169
178
|
});
|
|
170
179
|
return {
|
|
@@ -208,79 +217,21 @@ function h5pManifest(title) {
|
|
|
208
217
|
]
|
|
209
218
|
};
|
|
210
219
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
218
|
-
table[n] = c >>> 0;
|
|
219
|
-
}
|
|
220
|
-
return table;
|
|
221
|
-
})();
|
|
222
|
-
function crc32(data) {
|
|
223
|
-
let crc = 0xffffffff;
|
|
224
|
-
for (let i = 0; i < data.length; i++) {
|
|
225
|
-
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
|
226
|
-
}
|
|
227
|
-
return (crc ^ 0xffffffff) >>> 0;
|
|
228
|
-
}
|
|
229
|
-
// Fixed DOS timestamp (2026-01-01 00:00) keeps output byte-identical across runs.
|
|
230
|
-
const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
|
|
231
|
-
const DOS_TIME = 0;
|
|
232
|
-
/** Assemble a store-only (uncompressed) zip from named UTF-8 entries. */
|
|
233
|
-
function zipStore(entries) {
|
|
234
|
-
const encoder = new TextEncoder();
|
|
235
|
-
const chunks = [];
|
|
236
|
-
const central = [];
|
|
237
|
-
let offset = 0;
|
|
238
|
-
const u16 = (v) => new Uint8Array([v & 0xff, (v >> 8) & 0xff]);
|
|
239
|
-
const u32 = (v) => new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]);
|
|
240
|
-
const cat = (...parts) => {
|
|
241
|
-
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
242
|
-
const out = new Uint8Array(total);
|
|
243
|
-
let pos = 0;
|
|
244
|
-
for (const p of parts) {
|
|
245
|
-
out.set(p, pos);
|
|
246
|
-
pos += p.length;
|
|
247
|
-
}
|
|
248
|
-
return out;
|
|
249
|
-
};
|
|
250
|
-
for (const entry of entries) {
|
|
251
|
-
const name = encoder.encode(entry.name);
|
|
252
|
-
const data = encoder.encode(entry.text);
|
|
253
|
-
const crc = crc32(data);
|
|
254
|
-
// Local file header: flag bit 11 set = UTF-8 names; method 0 = store.
|
|
255
|
-
const local = cat(u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(DOS_TIME), u16(DOS_DATE), u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), name, data);
|
|
256
|
-
central.push(cat(u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(DOS_TIME), u16(DOS_DATE), u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), name));
|
|
257
|
-
chunks.push(local);
|
|
258
|
-
offset += local.length;
|
|
259
|
-
}
|
|
260
|
-
const centralBlob = cat(...central);
|
|
261
|
-
const eocd = cat(u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length), u32(centralBlob.length), u32(offset), u16(0));
|
|
262
|
-
return cat(...chunks, centralBlob, eocd);
|
|
220
|
+
/** Assemble the .h5p zip from an already-built content object. */
|
|
221
|
+
function h5pZip(content, title) {
|
|
222
|
+
return (0, zip_js_1.zipStore)([
|
|
223
|
+
{ name: 'h5p.json', data: JSON.stringify(h5pManifest(title)) },
|
|
224
|
+
{ name: 'content/content.json', data: JSON.stringify(content) }
|
|
225
|
+
]);
|
|
263
226
|
}
|
|
264
|
-
// ─── Public entry point ──────────────────────────────────────────────────────
|
|
265
227
|
/**
|
|
266
228
|
* Convert a pure-branching Ink story to a .h5p package (zip bytes).
|
|
267
229
|
* Throws with a construct-by-construct explanation if the story uses state.
|
|
268
230
|
*/
|
|
269
|
-
function exportH5P(inkSource, title) {
|
|
270
|
-
|
|
271
|
-
return zipStore([
|
|
272
|
-
{ name: 'h5p.json', text: JSON.stringify(h5pManifest(title)) },
|
|
273
|
-
{ name: 'content/content.json', text: JSON.stringify(content) }
|
|
274
|
-
]);
|
|
231
|
+
async function exportH5P(inkSource, title, sceneImages) {
|
|
232
|
+
return h5pZip(await toBranchingScenarioContent(inkSource, title, sceneImages), title);
|
|
275
233
|
}
|
|
276
|
-
/**
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
if (B)
|
|
280
|
-
return B.from(bytes).toString('base64');
|
|
281
|
-
let binary = '';
|
|
282
|
-
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
283
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
284
|
-
}
|
|
285
|
-
return btoa(binary);
|
|
234
|
+
/** Same as `exportH5P` when the caller already has the compiled JSON. */
|
|
235
|
+
function exportH5PFromCompiled(compiledJson, title, sceneImages) {
|
|
236
|
+
return h5pZip(toBranchingScenarioContentFromCompiled(compiledJson, title, sceneImages), title);
|
|
286
237
|
}
|
|
@@ -15,6 +15,8 @@ exports.isImageProviderConfigured = isImageProviderConfigured;
|
|
|
15
15
|
exports.generateImage = generateImage;
|
|
16
16
|
exports.normalizeImageBaseUrl = normalizeImageBaseUrl;
|
|
17
17
|
const REQUEST_TIMEOUT_MS = 180_000; // image generation can be slow
|
|
18
|
+
/** Refuse to download an image larger than this (protects renderer memory). */
|
|
19
|
+
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
18
20
|
/** Short style directive prepended to every scene prompt. */
|
|
19
21
|
const STYLE_DIRECTIVES = {
|
|
20
22
|
cartoon: 'Flat cartoon illustration: bold clean outlines, vibrant colors, simple storybook style.',
|
|
@@ -171,8 +173,16 @@ async function generateSwarmUIImage(prompt, config) {
|
|
|
171
173
|
if (!Array.isArray(images) || typeof images[0] !== 'string') {
|
|
172
174
|
throw new Error('SwarmUI returned no image');
|
|
173
175
|
}
|
|
174
|
-
// 3. Fetch the generated image by its server path.
|
|
175
|
-
|
|
176
|
+
// 3. Fetch the generated image by its server path. Only send the bearer
|
|
177
|
+
// token when the URL stays on the configured SwarmUI origin.
|
|
178
|
+
let origin;
|
|
179
|
+
try {
|
|
180
|
+
origin = new URL(baseUrl).origin;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
origin = undefined;
|
|
184
|
+
}
|
|
185
|
+
return fetchAsDataUrl(`${baseUrl}/${images[0].replace(/^\/+/, '')}`, headers, origin);
|
|
176
186
|
}
|
|
177
187
|
/** Parse a '1024x576' size string into [width, height]; null if malformed. */
|
|
178
188
|
function parseSize(size) {
|
|
@@ -206,13 +216,34 @@ async function fetchWithTimeout(url, options, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
206
216
|
clearTimeout(timer);
|
|
207
217
|
}
|
|
208
218
|
}
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Download an image URL and return it as a data URL (avoids expiring URLs).
|
|
221
|
+
* Only absolute http(s) URLs are accepted, so a hostile custom provider
|
|
222
|
+
* cannot make the app read `file:`/`data:` URLs from the machine. Auth headers
|
|
223
|
+
* are only sent when the URL stays on `authOrigin`.
|
|
224
|
+
*/
|
|
225
|
+
async function fetchAsDataUrl(url, headers = {}, authOrigin) {
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = new URL(url);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
throw new Error('Image provider returned a non-absolute URL');
|
|
232
|
+
}
|
|
233
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
234
|
+
throw new Error(`Refusing to fetch an image over ${parsed.protocol}`);
|
|
235
|
+
}
|
|
236
|
+
const sendHeaders = authOrigin && parsed.origin === authOrigin ? headers : {};
|
|
237
|
+
const response = await fetchWithTimeout(url, { method: 'GET', headers: sendHeaders }, 60_000);
|
|
212
238
|
if (!response.ok)
|
|
213
239
|
throw new Error(`Could not download image (${response.status})`);
|
|
240
|
+
const declared = Number(response.headers.get('content-length') || 0);
|
|
241
|
+
if (declared > MAX_IMAGE_BYTES)
|
|
242
|
+
throw new Error('Downloaded image is too large');
|
|
214
243
|
const mime = response.headers.get('content-type')?.split(';')[0] || 'image/png';
|
|
215
244
|
const buffer = await response.arrayBuffer();
|
|
245
|
+
if (buffer.byteLength > MAX_IMAGE_BYTES)
|
|
246
|
+
throw new Error('Downloaded image is too large');
|
|
216
247
|
return `data:${mime};base64,${arrayBufferToBase64(buffer)}`;
|
|
217
248
|
}
|
|
218
249
|
/** ArrayBuffer → base64 without Node's Buffer (the renderer has no Buffer). */
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Read the story graph out of inkjs's compiled JSON instead of re-parsing the
|
|
4
|
+
* generated source with regular expressions.
|
|
5
|
+
*
|
|
6
|
+
* The compiled form is the runtime's own canonical representation, so choice
|
|
7
|
+
* text, divert targets, and tag placement are exactly what the player will
|
|
8
|
+
* see — no drifting from Ink's escaping/indentation rules.
|
|
9
|
+
*
|
|
10
|
+
* Scope: the exporters that use this (H5P, Twee, EPUB) only accept flat
|
|
11
|
+
* branching stories (see `findStateConstructs`). The reader extracts each
|
|
12
|
+
* knot's intro prose, its first-level choices, its plain divert, and its tags.
|
|
13
|
+
*
|
|
14
|
+
* Container wire format (inkjs `JsonSerialisation`):
|
|
15
|
+
* [ ...contentItems, namedContentObjectOrNull ]
|
|
16
|
+
* Content items are `"^text"`, `"\n"`, control strings (`ev`, `str`, `end`, …),
|
|
17
|
+
* objects (`{"->": target}`, `{"*": path}`, `{"#": tag}`), or nested arrays.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.parseCompiledInk = parseCompiledInk;
|
|
21
|
+
function splitContainer(arr) {
|
|
22
|
+
const content = arr.slice(0, -1);
|
|
23
|
+
const last = arr[arr.length - 1];
|
|
24
|
+
const named = last && typeof last === 'object' && !Array.isArray(last)
|
|
25
|
+
? last
|
|
26
|
+
: null;
|
|
27
|
+
return { content, named };
|
|
28
|
+
}
|
|
29
|
+
/** Resolve a relative choice-body path (`".^.c-0"`) against a container's named content. */
|
|
30
|
+
function namedTarget(named, relPath) {
|
|
31
|
+
const parts = String(relPath)
|
|
32
|
+
.split('.')
|
|
33
|
+
.filter((p) => p && p !== '^');
|
|
34
|
+
let obj = named;
|
|
35
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
36
|
+
const next = obj ? obj[parts[i]] : null;
|
|
37
|
+
if (!Array.isArray(next))
|
|
38
|
+
return null;
|
|
39
|
+
obj = splitContainer(next).named;
|
|
40
|
+
}
|
|
41
|
+
const found = parts.length > 0 && obj ? obj[parts[parts.length - 1]] : null;
|
|
42
|
+
return Array.isArray(found) ? found : null;
|
|
43
|
+
}
|
|
44
|
+
/** First divert target anywhere in a container's content (choice bodies included). */
|
|
45
|
+
function firstDivert(container) {
|
|
46
|
+
const { content } = splitContainer(container);
|
|
47
|
+
for (const item of content) {
|
|
48
|
+
if (Array.isArray(item)) {
|
|
49
|
+
const nested = firstDivert(item);
|
|
50
|
+
if (nested)
|
|
51
|
+
return nested;
|
|
52
|
+
}
|
|
53
|
+
else if (item && typeof item === 'object' && '->' in item) {
|
|
54
|
+
const target = item['->'];
|
|
55
|
+
if (typeof target === 'string')
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** First divert in a container, not descending into named content. */
|
|
62
|
+
function firstDivertInContent(items) {
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
if (Array.isArray(item)) {
|
|
65
|
+
const nested = firstDivert(item);
|
|
66
|
+
if (nested)
|
|
67
|
+
return nested;
|
|
68
|
+
}
|
|
69
|
+
else if (item && typeof item === 'object' && '->' in item) {
|
|
70
|
+
const target = item['->'];
|
|
71
|
+
if (typeof target === 'string')
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function collectKnot(id, container) {
|
|
78
|
+
const paragraphs = [];
|
|
79
|
+
const tags = [];
|
|
80
|
+
const choices = [];
|
|
81
|
+
let nextTarget = null;
|
|
82
|
+
let prose = '';
|
|
83
|
+
let choiceText = null;
|
|
84
|
+
let tagMode = false;
|
|
85
|
+
let firstChoiceSeen = false;
|
|
86
|
+
const walk = (items, namedHere) => {
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
if (typeof item === 'string') {
|
|
89
|
+
if (item === 'str') {
|
|
90
|
+
choiceText = '';
|
|
91
|
+
}
|
|
92
|
+
else if (item === '/str' || item === 'ev' || item === '/ev') {
|
|
93
|
+
// Choice text stays buffered until the choice point consumes it.
|
|
94
|
+
}
|
|
95
|
+
else if (item === '#') {
|
|
96
|
+
tagMode = true;
|
|
97
|
+
}
|
|
98
|
+
else if (item === '/#') {
|
|
99
|
+
tagMode = false;
|
|
100
|
+
}
|
|
101
|
+
else if (item === 'done' || item === 'end') {
|
|
102
|
+
// Story terminator; nothing to record.
|
|
103
|
+
}
|
|
104
|
+
else if (item.startsWith('^')) {
|
|
105
|
+
const text = item.slice(1);
|
|
106
|
+
// Tags on a choice body belong to that branch, not the knot.
|
|
107
|
+
if (tagMode) {
|
|
108
|
+
if (!firstChoiceSeen)
|
|
109
|
+
tags.push(text);
|
|
110
|
+
}
|
|
111
|
+
else if (choiceText !== null)
|
|
112
|
+
choiceText += text;
|
|
113
|
+
else if (!firstChoiceSeen)
|
|
114
|
+
prose += text;
|
|
115
|
+
}
|
|
116
|
+
else if (item === '\n') {
|
|
117
|
+
if (choiceText === null && !firstChoiceSeen)
|
|
118
|
+
prose += '\n';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (Array.isArray(item)) {
|
|
122
|
+
const inner = splitContainer(item);
|
|
123
|
+
walk(inner.content, inner.named);
|
|
124
|
+
}
|
|
125
|
+
else if (item && typeof item === 'object') {
|
|
126
|
+
const obj = item;
|
|
127
|
+
if (typeof obj['*'] === 'string') {
|
|
128
|
+
firstChoiceSeen = true;
|
|
129
|
+
const body = namedTarget(namedHere, obj['*']);
|
|
130
|
+
choices.push({ text: (choiceText ?? '').trim(), target: (body && firstDivert(body)) || '' });
|
|
131
|
+
choiceText = null;
|
|
132
|
+
}
|
|
133
|
+
else if (typeof obj['->'] === 'string') {
|
|
134
|
+
if (!firstChoiceSeen && nextTarget === null)
|
|
135
|
+
nextTarget = obj['->'];
|
|
136
|
+
}
|
|
137
|
+
else if (typeof obj['#'] === 'string') {
|
|
138
|
+
if (!firstChoiceSeen)
|
|
139
|
+
tags.push(obj['#']);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const root = splitContainer(container);
|
|
145
|
+
walk(root.content, root.named);
|
|
146
|
+
for (const line of prose.split('\n')) {
|
|
147
|
+
const trimmed = line.trim();
|
|
148
|
+
if (trimmed)
|
|
149
|
+
paragraphs.push(trimmed);
|
|
150
|
+
}
|
|
151
|
+
const tagValue = (prefix) => {
|
|
152
|
+
const hit = tags.find((t) => t.startsWith(prefix));
|
|
153
|
+
return hit ? hit.slice(prefix.length).trim() || null : null;
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
id,
|
|
157
|
+
paragraphs,
|
|
158
|
+
tags,
|
|
159
|
+
choices,
|
|
160
|
+
nextTarget,
|
|
161
|
+
imagePath: tagValue('IMAGE:'),
|
|
162
|
+
imagePrompt: tagValue('IMAGE_PROMPT:'),
|
|
163
|
+
endingType: tagValue('ENDING:')
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Parse a compiled Ink JSON string/object into the knot-level graph the
|
|
168
|
+
* exporters need. Throws if the payload is not a compiled story.
|
|
169
|
+
*/
|
|
170
|
+
function parseCompiledInk(json) {
|
|
171
|
+
const data = typeof json === 'string' ? JSON.parse(json) : json;
|
|
172
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.root)) {
|
|
173
|
+
throw new Error('Not a compiled Ink story (missing root container)');
|
|
174
|
+
}
|
|
175
|
+
const root = splitContainer(data.root);
|
|
176
|
+
const start = firstDivertInContent(root.content);
|
|
177
|
+
const knots = [];
|
|
178
|
+
for (const [id, container] of Object.entries(root.named ?? {})) {
|
|
179
|
+
// `#f` and other flags are metadata, not knots.
|
|
180
|
+
if (id.startsWith('#') || !Array.isArray(container))
|
|
181
|
+
continue;
|
|
182
|
+
knots.push(collectKnot(id, container));
|
|
183
|
+
}
|
|
184
|
+
if (knots.length === 0) {
|
|
185
|
+
throw new Error('No knots found in the compiled story');
|
|
186
|
+
}
|
|
187
|
+
return { start, knots };
|
|
188
|
+
}
|
package/out/shared/project.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.projectIdFromName =
|
|
4
|
-
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
.toLowerCase()
|
|
8
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
9
|
-
.replace(/^-+|-+$/g, '')
|
|
10
|
-
.slice(0, 50) || 'project';
|
|
11
|
-
return `${slug}-${Date.now().toString(36)}`;
|
|
12
|
-
}
|
|
3
|
+
exports.projectIdFromName = exports.isSafeProjectId = void 0;
|
|
4
|
+
var projectId_js_1 = require("./projectId.js");
|
|
5
|
+
Object.defineProperty(exports, "isSafeProjectId", { enumerable: true, get: function () { return projectId_js_1.isSafeProjectId; } });
|
|
6
|
+
Object.defineProperty(exports, "projectIdFromName", { enumerable: true, get: function () { return projectId_js_1.projectIdFromName; } });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Project-id helpers with no imports, so the Electron main process can use
|
|
4
|
+
* them without pulling the renderer/AI modules into its TypeScript project.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.isSafeProjectId = isSafeProjectId;
|
|
8
|
+
exports.projectIdFromName = projectIdFromName;
|
|
9
|
+
/**
|
|
10
|
+
* True when a string is a project id this app created. Anything else (slashes,
|
|
11
|
+
* dots, absolute paths, empty) is rejected before it reaches a filesystem path.
|
|
12
|
+
*/
|
|
13
|
+
function isSafeProjectId(id) {
|
|
14
|
+
return typeof id === 'string' && /^[a-z0-9][a-z0-9-]{0,80}$/.test(id);
|
|
15
|
+
}
|
|
16
|
+
/** A filesystem-safe, unique id derived from a name (+ short suffix). */
|
|
17
|
+
function projectIdFromName(name) {
|
|
18
|
+
const slug = name
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.slice(0, 50) || 'project';
|
|
23
|
+
return `${slug}-${Date.now().toString(36)}`;
|
|
24
|
+
}
|