shipseal 0.0.6 → 0.0.8

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
@@ -49,7 +49,7 @@ jobs:
49
49
  steps:
50
50
  - uses: actions/checkout@v7
51
51
  with: { fetch-depth: 0 }
52
- - uses: akii09/shipseal@v1
52
+ - uses: akii09/shipseal@v0.0.8
53
53
  env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
54
54
  ```
55
55
 
package/dist/cli.js CHANGED
@@ -322,12 +322,27 @@ function deterministicCopy(facts, maxHighlights = 4) {
322
322
  for (const line of breaking) addHighlight(line, "Breaking: ");
323
323
  for (const line of features) addHighlight(line);
324
324
  for (const line of fixes) addHighlight(line);
325
- return {
325
+ const copy = {
326
326
  headline,
327
327
  subheadline,
328
328
  highlights,
329
329
  cta: ctaFor(facts)
330
330
  };
331
+ const snippet = facts.release?.codeSnippet?.value.code;
332
+ if (snippet !== void 0 && isInstallOnly(snippet)) copy.codeTitle = "Get started";
333
+ return copy;
334
+ }
335
+ /**
336
+ * Is this snippet nothing but install commands?
337
+ *
338
+ * Pairing "npm i thing" with a release headline reads as though the release was about
339
+ * installing, which is how v0.0.6 shipped a bug-fix headline over the README's install block.
340
+ * Install commands are still worth showing, they just need their own title.
341
+ */
342
+ const INSTALL_COMMAND = /^\s*(?:\$\s*)?(?:npm\s+(?:i|install|add)|pnpm\s+(?:i|install|add|dlx)|yarn\s+(?:add|install)|bun\s+(?:a|add|install)|npx|deno\s+add|pip\s+install|cargo\s+add|go\s+get|gem\s+install|brew\s+install)\b/;
343
+ function isInstallOnly(code) {
344
+ const lines = code.split("\n").map((line) => line.replace(/\s+#.*$/, "").trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
345
+ return lines.length > 0 && lines.every((line) => INSTALL_COMMAND.test(line));
331
346
  }
332
347
  function formatCount$1(value) {
333
348
  return value.toLocaleString("en-US");
@@ -588,7 +603,7 @@ const codeCard = {
588
603
  });
589
604
  return {
590
605
  props: {
591
- headline: copy.headline,
606
+ headline: copy.codeTitle ?? copy.headline,
592
607
  headingFamily: brand.fonts.heading.family,
593
608
  monoFamily: brand.fonts.mono.family,
594
609
  headingWeight: brand.fonts.heading.weight,
@@ -1695,6 +1710,20 @@ async function copyForTemplate(templateId, copy, facts, theme) {
1695
1710
  };
1696
1711
  }
1697
1712
 
1713
+ //#endregion
1714
+ //#region src/facts/fact.ts
1715
+ function fact(value, provenance) {
1716
+ const fetchedAt = provenance.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1717
+ return {
1718
+ value,
1719
+ provenance: {
1720
+ source: provenance.source,
1721
+ ref: provenance.ref,
1722
+ fetchedAt
1723
+ }
1724
+ };
1725
+ }
1726
+
1698
1727
  //#endregion
1699
1728
  //#region src/facts/merge.ts
1700
1729
  function mergeFacts(parts) {
@@ -1767,20 +1796,6 @@ function isCompleteRelease(release) {
1767
1796
  return release.version !== void 0 && release.tag !== void 0 && release.date !== void 0;
1768
1797
  }
1769
1798
 
1770
- //#endregion
1771
- //#region src/facts/fact.ts
1772
- function fact(value, provenance) {
1773
- const fetchedAt = provenance.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1774
- return {
1775
- value,
1776
- provenance: {
1777
- source: provenance.source,
1778
- ref: provenance.ref,
1779
- fetchedAt
1780
- }
1781
- };
1782
- }
1783
-
1784
1799
  //#endregion
1785
1800
  //#region src/sources/bench-file.ts
1786
1801
  const metricSchema = z.object({
@@ -1855,6 +1870,117 @@ async function collectBenchFile(cwd, filePath = ".shipseal/bench.json") {
1855
1870
  return { bench };
1856
1871
  }
1857
1872
 
1873
+ //#endregion
1874
+ //#region src/sources/readme.ts
1875
+ async function collectReadme(cwd) {
1876
+ let markdown;
1877
+ try {
1878
+ markdown = await readFile(join(cwd, "README.md"), "utf8");
1879
+ } catch {
1880
+ return {};
1881
+ }
1882
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
1883
+ const out = {};
1884
+ const h1 = extractH1(markdown);
1885
+ const tagline = extractTagline(markdown);
1886
+ const snippet = extractFirstCodeFence(markdown);
1887
+ const project = {};
1888
+ if (h1 !== void 0) project.name = fact(h1, {
1889
+ source: "readme",
1890
+ ref: "README.md H1",
1891
+ fetchedAt
1892
+ });
1893
+ if (tagline !== void 0) project.tagline = fact(tagline, {
1894
+ source: "readme",
1895
+ ref: "README.md first paragraph",
1896
+ fetchedAt
1897
+ });
1898
+ if (project.name !== void 0 || project.tagline !== void 0) out.project = project;
1899
+ if (snippet !== void 0) out.release = { codeSnippet: fact(snippet, {
1900
+ source: "readme",
1901
+ ref: "README.md first fenced code block",
1902
+ fetchedAt
1903
+ }) };
1904
+ return out;
1905
+ }
1906
+ async function collectConfiguredSnippet(cwd, spec) {
1907
+ const parsed = parseSnippetSpec(spec);
1908
+ if (parsed === void 0) return {};
1909
+ let contents;
1910
+ try {
1911
+ contents = await readFile(join(cwd, parsed.path), "utf8");
1912
+ } catch {
1913
+ return {};
1914
+ }
1915
+ const slice = contents.split(/\r?\n/).slice(parsed.start - 1, parsed.end).join("\n");
1916
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
1917
+ return { release: { codeSnippet: fact({
1918
+ code: slice,
1919
+ lang: langFromPath(parsed.path)
1920
+ }, {
1921
+ source: "user-config",
1922
+ ref: spec,
1923
+ fetchedAt
1924
+ }) } };
1925
+ }
1926
+ /**
1927
+ * Strip fenced code and HTML before reading prose out of a README.
1928
+ *
1929
+ * Skipping blocks that merely *start* with a fence is not enough. A `#` comment inside a YAML
1930
+ * example reads as a markdown H1, and a blank line inside a fence makes its body look like a
1931
+ * paragraph. Shipseal's own README hit both: the detected name became
1932
+ * ".github/workflows/shipseal.yml" and the tagline became a chunk of workflow YAML.
1933
+ */
1934
+ function stripNonProse(markdown) {
1935
+ return markdown.replace(/```[\s\S]*?```/g, "").replace(/~~~[\s\S]*?~~~/g, "").replace(/<[^>]+>/g, "");
1936
+ }
1937
+ function extractH1(markdown) {
1938
+ const heading = /^#\s+(.+)$/m.exec(stripNonProse(markdown))?.[1];
1939
+ if (heading === void 0) return;
1940
+ return heading.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
1941
+ }
1942
+ function extractTagline(markdown) {
1943
+ const prose = stripNonProse(markdown);
1944
+ const h1 = /^#\s+.+$/m.exec(prose);
1945
+ const start = h1 === null ? 0 : (h1.index ?? 0) + h1[0].length;
1946
+ for (const block of prose.slice(start).split(/\n\s*\n/)) {
1947
+ const cleaned = stripBadges(block).trim();
1948
+ if (cleaned.length >= 20 && !cleaned.startsWith("#") && !cleaned.startsWith("```")) return cleaned;
1949
+ }
1950
+ }
1951
+ function extractFirstCodeFence(markdown) {
1952
+ const match = /```([a-zA-Z0-9+-]+)\n([\s\S]*?)```/.exec(markdown);
1953
+ if (match === null || match[1] === void 0 || match[2] === void 0) return;
1954
+ return {
1955
+ lang: match[1],
1956
+ code: match[2].replace(/\n$/, "")
1957
+ };
1958
+ }
1959
+ function parseSnippetSpec(spec) {
1960
+ const match = /^(?<path>.+)#L(?<start>\d+)(?:-L(?<end>\d+))?$/.exec(spec);
1961
+ if (match === null || match.groups === void 0) return;
1962
+ const path = match.groups.path;
1963
+ const start = Number.parseInt(match.groups.start ?? "0", 10);
1964
+ const end = Number.parseInt(match.groups.end ?? match.groups.start ?? "0", 10);
1965
+ if (path === void 0 || start < 1 || end < start) return;
1966
+ return {
1967
+ path,
1968
+ start,
1969
+ end
1970
+ };
1971
+ }
1972
+ function langFromPath(path) {
1973
+ const ext = extname(path).replace(".", "");
1974
+ if (ext === "ts") return "ts";
1975
+ if (ext === "tsx") return "tsx";
1976
+ if (ext === "js" || ext === "mjs" || ext === "cjs") return "js";
1977
+ if (ext === "jsx") return "jsx";
1978
+ return ext.length > 0 ? ext : "text";
1979
+ }
1980
+ function stripBadges(text) {
1981
+ return text.replace(/\[!\[[^\]]*]\([^)]+\)]\([^)]+\)/g, "").replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/<img[^>]*>/gi, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
1982
+ }
1983
+
1858
1984
  //#endregion
