@rohal12/twee-ts 1.3.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/dist/index.d.cts CHANGED
@@ -76,6 +76,7 @@ interface CompileStats {
76
76
  interface PassageMetadata {
77
77
  position?: string;
78
78
  size?: string;
79
+ [key: string]: string | undefined;
79
80
  }
80
81
  interface Passage {
81
82
  name: string;
@@ -91,6 +92,7 @@ interface Twine2Metadata {
91
92
  formatVersion: string;
92
93
  options: Map<string, boolean>;
93
94
  start: string;
95
+ tags: string;
94
96
  tagColors: Map<string, string>;
95
97
  zoom: number;
96
98
  }
@@ -109,12 +111,22 @@ interface StoryFormatInfo {
109
111
  name: string;
110
112
  version: string;
111
113
  proofing: boolean;
114
+ author?: string;
115
+ description?: string;
116
+ image?: string;
117
+ url?: string;
118
+ license?: string;
112
119
  }
113
120
  interface Twine2FormatJSON {
114
121
  name: string;
115
122
  version: string;
116
123
  proofing?: boolean;
117
124
  source: string;
125
+ author?: string;
126
+ description?: string;
127
+ image?: string;
128
+ url?: string;
129
+ license?: string;
118
130
  }
119
131
  declare const enum ItemType {
120
132
  Error = 0,
@@ -301,10 +313,13 @@ declare function parseTwee(source: string, options?: ParseOptions): ParseResult;
301
313
  /**
302
314
  * Parse the Twine 2 format.js JSON chunk.
303
315
  * Handles Harlowe's malformed JSON by stripping the "setup" function property.
316
+ * Per spec, the name key is Optional.
304
317
  */
305
318
  declare function parseFormatJSON(source: string, formatId: string): Twine2FormatJSON | null;
306
319
  /**
307
320
  * Discover all story formats in the given search directories.
321
+ * Implements SemVer-based version pruning: within each (name, major) group,
322
+ * only the highest minor.patch survives.
308
323
  */
309
324
  declare function discoverFormats(searchDirs: string[]): Map<string, StoryFormatInfo>;
310
325
  /**
package/dist/index.d.ts CHANGED
@@ -76,6 +76,7 @@ interface CompileStats {
76
76
  interface PassageMetadata {
77
77
  position?: string;
78
78
  size?: string;
79
+ [key: string]: string | undefined;
79
80
  }
80
81
  interface Passage {
81
82
  name: string;
@@ -91,6 +92,7 @@ interface Twine2Metadata {
91
92
  formatVersion: string;
92
93
  options: Map<string, boolean>;
93
94
  start: string;
95
+ tags: string;
94
96
  tagColors: Map<string, string>;
95
97
  zoom: number;
96
98
  }
@@ -109,12 +111,22 @@ interface StoryFormatInfo {
109
111
  name: string;
110
112
  version: string;
111
113
  proofing: boolean;
114
+ author?: string;
115
+ description?: string;
116
+ image?: string;
117
+ url?: string;
118
+ license?: string;
112
119
  }
113
120
  interface Twine2FormatJSON {
114
121
  name: string;
115
122
  version: string;
116
123
  proofing?: boolean;
117
124
  source: string;
125
+ author?: string;
126
+ description?: string;
127
+ image?: string;
128
+ url?: string;
129
+ license?: string;
118
130
  }
119
131
  declare const enum ItemType {
120
132
  Error = 0,
@@ -301,10 +313,13 @@ declare function parseTwee(source: string, options?: ParseOptions): ParseResult;
301
313
  /**
302
314
  * Parse the Twine 2 format.js JSON chunk.
303
315
  * Handles Harlowe's malformed JSON by stripping the "setup" function property.
316
+ * Per spec, the name key is Optional.
304
317
  */
305
318
  declare function parseFormatJSON(source: string, formatId: string): Twine2FormatJSON | null;
306
319
  /**
307
320
  * Discover all story formats in the given search directories.
321
+ * Implements SemVer-based version pruning: within each (name, major) group,
322
+ * only the highest minor.patch survives.
308
323
  */
309
324
  declare function discoverFormats(searchDirs: string[]): Map<string, StoryFormatInfo>;
310
325
  /**
package/dist/index.js CHANGED
@@ -57,6 +57,10 @@ function attrEscape(s) {
57
57
  if (s.length === 0) return s;
58
58
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
59
59
  }
60
+ function fullAttrEscape(s) {
61
+ if (s.length === 0) return s;
62
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
63
+ }
60
64
  function htmlEscape(s) {
61
65
  if (s.length === 0) return s;
62
66
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -79,6 +83,13 @@ function commentSanitize(s) {
79
83
  function htmlCommentSanitize(s) {
80
84
  return s.replace(/-->/g, "-- >");
81
85
  }
86
+ function rot13(s) {
87
+ if (s.length === 0) return s;
88
+ return s.replace(/[A-Za-z]/g, (ch) => {
89
+ const base = ch <= "Z" ? 65 : 97;
90
+ return String.fromCharCode((ch.charCodeAt(0) - base + 13) % 26 + base);
91
+ });
92
+ }
82
93
  function tweeUnescape(s) {
83
94
  if (s.length === 0) return s;
84
95
  let result = "";
@@ -146,12 +157,14 @@ function hasMetadataSize(p) {
146
157
  return p.metadata != null && p.metadata.size != null && p.metadata.size !== "";
147
158
  }
148
159
  function hasAnyMetadata(p) {
149
- return hasMetadataPosition(p) || hasMetadataSize(p);
160
+ if (!p.metadata) return false;
161
+ return Object.values(p.metadata).some((v) => v != null && v !== "");
150
162
  }
151
163
  function marshalMetadata(meta) {
152
164
  const obj = {};
153
- if (meta.position) obj.position = meta.position;
154
- if (meta.size) obj.size = meta.size;
165
+ for (const [key, value] of Object.entries(meta)) {
166
+ if (typeof value === "string" && value) obj[key] = value;
167
+ }
155
168
  return JSON.stringify(obj);
156
169
  }
157
170
  function passageToTwee(p, outMode) {
@@ -194,9 +207,9 @@ function passageToPassagedata(p, pid) {
194
207
  } else {
195
208
  size = "100,100";
196
209
  }
197
- 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>`;
210
+ 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>`;
198
211
  }
199
- function passageToTiddler(p, pid) {
212
+ function passageToTiddler(p, pid, obfuscateRot13 = false) {
200
213
  let position;
201
214
  if (hasMetadataPosition(p)) {
202
215
  position = p.metadata?.position ?? "";
@@ -207,7 +220,10 @@ function passageToTiddler(p, pid) {
207
220
  const yp = x === 0 ? y : y + 1;
208
221
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
209
222
  }
210
- return `<div tiddler=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(p.text)}</div>`;
223
+ const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
224
+ const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
225
+ const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
226
+ 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>`;
211
227
  }
212
228
  function countWords(p) {
213
229
  let text = p.text;
@@ -250,6 +266,7 @@ function createStory() {
250
266
  formatVersion: "",
251
267
  options: /* @__PURE__ */ new Map(),
252
268
  start: "",
269
+ tags: "",
253
270
  tagColors: /* @__PURE__ */ new Map(),
254
271
  zoom: 1
255
272
  }
@@ -296,6 +313,7 @@ function marshalStoryData(story) {
296
313
  }
297
314
  if (options.length > 0) data.options = options;
298
315
  if (story.twine2.start) data.start = story.twine2.start;
316
+ if (story.twine2.tags) data.tags = story.twine2.tags;
299
317
  if (story.twine2.tagColors.size > 0) {
300
318
  data["tag-colors"] = Object.fromEntries(story.twine2.tagColors);
301
319
  }
@@ -323,6 +341,7 @@ function unmarshalStoryData(story, json) {
323
341
  }
324
342
  }
325
343
  if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
344
+ if (typeof data.tags === "string") story.twine2.tags = data.tags;
326
345
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
327
346
  for (const [tag, color] of Object.entries(data["tag-colors"])) {
328
347
  if (typeof color === "string") story.twine2.tagColors.set(tag, color);
@@ -394,7 +413,7 @@ function storyAdd(story, p, diagnostics) {
394
413
  processed = { ...p, text: marshalStoryData(story) };
395
414
  } else {
396
415
  diagnostics.push({
397
- level: "error",
416
+ level: "warning",
398
417
  message: `Cannot unmarshal "StoryData" compiler special passage; ${err}.`
399
418
  });
400
419
  }
@@ -617,17 +636,41 @@ function baseNameWithoutExt(filename) {
617
636
  }
618
637
 
619
638
  // src/formats.ts
639
+ function relaxedJSONParse(json) {
640
+ try {
641
+ return JSON.parse(json);
642
+ } catch {
643
+ }
644
+ let fixed = json;
645
+ fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
646
+ fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, '"$1":');
647
+ fixed = fixed.replace(/,\s*([}\]])/g, "$1");
648
+ return JSON.parse(fixed);
649
+ }
620
650
  function parseFormatJSON(source, formatId) {
621
651
  const first = source.indexOf("{");
622
652
  const last = source.lastIndexOf("}");
623
653
  if (first === -1 || last === -1) return null;
624
654
  let chunk = source.slice(first, last + 1);
625
655
  const parse = (json) => {
626
- const raw = JSON.parse(json);
656
+ const raw = relaxedJSONParse(json);
627
657
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
628
658
  const obj = raw;
629
- if (typeof obj.name !== "string" || typeof obj.version !== "string" || typeof obj.source !== "string") return null;
630
- return { name: obj.name, version: obj.version, source: obj.source, proofing: obj.proofing === true };
659
+ if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
660
+ if (!parseSemver(obj.version)) return null;
661
+ const name = typeof obj.name === "string" ? obj.name : "Untitled Story Format";
662
+ const result = {
663
+ name,
664
+ version: obj.version,
665
+ source: obj.source,
666
+ proofing: obj.proofing === true
667
+ };
668
+ if (typeof obj.author === "string") result.author = obj.author;
669
+ if (typeof obj.description === "string") result.description = obj.description;
670
+ if (typeof obj.image === "string") result.image = obj.image;
671
+ if (typeof obj.url === "string") result.url = obj.url;
672
+ if (typeof obj.license === "string") result.license = obj.license;
673
+ return result;
631
674
  };
632
675
  try {
633
676
  return parse(chunk);
@@ -693,17 +736,55 @@ function discoverFormats(searchDirs) {
693
736
  format.name = data.name;
694
737
  format.version = data.version;
695
738
  format.proofing = data.proofing ?? false;
739
+ if (data.author) format.author = data.author;
740
+ if (data.description) format.description = data.description;
741
+ if (data.image) format.image = data.image;
742
+ if (data.url) format.url = data.url;
743
+ if (data.license) format.license = data.license;
696
744
  } catch {
697
745
  continue;
698
746
  }
747
+ } else {
748
+ format.name = entry;
699
749
  }
700
750
  formats.set(entry, format);
701
751
  break;
702
752
  }
