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.
@@ -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.bytesToBase64 = bytesToBase64;
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 H5P Branching Scenario cannot represent:\n${shown}${more}\n` +
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
- * The passage prose shown for a knot: lines up to the first choice, minus
66
- * structural lines (tags, diverts, stitch markers). Returned as escaped HTML
67
- * paragraphs.
68
- */
69
- function passageHtml(knot) {
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
- /** The knot's plain divert target (first `-> target` outside a choice), if any. */
84
- function plainDivertTarget(knot) {
85
- for (const raw of knot.rawContent.split('\n')) {
86
- const line = raw.trim();
87
- if (line.startsWith('*') || line.startsWith('+'))
88
- break; // choice diverts don't count
89
- const m = line.match(/^->\s*(\w+)/);
90
- if (m)
91
- return m[1];
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 Ink source.
102
- * Throws if the story uses state (variables/conditionals).
103
- *
104
- * Mapping: each knot becomes one screen. A knot with choices becomes a
105
- * Branching Question (passage prose as the question, choices as alternatives);
106
- * a knot without choices becomes a text screen whose "proceed" follows the
107
- * knot's divert. Diverts to END/DONE (or nowhere) go to the end screen (-1).
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
- const parsed = (0, inkParser_js_1.parseInkSource)(source);
115
- if (parsed.knots.length === 0) {
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 = parsed.topLevelDivert && parsed.knots.some((k) => k.id === parsed.topLevelDivert)
120
- ? parsed.topLevelDivert
121
- : parsed.knots[0].id;
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
- ...parsed.knots.filter((k) => k.id === startId),
124
- ...parsed.knots.filter((k) => k.id !== startId)
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: { contentType: 'Branching Question', license: 'U', title: knot.title }
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.title }
174
+ metadata: { contentType: 'Text', license: 'U', title: knot.id.replace(/_/g, ' ') }
166
175
  },
167
- nextContentId: resolve(plainDivertTarget(knot) ?? '')
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
- // ─── Minimal ZIP writer (store method, no compression) ───────────────────────
212
- const CRC_TABLE = (() => {
213
- const table = new Uint32Array(256);
214
- for (let n = 0; n < 256; n++) {
215
- let c = n;
216
- for (let k = 0; k < 8; k++)
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
- const content = toBranchingScenarioContent(inkSource, title);
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
- /** Base64-encode package bytes (works in both Node and the browser). */
277
- function bytesToBase64(bytes) {
278
- const B = globalThis.Buffer;
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
- return fetchAsDataUrl(`${baseUrl}/${images[0].replace(/^\/+/, '')}`, headers);
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
- /** Download an image URL and return it as a data URL (avoids expiring URLs). */
210
- async function fetchAsDataUrl(url, headers = {}) {
211
- const response = await fetchWithTimeout(url, { method: 'GET', headers }, 60_000);
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
+ }
@@ -1,12 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.projectIdFromName = projectIdFromName;
4
- /** A filesystem-safe, unique id derived from a name (+ short suffix). */
5
- function projectIdFromName(name) {
6
- const slug = name
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
+ }