1859
1985
  //#region src/sources/changelog.ts
1860
1986
  async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
@@ -1902,6 +2028,15 @@ async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
1902
2028
  fixes,
1903
2029
  breaking
1904
2030
  } };
2031
+ const snippet = extractFirstCodeFence(section.body);
2032
+ if (snippet !== void 0) out.release = {
2033
+ ...out.release,
2034
+ codeSnippet: fact(snippet, {
2035
+ source: "changelog",
2036
+ ref: `${changelogPath} ${section.heading} first fenced code block`,
2037
+ fetchedAt
2038
+ })
2039
+ };
1905
2040
  if (section.date !== void 0) out.release = {
1906
2041
  ...out.release,
1907
2042
  date: fact(section.date, {
@@ -2288,6 +2423,7 @@ function lastPageFromLink(link) {
2288
2423
  //#endregion
2289
2424
  //#region src/sources/npm.ts
2290
2425
  const API_ROOT = "https://api.npmjs.org/downloads/point/last-week";
2426
+ const REGISTRY_ROOT = "https://registry.npmjs.org";
2291
2427
  const pointSchema = z.object({
2292
2428
  downloads: z.number().int().nonnegative(),
2293
2429
  start: z.string().optional(),
@@ -2325,6 +2461,31 @@ async function npmGet(url, fetchImpl, cache) {
2325
2461
  cache.set(url, json);
2326
2462
  return json;
2327
2463
  }
2464
+ /**
2465
+ * Is this name actually published?
2466
+ *
2467
+ * The downloads endpoint cannot answer this: it 404s for a package published minutes ago that
2468
+ * has no download data yet, which is exactly the case a first release is in. The registry
2469
+ * document is the authority.
2470
+ *
2471
+ * Only a definitive 404 counts as "no". A network error, a rate limit or anything else returns
2472
+ * undefined, so an unreachable registry never removes a call to action that was probably right.
2473
+ */
2474
+ async function npmPackageExists(name, fetchImpl = fetch) {
2475
+ const trimmed = name.trim();
2476
+ if (trimmed.length === 0) return false;
2477
+ let response;
2478
+ try {
2479
+ response = await fetchImpl(`${REGISTRY_ROOT}/${encodeURIComponent(trimmed)}`, {
2480
+ method: "HEAD",
2481
+ headers: { accept: "application/json" }
2482
+ });
2483
+ } catch {
2484
+ return;
2485
+ }
2486
+ if (response.status === 404) return false;
2487
+ return response.ok ? true : void 0;
2488
+ }
2328
2489
 
2329
2490
  //#endregion
2330
2491
  //#region src/sources/package-json.ts
@@ -2421,117 +2582,6 @@ function normalizeGitUrl$1(url) {
2421
2582
  return url.replace(/^git\+/, "").replace(/\.git$/, "");
2422
2583
  }
2423
2584
 
2424
- //#endregion
2425
- //#region src/sources/readme.ts
2426
- async function collectReadme(cwd) {
2427
- let markdown;
2428
- try {
2429
- markdown = await readFile(join(cwd, "README.md"), "utf8");
2430
- } catch {
2431
- return {};
2432
- }
2433
- const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
2434
- const out = {};
2435
- const h1 = extractH1(markdown);
2436
- const tagline = extractTagline(markdown);
2437
- const snippet = extractFirstCodeFence(markdown);
2438
- const project = {};
2439
- if (h1 !== void 0) project.name = fact(h1, {
2440
- source: "readme",
2441
- ref: "README.md H1",
2442
- fetchedAt
2443
- });
2444
- if (tagline !== void 0) project.tagline = fact(tagline, {
2445
- source: "readme",
2446
- ref: "README.md first paragraph",
2447
- fetchedAt
2448
- });
2449
- if (project.name !== void 0 || project.tagline !== void 0) out.project = project;
2450
- if (snippet !== void 0) out.release = { codeSnippet: fact(snippet, {
2451
- source: "readme",
2452
- ref: "README.md first fenced code block",
2453
- fetchedAt
2454
- }) };
2455
- return out;
2456
- }
2457
- async function collectConfiguredSnippet(cwd, spec) {
2458
- const parsed = parseSnippetSpec(spec);
2459
- if (parsed === void 0) return {};
2460
- let contents;
2461
- try {
2462
- contents = await readFile(join(cwd, parsed.path), "utf8");
2463
- } catch {
2464
- return {};
2465
- }
2466
- const slice = contents.split(/\r?\n/).slice(parsed.start - 1, parsed.end).join("\n");
2467
- const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
2468
- return { release: { codeSnippet: fact({
2469
- code: slice,
2470
- lang: langFromPath(parsed.path)
2471
- }, {
2472
- source: "user-config",
2473
- ref: spec,
2474
- fetchedAt
2475
- }) } };
2476
- }
2477
- /**
2478
- * Strip fenced code and HTML before reading prose out of a README.
2479
- *
2480
- * Skipping blocks that merely *start* with a fence is not enough. A `#` comment inside a YAML
2481
- * example reads as a markdown H1, and a blank line inside a fence makes its body look like a
2482
- * paragraph. Shipseal's own README hit both: the detected name became
2483
- * ".github/workflows/shipseal.yml" and the tagline became a chunk of workflow YAML.
2484
- */
2485
- function stripNonProse(markdown) {
2486
- return markdown.replace(/```[\s\S]*?```/g, "").replace(/~~~[\s\S]*?~~~/g, "").replace(/<[^>]+>/g, "");
2487
- }
2488
- function extractH1(markdown) {
2489
- const heading = /^#\s+(.+)$/m.exec(stripNonProse(markdown))?.[1];
2490
- if (heading === void 0) return;
2491
- return heading.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
2492
- }
2493
- function extractTagline(markdown) {
2494
- const prose = stripNonProse(markdown);
2495
- const h1 = /^#\s+.+$/m.exec(prose);
2496
- const start = h1 === null ? 0 : (h1.index ?? 0) + h1[0].length;
2497
- for (const block of prose.slice(start).split(/\n\s*\n/)) {
2498
- const cleaned = stripBadges(block).trim();
2499
- if (cleaned.length >= 20 && !cleaned.startsWith("#") && !cleaned.startsWith("```")) return cleaned;
2500
- }
2501
- }
2502
- function extractFirstCodeFence(markdown) {
2503
- const match = /```([a-zA-Z0-9+-]+)\n([\s\S]*?)```/.exec(markdown);
2504
- if (match === null || match[1] === void 0 || match[2] === void 0) return;
2505
- return {
2506
- lang: match[1],
2507
- code: match[2].replace(/\n$/, "")
2508
- };
2509
- }
2510
- function parseSnippetSpec(spec) {
2511
- const match = /^(?<path>.+)#L(?<start>\d+)(?:-L(?<end>\d+))?$/.exec(spec);
2512
- if (match === null || match.groups === void 0) return;
2513
- const path = match.groups.path;
2514
- const start = Number.parseInt(match.groups.start ?? "0", 10);
2515
- const end = Number.parseInt(match.groups.end ?? match.groups.start ?? "0", 10);
2516
- if (path === void 0 || start < 1 || end < start) return;
2517
- return {
2518
- path,
2519
- start,
2520
- end
2521
- };
2522
- }
2523
- function langFromPath(path) {
2524
- const ext = extname(path).replace(".", "");
2525
- if (ext === "ts") return "ts";
2526
- if (ext === "tsx") return "tsx";
2527
- if (ext === "js" || ext === "mjs" || ext === "cjs") return "js";
2528
- if (ext === "jsx") return "jsx";
2529
- return ext.length > 0 ? ext : "text";
2530
- }
2531
- function stripBadges(text) {
2532
- return text.replace(/\[!\[[^\]]*]\([^)]+\)]\([^)]+\)/g, "").replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/<img[^>]*>/gi, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
2533
- }
2534
-
2535
2585
  //#endregion
2536
2586
  //#region src/sources/collect.ts
2537
2587
  async function collectFacts(options) {
@@ -2546,13 +2596,15 @@ async function collectFacts(options) {
2546
2596
  const remote = await collectGitRemote(options.cwd);
2547
2597
  const changelog = await collectBestChangelog(options.cwd, version, options.changelogPath ?? "CHANGELOG.md", options.packagePath);
2548
2598
  const readme = await collectReadme(options.cwd);
2599
+ const snippet = options.snippet !== void 0 && options.snippet !== null && options.snippet.length > 0 ? await collectConfiguredSnippet(options.cwd, options.snippet) : {};
2549
2600
  const parts = [
2550
- remote,
2551
2601
  pkg,
2602
+ pkg.project?.npmPackage === void 0 ? await collectWorkspaceNpmPackage(options.cwd, options.skipNetwork === true ? {} : { verify: async (name) => npmPackageExists(name, options.fetchImpl ?? fetch) }) : {},
2603
+ remote,
2552
2604
  git,
2553
- readme,
2605
+ snippet,
2554
2606
  changelog,
2555
- options.snippet !== void 0 && options.snippet !== null && options.snippet.length > 0 ? await collectConfiguredSnippet(options.cwd, options.snippet) : {}
2607
+ readme
2556
2608
  ];
2557
2609
  if (options.event.kind === "bench") parts.push(await collectBenchFile(options.cwd, options.benchFile ?? ".shipseal/bench.json"));
2558
2610
  if (options.skipNetwork !== true) {
@@ -2593,6 +2645,31 @@ async function collectBestChangelog(cwd, version, changelogPath, packagePath) {
2593
2645
  const candidates = await workspaceChangelogs(cwd);
2594
2646
  return (await Promise.all(candidates.map((candidate) => collectChangelog(cwd, version, candidate)))).find((facts) => changelogHasNotes(facts)) ?? {};
2595
2647
  }
2648
+ /**
2649
+ * Find the one publishable package in a workspace.
2650
+ *
2651
+ * Only when there is exactly one candidate: two publishable packages make the CTA a guess, and
2652
+ * guessing which one to tell people to install is worse than falling back to the repository
2653
+ * URL. `--package` resolves the ambiguity explicitly.
2654
+ */
2655
+ async function collectWorkspaceNpmPackage(cwd, options = {}) {
2656
+ const paths = await workspaceManifests(cwd);
2657
+ const found = (await Promise.all(paths.map((path) => collectPackageJson(cwd, path)))).map((facts, index) => ({
2658
+ name: facts.project?.npmPackage?.value,
2659
+ path: paths[index] ?? ""
2660
+ })).filter((entry) => entry.name !== void 0);
2661
+ const only = found.length === 1 ? found[0] : void 0;
2662
+ if (only === void 0) return {};
2663
+ if (options.verify !== void 0 && await options.verify(only.name) === false) return {};
2664
+ return { project: { npmPackage: fact(only.name, {
2665
+ source: "package-json",
2666
+ ref: `${only.path}#name`,
2667
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2668
+ }) } };
2669
+ }
2670
+ async function workspaceManifests(cwd) {
2671
+ return (await workspaceChangelogs(cwd)).map((path) => path.replace(/CHANGELOG\.md$/, "package.json"));
2672
+ }
2596
2673
  async function workspaceChangelogs(cwd) {
2597
2674
  return (await Promise.all(["packages", "apps"].map(async (root) => {
2598
2675
  let entries;