703
753
  }
704
754
  }
755
+ pruneFormats(formats);
705
756
  return formats;
706
757
  }
758
+ function pruneFormats(formats) {
759
+ const bestByNameMajor = /* @__PURE__ */ new Map();
760
+ const seenNameVersion = /* @__PURE__ */ new Set();
761
+ for (const [id, f] of formats) {
762
+ if (!f.isTwine2 || !f.name || !f.version) continue;
763
+ const v = parseSemver(f.version);
764
+ if (!v) continue;
765
+ const nameVersionKey = `${f.name}@${f.version}`;
766
+ if (seenNameVersion.has(nameVersionKey)) {
767
+ formats.delete(id);
768
+ continue;
769
+ }
770
+ seenNameVersion.add(nameVersionKey);
771
+ const groupKey = `${f.name}@${v[0]}`;
772
+ const existing = bestByNameMajor.get(groupKey);
773
+ if (!existing || semverCompare(v, existing.version) > 0) {
774
+ bestByNameMajor.set(groupKey, { id, version: v });
775
+ }
776
+ }
777
+ for (const [id, f] of [...formats]) {
778
+ if (!f.isTwine2 || !f.name || !f.version) continue;
779
+ const v = parseSemver(f.version);
780
+ if (!v) continue;
781
+ const groupKey = `${f.name}@${v[0]}`;
782
+ const best = bestByNameMajor.get(groupKey);
783
+ if (best && best.id !== id) {
784
+ formats.delete(id);
785
+ }
786
+ }
787
+ }
707
788
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
708
789
  const subdirNames = ["storyformats", ".storyformats", "story-formats", "storyFormats", "targets"];
