@rohal12/twee-ts 1.1.2 → 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,12 +1213,12 @@ 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];
1221
+ const name = baseNameWithoutExt(filename);
1173
1222
  storyAdd(
1174
1223
  story,
1175
1224
  {
@@ -1183,7 +1232,7 @@ function loadMedia(story, tag, filename, diagnostics) {
1183
1232
  function loadFont(story, filename, diagnostics) {
1184
1233
  const source = readBase64(filename);
1185
1234
  const name = basename(filename);
1186
- const family = name.split(".")[0];
1235
+ const family = baseNameWithoutExt(filename);
1187
1236
  const ext = normalizedFileExt(filename);
1188
1237
  const mediaType = mediaTypeFromExt(ext);
1189
1238
  const hint = fontFormatHint(ext);
@@ -1201,9 +1250,11 @@ function loadFont(story, filename, diagnostics) {
1201
1250
  );
1202
1251
  }
1203
1252
 
1253
+ // src/version.ts
1254
+ var VERSION = "1.2.0";
1255
+
1204
1256
  // src/output-twine2.ts
1205
1257
  var CREATOR_NAME = "Twee-ts";
1206
- var CREATOR_VERSION = "0.1.0";
1207
1258
  function toTwine2Archive(story, startName, diagnostics) {
1208
1259
  return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1209
1260
  }
@@ -1231,12 +1282,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1231
1282
  }
1232
1283
  let styleContent = "";
1233
1284
  if (stylesheets.length === 1) {
1234
- styleContent = stylesheets[0].text;
1285
+ styleContent = stylesheets[0]?.text ?? "";
1235
1286
  } else if (stylesheets.length > 1) {
1236
1287
  let pidS = 1;
1237
1288
  for (const p of stylesheets) {
1238
1289
  if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
1239
- styleContent += `/* twine-user-stylesheet #${pidS}: "${p.name}" */
1290
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */
1240
1291
  `;
1241
1292
  styleContent += p.text;
1242
1293
  pidS++;
@@ -1245,12 +1296,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1245
1296
  parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
1246
1297
  let scriptContent = "";
1247
1298
  if (scripts.length === 1) {
1248
- scriptContent = scripts[0].text;
1299
+ scriptContent = scripts[0]?.text ?? "";
1249
1300
  } else if (scripts.length > 1) {
1250
1301
  let pidS = 1;
1251
1302
  for (const p of scripts) {
1252
1303
  if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
1253
- scriptContent += `/* twine-user-script #${pidS}: "${p.name}" */
1304
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */
1254
1305
  `;
1255
1306
  scriptContent += p.text;
1256
1307
  pidS++;
@@ -1280,7 +1331,7 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1280
1331
  }
1281
1332
  const options = opts.join(" ");
1282
1333
  const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
1283
- 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>`;
1284
1335
  return wrapper + parts.join("") + "</tw-storydata>";
1285
1336
  }
1286
1337
  function ensureIFID(story, diagnostics) {
@@ -1301,10 +1352,9 @@ function ensureIFID(story, diagnostics) {
1301
1352
  }
1302
1353
 
1303
1354
  // src/output-twine1.ts
1304
- import { readFileSync as readFileSync3 } from "fs";
1355
+ import { readFileSync as readFileSync2 } from "fs";
1305
1356
  import { join as join3, dirname } from "path";
1306
1357
  var CREATOR_NAME2 = "twee-ts";
1307
- var CREATOR_VERSION2 = "0.1.0";
1308
1358
  function toTwine1Archive(story, _startName) {
1309
1359
  const { data, count } = getTwine1PassageChunk(story);
1310
1360
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
@@ -1326,26 +1376,27 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1326
1376
  template = tryReplaceComponent(template, '"MODERNIZR"', join3(parentDir, "modernizr.js"), true);
1327
1377
  }
1328
1378
  const displayStart = startName === "Start" ? "" : startName;
1329
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1379
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1330
1380
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1331
- template = template.replace('"START_AT"', `"${displayStart}"`);
1381
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1332
1382
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1333
1383
  if (template.includes('"STORY"')) {
1334
1384
  template = template.replace('"STORY"', data);
1335
1385
  } else {
1336
1386
  let footer;
1337
1387
  try {
1338
- footer = readFileSync3(join3(formatDir, "footer.html"), "utf-8");
1388
+ footer = readFileSync2(join3(formatDir, "footer.html"), "utf-8");
1339
1389
  } catch {
1340
1390
  footer = "</div>\n</body>\n</html>\n";
1341
1391
  }
1342
1392
  template += data + footer;
1343
1393
  }
1344
1394
  if (story.ifid) {
1395
+ const safeIfid = htmlCommentSanitize(story.ifid);
1345
1396
  if (template.includes('<div id="store-area"')) {
1346
- 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"`);
1347
1398
  } else {
1348
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1399
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1349
1400
  }
1350
1401
  }
1351
1402
  return template;
@@ -1360,13 +1411,18 @@ function getTwine1PassageChunk(story) {
1360
1411
  }
1361
1412
  return { data, count };
1362
1413
  }
1363
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1414
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1364
1415
  if (!template.includes(placeholder)) return template;
1365
1416
  try {
1366
- let content = readFileSync3(componentPath, "utf-8");
1417
+ let content = readFileSync2(componentPath, "utf-8");
1367
1418
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1368
1419
  return template.replace(placeholder, content);
1369
- } 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
+ }
1370
1426
  return template;
