@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.
@@ -76,13 +76,26 @@ function tweeEscape(s) {
76
76
  if (s.length === 0) return s;
77
77
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
78
78
  }
79
+ function jsStringEscape(s) {
80
+ if (s.length === 0) return s;
81
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
82
+ }
83
+ function commentSanitize(s) {
84
+ return s.replace(/\*\//g, "* /");
85
+ }
86
+ function htmlCommentSanitize(s) {
87
+ return s.replace(/-->/g, "-- >");
88
+ }
79
89
  function tweeUnescape(s) {
80
90
  if (s.length === 0) return s;
81
91
  let result = "";
82
92
  for (let i = 0; i < s.length; i++) {
83
93
  if (s[i] === "\\") {
84
94
  i++;
85
- if (i >= s.length) break;
95
+ if (i >= s.length) {
96
+ result += "\\";
97
+ break;
98
+ }
86
99
  }
87
100
  result += s[i];
88
101
  }
@@ -172,16 +185,16 @@ function passageToPassagedata(p, pid) {
172
185
  let position;
173
186
  let size;
174
187
  if (hasMetadataPosition(p)) {
175
- position = p.metadata.position;
188
+ position = p.metadata?.position ?? "";
176
189
  } else {
177
190
  const x = pid % 10;
178
- let y = Math.floor(pid / 10);
191
+ const y = Math.floor(pid / 10);
179
192
  const xp = x === 0 ? 10 : x;
180
193
  const yp = x === 0 ? y : y + 1;
181
194
  position = `${xp * 125 - 25},${yp * 125 - 25}`;
182
195
  }
183
196
  if (hasMetadataSize(p)) {
184
- size = p.metadata.size;
197
+ size = p.metadata?.size ?? "";
185
198
  } else {
186
199
  size = "100,100";
187
200
  }
@@ -190,10 +203,10 @@ function passageToPassagedata(p, pid) {
190
203
  function passageToTiddler(p, pid) {
191
204
  let position;
192
205
  if (hasMetadataPosition(p)) {
193
- position = p.metadata.position;
206
+ position = p.metadata?.position ?? "";
194
207
  } else {
195
208
  const x = pid % 10;
196
- let y = Math.floor(pid / 10);
209
+ const y = Math.floor(pid / 10);
197
210
  const xp = x === 0 ? 10 : x;
198
211
  const yp = x === 0 ? y : y + 1;
199
212
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
@@ -212,15 +225,17 @@ function countWords(p) {
212
225
  }
213
226
  function applyTagAliases(passages, aliases) {
214
227
  const entries = Object.entries(aliases);
215
- if (entries.length === 0) return;
216
- for (const p of passages) {
217
- const original = [...p.tags];
228
+ if (entries.length === 0) return [...passages];
229
+ return passages.map((p) => {
230
+ const original = p.tags;
231
+ const added = [];
218
232
  for (const [alias, canonical] of entries) {
219
- if (original.includes(alias) && !p.tags.includes(canonical)) {
220
- p.tags.push(canonical);
233
+ if (original.includes(alias) && !original.includes(canonical) && !added.includes(canonical)) {
234
+ added.push(canonical);
221
235
  }
222
236
  }
223
- }
237
+ return added.length > 0 ? { ...p, tags: [...original, ...added] } : p;
238
+ });
224
239
  }
225
240
  function quote(s) {
226
241
  return `"${s}"`;
@@ -292,27 +307,32 @@ function marshalStoryData(story) {
292
307
  return JSON.stringify(data, null, " ");
293
308
  }
294
309
  function unmarshalStoryData(story, json) {
295
- let data;
310
+ let raw;
296
311
  try {
297
- data = JSON.parse(json);
312
+ raw = JSON.parse(json);
298
313
  } catch (e) {
299
314
  return `Cannot unmarshal "StoryData"; ${e instanceof Error ? e.message : String(e)}`;
300
315
  }
301
- if (data.ifid) story.ifid = data.ifid.toUpperCase();
302
- if (data.format) story.twine2.format = data.format;
303
- if (data["format-version"]) story.twine2.formatVersion = data["format-version"];
304
- if (data.options) {
316
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
317
+ return 'Cannot unmarshal "StoryData"; expected a JSON object';
318
+ }
319
+ const data = raw;
320
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
321
+ if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
322
+ if (typeof data["format-version"] === "string" && data["format-version"])
323
+ story.twine2.formatVersion = data["format-version"];
324
+ if (Array.isArray(data.options)) {
305
325
  for (const opt of data.options) {
306
- story.twine2.options.set(opt, true);
326
+ if (typeof opt === "string") story.twine2.options.set(opt, true);
307
327
  }
308
328
  }
309
- if (data.start) story.twine2.start = data.start;
310
- if (data["tag-colors"]) {
329
+ if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
330
+ if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
311
331
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
312
- story.twine2.tagColors.set(tag, color);
332
+ if (typeof color === "string") story.twine2.tagColors.set(tag, color);
313
333
  }
314
334
  }
315
- if (data.zoom != null && data.zoom !== 0) story.twine2.zoom = data.zoom;
335
+ if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
316
336
  return null;
317
337
  }
318
338
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -355,6 +375,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
355
375
  }
356
376
  }
357
377
  function storyAdd(story, p, diagnostics) {
378
+ let processed = p;
358
379
  switch (p.name) {
359
380
  case "StoryIncludes":
360
381
  diagnostics.push({
@@ -374,7 +395,7 @@ function storyAdd(story, p, diagnostics) {
374
395
  });
375
396
  }
376
397
  }
377
- p.text = marshalStoryData(story);
398
+ processed = { ...p, text: marshalStoryData(story) };
378
399
  } else {
379
400
  diagnostics.push({
380
401
  level: "error",
@@ -386,12 +407,14 @@ function storyAdd(story, p, diagnostics) {
386
407
  case "StorySettings":
387
408
  unmarshalStorySettings(story, p.text, diagnostics);
388
409
  break;
389
- case "StoryTitle":
390
- p.text = p.text.trim();
391
- story.name = p.text;
410
+ case "StoryTitle": {
411
+ const trimmed = p.text.trim();
412
+ processed = { ...p, text: trimmed };
413
+ story.name = trimmed;
392
414
  break;
415
+ }
393
416
  }
394
- storyAppend(story, p, diagnostics);
417
+ storyAppend(story, processed, diagnostics);
395
418
  }
396
419
  function getStoryStats(story) {
397
420
  let storyPassages = 0;
@@ -445,7 +468,7 @@ var MEDIA_TYPE_MAP = {
445
468
  webm: "video/webm"
446
469
  };
447
470
  function mediaTypeFromExt(ext) {
448
- return MEDIA_TYPE_MAP[ext] ?? "";
471
+ return MEDIA_TYPE_MAP[ext] ?? "application/octet-stream";
449
472
  }
450
473
  function mediaTypeFromFilename(filename) {
451
474
  return mediaTypeFromExt(normalizedFileExt(filename));
@@ -467,8 +490,6 @@ var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
467
490
  "twee",
468
491
  "tw2",
469
492
  "twee2",
470
- "htm",
471
- "html",
472
493
  "css",
473
494
  "js",
474
495
  "otf",
@@ -578,23 +599,49 @@ function watchFilesystem(pathnames, outFilename, callback) {
578
599
  }
579
600
 
580
601
  // src/formats.ts
581
- import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
602
+ import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
582
603
  import { join as join2 } from "path";
583
604
  import { homedir } from "os";
605
+
606
+ // src/util.ts
607
+ import { readFileSync } from "fs";
608
+ import { parse as parsePath } from "path";
609
+ function readUTF8(filename) {
610
+ let content = readFileSync(filename, "utf-8");
611
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
612
+ content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
613
+ return content;
614
+ }
615
+ function readBase64(filename) {
616
+ return readFileSync(filename).toString("base64");
617
+ }
618
+ function baseNameWithoutExt(filename) {
619
+ const { name, ext } = parsePath(filename);
620
+ return name || ext;
621
+ }
622
+
623
+ // src/formats.ts
584
624
  function parseFormatJSON(source, formatId) {
585
625
  const first = source.indexOf("{");
586
626
  const last = source.lastIndexOf("}");
587
627
  if (first === -1 || last === -1) return null;
588
628
  let chunk = source.slice(first, last + 1);
629
+ const parse = (json) => {
630
+ const raw = JSON.parse(json);
631
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
632
+ const obj = raw;
633
+ if (typeof obj.name !== "string" || typeof obj.version !== "string" || typeof obj.source !== "string") return null;
634
+ return { name: obj.name, version: obj.version, source: obj.source, proofing: obj.proofing === true };
635
+ };
589
636
  try {
590
- return JSON.parse(chunk);
637
+ return parse(chunk);
591
638
  } catch {
592
639
  if (formatId.toLowerCase().startsWith("harlowe")) {
593
640
  const setupIdx = chunk.lastIndexOf(',"setup": function');
594
641
  if (setupIdx !== -1) {
595
642
  chunk = chunk.slice(0, setupIdx) + "}";
596
643
  try {
597
- return JSON.parse(chunk);
644
+ return parse(chunk);
598
645
  } catch {
599
646
  }
600
647
  }
@@ -602,12 +649,6 @@ function parseFormatJSON(source, formatId) {
602
649
  return null;
603
650
  }
604
651
  }
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
652
  function discoverFormats(searchDirs) {
612
653
  const formats = /* @__PURE__ */ new Map();
613
654
  const baseFilenames = ["format.js", "header.html"];
@@ -702,9 +743,11 @@ function parseSemver(v) {
702
743
  return [Number(m[1]), Number(m[2]), Number(m[3])];
703
744
  }
704
745
  function semverCompare(a, b) {
705
- for (let i = 0; i < 3; i++) {
706
- if (a[i] !== b[i]) return a[i] - b[i];
707
- }
746
+ const [aMajor, aMinor, aPatch] = a;
747
+ const [bMajor, bMinor, bPatch] = b;
748
+ if (aMajor !== bMajor) return aMajor - bMajor;
749
+ if (aMinor !== bMinor) return aMinor - bMinor;
750
+ if (aPatch !== bPatch) return aPatch - bPatch;
708
751
  return 0;
709
752
  }
710
753
  function getFormatIdByNameAndVersion(formats, name, version) {
@@ -735,7 +778,6 @@ function readFormatSource(format) {
735
778
  }
736
779
 
737
780
  // src/loader.ts
738
- import { readFileSync as readFileSync2 } from "fs";
739
781
  import { basename } from "path";
740
782
 
741
783
  // src/lexer.ts
@@ -1012,13 +1054,13 @@ function hasTwee2Syntax(s) {
1012
1054
  }
1013
1055
  function twee2ToV3(s) {
1014
1056
  if (!hasTwee2Syntax(s)) return s;
1015
- s = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
1057
+ let result = s.replace(twee2HeaderRe, (_, p1, p2, p3) => {
1016
1058
  const tags = p2 ?? "";
1017
1059
  const pos = p3 ?? "";
1018
1060
  return `${p1}${tags} {"position":"${pos}"}`;
1019
1061
  });
1020
- s = s.replace(twee2BadPosRe, "$1");
1021
- return s;
1062
+ result = result.replace(twee2BadPosRe, "$1");
1063
+ return result;
1022
1064
  }
1023
1065
 
1024
1066
  // src/parser.ts
@@ -1096,7 +1138,11 @@ function parseTwee(source, options = {}) {
1096
1138
  return { passages, diagnostics };
1097
1139
  }
1098
1140
  try {
1099
- const parsed = JSON.parse(item.val);
1141
+ const raw = JSON.parse(item.val);
1142
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1143
+ throw new Error("expected a JSON object");
1144
+ }
1145
+ const parsed = raw;
1100
1146
  const meta = {};
1101
1147
  if (typeof parsed.position === "string") meta.position = parsed.position;
1102
1148
  if (typeof parsed.size === "string") meta.size = parsed.size;
@@ -1116,6 +1162,16 @@ function parseTwee(source, options = {}) {
1116
1162
  current.text = trim ? item.val.trim() : item.val;
1117
1163
  break;
1118
1164
  }
1165
+ default: {
1166
+ const _exhaustive = item.type;
1167
+ diagnostics.push({
1168
+ level: "error",
1169
+ message: `line ${item.line}: Unhandled lexer item type: ${_exhaustive}.`,
1170
+ file: filename,
1171
+ line: item.line
1172
+ });
1173
+ return { passages, diagnostics };
1174
+ }
1119
1175
  }
1120
1176
  lastType = item.type;
1121
1177
  }
@@ -1126,15 +1182,6 @@ function parseTwee(source, options = {}) {
1126
1182
  }
1127
1183
 
1128
1184
  // src/loader.ts
1129
- function readUTF82(filename) {
1130
- let content = readFileSync2(filename, "utf-8");
1131
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1132
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1133
- return content;
1134
- }
1135
- function readBase64(filename) {
1136
- return readFileSync2(filename).toString("base64");
1137
- }
1138
1185
  function loadSources(story, filenames, opts, diagnostics, processedFiles) {
1139
1186
  for (const filename of filenames) {
1140
1187
  if (processedFiles.has(filename)) {
@@ -1233,7 +1280,7 @@ function loadInlineSources(story, sources, opts, diagnostics) {
1233
1280
  }
1234
1281
  }
1235
1282
  function loadTwee(story, filename, opts, diagnostics) {
1236
- const source = readUTF82(filename);
1283
+ const source = readUTF8(filename);
1237
1284
  const result = parseTwee(source, {
1238
1285
  filename,
1239
1286
  trim: opts.trim ?? true,
@@ -1245,12 +1292,12 @@ function loadTwee(story, filename, opts, diagnostics) {
1245
1292
  }
1246
1293
  }
1247
1294
  function loadTagged(story, tag, filename, diagnostics) {
1248
- const source = readUTF82(filename);
1295
+ const source = readUTF8(filename);
1249
1296
  storyAdd(story, { name: basename(filename), tags: [tag], text: source }, diagnostics);
1250
1297
  }
1251
1298
  function loadMedia(story, tag, filename, diagnostics) {
1252
1299
  const source = readBase64(filename);
1253
- const name = basename(filename).split(".")[0];
1300
+ const name = baseNameWithoutExt(filename);
1254
1301
  storyAdd(
1255
1302
  story,
1256
1303
  {
@@ -1264,7 +1311,7 @@ function loadMedia(story, tag, filename, diagnostics) {
1264
1311
  function loadFont(story, filename, diagnostics) {
1265
1312
  const source = readBase64(filename);
1266
1313
  const name = basename(filename);
1267
- const family = name.split(".")[0];
1314
+ const family = baseNameWithoutExt(filename);
1268
1315
  const ext = normalizedFileExt(filename);
1269
1316
  const mediaType = mediaTypeFromExt(ext);
1270
1317
  const hint = fontFormatHint(ext);
@@ -1282,9 +1329,11 @@ function loadFont(story, filename, diagnostics) {
1282
1329
  );
1283
1330
  }
1284
1331
 
1332
+ // src/version.ts
1333
+ var VERSION = "1.2.0";
1334
+
1285
1335
  // src/output-twine2.ts
1286
1336
  var CREATOR_NAME = "Twee-ts";
1287
- var CREATOR_VERSION = "0.1.0";
1288
1337
  function toTwine2Archive(story, startName, diagnostics) {
1289
1338
  return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1290
1339
  }
@@ -1312,12 +1361,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1312
1361
  }
1313
1362
  let styleContent = "";
1314
1363
  if (stylesheets.length === 1) {
1315
- styleContent = stylesheets[0].text;
1364
+ styleContent = stylesheets[0]?.text ?? "";
1316
1365
  } else if (stylesheets.length > 1) {
1317
1366
  let pidS = 1;
1318
1367
  for (const p of stylesheets) {
1319
1368
  if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
1320
- styleContent += `/* twine-user-stylesheet #${pidS}: "${p.name}" */
1369
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */
1321
1370
  `;
1322
1371
  styleContent += p.text;
1323
1372
  pidS++;
@@ -1326,12 +1375,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1326
1375
  parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
1327
1376
  let scriptContent = "";
1328
1377
  if (scripts.length === 1) {
1329
- scriptContent = scripts[0].text;
1378
+ scriptContent = scripts[0]?.text ?? "";
1330
1379
  } else if (scripts.length > 1) {
1331
1380
  let pidS = 1;
1332
1381
  for (const p of scripts) {
1333
1382
  if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
1334
- scriptContent += `/* twine-user-script #${pidS}: "${p.name}" */
1383
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */
1335
1384
  `;
1336
1385
  scriptContent += p.text;
1337
1386
  pidS++;
@@ -1360,32 +1409,32 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1360
1409
  if (val) opts.push(opt);
1361
1410
  }
1362
1411
  const options = opts.join(" ");
1363
- const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
1364
- 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>`;
1412
+ const zoom = String(story.twine2.zoom);
1413
+ 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>`;
1365
1414
  return wrapper + parts.join("") + "</tw-storydata>";
1366
1415
  }
1367
1416
  function ensureIFID(story, diagnostics) {
1368
1417
  if (story.ifid !== "") return;
1369
1418
  if (story.legacyIFID !== "") {
1419
+ story.ifid = story.legacyIFID;
1370
1420
  diagnostics.push({
1371
1421
  level: "warning",
1372
1422
  message: 'Story IFID not found; reusing "ifid" entry from the "StorySettings" special passage.'
1373
1423
  });
1374
- story.ifid = story.legacyIFID;
1375
1424
  } else {
1376
- story.ifid = generateIFID();
1425
+ const ifid = generateIFID();
1426
+ story.ifid = ifid;
1377
1427
  diagnostics.push({
1378
- level: "warning",
1379
- message: `Story IFID not found; generated ${story.ifid} for your project. Add it to a StoryData passage.`
1428
+ level: "error",
1429
+ message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1380
1430
  });
1381
1431
  }
1382
1432
  }
1383
1433
 
1384
1434
  // src/output-twine1.ts
1385
- import { readFileSync as readFileSync3 } from "fs";
1435
+ import { readFileSync as readFileSync2 } from "fs";
1386
1436
  import { join as join3, dirname } from "path";
1387
1437
  var CREATOR_NAME2 = "twee-ts";
1388
- var CREATOR_VERSION2 = "0.1.0";
1389
1438
  function toTwine1Archive(story, _startName) {
1390
1439
  const { data, count } = getTwine1PassageChunk(story);
1391
1440
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
@@ -1407,26 +1456,27 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1407
1456
  template = tryReplaceComponent(template, '"MODERNIZR"', join3(parentDir, "modernizr.js"), true);
1408
1457
  }
1409
1458
  const displayStart = startName === "Start" ? "" : startName;
1410
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1459
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1411
1460
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1412
- template = template.replace('"START_AT"', `"${displayStart}"`);
1461
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1413
1462
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1414
1463
  if (template.includes('"STORY"')) {
1415
1464
  template = template.replace('"STORY"', data);
1416
1465
  } else {
1417
1466
  let footer;
1418
1467
  try {
1419
- footer = readFileSync3(join3(formatDir, "footer.html"), "utf-8");
1468
+ footer = readFileSync2(join3(formatDir, "footer.html"), "utf-8");
1420
1469
  } catch {
1421
1470
  footer = "</div>\n</body>\n</html>\n";
1422
1471
  }
1423
1472
  template += data + footer;
1424
1473
  }
1425
1474
  if (story.ifid) {
1475
+ const safeIfid = htmlCommentSanitize(story.ifid);
1426
1476
  if (template.includes('<div id="store-area"')) {
1427
- template = template.replace('<div id="store-area"', `<!-- UUID://${story.ifid}// --><div id="store-area"`);
1477
+ template = template.replace('<div id="store-area"', `<!-- UUID://${safeIfid}// --><div id="store-area"`);
1428
1478
  } else {
1429
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1479
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1430
1480
  }
1431
1481
  }
1432
1482
  return template;
@@ -1441,13 +1491,18 @@ function getTwine1PassageChunk(story) {
1441
1491
  }
1442
1492
  return { data, count };
1443
1493
  }
1444
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1494
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1445
1495
  if (!template.includes(placeholder)) return template;
1446
1496
  try {
1447
- let content = readFileSync3(componentPath, "utf-8");
1497
+ let content = readFileSync2(componentPath, "utf-8");
1448
1498
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1449
1499
  return template.replace(placeholder, content);
1450
- } catch {
1500
+ } catch (e) {
1501
+ if (required) {
1502
+ throw new Error(
1503
+ `Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`
1504
+ );
1505
+ }
1451
1506
  return template;
1452
1507
  }
1453
1508
  }
@@ -1462,16 +1517,6 @@ function toTwee(story, outMode) {
1462
1517
  }
1463
1518
 
1464
1519
  // src/modules.ts
1465
- import { readFileSync as readFileSync4 } from "fs";
1466
- import { basename as basename2 } from "path";
1467
- function readUTF83(filename) {
1468
- let content = readFileSync4(filename, "utf-8");
1469
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1470
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1471
- }
1472
- function readBase642(filename) {
1473
- return readFileSync4(filename).toString("base64");
1474
- }
1475
1520
  function loadModules(filenames) {
1476
1521
  const processed = /* @__PURE__ */ new Set();
1477
1522
  const headTags = [];
@@ -1501,16 +1546,16 @@ function loadModules(filenames) {
1501
1546
  return headTags.join("\n");
1502
1547
  }
1503
1548
  function loadModuleTagged(tag, filename) {
1504
- const source = readUTF83(filename).trim();
1549
+ const source = readUTF8(filename).trim();
1505
1550
  if (source.length === 0) return null;
1506
- const family = basename2(filename).split(".")[0];
1551
+ const family = baseNameWithoutExt(filename);
1507
1552
  const idSlug = `${tag}-module-${slugify(family)}`;
1508
1553
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1509
1554
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1510
1555
  }
1511
1556
  function loadModuleFont(filename) {
1512
- const source = readBase642(filename);
1513
- const family = basename2(filename).split(".")[0];
1557
+ const source = readBase64(filename);
1558
+ const family = baseNameWithoutExt(filename);
1514
1559
  const idSlug = `style-module-${slugify(family)}`;
1515
1560
  const ext = normalizedFileExt(filename);
1516
1561
  const mediaType = mediaTypeFromExt(ext);
@@ -1520,7 +1565,7 @@ function loadModuleFont(filename) {
1520
1565
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1521
1566
  }</style>`;
1522
1567
  }
1523
- function modifyHead(html, modulePaths, headFile) {
1568
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1524
1569
  const parts = [];
1525
1570
  if (modulePaths.length > 0) {
1526
1571
  const modules = loadModules(modulePaths).trim();
@@ -1528,9 +1573,13 @@ function modifyHead(html, modulePaths, headFile) {
1528
1573
  }
1529
1574
  if (headFile) {
1530
1575
  try {
1531
- const source = readUTF83(headFile).trim();
1576
+ const source = readUTF8(headFile).trim();
1532
1577
  if (source.length > 0) parts.push(source);
1533
- } catch {
1578
+ } catch (e) {
1579
+ diagnostics?.push({
1580
+ level: "warning",
1581
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
1582
+ });
1534
1583
  }
1535
1584
  }
1536
1585
  if (parts.length > 0) {
@@ -1541,7 +1590,7 @@ function modifyHead(html, modulePaths, headFile) {
1541
1590
  }
1542
1591
 
1543
1592
  // src/remote-formats.ts
1544
- import { mkdirSync, readFileSync as readFileSync5, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1593
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1545
1594
  import { join as join4 } from "path";
1546
1595
  import { homedir as homedir2 } from "os";
1547
1596
  var DEFAULT_SFA_INDICES = [
@@ -1557,6 +1606,24 @@ var indexCache = /* @__PURE__ */ new Map();
1557
1606
  function clearIndexCache() {
1558
1607
  indexCache.clear();
1559
1608
  }
1609
+ function isValidEntry(val) {
1610
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1611
+ const obj = val;
1612
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1613
+ }
1614
+ function validateSFAIndex(json) {
1615
+ if (typeof json !== "object" || json === null) {
1616
+ throw new Error("SFA index is not an object");
1617
+ }
1618
+ const obj = json;
1619
+ const twine1 = Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [];
1620
+ const twine2 = Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : [];
1621
+ return { twine1, twine2 };
1622
+ }
1623
+ function makeFormatId(name, version) {
1624
+ const major = parseSemver(version)?.[0] ?? "0";
1625
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1626
+ }
1560
1627
  async function fetchIndex(url) {
1561
1628
  const cached = indexCache.get(url);
1562
1629
  if (cached) return cached;
@@ -1564,37 +1631,42 @@ async function fetchIndex(url) {
1564
1631
  if (!res.ok) {
1565
1632
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1566
1633
  }
1567
- const data = await res.json();
1634
+ const data = validateSFAIndex(await res.json());
1568
1635
  indexCache.set(url, data);
1569
1636
  return data;
1570
1637
  }
1571
1638
  function findEntry(index, name, version) {
1572
1639
  const wanted = parseSemver(version);
1573
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1574
- let bestEntry;
1640
+ const searchArrays = [
1641
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1642
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1643
+ ];
1644
+ let bestResult;
1575
1645
  let bestVersion = null;
1576
- for (const entry of entries) {
1577
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1578
- const have = parseSemver(entry.version);
1579
- if (!have) continue;
1580
- if (wanted && semverCompare(have, wanted) === 0) return entry;
1581
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1582
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1583
- bestVersion = have;
1584
- bestEntry = entry;
1646
+ for (const { entries, formatType } of searchArrays) {
1647
+ for (const entry of entries) {
1648
+ if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1649
+ const have = parseSemver(entry.version);
1650
+ if (!have) continue;
1651
+ if (wanted && semverCompare(have, wanted) === 0) return { entry, formatType };
1652
+ if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1653
+ if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1654
+ bestVersion = have;
1655
+ bestResult = { entry, formatType };
1656
+ }
1585
1657
  }
1586
1658
  }
1587
1659
  }
1588
- return bestEntry;
1660
+ return bestResult;
1589
1661
  }
1590
1662
  async function verifySHA256(content, expectedHex) {
1591
1663
  const digest = await crypto.subtle.digest("SHA-256", content);
1592
1664
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1593
1665
  return hex === expectedHex.toLowerCase();
1594
1666
  }
1595
- function getDownloadUrl(indexUrl, entry) {
1667
+ function getDownloadUrl(indexUrl, entry, formatType) {
1596
1668
  const base = indexUrl.replace(/\/index\.json$/, "");
1597
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1669
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1598
1670
  }
1599
1671
  async function fetchAndCacheFormat(entry, downloadUrl) {
1600
1672
  const res = await fetch(downloadUrl);
@@ -1605,13 +1677,14 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1605
1677
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1606
1678
  if (checksumKey) {
1607
1679
  const expected = entry.checksums[checksumKey];
1680
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1608
1681
  const encoder = new TextEncoder();
1609
1682
  const valid = await verifySHA256(encoder.encode(text), expected);
1610
1683
  if (!valid) {
1611
1684
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1612
1685
  }
1613
1686
  }
1614
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1687
+ const id = makeFormatId(entry.name, entry.version);
1615
1688
  const data = parseFormatJSON(text, id);
1616
1689
  if (!data) {
1617
1690
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
@@ -1641,7 +1714,7 @@ async function fetchDirectFormat(url) {
1641
1714
  if (!data) {
1642
1715
  throw new Error(`Failed to parse format JSON from ${url}`);
1643
1716
  }
1644
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1717
+ const id = makeFormatId(data.name, data.version);
1645
1718
  const cacheDir = getCacheDir();
1646
1719
  const formatDir = join4(cacheDir, data.name, data.version);
1647
1720
  mkdirSync(formatDir, { recursive: true });
@@ -1657,6 +1730,7 @@ async function fetchDirectFormat(url) {
1657
1730
  };
1658
1731
  }
1659
1732
  async function resolveRemoteFormat(name, version, indices, urls) {
1733
+ let lastError;
1660
1734
  if (urls) {
1661
1735
  for (const url of urls) {
1662
1736
  try {
@@ -1668,7 +1742,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1668
1742
  return info;
1669
1743
  }
1670
1744
  }
1671
- } catch {
1745
+ } catch (e) {
1746
+ lastError = e instanceof Error ? e : new Error(String(e));
1672
1747
  }
1673
1748
  }
1674
1749
  }
@@ -1676,24 +1751,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1676
1751
  for (const indexUrl of allIndices) {
1677
1752
  try {
1678
1753
  const index = await fetchIndex(indexUrl);
1679
- const entry = findEntry(index, name, version);
1680
- if (entry) {
1681
- const cached = getCachedFormat(entry.name, entry.version);
1754
+ const result = findEntry(index, name, version);
1755
+ if (result) {
1756
+ const cached = getCachedFormat(result.entry.name, result.entry.version);
1682
1757
  if (cached) return cached;
1683
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1684
- return await fetchAndCacheFormat(entry, downloadUrl);
1758
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1759
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1685
1760
  }
1686
- } catch {
1761
+ } catch (e) {
1762
+ lastError = e instanceof Error ? e : new Error(String(e));
1687
1763
  }
1688
1764
  }
1765
+ if (lastError) throw lastError;
1689
1766
  return void 0;
1690
1767
  }
1691
1768
  function getCachedFormat(name, version) {
1692
1769
  const formatPath = join4(getCacheDir(), name, version, "format.js");
1693
1770
  try {
1694
1771
  if (!existsSync(formatPath)) return void 0;
1695
- const source = readFileSync5(formatPath, "utf-8");
1696
- const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(version)?.[0] ?? "0"}`;
1772
+ const source = readFileSync3(formatPath, "utf-8");
1773
+ const id = makeFormatId(name, version);
1697
1774
  const data = parseFormatJSON(source, id);
1698
1775
  if (!data) return void 0;
1699
1776
  return {
@@ -1746,6 +1823,7 @@ function discoverCachedFormats() {
1746
1823
  }
1747
1824
 
1748
1825
  // src/compiler.ts
1826
+ var CREATOR_NAME3 = "Twee-ts";
1749
1827
  var DEFAULT_FORMAT_ID = "sugarcube-2";
1750
1828
  var DEFAULT_START_NAME = "Start";
1751
1829
  var TweeTsError = class extends Error {
@@ -1802,10 +1880,7 @@ async function buildOutput(options) {
1802
1880
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1803
1881
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1804
1882
  if (options.tagAliases) {
1805
- applyTagAliases(story.passages, options.tagAliases);
1806
- }
1807
- const fatalErrors = diagnostics.filter((d) => d.level === "error");
1808
- if (fatalErrors.length > 0 && outputMode === "html") {
1883
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1809
1884
  }
1810
1885
  let format;
1811
1886
  let formatId = options.formatId ?? "";
@@ -1824,7 +1899,11 @@ async function buildOutput(options) {
1824
1899
  const remoteVersion = story.twine2.formatVersion || "";
1825
1900
  try {
1826
1901
  format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1827
- } catch {
1902
+ } catch (e) {
1903
+ diagnostics.push({
1904
+ level: "warning",
1905
+ message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
1906
+ });
1828
1907
  }
1829
1908
  }
1830
1909
  if (!format) {
@@ -1835,14 +1914,7 @@ async function buildOutput(options) {
1835
1914
  });
1836
1915
  }
1837
1916
  }
1838
- let startName;
1839
- if (options.startPassage) {
1840
- startName = options.startPassage;
1841
- } else if (story.twine2.start) {
1842
- startName = story.twine2.start;
1843
- } else {
1844
- startName = DEFAULT_START_NAME;
1845
- }
1917
+ const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
1846
1918
  if (testMode) {
1847
1919
  story.twine2.options.set("debug", true);
1848
1920
  }
@@ -1861,8 +1933,7 @@ async function buildOutput(options) {
1861
1933
  case "json":
1862
1934
  output = storyToJSON(story);
1863
1935
  break;
1864
- case "html":
1865
- default: {
1936
+ case "html": {
1866
1937
  if (!storyHas(story, startName)) {
1867
1938
  diagnostics.push({
1868
1939
  level: "error",
@@ -1884,9 +1955,13 @@ async function buildOutput(options) {
1884
1955
  output = toTwine1HTML(story, format, startName, diagnostics);
1885
1956
  }
1886
1957
  const modulePaths = options.modules ? getFilenames(options.modules) : [];
1887
- output = modifyHead(output, modulePaths, options.headFile);
1958
+ output = modifyHead(output, modulePaths, options.headFile, diagnostics);
1888
1959
  break;
1889
1960
  }
1961
+ default: {
1962
+ const _exhaustive = outputMode;
1963
+ throw new TweeTsError(`Unhandled output mode: ${_exhaustive}`, diagnostics);
1964
+ }
1890
1965
  }
1891
1966
  const stats = {
1892
1967
  ...getStoryStats(story),
@@ -1895,32 +1970,50 @@ async function buildOutput(options) {
1895
1970
  return { output, story, format, diagnostics, stats };
1896
1971
  }
1897
1972
  function storyToJSON(story) {
1898
- return JSON.stringify(
1899
- {
1900
- name: story.name,
1901
- ifid: story.ifid,
1902
- passages: story.passages.map((p) => ({
1973
+ const scripts = [];
1974
+ const stylesheets = [];
1975
+ const storyPassages = [];
1976
+ for (const p of story.passages) {
1977
+ if (p.name === "StoryTitle" || p.name === "StoryData") continue;
1978
+ if (hasTag(p, "Twine.private")) continue;
1979
+ if (hasTag(p, "script")) {
1980
+ scripts.push(p.text);
1981
+ } else if (hasTag(p, "stylesheet")) {
1982
+ stylesheets.push(p.text);
1983
+ } else {
1984
+ const entry = {
1903
1985
  name: p.name,
1904
1986
  tags: p.tags,
1905
- text: p.text,
1906
- metadata: p.metadata ?? null
1907
- })),
1908
- twine2: {
1909
- format: story.twine2.format,
1910
- formatVersion: story.twine2.formatVersion,
1911
- start: story.twine2.start,
1912
- tagColors: Object.fromEntries(story.twine2.tagColors),
1913
- options: Object.fromEntries(story.twine2.options),
1914
- zoom: story.twine2.zoom
1987
+ text: p.text
1988
+ };
1989
+ if (p.metadata && (p.metadata.position || p.metadata.size)) {
1990
+ const meta = {};
1991
+ if (p.metadata.position) meta.position = p.metadata.position;
1992
+ if (p.metadata.size) meta.size = p.metadata.size;
1993
+ entry.metadata = meta;
1915
1994
  }
1916
- },
1917
- null,
1918
- 2
1919
- );
1995
+ storyPassages.push(entry);
1996
+ }
1997
+ }
1998
+ const obj = {
1999
+ name: story.name
2000
+ };
2001
+ if (story.ifid) obj.ifid = story.ifid;
2002
+ if (story.twine2.format) obj.format = story.twine2.format;
2003
+ if (story.twine2.formatVersion) obj["format-version"] = story.twine2.formatVersion;
2004
+ if (story.twine2.start) obj.start = story.twine2.start;
2005
+ if (story.twine2.tagColors.size > 0) obj["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
2006
+ if (story.twine2.zoom !== 1) obj.zoom = story.twine2.zoom;
2007
+ obj.creator = CREATOR_NAME3;
2008
+ obj["creator-version"] = VERSION;
2009
+ obj.style = stylesheets.join("\n");
2010
+ obj.script = scripts.join("\n");
2011
+ obj.passages = storyPassages;
2012
+ return JSON.stringify(obj, null, 2);
1920
2013
  }
1921
2014
 
1922
2015
  // src/config.ts
1923
- import { readFileSync as readFileSync6, existsSync as existsSync2 } from "fs";
2016
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
1924
2017
  import { join as join5 } from "path";
1925
2018
  var CONFIG_FILENAME = "twee-ts.config.json";
1926
2019
  var VALID_OUTPUT_MODES = ["html", "twee3", "twee1", "twine2-archive", "twine1-archive", "json"];
@@ -1930,7 +2023,7 @@ function loadConfig(dir) {
1930
2023
  if (!existsSync2(configPath)) return null;
1931
2024
  let raw;
1932
2025
  try {
1933
- raw = readFileSync6(configPath, "utf-8");
2026
+ raw = readFileSync4(configPath, "utf-8");
1934
2027
  } catch {
1935
2028
  return null;
1936
2029
  }
@@ -1950,7 +2043,7 @@ function loadConfig(dir) {
1950
2043
  function loadConfigFile(filePath) {
1951
2044
  let raw;
1952
2045
  try {
1953
- raw = readFileSync6(filePath, "utf-8");
2046
+ raw = readFileSync4(filePath, "utf-8");
1954
2047
  } catch (e) {
1955
2048
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
1956
2049
  }
@@ -2021,7 +2114,6 @@ function scaffoldConfig() {
2021
2114
  }
2022
2115
 
2023
2116
  // bin/twee-ts.ts
2024
- var VERSION = "0.1.0";
2025
2117
  var { values, positionals } = parseArgs({
2026
2118
  allowPositionals: true,
2027
2119
  options: {
@@ -2251,7 +2343,7 @@ Options:
2251
2343
  -v, --version Show version`);
2252
2344
  }
2253
2345
  main().catch((err) => {
2254
- console.error(err.message || err);
2346
+ console.error(err instanceof Error ? err.message : String(err));
2255
2347
  process.exit(1);
2256
2348
  });
2257
2349
  //# sourceMappingURL=twee-ts.js.map