709
790
  const basePaths = /* @__PURE__ */ new Set();
@@ -1156,8 +1237,11 @@ function parseTwee(source, options = {}) {
1156
1237
  }
1157
1238
  const parsed = raw;
1158
1239
  const meta = {};
1159
- if (typeof parsed.position === "string") meta.position = parsed.position;
1160
- if (typeof parsed.size === "string") meta.size = parsed.size;
1240
+ for (const [key, value] of Object.entries(parsed)) {
1241
+ if (typeof value === "string") {
1242
+ meta[key] = value;
1243
+ }
1244
+ }
1161
1245
  current.metadata = meta;
1162
1246
  } catch (e) {
1163
1247
  diagnostics.push({
@@ -1171,7 +1255,11 @@ function parseTwee(source, options = {}) {
1171
1255
  }
1172
1256
  case 6 /* Content */: {
1173
1257
  if (!current) break;
1174
- current.text = trim ? item.val.trim() : item.val;
1258
+ if (trim) {
1259
+ current.text = item.val.trim();
1260
+ } else {
1261
+ current.text = item.val.replace(/\n\s*$/, "");
1262
+ }
1175
1263
  break;
1176
1264
  }
1177
1265
  default: {
@@ -1399,8 +1487,12 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1399
1487
  }
1400
1488
  }
1401
1489
  parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}</script>`);
1490
+ const validTagColors = /* @__PURE__ */ new Set(["gray", "red", "orange", "yellow", "green", "blue", "purple"]);
1491
+ const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
1402
1492
  for (const [tag, color] of story.twine2.tagColors) {
1403
- parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1493
+ if (validTagColors.has(color) || hexColorPattern.test(color)) {
1494
+ parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
1495
+ }
1404
1496
  }
1405
1497
  pid = 1;
1406
1498
  for (const p of story.passages) {
@@ -1422,7 +1514,7 @@ function getTwine2DataChunk(story, startName, diagnostics) {
1422
1514
  }
1423
1515
  const options = opts.join(" ");
1424
1516
  const zoom = String(story.twine2.zoom);
1425
- 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>`;
1517
+ 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>`;
1426
1518
  return wrapper + parts.join("") + "</tw-storydata>";
1427
1519
  }
1428
1520
  function ensureIFID(story, diagnostics) {
@@ -1438,7 +1530,7 @@ function ensureIFID(story, diagnostics) {
1438
1530
  story.ifid = ifid;
1439
1531
  diagnostics.push({
1440
1532
  level: "error",
1441
- message: `Story IFID not found. Add a StoryData passage with: {"ifid":"${ifid}"}`
1533
+ message: `Story IFID not found. Add an IFID to your story: {"ifid":"${ifid}"}`
1442
1534
  });
1443
1535
  }
1444
1536
  }
@@ -1449,7 +1541,7 @@ import { join as join3, dirname } from "path";
1449
1541
  var CREATOR_NAME2 = "twee-ts";
1450
1542
  function toTwine1Archive(story, _startName) {
1451
1543
  const { data, count } = getTwine1PassageChunk(story);
1452
- return `<div id="storeArea" data-size="${count}" hidden>${data}</div>
1544
+ return `<div id="storeArea" data-size="${count}">${data}</div>
1453
1545
  `;
1454
1546
  }
1455
1547
  function toTwine1HTML(story, format, startName, _diagnostics) {
@@ -1494,12 +1586,13 @@ function toTwine1HTML(story, format, startName, _diagnostics) {
1494
1586
  return template;
1495
1587
  }
1496
1588
  function getTwine1PassageChunk(story) {
1589
+ const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
1497
1590
  let data = "";
1498
1591
  let count = 0;
1499
1592
  for (const p of story.passages) {
1500
1593
  if (hasTag(p, "Twine.private")) continue;
1501
1594
  count++;
1502
- data += passageToTiddler(p, count);
1595
+ data += passageToTiddler(p, count, obfuscateRot13);
1503
1596
  }
1504
1597
  return { data, count };
1505
1598
  }
@@ -1998,11 +2091,14 @@ function storyToJSON(story) {
1998
2091
  tags: p.tags,
1999
2092
  text: p.text
2000
2093
  };
2001
- if (p.metadata && (p.metadata.position || p.metadata.size)) {
2094
+ if (p.metadata) {
2002
2095
  const meta = {};
2003
- if (p.metadata.position) meta.position = p.metadata.position;
2004
- if (p.metadata.size) meta.size = p.metadata.size;
2005
- entry.metadata = meta;
2096
+ for (const [key, value] of Object.entries(p.metadata)) {
2097
+ if (typeof value === "string") meta[key] = value;
2098
+ }
2099
+ if (Object.keys(meta).length > 0) {
2100
+ entry.metadata = meta;
2101
+ }
2006
2102
  }
2007
2103
  storyPassages.push(entry);
2008
2104
  }