shipseal 0.0.2 → 0.0.4

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/cli.js CHANGED
@@ -307,10 +307,10 @@ function deterministicCopy(facts, maxHighlights = 4) {
307
307
  const features = (facts.release?.features ?? []).map((item) => item.value);
308
308
  const fixes = (facts.release?.fixes ?? []).map((item) => item.value);
309
309
  const breaking = (facts.release?.breaking ?? []).map((item) => item.value);
310
- const headlineSource = features[0];
311
- const headline = headlineSource !== void 0 ? cleanLine(headlineSource) : version === void 0 ? name : `${name} ${version}`;
310
+ const headlineSource = breaking[0] ?? features[0] ?? fixes[0];
311
+ const headline = headlineSource !== void 0 ? cleanLine(firstSentence(headlineSource)) : version === void 0 ? name : `${name} ${version}`;
312
312
  const tagline = facts.project.tagline?.value;
313
- const subheadline = tagline !== void 0 && tagline.length > 0 ? cleanLine(tagline) : cleanLine(features[1] ?? fixes[0] ?? `What's new in ${version ?? name}`);
313
+ const subheadline = tagline !== void 0 && tagline.length > 0 ? cleanLine(tagline) : cleanLine(features[1] ?? fixes[1] ?? fixes[0] ?? `What's new in ${version ?? name}`);
314
314
  const highlights = [];
315
315
  for (const line of features) {
316
316
  if (highlights.length >= maxHighlights) break;
@@ -356,8 +356,20 @@ function benchCopy(facts) {
356
356
  cta: ctaFor(facts)
357
357
  };
358
358
  }
359
+ /**
360
+ * First sentence of an entry, for use as a headline.
361
+ *
362
+ * Changesets entries are prose paragraphs written for a changelog, so using one whole made a
363
+ * headline that could only be truncated mid-word. The first sentence is the part a person
364
+ * actually wrote as the summary. Abbreviations are not special-cased: a sentence ending is a
365
+ * period followed by a space and a capital, which leaves "e.g. foo" and version numbers alone.
366
+ */
367
+ function firstSentence(text) {
368
+ const collapsed = text.replace(/\s+/g, " ").trim();
369
+ return /^(.+?[.?!])\s+[A-Z]/.exec(collapsed)?.[1] ?? collapsed;
370
+ }
359
371
  function cleanLine(text) {
360
- const stripped = text.replaceAll("", ":").replaceAll("–", "-").replaceAll("!", ".").trim();
372
+ const stripped = text.replace(/\s*\u2014\s*/g, ": ").replace(/\s+\u2013\s+/g, ", ").replaceAll("–", "-").replaceAll("!", ".").replace(/\s+/g, " ").trim();
361
373
  const noTrail = stripped.endsWith(".") ? stripped.slice(0, -1) : stripped;
362
374
  const first = noTrail.at(0);
363
375
  if (first === void 0) return noTrail;
@@ -2111,6 +2123,36 @@ function displaySubject(text) {
2111
2123
  if (first === void 0) return cleaned;
2112
2124
  return first.toUpperCase() + cleaned.slice(1);
2113
2125
  }
2126
+ /**
2127
+ * Repository slug and URL from `git remote get-url origin`.
2128
+ *
2129
+ * Kept separate from `collectGit`, which returns nothing without a tag. A monorepo root
2130
+ * often has no `homepage` or `repository` in its private package.json, which left the call
2131
+ * to action falling back to the workspace name. The remote is always there.
2132
+ */
2133
+ async function collectGitRemote(cwd) {
2134
+ const remote = await git(cwd, [
2135
+ "remote",
2136
+ "get-url",
2137
+ "origin"
2138
+ ]);
2139
+ if (remote === void 0) return {};
2140
+ const slug = githubSlug(remote.trim());
2141
+ if (slug === void 0) return {};
2142
+ const provenance = {
2143
+ source: "git",
2144
+ ref: "git remote get-url origin",
2145
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2146
+ };
2147
+ return { project: {
2148
+ repo: fact(slug, provenance),
2149
+ url: fact(`https://github.com/${slug}`, provenance)
2150
+ } };
2151
+ }
2152
+ function githubSlug(url) {
2153
+ const cleaned = url.replace(/^git\+/, "").replace(/\.git$/, "");
2154
+ return /github\.com[/:]([^/]+\/[^/]+)$/.exec(cleaned)?.[1];
2155
+ }
2114
2156
 
2115
2157
  //#endregion
2116
2158
  //#region src/sources/github.ts
@@ -2281,6 +2323,7 @@ async function npmGet(url, fetchImpl, cache) {
2281
2323
  //#endregion
2282
2324
  //#region src/sources/package-json.ts
2283
2325
  const pkgSchema = z.object({
2326
+ private: z.boolean().optional(),
2284
2327
  name: z.string().optional(),
2285
2328
  description: z.string().optional(),
2286
2329
  version: z.string().optional(),
@@ -2327,7 +2370,7 @@ async function collectPackageJson(cwd, packagePath = "package.json") {
2327
2370
  ref: `${packagePath}#repository`,
2328
2371
  fetchedAt
2329
2372
  });
2330
- if (pkg.data.name !== void 0) project.npmPackage = fact(pkg.data.name, {
2373
+ if (pkg.data.name !== void 0 && pkg.data.private !== true) project.npmPackage = fact(pkg.data.name, {
2331
2374
  source: "package-json",
2332
2375
  ref: `${packagePath}#name`,
2333
2376
  fetchedAt
@@ -2493,9 +2536,11 @@ async function collectFacts(options) {
2493
2536
  if (previousTag !== void 0) gitEvent.previousTag = previousTag;
2494
2537
  const git = await collectGit(options.cwd, gitEvent);
2495
2538
  const version = git.release?.version?.value ?? pkg.release?.version?.value ?? (tag === void 0 ? void 0 : versionFromTag(tag));
2539
+ const remote = await collectGitRemote(options.cwd);
2496
2540
  const changelog = await collectBestChangelog(options.cwd, version, options.changelogPath ?? "CHANGELOG.md", options.packagePath);
2497
2541
  const readme = await collectReadme(options.cwd);
2498
2542
  const parts = [
2543
+ remote,
2499
2544
  pkg,
2500
2545
  git,
2501
2546
  readme,
@@ -2536,7 +2581,21 @@ async function collectBestChangelog(cwd, version, changelogPath, packagePath) {
2536
2581
  const sibling = join(dirname(packagePath), "CHANGELOG.md");
2537
2582
  if (!paths.includes(sibling)) paths.push(sibling);
2538
2583
  }
2539
- return (await Promise.all(paths.map((path) => collectChangelog(cwd, version, path)))).find((facts) => changelogHasNotes(facts)) ?? {};
2584
+ const found = (await Promise.all(paths.map((path) => collectChangelog(cwd, version, path)))).find((facts) => changelogHasNotes(facts));
2585
+ if (found !== void 0) return found;
2586
+ const candidates = await workspaceChangelogs(cwd);
2587
+ return (await Promise.all(candidates.map((candidate) => collectChangelog(cwd, version, candidate)))).find((facts) => changelogHasNotes(facts)) ?? {};
2588
+ }
2589
+ async function workspaceChangelogs(cwd) {
2590
+ return (await Promise.all(["packages", "apps"].map(async (root) => {
2591
+ let entries;
2592
+ try {
2593
+ entries = await readdir(join(cwd, root), { withFileTypes: true });
2594
+ } catch {
2595
+ return [];
2596
+ }
2597
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name, "CHANGELOG.md"));
2598
+ }))).flat().toSorted();
2540
2599
  }
2541
2600
  function changelogHasNotes(facts) {
2542
2601
  const release = facts.release;
@@ -3651,16 +3710,63 @@ function parseNumber(token) {
3651
3710
 
3652
3711
  //#endregion
3653
3712
  //#region src/brand/css-vars.ts
3654
- const PROP = /--(primary|brand|accent|background|foreground|muted-foreground|muted)\s*:\s*([^;]+);/g;
3713
+ const PROP = /--(primary|brand|accent|background|foreground|muted-foreground|muted)\s*:\s*([^;}]+)[;}]?/g;
3714
+ /** Any declaration, used to resolve `var(--brand-500)` style indirection. */
3715
+ const ANY_PROP = /(--[\w-]+)\s*:\s*([^;}]+)[;}]?/g;
3716
+ /**
3717
+ * Selectors that carry theme tokens in real projects. `:root` alone missed shadcn's
3718
+ * `[data-theme]` blocks, plain `html {}`, and `.light`.
3719
+ */
3720
+ const SELECTORS = [
3721
+ /:root\s*\{/g,
3722
+ /(?:^|[\s,}])html\s*\{/g,
3723
+ /\.light\b[^{]*\{/g,
3724
+ /\[data-theme=["']?light["']?\][^{]*\{/g,
3725
+ /\.dark\b[^{]*\{/g,
3726
+ /\[data-theme=["']?dark["']?\][^{]*\{/g
3727
+ ];
3655
3728
  function extractCssRootColors(css) {
3656
- const fromRoot = extractBlockColors(css, /:root\s*\{/g);
3657
- const fromDark = extractBlockColors(css, /\.dark\s*\{/g);
3658
- return mapFoundColors({
3659
- ...fromRoot,
3660
- ...fromDark
3661
- });
3729
+ const vars = allVariables(css);
3730
+ const found = {};
3731
+ for (const selector of SELECTORS) Object.assign(found, extractBlockColors(css, selector, vars));
3732
+ return mapFoundColors(found);
3733
+ }
3734
+ function allVariables(css) {
3735
+ const vars = {};
3736
+ ANY_PROP.lastIndex = 0;
3737
+ let match;
3738
+ while ((match = ANY_PROP.exec(css)) !== null) {
3739
+ const name = match[1];
3740
+ const value = match[2];
3741
+ if (name !== void 0 && value !== void 0) vars[name] = value.trim();
3742
+ }
3743
+ return vars;
3744
+ }
3745
+ /**
3746
+ * Resolve a declaration to a hex colour.
3747
+ *
3748
+ * Handles two things a plain colour parser does not. First, `var(--brand-500)` indirection,
3749
+ * up to three hops within the same file. Second, bare channel lists: shadcn writes
3750
+ * `--background: 0 0% 100%` and applies it as `hsl(var(--background))`, so the value is only
3751
+ * a colour once wrapped. Percent signs on the last two channels mean HSL, three plain
3752
+ * numbers mean RGB.
3753
+ */
3754
+ function resolveColor(raw, vars, depth = 0) {
3755
+ const value = raw.trim();
3756
+ if (depth < 3) {
3757
+ const ref = /^var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)$/.exec(value);
3758
+ const target = ref?.[1] === void 0 ? void 0 : vars[ref[1]];
3759
+ if (target !== void 0) return resolveColor(target, vars, depth + 1);
3760
+ }
3761
+ const direct = parseCssColor(value);
3762
+ if (direct !== void 0) return direct;
3763
+ const parts = value.split(/[\s/]+/).filter((p) => p.length > 0);
3764
+ if (parts.length === 3) {
3765
+ if (parts[1]?.endsWith("%") === true && parts[2]?.endsWith("%") === true) return parseCssColor(`hsl(${parts[0]} ${parts[1]} ${parts[2]})`);
3766
+ if (parts.every((p) => /^\d+(\.\d+)?$/.test(p))) return parseCssColor(`rgb(${parts[0]} ${parts[1]} ${parts[2]})`);
3767
+ }
3662
3768
  }
3663
- function extractBlockColors(css, blockRe) {
3769
+ function extractBlockColors(css, blockRe, vars) {
3664
3770
  const found = {};
3665
3771
  blockRe.lastIndex = 0;
3666
3772
  let match;
@@ -3676,7 +3782,7 @@ function extractBlockColors(css, blockRe) {
3676
3782
  const name = prop[1];
3677
3783
  const raw = prop[2];
3678
3784
  if (name === void 0 || raw === void 0) continue;
3679
- const hex = parseCssColor(raw.trim());
3785
+ const hex = resolveColor(raw, vars);
3680
3786
  if (hex !== void 0) found[name] = hex;
3681
3787
  }
3682
3788
  }
@@ -4481,12 +4587,39 @@ async function runInit(options) {
4481
4587
  const sampleDir = join(brandDir, "output", "sample");
4482
4588
  await mkdir(sampleDir, { recursive: true });
4483
4589
  const samplePath = join(sampleDir, "release-hero-og.png");
4484
- const png = await (await createTakumiRenderer()).render(testCardNode(), {
4485
- width: FORMATS.og.width,
4486
- height: FORMATS.og.height,
4487
- format: "png"
4488
- });
4489
- await writeFile(samplePath, png);
4590
+ const renderer = await createTakumiRenderer();
4591
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
4592
+ const provenance = {
4593
+ source: "brand",
4594
+ ref: ".shipseal/brand.json",
4595
+ fetchedAt
4596
+ };
4597
+ const sampleFacts = { project: { name: fact(brand.name, provenance) } };
4598
+ if (brand.tagline !== void 0) sampleFacts.project.tagline = fact(brand.tagline, provenance);
4599
+ if (brand.url !== void 0) sampleFacts.project.url = fact(brand.url, provenance);
4600
+ const first = (await generate({
4601
+ event: {
4602
+ kind: "release",
4603
+ tag: "v1.0.0"
4604
+ },
4605
+ facts: sampleFacts,
4606
+ brand,
4607
+ config: {
4608
+ ...config,
4609
+ formats: ["og"],
4610
+ release: {
4611
+ ...config.release,
4612
+ templates: ["release-hero"]
4613
+ }
4614
+ },
4615
+ copy: deterministicCopy(sampleFacts),
4616
+ copyMode: "deterministic",
4617
+ renderer,
4618
+ themes: [brand.theme],
4619
+ generatedAt: fetchedAt
4620
+ })).files[0];
4621
+ if (first === void 0) throw new ShipsealError("init.sample-failed", "Could not render the sample card.", "Run shipseal doctor to check fonts and the renderer.");
4622
+ await writeFile(samplePath, first.bytes);
4490
4623
  return {
4491
4624
  brandPath,
4492
4625
  configPath,