@rohal12/twee-ts 1.3.0 → 1.5.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
@@ -108,6 +108,10 @@ function attrEscape(s) {
108
108
  if (s.length === 0) return s;
109
109
  return s.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'");
110
110
  }
111
+ function fullAttrEscape(s) {
112
+ if (s.length === 0) return s;
113
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
114
+ }
111
115
  function htmlEscape(s) {
112
116
  if (s.length === 0) return s;
113
117
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -130,6 +134,13 @@ function commentSanitize(s) {
130
134
  function htmlCommentSanitize(s) {
131
135
  return s.replace(/-->/g, "-- >");
132
136
  }
137
+ function rot13(s) {
138
+ if (s.length === 0) return s;
139
+ return s.replace(/[A-Za-z]/g, (ch) => {
140
+ const base = ch <= "Z" ? 65 : 97;
141
+ return String.fromCharCode((ch.charCodeAt(0) - base + 13) % 26 + base);
142
+ });
143
+ }
133
144
  function tweeUnescape(s) {
134
145
  if (s.length === 0) return s;
135
146
  let result = "";
@@ -197,12 +208,14 @@ function hasMetadataSize(p) {
197
208
  return p.metadata != null && p.metadata.size != null && p.metadata.size !== "";
198
209
  }
199
210
  function hasAnyMetadata(p) {
200
- return hasMetadataPosition(p) || hasMetadataSize(p);
211
+ if (!p.metadata) return false;
212
+ return Object.values(p.metadata).some((v) => v != null && v !== "");
201
213
  }
202
214
  function marshalMetadata(meta) {
203
215
  const obj = {};
204
- if (meta.position) obj.position = meta.position;
205
- if (meta.size) obj.size = meta.size;
216
+ for (const [key, value] of Object.entries(meta)) {
217
+ if (typeof value === "string" && value) obj[key] = value;
218
+ }
206
219
  return JSON.stringify(obj);
207
220
  }
208
221
  function passageToTwee(p, outMode) {
@@ -228,7 +241,7 @@ function passageToTwee(p, outMode) {
228
241
  output += "\n\n";
229
242
  return output;
230
243
  }
231
- function passageToPassagedata(p, pid) {
244
+ function passageToPassagedata(p, pid, options) {
232
245
  let position;
233
246
  let size;
234
247
  if (hasMetadataPosition(p)) {
@@ -245,9 +258,13 @@ function passageToPassagedata(p, pid) {
245
258
  } else {
246
259
  size = "100,100";
247
260
  }
248
- return `<tw-passagedata pid="${pid}" name=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} position=${quote(attrEscape(position))} size=${quote(attrEscape(size))}>${htmlEscape(p.text)}</tw-passagedata>`;
261
+ let attrs = `<tw-passagedata pid="${pid}" name=${quote(fullAttrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} position=${quote(attrEscape(position))} size=${quote(attrEscape(size))}`;
262
+ if (options?.sourceInfo && p.source) {
263
+ attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
264
+ }
265
+ return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
249
266
  }
250
- function passageToTiddler(p, pid) {
267
+ function passageToTiddler(p, pid, obfuscateRot13 = false) {
251
268
  let position;
252
269
  if (hasMetadataPosition(p)) {
253
270
  position = p.metadata?.position ?? "";
@@ -258,7 +275,10 @@ function passageToTiddler(p, pid) {
258
275
  const yp = x === 0 ? y : y + 1;
259
276
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
260
277
  }
261
- return `<div tiddler=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(p.text)}</div>`;
278
+ const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
279
+ const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
280
+ const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
281
+ return `<div tiddler=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${nameComment}${content}</div>`;
262
282
  }
263
283
  function countWords(p) {
264
284
  let text = p.text;
@@ -301,6 +321,7 @@ function createStory() {
301
321
  formatVersion: "",
302
322
  options: /* @__PURE__ */ new Map(),
303
323
  start: "",
324
+ tags: "",
304
325
  tagColors: /* @__PURE__ */ new Map(),
305
326
  zoom: 1
306
327
  }
@@ -347,6 +368,7 @@ function marshalStoryData(story) {
347
368
  }
348
369
  if (options.length > 0) data.options = options;
349
370
  if (story.twine2.start) data.start = story.twine2.start;
371
+ if (story.twine2.tags) data.tags = story.twine2.tags;
350
372
  if (story.twine2.tagColors.size > 0) {
351
373
  data["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
352
374
  }
@@ -374,6 +396,7 @@ function unmarshalStoryData(story, json) {
374
396
  }
375
397
  }
376
398
  if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
399
+ if (typeof data.tags === "string") story.twine2.tags = data.tags;
377
400
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
378
401
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
379
402
  if (typeof color === "string") story.twine2.tagColors.set(tag, color);
@@ -445,7 +468,7 @@ function storyAdd(story, p, diagnostics) {
445
468
  processed = { ...p, text: marshalStoryData(story) };
446
469
  } else {
447
470
  diagnostics.push({
448
- level: "error",
471
+ level: "warning",
449
472
  message: `Cannot unmarshal "StoryData" compiler special passage; ${err}.`
450
473
  });
451
474
  }
@@ -668,17 +691,41 @@ function baseNameWithoutExt(filename) {
668
691
  }
669
692
 
670
693
  // src/formats.ts
694
+ function relaxedJSONParse(json) {
695
+ try {
696
+ return JSON.parse(json);
697
+ } catch {
698
+ }
699
+ let fixed = json;
700
+ fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
701
+ fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, '"$1":');
702
+ fixed = fixed.replace(/,\s*([}\]])/g, "$1");
703
+ return JSON.parse(fixed);
704
+ }
671
705
  function parseFormatJSON(source, formatId) {
672
706
  const first = source.indexOf("{");
673
707
  const last = source.lastIndexOf("}");
674
708
  if (first === -1 || last === -1) return null;
675
709
  let chunk = source.slice(first, last + 1);
676
710
  const parse = (json) => {
677
- const raw = JSON.parse(json);
711
+ const raw = relaxedJSONParse(json);
678
712
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
679
713
  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 };
714
+ if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
715
+ if (!parseSemver(obj.version)) return null;
716
+ const name = typeof obj.name === "string" ? obj.name : "Untitled Story Format";
717
+ const result = {
718
+ name,
719
+ version: obj.version,
720
+ source: obj.source,
721
+ proofing: obj.proofing === true
722
+ };
723
+ if (typeof obj.author === "string") result.author = obj.author;
724
+ if (typeof obj.description === "string") result.description = obj.description;
725
+ if (typeof obj.image === "string") result.image = obj.image;
726
+ if (typeof obj.url === "string") result.url = obj.url;
727
+ if (typeof obj.license === "string") result.license = obj.license;
728
+ return result;
682
729
  };
683
730
  try {
684
731
  return parse(chunk);
@@ -744,17 +791,55 @@ function discoverFormats(searchDirs) {
744
791
  format.name = data.name;
745
792
  format.version = data.version;
746
793
  format.proofing = data.proofing ?? false;
794
+ if (data.author) format.author = data.author;
795
+ if (data.description) format.description = data.description;
796
+ if (data.image) format.image = data.image;
797
+ if (data.url) format.url = data.url;
798
+ if (data.license) format.license = data.license;
747
799
  } catch {
748
800
  continue;
749
801
  }
802
+ } else {
803
+ format.name = entry;
750
804
  }
751
805
  formats.set(entry, format);
752
806
  break;
753
807
  }
754
808
  }
755
809
  }
810
+ pruneFormats(formats);
756
811
  return formats;
757
812
  }
813
+ function pruneFormats(formats) {
814
+ const bestByNameMajor = /* @__PURE__ */ new Map();
815
+ const seenNameVersion = /* @__PURE__ */ new Set();
816
+ for (const [id, f] of formats) {
817
+ if (!f.isTwine2 || !f.name || !f.version) continue;
818
+ const v = parseSemver(f.version);
819
+ if (!v) continue;
820
+ const nameVersionKey = `${f.name}@${f.version}`;
821
+ if (seenNameVersion.has(nameVersionKey)) {
822
+ formats.delete(id);
823
+ continue;
824
+ }
825
+ seenNameVersion.add(nameVersionKey);
826
+ const groupKey = `${f.name}@${v[0]}`;
827
+ const existing = bestByNameMajor.get(groupKey);
828
+ if (!existing || semverCompare(v, existing.version) > 0) {
829
+ bestByNameMajor.set(groupKey, { id, version: v });
830
+ }
831
+ }
832
+ for (const [id, f] of [...formats]) {
833
+ if (!f.isTwine2 || !f.name || !f.version) continue;
834
+ const v = parseSemver(f.version);
835
+ if (!v) continue;
836
+ const groupKey = `${f.name}@${v[0]}`;
837
+ const best = bestByNameMajor.get(groupKey);
838
+ if (best && best.id !== id) {
839
+ formats.delete(id);
840
+ }
841
+ }
842
+ }
758
843
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
759
844
  const subdirNames = ["storyformats", ".storyformats", "story-formats", "storyFormats", "targets"];
760
845
  const basePaths = /* @__PURE__ */ new Set();
@@ -1157,7 +1242,7 @@ function parseTwee(source, options = {}) {
1157
1242
  if (pCount > 1 && current) {
1158
1243
  passages.push(current);
1159
1244
  }
1160
- current = { name: "", tags: [], text: "" };
1245
+ current = { name: "", tags: [], text: "", source: { file: filename, line: item.line } };
1161
1246
  break;
1162
1247
  case 3 /* Name */: {
1163
1248
  if (!current) break;
@@ -1207,8 +1292,11 @@ function parseTwee(source, options = {}) {
1207
1292
  }
1208
1293
  const parsed = raw;
1209
1294
  const meta = {};
1210
- if (typeof parsed.position === "string") meta.position = parsed.position;
1211
- if (typeof parsed.size === "string") meta.size = parsed.size;
1295
+ for (const [key, value] of Object.entries(parsed)) {
1296
+ if (typeof value === "string") {
1297
+ meta[key] = value;
1298
+ }
1299
+ }
1212
1300
  current.metadata = meta;
1213
1301
  } catch (e) {
1214
1302
  diagnostics.push({
@@ -1222,7 +1310,11 @@ function parseTwee(source, options = {}) {
1222
1310
  }
1223
1311
  case 6 /* Content */: {
1224
1312
  if (!current) break;
1225
- current.text = trim ? item.val.trim() : item.val;
1313
+ if (trim) {
1314
+ current.text = item.val.trim();
1315
+ } else {
1316
+ current.text = item.val.replace(/\n\s*$/, "");
1317
+ }
1226
1318
  break;
1227
1319
  }
1228
1320
  default: {
@@ -1397,20 +1489,20 @@ var VERSION = "1.2.0";
1397
1489
 
1398
1490
  // src/output-twine2.ts
1399
1491
  var CREATOR_NAME = "Twee-ts";
1400
- function toTwine2Archive(story, startName, diagnostics) {
1401
- return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1492
+ function toTwine2Archive(story, startName, diagnostics, options) {
1493
+ return getTwine2DataChunk(story, startName, diagnostics, options) + "\n";
1402
1494
  }
1403
- function toTwine2HTML(story, format, startName, diagnostics) {
1495
+ function toTwine2HTML(story, format, startName, diagnostics, options) {
1404
1496
  let template = readFormatSource(format);
1405
1497
  if (template.includes("{{STORY_NAME}}")) {
1406
1498
  template = template.replaceAll("{{STORY_NAME}}", htmlEscape(story.name));
1407
1499
  }
1408
1500
  if (template.includes("{{STORY_DATA}}")) {
1409
- template = template.replace("{{STORY_DATA}}", getTwine2DataChunk(story, startName, diagnostics));
1501
+ template = template.replace("{{STORY_DATA}}", getTwine2DataChunk(story, startName, diagnostics, options));
1410
1502
  }
1411
1503
  return template;
1412
1504
  }
1413
- function getTwine2DataChunk(story, startName, diagnostics) {
1505
+ function getTwine2DataChunk(story, startName, diagnostics, options) {
1414
1506
  ensureIFID(story, diagnostics);
1415
1507
  const parts = [];
1416
1508
  let startID = "";
@@ -1450,8 +1542,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1450
1542
  }
1451
1543
  }
1452
1544
  parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}</script>`);
1545
+ const validTagColors = /* @__PURE__ */ new Set(["gray", "red", "orange", "yellow", "green", "blue", "purple"]);
1546
+ const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
1453
1547
  for (const [tag, color] of story.twine2.tagColors) {
1454
- parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1548
+ if (validTagColors.has(color) || hexColorPattern.test(color)) {
1549
+ parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1550
+ }
1455
1551
  }
1456
1552
  pid = 1;
1457
1553
  for (const p of story.passages) {
@@ -1461,7 +1557,7 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1461
1557
  if (p.name === "StorySettings" && story.twine1.settings.size === 0) {
1462
1558
  continue;
1463
1559
  }
1464
- parts.push(passageToPassagedata(p, pid));
1560
+ parts.push(passageToPassagedata(p, pid, options));
1465
1561
  if (startName === p.name) {
1466
1562
  startID = String(pid);
1467
1563
  }
@@ -1471,9 +1567,9 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1471
1567
  for (const [opt, val] of story.twine2.options) {
1472
1568
  if (val) opts.push(opt);
1473
1569
  }
1474
- const options = opts.join(" ");
1570
+ const optionsStr = opts.join(" ");
1475
1571
  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>`;
1572
+ 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(optionsStr)}" tags="${attrEscape(story.twine2.tags)}" hidden>`;
1477
1573
  return wrapper + parts.join("") + "</tw-storydata>";
1478
1574
  }
1479
1575
  function ensureIFID(story, diagnostics) {
@@ -1489,7 +1585,7 @@ function ensureIFID(story, diagnostics) {
1489
1585
  story.ifid = ifid;
1490
1586
  diagnostics.push({
1491
1587
  level: "error",
1492
- message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1588
+ message: `Story IFID not found. Add an IFID to your story: {"ifid":"${ifid}"}`
1493
1589
  });
1494
1590
  }
1495
1591
  }
@@ -1500,7 +1596,7 @@ var import_node_path6 = require("path");
1500
1596
  var CREATOR_NAME2 = "twee-ts";
1501
1597
  function toTwine1Archive(story, _startName) {
1502
1598
  const { data, count } = getTwine1PassageChunk(story);
1503
- return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
1599
+ return `<div id="storeArea" data-size="${count}">${data}</div>
1504
1600
  `;
1505
1601
  }
1506
1602
  function toTwine1HTML(story, format, startName, _diagnostics) {
@@ -1545,12 +1641,13 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1545
1641
  return template;
1546
1642
  }
1547
1643
  function getTwine1PassageChunk(story) {
1644
+ const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
1548
1645
  let data = "";
1549
1646
  let count = 0;
1550
1647
  for (const p of story.passages) {
1551
1648
  if (hasTag(p, "Twine.private")) continue;
1552
1649
  count++;
1553
- data += passageToTiddler(p, count);
1650
+ data += passageToTiddler(p, count, obfuscateRot13);
1554
1651
  }
1555
1652
  return { data, count };
1556
1653
  }
@@ -1927,6 +2024,7 @@ async function buildOutput(options) {
1927
2024
  const twee2Compat = options.twee2Compat ?? false;
1928
2025
  const testMode = options.testMode ?? false;
1929
2026
  const noRemote = options.noRemote ?? false;
2027
+ const sourceInfo = options.sourceInfo ?? false;
1930
2028
  clearIndexCache();
1931
2029
  const filePaths = [];
1932
2030
  const inlineSources = [];
@@ -1988,7 +2086,7 @@ async function buildOutput(options) {
1988
2086
  output = toTwee(story, outputMode);
1989
2087
  break;
1990
2088
  case "twine2-archive":
1991
- output = toTwine2Archive(story, startName, diagnostics);
2089
+ output = toTwine2Archive(story, startName, diagnostics, { sourceInfo });
1992
2090
  break;
1993
2091
  case "twine1-archive":
1994
2092
  output = toTwine1Archive(story, startName);
@@ -2007,7 +2105,7 @@ async function buildOutput(options) {
2007
2105
  throw new TweeTsError("No story format available for HTML output.", diagnostics);
2008
2106
  }
2009
2107
  if (format.isTwine2) {
2010
- output = toTwine2HTML(story, format, startName, diagnostics);
2108
+ output = toTwine2HTML(story, format, startName, diagnostics, { sourceInfo });
2011
2109
  } else {
2012
2110
  if (story.name === "" && !storyHas(story, "StoryTitle")) {
2013
2111
  diagnostics.push({
@@ -2049,11 +2147,14 @@ function storyToJSON(story) {
2049
2147
  tags: p.tags,
2050
2148
  text: p.text
2051
2149
  };
2052
- if (p.metadata && (p.metadata.position || p.metadata.size)) {
2150
+ if (p.metadata) {
2053
2151
  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;
2152
+ for (const [key, value] of Object.entries(p.metadata)) {
2153
+ if (typeof value === "string") meta[key] = value;
2154
+ }
2155
+ if (Object.keys(meta).length > 0) {
2156
+ entry.metadata = meta;
2157
+ }
2057
2158
  }
2058
2159
  storyPassages.push(entry);
2059
2160
  }
@@ -2237,7 +2338,7 @@ function validateConfig(data) {
2237
2338
  errors.push(`"${key}" must be a string.`);
2238
2339
  }
2239
2340
  }
2240
- for (const key of ["useTweegoPath", "trim", "twee2Compat", "testMode", "noRemote"]) {
2341
+ for (const key of ["useTweegoPath", "trim", "twee2Compat", "testMode", "noRemote", "sourceInfo"]) {
2241
2342
  if (key in obj && typeof obj[key] !== "boolean") {
2242
2343
  errors.push(`"${key}" must be a boolean.`);
2243
2344
  }