@rohal12/twee-ts 1.1.2 → 1.3.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++;
@@ -1279,32 +1330,32 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1279
1330
  if (val) opts.push(opt);
1280
1331
  }
1281
1332
  const options = opts.join(" ");
1282
- 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>`;
1333
+ const zoom = String(story.twine2.zoom);
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) {
1287
1338
  if (story.ifid !== "") return;
1288
1339
  if (story.legacyIFID !== "") {
1340
+ story.ifid = story.legacyIFID;
1289
1341
  diagnostics.push({
1290
1342
  level: "warning",
1291
1343
  message: 'Story IFID not found; reusing "ifid" entry from the "StorySettings" special passage.'
1292
1344
  });
1293
- story.ifid = story.legacyIFID;
1294
1345
  } else {
1295
- story.ifid = generateIFID();
1346
+ const ifid = generateIFID();
1347
+ story.ifid = ifid;
1296
1348
  diagnostics.push({
1297
- level: "warning",
1298
- message: `Story IFID not found; generated ${story.ifid} for your project. Add it to a StoryData passage.`
1349
+ level: "error",
1350
+ message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1299
1351
  });
1300
1352
  }
1301
1353
  }
1302
1354
 
1303
1355
  // src/output-twine1.ts
1304
- import { readFileSync as readFileSync3 } from "fs";
1356
+ import { readFileSync as readFileSync2 } from "fs";
1305
1357
  import { join as join3, dirname } from "path";
1306
1358
  var CREATOR_NAME2 = "twee-ts";
1307
- var CREATOR_VERSION2 = "0.1.0";
1308
1359
  function toTwine1Archive(story, _startName) {
1309
1360
  const { data, count } = getTwine1PassageChunk(story);
1310
1361
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
@@ -1326,26 +1377,27 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1326
1377
  template = tryReplaceComponent(template, '"MODERNIZR"', join3(parentDir, "modernizr.js"), true);
1327
1378
  }
1328
1379
  const displayStart = startName === "Start" ? "" : startName;
1329
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1380
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1330
1381
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1331
- template = template.replace('"START_AT"', `"${displayStart}"`);
1382
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1332
1383
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1333
1384
  if (template.includes('"STORY"')) {
1334
1385
  template = template.replace('"STORY"', data);
1335
1386
  } else {
1336
1387
  let footer;
1337
1388
  try {
1338
- footer = readFileSync3(join3(formatDir, "footer.html"), "utf-8");
1389
+ footer = readFileSync2(join3(formatDir, "footer.html"), "utf-8");
1339
1390
  } catch {
1340
1391
  footer = "</div>\n</body>\n</html>\n";
1341
1392
  }
1342
1393
  template += data + footer;
1343
1394
  }
1344
1395
  if (story.ifid) {
1396
+ const safeIfid = htmlCommentSanitize(story.ifid);
1345
1397
  if (template.includes('<div id="store-area"')) {
1346
- template = template.replace('<div id="store-area"', `<!-- UUID://${story.ifid}// --><div id="store-area"`);
1398
+ template = template.replace('<div id="store-area"', `<!-- UUID://${safeIfid}// --><div id="store-area"`);
1347
1399
  } else {
1348
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1400
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1349
1401
  }
1350
1402
  }
1351
1403
  return template;
@@ -1360,13 +1412,18 @@ function getTwine1PassageChunk(story) {
1360
1412
  }
1361
1413
  return { data, count };
1362
1414
  }
1363
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1415
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1364
1416
  if (!template.includes(placeholder)) return template;
1365
1417
  try {
1366
- let content = readFileSync3(componentPath, "utf-8");
1418
+ let content = readFileSync2(componentPath, "utf-8");
1367
1419
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1368
1420
  return template.replace(placeholder, content);
1369
- } catch {
1421
+ } catch (e) {
1422
+ if (required) {
1423
+ throw new Error(
1424
+ `Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`
1425
+ );
1426
+ }
1370
1427
  return template;
1371
1428
  }
1372
1429
  }
@@ -1381,16 +1438,6 @@ function toTwee(story, outMode) {
1381
1438
  }
1382
1439
 
1383
1440
  // 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
