shipseal 0.1.0 → 0.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/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { dirname, extname, isAbsolute, join, resolve } from "node:path";
4
4
  import { cac } from "cac";
5
5
  import { appendFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
6
6
  import { z } from "zod";
@@ -241,6 +241,8 @@ const DEFAULT_CONFIG = {
241
241
  },
242
242
  milestones: {
243
243
  stars: [
244
+ 10,
245
+ 50,
244
246
  100,
245
247
  250,
246
248
  500,
@@ -409,8 +411,22 @@ function firstSentence(text) {
409
411
  * sentences, and the fitting pass comfortably handles more.
410
412
  */
411
413
  const HEADLINE_MAX_CHARS = 72;
414
+ /**
415
+ * Emoji ranges, removed from anything that reaches a card.
416
+ *
417
+ * No font we register carries these glyphs, so `\u{1F310} Human-friendly ...` (got's real
418
+ * GitHub description) rendered a tofu box on shipseal.dev. Takumi ships an emoji helper, but it
419
+ * rewrites each emoji into an <img> pointing at jsDelivr, and `generate()` does no network.
420
+ *
421
+ * The ranges deliberately start at U+2600, which keeps the textual symbols people put in real
422
+ * product names: (c) U+00A9, (r) U+00AE and (tm) U+2122 all sit below it.
423
+ */
424
+ const EMOJI = /[\u{1F000}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|\u200D/gu;
425
+ function stripEmoji(text) {
426
+ return text.replaceAll(EMOJI, "").replace(/\s+/g, " ").trim();
427
+ }
412
428
  function cleanLine(text) {
413
- const stripped = text.replace(/\s*\u2014\s*/g, ": ").replace(/\s+\u2013\s+/g, ", ").replaceAll("–", "-").replaceAll("!", ".").replace(/\s+/g, " ").trim();
429
+ const stripped = stripEmoji(text).replace(/\s*\u2014\s*/g, ": ").replace(/\s+\u2013\s+/g, ", ").replaceAll("–", "-").replaceAll("!", ".").replace(/\s+/g, " ").trim();
414
430
  const noTrail = stripped.endsWith(".") ? stripped.slice(0, -1) : stripped;
415
431
  const first = noTrail.at(0);
416
432
  if (first === void 0) return noTrail;
@@ -1818,9 +1834,9 @@ const storyPage = {
1818
1834
  box: { width: 220 }
1819
1835
  },
1820
1836
  title: {
1821
- maxLines: tall ? 3 : 2,
1837
+ maxLines: tall ? 4 : 2,
1822
1838
  maxFontSize: tall ? 64 : 48,
1823
- minFontSize: 28,
1839
+ minFontSize: tall ? 36 : 30,
1824
1840
  step: 2,
1825
1841
  box: {
1826
1842
  width: width - 32,
@@ -1877,7 +1893,7 @@ const storyPage = {
1877
1893
  render(raw, ctx) {
1878
1894
  const props = propsSchema.parse(raw);
1879
1895
  const colors = themeColors(ctx.brand, ctx.theme);
1880
- const { tall, titleHeight, bodyHeight, width } = geometry(ctx.format);
1896
+ const { tall, bodyHeight, width } = geometry(ctx.format);
1881
1897
  const editorial = ctx.brand.style === "editorial";
1882
1898
  const terminal = ctx.brand.style === "terminal";
1883
1899
  const code = isCommand(props.page);
@@ -1911,6 +1927,7 @@ const storyPage = {
1911
1927
  justifyContent: "space-between",
1912
1928
  alignItems: "center",
1913
1929
  height: HEADER_HEIGHT,
1930
+ flexShrink: 0,
1914
1931
  borderBottom: terminal ? `1px solid ${colors.muted}` : NO_BORDER,
1915
1932
  paddingBottom: terminal ? 12 : 0
1916
1933
  },
@@ -1943,83 +1960,91 @@ const storyPage = {
1943
1960
  children: ctx.fitted.version?.text ?? props.version
1944
1961
  })]
1945
1962
  }),
1946
- /* @__PURE__ */ jsx("div", {
1963
+ /* @__PURE__ */ jsxs("div", {
1947
1964
  style: {
1948
1965
  display: "flex",
1949
- alignItems: "center",
1950
- height: titleHeight,
1951
- flexShrink: 0,
1952
- paddingLeft: editorial ? 24 : 0,
1953
- borderLeft: editorial ? `8px solid ${colors.primary}` : NO_BORDER
1966
+ flexDirection: "column",
1967
+ flexGrow: 1,
1968
+ justifyContent: "center",
1969
+ gap: 24,
1970
+ minHeight: 0
1954
1971
  },
1955
- children: /* @__PURE__ */ jsx("div", {
1972
+ children: [/* @__PURE__ */ jsx("div", {
1956
1973
  style: {
1957
- fontFamily: props.heading,
1958
- fontWeight: props.weight,
1959
- fontSize: title?.fontSize ?? 48,
1960
- lineHeight: 1.2,
1961
- maxWidth: width - (editorial ? 32 : 0)
1974
+ display: "flex",
1975
+ alignItems: "center",
1976
+ flexShrink: 0,
1977
+ paddingLeft: editorial ? 24 : 0,
1978
+ borderLeft: editorial ? `8px solid ${colors.primary}` : NO_BORDER
1962
1979
  },
1963
- children: title?.text ?? props.page.title.value
1964
- })
1965
- }),
1966
- comparison ? /* @__PURE__ */ jsx("div", {
1967
- style: {
1968
- display: "flex",
1969
- flexDirection: tall ? "column" : "row",
1970
- gap: 16,
1971
- height: bodyHeight
1972
- },
1973
- children: ["before", "after"].map((label) => /* @__PURE__ */ jsxs("div", {
1980
+ children: /* @__PURE__ */ jsx("div", {
1981
+ style: {
1982
+ fontFamily: props.heading,
1983
+ fontWeight: props.weight,
1984
+ fontSize: title?.fontSize ?? 48,
1985
+ lineHeight: 1.2,
1986
+ maxWidth: width - (editorial ? 32 : 0)
1987
+ },
1988
+ children: title?.text ?? props.page.title.value
1989
+ })
1990
+ }), comparison ? /* @__PURE__ */ jsx("div", {
1974
1991
  style: {
1975
1992
  display: "flex",
1976
- flexDirection: "column",
1977
- gap: 8,
1978
- width: paneWidth,
1979
- height: paneHeight
1993
+ flexDirection: tall ? "column" : "row",
1994
+ gap: 16,
1995
+ height: bodyHeight
1980
1996
  },
1981
- children: [/* @__PURE__ */ jsx("div", {
1997
+ children: ["before", "after"].map((label) => /* @__PURE__ */ jsxs("div", {
1982
1998
  style: {
1983
- fontFamily: props.body,
1984
- fontSize: 20,
1985
- color: colors.muted
1999
+ display: "flex",
2000
+ flexDirection: "column",
2001
+ gap: 8,
2002
+ width: paneWidth,
2003
+ height: paneHeight
1986
2004
  },
1987
- children: label === "before" ? "Before" : "After"
1988
- }), /* @__PURE__ */ jsx("img", {
1989
- src: `story-${label}`,
1990
- width: paneWidth,
1991
- height: paneHeight - HEADER_HEIGHT,
1992
- style: { objectFit: "contain" }
1993
- })]
1994
- }, label))
1995
- }) : hasBody ? /* @__PURE__ */ jsx("div", {
1996
- style: {
1997
- display: "flex",
1998
- flexDirection: "column",
1999
- justifyContent: "flex-start",
2000
- height: bodyHeight,
2001
- padding: BODY_PAD,
2002
- backgroundColor: terminal || code ? colors.card : "transparent",
2003
- borderRadius: terminal ? 0 : ctx.brand.radius,
2004
- borderLeft: terminal ? `3px solid ${colors.primary}` : NO_BORDER
2005
- },
2006
- children: /* @__PURE__ */ jsx("div", {
2005
+ children: [/* @__PURE__ */ jsx("div", {
2006
+ style: {
2007
+ fontFamily: props.body,
2008
+ fontSize: 20,
2009
+ color: colors.muted
2010
+ },
2011
+ children: label === "before" ? "Before" : "After"
2012
+ }), /* @__PURE__ */ jsx("img", {
2013
+ src: `story-${label}`,
2014
+ width: paneWidth,
2015
+ height: paneHeight - HEADER_HEIGHT,
2016
+ style: { objectFit: "contain" }
2017
+ })]
2018
+ }, label))
2019
+ }) : hasBody ? /* @__PURE__ */ jsx("div", {
2007
2020
  style: {
2008
- fontFamily: code ? props.mono : props.body,
2009
- fontWeight: 400,
2010
- fontSize: bodyFontSize,
2011
- lineHeight: code ? CODE_LINE_HEIGHT : 1.2,
2012
- whiteSpace: code ? "pre" : "normal",
2013
- color: colors.foreground
2021
+ display: "flex",
2022
+ flexDirection: "column",
2023
+ justifyContent: "flex-start",
2024
+ maxHeight: bodyHeight,
2025
+ padding: terminal || code ? BODY_PAD : 0,
2026
+ backgroundColor: terminal || code ? colors.card : "transparent",
2027
+ borderRadius: terminal ? 0 : ctx.brand.radius,
2028
+ borderLeft: terminal ? `3px solid ${colors.primary}` : NO_BORDER
2014
2029
  },
2015
- children: bodyText
2016
- })
2017
- }) : void 0,
2030
+ children: /* @__PURE__ */ jsx("div", {
2031
+ style: {
2032
+ fontFamily: code ? props.mono : props.body,
2033
+ fontWeight: 400,
2034
+ fontSize: bodyFontSize,
2035
+ lineHeight: code ? CODE_LINE_HEIGHT : 1.2,
2036
+ whiteSpace: code ? "pre" : "normal",
2037
+ color: colors.foreground
2038
+ },
2039
+ children: bodyText
2040
+ })
2041
+ }) : void 0]
2042
+ }),
2018
2043
  /* @__PURE__ */ jsxs("div", {
2019
2044
  style: {
2020
2045
  display: "flex",
2021
2046
  justifyContent: "space-between",
2022
- marginTop: "auto",
2047
+ flexShrink: 0,
2023
2048
  fontFamily: props.mono,
2024
2049
  fontWeight: 400,
2025
2050
  fontSize: 16,
@@ -2408,7 +2433,7 @@ async function collectReadme(cwd) {
2408
2433
  ref: "README.md H1",
2409
2434
  fetchedAt
2410
2435
  });
2411
- if (tagline !== void 0) project.tagline = fact(tagline, {
2436
+ if (tagline !== void 0) project.tagline = fact(stripEmoji(tagline), {
2412
2437
  source: "readme",
2413
2438
  ref: "README.md first paragraph",
2414
2439
  fetchedAt
@@ -2499,6 +2524,14 @@ function stripBadges(text) {
2499
2524
  return text.replace(/\[!\[[^\]]*]\([^)]+\)]\([^)]+\)/g, "").replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/<img[^>]*>/gi, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
2500
2525
  }
2501
2526
 
2527
+ //#endregion
2528
+ //#region src/sources/release-line.ts
2529
+ /** Markdown escapes a maintainer never meant to see rendered: \[ruff\] becomes [ruff]. */
2530
+ const MARKDOWN_ESCAPE = /\\([[\]()*_`~#+\-.!])/g;
2531
+ function cleanReleaseLine(item) {
2532
+ return item.replace(/^\s*[-*]\s*/, "").replace(/\[`[a-f0-9]{7,40}`]\([^)]+\)/gi, "").replace(/^[a-f0-9]{7,40}:\s*/i, "").replace(/\(#\d+\)/g, "").replace(/\[#\d+]\([^)]+\)/g, "").replace(/#\d+\b/g, "").replace(/Thanks\s+\[@[\w-]+]\([^)]+\)!?\s*-?\s*/gi, "").replace(/\[@[\w-]+]\([^)]+\)/g, "").replace(/\(@[\w-]+\)/g, "").replace(/\s+by\s+@[\w-]+/gi, "").replace(/@[\w-]+/g, "").replace(/^Thanks\s*!?\s*-?\s*/i, "").replaceAll(/!\[[^\]]*\]\([^)]*\)/g, "").replaceAll(/\[([^\]]+)\]\([^)]*\)/g, "$1").replaceAll(/<[^>]*>/g, "").replaceAll(MARKDOWN_ESCAPE, "$1").replaceAll(/[*`]/g, "").replace(/^\s*[-*]\s*/, "").replace(/\s*\(\s*\)/g, "").replace(/\s+([,.;:])/g, "$1").replace(/\s+/g, " ").trim().replace(/[\s,;:-]+$/, "").replace(/\.$/, "");
2533
+ }
2534
+
2502
2535
  //#endregion
2503
2536
  //#region src/sources/changelog.ts
2504
2537
  async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
@@ -2600,7 +2633,7 @@ function findVersionSection(markdown, version) {
2600
2633
  }
2601
2634
  }
2602
2635
  function cleanChangelogItem(item) {
2603
- return item.replace(/^\s*[-*]\s*/, "").replace(/\[`[a-f0-9]{7,40}`]\([^)]+\)/gi, "").replace(/^[a-f0-9]{7,40}:\s*/i, "").replace(/\(#\d+\)/g, "").replace(/\[#\d+]\([^)]+\)/g, "").replace(/Thanks\s+\[@[\w-]+]\([^)]+\)!?\s*-?\s*/gi, "").replace(/\[@[\w-]+]\([^)]+\)/g, "").replace(/\(@[\w-]+\)/g, "").replace(/\s+by\s+@[\w-]+/gi, "").replace(/@[\w-]+/g, "").replace(/^Thanks\s*!?\s*-?\s*/i, "").replace(/^\s*[-*]\s*/, "").replaceAll(/`([^`]+)`/g, "$1").replaceAll("`", "").replace(/\s+/g, " ").trim().replace(/\.$/, "");
2636
+ return cleanReleaseLine(item);
2604
2637
  }
2605
2638
  function headingIncludesVersion(heading, version) {
2606
2639
  const unwrapped = heading.replace(/[[\]]/g, " ");
@@ -2887,7 +2920,7 @@ async function collectGithub(options) {
2887
2920
  };
2888
2921
  if (repo.description !== void 0 && repo.description !== null && repo.description.length > 0) out.project = {
2889
2922
  ...out.project,
2890
- tagline: fact(repo.description, {
2923
+ tagline: fact(stripEmoji(repo.description), {
2891
2924
  source: "github-api",
2892
2925
  ref: `GET /repos/${slug.owner}/${slug.repo} description`,
2893
2926
  fetchedAt
@@ -3083,7 +3116,7 @@ async function collectPackageJson(cwd, packagePath = "package.json") {
3083
3116
  ref: rawName === void 0 ? `${packagePath} directory name` : `${packagePath}#name`,
3084
3117
  fetchedAt
3085
3118
  }) };
3086
- if (pkg.data.description !== void 0 && pkg.data.description.length > 0) project.tagline = fact(pkg.data.description, {
3119
+ if (pkg.data.description !== void 0 && pkg.data.description.length > 0) project.tagline = fact(stripEmoji(pkg.data.description), {
3087
3120
  source: "package-json",
3088
3121
  ref: `${packagePath}#description`,
3089
3122
  fetchedAt
@@ -4530,152 +4563,400 @@ function isRecord(value) {
4530
4563
  }
4531
4564
 
4532
4565
  //#endregion
4533
- //#region src/brand/logo.ts
4534
- const DIRECTORIES = [
4535
- "",
4536
- "assets",
4537
- "assets/brand",
4538
- "public",
4539
- "public/brand",
4540
- ".github",
4541
- "docs",
4542
- "static",
4543
- "branding"
4544
- ];
4545
- const ICON_NAMES = ["icon.svg", "icon.png"];
4546
- const NAMES = [
4547
- "logo.svg",
4548
- "logo.png",
4549
- "logo-light.svg",
4550
- "logo-light.png"
4551
- ];
4552
- const FALLBACKS = [...ICON_NAMES, "favicon.svg"];
4553
- function findLogo(cwd) {
4554
- for (const dir of DIRECTORIES) for (const name of NAMES) {
4555
- const relative = dir === "" ? name : join(dir, name);
4556
- if (existsSync(join(cwd, relative))) return relative;
4566
+ //#region src/brand/tailwind.ts
4567
+ const COLOR_PROP = /--color-([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4568
+ const CUSTOM_PROP = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4569
+ const SKIP_PREFIXES = /* @__PURE__ */ new Set([
4570
+ "chart",
4571
+ "sidebar",
4572
+ "ring",
4573
+ "border",
4574
+ "input",
4575
+ "destructive"
4576
+ ]);
4577
+ function extractTailwindV4Colors(css) {
4578
+ const blocks = themeBlocks(css);
4579
+ const custom = collectCustomProperties(css);
4580
+ const fromTheme = {};
4581
+ for (const block of blocks) {
4582
+ COLOR_PROP.lastIndex = 0;
4583
+ let match;
4584
+ while ((match = COLOR_PROP.exec(block)) !== null) {
4585
+ const name = match[1];
4586
+ const raw = match[2];
4587
+ if (name === void 0 || raw === void 0 || shouldSkip(name)) continue;
4588
+ fromTheme[name] = raw.trim();
4589
+ }
4557
4590
  }
4558
- for (const dir of DIRECTORIES) for (const name of FALLBACKS) {
4559
- const relative = dir === "" ? name : join(dir, name);
4560
- if (existsSync(join(cwd, relative))) return relative;
4591
+ const resolved = {};
4592
+ for (const [name, raw] of Object.entries(fromTheme)) {
4593
+ const hex = resolveToHex(raw, custom, 0);
4594
+ if (hex !== void 0) resolved[name] = hex;
4561
4595
  }
4596
+ return mapBrandColors(resolved);
4562
4597
  }
4563
- function findLogoPair(cwd) {
4564
- const light = findLogo(cwd);
4565
- if (light === void 0) return;
4566
- const darkNames = ["logo-dark.svg", "logo-dark.png"];
4567
- for (const dir of DIRECTORIES) for (const name of darkNames) {
4568
- const relative = dir === "" ? name : join(dir, name);
4569
- if (existsSync(join(cwd, relative))) return {
4570
- light,
4571
- dark: relative
4572
- };
4598
+ function extractTailwindV3Colors(source) {
4599
+ const found = {};
4600
+ const re = /\b(primary|brand|accent|background|foreground|muted|secondary)\b\s*:\s*["'`]([^"'`]+)["'`]/g;
4601
+ let match;
4602
+ while ((match = re.exec(source)) !== null) {
4603
+ const name = match[1];
4604
+ const raw = match[2];
4605
+ if (name === void 0 || raw === void 0) continue;
4606
+ const hex = parseCssColor(raw);
4607
+ if (hex !== void 0) found[name] = hex;
4573
4608
  }
4574
- return { light };
4609
+ return mapBrandColors(found);
4575
4610
  }
4576
-
4577
- //#endregion
4578
- //#region src/brand/png.ts
4579
- const SIGNATURE = Buffer.from([
4580
- 137,
4581
- 80,
4582
- 78,
4583
- 71,
4584
- 13,
4585
- 10,
4586
- 26,
4587
- 10
4588
- ]);
4589
- /** Channels per pixel for each PNG color type. Index is the color type. */
4590
- const CHANNELS = {
4591
- 0: 1,
4592
- 2: 3,
4593
- 3: 1,
4594
- 4: 2,
4595
- 6: 4
4596
- };
4597
- function decodePng(buffer) {
4598
- if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) return;
4599
- let width = 0;
4600
- let height = 0;
4601
- let bitDepth = 0;
4602
- let colorType = 0;
4603
- let interlace = 0;
4604
- let palette;
4605
- let paletteAlpha;
4606
- const idat = [];
4607
- let offset = 8;
4608
- while (offset + 8 <= buffer.length) {
4609
- const length = buffer.readUInt32BE(offset);
4610
- const type = buffer.toString("ascii", offset + 4, offset + 8);
4611
- const start = offset + 8;
4612
- const end = start + length;
4613
- if (end > buffer.length) return;
4614
- if (type === "IHDR") {
4615
- width = buffer.readUInt32BE(start);
4616
- height = buffer.readUInt32BE(start + 4);
4617
- bitDepth = buffer[start + 8] ?? 0;
4618
- colorType = buffer[start + 9] ?? 0;
4619
- interlace = buffer[start + 12] ?? 0;
4620
- } else if (type === "PLTE") palette = buffer.subarray(start, end);
4621
- else if (type === "tRNS") paletteAlpha = buffer.subarray(start, end);
4622
- else if (type === "IDAT") idat.push(buffer.subarray(start, end));
4623
- else if (type === "IEND") break;
4624
- offset = end + 4;
4625
- }
4626
- const channels = CHANNELS[colorType];
4627
- if (width === 0 || height === 0 || channels === void 0 || interlace !== 0 || bitDepth !== 8 && bitDepth !== 16 || idat.length === 0 || colorType === 3 && palette === void 0) return;
4628
- if (width * height > 64e6) return;
4629
- let raw;
4630
- try {
4631
- raw = inflateSync(Buffer.concat(idat));
4632
- } catch {
4633
- return;
4611
+ function themeBlocks(css) {
4612
+ const blocks = [];
4613
+ const re = /@theme(?:\s+inline)?\s*\{/g;
4614
+ let match;
4615
+ while ((match = re.exec(css)) !== null) {
4616
+ const open = css.indexOf("{", match.index);
4617
+ if (open === -1) break;
4618
+ const close = matchingBrace(css, open);
4619
+ if (close === -1) break;
4620
+ blocks.push(css.slice(open + 1, close));
4634
4621
  }
4635
- const bytesPerSample = bitDepth / 8;
4636
- const bpp = channels * bytesPerSample;
4637
- const stride = width * bpp;
4638
- if (raw.length < height * (stride + 1)) return;
4639
- const unfiltered = unfilter(raw, height, stride, bpp);
4640
- if (unfiltered === void 0) return;
4641
- return {
4642
- width,
4643
- height,
4644
- pixels: toRgba(unfiltered, width, height, colorType, bytesPerSample, palette, paletteAlpha)
4645
- };
4622
+ return blocks;
4646
4623
  }
4647
- /** Reverse the per-scanline filters defined in the PNG spec. */
4648
- function unfilter(raw, height, stride, bpp) {
4649
- const out = Buffer.alloc(height * stride);
4650
- let pos = 0;
4651
- for (let y = 0; y < height; y++) {
4652
- const filter = raw[pos];
4653
- pos += 1;
4654
- if (filter === void 0 || filter > 4) return;
4655
- const rowStart = y * stride;
4656
- const prevStart = rowStart - stride;
4657
- for (let x = 0; x < stride; x++) {
4658
- const value = raw[pos + x] ?? 0;
4659
- const a = x >= bpp ? out[rowStart + x - bpp] ?? 0 : 0;
4660
- const b = y > 0 ? out[prevStart + x] ?? 0 : 0;
4661
- const c = x >= bpp && y > 0 ? out[prevStart + x - bpp] ?? 0 : 0;
4662
- let restored;
4663
- switch (filter) {
4664
- case 1:
4665
- restored = value + a;
4666
- break;
4667
- case 2:
4668
- restored = value + b;
4669
- break;
4670
- case 3:
4671
- restored = value + Math.floor((a + b) / 2);
4672
- break;
4673
- case 4:
4674
- restored = value + paeth(a, b, c);
4675
- break;
4676
- default: restored = value;
4677
- }
4678
- out[rowStart + x] = restored & 255;
4624
+ function matchingBrace(source, openIndex) {
4625
+ let depth = 0;
4626
+ for (let i = openIndex; i < source.length; i += 1) {
4627
+ const ch = source[i];
4628
+ if (ch === "{") depth += 1;
4629
+ else if (ch === "}") {
4630
+ depth -= 1;
4631
+ if (depth === 0) return i;
4632
+ }
4633
+ }
4634
+ return -1;
4635
+ }
4636
+ function collectCustomProperties(css) {
4637
+ const props = /* @__PURE__ */ new Map();
4638
+ CUSTOM_PROP.lastIndex = 0;
4639
+ let match;
4640
+ while ((match = CUSTOM_PROP.exec(css)) !== null) {
4641
+ const name = match[1];
4642
+ const value = match[2];
4643
+ if (name !== void 0 && value !== void 0) props.set(`--${name}`, value.trim());
4644
+ }
4645
+ return props;
4646
+ }
4647
+ function resolveToHex(raw, custom, depth) {
4648
+ if (depth > 3) return;
4649
+ const direct = parseCssColor(raw);
4650
+ if (direct !== void 0) return direct;
4651
+ const varMatch = /^var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,[^)]+)?\)$/.exec(raw);
4652
+ if (varMatch === null) return;
4653
+ const ref = varMatch[1];
4654
+ if (ref === void 0) return;
4655
+ const next = custom.get(ref);
4656
+ if (next === void 0) return;
4657
+ return resolveToHex(next, custom, depth + 1);
4658
+ }
4659
+ function shouldSkip(name) {
4660
+ const root = name.split("-")[0];
4661
+ return root !== void 0 && SKIP_PREFIXES.has(root);
4662
+ }
4663
+ function mapBrandColors(found) {
4664
+ const out = {};
4665
+ const background = found.background ?? found.bg;
4666
+ if (background !== void 0) out.background = background;
4667
+ const foreground = found.foreground ?? found.fg;
4668
+ if (foreground !== void 0) out.foreground = foreground;
4669
+ const muted = found["muted-foreground"] ?? found.muted;
4670
+ if (muted !== void 0) out.muted = muted;
4671
+ const primary = found.primary ?? found.brand;
4672
+ if (primary !== void 0) out.primary = primary;
4673
+ const accent = found.accent ?? found.secondary;
4674
+ if (accent !== void 0) out.accent = accent;
4675
+ return out;
4676
+ }
4677
+
4678
+ //#endregion
4679
+ //#region src/brand/detect-colors.ts
4680
+ /** Path helpers, kept local so this module never imports `node:path`. */
4681
+ function basename$1(path) {
4682
+ return path.split("/").pop() ?? path;
4683
+ }
4684
+ function relative(from, path) {
4685
+ const prefix = from.endsWith("/") ? from : `${from}/`;
4686
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
4687
+ }
4688
+ async function detectColorsFrom(files, cwd, logoPath, sources, notes, logoColor = () => Promise.resolve(void 0)) {
4689
+ const merged = {};
4690
+ const paths = await files.list();
4691
+ const cssFiles = paths.filter((file) => file.endsWith(".css"));
4692
+ const cssContents = await Promise.all(cssFiles.map(async (file) => ({
4693
+ file,
4694
+ css: await files.read(file)
4695
+ })));
4696
+ for (const { file, css } of cssContents) {
4697
+ if (css === void 0) continue;
4698
+ const fromTheme = extractTailwindV4Colors(css);
4699
+ const fromRoot = extractCssRootColors(css);
4700
+ const rel = relative(cwd, file);
4701
+ applyColors(merged, fromTheme, sources, `Tailwind v4 @theme in ${rel}`);
4702
+ applyColors(merged, fromRoot, sources, `:root variables in ${rel}`);
4703
+ }
4704
+ const configFiles = paths.filter((file) => basename$1(file).startsWith("tailwind.config."));
4705
+ const configContents = await Promise.all(configFiles.map(async (file) => ({
4706
+ file,
4707
+ source: await files.read(file)
4708
+ })));
4709
+ for (const { file, source } of configContents) {
4710
+ if (source === void 0) continue;
4711
+ applyColors(merged, extractTailwindV3Colors(source), sources, `Tailwind config ${relative(cwd, file)}`);
4712
+ }
4713
+ const tokenFile = paths.find((file) => file.endsWith(".tokens.json"));
4714
+ if (tokenFile !== void 0) {
4715
+ const raw = await files.read(tokenFile);
4716
+ if (raw !== void 0) try {
4717
+ const parsed = JSON.parse(raw);
4718
+ applyColors(merged, extractDtcgColors(parsed), sources, relative(cwd, tokenFile));
4719
+ } catch (error) {
4720
+ if (error instanceof SyntaxError) notes.push(`Could not parse design tokens file ${relative(cwd, tokenFile)}.`);
4721
+ else throw error;
4722
+ }
4723
+ }
4724
+ if (merged.primary === void 0 && logoPath !== void 0) {
4725
+ const fromPng = logoPath.endsWith(".png") ? await logoColor(files, logoPath) : void 0;
4726
+ if (fromPng !== void 0) {
4727
+ merged.primary = fromPng;
4728
+ sources.push({
4729
+ field: "colors.primary",
4730
+ source: `dominant color in ${logoPath}`
4731
+ });
4732
+ }
4733
+ const svg = logoPath.endsWith(".svg") ? await files.read(logoPath) : void 0;
4734
+ if (svg !== void 0) {
4735
+ const fromLogo = firstNonNeutralSvgColor(svg);
4736
+ if (fromLogo !== void 0) {
4737
+ merged.primary = fromLogo;
4738
+ sources.push({
4739
+ field: "colors.primary",
4740
+ source: `SVG fill in ${logoPath}`
4741
+ });
4742
+ }
4743
+ }
4744
+ }
4745
+ const fellBack = [
4746
+ "background",
4747
+ "foreground",
4748
+ "muted",
4749
+ "primary",
4750
+ "accent"
4751
+ ].filter((field) => merged[field] === void 0);
4752
+ if (fellBack.length > 0) notes.push(`Using built-in defaults for ${fellBack.map((f) => `colors.${f}`).join(", ")}. Nothing in this project set them. Edit .shipseal/brand.json to use your own.`);
4753
+ return {
4754
+ background: merged.background ?? DEFAULT_BRAND_COLORS.background,
4755
+ foreground: merged.foreground ?? DEFAULT_BRAND_COLORS.foreground,
4756
+ muted: merged.muted ?? DEFAULT_BRAND_COLORS.muted,
4757
+ primary: merged.primary ?? DEFAULT_BRAND_COLORS.primary,
4758
+ accent: merged.accent ?? DEFAULT_BRAND_COLORS.accent
4759
+ };
4760
+ }
4761
+ function applyColors(target, incoming, sources, source) {
4762
+ for (const key of [
4763
+ "background",
4764
+ "foreground",
4765
+ "muted",
4766
+ "primary",
4767
+ "accent"
4768
+ ]) {
4769
+ const value = incoming[key];
4770
+ if (value !== void 0 && target[key] === void 0) {
4771
+ target[key] = value;
4772
+ sources.push({
4773
+ field: `colors.${key}`,
4774
+ source
4775
+ });
4776
+ }
4777
+ }
4778
+ }
4779
+ function applyReadableMuted(colors, sources, notes) {
4780
+ if (colors.muted === void 0 || readableOnBackground(colors.background, colors.muted)) return;
4781
+ colors.muted = DEFAULT_BRAND_COLORS.muted;
4782
+ notes.push(`Muted text color failed WCAG AA large-text contrast (3:1) against background, so it was set to ${DEFAULT_BRAND_COLORS.muted}.`);
4783
+ upsertSource(sources, "colors.muted", "contrast adjustment");
4784
+ }
4785
+ function applyReadableAccent(colors, sources, notes) {
4786
+ if (colors.accent === void 0 || readableOnBackground(colors.background, colors.accent)) return;
4787
+ colors.accent = colors.primary ?? DEFAULT_BRAND_COLORS.primary;
4788
+ notes.push("Accent failed contrast against background, so it uses the primary color.");
4789
+ upsertSource(sources, "colors.accent", "contrast adjustment");
4790
+ }
4791
+ function upsertSource(sources, field, source) {
4792
+ const existing = sources.find((entry) => entry.field === field);
4793
+ if (existing !== void 0) {
4794
+ existing.source = source;
4795
+ return;
4796
+ }
4797
+ sources.push({
4798
+ field,
4799
+ source
4800
+ });
4801
+ }
4802
+ function firstNonNeutralSvgColor(svg) {
4803
+ const re = /(?:fill|stroke)="([^"]+)"/g;
4804
+ let match;
4805
+ while ((match = re.exec(svg)) !== null) {
4806
+ const raw = match[1];
4807
+ if (raw === void 0 || raw === "none" || raw === "currentColor") continue;
4808
+ const hex = parseCssColor(raw);
4809
+ if (hex !== void 0 && !isNeutralHex(hex)) return hex;
4810
+ }
4811
+ }
4812
+
4813
+ //#endregion
4814
+ //#region src/brand/logo.ts
4815
+ const DIRECTORIES = [
4816
+ "",
4817
+ "assets",
4818
+ "assets/brand",
4819
+ "public",
4820
+ "public/brand",
4821
+ ".github",
4822
+ "docs",
4823
+ "static",
4824
+ "branding"
4825
+ ];
4826
+ const ICON_NAMES = ["icon.svg", "icon.png"];
4827
+ const NAMES = [
4828
+ "logo.svg",
4829
+ "logo.png",
4830
+ "logo-light.svg",
4831
+ "logo-light.png"
4832
+ ];
4833
+ const FALLBACKS = [...ICON_NAMES, "favicon.svg"];
4834
+ function findLogo(cwd) {
4835
+ for (const dir of DIRECTORIES) for (const name of NAMES) {
4836
+ const relative = dir === "" ? name : join(dir, name);
4837
+ if (existsSync(join(cwd, relative))) return relative;
4838
+ }
4839
+ for (const dir of DIRECTORIES) for (const name of FALLBACKS) {
4840
+ const relative = dir === "" ? name : join(dir, name);
4841
+ if (existsSync(join(cwd, relative))) return relative;
4842
+ }
4843
+ }
4844
+ function findLogoPair(cwd) {
4845
+ const light = findLogo(cwd);
4846
+ if (light === void 0) return;
4847
+ const darkNames = ["logo-dark.svg", "logo-dark.png"];
4848
+ for (const dir of DIRECTORIES) for (const name of darkNames) {
4849
+ const relative = dir === "" ? name : join(dir, name);
4850
+ if (existsSync(join(cwd, relative))) return {
4851
+ light,
4852
+ dark: relative
4853
+ };
4854
+ }
4855
+ return { light };
4856
+ }
4857
+
4858
+ //#endregion
4859
+ //#region src/brand/png.ts
4860
+ const SIGNATURE = Buffer.from([
4861
+ 137,
4862
+ 80,
4863
+ 78,
4864
+ 71,
4865
+ 13,
4866
+ 10,
4867
+ 26,
4868
+ 10
4869
+ ]);
4870
+ /** Channels per pixel for each PNG color type. Index is the color type. */
4871
+ const CHANNELS = {
4872
+ 0: 1,
4873
+ 2: 3,
4874
+ 3: 1,
4875
+ 4: 2,
4876
+ 6: 4
4877
+ };
4878
+ function decodePng(buffer) {
4879
+ if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) return;
4880
+ let width = 0;
4881
+ let height = 0;
4882
+ let bitDepth = 0;
4883
+ let colorType = 0;
4884
+ let interlace = 0;
4885
+ let palette;
4886
+ let paletteAlpha;
4887
+ const idat = [];
4888
+ let offset = 8;
4889
+ while (offset + 8 <= buffer.length) {
4890
+ const length = buffer.readUInt32BE(offset);
4891
+ const type = buffer.toString("ascii", offset + 4, offset + 8);
4892
+ const start = offset + 8;
4893
+ const end = start + length;
4894
+ if (end > buffer.length) return;
4895
+ if (type === "IHDR") {
4896
+ width = buffer.readUInt32BE(start);
4897
+ height = buffer.readUInt32BE(start + 4);
4898
+ bitDepth = buffer[start + 8] ?? 0;
4899
+ colorType = buffer[start + 9] ?? 0;
4900
+ interlace = buffer[start + 12] ?? 0;
4901
+ } else if (type === "PLTE") palette = buffer.subarray(start, end);
4902
+ else if (type === "tRNS") paletteAlpha = buffer.subarray(start, end);
4903
+ else if (type === "IDAT") idat.push(buffer.subarray(start, end));
4904
+ else if (type === "IEND") break;
4905
+ offset = end + 4;
4906
+ }
4907
+ const channels = CHANNELS[colorType];
4908
+ if (width === 0 || height === 0 || channels === void 0 || interlace !== 0 || bitDepth !== 8 && bitDepth !== 16 || idat.length === 0 || colorType === 3 && palette === void 0) return;
4909
+ if (width * height > 64e6) return;
4910
+ let raw;
4911
+ try {
4912
+ raw = inflateSync(Buffer.concat(idat));
4913
+ } catch {
4914
+ return;
4915
+ }
4916
+ const bytesPerSample = bitDepth / 8;
4917
+ const bpp = channels * bytesPerSample;
4918
+ const stride = width * bpp;
4919
+ if (raw.length < height * (stride + 1)) return;
4920
+ const unfiltered = unfilter(raw, height, stride, bpp);
4921
+ if (unfiltered === void 0) return;
4922
+ return {
4923
+ width,
4924
+ height,
4925
+ pixels: toRgba(unfiltered, width, height, colorType, bytesPerSample, palette, paletteAlpha)
4926
+ };
4927
+ }
4928
+ /** Reverse the per-scanline filters defined in the PNG spec. */
4929
+ function unfilter(raw, height, stride, bpp) {
4930
+ const out = Buffer.alloc(height * stride);
4931
+ let pos = 0;
4932
+ for (let y = 0; y < height; y++) {
4933
+ const filter = raw[pos];
4934
+ pos += 1;
4935
+ if (filter === void 0 || filter > 4) return;
4936
+ const rowStart = y * stride;
4937
+ const prevStart = rowStart - stride;
4938
+ for (let x = 0; x < stride; x++) {
4939
+ const value = raw[pos + x] ?? 0;
4940
+ const a = x >= bpp ? out[rowStart + x - bpp] ?? 0 : 0;
4941
+ const b = y > 0 ? out[prevStart + x] ?? 0 : 0;
4942
+ const c = x >= bpp && y > 0 ? out[prevStart + x - bpp] ?? 0 : 0;
4943
+ let restored;
4944
+ switch (filter) {
4945
+ case 1:
4946
+ restored = value + a;
4947
+ break;
4948
+ case 2:
4949
+ restored = value + b;
4950
+ break;
4951
+ case 3:
4952
+ restored = value + Math.floor((a + b) / 2);
4953
+ break;
4954
+ case 4:
4955
+ restored = value + paeth(a, b, c);
4956
+ break;
4957
+ default: restored = value;
4958
+ }
4959
+ out[rowStart + x] = restored & 255;
4679
4960
  }
4680
4961
  pos += stride;
4681
4962
  }
@@ -4764,141 +5045,22 @@ function dominantNonNeutralColor(png) {
4764
5045
  bucket.count += 1;
4765
5046
  bucket.r += r;
4766
5047
  bucket.g += g;
4767
- bucket.b += b;
4768
- }
4769
- }
4770
- let best;
4771
- for (const bucket of buckets.values()) if (best === void 0 || bucket.count > best.count) best = bucket;
4772
- if (best === void 0) return;
4773
- const channel = (total) => Math.round(total / best.count).toString(16).padStart(2, "0");
4774
- return `#${channel(best.r)}${channel(best.g)}${channel(best.b)}`;
4775
- }
4776
-
4777
- //#endregion
4778
- //#region src/brand/tailwind.ts
4779
- const COLOR_PROP = /--color-([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4780
- const CUSTOM_PROP = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4781
- const SKIP_PREFIXES = /* @__PURE__ */ new Set([
4782
- "chart",
4783
- "sidebar",
4784
- "ring",
4785
- "border",
4786
- "input",
4787
- "destructive"
4788
- ]);
4789
- function extractTailwindV4Colors(css) {
4790
- const blocks = themeBlocks(css);
4791
- const custom = collectCustomProperties(css);
4792
- const fromTheme = {};
4793
- for (const block of blocks) {
4794
- COLOR_PROP.lastIndex = 0;
4795
- let match;
4796
- while ((match = COLOR_PROP.exec(block)) !== null) {
4797
- const name = match[1];
4798
- const raw = match[2];
4799
- if (name === void 0 || raw === void 0 || shouldSkip(name)) continue;
4800
- fromTheme[name] = raw.trim();
4801
- }
4802
- }
4803
- const resolved = {};
4804
- for (const [name, raw] of Object.entries(fromTheme)) {
4805
- const hex = resolveToHex(raw, custom, 0);
4806
- if (hex !== void 0) resolved[name] = hex;
4807
- }
4808
- return mapBrandColors(resolved);
4809
- }
4810
- function extractTailwindV3Colors(source) {
4811
- const found = {};
4812
- const re = /\b(primary|brand|accent|background|foreground|muted|secondary)\b\s*:\s*["'`]([^"'`]+)["'`]/g;
4813
- let match;
4814
- while ((match = re.exec(source)) !== null) {
4815
- const name = match[1];
4816
- const raw = match[2];
4817
- if (name === void 0 || raw === void 0) continue;
4818
- const hex = parseCssColor(raw);
4819
- if (hex !== void 0) found[name] = hex;
4820
- }
4821
- return mapBrandColors(found);
4822
- }
4823
- function themeBlocks(css) {
4824
- const blocks = [];
4825
- const re = /@theme(?:\s+inline)?\s*\{/g;
4826
- let match;
4827
- while ((match = re.exec(css)) !== null) {
4828
- const open = css.indexOf("{", match.index);
4829
- if (open === -1) break;
4830
- const close = matchingBrace(css, open);
4831
- if (close === -1) break;
4832
- blocks.push(css.slice(open + 1, close));
4833
- }
4834
- return blocks;
4835
- }
4836
- function matchingBrace(source, openIndex) {
4837
- let depth = 0;
4838
- for (let i = openIndex; i < source.length; i += 1) {
4839
- const ch = source[i];
4840
- if (ch === "{") depth += 1;
4841
- else if (ch === "}") {
4842
- depth -= 1;
4843
- if (depth === 0) return i;
4844
- }
4845
- }
4846
- return -1;
4847
- }
4848
- function collectCustomProperties(css) {
4849
- const props = /* @__PURE__ */ new Map();
4850
- CUSTOM_PROP.lastIndex = 0;
4851
- let match;
4852
- while ((match = CUSTOM_PROP.exec(css)) !== null) {
4853
- const name = match[1];
4854
- const value = match[2];
4855
- if (name !== void 0 && value !== void 0) props.set(`--${name}`, value.trim());
5048
+ bucket.b += b;
5049
+ }
4856
5050
  }
4857
- return props;
4858
- }
4859
- function resolveToHex(raw, custom, depth) {
4860
- if (depth > 3) return;
4861
- const direct = parseCssColor(raw);
4862
- if (direct !== void 0) return direct;
4863
- const varMatch = /^var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,[^)]+)?\)$/.exec(raw);
4864
- if (varMatch === null) return;
4865
- const ref = varMatch[1];
4866
- if (ref === void 0) return;
4867
- const next = custom.get(ref);
4868
- if (next === void 0) return;
4869
- return resolveToHex(next, custom, depth + 1);
4870
- }
4871
- function shouldSkip(name) {
4872
- const root = name.split("-")[0];
4873
- return root !== void 0 && SKIP_PREFIXES.has(root);
4874
- }
4875
- function mapBrandColors(found) {
4876
- const out = {};
4877
- const background = found.background ?? found.bg;
4878
- if (background !== void 0) out.background = background;
4879
- const foreground = found.foreground ?? found.fg;
4880
- if (foreground !== void 0) out.foreground = foreground;
4881
- const muted = found["muted-foreground"] ?? found.muted;
4882
- if (muted !== void 0) out.muted = muted;
4883
- const primary = found.primary ?? found.brand;
4884
- if (primary !== void 0) out.primary = primary;
4885
- const accent = found.accent ?? found.secondary;
4886
- if (accent !== void 0) out.accent = accent;
4887
- return out;
5051
+ let best;
5052
+ for (const bucket of buckets.values()) if (best === void 0 || bucket.count > best.count) best = bucket;
5053
+ if (best === void 0) return;
5054
+ const channel = (total) => Math.round(total / best.count).toString(16).padStart(2, "0");
5055
+ return `#${channel(best.r)}${channel(best.g)}${channel(best.b)}`;
4888
5056
  }
4889
5057
 
4890
5058
  //#endregion
4891
5059
  //#region src/brand/detect.ts
4892
- const execFileAsync = promisify(execFile);
4893
- const SKIP_DIRS = /* @__PURE__ */ new Set([
4894
- "node_modules",
4895
- ".git",
4896
- "dist",
4897
- "coverage",
4898
- ".shipseal",
4899
- ".next",
4900
- "build"
4901
- ]);
5060
+ /** Path helpers, kept local so this module stays free of `node:path` and reaches the browser. */
5061
+ function basename(path) {
5062
+ return path.split("/").pop() ?? path;
5063
+ }
4902
5064
  const packageSchema = z.object({
4903
5065
  private: z.boolean().optional(),
4904
5066
  name: z.string().optional(),
@@ -4906,20 +5068,24 @@ const packageSchema = z.object({
4906
5068
  homepage: z.string().optional(),
4907
5069
  repository: z.union([z.string(), z.object({ url: z.string().optional() })]).optional()
4908
5070
  });
4909
- async function detectBrand(cwd) {
5071
+ /**
5072
+ * Detection over any source. `root` is only used to make reported paths readable, so the
5073
+ * GitHub implementation can pass the repository slug.
5074
+ */
5075
+ async function detectBrandFrom(files, cwd = ".") {
4910
5076
  const sources = [];
4911
5077
  const notes = [];
4912
- const pkg = await readPackage(cwd);
4913
- const readme = await readMaybe(join(cwd, "README.md"));
5078
+ const pkg = await readPackage(files);
5079
+ const readme = await files.read("README.md");
4914
5080
  const name = detectName(pkg, readme, cwd, sources);
4915
5081
  const tagline = detectTagline(pkg, readme, sources);
4916
- const url = await detectUrl(pkg, cwd, sources);
5082
+ const url = await detectUrl(files, pkg, cwd, sources);
4917
5083
  const logo = findLogoPair(cwd);
4918
5084
  if (logo !== void 0) sources.push({
4919
5085
  field: "logo",
4920
5086
  source: logo.light
4921
5087
  });
4922
- const colors = await detectColors(cwd, logo?.light, sources, notes);
5088
+ const colors = await detectColorsFrom(files, cwd, logo?.light, sources, notes, dominantLogoColor);
4923
5089
  const contrast = ensureForegroundContrast(colors.background, colors.foreground);
4924
5090
  if (contrast.adjusted) {
4925
5091
  colors.foreground = contrast.foreground;
@@ -5009,7 +5175,7 @@ function detectTagline(pkg, readme, sources) {
5009
5175
  }
5010
5176
  }
5011
5177
  }
5012
- async function detectUrl(pkg, cwd, sources) {
5178
+ async function detectUrl(files, pkg, cwd, sources) {
5013
5179
  if (pkg?.homepage !== void 0 && pkg.homepage.length > 0) {
5014
5180
  sources.push({
5015
5181
  field: "url",
@@ -5025,7 +5191,7 @@ async function detectUrl(pkg, cwd, sources) {
5025
5191
  });
5026
5192
  return repo;
5027
5193
  }
5028
- const remote = await gitRemote(cwd);
5194
+ const remote = await files.gitRemote?.();
5029
5195
  if (remote !== void 0) {
5030
5196
  sources.push({
5031
5197
  field: "url",
@@ -5034,122 +5200,8 @@ async function detectUrl(pkg, cwd, sources) {
5034
5200
  return remote;
5035
5201
  }
5036
5202
  }
5037
- async function detectColors(cwd, logoPath, sources, notes) {
5038
- const merged = {};
5039
- const files = await listFiles(cwd, 4);
5040
- const cssFiles = files.filter((file) => file.endsWith(".css"));
5041
- const cssContents = await Promise.all(cssFiles.map(async (file) => ({
5042
- file,
5043
- css: await readMaybe(file)
5044
- })));
5045
- for (const { file, css } of cssContents) {
5046
- if (css === void 0) continue;
5047
- const fromTheme = extractTailwindV4Colors(css);
5048
- const fromRoot = extractCssRootColors(css);
5049
- const rel = relative(cwd, file);
5050
- applyColors(merged, fromTheme, sources, `Tailwind v4 @theme in ${rel}`);
5051
- applyColors(merged, fromRoot, sources, `:root variables in ${rel}`);
5052
- }
5053
- const configFiles = files.filter((file) => basename(file).startsWith("tailwind.config."));
5054
- const configContents = await Promise.all(configFiles.map(async (file) => ({
5055
- file,
5056
- source: await readMaybe(file)
5057
- })));
5058
- for (const { file, source } of configContents) {
5059
- if (source === void 0) continue;
5060
- applyColors(merged, extractTailwindV3Colors(source), sources, `Tailwind config ${relative(cwd, file)}`);
5061
- }
5062
- const tokenFile = files.find((file) => file.endsWith(".tokens.json"));
5063
- if (tokenFile !== void 0) {
5064
- const raw = await readMaybe(tokenFile);
5065
- if (raw !== void 0) try {
5066
- const parsed = JSON.parse(raw);
5067
- applyColors(merged, extractDtcgColors(parsed), sources, relative(cwd, tokenFile));
5068
- } catch (error) {
5069
- if (error instanceof SyntaxError) notes.push(`Could not parse design tokens file ${relative(cwd, tokenFile)}.`);
5070
- else throw error;
5071
- }
5072
- }
5073
- if (merged.primary === void 0 && logoPath !== void 0) {
5074
- const fromPng = logoPath.endsWith(".png") ? await dominantLogoColor(join(cwd, logoPath)) : void 0;
5075
- if (fromPng !== void 0) {
5076
- merged.primary = fromPng;
5077
- sources.push({
5078
- field: "colors.primary",
5079
- source: `dominant color in ${logoPath}`
5080
- });
5081
- }
5082
- const svg = logoPath.endsWith(".svg") ? await readMaybe(join(cwd, logoPath)) : void 0;
5083
- if (svg !== void 0) {
5084
- const fromLogo = firstNonNeutralSvgColor(svg);
5085
- if (fromLogo !== void 0) {
5086
- merged.primary = fromLogo;
5087
- sources.push({
5088
- field: "colors.primary",
5089
- source: `SVG fill in ${logoPath}`
5090
- });
5091
- }
5092
- }
5093
- }
5094
- const fellBack = [
5095
- "background",
5096
- "foreground",
5097
- "muted",
5098
- "primary",
5099
- "accent"
5100
- ].filter((field) => merged[field] === void 0);
5101
- if (fellBack.length > 0) notes.push(`Using built-in defaults for ${fellBack.map((f) => `colors.${f}`).join(", ")}. Nothing in this project set them. Edit .shipseal/brand.json to use your own.`);
5102
- return {
5103
- background: merged.background ?? DEFAULT_BRAND_COLORS.background,
5104
- foreground: merged.foreground ?? DEFAULT_BRAND_COLORS.foreground,
5105
- muted: merged.muted ?? DEFAULT_BRAND_COLORS.muted,
5106
- primary: merged.primary ?? DEFAULT_BRAND_COLORS.primary,
5107
- accent: merged.accent ?? DEFAULT_BRAND_COLORS.accent
5108
- };
5109
- }
5110
- function applyReadableMuted(colors, sources, notes) {
5111
- if (colors.muted === void 0 || readableOnBackground(colors.background, colors.muted)) return;
5112
- colors.muted = DEFAULT_BRAND_COLORS.muted;
5113
- notes.push(`Muted text color failed WCAG AA large-text contrast (3:1) against background, so it was set to ${DEFAULT_BRAND_COLORS.muted}.`);
5114
- upsertSource(sources, "colors.muted", "contrast adjustment");
5115
- }
5116
- function applyReadableAccent(colors, sources, notes) {
5117
- if (colors.accent === void 0 || readableOnBackground(colors.background, colors.accent)) return;
5118
- colors.accent = colors.primary ?? DEFAULT_BRAND_COLORS.primary;
5119
- notes.push("Accent failed contrast against background, so it uses the primary color.");
5120
- upsertSource(sources, "colors.accent", "contrast adjustment");
5121
- }
5122
- function applyColors(target, incoming, sources, source) {
5123
- for (const key of [
5124
- "background",
5125
- "foreground",
5126
- "muted",
5127
- "primary",
5128
- "accent"
5129
- ]) {
5130
- const value = incoming[key];
5131
- if (value !== void 0 && target[key] === void 0) {
5132
- target[key] = value;
5133
- sources.push({
5134
- field: `colors.${key}`,
5135
- source
5136
- });
5137
- }
5138
- }
5139
- }
5140
- function upsertSource(sources, field, source) {
5141
- const existing = sources.find((entry) => entry.field === field);
5142
- if (existing !== void 0) {
5143
- existing.source = source;
5144
- return;
5145
- }
5146
- sources.push({
5147
- field,
5148
- source
5149
- });
5150
- }
5151
- async function readPackage(cwd) {
5152
- const raw = await readMaybe(join(cwd, "package.json"));
5203
+ async function readPackage(files) {
5204
+ const raw = await files.read("package.json");
5153
5205
  if (raw === void 0) return;
5154
5206
  try {
5155
5207
  const parsed = JSON.parse(raw);
@@ -5160,18 +5212,69 @@ async function readPackage(cwd) {
5160
5212
  throw error;
5161
5213
  }
5162
5214
  }
5163
- async function gitRemote(cwd) {
5164
- try {
5165
- const { stdout } = await execFileAsync("git", [
5166
- "remote",
5167
- "get-url",
5168
- "origin"
5169
- ], { cwd });
5170
- return normalizeGitUrl(stdout.trim());
5171
- } catch (error) {
5172
- if (error instanceof Error) return;
5173
- throw error;
5174
- }
5215
+ function stripScope(name) {
5216
+ const parts = name.split("/");
5217
+ return parts[parts.length - 1] ?? name;
5218
+ }
5219
+ function repositoryUrl(repository) {
5220
+ if (typeof repository === "string") return normalizeGitUrl(repository);
5221
+ if (repository?.url !== void 0) return normalizeGitUrl(repository.url);
5222
+ }
5223
+ function normalizeGitUrl(url) {
5224
+ const ssh = /^git@([^:]+):(.+)$/.exec(url);
5225
+ if (ssh !== null && ssh[1] !== void 0 && ssh[2] !== void 0) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
5226
+ return url.replace(/^git\+/, "").replace(/\.git$/, "");
5227
+ }
5228
+ /**
5229
+ * Dominant non-neutral color of a PNG logo, or undefined when the file cannot be read or
5230
+ * carries no color. Detection reports the color as not found rather than falling back to a
5231
+ * built-in default that would be presented to the user as "your brand".
5232
+ */
5233
+ async function dominantLogoColor(files, path) {
5234
+ const buffer = await files.readBinary(path);
5235
+ if (buffer === void 0) return;
5236
+ const png = decodePng(Buffer.from(buffer));
5237
+ if (png === void 0) return;
5238
+ return dominantNonNeutralColor(png);
5239
+ }
5240
+
5241
+ //#endregion
5242
+ //#region src/brand/detect-node.ts
5243
+ const execFileAsync = promisify(execFile);
5244
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
5245
+ "node_modules",
5246
+ ".git",
5247
+ "dist",
5248
+ "build",
5249
+ "coverage",
5250
+ ".next",
5251
+ ".astro",
5252
+ "out",
5253
+ "vendor"
5254
+ ]);
5255
+ async function detectBrand(cwd) {
5256
+ return detectBrandFrom(diskFiles(cwd), cwd);
5257
+ }
5258
+ /** The disk implementation of the port, used by the CLI. Paths stay absolute here. */
5259
+ function diskFiles(cwd, maxDepth = 4) {
5260
+ return {
5261
+ list: () => listFiles(cwd, maxDepth),
5262
+ read: async (path) => {
5263
+ try {
5264
+ return await readFile(isAbsolute(path) ? path : join(cwd, path), "utf8");
5265
+ } catch {
5266
+ return;
5267
+ }
5268
+ },
5269
+ gitRemote: () => gitRemote(cwd),
5270
+ readBinary: async (path) => {
5271
+ try {
5272
+ return await readFile(isAbsolute(path) ? path : join(cwd, path));
5273
+ } catch {
5274
+ return;
5275
+ }
5276
+ }
5277
+ };
5175
5278
  }
5176
5279
  async function listFiles(root, maxDepth) {
5177
5280
  const out = [];
@@ -5197,50 +5300,17 @@ async function listFiles(root, maxDepth) {
5197
5300
  await walk(root, maxDepth);
5198
5301
  return out;
5199
5302
  }
5200
- async function readMaybe(path) {
5201
- try {
5202
- return await readFile(path, "utf8");
5203
- } catch {
5204
- return;
5205
- }
5206
- }
5207
- function stripScope(name) {
5208
- const parts = name.split("/");
5209
- return parts[parts.length - 1] ?? name;
5210
- }
5211
- function repositoryUrl(repository) {
5212
- if (typeof repository === "string") return normalizeGitUrl(repository);
5213
- if (repository?.url !== void 0) return normalizeGitUrl(repository.url);
5214
- }
5215
- function normalizeGitUrl(url) {
5216
- const ssh = /^git@([^:]+):(.+)$/.exec(url);
5217
- if (ssh !== null && ssh[1] !== void 0 && ssh[2] !== void 0) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
5218
- return url.replace(/^git\+/, "").replace(/\.git$/, "");
5219
- }
5220
- /**
5221
- * Dominant non-neutral color of a PNG logo, or undefined when the file cannot be read or
5222
- * carries no color. Detection reports the color as not found rather than falling back to a
5223
- * built-in default that would be presented to the user as "your brand".
5224
- */
5225
- async function dominantLogoColor(path) {
5226
- let buffer;
5303
+ async function gitRemote(cwd) {
5227
5304
  try {
5228
- buffer = await readFile(path);
5229
- } catch {
5230
- return;
5231
- }
5232
- const png = decodePng(buffer);
5233
- if (png === void 0) return;
5234
- return dominantNonNeutralColor(png);
5235
- }
5236
- function firstNonNeutralSvgColor(svg) {
5237
- const re = /(?:fill|stroke)="([^"]+)"/g;
5238
- let match;
5239
- while ((match = re.exec(svg)) !== null) {
5240
- const raw = match[1];
5241
- if (raw === void 0 || raw === "none" || raw === "currentColor") continue;
5242
- const hex = parseCssColor(raw);
5243
- if (hex !== void 0 && !isNeutralHex(hex)) return hex;
5305
+ const { stdout } = await execFileAsync("git", [
5306
+ "remote",
5307
+ "get-url",
5308
+ "origin"
5309
+ ], { cwd });
5310
+ return normalizeGitUrl(stdout.trim());
5311
+ } catch (error) {
5312
+ if (error instanceof Error) return;
5313
+ throw error;
5244
5314
  }
5245
5315
  }
5246
5316
 
@@ -5596,10 +5666,10 @@ function overlayReleaseConfig(config, flags) {
5596
5666
  //#region src/core/story.ts
5597
5667
  const DEFAULT_CHANGE_PAGES = 4;
5598
5668
  const STORY_FORMATS$1 = "portrait, square, og, github-social, x, linkedin, or producthunt";
5599
- function storyFacts(facts, config, name, generatedAt) {
5669
+ function storyFacts(facts, config, brand, generatedAt) {
5600
5670
  const release = facts.release;
5601
5671
  if (release === void 0) throw new ShipsealError("story.no-release", "No release facts are available for a story.", "Choose a published release or pass --tag to a local repository.");
5602
- const copy = deterministicCopy(facts, config.release?.maxHighlights, name);
5672
+ const copy = deterministicCopy(facts, config.release?.maxHighlights, brand.name);
5603
5673
  const configured = (value, ref) => fact(value, {
5604
5674
  source: "user-config",
5605
5675
  ref,
@@ -5614,7 +5684,11 @@ function storyFacts(facts, config, name, generatedAt) {
5614
5684
  const pages = [{
5615
5685
  kind: "cover",
5616
5686
  title: fact(copy.headline, headlineSource.provenance),
5617
- body: release.subheadline ?? facts.project.tagline ?? fact("Release notes", release.tag.provenance)
5687
+ body: release.subheadline ?? facts.project.tagline ?? (brand.tagline === void 0 ? fact("Release notes", release.tag.provenance) : fact(brand.tagline, {
5688
+ source: "user-config",
5689
+ ref: "brand.json#tagline",
5690
+ fetchedAt: generatedAt
5691
+ }))
5618
5692
  }];
5619
5693
  for (const item of changes.slice(0, config.release?.maxHighlights ?? DEFAULT_CHANGE_PAGES)) {
5620
5694
  const collapsed = item.value.replaceAll(/\s+/g, " ").trim();
@@ -5657,7 +5731,7 @@ function storyFacts(facts, config, name, generatedAt) {
5657
5731
  };
5658
5732
  }
5659
5733
  async function generateStory(input) {
5660
- const facts = storyFacts(input.facts, input.config, input.brand.name, input.generatedAt);
5734
+ const facts = storyFacts(input.facts, input.config, input.brand, input.generatedAt);
5661
5735
  const pages = facts.story ?? [];
5662
5736
  const result = {
5663
5737
  files: [],