@rohal12/twee-ts 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,38 +1292,48 @@ 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];
1254
- storyAdd(story, {
1255
- name,
1256
- tags: [tag],
1257
- text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
1258
- }, diagnostics);
1300
+ const name = baseNameWithoutExt(filename);
1301
+ storyAdd(
1302
+ story,
1303
+ {
1304
+ name,
1305
+ tags: [tag],
1306
+ text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
1307
+ },
1308
+ diagnostics
1309
+ );
1259
1310
  }
1260
1311
  function loadFont(story, filename, diagnostics) {
1261
1312
  const source = readBase64(filename);
1262
1313
  const name = basename(filename);
1263
- const family = name.split(".")[0];
1314
+ const family = baseNameWithoutExt(filename);
1264
1315
  const ext = normalizedFileExt(filename);
1265
1316
  const mediaType = mediaTypeFromExt(ext);
1266
1317
  const hint = fontFormatHint(ext);
1267
- storyAdd(story, {
1268
- name,
1269
- tags: ["stylesheet"],
1270
- text: `@font-face {
1318
+ storyAdd(
1319
+ story,
1320
+ {
1321
+ name,
1322
+ tags: ["stylesheet"],
1323
+ text: `@font-face {
1271
1324
  font-family: "${family}";
1272
1325
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1273
1326
  }`
1274
- }, diagnostics);
1327
+ },
1328
+ diagnostics
1329
+ );
1275
1330
  }
1276
1331
 
1332
+ // src/version.ts
1333
+ var VERSION = "1.2.0";
1334
+
1277
1335
  // src/output-twine2.ts
1278
1336
  var CREATOR_NAME = "Twee-ts";
1279
- var CREATOR_VERSION = "0.1.0";
1280
1337
  function toTwine2Archive(story, startName, diagnostics) {
1281
1338
  return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1282
1339
  }
@@ -1304,12 +1361,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1304
1361
  }
1305
1362
  let styleContent = "";
1306
1363
  if (stylesheets.length === 1) {
1307
- styleContent = stylesheets[0].text;
1364
+ styleContent = stylesheets[0]?.text ?? "";
1308
1365
  } else if (stylesheets.length > 1) {
1309
1366
  let pidS = 1;
1310
1367
  for (const p of stylesheets) {
1311
1368
  if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
1312
- styleContent += `/* twine-user-stylesheet #${pidS}: "${p.name}" */
1369
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */
1313
1370
  `;
1314
1371
  styleContent += p.text;
1315
1372
  pidS++;
@@ -1318,12 +1375,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1318
1375
  parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
1319
1376
  let scriptContent = "";
1320
1377
  if (scripts.length === 1) {
1321
- scriptContent = scripts[0].text;
1378
+ scriptContent = scripts[0]?.text ?? "";
1322
1379
  } else if (scripts.length > 1) {
1323
1380
  let pidS = 1;
1324
1381
  for (const p of scripts) {
1325
1382
  if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
1326
- scriptContent += `/* twine-user-script #${pidS}: "${p.name}" */
1383
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */
1327
1384
  `;
1328
1385
  scriptContent += p.text;
1329
1386
  pidS++;
@@ -1353,7 +1410,7 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1353
1410
  }
1354
1411
  const options = opts.join(" ");
1355
1412
  const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
1356
- 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>`;
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>`;
1357
1414
  return wrapper + parts.join("") + "</tw-storydata>";
1358
1415
  }
1359
1416
  function ensureIFID(story, diagnostics) {
@@ -1374,10 +1431,9 @@ function ensureIFID(story, diagnostics) {
1374
1431
  }
1375
1432
 
1376
1433
  // src/output-twine1.ts
1377
- import { readFileSync as readFileSync3 } from "fs";
1434
+ import { readFileSync as readFileSync2 } from "fs";
1378
1435
  import { join as join3, dirname } from "path";
1379
1436
  var CREATOR_NAME2 = "twee-ts";
1380
- var CREATOR_VERSION2 = "0.1.0";
1381
1437
  function toTwine1Archive(story, _startName) {
1382
1438
  const { data, count } = getTwine1PassageChunk(story);
1383
1439
  return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
@@ -1399,26 +1455,27 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1399
1455
  template = tryReplaceComponent(template, '"MODERNIZR"', join3(parentDir, "modernizr.js"), true);
1400
1456
  }
1401
1457
  const displayStart = startName === "Start" ? "" : startName;
1402
- template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${CREATOR_VERSION2}`);
1458
+ template = template.replace('"VERSION"', `Compiled with ${CREATOR_NAME2}, ${VERSION}`);
1403
1459
  template = template.replace('"TIME"', `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1404
- template = template.replace('"START_AT"', `"${displayStart}"`);
1460
+ template = template.replace('"START_AT"', `"${jsStringEscape(displayStart)}"`);
1405
1461
  template = template.replace('"STORY_SIZE"', `"${count}"`);
1406
1462
  if (template.includes('"STORY"')) {
1407
1463
  template = template.replace('"STORY"', data);
1408
1464
  } else {
1409
1465
  let footer;
1410
1466
  try {
1411
- footer = readFileSync3(join3(formatDir, "footer.html"), "utf-8");
1467
+ footer = readFileSync2(join3(formatDir, "footer.html"), "utf-8");
1412
1468
  } catch {
1413
1469
  footer = "</div>\n</body>\n</html>\n";
1414
1470
  }
1415
1471
  template += data + footer;
1416
1472
  }
1417
1473
  if (story.ifid) {
1474
+ const safeIfid = htmlCommentSanitize(story.ifid);
1418
1475
  if (template.includes('<div id="store-area"')) {
1419
- template = template.replace('<div id="store-area"', `<!-- UUID://${story.ifid}// --><div id="store-area"`);
1476
+ template = template.replace('<div id="store-area"', `<!-- UUID://${safeIfid}// --><div id="store-area"`);
1420
1477
  } else {
1421
- template = template.replace('<div id="storeArea"', `<!-- UUID://${story.ifid}// --><div id="storeArea"`);
1478
+ template = template.replace('<div id="storeArea"', `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
1422
1479
  }
1423
1480
  }
1424
1481
  return template;
@@ -1433,13 +1490,18 @@ function getTwine1PassageChunk(story) {
1433
1490
  }
1434
1491
  return { data, count };
1435
1492
  }
1436
- function tryReplaceComponent(template, placeholder, componentPath, _required) {
1493
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
1437
1494
  if (!template.includes(placeholder)) return template;
1438
1495
  try {
1439
- let content = readFileSync3(componentPath, "utf-8");
1496
+ let content = readFileSync2(componentPath, "utf-8");
1440
1497
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
1441
1498
  return template.replace(placeholder, content);
1442
- } catch {
1499
+ } catch (e) {
1500
+ if (required) {
1501
+ throw new Error(
1502
+ `Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`
1503
+ );
1504
+ }
1443
1505
  return template;
1444
1506
  }
1445
1507
  }
@@ -1454,16 +1516,6 @@ function toTwee(story, outMode) {
1454
1516
  }
1455
1517
 
1456
1518
  // src/modules.ts
1457
- import { readFileSync as readFileSync4 } from "fs";
1458
- import { basename as basename2 } from "path";
1459
- function readUTF83(filename) {
1460
- let content = readFileSync4(filename, "utf-8");
1461
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
1462
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1463
- }
1464
- function readBase642(filename) {
1465
- return readFileSync4(filename).toString("base64");
1466
- }
1467
1519
  function loadModules(filenames) {
1468
1520
  const processed = /* @__PURE__ */ new Set();
1469
1521
  const headTags = [];
@@ -1493,16 +1545,16 @@ function loadModules(filenames) {
1493
1545
  return headTags.join("\n");
1494
1546
  }
1495
1547
  function loadModuleTagged(tag, filename) {
1496
- const source = readUTF83(filename).trim();
1548
+ const source = readUTF8(filename).trim();
1497
1549
  if (source.length === 0) return null;
1498
- const family = basename2(filename).split(".")[0];
1550
+ const family = baseNameWithoutExt(filename);
1499
1551
  const idSlug = `${tag}-module-${slugify(family)}`;
1500
1552
  const mimeType = tag === "script" ? "text/javascript" : "text/css";
1501
1553
  return `<${tag} id="${idSlug}" type="${mimeType}">${source}</${tag}>`;
1502
1554
  }
1503
1555
  function loadModuleFont(filename) {
1504
- const source = readBase642(filename);
1505
- const family = basename2(filename).split(".")[0];
1556
+ const source = readBase64(filename);
1557
+ const family = baseNameWithoutExt(filename);
1506
1558
  const idSlug = `style-module-${slugify(family)}`;
1507
1559
  const ext = normalizedFileExt(filename);
1508
1560
  const mediaType = mediaTypeFromExt(ext);
@@ -1512,7 +1564,7 @@ function loadModuleFont(filename) {
1512
1564
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1513
1565
  }</style>`;
1514
1566
  }
1515
- function modifyHead(html, modulePaths, headFile) {
1567
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
1516
1568
  const parts = [];
1517
1569
  if (modulePaths.length > 0) {
1518
1570
  const modules = loadModules(modulePaths).trim();
@@ -1520,9 +1572,13 @@ function modifyHead(html, modulePaths, headFile) {
1520
1572
  }
1521
1573
  if (headFile) {
1522
1574
  try {
1523
- const source = readUTF83(headFile).trim();
1575
+ const source = readUTF8(headFile).trim();
1524
1576
  if (source.length > 0) parts.push(source);
1525
- } catch {
1577
+ } catch (e) {
1578
+ diagnostics?.push({
1579
+ level: "warning",
1580
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
1581
+ });
1526
1582
  }
1527
1583
  }
1528
1584
  if (parts.length > 0) {
@@ -1533,7 +1589,7 @@ function modifyHead(html, modulePaths, headFile) {
1533
1589
  }
1534
1590
 
1535
1591
  // src/remote-formats.ts
1536
- import { mkdirSync, readFileSync as readFileSync5, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1592
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1537
1593
  import { join as join4 } from "path";
1538
1594
  import { homedir as homedir2 } from "os";
1539
1595
  var DEFAULT_SFA_INDICES = [
@@ -1549,6 +1605,24 @@ var indexCache = /* @__PURE__ */ new Map();
1549
1605
  function clearIndexCache() {
1550
1606
  indexCache.clear();
1551
1607
  }
1608
+ function isValidEntry(val) {
1609
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1610
+ const obj = val;
1611
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1612
+ }
1613
+ function validateSFAIndex(json) {
1614
+ if (typeof json !== "object" || json === null) {
1615
+ throw new Error("SFA index is not an object");
1616
+ }
1617
+ const obj = json;
1618
+ const twine1 = Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [];
1619
+ const twine2 = Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : [];
1620
+ return { twine1, twine2 };
1621
+ }
1622
+ function makeFormatId(name, version) {
1623
+ const major = parseSemver(version)?.[0] ?? "0";
1624
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1625
+ }
1552
1626
  async function fetchIndex(url) {
1553
1627
  const cached = indexCache.get(url);
1554
1628
  if (cached) return cached;
@@ -1556,37 +1630,42 @@ async function fetchIndex(url) {
1556
1630
  if (!res.ok) {
1557
1631
  throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1558
1632
  }
1559
- const data = await res.json();
1633
+ const data = validateSFAIndex(await res.json());
1560
1634
  indexCache.set(url, data);
1561
1635
  return data;
1562
1636
  }
1563
1637
  function findEntry(index, name, version) {
1564
1638
  const wanted = parseSemver(version);
1565
- const entries = [...index.twine2 ?? [], ...index.twine1 ?? []];
1566
- let bestEntry;
1639
+ const searchArrays = [
1640
+ { entries: index.twine2 ?? [], formatType: "twine2" },
1641
+ { entries: index.twine1 ?? [], formatType: "twine1" }
1642
+ ];
1643
+ let bestResult;
1567
1644
  let bestVersion = null;
1568
- for (const entry of entries) {
1569
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1570
- const have = parseSemver(entry.version);
1571
- if (!have) continue;
1572
- if (wanted && semverCompare(have, wanted) === 0) return entry;
1573
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1574
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1575
- bestVersion = have;
1576
- bestEntry = entry;
1645
+ for (const { entries, formatType } of searchArrays) {
1646
+ for (const entry of entries) {
1647
+ if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
1648
+ const have = parseSemver(entry.version);
1649
+ if (!have) continue;
1650
+ if (wanted && semverCompare(have, wanted) === 0) return { entry, formatType };
1651
+ if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1652
+ if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1653
+ bestVersion = have;
1654
+ bestResult = { entry, formatType };
1655
+ }
1577
1656
  }
1578
1657
  }
1579
1658
  }
1580
- return bestEntry;
1659
+ return bestResult;
1581
1660
  }
1582
1661
  async function verifySHA256(content, expectedHex) {
1583
1662
  const digest = await crypto.subtle.digest("SHA-256", content);
1584
1663
  const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
1585
1664
  return hex === expectedHex.toLowerCase();
1586
1665
  }
1587
- function getDownloadUrl(indexUrl, entry) {
1666
+ function getDownloadUrl(indexUrl, entry, formatType) {
1588
1667
  const base = indexUrl.replace(/\/index\.json$/, "");
1589
- return `${base}/twine2/${entry.name}/${entry.version}/format.js`;
1668
+ return `${base}/${formatType}/${entry.name}/${entry.version}/format.js`;
1590
1669
  }
1591
1670
  async function fetchAndCacheFormat(entry, downloadUrl) {
1592
1671
  const res = await fetch(downloadUrl);
@@ -1597,13 +1676,14 @@ async function fetchAndCacheFormat(entry, downloadUrl) {
1597
1676
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1598
1677
  if (checksumKey) {
1599
1678
  const expected = entry.checksums[checksumKey];
1679
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1600
1680
  const encoder = new TextEncoder();
1601
1681
  const valid = await verifySHA256(encoder.encode(text), expected);
1602
1682
  if (!valid) {
1603
1683
  throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1604
1684
  }
1605
1685
  }
1606
- const id = `${entry.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(entry.version)?.[0] ?? "0"}`;
1686
+ const id = makeFormatId(entry.name, entry.version);
1607
1687
  const data = parseFormatJSON(text, id);
1608
1688
  if (!data) {
1609
1689
  throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
@@ -1633,7 +1713,7 @@ async function fetchDirectFormat(url) {
1633
1713
  if (!data) {
1634
1714
  throw new Error(`Failed to parse format JSON from ${url}`);
1635
1715
  }
1636
- const id = `${data.name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(data.version)?.[0] ?? "0"}`;
1716
+ const id = makeFormatId(data.name, data.version);
1637
1717
  const cacheDir = getCacheDir();
1638
1718
  const formatDir = join4(cacheDir, data.name, data.version);
1639
1719
  mkdirSync(formatDir, { recursive: true });
@@ -1649,6 +1729,7 @@ async function fetchDirectFormat(url) {
1649
1729
  };
1650
1730
  }
1651
1731
  async function resolveRemoteFormat(name, version, indices, urls) {
1732
+ let lastError;
1652
1733
  if (urls) {
1653
1734
  for (const url of urls) {
1654
1735
  try {
@@ -1660,7 +1741,8 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1660
1741
  return info;
1661
1742
  }
1662
1743
  }
1663
- } catch {
1744
+ } catch (e) {
1745
+ lastError = e instanceof Error ? e : new Error(String(e));
1664
1746
  }
1665
1747
  }
1666
1748
  }
@@ -1668,24 +1750,26 @@ async function resolveRemoteFormat(name, version, indices, urls) {
1668
1750
  for (const indexUrl of allIndices) {
1669
1751
  try {
1670
1752
  const index = await fetchIndex(indexUrl);
1671
- const entry = findEntry(index, name, version);
1672
- if (entry) {
1673
- const cached = getCachedFormat(entry.name, entry.version);
1753
+ const result = findEntry(index, name, version);
1754
+ if (result) {
1755
+ const cached = getCachedFormat(result.entry.name, result.entry.version);
1674
1756
  if (cached) return cached;
1675
- const downloadUrl = getDownloadUrl(indexUrl, entry);
1676
- return await fetchAndCacheFormat(entry, downloadUrl);
1757
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1758
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1677
1759
  }
1678
- } catch {
1760
+ } catch (e) {
1761
+ lastError = e instanceof Error ? e : new Error(String(e));
1679
1762
  }
1680
1763
  }
1764
+ if (lastError) throw lastError;
1681
1765
  return void 0;
1682
1766
  }
1683
1767
  function getCachedFormat(name, version) {
1684
1768
  const formatPath = join4(getCacheDir(), name, version, "format.js");
1685
1769
  try {
1686
1770
  if (!existsSync(formatPath)) return void 0;
1687
- const source = readFileSync5(formatPath, "utf-8");
1688
- const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${parseSemver(version)?.[0] ?? "0"}`;
1771
+ const source = readFileSync3(formatPath, "utf-8");
1772
+ const id = makeFormatId(name, version);
1689
1773
  const data = parseFormatJSON(source, id);
1690
1774
  if (!data) return void 0;
1691
1775
  return {
@@ -1794,18 +1878,12 @@ async function buildOutput(options) {
1794
1878
  loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
1795
1879
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1796
1880
  if (options.tagAliases) {
1797
- applyTagAliases(story.passages, options.tagAliases);
1798
- }
1799
- const fatalErrors = diagnostics.filter((d) => d.level === "error");
1800
- if (fatalErrors.length > 0 && outputMode === "html") {
1881
+ story.passages = applyTagAliases(story.passages, options.tagAliases);
1801
1882
  }
1802
1883
  let format;
1803
1884
  let formatId = options.formatId ?? "";
1804
1885
  if (outputMode === "html") {
1805
- const formatSearchDirs = getFormatSearchDirs(
1806
- options.formatPaths,
1807
- options.useTweegoPath ?? true
1808
- );
1886
+ const formatSearchDirs = getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true);
1809
1887
  const formats = discoverFormats(formatSearchDirs);
1810
1888
  if (!formatId && story.twine2.format) {
1811
1889
  formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
@@ -1818,13 +1896,12 @@ async function buildOutput(options) {
1818
1896
  const remoteName = story.twine2.format || formatId;
1819
1897
  const remoteVersion = story.twine2.formatVersion || "";
1820
1898
  try {
1821
- format = await resolveRemoteFormat(
1822
- remoteName,
1823
- remoteVersion,
1824
- options.formatIndices,
1825
- options.formatUrls
1826
- );
1827
- } catch {
1899
+ format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
1900
+ } catch (e) {
1901
+ diagnostics.push({
1902
+ level: "warning",
1903
+ message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
1904
+ });
1828
1905
  }
1829
1906
  }
1830
1907
  if (!format) {
@@ -1835,14 +1912,7 @@ async function buildOutput(options) {
1835
1912
  });
1836
1913
  }
1837
1914
  }
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
- }
1915
+ const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
1846
1916
  if (testMode) {
1847
1917
  story.twine2.options.set("debug", true);
1848
1918
  }
@@ -1861,8 +1931,7 @@ async function buildOutput(options) {
1861
1931
  case "json":
1862
1932
  output = storyToJSON(story);
1863
1933
  break;
1864
- case "html":
1865
- default: {
1934
+ case "html": {
1866
1935
  if (!storyHas(story, startName)) {
1867
1936
  diagnostics.push({
1868
1937
  level: "error",
@@ -1884,9 +1953,13 @@ async function buildOutput(options) {
1884
1953
  output = toTwine1HTML(story, format, startName, diagnostics);
1885
1954
  }
1886
1955
  const modulePaths = options.modules ? getFilenames(options.modules) : [];
1887
- output = modifyHead(output, modulePaths, options.headFile);
1956
+ output = modifyHead(output, modulePaths, options.headFile, diagnostics);
1888
1957
  break;
1889
1958
  }
1959
+ default: {
1960
+ const _exhaustive = outputMode;
1961
+ throw new TweeTsError(`Unhandled output mode: ${_exhaustive}`, diagnostics);
1962
+ }
1890
1963
  }
1891
1964
  const stats = {
1892
1965
  ...getStoryStats(story),
@@ -1895,28 +1968,32 @@ async function buildOutput(options) {
1895
1968
  return { output, story, format, diagnostics, stats };
1896
1969
  }
1897
1970
  function storyToJSON(story) {
1898
- return JSON.stringify({
1899
- name: story.name,
1900
- ifid: story.ifid,
1901
- passages: story.passages.map((p) => ({
1902
- name: p.name,
1903
- tags: p.tags,
1904
- text: p.text,
1905
- metadata: p.metadata ?? null
1906
- })),
1907
- twine2: {
1908
- format: story.twine2.format,
1909
- formatVersion: story.twine2.formatVersion,
1910
- start: story.twine2.start,
1911
- tagColors: Object.fromEntries(story.twine2.tagColors),
1912
- options: Object.fromEntries(story.twine2.options),
1913
- zoom: story.twine2.zoom
1914
- }
1915
- }, null, 2);
1971
+ return JSON.stringify(
1972
+ {
1973
+ name: story.name,
1974
+ ifid: story.ifid,
1975
+ passages: story.passages.map((p) => ({
1976
+ name: p.name,
1977
+ tags: p.tags,
1978
+ text: p.text,
1979
+ metadata: p.metadata ?? null
1980
+ })),
1981
+ twine2: {
1982
+ format: story.twine2.format,
1983
+ formatVersion: story.twine2.formatVersion,
1984
+ start: story.twine2.start,
1985
+ tagColors: Object.fromEntries(story.twine2.tagColors),
1986
+ options: Object.fromEntries(story.twine2.options),
1987
+ zoom: story.twine2.zoom
1988
+ }
1989
+ },
1990
+ null,
1991
+ 2
1992
+ );
1916
1993
  }
1917
1994
 
1918
1995
  // src/config.ts
1919
- import { readFileSync as readFileSync6, existsSync as existsSync2 } from "fs";
1996
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
1920
1997
  import { join as join5 } from "path";
1921
1998
  var CONFIG_FILENAME = "twee-ts.config.json";
1922
1999
  var VALID_OUTPUT_MODES = ["html", "twee3", "twee1", "twine2-archive", "twine1-archive", "json"];
@@ -1926,7 +2003,7 @@ function loadConfig(dir) {
1926
2003
  if (!existsSync2(configPath)) return null;
1927
2004
  let raw;
1928
2005
  try {
1929
- raw = readFileSync6(configPath, "utf-8");
2006
+ raw = readFileSync4(configPath, "utf-8");
1930
2007
  } catch {
1931
2008
  return null;
1932
2009
  }
@@ -1946,7 +2023,7 @@ function loadConfig(dir) {
1946
2023
  function loadConfigFile(filePath) {
1947
2024
  let raw;
1948
2025
  try {
1949
- raw = readFileSync6(filePath, "utf-8");
2026
+ raw = readFileSync4(filePath, "utf-8");
1950
2027
  } catch (e) {
1951
2028
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
1952
2029
  }
@@ -2017,7 +2094,6 @@ function scaffoldConfig() {
2017
2094
  }
2018
2095
 
2019
2096
  // bin/twee-ts.ts
2020
- var VERSION = "0.1.0";
2021
2097
  var { values, positionals } = parseArgs({
2022
2098
  allowPositionals: true,
2023
2099
  options: {
@@ -2188,16 +2264,22 @@ Statistics:`);
2188
2264
  function runInit() {
2189
2265
  console.log("Initializing new twee-ts project...");
2190
2266
  mkdirSync2("src", { recursive: true });
2191
- writeFileSync3(join6("src", "StoryData.tw"), `:: StoryData
2267
+ writeFileSync3(
2268
+ join6("src", "StoryData.tw"),
2269
+ `:: StoryData
2192
2270
  {
2193
2271
  "ifid": "${crypto.randomUUID().toUpperCase()}"
2194
2272
  }
2195
- `);
2196
- writeFileSync3(join6("src", "Start.tw"), `:: Start
2273
+ `
2274
+ );
2275
+ writeFileSync3(
2276
+ join6("src", "Start.tw"),
2277
+ `:: Start
2197
2278
  Welcome to your new Twine story!
2198
2279
 
2199
2280
  This is the starting passage. Edit this file to begin writing your story.
2200
- `);
2281
+ `
2282
+ );
2201
2283
  if (!existsSync3(CONFIG_FILENAME)) {
2202
2284
  writeFileSync3(CONFIG_FILENAME, scaffoldConfig());
2203
2285
  console.log(`Created: ${CONFIG_FILENAME}`);
@@ -2241,7 +2323,7 @@ Options:
2241
2323
  -v, --version Show version`);
2242
2324
  }
2243
2325
  main().catch((err) => {
2244
- console.error(err.message || err);
2326
+ console.error(err instanceof Error ? err.message : String(err));
2245
2327
  process.exit(1);
2246
2328
  });
2247
2329
  //# sourceMappingURL=twee-ts.js.map