1441
  function loadModules(filenames) {
1395
1442
  const processed = /* @__PURE__ */ new Set();
1396
1443
  const headTags = [];
@@ -1420,16 +1467,16 @@ function loadModules(filenames) {
1420
1467
  return headTags.join("\n");
1421
1468
  }
1422
1469
  function loadModuleTagged(tag, filename) {
1423
- const source = readUTF83(filename).trim();
1470
+ const source = readUTF8(filename).trim();
1424
1471
  if (source.length === 0) return null;
1425
- const family = basename2(filename).split(".")[0];
1472
+ const family = baseNameWithoutExt(filename);
1426
1473
  const idSlug = `${tag}-module-${slugify(family)}`;
1427
1474
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1428
1475
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1429
1476
  }
1430
1477
  function loadModuleFont(filename) {
1431
- const source = readBase642(filename);
1432
- const family = basename2(filename).split(".")[0];
1478
+ const source = readBase64(filename);
1479
+ const family = baseNameWithoutExt(filename);
1433
1480
  const idSlug = `style-module-${slugify(family)}`;
1434
1481
  const ext = normalizedFileExt(filename);
1435
1482
  const mediaType = mediaTypeFromExt(ext);
@@ -1439,7 +1486,7 @@ function loadModuleFont(filename) {
1439
1486
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1440
1487
  }</style>`;
1441
1488
  }
1442
- function modifyHead(html, modulePaths, headFile) {
1489
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1443
1490
  const parts = [];
1444
1491
  if (modulePaths.length > 0) {
1445
1492
  const modules = loadModules(modulePaths).trim();
@@ -1447,9 +1494,13 @@ function modifyHead(html, modulePaths, headFile) {
1447
1494
  }
1448
1495
  if (headFile) {
1449
1496
  try {
1450
- const source = readUTF83(headFile).trim();
1497
+ const source = readUTF8(headFile).trim();
1451
1498
  if (source.length > 0) parts.push(source);
1452
- } catch {
1499
+ } catch (e) {
1500
+ diagnostics?.push({
1501
+ level: "warning",
1502
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
1503
+ });
1453
1504
  }
1454
1505
  }
1455
1506
  if (parts.length > 0) {
@@ -1460,7 +1511,7 @@ function modifyHead(html, modulePaths, headFile) {
1460
1511
  }
1461
1512
 
1462
1513
  // src/remote-formats.ts
1463
- import { mkdirSync, readFileSync as readFileSync5, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1514
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1464
1515
  import { join as join4 } from "path";
1465
1516
  import { homedir as homedir2 } from "os";
1466
1517
  var DEFAULT_SFA_INDICES = [
@@ -1476,6 +1527,24 @@ var indexCache = /* @__PURE__ */ new Map();
1476
1527
  function clearIndexCache() {
1477
1528
  indexCache.clear();
1478
1529
  }
1530
+ function isValidEntry(val) {
1531
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1532
+ const obj = val;
1533
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1534
+ }
1535
+ function validateSFAIndex(json) {
1536
+ if (typeof json !== "object" || json === null) {
1537
+ throw new Error("SFA index is not an object");
1538
+ }
1539
+ const obj = json;
1540
+ const twine1 = Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [];
1541
+ const twine2 = Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : [];
1542
+ return { twine1, twine2 };
1543
+ }
1544
+ function makeFormatId(name, version) {
1545
+ const major = parseSemver(version)?.[0] ?? "0";
1546
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1547
+ }
1479
1548
  async function fetchIndex(url) {
1480
1549
  const cached = indexCache.get(url);
1481
1550
  if (cached) return cached;
@@ -1483,37 +1552,42 @@ async function fetchIndex(url) {
1483
1552
  if (!res.ok) {
1484
1553
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1485
1554
  }
1486
- const data = await res.json();
1555
+ const data = validateSFAIndex(await res.json());
1487
1556
  indexCache.set(url, data);
1488
1557
  return data;
1489
1558
  }
1490
1559
  function findEntry(index, name, version) {
1491
1560
  const wanted = parseSemver(version);
1492
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1493
- let bestEntry;
1561
+ const searchArrays = [
1562
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1563
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1564
+ ];
1565
+ let bestResult;
1494
1566
  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;
1567
+ for (const { entries, formatType } of searchArrays) {
1568
+ for (const entry of entries) {
1569
+ if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1570
+ const have = parseSemver(entry.version);
1571
+ if (!have) continue;
1572
+ if (wanted && semverCompare(have, wanted) === 0) return { entry, formatType };
1573
+ if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1574
+ if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1575
+ bestVersion = have;
1576
+ bestResult = { entry, formatType };
1577
+ }
1504
1578
  }
1505
1579
  }
1506
1580
  }
1507
- return bestEntry;
1581
+ return bestResult;
1508
1582
  }
1509
1583
  async function verifySHA256(content, expectedHex) {
1510
1584
  const digest = await crypto.subtle.digest("SHA-256", content);
1511
1585
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1512
1586
  return hex === expectedHex.toLowerCase();
1513
1587
  }
1514
- function getDownloadUrl(indexUrl, entry) {
1588
+ function getDownloadUrl(indexUrl, entry, formatType) {
1515
1589
  const base = indexUrl.replace(/\/index\.json$/, "");
1516
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1590
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1517
1591
  }
1518
1592
  async function fetchAndCacheFormat(entry, downloadUrl) {
1519
1593
  const res = await fetch(downloadUrl);
@@ -1524,13 +1598,14 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1524
1598
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1525
1599
  if (checksumKey) {
1526
1600
  const expected = entry.checksums[checksumKey];
1601
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1527
1602
  const encoder = new TextEncoder();
1528
1603
  const valid = await verifySHA256(encoder.encode(text), expected);
1529
1604
  if (!valid) {
1530
1605
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1531
1606
  }
1532
1607
  }
1533
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1608
+ const id = makeFormatId(entry.name, entry.version);
1534
1609
  const data = parseFormatJSON(text, id);
1535
1610
  if (!data) {
1536
1611
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
@@ -1560,7 +1635,7 @@ async function fetchDirectFormat(url) {
1560
1635
  if (!data) {
1561
1636
  throw new Error(`Failed to parse format JSON from ${url}`);
1562
1637
  }
1563
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1638
+ const id = makeFormatId(data.name, data.version);
1564
1639
  const cacheDir = getCacheDir();
1565
1640
  const formatDir = join4(cacheDir, data.name, data.version);
1566
1641
  mkdirSync(formatDir, { recursive: true });
@@ -1576,6 +1651,7 @@ async function fetchDirectFormat(url) {
1576
1651
  };
1577
1652
  }
1578
1653
  async function resolveRemoteFormat(name, version, indices, urls) {
1654
+ let lastError;
1579
1655
  if (urls) {
1580
1656
  for (const url of urls) {
1581
1657
  try {
@@ -1587,7 +1663,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1587
1663
  return info;
1588
1664
  }
1589
1665
  }
1590
- } catch {
1666
+ } catch (e) {
1667
+ lastError = e instanceof Error ? e : new Error(String(e));
1591
1668
  }
1592
1669
  }
1593
1670
  }
@@ -1595,24 +1672,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1595
1672
  for (const indexUrl of allIndices) {
1596
1673
  try {
1597
1674
  const index = await fetchIndex(indexUrl);
1598
- const entry = findEntry(index, name, version);
1599
- if (entry) {
1600
- const cached = getCachedFormat(entry.name, entry.version);
1675
+ const result = findEntry(index, name, version);
1676
+ if (result) {
1677
+ const cached = getCachedFormat(result.entry.name, result.entry.version);
1601
1678
  if (cached) return cached;
1602
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1603
- return await fetchAndCacheFormat(entry, downloadUrl);
1679
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1680
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1604
1681
  }
1605
- } catch {
1682
+ } catch (e) {
1683
+ lastError = e instanceof Error ? e : new Error(String(e));
1606
1684
  }
1607
1685
  }
1686
+ if (lastError) throw lastError;
1608
1687
  return void 0;
1609
1688
  }
1610
1689
  function getCachedFormat(name, version) {
1611
1690
  const formatPath = join4(getCacheDir(), name, version, "format.js");
1612
1691
  try {
1613
1692
  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"}`;
1693
+ const source = readFileSync3(formatPath, "utf-8");
1694
+ const id = makeFormatId(name, version);
1616
1695
  const data = parseFormatJSON(source, id);
1617
1696
  if (!data) return void 0;
1618
1697
  return {
@@ -1629,6 +1708,7 @@ function getCachedFormat(name, version) {
1629
1708
  }
1630
1709
 
1631
1710
  // src/compiler.ts
1711
+ var CREATOR_NAME3 = "Twee-ts";
1632
1712
  var DEFAULT_FORMAT_ID = "sugarcube-2";
1633
1713
  var DEFAULT_START_NAME = "Start";
1634
1714
  var TweeTsError = class extends Error {
@@ -1664,10 +1744,7 @@ async function buildOutput(options) {
1664
1744
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1665
1745
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1666
1746
  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") {
1747
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1671
1748
  }
1672
1749
  let format;
1673
1750
  let formatId = options.formatId ?? "";
@@ -1686,7 +1763,11 @@ async function buildOutput(options) {
1686
1763
  const remoteVersion = story.twine2.formatVersion || "";
1687
1764
  try {
1688
1765
  format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1689
- } catch {
1766
+ } catch (e) {
1767
+ diagnostics.push({
1768
+ level: "warning",
1769
+ message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
1770
+ });
1690
1771
  }
1691
1772
  }
1692
1773
  if (!format) {
@@ -1697,14 +1778,7 @@ async function buildOutput(options) {
1697
1778
  });
1698
1779
  }
1699
1780
  }
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
- }
1781
+ const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
1708
1782
  if (testMode) {
1709
1783
  story.twine2.options.set("debug", true);
1710
1784
  }
@@ -1723,8 +1797,7 @@ async function buildOutput(options) {
1723
1797
  case "json":
1724
1798
  output = storyToJSON(story);
1725
1799
  break;
1726
- case "html":
1727
- default: {
1800
+ case "html": {
1728
1801
  if (!storyHas(story, startName)) {
1729
1802
  diagnostics.push({
1730
1803
  level: "error",
@@ -1746,9 +1819,13 @@ async function buildOutput(options) {
1746
1819
  output = toTwine1HTML(story, format, startName, diagnostics);
1747
1820
  }
1748
1821
  const modulePaths = options.modules ? getFilenames(options.modules) : [];
1749
- output = modifyHead(output, modulePaths, options.headFile);
1822
+ output = modifyHead(output, modulePaths, options.headFile, diagnostics);
1750
1823
  break;
1751
1824
  }
1825
+ default: {
1826
+ const _exhaustive = outputMode;
1827
+ throw new TweeTsError(`Unhandled output mode: ${_exhaustive}`, diagnostics);
1828
+ }
1752
1829
  }
1753
1830
  const stats = {
1754
1831
  ...getStoryStats(story),
@@ -1757,31 +1834,49 @@ async function buildOutput(options) {
1757
1834
  return { output, story, format, diagnostics, stats };
1758
1835
  }
1759
1836
  function storyToJSON(story) {
1760
- return JSON.stringify(
1761
- {
1762
- name: story.name,
1763
- ifid: story.ifid,
1764
- passages: story.passages.map((p) => ({
1837
+ const scripts = [];
1838
+ const stylesheets = [];
1839
+ const storyPassages = [];
1840
+ for (const p of story.passages) {
1841
+ if (p.name === "StoryTitle" || p.name === "StoryData") continue;
1842
+ if (hasTag(p, "Twine.private")) continue;
1843
+ if (hasTag(p, "script")) {
1844
+ scripts.push(p.text);
1845
+ } else if (hasTag(p, "stylesheet")) {
1846
+ stylesheets.push(p.text);
1847
+ } else {
1848
+ const entry = {
1765
1849
  name: p.name,
1766
1850
  tags: p.tags,
1767
- text: p.text,
1768
- metadata: p.metadata ?? null
1769
- })),
1770
- twine2: {
1771
- format: story.twine2.format,
1772
- formatVersion: story.twine2.formatVersion,
1773
- start: story.twine2.start,
1774
- tagColors: Object.fromEntries(story.twine2.tagColors),
1775
- options: Object.fromEntries(story.twine2.options),
1776
- zoom: story.twine2.zoom
1851
+ text: p.text
1852
+ };
1853
+ if (p.metadata && (p.metadata.position || p.metadata.size)) {
1854
+ const meta = {};
1855
+ if (p.metadata.position) meta.position = p.metadata.position;
1856
+ if (p.metadata.size) meta.size = p.metadata.size;
1857
+ entry.metadata = meta;
1777
1858
  }
1778
- },
1779
- null,
1780
- 2
1781
- );
1859
+ storyPassages.push(entry);
1860
+ }
1861
+ }
1862
+ const obj = {
1863
+ name: story.name
1864
+ };
1865
+ if (story.ifid) obj.ifid = story.ifid;
1866
+ if (story.twine2.format) obj.format = story.twine2.format;
1867
+ if (story.twine2.formatVersion) obj["format-version"] = story.twine2.formatVersion;
1868
+ if (story.twine2.start) obj.start = story.twine2.start;
1869
+ if (story.twine2.tagColors.size > 0) obj["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
1870
+ if (story.twine2.zoom !== 1) obj.zoom = story.twine2.zoom;
1871
+ obj.creator = CREATOR_NAME3;
1872
+ obj["creator-version"] = VERSION;
1873
+ obj.style = stylesheets.join("\n");
1874
+ obj.script = scripts.join("\n");
1875
+ obj.passages = storyPassages;
1876
+ return JSON.stringify(obj, null, 2);
1782
1877
  }
1783
1878
 
1784
1879
  export {
1785
1880
  compile
1786
1881
  };
1787
- //# sourceMappingURL=chunk-QTXRKA4T.js.map
1882
+ //# sourceMappingURL=chunk-OGP2NXLJ.js.map