@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.
package/dist/index.js CHANGED
@@ -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
  }
@@ -168,16 +181,16 @@ function passageToPassagedata(p, pid) {
168
181
  let position;
169
182
  let size;
170
183
  if (hasMetadataPosition(p)) {
171
- position = p.metadata.position;
184
+ position = p.metadata?.position ?? "";
172
185
  } else {
173
186
  const x = pid % 10;
174
- let y = Math.floor(pid / 10);
187
+ const y = Math.floor(pid / 10);
175
188
  const xp = x === 0 ? 10 : x;
176
189
  const yp = x === 0 ? y : y + 1;
177
190
  position = `${xp * 125 - 25},${yp * 125 - 25}`;
178
191
  }
179
192
  if (hasMetadataSize(p)) {
180
- size = p.metadata.size;
193
+ size = p.metadata?.size ?? "";
181
194
  } else {
182
195
  size = "100,100";
183
196
  }
@@ -186,10 +199,10 @@ function passageToPassagedata(p, pid) {
186
199
  function passageToTiddler(p, pid) {
187
200
  let position;
188
201
  if (hasMetadataPosition(p)) {
189
- position = p.metadata.position;
202
+ position = p.metadata?.position ?? "";
190
203
  } else {
191
204
  const x = pid % 10;
192
- let y = Math.floor(pid / 10);
205
+ const y = Math.floor(pid / 10);
193
206
  const xp = x === 0 ? 10 : x;
194
207
  const yp = x === 0 ? y : y + 1;
195
208
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
@@ -208,15 +221,17 @@ function countWords(p) {
208
221
  }
209
222
  function applyTagAliases(passages, aliases) {
210
223
  const entries = Object.entries(aliases);
211
- if (entries.length === 0) return;
212
- for (const p of passages) {
213
- const original = [...p.tags];
224
+ if (entries.length === 0) return [...passages];
225
+ return passages.map((p) => {
226
+ const original = p.tags;
227
+ const added = [];
214
228
  for (const [alias, canonical] of entries) {
215
- if (original.includes(alias) && !p.tags.includes(canonical)) {
216
- p.tags.push(canonical);
229
+ if (original.includes(alias) && !original.includes(canonical) && !added.includes(canonical)) {
230
+ added.push(canonical);
217
231
  }
218
232
  }
219
- }
233
+ return added.length > 0 ? { ...p, tags: [...original, ...added] } : p;
234
+ });
220
235
  }
221
236
  function quote(s) {
222
237
  return `"${s}"`;
@@ -288,27 +303,32 @@ function marshalStoryData(story) {
288
303
  return JSON.stringify(data, null, " ");
289
304
  }
290
305
  function unmarshalStoryData(story, json) {
291
- let data;
306
+ let raw;
292
307
  try {
293
- data = JSON.parse(json);
308
+ raw = JSON.parse(json);
294
309
  } catch (e) {
295
310
  return `Cannot unmarshal "StoryData"; ${e instanceof Error ? e.message : String(e)}`;
296
311
  }
297
- if (data.ifid) story.ifid = data.ifid.toUpperCase();
298
- if (data.format) story.twine2.format = data.format;
299
- if (data["format-version"]) story.twine2.formatVersion = data["format-version"];
300
- if (data.options) {
312
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
313
+ return 'Cannot unmarshal "StoryData"; expected a JSON object';
314
+ }
315
+ const data = raw;
316
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
317
+ if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
318
+ if (typeof data["format-version"] === "string" && data["format-version"])
319
+ story.twine2.formatVersion = data["format-version"];
320
+ if (Array.isArray(data.options)) {
301
321
  for (const opt of data.options) {
302
- story.twine2.options.set(opt, true);
322
+ if (typeof opt === "string") story.twine2.options.set(opt, true);
303
323
  }
304
324
  }
305
- if (data.start) story.twine2.start = data.start;
306
- if (data["tag-colors"]) {
325
+ if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
326
+ if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
307
327
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
308
- story.twine2.tagColors.set(tag, color);
328
+ if (typeof color === "string") story.twine2.tagColors.set(tag, color);
309
329
  }
310
330
  }
311
- if (data.zoom != null && data.zoom !== 0) story.twine2.zoom = data.zoom;
331
+ if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
312
332
  return null;
313
333
  }
314
334
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -351,6 +371,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
351
371
  }
352
372
  }
353
373
  function storyAdd(story, p, diagnostics) {
374
+ let processed = p;
354
375
  switch (p.name) {
355
376
  case "StoryIncludes":
356
377
  diagnostics.push({
@@ -370,7 +391,7 @@ function storyAdd(story, p, diagnostics) {
370
391
  });
371
392
  }
372
393
  }
373
- p.text = marshalStoryData(story);
394
+ processed = { ...p, text: marshalStoryData(story) };
374
395
  } else {
375
396
  diagnostics.push({
376
397
  level: "error",
@@ -382,12 +403,14 @@ function storyAdd(story, p, diagnostics) {
382
403
  case "StorySettings":
383
404
  unmarshalStorySettings(story, p.text, diagnostics);
384
405
  break;
385
- case "StoryTitle":
386
- p.text = p.text.trim();
387
- story.name = p.text;
406
+ case "StoryTitle": {
407
+ const trimmed = p.text.trim();
408
+ processed = { ...p, text: trimmed };
409
+ story.name = trimmed;
388
410
  break;
411
+ }
389
412
  }
390
- storyAppend(story, p, diagnostics);
413
+ storyAppend(story, processed, diagnostics);
391
414
  }
392
415
  function getStoryStats(story) {
393
416
  let storyPassages = 0;
@@ -441,7 +464,7 @@ var MEDIA_TYPE_MAP = {
441
464
  webm: "video/webm"
442
465
  };
443
466
  function mediaTypeFromExt(ext) {
444
- return MEDIA_TYPE_MAP[ext] ?? "";
467
+ return MEDIA_TYPE_MAP[ext] ?? "application/octet-stream";
445
468
  }
446
469
  function mediaTypeFromFilename(filename) {
447
470
  return mediaTypeFromExt(normalizedFileExt(filename));
@@ -463,8 +486,6 @@ var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
463
486
  "twee",
464
487
  "tw2",
465
488
  "twee2",
466
- "htm",
467
- "html",
468
489
  "css",
469
490
  "js",
470
491
  "otf",
@@ -574,23 +595,49 @@ function watchFilesystem(pathnames, outFilename, callback) {
574
595
  }
575
596
 
576
597
  // src/formats.ts
577
- import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
598
+ import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
578
599
  import { join as join2 } from "path";
579
600
  import { homedir } from "os";
601
+
602
+ // src/util.ts
603
+ import { readFileSync } from "fs";
604
+ import { parse as parsePath } from "path";
605
+ function readUTF8(filename) {
606
+ let content = readFileSync(filename, "utf-8");
607
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
608
+ content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
609
+ return content;
610
+ }
611
+ function readBase64(filename) {
612
+ return readFileSync(filename).toString("base64");
613
+ }
614
+ function baseNameWithoutExt(filename) {
615
+ const { name, ext } = parsePath(filename);
616
+ return name || ext;
617
+ }
618
+
619
+ // src/formats.ts
580
620
  function parseFormatJSON(source, formatId) {
581
621
  const first = source.indexOf("{");
582
622
  const last = source.lastIndexOf("}");
583
623
  if (first === -1 || last === -1) return null;
584
624
  let chunk = source.slice(first, last + 1);
625
+ const parse = (json) => {
626
+ const raw = JSON.parse(json);
627
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
628
+ const obj = raw;
629
+ if (typeof obj.name !== "string" || typeof obj.version !== "string" || typeof obj.source !== "string") return null;
630
+ return { name: obj.name, version: obj.version, source: obj.source, proofing: obj.proofing === true };
631
+ };
585
632
  try {
586
- return JSON.parse(chunk);
633
+ return parse(chunk);
587
634
  } catch {
588
635
  if (formatId.toLowerCase().startsWith("harlowe")) {
589
636
  const setupIdx = chunk.lastIndexOf(',"setup": function');
590
637
  if (setupIdx !== -1) {
591
638
  chunk = chunk.slice(0, setupIdx) + "}";
592
639
  try {
593
- return JSON.parse(chunk);
640
+ return parse(chunk);
594
641
  } catch {
595
642
  }
596
643
  }
@@ -598,12 +645,6 @@ function parseFormatJSON(source, formatId) {
598
645
  return null;
599
646
  }
600
647
  }
601
- function readUTF8(filename) {
602
- let content = readFileSync(filename, "utf-8");
603
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
604
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
605
- return content;
606
- }
607
648
  function discoverFormats(searchDirs) {
608
649
  const formats = /* @__PURE__ */ new Map();
609
650
  const baseFilenames = ["format.js", "header.html"];
@@ -698,9 +739,11 @@ function parseSemver(v) {
698
739
  return [Number(m[1]), Number(m[2]), Number(m[3])];
699
740
  }
700
741
  function semverCompare(a, b) {
701
- for (let i = 0; i < 3; i++) {
702
- if (a[i] !== b[i]) return a[i] - b[i];
703
- }
742
+ const [aMajor, aMinor, aPatch] = a;
743
+ const [bMajor, bMinor, bPatch] = b;
744
+ if (aMajor !== bMajor) return aMajor - bMajor;
745
+ if (aMinor !== bMinor) return aMinor - bMinor;
746
+ if (aPatch !== bPatch) return aPatch - bPatch;
704
747
  return 0;
705
748
  }
706
749
  function getFormatIdByNameAndVersion(formats, name, version) {
@@ -731,7 +774,6 @@ function readFormatSource(format) {
731
774
  }
732
775
 
733
776
  // src/loader.ts
734
- import { readFileSync as readFileSync2 } from "fs";
735
777
  import { basename } from "path";
736
778
 
737
779
  // src/lexer.ts
@@ -1024,13 +1066,13 @@ function hasTwee2Syntax(s) {
1024
1066
  }
1025
1067
  function twee2ToV3(s) {
1026
1068
  if (!hasTwee2Syntax(s)) return s;
1027
- s = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
1069
+ let result = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
1028
1070
  const tags = p2 ?? "";
1029
1071
  const pos = p3 ?? "";
1030
1072
  return `${p1}${tags} {"position":"${pos}"}`;
1031
1073
  });
1032
- s = s.replace(twee2BadPosRe, "$1");
1033
- return s;
1074
+ result = result.replace(twee2BadPosRe, "$1");
1075
+ return result;
1034
1076
  }
1035
1077
 
1036
1078
  // src/parser.ts
@@ -1108,7 +1150,11 @@ function parseTwee(source, options = {}) {
1108
1150
  return { passages, diagnostics };
1109
1151
  }
1110
1152
  try {
1111
- const parsed = JSON.parse(item.val);
1153
+ const raw = JSON.parse(item.val);
1154
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1155
+ throw new Error("expected a JSON object");
1156
+ }
1157
+ const parsed = raw;
1112
1158
  const meta = {};
1113
1159
  if (typeof parsed.position === "string") meta.position = parsed.position;
1114
1160
  if (typeof parsed.size === "string") meta.size = parsed.size;
@@ -1128,6 +1174,16 @@ function parseTwee(source, options = {}) {
1128
1174
  current.text = trim ? item.val.trim() : item.val;
1129
1175
  break;
1130
1176
  }
1177
+ default: {
1178
+ const _exhaustive = item.type;
1179
+ diagnostics.push({
1180
+ level: "error",
1181
+ message: `line ${item.line}: Unhandled lexer item type: ${_exhaustive}.`,
1182
+ file: filename,
1183
+ line: item.line
1184
+ });
1185
+ return { passages, diagnostics };
1186
+ }
1131
1187
  }
1132
1188
  lastType = item.type;
1133
1189
  }
@@ -1138,15 +1194,6 @@ function parseTwee(source, options = {}) {
1138
1194
  }
1139
1195
 
1140
1196
  // src/loader.ts
1141
- function readUTF82(filename) {
1142
- let content = readFileSync2(filename, "utf-8");
1143
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1144
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1145
- return content;
1146
- }
1147
- function readBase64(filename) {
1148
- return readFileSync2(filename).toString("base64");
1149
- }
1150
1197
  function loadSources(story, filenames, opts, diagnostics, processedFiles) {
1151
1198
  for (const filename of filenames) {
1152
1199
  if (processedFiles.has(filename)) {
@@ -1245,7 +1292,7 @@ function loadInlineSources(story, sources, opts, diagnostics) {
1245
1292
  }
1246
1293
  }
1247
1294
  function loadTwee(story, filename, opts, diagnostics) {
1248
- const source = readUTF82(filename);
1295
+ const source = readUTF8(filename);
1249
1296
  const result = parseTwee(source, {
1250
1297
  filename,
1251
1298
  trim: opts.trim ?? true,
@@ -1257,12 +1304,12 @@ function loadTwee(story, filename, opts, diagnostics) {
1257
1304
  }
1258
1305
  }
1259
1306
  function loadTagged(story, tag, filename, diagnostics) {
1260
- const source = readUTF82(filename);
1307
+ const source = readUTF8(filename);
1261
1308
  storyAdd(story, { name: basename(filename), tags: [tag], text: source }, diagnostics);
1262
1309
  }
1263
1310
  function loadMedia(story, tag, filename, diagnostics) {
1264
1311
  const source = readBase64(filename);
1265
- const name = basename(filename).split(".")[0];
1312
+ const name = baseNameWithoutExt(filename);
1266
1313
  storyAdd(
1267
1314
  story,
1268
1315
  {
@@ -1276,7 +1323,7 @@ function loadMedia(story, tag, filename, diagnostics) {
1276
1323
  function loadFont(story, filename, diagnostics) {
1277
1324
  const source = readBase64(filename);
1278
1325
  const name = basename(filename);
1279
- const family = name.split(".")[0];
1326
+ const family = baseNameWithoutExt(filename);
1280
1327
  const ext = normalizedFileExt(filename);
1281
1328
  const mediaType = mediaTypeFromExt(ext);
1282
1329
  const hint = fontFormatHint(ext);
@@ -1294,9 +1341,11 @@ function loadFont(story, filename, diagnostics) {
1294
1341
  );
1295
1342
  }
1296
1343
 
1344
+ // src/version.ts
1345
+ var VERSION = "1.2.0";
1346
+
1297
1347
  // src/output-twine2.ts
1298
1348
  var CREATOR_NAME = "Twee-ts";
1299
- var CREATOR_VERSION = "0.1.0";
1300
1349
  function toTwine2Archive(story, startName, diagnostics) {
1301
1350
  return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1302
1351
  }
@@ -1324,12 +1373,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1324
1373
  }
1325
1374
  let styleContent = "";
1326
1375
  if (stylesheets.length === 1) {
1327
- styleContent = stylesheets[0].text;
1376
+ styleContent = stylesheets[0]?.text ?? "";
1328
1377
  } else if (stylesheets.length > 1) {
1329
1378
  let pidS = 1;
1330
1379
  for (const p of stylesheets) {
1331
1380
  if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
1332
- styleContent += `/* twine-user-stylesheet #${pidS}: "${p.name}" */
1381
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */
1333
1382
  `;
1334
1383
  styleContent += p.text;
1335
1384
  pidS++;
@@ -1338,12 +1387,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1338
1387
  parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
1339
1388
  let scriptContent = "";
1340
1389
  if (scripts.length === 1) {
1341
- scriptContent = scripts[0].text;
1390
+ scriptContent = scripts[0]?.text ?? "";
1342
1391
  } else if (scripts.length > 1) {
1343
1392
  let pidS = 1;
1344
1393
  for (const p of scripts) {
1345
1394
  if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
1346
- scriptContent += `/* twine-user-script #${pidS}: "${p.name}" */
1395
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */
1347
1396
  `;
1348
1397
  scriptContent += p.text;
1349
1398
  pidS++;
@@ -1372,32 +1421,32 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1372
1421
  if (val) opts.push(opt);
1373
1422
  }
1374
1423
  const options = opts.join(" ");
1375
- const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
1376
- 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>`;
1424
+ const zoom = String(story.twine2.zoom);
1425
+ 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>`;
1377
1426
  return wrapper + parts.join("") + "</tw-storydata>";
1378
1427
  }
1379
1428
  function ensureIFID(story, diagnostics) {
1380
1429
  if (story.ifid !== "") return;
1381
1430
  if (story.legacyIFID !== "") {
1431
+ story.ifid = story.legacyIFID;
1382
1432
  diagnostics.push({
1383
1433
  level: "warning",
1384
1434
  message: 'Story IFID not found; reusing "ifid" entry from the "StorySettings" special passage.'
1385
1435
  });
1386
- story.ifid = story.legacyIFID;
1387
1436
  } else {
1388
- story.ifid = generateIFID();
1437
+ const ifid = generateIFID();
1438
+ story.ifid = ifid;
1389
1439
  diagnostics.push({
1390
- level: "warning",
1391
- message: `Story IFID not found; generated ${story.ifid} for your project. Add it to a StoryData passage.`
1440
+ level: "error",
1441
+ message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1392
1442
  });
1393
1443
  }
1394
1444
  }
1395
1445
 
1396
1446
  // src/output-twine1.ts
1397
- import { readFileSync as readFileSync3 } from "fs";
1447
+ import { readFileSync as readFileSync2 } from "fs";
1398
1448
  import { join as join3, dirname } from "path";
1399
1449
  var CREATOR_NAME2 = "twee-ts";
1400
- var CREATOR_VERSION2 = "0.1.0";
1401
1450
  function toTwine1Archive(story, _startName) {
1402
1451
  const { data, count } = getTwine1PassageChunk(story);
1403
1452
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
@@ -1419,26 +1468,27 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1419
1468
  template = tryReplaceComponent(template, '"MODERNIZR"', join3(parentDir, "modernizr.js"), true);
1420
1469
  }
1421
1470
  const displayStart = startName === "Start" ? "" : startName;
1422
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1471
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1423
1472
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1424
- template = template.replace('"START_AT"', `"${displayStart}"`);
1473
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1425
1474
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1426
1475
  if (template.includes('"STORY"')) {
1427
1476
  template = template.replace('"STORY"', data);
1428
1477
  } else {
1429
1478
  let footer;
1430
1479
  try {
1431
- footer = readFileSync3(join3(formatDir, "footer.html"), "utf-8");
1480
+ footer = readFileSync2(join3(formatDir, "footer.html"), "utf-8");
1432
1481
  } catch {
1433
1482
  footer = "</div>\n</body>\n</html>\n";
1434
1483
  }
1435
1484
  template += data + footer;
1436
1485
  }
1437
1486
  if (story.ifid) {
1487
+ const safeIfid = htmlCommentSanitize(story.ifid);
1438
1488
  if (template.includes('<div id="store-area"')) {
1439
- template = template.replace('<div id="store-area"', `<!-- UUID://${story.ifid}// --><div id="store-area"`);
1489
+ template = template.replace('<div id="store-area"', `<!-- UUID://${safeIfid}// --><div id="store-area"`);
1440
1490
  } else {
1441
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1491
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1442
1492
  }
1443
1493
  }
1444
1494
  return template;
@@ -1453,13 +1503,18 @@ function getTwine1PassageChunk(story) {
1453
1503
  }
1454
1504
  return { data, count };
1455
1505
  }
1456
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1506
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1457
1507
  if (!template.includes(placeholder)) return template;
1458
1508
  try {
1459
- let content = readFileSync3(componentPath, "utf-8");
1509
+ let content = readFileSync2(componentPath, "utf-8");
1460
1510
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1461
1511
  return template.replace(placeholder, content);
1462
- } catch {
1512
+ } catch (e) {
1513
+ if (required) {
1514
+ throw new Error(
1515
+ `Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`
1516
+ );
1517
+ }
1463
1518
  return template;
1464
1519
  }
1465
1520
  }
@@ -1474,16 +1529,6 @@ function toTwee(story, outMode) {
1474
1529
  }
1475
1530
 
1476
1531
  // src/modules.ts
1477
- import { readFileSync as readFileSync4 } from "fs";
1478
- import { basename as basename2 } from "path";
1479
- function readUTF83(filename) {
1480
- let content = readFileSync4(filename, "utf-8");
1481
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1482
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1483
- }
1484
- function readBase642(filename) {
1485
- return readFileSync4(filename).toString("base64");
1486
- }
1487
1532
  function loadModules(filenames) {
1488
1533
  const processed = /* @__PURE__ */ new Set();
1489
1534
  const headTags = [];
@@ -1513,16 +1558,16 @@ function loadModules(filenames) {
1513
1558
  return headTags.join("\n");
1514
1559
  }
1515
1560
  function loadModuleTagged(tag, filename) {
1516
- const source = readUTF83(filename).trim();
1561
+ const source = readUTF8(filename).trim();
1517
1562
  if (source.length === 0) return null;
1518
- const family = basename2(filename).split(".")[0];
1563
+ const family = baseNameWithoutExt(filename);
1519
1564
  const idSlug = `${tag}-module-${slugify(family)}`;
1520
1565
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1521
1566
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1522
1567
  }
1523
1568
  function loadModuleFont(filename) {
1524
- const source = readBase642(filename);
1525
- const family = basename2(filename).split(".")[0];
1569
+ const source = readBase64(filename);
1570
+ const family = baseNameWithoutExt(filename);
1526
1571
  const idSlug = `style-module-${slugify(family)}`;
1527
1572
  const ext = normalizedFileExt(filename);
1528
1573
  const mediaType = mediaTypeFromExt(ext);
@@ -1532,7 +1577,7 @@ function loadModuleFont(filename) {
1532
1577
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1533
1578
  }</style>`;
1534
1579
  }
1535
- function modifyHead(html, modulePaths, headFile) {
1580
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1536
1581
  const parts = [];
1537
1582
  if (modulePaths.length > 0) {
1538
1583
  const modules = loadModules(modulePaths).trim();
@@ -1540,9 +1585,13 @@ function modifyHead(html, modulePaths, headFile) {
1540
1585
  }
1541
1586
  if (headFile) {
1542
1587
  try {
1543
- const source = readUTF83(headFile).trim();
1588
+ const source = readUTF8(headFile).trim();
1544
1589
  if (source.length > 0) parts.push(source);
1545
- } catch {
1590
+ } catch (e) {
1591
+ diagnostics?.push({
1592
+ level: "warning",
1593
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
1594
+ });
1546
1595
  }
1547
1596
  }
1548
1597
  if (parts.length > 0) {
@@ -1553,7 +1602,7 @@ function modifyHead(html, modulePaths, headFile) {
1553
1602
  }
1554
1603
 
1555
1604
  // src/remote-formats.ts
1556
- import { mkdirSync, readFileSync as readFileSync5, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1605
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1557
1606
  import { join as join4 } from "path";
1558
1607
  import { homedir as homedir2 } from "os";
1559
1608
  var DEFAULT_SFA_INDICES = [
@@ -1569,6 +1618,24 @@ var indexCache = /* @__PURE__ */ new Map();
1569
1618
  function clearIndexCache() {
1570
1619
  indexCache.clear();
1571
1620
  }
1621
+ function isValidEntry(val) {
1622
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1623
+ const obj = val;
1624
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1625
+ }
1626
+ function validateSFAIndex(json) {
1627
+ if (typeof json !== "object" || json === null) {
1628
+ throw new Error("SFA index is not an object");
1629
+ }
1630
+ const obj = json;
1631
+ const twine1 = Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [];
1632
+ const twine2 = Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : [];
1633
+ return { twine1, twine2 };
1634
+ }
1635
+ function makeFormatId(name, version) {
1636
+ const major = parseSemver(version)?.[0] ?? "0";
1637
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1638
+ }
1572
1639
  async function fetchIndex(url) {
1573
1640
  const cached = indexCache.get(url);
1574
1641
  if (cached) return cached;
@@ -1576,37 +1643,42 @@ async function fetchIndex(url) {
1576
1643
  if (!res.ok) {
1577
1644
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1578
1645
  }
1579
- const data = await res.json();
1646
+ const data = validateSFAIndex(await res.json());
1580
1647
  indexCache.set(url, data);
1581
1648
  return data;
1582
1649
  }
1583
1650
  function findEntry(index, name, version) {
1584
1651
  const wanted = parseSemver(version);
1585
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1586
- let bestEntry;
1652
+ const searchArrays = [
1653
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1654
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1655
+ ];
1656
+ let bestResult;
1587
1657
  let bestVersion = null;
1588
- for (const entry of entries) {
1589
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1590
- const have = parseSemver(entry.version);
1591
- if (!have) continue;
1592
- if (wanted && semverCompare(have, wanted) === 0) return entry;
1593
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1594
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1595
- bestVersion = have;
1596
- bestEntry = entry;
1658
+ for (const { entries, formatType } of searchArrays) {
1659
+ for (const entry of entries) {
1660
+ if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1661
+ const have = parseSemver(entry.version);
1662
+ if (!have) continue;
1663
+ if (wanted && semverCompare(have, wanted) === 0) return { entry, formatType };
1664
+ if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1665
+ if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1666
+ bestVersion = have;
1667
+ bestResult = { entry, formatType };
1668
+ }
1597
1669
  }
1598
1670
  }
1599
1671
  }
1600
- return bestEntry;
1672
+ return bestResult;
1601
1673
  }
1602
1674
  async function verifySHA256(content, expectedHex) {
1603
1675
  const digest = await crypto.subtle.digest("SHA-256", content);
1604
1676
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1605
1677
  return hex === expectedHex.toLowerCase();
1606
1678
  }
1607
- function getDownloadUrl(indexUrl, entry) {
1679
+ function getDownloadUrl(indexUrl, entry, formatType) {
1608
1680
  const base = indexUrl.replace(/\/index\.json$/, "");
1609
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1681
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1610
1682
  }
1611
1683
  async function fetchAndCacheFormat(entry, downloadUrl) {
1612
1684
  const res = await fetch(downloadUrl);
@@ -1617,13 +1689,14 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1617
1689
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1618
1690
  if (checksumKey) {
1619
1691
  const expected = entry.checksums[checksumKey];
1692
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1620
1693
  const encoder = new TextEncoder();
1621
1694
  const valid = await verifySHA256(encoder.encode(text), expected);
1622
1695
  if (!valid) {
1623
1696
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1624
1697
  }
1625
1698
  }
1626
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1699
+ const id = makeFormatId(entry.name, entry.version);
1627
1700
  const data = parseFormatJSON(text, id);
1628
1701
  if (!data) {
1629
1702
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
@@ -1653,7 +1726,7 @@ async function fetchDirectFormat(url) {
1653
1726
  if (!data) {
1654
1727
  throw new Error(`Failed to parse format JSON from ${url}`);
1655
1728
  }
1656
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1729
+ const id = makeFormatId(data.name, data.version);
1657
1730
  const cacheDir = getCacheDir();
1658
1731
  const formatDir = join4(cacheDir, data.name, data.version);
1659
1732
  mkdirSync(formatDir, { recursive: true });
@@ -1669,6 +1742,7 @@ async function fetchDirectFormat(url) {
1669
1742
  };
1670
1743
  }
1671
1744
  async function resolveRemoteFormat(name, version, indices, urls) {
1745
+ let lastError;
1672
1746
  if (urls) {
1673
1747
  for (const url of urls) {
1674
1748
  try {
@@ -1680,7 +1754,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1680
1754
  return info;
1681
1755
  }
1682
1756
  }
1683
- } catch {
1757
+ } catch (e) {
1758
+ lastError = e instanceof Error ? e : new Error(String(e));
1684
1759
  }
1685
1760
  }
1686
1761
  }
@@ -1688,24 +1763,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1688
1763
  for (const indexUrl of allIndices) {
1689
1764
  try {
1690
1765
  const index = await fetchIndex(indexUrl);
1691
- const entry = findEntry(index, name, version);
1692
- if (entry) {
1693
- const cached = getCachedFormat(entry.name, entry.version);
1766
+ const result = findEntry(index, name, version);
1767
+ if (result) {
1768
+ const cached = getCachedFormat(result.entry.name, result.entry.version);
1694
1769
  if (cached) return cached;
1695
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1696
- return await fetchAndCacheFormat(entry, downloadUrl);
1770
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1771
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1697
1772
  }
1698
- } catch {
1773
+ } catch (e) {
1774
+ lastError = e instanceof Error ? e : new Error(String(e));
1699
1775
  }
1700
1776
  }
1777
+ if (lastError) throw lastError;
1701
1778
  return void 0;
1702
1779
  }
1703
1780
  function getCachedFormat(name, version) {
1704
1781
  const formatPath = join4(getCacheDir(), name, version, "format.js");
1705
1782
  try {
1706
1783
  if (!existsSync(formatPath)) return void 0;
1707
- const source = readFileSync5(formatPath, "utf-8");
1708
- const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(version)?.[0] ?? "0"}`;
1784
+ const source = readFileSync3(formatPath, "utf-8");
1785
+ const id = makeFormatId(name, version);
1709
1786
  const data = parseFormatJSON(source, id);
1710
1787
  if (!data) return void 0;
1711
1788
  return {
@@ -1758,6 +1835,7 @@ function discoverCachedFormats() {
1758
1835
  }
1759
1836
 
1760
1837
  // src/compiler.ts
1838
+ var CREATOR_NAME3 = "Twee-ts";
1761
1839
  var DEFAULT_FORMAT_ID = "sugarcube-2";
1762
1840
  var DEFAULT_START_NAME = "Start";
1763
1841
  var TweeTsError = class extends Error {
@@ -1814,10 +1892,7 @@ async function buildOutput(options) {
1814
1892
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1815
1893
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1816
1894
  if (options.tagAliases) {
1817
- applyTagAliases(story.passages, options.tagAliases);
1818
- }
1819
- const fatalErrors = diagnostics.filter((d) => d.level === "error");
1820
- if (fatalErrors.length > 0 && outputMode === "html") {
1895
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1821
1896
  }
1822
1897
  let format;
1823
1898
  let formatId = options.formatId ?? "";
@@ -1836,7 +1911,11 @@ async function buildOutput(options) {
1836
1911
  const remoteVersion = story.twine2.formatVersion || "";
1837
1912
  try {
1838
1913
  format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1839
- } catch {
1914
+ } catch (e) {
1915
+ diagnostics.push({
1916
+ level: "warning",
1917
+ message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
1918
+ });
1840
1919
  }
1841
1920
  }
1842
1921
  if (!format) {
@@ -1847,14 +1926,7 @@ async function buildOutput(options) {
1847
1926
  });
1848
1927
  }
1849
1928
  }
1850
- let startName;
1851
- if (options.startPassage) {
1852
- startName = options.startPassage;
1853
- } else if (story.twine2.start) {
1854
- startName = story.twine2.start;
1855
- } else {
1856
- startName = DEFAULT_START_NAME;
1857
- }
1929
+ const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
1858
1930
  if (testMode) {
1859
1931
  story.twine2.options.set("debug", true);
1860
1932
  }
@@ -1873,8 +1945,7 @@ async function buildOutput(options) {
1873
1945
  case "json":
1874
1946
  output = storyToJSON(story);
1875
1947
  break;
1876
- case "html":
1877
- default: {
1948
+ case "html": {
1878
1949
  if (!storyHas(story, startName)) {
1879
1950
  diagnostics.push({
1880
1951
  level: "error",
@@ -1896,9 +1967,13 @@ async function buildOutput(options) {
1896
1967
  output = toTwine1HTML(story, format, startName, diagnostics);
1897
1968
  }
1898
1969
  const modulePaths = options.modules ? getFilenames(options.modules) : [];
1899
- output = modifyHead(output, modulePaths, options.headFile);
1970
+ output = modifyHead(output, modulePaths, options.headFile, diagnostics);
1900
1971
  break;
1901
1972
  }
1973
+ default: {
1974
+ const _exhaustive = outputMode;
1975
+ throw new TweeTsError(`Unhandled output mode: ${_exhaustive}`, diagnostics);
1976
+ }
1902
1977
  }
1903
1978
  const stats = {
1904
1979
  ...getStoryStats(story),
@@ -1907,28 +1982,46 @@ async function buildOutput(options) {
1907
1982
  return { output, story, format, diagnostics, stats };
1908
1983
  }
1909
1984
  function storyToJSON(story) {
1910
- return JSON.stringify(
1911
- {
1912
- name: story.name,
1913
- ifid: story.ifid,
1914
- passages: story.passages.map((p) => ({
1985
+ const scripts = [];
1986
+ const stylesheets = [];
1987
+ const storyPassages = [];
1988
+ for (const p of story.passages) {
1989
+ if (p.name === "StoryTitle" || p.name === "StoryData") continue;
1990
+ if (hasTag(p, "Twine.private")) continue;
1991
+ if (hasTag(p, "script")) {
1992
+ scripts.push(p.text);
1993
+ } else if (hasTag(p, "stylesheet")) {
1994
+ stylesheets.push(p.text);
1995
+ } else {
1996
+ const entry = {
1915
1997
  name: p.name,
1916
1998
  tags: p.tags,
1917
- text: p.text,
1918
- metadata: p.metadata ?? null
1919
- })),
1920
- twine2: {
1921
- format: story.twine2.format,
1922
- formatVersion: story.twine2.formatVersion,
1923
- start: story.twine2.start,
1924
- tagColors: Object.fromEntries(story.twine2.tagColors),
1925
- options: Object.fromEntries(story.twine2.options),
1926
- zoom: story.twine2.zoom
1999
+ text: p.text
2000
+ };
2001
+ if (p.metadata && (p.metadata.position || p.metadata.size)) {
2002
+ const meta = {};
2003
+ if (p.metadata.position) meta.position = p.metadata.position;
2004
+ if (p.metadata.size) meta.size = p.metadata.size;
2005
+ entry.metadata = meta;
1927
2006
  }
1928
- },
1929
- null,
1930
- 2
1931
- );
2007
+ storyPassages.push(entry);
2008
+ }
2009
+ }
2010
+ const obj = {
2011
+ name: story.name
2012
+ };
2013
+ if (story.ifid) obj.ifid = story.ifid;
2014
+ if (story.twine2.format) obj.format = story.twine2.format;
2015
+ if (story.twine2.formatVersion) obj["format-version"] = story.twine2.formatVersion;
2016
+ if (story.twine2.start) obj.start = story.twine2.start;
2017
+ if (story.twine2.tagColors.size > 0) obj["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
2018
+ if (story.twine2.zoom !== 1) obj.zoom = story.twine2.zoom;
2019
+ obj.creator = CREATOR_NAME3;
2020
+ obj["creator-version"] = VERSION;
2021
+ obj.style = stylesheets.join("\n");
2022
+ obj.script = scripts.join("\n");
2023
+ obj.passages = storyPassages;
2024
+ return JSON.stringify(obj, null, 2);
1932
2025
  }
1933
2026
 
1934
2027
  // src/inspect.ts
@@ -2034,7 +2127,7 @@ function storyInspect(story) {
2034
2127
  }
2035
2128
 
2036
2129
  // src/config.ts
2037
- import { readFileSync as readFileSync6, existsSync as existsSync2 } from "fs";
2130
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
2038
2131
  import { join as join5 } from "path";
2039
2132
  var CONFIG_FILENAME = "twee-ts.config.json";
2040
2133
  var VALID_OUTPUT_MODES = ["html", "twee3", "twee1", "twine2-archive", "twine1-archive", "json"];
@@ -2044,7 +2137,7 @@ function loadConfig(dir) {
2044
2137
  if (!existsSync2(configPath)) return null;
2045
2138
  let raw;
2046
2139
  try {
2047
- raw = readFileSync6(configPath, "utf-8");
2140
+ raw = readFileSync4(configPath, "utf-8");
2048
2141
  } catch {
2049
2142
  return null;
2050
2143
  }
@@ -2064,7 +2157,7 @@ function loadConfig(dir) {
2064
2157
  function loadConfigFile(filePath) {
2065
2158
  let raw;
2066
2159
  try {
2067
- raw = readFileSync6(filePath, "utf-8");
2160
+ raw = readFileSync4(filePath, "utf-8");
2068
2161
  } catch (e) {
2069
2162
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
2070
2163
  }