@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.
@@ -64,6 +64,10 @@ function attrEscape(s) {
64
64
  if (s.length === 0) return s;
65
65
  return s.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'");
66
66
  }
67
+ function fullAttrEscape(s) {
68
+ if (s.length === 0) return s;
69
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
70
+ }
67
71
  function htmlEscape(s) {
68
72
  if (s.length === 0) return s;
69
73
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -86,6 +90,13 @@ function commentSanitize(s) {
86
90
  function htmlCommentSanitize(s) {
87
91
  return s.replace(/-->/g, "-- >");
88
92
  }
93
+ function rot13(s) {
94
+ if (s.length === 0) return s;
95
+ return s.replace(/[A-Za-z]/g, (ch) => {
96
+ const base = ch <= "Z" ? 65 : 97;
97
+ return String.fromCharCode((ch.charCodeAt(0) - base + 13) % 26 + base);
98
+ });
99
+ }
89
100
  function tweeUnescape(s) {
90
101
  if (s.length === 0) return s;
91
102
  let result = "";
@@ -150,12 +161,14 @@ function hasMetadataSize(p) {
150
161
  return p.metadata != null && p.metadata.size != null && p.metadata.size !== "";
151
162
  }
152
163
  function hasAnyMetadata(p) {
153
- return hasMetadataPosition(p) || hasMetadataSize(p);
164
+ if (!p.metadata) return false;
165
+ return Object.values(p.metadata).some((v) => v != null && v !== "");
154
166
  }
155
167
  function marshalMetadata(meta) {
156
168
  const obj = {};
157
- if (meta.position) obj.position = meta.position;
158
- if (meta.size) obj.size = meta.size;
169
+ for (const [key, value] of Object.entries(meta)) {
170
+ if (typeof value === "string" && value) obj[key] = value;
171
+ }
159
172
  return JSON.stringify(obj);
160
173
  }
161
174
  function passageToTwee(p, outMode) {
@@ -181,7 +194,7 @@ function passageToTwee(p, outMode) {
181
194
  output += "\n\n";
182
195
  return output;
183
196
  }
184
- function passageToPassagedata(p, pid) {
197
+ function passageToPassagedata(p, pid, options) {
185
198
  let position;
186
199
  let size;
187
200
  if (hasMetadataPosition(p)) {
@@ -198,9 +211,13 @@ function passageToPassagedata(p, pid) {
198
211
  } else {
199
212
  size = "100,100";
200
213
  }
201
- 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>`;
214
+ 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))}`;
215
+ if (options?.sourceInfo && p.source) {
216
+ attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
217
+ }
218
+ return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
202
219
  }
203
- function passageToTiddler(p, pid) {
220
+ function passageToTiddler(p, pid, obfuscateRot13 = false) {
204
221
  let position;
205
222
  if (hasMetadataPosition(p)) {
206
223
  position = p.metadata?.position ?? "";
@@ -211,7 +228,10 @@ function passageToTiddler(p, pid) {
211
228
  const yp = x === 0 ? y : y + 1;
212
229
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
213
230
  }
214
- return `<div tiddler=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(p.text)}</div>`;
231
+ const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
232
+ const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
233
+ const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
234
+ 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>`;
215
235
  }
216
236
  function countWords(p) {
217
237
  let text = p.text;
@@ -254,6 +274,7 @@ function createStory() {
254
274
  formatVersion: "",
255
275
  options: /* @__PURE__ */ new Map(),
256
276
  start: "",
277
+ tags: "",
257
278
  tagColors: /* @__PURE__ */ new Map(),
258
279
  zoom: 1
259
280
  }
@@ -300,6 +321,7 @@ function marshalStoryData(story) {
300
321
  }
301
322
  if (options.length > 0) data.options = options;
302
323
  if (story.twine2.start) data.start = story.twine2.start;
324
+ if (story.twine2.tags) data.tags = story.twine2.tags;
303
325
  if (story.twine2.tagColors.size > 0) {
304
326
  data["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
305
327
  }
@@ -327,6 +349,7 @@ function unmarshalStoryData(story, json) {
327
349
  }
328
350
  }
329
351
  if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
352
+ if (typeof data.tags === "string") story.twine2.tags = data.tags;
330
353
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
331
354
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
332
355
  if (typeof color === "string") story.twine2.tagColors.set(tag, color);
@@ -398,7 +421,7 @@ function storyAdd(story, p, diagnostics) {
398
421
  processed = { ...p, text: marshalStoryData(story) };
399
422
  } else {
400
423
  diagnostics.push({
401
- level: "error",
424
+ level: "warning",
402
425
  message: `Cannot unmarshal "StoryData" compiler special passage; ${err}.`
403
426
  });
404
427
  }
@@ -621,17 +644,41 @@ function baseNameWithoutExt(filename) {
621
644
  }
622
645
 
623
646
  // src/formats.ts
647
+ function relaxedJSONParse(json) {
648
+ try {
649
+ return JSON.parse(json);
650
+ } catch {
651
+ }
652
+ let fixed = json;
653
+ fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
654
+ fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, '"$1":');
655
+ fixed = fixed.replace(/,\s*([}\]])/g, "$1");
656
+ return JSON.parse(fixed);
657
+ }
624
658
  function parseFormatJSON(source, formatId) {
625
659
  const first = source.indexOf("{");
626
660
  const last = source.lastIndexOf("}");
627
661
  if (first === -1 || last === -1) return null;
628
662
  let chunk = source.slice(first, last + 1);
629
663
  const parse = (json) => {
630
- const raw = JSON.parse(json);
664
+ const raw = relaxedJSONParse(json);
631
665
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
632
666
  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 };
667
+ if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
668
+ if (!parseSemver(obj.version)) return null;
669
+ const name = typeof obj.name === "string" ? obj.name : "Untitled Story Format";
670
+ const result = {
671
+ name,
672
+ version: obj.version,
673
+ source: obj.source,
674
+ proofing: obj.proofing === true
675
+ };
676
+ if (typeof obj.author === "string") result.author = obj.author;
677
+ if (typeof obj.description === "string") result.description = obj.description;
678
+ if (typeof obj.image === "string") result.image = obj.image;
679
+ if (typeof obj.url === "string") result.url = obj.url;
680
+ if (typeof obj.license === "string") result.license = obj.license;
681
+ return result;
635
682
  };
636
683
  try {
637
684
  return parse(chunk);
@@ -697,17 +744,55 @@ function discoverFormats(searchDirs) {
697
744
  format.name = data.name;
698
745
  format.version = data.version;
699
746
  format.proofing = data.proofing ?? false;
747
+ if (data.author) format.author = data.author;
748
+ if (data.description) format.description = data.description;
749
+ if (data.image) format.image = data.image;
750
+ if (data.url) format.url = data.url;
751
+ if (data.license) format.license = data.license;
700
752
  } catch {
701
753
  continue;
702
754
  }
755
+ } else {
756
+ format.name = entry;
703
757
  }
704
758
  formats.set(entry, format);
705
759
  break;
706
760
  }
707
761
  }
708
762
  }
763
+ pruneFormats(formats);
709
764
  return formats;
710
765
  }
766
+ function pruneFormats(formats) {
767
+ const bestByNameMajor = /* @__PURE__ */ new Map();
768
+ const seenNameVersion = /* @__PURE__ */ new Set();
769
+ for (const [id, f] of formats) {
770
+ if (!f.isTwine2 || !f.name || !f.version) continue;
771
+ const v = parseSemver(f.version);
772
+ if (!v) continue;
773
+ const nameVersionKey = `${f.name}@${f.version}`;
774
+ if (seenNameVersion.has(nameVersionKey)) {
775
+ formats.delete(id);
776
+ continue;
777
+ }
778
+ seenNameVersion.add(nameVersionKey);
779
+ const groupKey = `${f.name}@${v[0]}`;
780
+ const existing = bestByNameMajor.get(groupKey);
781
+ if (!existing || semverCompare(v, existing.version) > 0) {
782
+ bestByNameMajor.set(groupKey, { id, version: v });
783
+ }
784
+ }
785
+ for (const [id, f] of [...formats]) {
786
+ if (!f.isTwine2 || !f.name || !f.version) continue;
787
+ const v = parseSemver(f.version);
788
+ if (!v) continue;
789
+ const groupKey = `${f.name}@${v[0]}`;
790
+ const best = bestByNameMajor.get(groupKey);
791
+ if (best && best.id !== id) {
792
+ formats.delete(id);
793
+ }
794
+ }
795
+ }
711
796
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
712
797
  const subdirNames = ["storyformats", ".storyformats", "story-formats", "storyFormats", "targets"];
713
798
  const basePaths = /* @__PURE__ */ new Set();
@@ -1094,7 +1179,7 @@ function parseTwee(source, options = {}) {
1094
1179
  if (pCount > 1 && current) {
1095
1180
  passages.push(current);
1096
1181
  }
1097
- current = { name: "", tags: [], text: "" };
1182
+ current = { name: "", tags: [], text: "", source: { file: filename, line: item.line } };
1098
1183
  break;
1099
1184
  case 3 /* Name */: {
1100
1185
  if (!current) break;
@@ -1144,8 +1229,11 @@ function parseTwee(source, options = {}) {
1144
1229
  }
1145
1230
  const parsed = raw;
1146
1231
  const meta = {};
1147
- if (typeof parsed.position === "string") meta.position = parsed.position;
1148
- if (typeof parsed.size === "string") meta.size = parsed.size;
1232
+ for (const [key, value] of Object.entries(parsed)) {
1233
+ if (typeof value === "string") {
1234
+ meta[key] = value;
1235
+ }
1236
+ }
1149
1237
  current.metadata = meta;
1150
1238
  } catch (e) {
1151
1239
  diagnostics.push({
@@ -1159,7 +1247,11 @@ function parseTwee(source, options = {}) {
1159
1247
  }
1160
1248
  case 6 /* Content */: {
1161
1249
  if (!current) break;
1162
- current.text = trim ? item.val.trim() : item.val;
1250
+ if (trim) {
1251
+ current.text = item.val.trim();
1252
+ } else {
1253
+ current.text = item.val.replace(/\n\s*$/, "");
1254
+ }
1163
1255
  break;
1164
1256
  }
1165
1257
  default: {
@@ -1334,20 +1426,20 @@ var VERSION = "1.2.0";
1334
1426
 
1335
1427
  // src/output-twine2.ts
1336
1428
  var CREATOR_NAME = "Twee-ts";
1337
- function toTwine2Archive(story, startName, diagnostics) {
1338
- return getTwine2DataChunk(story, startName, diagnostics) + "\n";
1429
+ function toTwine2Archive(story, startName, diagnostics, options) {
1430
+ return getTwine2DataChunk(story, startName, diagnostics, options) + "\n";
1339
1431
  }
1340
- function toTwine2HTML(story, format, startName, diagnostics) {
1432
+ function toTwine2HTML(story, format, startName, diagnostics, options) {
1341
1433
  let template = readFormatSource(format);
1342
1434
  if (template.includes("{{STORY_NAME}}")) {
1343
1435
  template = template.replaceAll("{{STORY_NAME}}", htmlEscape(story.name));
1344
1436
  }
1345
1437
  if (template.includes("{{STORY_DATA}}")) {
1346
- template = template.replace("{{STORY_DATA}}", getTwine2DataChunk(story, startName, diagnostics));
1438
+ template = template.replace("{{STORY_DATA}}", getTwine2DataChunk(story, startName, diagnostics, options));
1347
1439
  }
1348
1440
  return template;
1349
1441
  }
1350
- function getTwine2DataChunk(story, startName, diagnostics) {
1442
+ function getTwine2DataChunk(story, startName, diagnostics, options) {
1351
1443
  ensureIFID(story, diagnostics);
1352
1444
  const parts = [];
1353
1445
  let startID = "";
@@ -1387,8 +1479,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1387
1479
  }
1388
1480
  }
1389
1481
  parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}</script>`);
1482
+ const validTagColors = /* @__PURE__ */ new Set(["gray", "red", "orange", "yellow", "green", "blue", "purple"]);
1483
+ const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
1390
1484
  for (const [tag, color] of story.twine2.tagColors) {
1391
- parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1485
+ if (validTagColors.has(color) || hexColorPattern.test(color)) {
1486
+ parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1487
+ }
1392
1488
  }
1393
1489
  pid = 1;
1394
1490
  for (const p of story.passages) {
@@ -1398,7 +1494,7 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1398
1494
  if (p.name === "StorySettings" && story.twine1.settings.size === 0) {
1399
1495
  continue;
1400
1496
  }
1401
- parts.push(passageToPassagedata(p, pid));
1497
+ parts.push(passageToPassagedata(p, pid, options));
1402
1498
  if (startName === p.name) {
1403
1499
  startID = String(pid);
1404
1500
  }
@@ -1408,9 +1504,9 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1408
1504
  for (const [opt, val] of story.twine2.options) {
1409
1505
  if (val) opts.push(opt);
1410
1506
  }
1411
- const options = opts.join(" ");
1507
+ const optionsStr = opts.join(" ");
1412
1508
  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>`;
1509
+ 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>`;
1414
1510
  return wrapper + parts.join("") + "</tw-storydata>";
1415
1511
  }
1416
1512
  function ensureIFID(story, diagnostics) {
@@ -1426,7 +1522,7 @@ function ensureIFID(story, diagnostics) {
1426
1522
  story.ifid = ifid;
1427
1523
  diagnostics.push({
1428
1524
  level: "error",
1429
- message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1525
+ message: `Story IFID not found. Add an IFID to your story: {"ifid":"${ifid}"}`
1430
1526
  });
1431
1527
  }
1432
1528
  }
@@ -1437,7 +1533,7 @@ import { join as join3, dirname } from "path";
1437
1533
  var CREATOR_NAME2 = "twee-ts";
1438
1534
  function toTwine1Archive(story, _startName) {
1439
1535
  const { data, count } = getTwine1PassageChunk(story);
1440
- return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
1536
+ return `<div id="storeArea" data-size="${count}">${data}</div>
1441
1537
  `;
1442
1538
  }
1443
1539
  function toTwine1HTML(story, format, startName, _diagnostics) {
@@ -1482,12 +1578,13 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1482
1578
  return template;
1483
1579
  }
1484
1580
  function getTwine1PassageChunk(story) {
1581
+ const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
1485
1582
  let data = "";
1486
1583
  let count = 0;
1487
1584
  for (const p of story.passages) {
1488
1585
  if (hasTag(p, "Twine.private")) continue;
1489
1586
  count++;
1490
- data += passageToTiddler(p, count);
1587
+ data += passageToTiddler(p, count, obfuscateRot13);
1491
1588
  }
1492
1589
  return { data, count };
1493
1590
  }
@@ -1864,6 +1961,7 @@ async function buildOutput(options) {
1864
1961
  const twee2Compat = options.twee2Compat ?? false;
1865
1962
  const testMode = options.testMode ?? false;
1866
1963
  const noRemote = options.noRemote ?? false;
1964
+ const sourceInfo = options.sourceInfo ?? false;
1867
1965
  clearIndexCache();
1868
1966
  const filePaths = [];
1869
1967
  const inlineSources = [];
@@ -1925,7 +2023,7 @@ async function buildOutput(options) {
1925
2023
  output = toTwee(story, outputMode);
1926
2024
  break;
1927
2025
  case "twine2-archive":
1928
- output = toTwine2Archive(story, startName, diagnostics);
2026
+ output = toTwine2Archive(story, startName, diagnostics, { sourceInfo });
1929
2027
  break;
1930
2028
  case "twine1-archive":
1931
2029
  output = toTwine1Archive(story, startName);
@@ -1944,7 +2042,7 @@ async function buildOutput(options) {
1944
2042
  throw new TweeTsError("No story format available for HTML output.", diagnostics);
1945
2043
  }
1946
2044
  if (format.isTwine2) {
1947
- output = toTwine2HTML(story, format, startName, diagnostics);
2045
+ output = toTwine2HTML(story, format, startName, diagnostics, { sourceInfo });
1948
2046
  } else {
1949
2047
  if (story.name === "" && !storyHas(story, "StoryTitle")) {
1950
2048
  diagnostics.push({
@@ -1986,11 +2084,14 @@ function storyToJSON(story) {
1986
2084
  tags: p.tags,
1987
2085
  text: p.text
1988
2086
  };
1989
- if (p.metadata && (p.metadata.position || p.metadata.size)) {
2087
+ if (p.metadata) {
1990
2088
  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;
2089
+ for (const [key, value] of Object.entries(p.metadata)) {
2090
+ if (typeof value === "string") meta[key] = value;
2091
+ }
2092
+ if (Object.keys(meta).length > 0) {
2093
+ entry.metadata = meta;
2094
+ }
1994
2095
  }
1995
2096
  storyPassages.push(entry);
1996
2097
  }
@@ -2072,7 +2173,7 @@ function validateConfig(data) {
2072
2173
  errors.push(`"${key}" must be a string.`);
2073
2174
  }
2074
2175
  }
2075
- for (const key of ["useTweegoPath", "trim", "twee2Compat", "testMode", "noRemote"]) {
2176
+ for (const key of ["useTweegoPath", "trim", "twee2Compat", "testMode", "noRemote", "sourceInfo"]) {
2076
2177
  if (key in obj && typeof obj[key] !== "boolean") {
2077
2178
  errors.push(`"${key}" must be a boolean.`);
2078
2179
  }
@@ -2142,6 +2243,7 @@ var { values, positionals } = parseArgs({
2142
2243
  "format-url": { type: "string", multiple: true },
2143
2244
  "no-remote": { type: "boolean" },
2144
2245
  "tag-alias": { type: "string", multiple: true },
2246
+ "source-info": { type: "boolean" },
2145
2247
  config: { type: "string", short: "c" },
2146
2248
  "no-config": { type: "boolean" }
2147
2249
  }
@@ -2210,7 +2312,8 @@ async function main() {
2210
2312
  formatIndices: values["format-index"] ?? config?.formatIndices,
2211
2313
  formatUrls: values["format-url"] ?? config?.formatUrls,
2212
2314
  noRemote: values["no-remote"] ?? config?.noRemote ?? false,
2213
- tagAliases
2315
+ tagAliases,
2316
+ sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false
2214
2317
  };
2215
2318
  const outFile = values.output ?? config?.output ?? "-";
2216
2319
  if (values.watch) {
@@ -2336,6 +2439,7 @@ Options:
2336
2439
  --format-index <url> SFA-compatible format index URL (repeatable)
2337
2440
  --format-url <url> Direct format.js URL (repeatable)
2338
2441
  --tag-alias <alias=target> Map a tag to a special tag (repeatable)
2442
+ --source-info Emit source file/line as data- attributes on passages
2339
2443
  --no-remote Disable remote format fetching
2340
2444
  -c, --config <file> Config file path (default: ${CONFIG_FILENAME})
2341
2445
  --no-config Skip config file loading