@rohal12/twee-ts 1.18.1 → 1.18.2

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
@@ -158,8 +158,9 @@ interface Story {
158
158
  twine1: Twine1Metadata;
159
159
  twine2: Twine2Metadata;
160
160
  }
161
- type ReadonlyPassage = Readonly<Passage> & {
161
+ type ReadonlyPassage = Readonly<Omit<Passage, 'tags' | 'metadata'>> & {
162
162
  readonly tags: readonly string[];
163
+ readonly metadata?: Readonly<PassageMetadata>;
163
164
  };
164
165
  type ReadonlyStory = Readonly<Omit<Story, 'passages' | 'twine1' | 'twine2'>> & {
165
166
  readonly passages: readonly ReadonlyPassage[];
package/dist/index.d.ts CHANGED
@@ -158,8 +158,9 @@ interface Story {
158
158
  twine1: Twine1Metadata;
159
159
  twine2: Twine2Metadata;
160
160
  }
161
- type ReadonlyPassage = Readonly<Passage> & {
161
+ type ReadonlyPassage = Readonly<Omit<Passage, 'tags' | 'metadata'>> & {
162
162
  readonly tags: readonly string[];
163
+ readonly metadata?: Readonly<PassageMetadata>;
163
164
  };
164
165
  type ReadonlyStory = Readonly<Omit<Story, 'passages' | 'twine1' | 'twine2'>> & {
165
166
  readonly passages: readonly ReadonlyPassage[];
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as nodePath from "node:path";
2
2
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
- import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
4
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  //#region src/ifid.ts
@@ -1599,6 +1599,89 @@ function stripSetupFunction(chunk) {
1599
1599
  for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
1600
1600
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
1601
1601
  }
1602
+ /** Index after the whitespace and `//` or block comments starting at `i`. */
1603
+ function skipTrivia(text, i) {
1604
+ let pos = i;
1605
+ for (;;) {
1606
+ const ch = text[pos];
1607
+ if (ch !== void 0 && /\s/.test(ch)) pos++;
1608
+ else if (text.startsWith("//", pos)) {
1609
+ const newline = text.indexOf("\n", pos);
1610
+ pos = newline === -1 ? text.length : newline + 1;
1611
+ } else if (text.startsWith("/*", pos)) {
1612
+ const close = text.indexOf("*/", pos + 2);
1613
+ pos = close === -1 ? text.length : close + 2;
1614
+ } else return pos;
1615
+ }
1616
+ }
1617
+ /** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
1618
+ function skipString(text, i) {
1619
+ const quote = text[i];
1620
+ let pos = i + 1;
1621
+ while (pos < text.length) {
1622
+ const ch = text[pos];
1623
+ if (ch === "\\") pos += 2;
1624
+ else if (ch === quote) return pos + 1;
1625
+ else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
1626
+ const close = findClosingBrace(text, pos + 1);
1627
+ if (close === -1) return text.length;
1628
+ pos = close + 1;
1629
+ } else pos++;
1630
+ }
1631
+ return text.length;
1632
+ }
1633
+ function startsComment(text, pos) {
1634
+ return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
1635
+ }
1636
+ function startsString(text, pos) {
1637
+ const ch = text[pos];
1638
+ return ch === "\"" || ch === "'" || ch === "`";
1639
+ }
1640
+ /** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
1641
+ function findClosingBrace(text, open) {
1642
+ let depth = 0;
1643
+ let pos = open;
1644
+ while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1645
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1646
+ else {
1647
+ if (text[pos] === "{") depth++;
1648
+ else if (text[pos] === "}" && --depth === 0) return pos;
1649
+ pos++;
1650
+ }
1651
+ return -1;
1652
+ }
1653
+ const STORY_FORMAT = "storyFormat";
1654
+ const IDENTIFIER_CHAR = /[\w$]/;
1655
+ /**
1656
+ * Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
1657
+ * strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
1658
+ * and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
1659
+ * hold regular expression literals), the last `}` of the file ends it.
1660
+ */
1661
+ function locateFormatObject(text) {
1662
+ let start = -1;
1663
+ let firstBrace = -1;
1664
+ let pos = 0;
1665
+ while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1666
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1667
+ else if (text[pos] === "{") {
1668
+ if (firstBrace === -1) firstBrace = pos;
1669
+ pos++;
1670
+ } else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
1671
+ const paren = skipTrivia(text, pos + 11);
1672
+ const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
1673
+ if (text[brace] === "{") start = brace;
1674
+ pos += 11;
1675
+ } else pos++;
1676
+ if (start === -1) start = firstBrace;
1677
+ if (start === -1) return void 0;
1678
+ const close = findClosingBrace(text, start);
1679
+ const end = close === -1 ? text.lastIndexOf("}") : close;
1680
+ return end < start ? void 0 : {
1681
+ start,
1682
+ end: end + 1
1683
+ };
1684
+ }
1602
1685
  /**
1603
1686
  * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1604
1687
  * JavaScript literal subset formats use. Error positions point into `text`.
@@ -1669,15 +1752,14 @@ function toFormatJSON(raw) {
1669
1752
  * `harlowe-3` directory or a direct download.
1670
1753
  */
1671
1754
  function decodeFormatJSON(source) {
1672
- const first = source.indexOf("{");
1673
- const last = source.lastIndexOf("}");
1674
- if (first === -1 || last < first) return {
1755
+ const located = locateFormatObject(source);
1756
+ if (located === void 0) return {
1675
1757
  ok: false,
1676
1758
  reason: "Could not find Twine 2 style story format JSON chunk."
1677
1759
  };
1678
- const parsed = parseFormatObject(source, first, last + 1);
1760
+ const parsed = parseFormatObject(source, located.start, located.end);
1679
1761
  if (parsed.ok) return toFormatJSON(parsed.value);
1680
- const stripped = stripSetupFunction(source.slice(first, last + 1));
1762
+ const stripped = stripSetupFunction(source.slice(located.start, located.end));
1681
1763
  const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1682
1764
  if (retried?.ok) return toFormatJSON(retried.value);
1683
1765
  return {
@@ -1981,16 +2063,17 @@ function readFormatSource(format, diagnostics) {
1981
2063
  /**
1982
2064
  * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1983
2065
  * one, never a partly written file. The data goes to a temporary file in the same folder,
1984
- * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1985
- * replaced, and an existing file keeps its permissions.
2066
+ * which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
2067
+ * finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
1986
2068
  *
1987
2069
  * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1988
2070
  * as it was, and the error is thrown with `path` in its message and its original `code`.
1989
2071
  */
1990
2072
  function writeFileAtomic(path, data) {
1991
- const target = followSymlink(path);
1992
- const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
2073
+ let temp;
1993
2074
  try {
2075
+ const target = resolveWriteTarget(path);
2076
+ temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1994
2077
  writeFileSync(temp, data, {
1995
2078
  encoding: "utf-8",
1996
2079
  flag: "wx"
@@ -1998,17 +2081,25 @@ function writeFileAtomic(path, data) {
1998
2081
  keepPermissions(target, temp);
1999
2082
  renameSync(temp, target);
2000
2083
  } catch (e) {
2001
- rmSync(temp, { force: true });
2084
+ if (temp !== void 0) rmSync(temp, { force: true });
2002
2085
  throw writeError(path, e);
2003
2086
  }
2004
2087
  }
2005
- /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
2006
- function followSymlink(path) {
2007
- try {
2008
- return realpathSync(path);
2009
- } catch {
2010
- return path;
2088
+ /** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
2089
+ const MAX_LINK_DEPTH = 40;
2090
+ /**
2091
+ * The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
2092
+ * followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
2093
+ * of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
2094
+ */
2095
+ function resolveWriteTarget(path) {
2096
+ let current = path;
2097
+ for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
2098
+ const stat = lstatSync(current, { throwIfNoEntry: false });
2099
+ if (stat === void 0 || !stat.isSymbolicLink()) return current;
2100
+ current = resolve(dirname(current), readlinkSync(current));
2011
2101
  }
2102
+ throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
2012
2103
  }
2013
2104
  /**
2014
2105
  * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
@@ -2350,6 +2441,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
2350
2441
  }
2351
2442
  if (lastError) throw lastError;
2352
2443
  }
2444
+ /**
2445
+ * Find the best format among the cached copies of direct format URLs, without network access.
2446
+ * Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
2447
+ * Each URL keeps its own copy; the shared name and version downloads are never consulted.
2448
+ *
2449
+ * Internal, for format resolution; not part of the public API.
2450
+ */
2451
+ function findCachedUrlFormat(request, urls, options = {}) {
2452
+ return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
2453
+ }
2353
2454
  /** Whether `info` is a format the request accepts. */
2354
2455
  function answers(request, info) {
2355
2456
  return selectFormatCandidate(request, [info], (f) => f) !== void 0;
@@ -2602,8 +2703,8 @@ function findLocalFormat(all, pruned, request) {
2602
2703
  * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2603
2704
  * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2604
2705
  * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2605
- * names a version that none of them has, an older version of
2606
- * the same format and major version is used with a warning. Anything else is an error diagnostic,
2706
+ * names a version that none of them has, an older version of the same format and major version
2707
+ * is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
2607
2708
  * and the result is `undefined`.
2608
2709
  */
2609
2710
  async function resolveStoryFormat(request, options, diagnostics) {
@@ -2641,7 +2742,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
2641
2742
  if (remote) return remote;
2642
2743
  }
2643
2744
  if (request.kind === "name") {
2644
- const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2745
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2645
2746
  if (older) {
2646
2747
  diagnostics.push({
2647
2748
  level: "warning",
@@ -6524,8 +6625,16 @@ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6524
6625
  * a full HTML tokenizer; it needs no more than the tags and where they end.
6525
6626
  */
6526
6627
  function scanHeadTags(html) {
6628
+ const { closingHead, bodyStart } = scanTags$1(html);
6629
+ return {
6630
+ closingHead,
6631
+ bodyStart
6632
+ };
6633
+ }
6634
+ function scanTags$1(html) {
6527
6635
  let closingHead;
6528
6636
  let bodyStart;
6637
+ let headStartEnd;
6529
6638
  let i = 0;
6530
6639
  while (i < html.length) {
6531
6640
  const lt = html.indexOf("<", i);
@@ -6544,13 +6653,15 @@ function scanHeadTags(html) {
6544
6653
  } else if (isLetter(next)) {
6545
6654
  const { name, end } = readTag(html, lt + 1);
6546
6655
  if (name === "body" && bodyStart === void 0) bodyStart = lt;
6656
+ if (name === "head" && headStartEnd === void 0) headStartEnd = end;
6547
6657
  i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6548
6658
  } else i = lt + 1;
6549
6659
  if (closingHead !== void 0 && bodyStart !== void 0) break;
6550
6660
  }
6551
6661
  return {
6552
6662
  closingHead,
6553
- bodyStart
6663
+ bodyStart,
6664
+ headStartEnd
6554
6665
  };
6555
6666
  }
6556
6667
  /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */