@rohal12/twee-ts 1.2.0 → 1.4.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/README.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # twee-ts
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@rohal12/twee-ts)](https://www.npmjs.com/package/@rohal12/twee-ts)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/rohal12/twee-ts/ci.yml)](https://github.com/rohal12/twee-ts/actions)
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/@rohal12/twee-ts)](https://bundlephobia.com/package/@rohal12/twee-ts)
6
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)]()
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org/)
8
+ [![node](https://img.shields.io/node/v/@rohal12/twee-ts)](https://nodejs.org/)
9
+ [![last commit](https://img.shields.io/github/last-commit/rohal12/twee-ts)](https://github.com/rohal12/twee-ts/commits)
10
+ [![license](https://img.shields.io/npm/l/@rohal12/twee-ts)](./UNLICENSE)
11
+
3
12
  TypeScript reimplementation of [Tweego](https://www.motoslave.net/tweego/) — a command-line compiler for Twine/Twee interactive fiction projects.
4
13
 
5
14
  Zero runtime dependencies. Node.js 22+.
@@ -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) {
@@ -198,9 +211,9 @@ 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
+ return `<tw-passagedata pid="${pid}" name=${quote(fullAttrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} position=${quote(attrEscape(position))} size=${quote(attrEscape(size))}>${htmlEscape(p.text)}</tw-passagedata>`;
202
215
  }
203
- function passageToTiddler(p, pid) {
216
+ function passageToTiddler(p, pid, obfuscateRot13 = false) {
204
217
  let position;
205
218
  if (hasMetadataPosition(p)) {
206
219
  position = p.metadata?.position ?? "";
@@ -211,7 +224,10 @@ function passageToTiddler(p, pid) {
211
224
  const yp = x === 0 ? y : y + 1;
212
225
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
213
226
  }
214
- return `<div tiddler=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(p.text)}</div>`;
227
+ const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
228
+ const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
229
+ const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
230
+ 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
231
  }
216
232
  function countWords(p) {
217
233
  let text = p.text;
@@ -254,6 +270,7 @@ function createStory() {
254
270
  formatVersion: "",
255
271
  options: /* @__PURE__ */ new Map(),
256
272
  start: "",
273
+ tags: "",
257
274
  tagColors: /* @__PURE__ */ new Map(),
258
275
  zoom: 1
259
276
  }
@@ -300,6 +317,7 @@ function marshalStoryData(story) {
300
317
  }
301
318
  if (options.length > 0) data.options = options;
302
319
  if (story.twine2.start) data.start = story.twine2.start;
320
+ if (story.twine2.tags) data.tags = story.twine2.tags;
303
321
  if (story.twine2.tagColors.size > 0) {
304
322
  data["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
305
323
  }
@@ -327,6 +345,7 @@ function unmarshalStoryData(story, json) {
327
345
  }
328
346
  }
329
347
  if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
348
+ if (typeof data.tags === "string") story.twine2.tags = data.tags;
330
349
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
331
350
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
332
351
  if (typeof color === "string") story.twine2.tagColors.set(tag, color);
@@ -398,7 +417,7 @@ function storyAdd(story, p, diagnostics) {
398
417
  processed = { ...p, text: marshalStoryData(story) };
399
418
  } else {
400
419
  diagnostics.push({
401
- level: "error",
420
+ level: "warning",
402
421
  message: `Cannot unmarshal "StoryData" compiler special passage; ${err}.`
403
422
  });
404
423
  }
@@ -621,17 +640,41 @@ function baseNameWithoutExt(filename) {
621
640
  }
622
641
 
623
642
  // src/formats.ts
643
+ function relaxedJSONParse(json) {
644
+ try {
645
+ return JSON.parse(json);
646
+ } catch {
647
+ }
648
+ let fixed = json;
649
+ fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
650
+ fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, '"$1":');
651
+ fixed = fixed.replace(/,\s*([}\]])/g, "$1");
652
+ return JSON.parse(fixed);
653
+ }
624
654
  function parseFormatJSON(source, formatId) {
625
655
  const first = source.indexOf("{");
626
656
  const last = source.lastIndexOf("}");
627
657
  if (first === -1 || last === -1) return null;
628
658
  let chunk = source.slice(first, last + 1);
629
659
  const parse = (json) => {
630
- const raw = JSON.parse(json);
660
+ const raw = relaxedJSONParse(json);
631
661
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
632
662
  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 };
663
+ if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
664
+ if (!parseSemver(obj.version)) return null;
665
+ const name = typeof obj.name === "string" ? obj.name : "Untitled Story Format";
666
+ const result = {
667
+ name,
668
+ version: obj.version,
669
+ source: obj.source,
670
+ proofing: obj.proofing === true
671
+ };
672
+ if (typeof obj.author === "string") result.author = obj.author;
673
+ if (typeof obj.description === "string") result.description = obj.description;
674
+ if (typeof obj.image === "string") result.image = obj.image;
675
+ if (typeof obj.url === "string") result.url = obj.url;
676
+ if (typeof obj.license === "string") result.license = obj.license;
677
+ return result;
635
678
  };
636
679
  try {
637
680
  return parse(chunk);
@@ -697,17 +740,55 @@ function discoverFormats(searchDirs) {
697
740
  format.name = data.name;
698
741
  format.version = data.version;
699
742
  format.proofing = data.proofing ?? false;
743
+ if (data.author) format.author = data.author;
744
+ if (data.description) format.description = data.description;
745
+ if (data.image) format.image = data.image;
746
+ if (data.url) format.url = data.url;
747
+ if (data.license) format.license = data.license;
700
748
  } catch {
701
749
  continue;
702
750
  }
751
+ } else {
752
+ format.name = entry;
703
753
  }
704
754
  formats.set(entry, format);
705
755
  break;
706
756
  }
707
757
  }
708
758
  }
759
+ pruneFormats(formats);
709
760
  return formats;
710
761
  }
762
+ function pruneFormats(formats) {
763
+ const bestByNameMajor = /* @__PURE__ */ new Map();
764
+ const seenNameVersion = /* @__PURE__ */ new Set();
765
+ for (const [id, f] of formats) {
766
+ if (!f.isTwine2 || !f.name || !f.version) continue;
767
+ const v = parseSemver(f.version);
768
+ if (!v) continue;
769
+ const nameVersionKey = `${f.name}@${f.version}`;
770
+ if (seenNameVersion.has(nameVersionKey)) {
771
+ formats.delete(id);
772
+ continue;
773
+ }
774
+ seenNameVersion.add(nameVersionKey);
775
+ const groupKey = `${f.name}@${v[0]}`;
776
+ const existing = bestByNameMajor.get(groupKey);
777
+ if (!existing || semverCompare(v, existing.version) > 0) {
778
+ bestByNameMajor.set(groupKey, { id, version: v });
779
+ }
780
+ }
781
+ for (const [id, f] of [...formats]) {
782
+ if (!f.isTwine2 || !f.name || !f.version) continue;
783
+ const v = parseSemver(f.version);
784
+ if (!v) continue;
785
+ const groupKey = `${f.name}@${v[0]}`;
786
+ const best = bestByNameMajor.get(groupKey);
787
+ if (best && best.id !== id) {
788
+ formats.delete(id);
789
+ }
790
+ }
791
+ }
711
792
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
712
793
  const subdirNames = ["storyformats", ".storyformats", "story-formats", "storyFormats", "targets"];
713
794
  const basePaths = /* @__PURE__ */ new Set();
@@ -1144,8 +1225,11 @@ function parseTwee(source, options = {}) {
1144
1225
  }
1145
1226
  const parsed = raw;
1146
1227
  const meta = {};
1147
- if (typeof parsed.position === "string") meta.position = parsed.position;
1148
- if (typeof parsed.size === "string") meta.size = parsed.size;
1228
+ for (const [key, value] of Object.entries(parsed)) {
1229
+ if (typeof value === "string") {
1230
+ meta[key] = value;
1231
+ }
1232
+ }
1149
1233
  current.metadata = meta;
1150
1234
  } catch (e) {
1151
1235
  diagnostics.push({
@@ -1159,7 +1243,11 @@ function parseTwee(source, options = {}) {
1159
1243
  }
1160
1244
  case 6 /* Content */: {
1161
1245
  if (!current) break;
1162
- current.text = trim ? item.val.trim() : item.val;
1246
+ if (trim) {
1247
+ current.text = item.val.trim();
1248
+ } else {
1249
+ current.text = item.val.replace(/\n\s*$/, "");
1250
+ }
1163
1251
  break;
1164
1252
  }
1165
1253
  default: {
@@ -1387,8 +1475,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1387
1475
  }
1388
1476
  }
1389
1477
  parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}</script>`);
1478
+ const validTagColors = /* @__PURE__ */ new Set(["gray", "red", "orange", "yellow", "green", "blue", "purple"]);
1479
+ const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
1390
1480
  for (const [tag, color] of story.twine2.tagColors) {
1391
- parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1481
+ if (validTagColors.has(color) || hexColorPattern.test(color)) {
1482
+ parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1483
+ }
1392
1484
  }
1393
1485
  pid = 1;
1394
1486
  for (const p of story.passages) {
@@ -1409,23 +1501,24 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1409
1501
  if (val) opts.push(opt);
1410
1502
  }
1411
1503
  const options = opts.join(" ");
1412
- const zoom = story.twine2.zoom === Math.floor(story.twine2.zoom) ? story.twine2.zoom.toString() : story.twine2.zoom.toFixed(1);
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>`;
1504
+ const zoom = String(story.twine2.zoom);
1505
+ 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)}" tags="${attrEscape(story.twine2.tags)}" hidden>`;
1414
1506
  return wrapper + parts.join("") + "</tw-storydata>";