1371
1427
  }
1372
1428
  }
@@ -1381,16 +1437,6 @@ function toTwee(story, outMode) {
1381
1437
  }
1382
1438
 
1383
1439
  // src/modules.ts
1384
- import { readFileSync as readFileSync4 } from "fs";
1385
- import { basename as basename2 } from "path";
1386
- function readUTF83(filename) {
1387
- let content = readFileSync4(filename, "utf-8");
1388
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1389
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1390
- }
1391
- function readBase642(filename) {
1392
- return readFileSync4(filename).toString("base64");
1393
- }
1394
1440
  function loadModules(filenames) {
1395
1441
  const processed = /* @__PURE__ */ new Set();
1396
1442
  const headTags = [];
@@ -1420,16 +1466,16 @@ function loadModules(filenames) {
1420
1466
  return headTags.join("\n");
1421
1467
  }
1422
1468
  function loadModuleTagged(tag, filename) {
1423
- const source = readUTF83(filename).trim();
1469
+ const source = readUTF8(filename).trim();
1424
1470
  if (source.length === 0) return null;
1425
- const family = basename2(filename).split(".")[0];
1471
+ const family = baseNameWithoutExt(filename);
1426
1472
  const idSlug = `${tag}-module-${slugify(family)}`;
1427
1473
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1428
1474
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1429
1475
  }
1430
1476
  function loadModuleFont(filename) {
1431
- const source = readBase642(filename);
1432
- const family = basename2(filename).split(".")[0];
1477
+ const source = readBase64(filename);
1478
+ const family = baseNameWithoutExt(filename);
1433
1479
  const idSlug = `style-module-${slugify(family)}`;
1434
1480
  const ext = normalizedFileExt(filename);
1435
1481
  const mediaType = mediaTypeFromExt(ext);
@@ -1439,7 +1485,7 @@ function loadModuleFont(filename) {
1439
1485
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1440
1486
  }</style>`;
1441
1487
  }
1442
- function modifyHead(html, modulePaths, headFile) {
1488
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1443
1489
  const parts = [];
1444
1490
  if (modulePaths.length > 0) {
1445
1491
  const modules = loadModules(modulePaths).trim();
@@ -1447,9 +1493,13 @@ function modifyHead(html, modulePaths, headFile) {
1447
1493
  }
1448
1494
  if (headFile) {
1449
1495
  try {
1450
- const source = readUTF83(headFile).trim();
1496
+ const source = readUTF8(headFile).trim();
1451
1497
  if (source.length > 0) parts.push(source);
1452
- } 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
+ });
1453
1503
  }
1454
1504
  }
1455
1505
  if (parts.length > 0) {
@@ -1460,7 +1510,7 @@ function modifyHead(html, modulePaths, headFile) {
1460
1510
  }
1461
1511
 
1462
1512
  // src/remote-formats.ts
1463
- 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";
1464
1514
  import { join as join4 } from "path";
1465
1515
  import { homedir as homedir2 } from "os";
1466
1516
  var DEFAULT_SFA_INDICES = [
@@ -1476,6 +1526,24 @@ var indexCache = /* @__PURE__ */ new Map();
1476
1526
  function clearIndexCache() {
1477
1527
  indexCache.clear();
1478
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
+ }
1479
1547
  async function fetchIndex(url) {
1480
1548
  const cached = indexCache.get(url);
1481
1549
  if (cached) return cached;
@@ -1483,37 +1551,42 @@ async function fetchIndex(url) {
1483
1551
  if (!res.ok) {
1484
1552
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1485
1553
  }
1486
- const data = await res.json();
1554
+ const data = validateSFAIndex(await res.json());
1487
1555
  indexCache.set(url, data);
1488
1556
  return data;
1489
1557
  }
1490
1558
  function findEntry(index, name, version) {
1491
1559
  const wanted = parseSemver(version);
1492
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1493
- let bestEntry;
1560
+ const searchArrays = [
1561
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1562
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1563
+ ];
1564
+ let bestResult;
1494
1565
  let bestVersion = null;
1495
- for (const entry of entries) {
1496
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1497
- const have = parseSemver(entry.version);
1498
- if (!have) continue;
1499
- if (wanted && semverCompare(have, wanted) === 0) return entry;
1500
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1501
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1502
- bestVersion = have;
1503
- 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
+ }
1504
1577
  }
1505
1578
  }
1506
1579
  }
1507
- return bestEntry;
1580
+ return bestResult;
1508
1581
  }
1509
1582
  async function verifySHA256(content, expectedHex) {
1510
1583
  const digest = await crypto.subtle.digest("SHA-256", content);
1511
1584
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1512
1585
  return hex === expectedHex.toLowerCase();
1513
1586
  }
1514
- function getDownloadUrl(indexUrl, entry) {
1587
+ function getDownloadUrl(indexUrl, entry, formatType) {
1515
1588
  const base = indexUrl.replace(/\/index\.json$/, "");
1516
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1589
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1517
1590
  }
1518
1591
  async function fetchAndCacheFormat(entry, downloadUrl) {
1519
1592
  const res = await fetch(downloadUrl);
@@ -1524,13 +1597,14 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1524
1597
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1525
1598
  if (checksumKey) {
1526
1599
  const expected = entry.checksums[checksumKey];
1600
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1527
1601
  const encoder = new TextEncoder();
1528
1602
  const valid = await verifySHA256(encoder.encode(text), expected);
1529
1603
  if (!valid) {
1530
1604
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1531
1605
  }
1532
1606
  }
1533
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1607
+ const id = makeFormatId(entry.name, entry.version);
1534
1608
  const data = parseFormatJSON(text, id);
1535
1609
  if (!data) {
1536
1610
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
@@ -1560,7 +1634,7 @@ async function fetchDirectFormat(url) {
1560
1634
  if (!data) {
1561
1635
  throw new Error(`Failed to parse format JSON from ${url}`);
1562
1636
  }
1563
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1637
+ const id = makeFormatId(data.name, data.version);
1564
1638
  const cacheDir = getCacheDir();
1565
1639
  const formatDir = join4(cacheDir, data.name, data.version);
1566
1640
  mkdirSync(formatDir, { recursive: true });
@@ -1576,6 +1650,7 @@ async function fetchDirectFormat(url) {
1576
1650
  };
1577
1651
  }
1578
1652
  async function resolveRemoteFormat(name, version, indices, urls) {
1653
+ let lastError;
1579
1654
  if (urls) {
1580
1655
  for (const url of urls) {
1581
1656
  try {
@@ -1587,7 +1662,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1587
1662
  return info;
1588
1663
  }
1589
1664
  }
1590
- } catch {
1665
+ } catch (e) {
1666
+ lastError = e instanceof Error ? e : new Error(String(e));
1591
1667
  }
1592
1668
  }
1593
1669
  }
@@ -1595,24 +1671,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1595
1671
  for (const indexUrl of allIndices) {
1596
1672
  try {
1597
1673
  const index = await fetchIndex(indexUrl);
1598
- const entry = findEntry(index, name, version);
1599
- if (entry) {
1600
- 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);
1601
1677
  if (cached) return cached;
1602
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1603
- return await fetchAndCacheFormat(entry, downloadUrl);
1678
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1679
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1604
1680
  }
1605
- } catch {
1681
+ } catch (e) {
1682
+ lastError = e instanceof Error ? e : new Error(String(e));
1606
1683
  }
1607
1684
  }
1685
+ if (lastError) throw lastError;
1608
1686
  return void 0;
1609
1687
  }
1610
1688
  function getCachedFormat(name, version) {
1611
1689
  const formatPath = join4(getCacheDir(), name, version, "format.js");
1612
1690
  try {
1613
1691
  if (!existsSync(formatPath)) return void 0;
1614
- const source = readFileSync5(formatPath, "utf-8");
1615
- const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(version)?.[0] ?? "0"}`;
1692
+ const source = readFileSync3(formatPath, "utf-8");
1693
+ const id = makeFormatId(name, version);
1616
1694
  const data = parseFormatJSON(source, id);
1617
1695
  if (!data) return void 0;
1618
1696
  return {
@@ -1664,10 +1742,7 @@ async function buildOutput(options) {
1664
1742
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1665
1743
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1666
1744
  if (options.tagAliases) {
1667
- applyTagAliases(story.passages, options.tagAliases);
1668
- }
1669
- const fatalErrors = diagnostics.filter((d) => d.level === "error");
1670
- if (fatalErrors.length > 0 && outputMode === "html") {
1745
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1671
1746
  }
1672
1747
  let format;
1673
1748
  let formatId = options.formatId ?? "";
@@ -1686,7 +1761,11 @@ async function buildOutput(options) {
1686
1761
  const remoteVersion = story.twine2.formatVersion || "";
1687
1762
  try {
1688
1763
  format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1689
- } catch {
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),
@@ -1784,4 +1859,4 @@ function storyToJSON(story) {
1784
1859
  export {
1785
1860
  compile
1786
1861
  };
1787
- //# sourceMappingURL=chunk-QTXRKA4T.js.map
1862
+ //# sourceMappingURL=chunk-S4SWCQLW.js.map