@rohal12/twee-ts 1.1.1 → 1.2.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.
@@ -69,13 +69,26 @@ function tweeEscape(s) {
69
69
  if (s.length === 0) return s;
70
70
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
71
71
  }
72
+ function jsStringEscape(s) {
73
+ if (s.length === 0) return s;
74
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
75
+ }
76
+ function commentSanitize(s) {
77
+ return s.replace(/\*\//g, "* /");
78
+ }
79
+ function htmlCommentSanitize(s) {
80
+ return s.replace(/-->/g, "-- >");
81
+ }
72
82
  function tweeUnescape(s) {
73
83
  if (s.length === 0) return s;
74
84
  let result = "";
75
85
  for (let i = 0; i < s.length; i++) {
76
86
  if (s[i] === "\\") {
77
87
  i++;
78
- if (i >= s.length) break;
88
+ if (i >= s.length) {
89
+ result += "\\";
90
+ break;
91
+ }
79
92
  }
80
93
  result += s[i];
81
94
  }
@@ -165,16 +178,16 @@ function passageToPassagedata(p, pid) {
165
178
  let position;
166
179
  let size;
167
180
  if (hasMetadataPosition(p)) {
168
- position = p.metadata.position;
181
+ position = p.metadata?.position ?? "";
169
182
  } else {
170
183
  const x = pid % 10;
171
- let y = Math.floor(pid / 10);
184
+ const y = Math.floor(pid / 10);
172
185
  const xp = x === 0 ? 10 : x;
173
186
  const yp = x === 0 ? y : y + 1;
174
187
  position = `${xp * 125 - 25},${yp * 125 - 25}`;
175
188
  }
176
189
  if (hasMetadataSize(p)) {
177
- size = p.metadata.size;
190
+ size = p.metadata?.size ?? "";
178
191
  } else {
179
192
  size = "100,100";
180
193
  }
@@ -183,10 +196,10 @@ function passageToPassagedata(p, pid) {
183
196
  function passageToTiddler(p, pid) {
184
197
  let position;
185
198
  if (hasMetadataPosition(p)) {
186
- position = p.metadata.position;
199
+ position = p.metadata?.position ?? "";
187
200
  } else {
188
201
  const x = pid % 10;
189
- let y = Math.floor(pid / 10);
202
+ const y = Math.floor(pid / 10);
190
203
  const xp = x === 0 ? 10 : x;
191
204
  const yp = x === 0 ? y : y + 1;
192
205
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
@@ -205,15 +218,17 @@ function countWords(p) {
205
218
  }
206
219
  function applyTagAliases(passages, aliases) {
207
220
  const entries = Object.entries(aliases);
208
- if (entries.length === 0) return;
209
- for (const p of passages) {
210
- const original = [...p.tags];
221
+ if (entries.length === 0) return [...passages];
222
+ return passages.map((p) => {
223
+ const original = p.tags;
224
+ const added = [];
211
225
  for (const [alias, canonical] of entries) {
212
- if (original.includes(alias) && !p.tags.includes(canonical)) {
213
- p.tags.push(canonical);
226
+ if (original.includes(alias) && !original.includes(canonical) && !added.includes(canonical)) {
227
+ added.push(canonical);
214
228
  }
215
229
  }
216
- }
230
+ return added.length > 0 ? { ...p, tags: [...original, ...added] } : p;
231
+ });
217
232
  }
218
233
  function quote(s) {
219
234
  return `"${s}"`;
@@ -285,27 +300,32 @@ function marshalStoryData(story) {
285
300
  return JSON.stringify(data, null, " ");
286
301
  }
287
302
  function unmarshalStoryData(story, json) {
288
- let data;
303
+ let raw;
289
304
  try {
290
- data = JSON.parse(json);
305
+ raw = JSON.parse(json);
291
306
  } catch (e) {
292
307
  return `Cannot unmarshal "StoryData"; ${e instanceof Error ? e.message : String(e)}`;
293
308
  }
294
- if (data.ifid) story.ifid = data.ifid.toUpperCase();
295
- if (data.format) story.twine2.format = data.format;
296
- if (data["format-version"]) story.twine2.formatVersion = data["format-version"];
297
- if (data.options) {
309
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
310
+ return 'Cannot unmarshal "StoryData"; expected a JSON object';
311
+ }
312
+ const data = raw;
313
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
314
+ if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
315
+ if (typeof data["format-version"] === "string" && data["format-version"])
316
+ story.twine2.formatVersion = data["format-version"];
317
+ if (Array.isArray(data.options)) {
298
318
  for (const opt of data.options) {
299
- story.twine2.options.set(opt, true);
319
+ if (typeof opt === "string") story.twine2.options.set(opt, true);
300
320
  }
301
321
  }
302
- if (data.start) story.twine2.start = data.start;
303
- if (data["tag-colors"]) {
322
+ if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
323
+ if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
304
324
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
305
- story.twine2.tagColors.set(tag, color);
325
+ if (typeof color === "string") story.twine2.tagColors.set(tag, color);
306
326
  }
307
327
  }
308
- if (data.zoom != null && data.zoom !== 0) story.twine2.zoom = data.zoom;
328
+ if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
309
329
  return null;
310
330
  }
311
331
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -348,6 +368,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
348
368
  }
349
369
  }
350
370
  function storyAdd(story, p, diagnostics) {
371
+ let processed = p;
351
372
  switch (p.name) {
352
373
  case "StoryIncludes":
353
374
  diagnostics.push({
@@ -367,7 +388,7 @@ function storyAdd(story, p, diagnostics) {
367
388
  });
368
389
  }
369
390
  }
370
- p.text = marshalStoryData(story);
391
+ processed = { ...p, text: marshalStoryData(story) };
371
392
  } else {
372
393
  diagnostics.push({
373
394
  level: "error",
@@ -379,12 +400,14 @@ function storyAdd(story, p, diagnostics) {
379
400
  case "StorySettings":
380
401
  unmarshalStorySettings(story, p.text, diagnostics);
381
402
  break;
382
- case "StoryTitle":
383
- p.text = p.text.trim();
384
- story.name = p.text;
403
+ case "StoryTitle": {
404
+ const trimmed = p.text.trim();
405
+ processed = { ...p, text: trimmed };
406
+ story.name = trimmed;
385
407
  break;
408
+ }
386
409
  }
387
- storyAppend(story, p, diagnostics);
410
+ storyAppend(story, processed, diagnostics);
388
411
  }
389
412
  function getStoryStats(story) {
390
413
  let storyPassages = 0;
@@ -438,7 +461,7 @@ var MEDIA_TYPE_MAP = {
438
461
  webm: "video/webm"
439
462
  };
440
463
  function mediaTypeFromExt(ext) {
441
- return MEDIA_TYPE_MAP[ext] ?? "";
464
+ return MEDIA_TYPE_MAP[ext] ?? "application/octet-stream";
442
465
  }
443
466
  function mediaTypeFromFilename(filename) {
444
467
  return mediaTypeFromExt(normalizedFileExt(filename));
@@ -497,23 +520,49 @@ function getFilenames(pathnames, outFilename) {
497
520
  }
498
521
 
499
522
  // src/formats.ts
500
- import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
523
+ import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
501
524
  import { join as join2 } from "path";
502
525
  import { homedir } from "os";
526
+
527
+ // src/util.ts
528
+ import { readFileSync } from "fs";
529
+ import { parse as parsePath } from "path";
530
+ function readUTF8(filename) {
531
+ let content = readFileSync(filename, "utf-8");
532
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
533
+ content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
534
+ return content;
535
+ }
536
+ function readBase64(filename) {
537
+ return readFileSync(filename).toString("base64");
538
+ }
539
+ function baseNameWithoutExt(filename) {
540
+ const { name, ext } = parsePath(filename);
541
+ return name || ext;
542
+ }
543
+
544
+ // src/formats.ts
503
545
  function parseFormatJSON(source, formatId) {
504
546
  const first = source.indexOf("{");
505
547
  const last = source.lastIndexOf("}");
506
548
  if (first === -1 || last === -1) return null;
507
549
  let chunk = source.slice(first, last + 1);
550
+ const parse = (json) => {
551
+ const raw = JSON.parse(json);
552
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
553
+ const obj = raw;
554
+ if (typeof obj.name !== "string" || typeof obj.version !== "string" || typeof obj.source !== "string") return null;
555
+ return { name: obj.name, version: obj.version, source: obj.source, proofing: obj.proofing === true };
556
+ };
508
557
  try {
509
- return JSON.parse(chunk);
558
+ return parse(chunk);
510
559
  } catch {
511
560
  if (formatId.toLowerCase().startsWith("harlowe")) {
512
561
  const setupIdx = chunk.lastIndexOf(',"setup": function');
513
562
  if (setupIdx !== -1) {
514
563
  chunk = chunk.slice(0, setupIdx) + "}";
515
564
  try {
516
- return JSON.parse(chunk);
565
+ return parse(chunk);
517
566
  } catch {
518
567
  }
519
568
  }
@@ -521,12 +570,6 @@ function parseFormatJSON(source, formatId) {
521
570
  return null;
522
571
  }
523
572
  }
524
- function readUTF8(filename) {
525
- let content = readFileSync(filename, "utf-8");
526
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
527
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
528
- return content;
529
- }
530
573
  function discoverFormats(searchDirs) {
531
574
  const formats = /* @__PURE__ */ new Map();
532
575
  const baseFilenames = ["format.js", "header.html"];
@@ -621,9 +664,11 @@ function parseSemver(v) {
621
664
  return [Number(m[1]), Number(m[2]), Number(m[3])];
622
665
  }
623
666
  function semverCompare(a, b) {
624
- for (let i = 0; i < 3; i++) {
625
- if (a[i] !== b[i]) return a[i] - b[i];
626
- }
667
+ const [aMajor, aMinor, aPatch] = a;
668
+ const [bMajor, bMinor, bPatch] = b;
669
+ if (aMajor !== bMajor) return aMajor - bMajor;
670
+ if (aMinor !== bMinor) return aMinor - bMinor;
671
+ if (aPatch !== bPatch) return aPatch - bPatch;
627
672
  return 0;
628
673
  }
629
674
  function getFormatIdByNameAndVersion(formats, name, version) {
@@ -654,7 +699,6 @@ function readFormatSource(format) {
654
699
  }
655
700
 
656
701
  // src/loader.ts
657
- import { readFileSync as readFileSync2 } from "fs";
658
702
  import { basename } from "path";
659
703
 
660
704
  // src/lexer.ts
@@ -931,13 +975,13 @@ function hasTwee2Syntax(s) {
931
975
  }
932
976
  function twee2ToV3(s) {
933
977
  if (!hasTwee2Syntax(s)) return s;
934
- s = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
978
+ let result = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
935
979
  const tags = p2 ?? "";
936
980
  const pos = p3 ?? "";
937
981
  return `${p1}${tags} {"position":"${pos}"}`;
938
982
  });
939
- s = s.replace(twee2BadPosRe, "$1");
940
- return s;
983
+ result = result.replace(twee2BadPosRe, "$1");
984
+ return result;
941
985
  }
942
986
 
943
987
  // src/parser.ts
@@ -1015,7 +1059,11 @@ function parseTwee(source, options = {}) {
1015
1059
  return { passages, diagnostics };
1016
1060
  }
1017
1061
  try {
1018
- const parsed = JSON.parse(item.val);
1062
+ const raw = JSON.parse(item.val);
1063
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1064
+ throw new Error("expected a JSON object");
1065
+ }
1066
+ const parsed = raw;
1019
1067
  const meta = {};
1020
1068
  if (typeof parsed.position === "string") meta.position = parsed.position;
1021
1069
  if (typeof parsed.size === "string") meta.size = parsed.size;
@@ -1035,6 +1083,16 @@ function parseTwee(source, options = {}) {
1035
1083
  current.text = trim ? item.val.trim() : item.val;
1036
1084
  break;
1037
1085
  }
1086
+ default: {
1087
+ const _exhaustive = item.type;
1088
+ diagnostics.push({
1089
+ level: "error",
1090
+ message: `line ${item.line}: Unhandled lexer item type: ${_exhaustive}.`,
1091
+ file: filename,
1092
+ line: item.line
1093
+ });
1094
+ return { passages, diagnostics };
1095
+ }
1038
1096
  }
1039
1097
  lastType = item.type;
1040
1098
  }
@@ -1045,15 +1103,6 @@ function parseTwee(source, options = {}) {
1045
1103
  }
1046
1104
 
1047
1105
  // src/loader.ts
1048
- function readUTF82(filename) {
1049
- let content = readFileSync2(filename, "utf-8");
1050
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1051
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1052
- return content;
1053
- }
1054
- function readBase64(filename) {
1055
- return readFileSync2(filename).toString("base64");
1056
- }
1057
1106
  function loadSources(story, filenames, opts, diagnostics, processedFiles) {
1058
1107
  for (const filename of filenames) {
1059
1108
  if (processedFiles.has(filename)) {
@@ -1152,7 +1201,7 @@ function loadInlineSources(story, sources, opts, diagnostics) {
1152
1201
  }
1153
1202
  }
1154
1203
  function loadTwee(story, filename, opts, diagnostics) {
1155
- const source = readUTF82(filename);
1204
+ const source = readUTF8(filename);
1156
1205
  const result = parseTwee(source, {
1157
1206
  filename,
1158
1207
  trim: opts.trim ?? true,
@@ -1164,38 +1213,48 @@ function loadTwee(story, filename, opts, diagnostics) {
1164
1213
  }
1165
1214
  }
1166
1215
  function loadTagged(story, tag, filename, diagnostics) {
1167
- const source = readUTF82(filename);
1216
+ const source = readUTF8(filename);
1168
1217
  storyAdd(story, { name: basename(filename), tags: [tag], text: source }, diagnostics);
1169
1218
  }
1170
1219
  function loadMedia(story, tag, filename, diagnostics) {
1171
1220
  const source = readBase64(filename);
1172
- const name = basename(filename).split(".")[0];
1173
- storyAdd(story, {
1174
- name,
1175
- tags: [tag],
1176
- text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
1177
- }, diagnostics);
1221
+ const name = baseNameWithoutExt(filename);
1222
+ storyAdd(
1223
+ story,
1224
+ {
1225
+ name,
1226
+ tags: [tag],
1227
+ text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
1228
+ },
1229
+ diagnostics
1230
+ );
1178
1231
  }
1179
1232
  function loadFont(story, filename, diagnostics) {
1180
1233
  const source = readBase64(filename);
1181
1234
  const name = basename(filename);
1182
- const family = name.split(".")[0];
1235
+ const family = baseNameWithoutExt(filename);
1183
1236
  const ext = normalizedFileExt(filename);
1184
1237
  const mediaType = mediaTypeFromExt(ext);
1185
1238
  const hint = fontFormatHint(ext);
1186
- storyAdd(story, {
1187
- name,
1188
- tags: ["stylesheet"],
1189
- text: `@font-face {
1239
+ storyAdd(
1240
+ story,
1241
+ {
1242
+ name,
1243
+ tags: ["stylesheet"],
1244
+ text: `@font-face {
1190
1245
  font-family: "${family}";
1191
1246
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1192
1247
  }`
1193
- }, diagnostics);
1248
+ },
1249
+ diagnostics
1250
+ );
1194
1251
  }
1195
1252
 
1253
+ // src/version.ts
1254
+ var VERSION = "1.2.0";
1255
+
1196
1256
  // src/output-twine2.ts
1197
1257
  var CREATOR_NAME = "Twee-ts";
1198
- var CREATOR_VERSION = "0.1.0";
1199
1258
  function toTwine2Archive(story, startName, diagnostics) {
1200
1259
  return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1201
1260
  }
@@ -1223,12 +1282,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1223
1282
  }
1224
1283
  let styleContent = "";
1225
1284
  if (stylesheets.length === 1) {
1226
- styleContent = stylesheets[0].text;
1285
+ styleContent = stylesheets[0]?.text ?? "";
1227
1286
  } else if (stylesheets.length > 1) {
1228
1287
  let pidS = 1;
1229
1288
  for (const p of stylesheets) {
1230
1289
  if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
1231
- styleContent += `/* twine-user-stylesheet #${pidS}: "${p.name}" */
1290
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */
1232
1291
  `;
1233
1292
  styleContent += p.text;
1234
1293
  pidS++;
@@ -1237,12 +1296,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1237
1296
  parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
1238
1297
  let scriptContent = "";
1239
1298
  if (scripts.length === 1) {
1240
- scriptContent = scripts[0].text;
1299
+ scriptContent = scripts[0]?.text ?? "";
1241
1300
  } else if (scripts.length > 1) {
1242
1301
  let pidS = 1;
1243
1302
  for (const p of scripts) {
1244
1303
  if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
1245
- scriptContent += `/* twine-user-script #${pidS}: "${p.name}" */
1304
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */
1246
1305
  `;
1247
1306
  scriptContent += p.text;
1248
1307
  pidS++;
@@ -1272,7 +1331,7 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1272
1331
  }
1273
1332
  const options = opts.join(" ");
1274
1333
  const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
1275
- const wrapper = `<!-- UUID://${story.ifid}// --><tw-storydata name="${attrEscape(story.name)}" startnode="${startID}" creator="${attrEscape(CREATOR_NAME)}" creator-version="${attrEscape(CREATOR_VERSION)}" ifid="${attrEscape(story.ifid)}" zoom="${attrEscape(zoom)}" format="${attrEscape(story.twine2.format)}" format-version="${attrEscape(story.twine2.formatVersion)}" options="${attrEscape(options)}" hidden>`;
1334
+ const wrapper = `<!-- UUID://${htmlCommentSanitize(story.ifid)}// --><tw-storydata name="${attrEscape(story.name)}" startnode="${attrEscape(startID)}" creator="${attrEscape(CREATOR_NAME)}" creator-version="${attrEscape(VERSION)}" ifid="${attrEscape(story.ifid)}" zoom="${attrEscape(zoom)}" format="${attrEscape(story.twine2.format)}" format-version="${attrEscape(story.twine2.formatVersion)}" options="${attrEscape(options)}" hidden>`;
1276
1335
  return wrapper + parts.join("") + "</tw-storydata>";
1277
1336
  }
1278
1337
  function ensureIFID(story, diagnostics) {
@@ -1293,10 +1352,9 @@ function ensureIFID(story, diagnostics) {
1293
1352
  }
1294
1353
 
1295
1354
  // src/output-twine1.ts
1296
- import { readFileSync as readFileSync3 } from "fs";
1355
+ import { readFileSync as readFileSync2 } from "fs";
1297
1356
  import { join as join3, dirname } from "path";
1298
1357
  var CREATOR_NAME2 = "twee-ts";
1299
- var CREATOR_VERSION2 = "0.1.0";
1300
1358
  function toTwine1Archive(story, _startName) {
1301
1359
  const { data, count } = getTwine1PassageChunk(story);
1302
1360
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
@@ -1318,26 +1376,27 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1318
1376
  template = tryReplaceComponent(template, '"MODERNIZR"', join3(parentDir, "modernizr.js"), true);
1319
1377
  }
1320
1378
  const displayStart = startName === "Start" ? "" : startName;
1321
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1379
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1322
1380
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1323
- template = template.replace('"START_AT"', `"${displayStart}"`);
1381
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1324
1382
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1325
1383
  if (template.includes('"STORY"')) {
1326
1384
  template = template.replace('"STORY"', data);
1327
1385
  } else {
1328
1386
  let footer;
1329
1387
  try {
1330
- footer = readFileSync3(join3(formatDir, "footer.html"), "utf-8");
1388
+ footer = readFileSync2(join3(formatDir, "footer.html"), "utf-8");
1331
1389
  } catch {
1332
1390
  footer = "</div>\n</body>\n</html>\n";
1333
1391
  }
1334
1392
  template += data + footer;
1335
1393
  }
1336
1394
  if (story.ifid) {
1395
+ const safeIfid = htmlCommentSanitize(story.ifid);
1337
1396
  if (template.includes('<div id="store-area"')) {
1338
- template = template.replace('<div id="store-area"', `<!-- UUID://${story.ifid}// --><div id="store-area"`);
1397
+ template = template.replace('<div id="store-area"', `<!-- UUID://${safeIfid}// --><div id="store-area"`);
1339
1398
  } else {
1340
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1399
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1341
1400
  }
1342
1401
  }
1343
1402
  return template;
@@ -1352,13 +1411,18 @@ function getTwine1PassageChunk(story) {
1352
1411
  }
1353
1412
  return { data, count };
1354
1413
  }
1355
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1414
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1356
1415
  if (!template.includes(placeholder)) return template;
1357
1416
  try {
1358
- let content = readFileSync3(componentPath, "utf-8");
1417
+ let content = readFileSync2(componentPath, "utf-8");
1359
1418
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1360
1419
  return template.replace(placeholder, content);
1361
- } catch {
1420
+ } catch (e) {
1421
+ if (required) {
1422
+ throw new Error(
1423
+ `Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`
1424
+ );
1425
+ }
1362
1426
  return template;
1363
1427
  }
1364
1428
  }
@@ -1373,16 +1437,6 @@ function toTwee(story, outMode) {
1373
1437
  }
1374
1438
 
1375
1439
  // src/modules.ts
1376
- import { readFileSync as readFileSync4 } from "fs";
1377
- import { basename as basename2 } from "path";
1378
- function readUTF83(filename) {
1379
- let content = readFileSync4(filename, "utf-8");
1380
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1381
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1382
- }
1383
- function readBase642(filename) {
1384
- return readFileSync4(filename).toString("base64");
1385
- }
1386
1440
  function loadModules(filenames) {
1387
1441
  const processed = /* @__PURE__ */ new Set();
1388
1442
  const headTags = [];
@@ -1412,16 +1466,16 @@ function loadModules(filenames) {
1412
1466
  return headTags.join("\n");
1413
1467
  }
1414
1468
  function loadModuleTagged(tag, filename) {
1415
- const source = readUTF83(filename).trim();
1469
+ const source = readUTF8(filename).trim();
1416
1470
  if (source.length === 0) return null;
1417
- const family = basename2(filename).split(".")[0];
1471
+ const family = baseNameWithoutExt(filename);
1418
1472
  const idSlug = `${tag}-module-${slugify(family)}`;
1419
1473
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1420
1474
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1421
1475
  }
1422
1476
  function loadModuleFont(filename) {
1423
- const source = readBase642(filename);
1424
- const family = basename2(filename).split(".")[0];
1477
+ const source = readBase64(filename);
1478
+ const family = baseNameWithoutExt(filename);
1425
1479
  const idSlug = `style-module-${slugify(family)}`;
1426
1480
  const ext = normalizedFileExt(filename);
1427
1481
  const mediaType = mediaTypeFromExt(ext);
@@ -1431,7 +1485,7 @@ function loadModuleFont(filename) {
1431
1485
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1432
1486
  }</style>`;
1433
1487
  }
1434
- function modifyHead(html, modulePaths, headFile) {
1488
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1435
1489
  const parts = [];
1436
1490
  if (modulePaths.length > 0) {
1437
1491
  const modules = loadModules(modulePaths).trim();
@@ -1439,9 +1493,13 @@ function modifyHead(html, modulePaths, headFile) {
1439
1493
  }
1440
1494
  if (headFile) {
1441
1495
  try {
1442
- const source = readUTF83(headFile).trim();
1496
+ const source = readUTF8(headFile).trim();
1443
1497
  if (source.length > 0) parts.push(source);
1444
- } catch {
1498
+ } catch (e) {
1499
+ diagnostics?.push({
1500
+ level: "warning",
1501
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
1502
+ });
1445
1503
  }
1446
1504
  }
1447
1505
  if (parts.length > 0) {
@@ -1452,7 +1510,7 @@ function modifyHead(html, modulePaths, headFile) {
1452
1510
  }
1453
1511
 
1454
1512
  // src/remote-formats.ts
1455
- import { mkdirSync, readFileSync as readFileSync5, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1513
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1456
1514
  import { join as join4 } from "path";
1457
1515
  import { homedir as homedir2 } from "os";
1458
1516
  var DEFAULT_SFA_INDICES = [
@@ -1468,6 +1526,24 @@ var indexCache = /* @__PURE__ */ new Map();
1468
1526
  function clearIndexCache() {
1469
1527
  indexCache.clear();
1470
1528
  }
1529
+ function isValidEntry(val) {
1530
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1531
+ const obj = val;
1532
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1533
+ }
1534
+ function validateSFAIndex(json) {
1535
+ if (typeof json !== "object" || json === null) {
1536
+ throw new Error("SFA index is not an object");
1537
+ }
1538
+ const obj = json;
1539
+ const twine1 = Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [];
1540
+ const twine2 = Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : [];
1541
+ return { twine1, twine2 };
1542
+ }
1543
+ function makeFormatId(name, version) {
1544
+ const major = parseSemver(version)?.[0] ?? "0";
1545
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1546
+ }
1471
1547
  async function fetchIndex(url) {
1472
1548
  const cached = indexCache.get(url);
1473
1549
  if (cached) return cached;
@@ -1475,37 +1551,42 @@ async function fetchIndex(url) {
1475
1551
  if (!res.ok) {
1476
1552
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1477
1553
  }
1478
- const data = await res.json();
1554
+ const data = validateSFAIndex(await res.json());
1479
1555
  indexCache.set(url, data);
1480
1556
  return data;
1481
1557
  }
1482
1558
  function findEntry(index, name, version) {
1483
1559
  const wanted = parseSemver(version);
1484
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1485
- let bestEntry;
1560
+ const searchArrays = [
1561
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1562
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1563
+ ];
1564
+ let bestResult;
1486
1565
  let bestVersion = null;
1487
- for (const entry of entries) {
1488
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1489
- const have = parseSemver(entry.version);
1490
- if (!have) continue;
1491
- if (wanted && semverCompare(have, wanted) === 0) return entry;
1492
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1493
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1494
- bestVersion = have;
1495
- bestEntry = entry;
1566
+ for (const { entries, formatType } of searchArrays) {
1567
+ for (const entry of entries) {
1568
+ if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1569
+ const have = parseSemver(entry.version);
1570
+ if (!have) continue;
1571
+ if (wanted && semverCompare(have, wanted) === 0) return { entry, formatType };
1572
+ if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1573
+ if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1574
+ bestVersion = have;
1575
+ bestResult = { entry, formatType };
1576
+ }
1496
1577
  }
1497
1578
  }
1498
1579
  }
1499
- return bestEntry;
1580
+ return bestResult;
1500
1581
  }
1501
1582
  async function verifySHA256(content, expectedHex) {
1502
1583
  const digest = await crypto.subtle.digest("SHA-256", content);
1503
1584
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1504
1585
  return hex === expectedHex.toLowerCase();
1505
1586
  }
1506
- function getDownloadUrl(indexUrl, entry) {
1587
+ function getDownloadUrl(indexUrl, entry, formatType) {
1507
1588
  const base = indexUrl.replace(/\/index\.json$/, "");
1508
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1589
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1509
1590
  }
1510
1591
  async function fetchAndCacheFormat(entry, downloadUrl) {
1511
1592
  const res = await fetch(downloadUrl);
@@ -1516,13 +1597,14 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1516
1597
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1517
1598
  if (checksumKey) {
1518
1599
  const expected = entry.checksums[checksumKey];
1600
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1519
1601
  const encoder = new TextEncoder();
1520
1602
  const valid = await verifySHA256(encoder.encode(text), expected);
1521
1603
  if (!valid) {
1522
1604
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1523
1605
  }
1524
1606
  }
1525
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1607
+ const id = makeFormatId(entry.name, entry.version);
1526
1608
  const data = parseFormatJSON(text, id);
1527
1609
  if (!data) {
1528
1610
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
@@ -1552,7 +1634,7 @@ async function fetchDirectFormat(url) {
1552
1634
  if (!data) {
1553
1635
  throw new Error(`Failed to parse format JSON from ${url}`);
1554
1636
  }
1555
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1637
+ const id = makeFormatId(data.name, data.version);
1556
1638
  const cacheDir = getCacheDir();
1557
1639
  const formatDir = join4(cacheDir, data.name, data.version);
1558
1640
  mkdirSync(formatDir, { recursive: true });
@@ -1568,6 +1650,7 @@ async function fetchDirectFormat(url) {
1568
1650
  };
1569
1651
  }
1570
1652
  async function resolveRemoteFormat(name, version, indices, urls) {
1653
+ let lastError;
1571
1654
  if (urls) {
1572
1655
  for (const url of urls) {
1573
1656
  try {
@@ -1579,7 +1662,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1579
1662
  return info;
1580
1663
  }
1581
1664
  }
1582
- } catch {
1665
+ } catch (e) {
1666
+ lastError = e instanceof Error ? e : new Error(String(e));
1583
1667
  }
1584
1668
  }
1585
1669
  }
@@ -1587,24 +1671,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1587
1671
  for (const indexUrl of allIndices) {
1588
1672
  try {
1589
1673
  const index = await fetchIndex(indexUrl);
1590
- const entry = findEntry(index, name, version);
1591
- if (entry) {
1592
- const cached = getCachedFormat(entry.name, entry.version);
1674
+ const result = findEntry(index, name, version);
1675
+ if (result) {
1676
+ const cached = getCachedFormat(result.entry.name, result.entry.version);
1593
1677
  if (cached) return cached;
1594
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1595
- return await fetchAndCacheFormat(entry, downloadUrl);
1678
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1679
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1596
1680
  }
1597
- } catch {
1681
+ } catch (e) {
1682
+ lastError = e instanceof Error ? e : new Error(String(e));
1598
1683
  }
1599
1684
  }
1685
+ if (lastError) throw lastError;
1600
1686
  return void 0;
1601
1687
  }
1602
1688
  function getCachedFormat(name, version) {
1603
1689
  const formatPath = join4(getCacheDir(), name, version, "format.js");
1604
1690
  try {
1605
1691
  if (!existsSync(formatPath)) return void 0;
1606
- const source = readFileSync5(formatPath, "utf-8");
1607
- const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(version)?.[0] ?? "0"}`;
1692
+ const source = readFileSync3(formatPath, "utf-8");
1693
+ const id = makeFormatId(name, version);
1608
1694
  const data = parseFormatJSON(source, id);
1609
1695
  if (!data) return void 0;
1610
1696
  return {
@@ -1656,18 +1742,12 @@ async function buildOutput(options) {
1656
1742
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1657
1743
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1658
1744
  if (options.tagAliases) {
1659
- applyTagAliases(story.passages, options.tagAliases);
1660
- }
1661
- const fatalErrors = diagnostics.filter((d) => d.level === "error");
1662
- if (fatalErrors.length > 0 && outputMode === "html") {
1745
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1663
1746
  }
1664
1747
  let format;
1665
1748
  let formatId = options.formatId ?? "";
1666
1749
  if (outputMode === "html") {
1667
- const formatSearchDirs = getFormatSearchDirs(
1668
- options.formatPaths,
1669
- options.useTweegoPath ?? true
1670
- );
1750
+ const formatSearchDirs = getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true);
1671
1751
  const formats = discoverFormats(formatSearchDirs);
1672
1752
  if (!formatId && story.twine2.format) {
1673
1753
  formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
@@ -1680,13 +1760,12 @@ async function buildOutput(options) {
1680
1760
  const remoteName = story.twine2.format || formatId;
1681
1761
  const remoteVersion = story.twine2.formatVersion || "";
1682
1762
  try {
1683
- format = await resolveRemoteFormat(
1684
- remoteName,
1685
- remoteVersion,
1686
- options.formatIndices,
1687
- options.formatUrls
1688
- );
1689
- } catch {
1763
+ format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1764
+ } catch (e) {
1765
+ diagnostics.push({
1766
+ level: "warning",
1767
+ message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
1768
+ });
1690
1769
  }
1691
1770
  }
1692
1771
  if (!format) {
@@ -1697,14 +1776,7 @@ async function buildOutput(options) {
1697
1776
  });
1698
1777
  }
1699
1778
  }
1700
- let startName;
1701
- if (options.startPassage) {
1702
- startName = options.startPassage;
1703
- } else if (story.twine2.start) {
1704
- startName = story.twine2.start;
1705
- } else {
1706
- startName = DEFAULT_START_NAME;
1707
- }
1779
+ const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
1708
1780
  if (testMode) {
1709
1781
  story.twine2.options.set("debug", true);
1710
1782
  }
@@ -1723,8 +1795,7 @@ async function buildOutput(options) {
1723
1795
  case "json":
1724
1796
  output = storyToJSON(story);
1725
1797
  break;
1726
- case "html":
1727
- default: {
1798
+ case "html": {
1728
1799
  if (!storyHas(story, startName)) {
1729
1800
  diagnostics.push({
1730
1801
  level: "error",
@@ -1746,9 +1817,13 @@ async function buildOutput(options) {
1746
1817
  output = toTwine1HTML(story, format, startName, diagnostics);
1747
1818
  }
1748
1819
  const modulePaths = options.modules ? getFilenames(options.modules) : [];
1749
- output = modifyHead(output, modulePaths, options.headFile);
1820
+ output = modifyHead(output, modulePaths, options.headFile, diagnostics);
1750
1821
  break;
1751
1822
  }
1823
+ default: {
1824
+ const _exhaustive = outputMode;
1825
+ throw new TweeTsError(`Unhandled output mode: ${_exhaustive}`, diagnostics);
1826
+ }
1752
1827
  }
1753
1828
  const stats = {
1754
1829
  ...getStoryStats(story),
@@ -1757,27 +1832,31 @@ async function buildOutput(options) {
1757
1832
  return { output, story, format, diagnostics, stats };
1758
1833
  }
1759
1834
  function storyToJSON(story) {
1760
- return JSON.stringify({
1761
- name: story.name,
1762
- ifid: story.ifid,
1763
- passages: story.passages.map((p) => ({
1764
- name: p.name,
1765
- tags: p.tags,
1766
- text: p.text,
1767
- metadata: p.metadata ?? null
1768
- })),
1769
- twine2: {
1770
- format: story.twine2.format,
1771
- formatVersion: story.twine2.formatVersion,
1772
- start: story.twine2.start,
1773
- tagColors: Object.fromEntries(story.twine2.tagColors),
1774
- options: Object.fromEntries(story.twine2.options),
1775
- zoom: story.twine2.zoom
1776
- }
1777
- }, null, 2);
1835
+ return JSON.stringify(
1836
+ {
1837
+ name: story.name,
1838
+ ifid: story.ifid,
1839
+ passages: story.passages.map((p) => ({
1840
+ name: p.name,
1841
+ tags: p.tags,
1842
+ text: p.text,
1843
+ metadata: p.metadata ?? null
1844
+ })),
1845
+ twine2: {
1846
+ format: story.twine2.format,
1847
+ formatVersion: story.twine2.formatVersion,
1848
+ start: story.twine2.start,
1849
+ tagColors: Object.fromEntries(story.twine2.tagColors),
1850
+ options: Object.fromEntries(story.twine2.options),
1851
+ zoom: story.twine2.zoom
1852
+ }
1853
+ },
1854
+ null,
1855
+ 2
1856
+ );
1778
1857
  }
1779
1858
 
1780
1859
  export {
1781
1860
  compile
1782
1861
  };
1783
- //# sourceMappingURL=chunk-QPA7RLK5.js.map
1862
+ //# sourceMappingURL=chunk-S4SWCQLW.js.map