@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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
3
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import * as nodePath from "node:path";
5
5
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -1555,6 +1555,89 @@ function stripSetupFunction(chunk) {
1555
1555
  for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
1556
1556
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
1557
1557
  }
1558
+ /** Index after the whitespace and `//` or block comments starting at `i`. */
1559
+ function skipTrivia(text, i) {
1560
+ let pos = i;
1561
+ for (;;) {
1562
+ const ch = text[pos];
1563
+ if (ch !== void 0 && /\s/.test(ch)) pos++;
1564
+ else if (text.startsWith("//", pos)) {
1565
+ const newline = text.indexOf("\n", pos);
1566
+ pos = newline === -1 ? text.length : newline + 1;
1567
+ } else if (text.startsWith("/*", pos)) {
1568
+ const close = text.indexOf("*/", pos + 2);
1569
+ pos = close === -1 ? text.length : close + 2;
1570
+ } else return pos;
1571
+ }
1572
+ }
1573
+ /** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
1574
+ function skipString(text, i) {
1575
+ const quote = text[i];
1576
+ let pos = i + 1;
1577
+ while (pos < text.length) {
1578
+ const ch = text[pos];
1579
+ if (ch === "\\") pos += 2;
1580
+ else if (ch === quote) return pos + 1;
1581
+ else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
1582
+ const close = findClosingBrace(text, pos + 1);
1583
+ if (close === -1) return text.length;
1584
+ pos = close + 1;
1585
+ } else pos++;
1586
+ }
1587
+ return text.length;
1588
+ }
1589
+ function startsComment(text, pos) {
1590
+ return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
1591
+ }
1592
+ function startsString(text, pos) {
1593
+ const ch = text[pos];
1594
+ return ch === "\"" || ch === "'" || ch === "`";
1595
+ }
1596
+ /** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
1597
+ function findClosingBrace(text, open) {
1598
+ let depth = 0;
1599
+ let pos = open;
1600
+ while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1601
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1602
+ else {
1603
+ if (text[pos] === "{") depth++;
1604
+ else if (text[pos] === "}" && --depth === 0) return pos;
1605
+ pos++;
1606
+ }
1607
+ return -1;
1608
+ }
1609
+ const STORY_FORMAT = "storyFormat";
1610
+ const IDENTIFIER_CHAR = /[\w$]/;
1611
+ /**
1612
+ * Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
1613
+ * strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
1614
+ * and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
1615
+ * hold regular expression literals), the last `}` of the file ends it.
1616
+ */
1617
+ function locateFormatObject(text) {
1618
+ let start = -1;
1619
+ let firstBrace = -1;
1620
+ let pos = 0;
1621
+ while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1622
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1623
+ else if (text[pos] === "{") {
1624
+ if (firstBrace === -1) firstBrace = pos;
1625
+ pos++;
1626
+ } else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
1627
+ const paren = skipTrivia(text, pos + 11);
1628
+ const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
1629
+ if (text[brace] === "{") start = brace;
1630
+ pos += 11;
1631
+ } else pos++;
1632
+ if (start === -1) start = firstBrace;
1633
+ if (start === -1) return void 0;
1634
+ const close = findClosingBrace(text, start);
1635
+ const end = close === -1 ? text.lastIndexOf("}") : close;
1636
+ return end < start ? void 0 : {
1637
+ start,
1638
+ end: end + 1
1639
+ };
1640
+ }
1558
1641
  /**
1559
1642
  * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1560
1643
  * JavaScript literal subset formats use. Error positions point into `text`.
@@ -1625,15 +1708,14 @@ function toFormatJSON(raw) {
1625
1708
  * `harlowe-3` directory or a direct download.
1626
1709
  */