1415
1507
  }
1416
1508
  function ensureIFID(story, diagnostics) {
1417
1509
  if (story.ifid !== "") return;
1418
1510
  if (story.legacyIFID !== "") {
1511
+ story.ifid = story.legacyIFID;
1419
1512
  diagnostics.push({
1420
1513
  level: "warning",
1421
1514
  message: 'Story IFID not found; reusing "ifid" entry from the "StorySettings" special passage.'
1422
1515
  });
1423
- story.ifid = story.legacyIFID;
1424
1516
  } else {
1425
- story.ifid = generateIFID();
1517
+ const ifid = generateIFID();
1518
+ story.ifid = ifid;
1426
1519
  diagnostics.push({
1427
- level: "warning",
1428
- message: `Story IFID not found; generated ${story.ifid} for your project. Add it to a StoryData passage.`
1520
+ level: "error",
1521
+ message: `Story IFID not found. Add an IFID to your story: {"ifid":"${ifid}"}`
1429
1522
  });
1430
1523
  }
1431
1524
  }
@@ -1436,7 +1529,7 @@ import { join as join3, dirname } from "path";
1436
1529
  var CREATOR_NAME2 = "twee-ts";
1437
1530
  function toTwine1Archive(story, _startName) {
1438
1531
  const { data, count } = getTwine1PassageChunk(story);
1439
- return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
1532
+ return `<div id="storeArea" data-size="${count}">${data}</div>
1440
1533
  `;
1441
1534
  }
1442
1535
  function toTwine1HTML(story, format, startName, _diagnostics) {
@@ -1481,12 +1574,13 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1481
1574
  return template;
1482
1575
  }
1483
1576
  function getTwine1PassageChunk(story) {
1577
+ const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
1484
1578
  let data = "";
1485
1579
  let count = 0;
1486
1580
  for (const p of story.passages) {
1487
1581
  if (hasTag(p, "Twine.private")) continue;
1488
1582
  count++;
1489
- data += passageToTiddler(p, count);
1583
+ data += passageToTiddler(p, count, obfuscateRot13);
1490
1584
  }
1491
1585
  return { data, count };
1492
1586
  }
@@ -1822,6 +1916,7 @@ function discoverCachedFormats() {
1822
1916
  }
1823
1917
 
1824
1918
  // src/compiler.ts
1919
+ var CREATOR_NAME3 = "Twee-ts";
1825
1920
  var DEFAULT_FORMAT_ID = "sugarcube-2";
1826
1921
  var DEFAULT_START_NAME = "Start";
1827
1922
  var TweeTsError = class extends Error {
@@ -1968,28 +2063,49 @@ async function buildOutput(options) {
1968
2063
  return { output, story, format, diagnostics, stats };
1969
2064
  }
1970
2065
  function storyToJSON(story) {
1971
- return JSON.stringify(
1972
- {
1973
- name: story.name,
1974
- ifid: story.ifid,
1975
- passages: story.passages.map((p) => ({
2066
+ const scripts = [];
2067
+ const stylesheets = [];
2068
+ const storyPassages = [];
2069
+ for (const p of story.passages) {
2070
+ if (p.name === "StoryTitle" || p.name === "StoryData") continue;
2071
+ if (hasTag(p, "Twine.private")) continue;
2072
+ if (hasTag(p, "script")) {
2073
+ scripts.push(p.text);
2074
+ } else if (hasTag(p, "stylesheet")) {
2075
+ stylesheets.push(p.text);
2076
+ } else {
2077
+ const entry = {
1976
2078
  name: p.name,
1977
2079
  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
2080
+ text: p.text
2081
+ };
2082
+ if (p.metadata) {
2083
+ const meta = {};
2084
+ for (const [key, value] of Object.entries(p.metadata)) {
2085
+ if (typeof value === "string") meta[key] = value;
2086
+ }
2087
+ if (Object.keys(meta).length > 0) {
2088
+ entry.metadata = meta;
2089
+ }
1988
2090
  }
1989
- },
1990
- null,
1991
- 2
1992
- );
2091
+ storyPassages.push(entry);
2092
+ }
2093
+ }
2094
+ const obj = {
2095
+ name: story.name
2096
+ };
2097
+ if (story.ifid) obj.ifid = story.ifid;
2098
+ if (story.twine2.format) obj.format = story.twine2.format;
2099
+ if (story.twine2.formatVersion) obj["format-version"] = story.twine2.formatVersion;
2100
+ if (story.twine2.start) obj.start = story.twine2.start;
2101
+ if (story.twine2.tagColors.size > 0) obj["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
2102
+ if (story.twine2.zoom !== 1) obj.zoom = story.twine2.zoom;
2103
+ obj.creator = CREATOR_NAME3;
2104
+ obj["creator-version"] = VERSION;
2105
+ obj.style = stylesheets.join("\n");
2106
+ obj.script = scripts.join("\n");
2107
+ obj.passages = storyPassages;
2108
+ return JSON.stringify(obj, null, 2);
1993
2109
  }
1994
2110
 
1995
2111
  // src/config.ts