@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.cjs CHANGED
@@ -50,7 +50,7 @@ __export(src_exports, {
50
50
  module.exports = __toCommonJS(src_exports);
51
51
 
52
52
  // src/compiler.ts
53
- var import_node_fs7 = require("fs");
53
+ var import_node_fs6 = require("fs");
54
54
 
55
55
  // src/ifid.ts
56
56
  var import_node_crypto = require("crypto");
@@ -120,13 +120,26 @@ function tweeEscape(s) {
120
120
  if (s.length === 0) return s;
121
121
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
122
122
  }
123
+ function jsStringEscape(s) {
124
+ if (s.length === 0) return s;
125
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
126
+ }
127
+ function commentSanitize(s) {
128
+ return s.replace(/\*\//g, "* /");
129
+ }
130
+ function htmlCommentSanitize(s) {
131
+ return s.replace(/-->/g, "-- >");
132
+ }
123
133
  function tweeUnescape(s) {
124
134
  if (s.length === 0) return s;
125
135
  let result = "";
126
136
  for (let i = 0; i < s.length; i++) {
127
137
  if (s[i] === "\\") {
128
138
  i++;
129
- if (i >= s.length) break;
139
+ if (i >= s.length) {
140
+ result += "\\";
141
+ break;
142
+ }
130
143
  }
131
144
  result += s[i];
132
145
  }
@@ -219,16 +232,16 @@ function passageToPassagedata(p, pid) {
219
232
  let position;
220
233
  let size;
221
234
  if (hasMetadataPosition(p)) {
222
- position = p.metadata.position;
235
+ position = p.metadata?.position ?? "";
223
236
  } else {
224
237
  const x = pid % 10;
225
- let y = Math.floor(pid / 10);
238
+ const y = Math.floor(pid / 10);
226
239
  const xp = x === 0 ? 10 : x;
227
240
  const yp = x === 0 ? y : y + 1;
228
241
  position = `${xp * 125 - 25},${yp * 125 - 25}`;
229
242
  }
230
243
  if (hasMetadataSize(p)) {
231
- size = p.metadata.size;
244
+ size = p.metadata?.size ?? "";
232
245
  } else {
233
246
  size = "100,100";
234
247
  }
@@ -237,10 +250,10 @@ function passageToPassagedata(p, pid) {
237
250
  function passageToTiddler(p, pid) {
238
251
  let position;
239
252
  if (hasMetadataPosition(p)) {
240
- position = p.metadata.position;
253
+ position = p.metadata?.position ?? "";
241
254
  } else {
242
255
  const x = pid % 10;
243
- let y = Math.floor(pid / 10);
256
+ const y = Math.floor(pid / 10);
244
257
  const xp = x === 0 ? 10 : x;
245
258
  const yp = x === 0 ? y : y + 1;
246
259
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
@@ -259,15 +272,17 @@ function countWords(p) {
259
272
  }
260
273
  function applyTagAliases(passages, aliases) {
261
274
  const entries = Object.entries(aliases);
262
- if (entries.length === 0) return;
263
- for (const p of passages) {
264
- const original = [...p.tags];
275
+ if (entries.length === 0) return [...passages];
276
+ return passages.map((p) => {
277
+ const original = p.tags;
278
+ const added = [];
265
279
  for (const [alias, canonical] of entries) {
266
- if (original.includes(alias) && !p.tags.includes(canonical)) {
267
- p.tags.push(canonical);
280
+ if (original.includes(alias) && !original.includes(canonical) && !added.includes(canonical)) {
281
+ added.push(canonical);
268
282
  }
269
283
  }
270
- }
284
+ return added.length > 0 ? { ...p, tags: [...original, ...added] } : p;
285
+ });
271
286
  }
272
287
  function quote(s) {
273
288
  return `"${s}"`;
@@ -339,27 +354,32 @@ function marshalStoryData(story) {
339
354
  return JSON.stringify(data, null, " ");
340
355
  }
341
356
  function unmarshalStoryData(story, json) {
342
- let data;
357
+ let raw;
343
358
  try {
344
- data = JSON.parse(json);
359
+ raw = JSON.parse(json);
345
360
  } catch (e) {
346
361
  return `Cannot unmarshal "StoryData"; ${e instanceof Error ? e.message : String(e)}`;
347
362
  }
348
- if (data.ifid) story.ifid = data.ifid.toUpperCase();
349
- if (data.format) story.twine2.format = data.format;
350
- if (data["format-version"]) story.twine2.formatVersion = data["format-version"];
351
- if (data.options) {
363
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
364
+ return 'Cannot unmarshal "StoryData"; expected a JSON object';
365
+ }
366
+ const data = raw;
367
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
368
+ if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
369
+ if (typeof data["format-version"] === "string" && data["format-version"])
370
+ story.twine2.formatVersion = data["format-version"];
371
+ if (Array.isArray(data.options)) {
352
372
  for (const opt of data.options) {
353
- story.twine2.options.set(opt, true);
373
+ if (typeof opt === "string") story.twine2.options.set(opt, true);
354
374
  }
355
375
  }
356
- if (data.start) story.twine2.start = data.start;
357
- if (data["tag-colors"]) {
376
+ if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
377
+ if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
358
378
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
359
- story.twine2.tagColors.set(tag, color);
379
+ if (typeof color === "string") story.twine2.tagColors.set(tag, color);
360
380
  }
361
381
  }
362
- if (data.zoom != null && data.zoom !== 0) story.twine2.zoom = data.zoom;
382
+ if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
363
383
  return null;
364
384
  }
365
385
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -402,6 +422,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
402
422
  }
403
423
  }
404
424
  function storyAdd(story, p, diagnostics) {
425
+ let processed = p;
405
426
  switch (p.name) {
406
427
  case "StoryIncludes":
407
428
  diagnostics.push({
@@ -421,7 +442,7 @@ function storyAdd(story, p, diagnostics) {
421
442
  });
422
443
  }
423
444
  }
424
- p.text = marshalStoryData(story);
445
+ processed = { ...p, text: marshalStoryData(story) };
425
446
  } else {
426
447
  diagnostics.push({
427
448
  level: "error",
@@ -433,12 +454,14 @@ function storyAdd(story, p, diagnostics) {
433
454
  case "StorySettings":
434
455
  unmarshalStorySettings(story, p.text, diagnostics);
435
456
  break;
436
- case "StoryTitle":
437
- p.text = p.text.trim();
438
- story.name = p.text;
457
+ case "StoryTitle": {
458
+ const trimmed = p.text.trim();
459
+ processed = { ...p, text: trimmed };
460
+ story.name = trimmed;
439
461
  break;
462
+ }
440
463
  }
441
- storyAppend(story, p, diagnostics);
464
+ storyAppend(story, processed, diagnostics);
442
465
  }
443
466
  function getStoryStats(story) {
444
467
  let storyPassages = 0;
@@ -492,7 +515,7 @@ var MEDIA_TYPE_MAP = {
492
515
  webm: "video/webm"
493
516
  };
494
517
  function mediaTypeFromExt(ext) {
495
- return MEDIA_TYPE_MAP[ext] ?? "";
518
+ return MEDIA_TYPE_MAP[ext] ?? "application/octet-stream";
496
519
  }
497
520
  function mediaTypeFromFilename(filename) {
498
521
  return mediaTypeFromExt(normalizedFileExt(filename));
@@ -514,8 +537,6 @@ var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
514
537
  "twee",
515
538
  "tw2",
516
539
  "twee2",
517
- "htm",
518
- "html",
519
540
  "css",
520
541
  "js",
521
542
  "otf",
@@ -625,23 +646,49 @@ function watchFilesystem(pathnames, outFilename, callback) {
625
646
  }
626
647
 
627
648
  // src/formats.ts
649
+ var import_node_fs3 = require("fs");
650
+ var import_node_path4 = require("path");
651
+ var import_node_os = require("os");
652
+
653
+ // src/util.ts
628
654
  var import_node_fs2 = require("fs");
629
655
  var import_node_path3 = require("path");
630
- var import_node_os = require("os");
656
+ function readUTF8(filename) {
657
+ let content = (0, import_node_fs2.readFileSync)(filename, "utf-8");
658
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
659
+ content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
660
+ return content;
661
+ }
662
+ function readBase64(filename) {
663
+ return (0, import_node_fs2.readFileSync)(filename).toString("base64");
664
+ }
665
+ function baseNameWithoutExt(filename) {
666
+ const { name, ext } = (0, import_node_path3.parse)(filename);
667
+ return name || ext;
668
+ }
669
+
670
+ // src/formats.ts
631
671
  function parseFormatJSON(source, formatId) {
632
672
  const first = source.indexOf("{");
633
673
  const last = source.lastIndexOf("}");
634
674
  if (first === -1 || last === -1) return null;
635
675
  let chunk = source.slice(first, last + 1);
676
+ const parse = (json) => {
677
+ const raw = JSON.parse(json);
678
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
679
+ const obj = raw;
680
+ if (typeof obj.name !== "string" || typeof obj.version !== "string" || typeof obj.source !== "string") return null;
681
+ return { name: obj.name, version: obj.version, source: obj.source, proofing: obj.proofing === true };
682
+ };
636
683
  try {
637
- return JSON.parse(chunk);
684
+ return parse(chunk);
638
685
  } catch {
639
686
  if (formatId.toLowerCase().startsWith("harlowe")) {
640
687
  const setupIdx = chunk.lastIndexOf(',"setup": function');
641
688
  if (setupIdx !== -1) {
642
689
  chunk = chunk.slice(0, setupIdx) + "}";
643
690
  try {
644
- return JSON.parse(chunk);
691
+ return parse(chunk);
645
692
  } catch {
646
693
  }
647
694
  }
@@ -649,39 +696,33 @@ function parseFormatJSON(source, formatId) {
649
696
  return null;
650
697
  }
651
698
  }
652
- function readUTF8(filename) {
653
- let content = (0, import_node_fs2.readFileSync)(filename, "utf-8");
654
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
655
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
656
- return content;
657
- }
658
699
  function discoverFormats(searchDirs) {
659
700
  const formats = /* @__PURE__ */ new Map();
660
701
  const baseFilenames = ["format.js", "header.html"];
661
702
  for (const searchDir of searchDirs) {
662
703
  try {
663
- const stat = (0, import_node_fs2.statSync)(searchDir);
704
+ const stat = (0, import_node_fs3.statSync)(searchDir);
664
705
  if (!stat.isDirectory()) continue;
665
706
  } catch {
666
707
  continue;
667
708
  }
668
709
  let entries;
669
710
  try {
670
- entries = (0, import_node_fs2.readdirSync)(searchDir);
711
+ entries = (0, import_node_fs3.readdirSync)(searchDir);
671
712
  } catch {
672
713
  continue;
673
714
  }
674
715
  for (const entry of entries) {
675
- const formatDir = (0, import_node_path3.join)(searchDir, entry);
716
+ const formatDir = (0, import_node_path4.join)(searchDir, entry);
676
717
  try {
677
- if (!(0, import_node_fs2.statSync)(formatDir).isDirectory()) continue;
718
+ if (!(0, import_node_fs3.statSync)(formatDir).isDirectory()) continue;
678
719
  } catch {
679
720
  continue;
680
721
  }
681
722
  for (const baseFilename of baseFilenames) {
682
- const formatFilename = (0, import_node_path3.join)(formatDir, baseFilename);
723
+ const formatFilename = (0, import_node_path4.join)(formatDir, baseFilename);
683
724
  try {
684
- const stat = (0, import_node_fs2.statSync)(formatFilename);
725
+ const stat = (0, import_node_fs3.statSync)(formatFilename);
685
726
  if (!stat.isFile()) continue;
686
727
  } catch {
687
728
  continue;
@@ -725,9 +766,9 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
725
766
  const dirs = [];
726
767
  for (const base of basePaths) {
727
768
  for (const sub of subdirNames) {
728
- const dir = (0, import_node_path3.join)(base, sub);
769
+ const dir = (0, import_node_path4.join)(base, sub);
729
770
  try {
730
- if ((0, import_node_fs2.statSync)(dir).isDirectory()) {
771
+ if ((0, import_node_fs3.statSync)(dir).isDirectory()) {
731
772
  dirs.push(dir);
732
773
  }
733
774
  } catch {
@@ -749,9 +790,11 @@ function parseSemver(v) {
749
790
  return [Number(m[1]), Number(m[2]), Number(m[3])];
750
791
  }
751
792
  function semverCompare(a, b) {
752
- for (let i = 0; i < 3; i++) {
753
- if (a[i] !== b[i]) return a[i] - b[i];
754
- }
793
+ const [aMajor, aMinor, aPatch] = a;
794
+ const [bMajor, bMinor, bPatch] = b;
795
+ if (aMajor !== bMajor) return aMajor - bMajor;
796
+ if (aMinor !== bMinor) return aMinor - bMinor;
797
+ if (aPatch !== bPatch) return aPatch - bPatch;
755
798
  return 0;
756
799
  }
757
800
  function getFormatIdByNameAndVersion(formats, name, version) {
@@ -782,8 +825,7 @@ function readFormatSource(format) {
782
825
  }
783
826
 
784
827
  // src/loader.ts
785
- var import_node_fs3 = require("fs");
786
- var import_node_path4 = require("path");
828
+ var import_node_path5 = require("path");
787
829
 
788
830
  // src/lexer.ts
789
831
  var EOF = -1;
@@ -1075,13 +1117,13 @@ function hasTwee2Syntax(s) {
1075
1117
  }
1076
1118
  function twee2ToV3(s) {
1077
1119
  if (!hasTwee2Syntax(s)) return s;
1078
- s = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
1120
+ let result = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
1079
1121
  const tags = p2 ?? "";
1080
1122
  const pos = p3 ?? "";
1081
1123
  return `${p1}${tags} {"position":"${pos}"}`;
1082
1124
  });
1083
- s = s.replace(twee2BadPosRe, "$1");
1084
- return s;
1125
+ result = result.replace(twee2BadPosRe, "$1");
1126
+ return result;
1085
1127
  }
1086
1128
 
1087
1129
  // src/parser.ts
@@ -1159,7 +1201,11 @@ function parseTwee(source, options = {}) {
1159
1201
  return { passages, diagnostics };
1160
1202
  }
1161
1203
  try {
1162
- const parsed = JSON.parse(item.val);
1204
+ const raw = JSON.parse(item.val);
1205
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1206
+ throw new Error("expected a JSON object");
1207
+ }
1208
+ const parsed = raw;
1163
1209
  const meta = {};
1164
1210
  if (typeof parsed.position === "string") meta.position = parsed.position;
1165
1211
  if (typeof parsed.size === "string") meta.size = parsed.size;
@@ -1179,6 +1225,16 @@ function parseTwee(source, options = {}) {
1179
1225
  current.text = trim ? item.val.trim() : item.val;
1180
1226
  break;
1181
1227
  }
1228
+ default: {
1229
+ const _exhaustive = item.type;
1230
+ diagnostics.push({
1231
+ level: "error",
1232
+ message: `line ${item.line}: Unhandled lexer item type: ${_exhaustive}.`,
1233
+ file: filename,
1234
+ line: item.line
1235
+ });
1236
+ return { passages, diagnostics };
1237
+ }
1182
1238
  }
1183
1239
  lastType = item.type;
1184
1240
  }
@@ -1189,15 +1245,6 @@ function parseTwee(source, options = {}) {
1189
1245
  }
1190
1246
 
1191
1247
  // src/loader.ts
1192
- function readUTF82(filename) {
1193
- let content = (0, import_node_fs3.readFileSync)(filename, "utf-8");
1194
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1195
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1196
- return content;
1197
- }
1198
- function readBase64(filename) {
1199
- return (0, import_node_fs3.readFileSync)(filename).toString("base64");
1200
- }
1201
1248
  function loadSources(story, filenames, opts, diagnostics, processedFiles) {
1202
1249
  for (const filename of filenames) {
1203
1250
  if (processedFiles.has(filename)) {
@@ -1296,7 +1343,7 @@ function loadInlineSources(story, sources, opts, diagnostics) {
1296
1343
  }
1297
1344
  }
1298
1345
  function loadTwee(story, filename, opts, diagnostics) {
1299
- const source = readUTF82(filename);
1346
+ const source = readUTF8(filename);
1300
1347
  const result = parseTwee(source, {
1301
1348
  filename,
1302
1349
  trim: opts.trim ?? true,
@@ -1308,12 +1355,12 @@ function loadTwee(story, filename, opts, diagnostics) {
1308
1355
  }
1309
1356
  }
1310
1357
  function loadTagged(story, tag, filename, diagnostics) {
1311
- const source = readUTF82(filename);
1312
- storyAdd(story, { name: (0, import_node_path4.basename)(filename), tags: [tag], text: source }, diagnostics);
1358
+ const source = readUTF8(filename);
1359
+ storyAdd(story, { name: (0, import_node_path5.basename)(filename), tags: [tag], text: source }, diagnostics);
1313
1360
  }
1314
1361
  function loadMedia(story, tag, filename, diagnostics) {
1315
1362
  const source = readBase64(filename);
1316
- const name = (0, import_node_path4.basename)(filename).split(".")[0];
1363
+ const name = baseNameWithoutExt(filename);
1317
1364
  storyAdd(
1318
1365
  story,
1319
1366
  {
@@ -1326,8 +1373,8 @@ function loadMedia(story, tag, filename, diagnostics) {
1326
1373
  }
1327
1374
  function loadFont(story, filename, diagnostics) {
1328
1375
  const source = readBase64(filename);
1329
- const name = (0, import_node_path4.basename)(filename);
1330
- const family = name.split(".")[0];
1376
+ const name = (0, import_node_path5.basename)(filename);
1377
+ const family = baseNameWithoutExt(filename);
1331
1378
  const ext = normalizedFileExt(filename);
1332
1379
  const mediaType = mediaTypeFromExt(ext);
1333
1380
  const hint = fontFormatHint(ext);
@@ -1345,9 +1392,11 @@ function loadFont(story, filename, diagnostics) {
1345
1392
  );
1346
1393
  }
1347
1394
 
1395
+ // src/version.ts
1396
+ var VERSION = "1.2.0";
1397
+
1348
1398
  // src/output-twine2.ts
1349
1399
  var CREATOR_NAME = "Twee-ts";
1350
- var CREATOR_VERSION = "0.1.0";
1351
1400
  function toTwine2Archive(story, startName, diagnostics) {
1352
1401
  return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1353
1402
  }
@@ -1375,12 +1424,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1375
1424
  }
1376
1425
  let styleContent = "";
1377
1426
  if (stylesheets.length === 1) {
1378
- styleContent = stylesheets[0].text;
1427
+ styleContent = stylesheets[0]?.text ?? "";
1379
1428
  } else if (stylesheets.length > 1) {
1380
1429
  let pidS = 1;
1381
1430
  for (const p of stylesheets) {
1382
1431
  if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
1383
- styleContent += `/* twine-user-stylesheet #${pidS}: "${p.name}" */
1432
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */
1384
1433
  `;
1385
1434
  styleContent += p.text;
1386
1435
  pidS++;
@@ -1389,12 +1438,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1389
1438
  parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
1390
1439
  let scriptContent = "";
1391
1440
  if (scripts.length === 1) {
1392
- scriptContent = scripts[0].text;
1441
+ scriptContent = scripts[0]?.text ?? "";
1393
1442
  } else if (scripts.length > 1) {
1394
1443
  let pidS = 1;
1395
1444
  for (const p of scripts) {
1396
1445
  if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
1397
- scriptContent += `/* twine-user-script #${pidS}: "${p.name}" */
1446
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */
1398
1447
  `;
1399
1448
  scriptContent += p.text;
1400
1449
  pidS++;
@@ -1423,73 +1472,74 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1423
1472
  if (val) opts.push(opt);
1424
1473
  }
1425
1474
  const options = opts.join(" ");
1426
- const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
1427
- 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>`;
1475
+ const zoom = String(story.twine2.zoom);
1476
+ 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>`;
1428
1477
  return wrapper + parts.join("") + "</tw-storydata>";
1429
1478
  }
1430
1479
  function ensureIFID(story, diagnostics) {
1431
1480
  if (story.ifid !== "") return;
1432
1481
  if (story.legacyIFID !== "") {
1482
+ story.ifid = story.legacyIFID;
1433
1483
  diagnostics.push({
1434
1484
  level: "warning",
1435
1485
  message: 'Story IFID not found; reusing "ifid" entry from the "StorySettings" special passage.'
1436
1486
  });
1437
- story.ifid = story.legacyIFID;
1438
1487
  } else {
1439
- story.ifid = generateIFID();
1488
+ const ifid = generateIFID();
1489
+ story.ifid = ifid;
1440
1490
  diagnostics.push({
1441
- level: "warning",
1442
- message: `Story IFID not found; generated ${story.ifid} for your project. Add it to a StoryData passage.`
1491
+ level: "error",
1492
+ message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1443
1493
  });
1444
1494
  }
1445
1495
  }
1446
1496
 
1447
1497
  // src/output-twine1.ts
1448
1498
  var import_node_fs4 = require("fs");
1449
- var import_node_path5 = require("path");
1499
+ var import_node_path6 = require("path");
1450
1500
  var CREATOR_NAME2 = "twee-ts";
1451
- var CREATOR_VERSION2 = "0.1.0";
1452
1501
  function toTwine1Archive(story, _startName) {
1453
1502
  const { data, count } = getTwine1PassageChunk(story);
1454
1503
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
1455
1504
  `;
1456
1505
  }
1457
1506
  function toTwine1HTML(story, format, startName, _diagnostics) {
1458
- const formatDir = (0, import_node_path5.dirname)(format.filename);
1459
- const parentDir = (0, import_node_path5.dirname)(formatDir);
1507
+ const formatDir = (0, import_node_path6.dirname)(format.filename);
1508
+ const parentDir = (0, import_node_path6.dirname)(formatDir);
1460
1509
  let template = readFormatSource(format);
1461
1510
  const { data, count } = getTwine1PassageChunk(story);
1462
- template = tryReplaceComponent(template, '"USER_LIB"', (0, import_node_path5.join)(formatDir, "userlib.js"), false);
1463
- template = tryReplaceComponent(template, '"ENGINE"', (0, import_node_path5.join)(parentDir, "engine.js"), true);
1464
- template = tryReplaceComponent(template, '"SUGARCANE"', (0, import_node_path5.join)(formatDir, "code.js"), true);
1465
- template = tryReplaceComponent(template, '"JONAH"', (0, import_node_path5.join)(formatDir, "code.js"), true);
1511
+ template = tryReplaceComponent(template, '"USER_LIB"', (0, import_node_path6.join)(formatDir, "userlib.js"), false);
1512
+ template = tryReplaceComponent(template, '"ENGINE"', (0, import_node_path6.join)(parentDir, "engine.js"), true);
1513
+ template = tryReplaceComponent(template, '"SUGARCANE"', (0, import_node_path6.join)(formatDir, "code.js"), true);
1514
+ template = tryReplaceComponent(template, '"JONAH"', (0, import_node_path6.join)(formatDir, "code.js"), true);
1466
1515
  if (story.twine1.settings.get("jquery") === "on") {
1467
- template = tryReplaceComponent(template, '"JQUERY"', (0, import_node_path5.join)(parentDir, "jquery.js"), true);
1516
+ template = tryReplaceComponent(template, '"JQUERY"', (0, import_node_path6.join)(parentDir, "jquery.js"), true);
1468
1517
  }
1469
1518
  if (story.twine1.settings.get("modernizr") === "on") {
1470
- template = tryReplaceComponent(template, '"MODERNIZR"', (0, import_node_path5.join)(parentDir, "modernizr.js"), true);
1519
+ template = tryReplaceComponent(template, '"MODERNIZR"', (0, import_node_path6.join)(parentDir, "modernizr.js"), true);
1471
1520
  }
1472
1521
  const displayStart = startName === "Start" ? "" : startName;
1473
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1522
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1474
1523
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1475
- template = template.replace('"START_AT"', `"${displayStart}"`);
1524
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1476
1525
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1477
1526
  if (template.includes('"STORY"')) {
1478
1527
  template = template.replace('"STORY"', data);
1479
1528
  } else {
1480
1529
  let footer;
1481
1530
  try {
1482
- footer = (0, import_node_fs4.readFileSync)((0, import_node_path5.join)(formatDir, "footer.html"), "utf-8");
1531
+ footer = (0, import_node_fs4.readFileSync)((0, import_node_path6.join)(formatDir, "footer.html"), "utf-8");
1483
1532
  } catch {
1484
1533
  footer = "</div>\n</body>\n</html>\n";
1485
1534
  }
1486
1535
  template += data + footer;
1487
1536
  }
1488
1537
  if (story.ifid) {
1538
+ const safeIfid = htmlCommentSanitize(story.ifid);
1489
1539
  if (template.includes('<div id="store-area"')) {
1490
- template = template.replace('<div id="store-area"', `<!-- UUID://${story.ifid}// --><div id="store-area"`);
1540
+ template = template.replace('<div id="store-area"', `<!-- UUID://${safeIfid}// --><div id="store-area"`);
1491
1541
  } else {
1492
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1542
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1493
1543
  }
1494
1544
  }
1495
1545
  return template;
@@ -1504,13 +1554,18 @@ function getTwine1PassageChunk(story) {
1504
1554
  }
1505
1555
  return { data, count };
1506
1556
  }
1507
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1557
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1508
1558
  if (!template.includes(placeholder)) return template;
1509
1559
  try {
1510
1560
  let content = (0, import_node_fs4.readFileSync)(componentPath, "utf-8");
1511
1561
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1512
1562
  return template.replace(placeholder, content);
1513
- } catch {
1563
+ } catch (e) {
1564
+ if (required) {
1565
+ throw new Error(
1566
+ `Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`
1567
+ );
1568
+ }
1514
1569
  return template;
1515
1570
  }
1516
1571
  }
@@ -1525,16 +1580,6 @@ function toTwee(story, outMode) {
1525
1580
  }
1526
1581
 
1527
1582
  // src/modules.ts
1528
- var import_node_fs5 = require("fs");
1529
- var import_node_path6 = require("path");
1530
- function readUTF83(filename) {
1531
- let content = (0, import_node_fs5.readFileSync)(filename, "utf-8");
1532
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1533
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1534
- }
1535
- function readBase642(filename) {
1536
- return (0, import_node_fs5.readFileSync)(filename).toString("base64");
1537
- }
1538
1583
  function loadModules(filenames) {
1539
1584
  const processed = /* @__PURE__ */ new Set();
1540
1585
  const headTags = [];
@@ -1564,16 +1609,16 @@ function loadModules(filenames) {
1564
1609
  return headTags.join("\n");
1565
1610
  }
1566
1611
  function loadModuleTagged(tag, filename) {
1567
- const source = readUTF83(filename).trim();
1612
+ const source = readUTF8(filename).trim();
1568
1613
  if (source.length === 0) return null;
1569
- const family = (0, import_node_path6.basename)(filename).split(".")[0];
1614
+ const family = baseNameWithoutExt(filename);
1570
1615
  const idSlug = `${tag}-module-${slugify(family)}`;
1571
1616
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1572
1617
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1573
1618
  }
1574
1619
  function loadModuleFont(filename) {
1575
- const source = readBase642(filename);
1576
- const family = (0, import_node_path6.basename)(filename).split(".")[0];
1620
+ const source = readBase64(filename);
1621
+ const family = baseNameWithoutExt(filename);
1577
1622
  const idSlug = `style-module-${slugify(family)}`;
1578
1623
  const ext = normalizedFileExt(filename);
1579
1624
  const mediaType = mediaTypeFromExt(ext);
@@ -1583,7 +1628,7 @@ function loadModuleFont(filename) {
1583
1628
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1584
1629
  }</style>`;
1585
1630
  }
1586
- function modifyHead(html, modulePaths, headFile) {
1631
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1587
1632
  const parts = [];
1588
1633
  if (modulePaths.length > 0) {
1589
1634
  const modules = loadModules(modulePaths).trim();
@@ -1591,9 +1636,13 @@ function modifyHead(html, modulePaths, headFile) {
1591
1636
  }
1592
1637
  if (headFile) {
1593
1638
  try {
1594
- const source = readUTF83(headFile).trim();
1639
+ const source = readUTF8(headFile).trim();
1595
1640
  if (source.length > 0) parts.push(source);
1596
- } catch {
1641
+ } catch (e) {
1642
+ diagnostics?.push({
1643
+ level: "warning",
1644
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
1645
+ });
1597
1646
  }
1598
1647
  }
1599
1648
  if (parts.length > 0) {
@@ -1604,7 +1653,7 @@ function modifyHead(html, modulePaths, headFile) {
1604
1653
  }
1605
1654
 
1606
1655
  // src/remote-formats.ts
1607
- var import_node_fs6 = require("fs");
1656
+ var import_node_fs5 = require("fs");
1608
1657
  var import_node_path7 = require("path");
1609
1658
  var import_node_os2 = require("os");
1610
1659
  var DEFAULT_SFA_INDICES = [
@@ -1620,6 +1669,24 @@ var indexCache = /* @__PURE__ */ new Map();
1620
1669
  function clearIndexCache() {
1621
1670
  indexCache.clear();
1622
1671
  }
1672
+ function isValidEntry(val) {
1673
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1674
+ const obj = val;
1675
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1676
+ }
1677
+ function validateSFAIndex(json) {
1678
+ if (typeof json !== "object" || json === null) {
1679
+ throw new Error("SFA index is not an object");
1680
+ }
1681
+ const obj = json;
1682
+ const twine1 = Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [];
1683
+ const twine2 = Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : [];
1684
+ return { twine1, twine2 };
1685
+ }
1686
+ function makeFormatId(name, version) {
1687
+ const major = parseSemver(version)?.[0] ?? "0";
1688
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1689
+ }
1623
1690
  async function fetchIndex(url) {
1624
1691
  const cached = indexCache.get(url);
1625
1692
  if (cached) return cached;
@@ -1627,37 +1694,42 @@ async function fetchIndex(url) {
1627
1694
  if (!res.ok) {
1628
1695
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1629
1696
  }
1630
- const data = await res.json();
1697
+ const data = validateSFAIndex(await res.json());
1631
1698
  indexCache.set(url, data);
1632
1699
  return data;
1633
1700
  }
1634
1701
  function findEntry(index, name, version) {
1635
1702
  const wanted = parseSemver(version);
1636
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1637
- let bestEntry;
1703
+ const searchArrays = [
1704
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1705
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1706
+ ];
1707
+ let bestResult;
1638
1708
  let bestVersion = null;
1639
- for (const entry of entries) {
1640
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1641
- const have = parseSemver(entry.version);
1642
- if (!have) continue;
1643
- if (wanted && semverCompare(have, wanted) === 0) return entry;
1644
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1645
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1646
- bestVersion = have;
1647
- bestEntry = entry;
1709
+ for (const { entries, formatType } of searchArrays) {
1710
+ for (const entry of entries) {
1711
+ if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1712
+ const have = parseSemver(entry.version);
1713
+ if (!have) continue;
1714
+ if (wanted && semverCompare(have, wanted) === 0) return { entry, formatType };
1715
+ if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1716
+ if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1717
+ bestVersion = have;
1718
+ bestResult = { entry, formatType };
1719
+ }
1648
1720
  }
1649
1721
  }
1650
1722
  }
1651
- return bestEntry;
1723
+ return bestResult;
1652
1724
  }
1653
1725
  async function verifySHA256(content, expectedHex) {
1654
1726
  const digest = await crypto.subtle.digest("SHA-256", content);
1655
1727
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1656
1728
  return hex === expectedHex.toLowerCase();
1657
1729
  }
1658
- function getDownloadUrl(indexUrl, entry) {
1730
+ function getDownloadUrl(indexUrl, entry, formatType) {
1659
1731
  const base = indexUrl.replace(/\/index\.json$/, "");
1660
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1732
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1661
1733
  }
1662
1734
  async function fetchAndCacheFormat(entry, downloadUrl) {
1663
1735
  const res = await fetch(downloadUrl);
@@ -1668,22 +1740,23 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1668
1740
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1669
1741
  if (checksumKey) {
1670
1742
  const expected = entry.checksums[checksumKey];
1743
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1671
1744
  const encoder = new TextEncoder();
1672
1745
  const valid = await verifySHA256(encoder.encode(text), expected);
1673
1746
  if (!valid) {
1674
1747
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1675
1748
  }
1676
1749
  }
1677
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1750
+ const id = makeFormatId(entry.name, entry.version);
1678
1751
  const data = parseFormatJSON(text, id);
1679
1752
  if (!data) {
1680
1753
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1681
1754
  }
1682
1755
  const cacheDir = getCacheDir();
1683
1756
  const formatDir = (0, import_node_path7.join)(cacheDir, entry.name, entry.version);
1684
- (0, import_node_fs6.mkdirSync)(formatDir, { recursive: true });
1757
+ (0, import_node_fs5.mkdirSync)(formatDir, { recursive: true });
1685
1758
  const formatPath = (0, import_node_path7.join)(formatDir, "format.js");
1686
- (0, import_node_fs6.writeFileSync)(formatPath, text, "utf-8");
1759
+ (0, import_node_fs5.writeFileSync)(formatPath, text, "utf-8");
1687
1760
  return {
1688
1761
  id,
1689
1762
  filename: formatPath,
@@ -1704,12 +1777,12 @@ async function fetchDirectFormat(url) {
1704
1777
  if (!data) {
1705
1778
  throw new Error(`Failed to parse format JSON from ${url}`);
1706
1779
  }
1707
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1780
+ const id = makeFormatId(data.name, data.version);
1708
1781
  const cacheDir = getCacheDir();
1709
1782
  const formatDir = (0, import_node_path7.join)(cacheDir, data.name, data.version);
1710
- (0, import_node_fs6.mkdirSync)(formatDir, { recursive: true });
1783
+ (0, import_node_fs5.mkdirSync)(formatDir, { recursive: true });
1711
1784
  const formatPath = (0, import_node_path7.join)(formatDir, "format.js");
1712
- (0, import_node_fs6.writeFileSync)(formatPath, text, "utf-8");
1785
+ (0, import_node_fs5.writeFileSync)(formatPath, text, "utf-8");
1713
1786
  return {
1714
1787
  id,
1715
1788
  filename: formatPath,
@@ -1720,6 +1793,7 @@ async function fetchDirectFormat(url) {
1720
1793
  };
1721
1794
  }
1722
1795
  async function resolveRemoteFormat(name, version, indices, urls) {
1796
+ let lastError;
1723
1797
  if (urls) {
1724
1798
  for (const url of urls) {
1725
1799
  try {
@@ -1731,7 +1805,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1731
1805
  return info;
1732
1806
  }
1733
1807
  }
1734
- } catch {
1808
+ } catch (e) {
1809
+ lastError = e instanceof Error ? e : new Error(String(e));
1735
1810
  }
1736
1811
  }
1737
1812
  }
@@ -1739,24 +1814,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1739
1814
  for (const indexUrl of allIndices) {
1740
1815
  try {
1741
1816
  const index = await fetchIndex(indexUrl);
1742
- const entry = findEntry(index, name, version);
1743
- if (entry) {
1744
- const cached = getCachedFormat(entry.name, entry.version);
1817
+ const result = findEntry(index, name, version);
1818
+ if (result) {
1819
+ const cached = getCachedFormat(result.entry.name, result.entry.version);
1745
1820
  if (cached) return cached;
1746
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1747
- return await fetchAndCacheFormat(entry, downloadUrl);
1821
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1822
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1748
1823
  }
1749
- } catch {
1824
+ } catch (e) {
1825
+ lastError = e instanceof Error ? e : new Error(String(e));
1750
1826
  }
1751
1827
  }
1828
+ if (lastError) throw lastError;
1752
1829
  return void 0;
1753
1830
  }
1754
1831
  function getCachedFormat(name, version) {
1755
1832
  const formatPath = (0, import_node_path7.join)(getCacheDir(), name, version, "format.js");
1756
1833
  try {
1757
- if (!(0, import_node_fs6.existsSync)(formatPath)) return void 0;
1758
- const source = (0, import_node_fs6.readFileSync)(formatPath, "utf-8");
1759
- const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(version)?.[0] ?? "0"}`;
1834
+ if (!(0, import_node_fs5.existsSync)(formatPath)) return void 0;
1835
+ const source = (0, import_node_fs5.readFileSync)(formatPath, "utf-8");
1836
+ const id = makeFormatId(name, version);
1760
1837
  const data = parseFormatJSON(source, id);
1761
1838
  if (!data) return void 0;
1762
1839
  return {
@@ -1775,26 +1852,26 @@ function discoverCachedFormats() {
1775
1852
  const formats = /* @__PURE__ */ new Map();
1776
1853
  const cacheDir = getCacheDir();
1777
1854
  try {
1778
- if (!(0, import_node_fs6.existsSync)(cacheDir)) return formats;
1855
+ if (!(0, import_node_fs5.existsSync)(cacheDir)) return formats;
1779
1856
  } catch {
1780
1857
  return formats;
1781
1858
  }
1782
1859
  let names;
1783
1860
  try {
1784
- names = (0, import_node_fs6.readdirSync)(cacheDir);
1861
+ names = (0, import_node_fs5.readdirSync)(cacheDir);
1785
1862
  } catch {
1786
1863
  return formats;
1787
1864
  }
1788
1865
  for (const name of names) {
1789
1866
  const nameDir = (0, import_node_path7.join)(cacheDir, name);
1790
1867
  try {
1791
- if (!(0, import_node_fs6.statSync)(nameDir).isDirectory()) continue;
1868
+ if (!(0, import_node_fs5.statSync)(nameDir).isDirectory()) continue;
1792
1869
  } catch {
1793
1870
  continue;
1794
1871
  }
1795
1872
  let versions;
1796
1873
  try {
1797
- versions = (0, import_node_fs6.readdirSync)(nameDir);
1874
+ versions = (0, import_node_fs5.readdirSync)(nameDir);
1798
1875
  } catch {
1799
1876
  continue;
1800
1877
  }
@@ -1809,6 +1886,7 @@ function discoverCachedFormats() {
1809
1886
  }
1810
1887
 
1811
1888
  // src/compiler.ts
1889
+ var CREATOR_NAME3 = "Twee-ts";
1812
1890
  var DEFAULT_FORMAT_ID = "sugarcube-2";
1813
1891
  var DEFAULT_START_NAME = "Start";
1814
1892
  var TweeTsError = class extends Error {
@@ -1823,7 +1901,7 @@ async function compile(options) {
1823
1901
  }
1824
1902
  async function compileToFile(options) {
1825
1903
  const result = await compile(options);
1826
- (0, import_node_fs7.writeFileSync)(options.outFile, result.output, "utf-8");
1904
+ (0, import_node_fs6.writeFileSync)(options.outFile, result.output, "utf-8");
1827
1905
  return result;
1828
1906
  }
1829
1907
  async function watch(options) {
@@ -1833,7 +1911,7 @@ async function watch(options) {
1833
1911
  const allPaths = [...filePaths, ...modulePaths];
1834
1912
  const handle = watchFilesystem(allPaths, options.outFile, () => {
1835
1913
  buildOutput(options).then((result) => {
1836
- (0, import_node_fs7.writeFileSync)(options.outFile, result.output, "utf-8");
1914
+ (0, import_node_fs6.writeFileSync)(options.outFile, result.output, "utf-8");
1837
1915
  options.onBuild?.(result);
1838
1916
  }).catch((e) => {
1839
1917
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -1865,10 +1943,7 @@ async function buildOutput(options) {
1865
1943
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1866
1944
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1867
1945
  if (options.tagAliases) {
1868
- applyTagAliases(story.passages, options.tagAliases);
1869
- }
1870
- const fatalErrors = diagnostics.filter((d) => d.level === "error");
1871
- if (fatalErrors.length > 0 && outputMode === "html") {
1946
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1872
1947
  }
1873
1948
  let format;
1874
1949
  let formatId = options.formatId ?? "";
@@ -1887,7 +1962,11 @@ async function buildOutput(options) {
1887
1962
  const remoteVersion = story.twine2.formatVersion || "";
1888
1963
  try {
1889
1964
  format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1890
- } catch {
1965
+ } catch (e) {
1966
+ diagnostics.push({
1967
+ level: "warning",
1968
+ message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
1969
+ });
1891
1970
  }
1892
1971
  }
1893
1972
  if (!format) {
@@ -1898,14 +1977,7 @@ async function buildOutput(options) {
1898
1977
  });
1899
1978
  }
1900
1979
  }
1901
- let startName;
1902
- if (options.startPassage) {
1903
- startName = options.startPassage;
1904
- } else if (story.twine2.start) {
1905
- startName = story.twine2.start;
1906
- } else {
1907
- startName = DEFAULT_START_NAME;
1908
- }
1980
+ const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
1909
1981
  if (testMode) {
1910
1982
  story.twine2.options.set("debug", true);
1911
1983
  }
@@ -1924,8 +1996,7 @@ async function buildOutput(options) {
1924
1996
  case "json":
1925
1997
  output = storyToJSON(story);
1926
1998
  break;
1927
- case "html":
1928
- default: {
1999
+ case "html": {
1929
2000
  if (!storyHas(story, startName)) {
1930
2001
  diagnostics.push({
1931
2002
  level: "error",
@@ -1947,9 +2018,13 @@ async function buildOutput(options) {
1947
2018
  output = toTwine1HTML(story, format, startName, diagnostics);
1948
2019
  }
1949
2020
  const modulePaths = options.modules ? getFilenames(options.modules) : [];
1950
- output = modifyHead(output, modulePaths, options.headFile);
2021
+ output = modifyHead(output, modulePaths, options.headFile, diagnostics);
1951
2022
  break;
1952
2023
  }
2024
+ default: {
2025
+ const _exhaustive = outputMode;
2026
+ throw new TweeTsError(`Unhandled output mode: ${_exhaustive}`, diagnostics);
2027
+ }
1953
2028
  }
1954
2029
  const stats = {
1955
2030
  ...getStoryStats(story),
@@ -1958,28 +2033,46 @@ async function buildOutput(options) {
1958
2033
  return { output, story, format, diagnostics, stats };
1959
2034
  }
1960
2035
  function storyToJSON(story) {
1961
- return JSON.stringify(
1962
- {
1963
- name: story.name,
1964
- ifid: story.ifid,
1965
- passages: story.passages.map((p) => ({
2036
+ const scripts = [];
2037
+ const stylesheets = [];
2038
+ const storyPassages = [];
2039
+ for (const p of story.passages) {
2040
+ if (p.name === "StoryTitle" || p.name === "StoryData") continue;
2041
+ if (hasTag(p, "Twine.private")) continue;
2042
+ if (hasTag(p, "script")) {
2043
+ scripts.push(p.text);
2044
+ } else if (hasTag(p, "stylesheet")) {
2045
+ stylesheets.push(p.text);
2046
+ } else {
2047
+ const entry = {
1966
2048
  name: p.name,
1967
2049
  tags: p.tags,
1968
- text: p.text,
1969
- metadata: p.metadata ?? null
1970
- })),
1971
- twine2: {
1972
- format: story.twine2.format,
1973
- formatVersion: story.twine2.formatVersion,
1974
- start: story.twine2.start,
1975
- tagColors: Object.fromEntries(story.twine2.tagColors),
1976
- options: Object.fromEntries(story.twine2.options),
1977
- zoom: story.twine2.zoom
2050
+ text: p.text
2051
+ };
2052
+ if (p.metadata && (p.metadata.position || p.metadata.size)) {
2053
+ const meta = {};
2054
+ if (p.metadata.position) meta.position = p.metadata.position;
2055
+ if (p.metadata.size) meta.size = p.metadata.size;
2056
+ entry.metadata = meta;
1978
2057
  }
1979
- },
1980
- null,
1981
- 2
1982
- );
2058
+ storyPassages.push(entry);
2059
+ }
2060
+ }
2061
+ const obj = {
2062
+ name: story.name
2063
+ };
2064
+ if (story.ifid) obj.ifid = story.ifid;
2065
+ if (story.twine2.format) obj.format = story.twine2.format;
2066
+ if (story.twine2.formatVersion) obj["format-version"] = story.twine2.formatVersion;
2067
+ if (story.twine2.start) obj.start = story.twine2.start;
2068
+ if (story.twine2.tagColors.size > 0) obj["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
2069
+ if (story.twine2.zoom !== 1) obj.zoom = story.twine2.zoom;
2070
+ obj.creator = CREATOR_NAME3;
2071
+ obj["creator-version"] = VERSION;
2072
+ obj.style = stylesheets.join("\n");
2073
+ obj.script = scripts.join("\n");
2074
+ obj.passages = storyPassages;
2075
+ return JSON.stringify(obj, null, 2);
1983
2076
  }
1984
2077
 
1985
2078
  // src/inspect.ts
@@ -2085,17 +2178,17 @@ function storyInspect(story) {
2085
2178
  }
2086
2179
 
2087
2180
  // src/config.ts
2088
- var import_node_fs8 = require("fs");
2181
+ var import_node_fs7 = require("fs");
2089
2182
  var import_node_path8 = require("path");
2090
2183
  var CONFIG_FILENAME = "twee-ts.config.json";
2091
2184
  var VALID_OUTPUT_MODES = ["html", "twee3", "twee1", "twine2-archive", "twine1-archive", "json"];
2092
2185
  function loadConfig(dir) {
2093
2186
  const base = dir ?? process.cwd();
2094
2187
  const configPath = (0, import_node_path8.join)(base, CONFIG_FILENAME);
2095
- if (!(0, import_node_fs8.existsSync)(configPath)) return null;
2188
+ if (!(0, import_node_fs7.existsSync)(configPath)) return null;
2096
2189
  let raw;
2097
2190
  try {
2098
- raw = (0, import_node_fs8.readFileSync)(configPath, "utf-8");
2191
+ raw = (0, import_node_fs7.readFileSync)(configPath, "utf-8");
2099
2192
  } catch {
2100
2193
  return null;
2101
2194
  }
@@ -2115,7 +2208,7 @@ function loadConfig(dir) {
2115
2208
  function loadConfigFile(filePath) {
2116
2209
  let raw;
2117
2210
  try {
2118
- raw = (0, import_node_fs8.readFileSync)(filePath, "utf-8");
2211
+ raw = (0, import_node_fs7.readFileSync)(filePath, "utf-8");
2119
2212
  } catch (e) {
2120
2213
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
2121
2214
  }