1627
1710
  function decodeFormatJSON(source) {
1628
- const first = source.indexOf("{");
1629
- const last = source.lastIndexOf("}");
1630
- if (first === -1 || last < first) return {
1711
+ const located = locateFormatObject(source);
1712
+ if (located === void 0) return {
1631
1713
  ok: false,
1632
1714
  reason: "Could not find Twine 2 style story format JSON chunk."
1633
1715
  };
1634
- const parsed = parseFormatObject(source, first, last + 1);
1716
+ const parsed = parseFormatObject(source, located.start, located.end);
1635
1717
  if (parsed.ok) return toFormatJSON(parsed.value);
1636
- const stripped = stripSetupFunction(source.slice(first, last + 1));
1718
+ const stripped = stripSetupFunction(source.slice(located.start, located.end));
1637
1719
  const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1638
1720
  if (retried?.ok) return toFormatJSON(retried.value);
1639
1721
  return {
@@ -1920,16 +2002,17 @@ function readFormatSource(format, diagnostics) {
1920
2002
  /**
1921
2003
  * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1922
2004
  * one, never a partly written file. The data goes to a temporary file in the same folder,
1923
- * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1924
- * replaced, and an existing file keeps its permissions.
2005
+ * which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
2006
+ * finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
1925
2007
  *
1926
2008
  * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1927
2009
  * as it was, and the error is thrown with `path` in its message and its original `code`.
1928
2010
  */
1929
2011
  function writeFileAtomic(path, data) {
1930
- const target = followSymlink(path);
1931
- const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
2012
+ let temp;
1932
2013
  try {
2014
+ const target = resolveWriteTarget(path);
2015
+ temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1933
2016
  writeFileSync(temp, data, {
1934
2017
  encoding: "utf-8",
1935
2018
  flag: "wx"
@@ -1937,17 +2020,25 @@ function writeFileAtomic(path, data) {
1937
2020
  keepPermissions(target, temp);
1938
2021
  renameSync(temp, target);
1939
2022
  } catch (e) {
1940
- rmSync(temp, { force: true });
2023
+ if (temp !== void 0) rmSync(temp, { force: true });
1941
2024
  throw writeError(path, e);
1942
2025
  }
1943
2026
  }
1944
- /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
1945
- function followSymlink(path) {
1946
- try {
1947
- return realpathSync(path);
1948
- } catch {
1949
- return path;
2027
+ /** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
2028
+ const MAX_LINK_DEPTH = 40;
2029
+ /**
2030
+ * The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
2031
+ * followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
2032
+ * of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
2033
+ */
2034
+ function resolveWriteTarget(path) {
2035
+ let current = path;
2036
+ for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
2037
+ const stat = lstatSync(current, { throwIfNoEntry: false });
2038
+ if (stat === void 0 || !stat.isSymbolicLink()) return current;
2039
+ current = resolve(dirname(current), readlinkSync(current));
1950
2040
  }
2041
+ throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
1951
2042
  }
1952
2043
  /**
1953
2044
  * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
@@ -2289,6 +2380,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
2289
2380
  }
2290
2381
  if (lastError) throw lastError;
2291
2382
  }
2383
+ /**
2384
+ * Find the best format among the cached copies of direct format URLs, without network access.
2385
+ * Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
2386
+ * Each URL keeps its own copy; the shared name and version downloads are never consulted.
2387
+ *
2388
+ * Internal, for format resolution; not part of the public API.
2389
+ */
2390
+ function findCachedUrlFormat(request, urls, options = {}) {
2391
+ return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
2392
+ }
2292
2393
  /** Whether `info` is a format the request accepts. */
2293
2394
  function answers(request, info) {
2294
2395
  return selectFormatCandidate(request, [info], (f) => f) !== void 0;
@@ -2524,8 +2625,8 @@ function findLocalFormat(all, pruned, request) {
2524
2625
  * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2525
2626
  * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2526
2627
  * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2527
- * names a version that none of them has, an older version of
2528
- * the same format and major version is used with a warning. Anything else is an error diagnostic,
2628
+ * names a version that none of them has, an older version of the same format and major version
2629
+ * is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
2529
2630
  * and the result is `undefined`.
2530
2631
  */
2531
2632
  async function resolveStoryFormat(request, options, diagnostics) {
@@ -2563,7 +2664,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
2563
2664
  if (remote) return remote;
2564
2665
  }
2565
2666
  if (request.kind === "name") {
2566
- const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2667
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2567
2668
  if (older) {
2568
2669
  diagnostics.push({
2569
2670
  level: "warning",
@@ -6411,8 +6512,16 @@ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6411
6512
  * a full HTML tokenizer; it needs no more than the tags and where they end.
6412
6513
  */
6413
6514
  function scanHeadTags(html) {
6515
+ const { closingHead, bodyStart } = scanTags$1(html);
6516
+ return {
6517
+ closingHead,
6518
+ bodyStart
6519
+ };
6520
+ }
6521
+ function scanTags$1(html) {
6414
6522
  let closingHead;
6415
6523
  let bodyStart;
6524
+ let headStartEnd;
6416
6525
  let i = 0;
6417
6526
  while (i < html.length) {
6418
6527
  const lt = html.indexOf("<", i);
@@ -6431,13 +6540,15 @@ function scanHeadTags(html) {
6431
6540
  } else if (isLetter(next)) {
6432
6541
  const { name, end } = readTag(html, lt + 1);
6433
6542
  if (name === "body" && bodyStart === void 0) bodyStart = lt;
6543
+ if (name === "head" && headStartEnd === void 0) headStartEnd = end;
6434
6544
  i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6435
6545
  } else i = lt + 1;
6436
6546
  if (closingHead !== void 0 && bodyStart !== void 0) break;
6437
6547
  }
6438
6548
  return {
6439
6549
  closingHead,
6440
- bodyStart
6550
+ bodyStart,
6551
+ headStartEnd
6441
6552
  };
6442
6553
  }
6443
6554
  /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */