shipseal 0.1.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,
@@ -323,6 +325,24 @@ const COPY_LIMITS = {
323
325
  milestoneLine: 48
324
326
  };
325
327
 
328
+ //#endregion
329
+ //#region src/copy/headline.ts
330
+ /** Below this a line is a fragment or a label, not a description of a change. */
331
+ const MIN_WORDS = 3;
332
+ const MIN_CHARS = 12;
333
+ /** Housekeeping prefixes. A chore is not what a release is about. */
334
+ const CHORE = /^(chore|docs?|ci|build|test|refactor|style|revert|bump|deps?|dependencies)\b[:(]?/i;
335
+ /** A line that is only a person's name: "Adam Chmara", "Jane Q. Smith". */
336
+ const PERSON_NAME = /^[A-Z][\p{L}'’-]+(?:\s+[A-Z][.\p{L}'’-]*){1,2}$/u;
337
+ /** Package-ish fragments: "/api", "@scope/pkg", "solid-query.0.0-rc.3", "v1.2.3". */
338
+ const FRAGMENT = /^[@/]|^v?\d+\.\d+|^[\w@/.-]+$/;
339
+ function isHeadlineWorthy(line) {
340
+ const text = line.trim();
341
+ if (text.length < MIN_CHARS || text.split(/\s+/).length < MIN_WORDS) return false;
342
+ if (CHORE.test(text) || PERSON_NAME.test(text) || FRAGMENT.test(text)) return false;
343
+ return !/https?:\/\//.test(text);
344
+ }
345
+
326
346
  //#endregion
327
347
  //#region src/copy/deterministic.ts
328
348
  function deterministicCopy(facts, maxHighlights = 4, displayName) {
@@ -331,7 +351,7 @@ function deterministicCopy(facts, maxHighlights = 4, displayName) {
331
351
  const features = (facts.release?.features ?? []).map((item) => item.value);
332
352
  const fixes = (facts.release?.fixes ?? []).map((item) => item.value);
333
353
  const breaking = (facts.release?.breaking ?? []).map((item) => item.value);
334
- const headlineSource = [...breaking, ...features].map((line) => cleanLine(firstSentence(line))).find((line) => line.length <= HEADLINE_MAX_CHARS);
354
+ const headlineSource = [...breaking, ...features].map((line) => cleanLine(firstSentence(line))).find((line) => line.length <= HEADLINE_MAX_CHARS && isHeadlineWorthy(line));
335
355
  const title = displayName ?? name;
336
356
  const headline = facts.release?.headline?.value ?? headlineSource ?? (version === void 0 ? title : `${title} ${version}`);
337
357
  const tagline = facts.release?.subheadline?.value ?? facts.project.tagline?.value;
@@ -1832,7 +1852,7 @@ const storyPage = {
1832
1852
  box: { width: 220 }
1833
1853
  },
1834
1854
  title: {
1835
- maxLines: tall ? 3 : 2,
1855
+ maxLines: tall ? 4 : 2,
1836
1856
  maxFontSize: tall ? 64 : 48,
1837
1857
  minFontSize: tall ? 36 : 30,
1838
1858
  step: 2,
@@ -2522,6 +2542,14 @@ function stripBadges(text) {
2522
2542
  return text.replace(/\[!\[[^\]]*]\([^)]+\)]\([^)]+\)/g, "").replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/<img[^>]*>/gi, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
2523
2543
  }
2524
2544
 
2545
+ //#endregion
2546
+ //#region src/sources/release-line.ts
2547
+ /** Markdown escapes a maintainer never meant to see rendered: \[ruff\] becomes [ruff]. */
2548
+ const MARKDOWN_ESCAPE = /\\([[\]()*_`~#+\-.!])/g;
2549
+ function cleanReleaseLine(item) {
2550
+ 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, "").replaceAll(/\(\s*[a-f0-9]{6,40}\s*\)/gi, "").replace(/^\[[^\]]{1,24}\]\s*/, "").replace(/^\s*[-*]\s*/, "").replace(/\s*\(\s*\)/g, "").replace(/\s+([,.;:])/g, "$1").replace(/\s+/g, " ").trim().replace(/[\s,;:-]+$/, "").replace(/\.$/, "");
2551
+ }
2552
+
2525
2553
  //#endregion
2526
2554
  //#region src/sources/changelog.ts
2527
2555
  async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
@@ -2623,7 +2651,7 @@ function findVersionSection(markdown, version) {
2623
2651
  }
2624
2652
  }
2625
2653
  function cleanChangelogItem(item) {
2626
- 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(/\.$/, "");
2654
+ return cleanReleaseLine(item);
2627
2655
  }
2628
2656
  function headingIncludesVersion(heading, version) {
2629
2657
  const unwrapped = heading.replace(/[[\]]/g, " ");
@@ -4552,6 +4580,254 @@ function isRecord(value) {
4552
4580
  return typeof value === "object" && value !== null && !Array.isArray(value);
4553
4581
  }
4554
4582
 
4583
+ //#endregion
4584
+ //#region src/brand/tailwind.ts
4585
+ const COLOR_PROP = /--color-([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4586
+ const CUSTOM_PROP = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4587
+ const SKIP_PREFIXES = /* @__PURE__ */ new Set([
4588
+ "chart",
4589
+ "sidebar",
4590
+ "ring",
4591
+ "border",
4592
+ "input",
4593
+ "destructive"
4594
+ ]);
4595
+ function extractTailwindV4Colors(css) {
4596
+ const blocks = themeBlocks(css);
4597
+ const custom = collectCustomProperties(css);
4598
+ const fromTheme = {};
4599
+ for (const block of blocks) {
4600
+ COLOR_PROP.lastIndex = 0;
4601
+ let match;
4602
+ while ((match = COLOR_PROP.exec(block)) !== null) {
4603
+ const name = match[1];
4604
+ const raw = match[2];
4605
+ if (name === void 0 || raw === void 0 || shouldSkip(name)) continue;
4606
+ fromTheme[name] = raw.trim();
4607
+ }
4608
+ }
4609
+ const resolved = {};
4610
+ for (const [name, raw] of Object.entries(fromTheme)) {
4611
+ const hex = resolveToHex(raw, custom, 0);
4612
+ if (hex !== void 0) resolved[name] = hex;
4613
+ }
4614
+ return mapBrandColors(resolved);
4615
+ }
4616
+ function extractTailwindV3Colors(source) {
4617
+ const found = {};
4618
+ const re = /\b(primary|brand|accent|background|foreground|muted|secondary)\b\s*:\s*["'`]([^"'`]+)["'`]/g;
4619
+ let match;
4620
+ while ((match = re.exec(source)) !== null) {
4621
+ const name = match[1];
4622
+ const raw = match[2];
4623
+ if (name === void 0 || raw === void 0) continue;
4624
+ const hex = parseCssColor(raw);
4625
+ if (hex !== void 0) found[name] = hex;
4626
+ }
4627
+ return mapBrandColors(found);
4628
+ }
4629
+ function themeBlocks(css) {
4630
+ const blocks = [];
4631
+ const re = /@theme(?:\s+inline)?\s*\{/g;
4632
+ let match;
4633
+ while ((match = re.exec(css)) !== null) {
4634
+ const open = css.indexOf("{", match.index);
4635
+ if (open === -1) break;
4636
+ const close = matchingBrace(css, open);
4637
+ if (close === -1) break;
4638
+ blocks.push(css.slice(open + 1, close));
4639
+ }
4640
+ return blocks;
4641
+ }
4642
+ function matchingBrace(source, openIndex) {
4643
+ let depth = 0;
4644
+ for (let i = openIndex; i < source.length; i += 1) {
4645
+ const ch = source[i];
4646
+ if (ch === "{") depth += 1;
4647
+ else if (ch === "}") {
4648
+ depth -= 1;
4649
+ if (depth === 0) return i;
4650
+ }
4651
+ }
4652
+ return -1;
4653
+ }
4654
+ function collectCustomProperties(css) {
4655
+ const props = /* @__PURE__ */ new Map();
4656
+ CUSTOM_PROP.lastIndex = 0;
4657
+ let match;
4658
+ while ((match = CUSTOM_PROP.exec(css)) !== null) {
4659
+ const name = match[1];
4660
+ const value = match[2];
4661
+ if (name !== void 0 && value !== void 0) props.set(`--${name}`, value.trim());
4662
+ }
4663
+ return props;
4664
+ }
4665
+ function resolveToHex(raw, custom, depth) {
4666
+ if (depth > 3) return;
4667
+ const direct = parseCssColor(raw);
4668
+ if (direct !== void 0) return direct;
4669
+ const varMatch = /^var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,[^)]+)?\)$/.exec(raw);
4670
+ if (varMatch === null) return;
4671
+ const ref = varMatch[1];
4672
+ if (ref === void 0) return;
4673
+ const next = custom.get(ref);
4674
+ if (next === void 0) return;
4675
+ return resolveToHex(next, custom, depth + 1);
4676
+ }
4677
+ function shouldSkip(name) {
4678
+ const root = name.split("-")[0];
4679
+ return root !== void 0 && SKIP_PREFIXES.has(root);
4680
+ }
4681
+ function mapBrandColors(found) {
4682
+ const out = {};
4683
+ const background = found.background ?? found.bg;
4684
+ if (background !== void 0) out.background = background;
4685
+ const foreground = found.foreground ?? found.fg;
4686
+ if (foreground !== void 0) out.foreground = foreground;
4687
+ const muted = found["muted-foreground"] ?? found.muted;
4688
+ if (muted !== void 0) out.muted = muted;
4689
+ const primary = found.primary ?? found.brand;
4690
+ if (primary !== void 0) out.primary = primary;
4691
+ const accent = found.accent ?? found.secondary;
4692
+ if (accent !== void 0) out.accent = accent;
4693
+ return out;
4694
+ }
4695
+
4696
+ //#endregion
4697
+ //#region src/brand/detect-colors.ts
4698
+ /** Path helpers, kept local so this module never imports `node:path`. */
4699
+ function basename$1(path) {
4700
+ return path.split("/").pop() ?? path;
4701
+ }
4702
+ function relative(from, path) {
4703
+ const prefix = from.endsWith("/") ? from : `${from}/`;
4704
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
4705
+ }
4706
+ async function detectColorsFrom(files, cwd, logoPath, sources, notes, logoColor = () => Promise.resolve(void 0)) {
4707
+ const merged = {};
4708
+ const paths = await files.list();
4709
+ const cssFiles = paths.filter((file) => file.endsWith(".css"));
4710
+ const cssContents = await Promise.all(cssFiles.map(async (file) => ({
4711
+ file,
4712
+ css: await files.read(file)
4713
+ })));
4714
+ for (const { file, css } of cssContents) {
4715
+ if (css === void 0) continue;
4716
+ const fromTheme = extractTailwindV4Colors(css);
4717
+ const fromRoot = extractCssRootColors(css);
4718
+ const rel = relative(cwd, file);
4719
+ applyColors(merged, fromTheme, sources, `Tailwind v4 @theme in ${rel}`);
4720
+ applyColors(merged, fromRoot, sources, `:root variables in ${rel}`);
4721
+ }
4722
+ const configFiles = paths.filter((file) => basename$1(file).startsWith("tailwind.config."));
4723
+ const configContents = await Promise.all(configFiles.map(async (file) => ({
4724
+ file,
4725
+ source: await files.read(file)
4726
+ })));
4727
+ for (const { file, source } of configContents) {
4728
+ if (source === void 0) continue;
4729
+ applyColors(merged, extractTailwindV3Colors(source), sources, `Tailwind config ${relative(cwd, file)}`);
4730
+ }
4731
+ const tokenFile = paths.find((file) => file.endsWith(".tokens.json"));
4732
+ if (tokenFile !== void 0) {
4733
+ const raw = await files.read(tokenFile);
4734
+ if (raw !== void 0) try {
4735
+ const parsed = JSON.parse(raw);
4736
+ applyColors(merged, extractDtcgColors(parsed), sources, relative(cwd, tokenFile));
4737
+ } catch (error) {
4738
+ if (error instanceof SyntaxError) notes.push(`Could not parse design tokens file ${relative(cwd, tokenFile)}.`);
4739
+ else throw error;
4740
+ }
4741
+ }
4742
+ if (merged.primary === void 0 && logoPath !== void 0) {
4743
+ const fromPng = logoPath.endsWith(".png") ? await logoColor(files, logoPath) : void 0;
4744
+ if (fromPng !== void 0) {
4745
+ merged.primary = fromPng;
4746
+ sources.push({
4747
+ field: "colors.primary",
4748
+ source: `dominant color in ${logoPath}`
4749
+ });
4750
+ }
4751
+ const svg = logoPath.endsWith(".svg") ? await files.read(logoPath) : void 0;
4752
+ if (svg !== void 0) {
4753
+ const fromLogo = firstNonNeutralSvgColor(svg);
4754
+ if (fromLogo !== void 0) {
4755
+ merged.primary = fromLogo;
4756
+ sources.push({
4757
+ field: "colors.primary",
4758
+ source: `SVG fill in ${logoPath}`
4759
+ });
4760
+ }
4761
+ }
4762
+ }
4763
+ const fellBack = [
4764
+ "background",
4765
+ "foreground",
4766
+ "muted",
4767
+ "primary",
4768
+ "accent"
4769
+ ].filter((field) => merged[field] === void 0);
4770
+ 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.`);
4771
+ return {
4772
+ background: merged.background ?? DEFAULT_BRAND_COLORS.background,
4773
+ foreground: merged.foreground ?? DEFAULT_BRAND_COLORS.foreground,
4774
+ muted: merged.muted ?? DEFAULT_BRAND_COLORS.muted,
4775
+ primary: merged.primary ?? DEFAULT_BRAND_COLORS.primary,
4776
+ accent: merged.accent ?? DEFAULT_BRAND_COLORS.accent
4777
+ };
4778
+ }
4779
+ function applyColors(target, incoming, sources, source) {
4780
+ for (const key of [
4781
+ "background",
4782
+ "foreground",
4783
+ "muted",
4784
+ "primary",
4785
+ "accent"
4786
+ ]) {
4787
+ const value = incoming[key];
4788
+ if (value !== void 0 && target[key] === void 0) {
4789
+ target[key] = value;
4790
+ sources.push({
4791
+ field: `colors.${key}`,
4792
+ source
4793
+ });
4794
+ }
4795
+ }
4796
+ }
4797
+ function applyReadableMuted(colors, sources, notes) {
4798
+ if (colors.muted === void 0 || readableOnBackground(colors.background, colors.muted)) return;
4799
+ colors.muted = DEFAULT_BRAND_COLORS.muted;
4800
+ notes.push(`Muted text color failed WCAG AA large-text contrast (3:1) against background, so it was set to ${DEFAULT_BRAND_COLORS.muted}.`);
4801
+ upsertSource(sources, "colors.muted", "contrast adjustment");
4802
+ }
4803
+ function applyReadableAccent(colors, sources, notes) {
4804
+ if (colors.accent === void 0 || readableOnBackground(colors.background, colors.accent)) return;
4805
+ colors.accent = colors.primary ?? DEFAULT_BRAND_COLORS.primary;
4806
+ notes.push("Accent failed contrast against background, so it uses the primary color.");
4807
+ upsertSource(sources, "colors.accent", "contrast adjustment");
4808
+ }
4809
+ function upsertSource(sources, field, source) {
4810
+ const existing = sources.find((entry) => entry.field === field);
4811
+ if (existing !== void 0) {
4812
+ existing.source = source;
4813
+ return;
4814
+ }
4815
+ sources.push({
4816
+ field,
4817
+ source
4818
+ });
4819
+ }
4820
+ function firstNonNeutralSvgColor(svg) {
4821
+ const re = /(?:fill|stroke)="([^"]+)"/g;
4822
+ let match;
4823
+ while ((match = re.exec(svg)) !== null) {
4824
+ const raw = match[1];
4825
+ if (raw === void 0 || raw === "none" || raw === "currentColor") continue;
4826
+ const hex = parseCssColor(raw);
4827
+ if (hex !== void 0 && !isNeutralHex(hex)) return hex;
4828
+ }
4829
+ }
4830
+
4555
4831
  //#endregion
4556
4832
  //#region src/brand/logo.ts
4557
4833
  const DIRECTORIES = [
@@ -4797,131 +5073,12 @@ function dominantNonNeutralColor(png) {
4797
5073
  return `#${channel(best.r)}${channel(best.g)}${channel(best.b)}`;
4798
5074
  }
4799
5075
 
4800
- //#endregion
4801
- //#region src/brand/tailwind.ts
4802
- const COLOR_PROP = /--color-([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4803
- const CUSTOM_PROP = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
4804
- const SKIP_PREFIXES = /* @__PURE__ */ new Set([
4805
- "chart",
4806
- "sidebar",
4807
- "ring",
4808
- "border",
4809
- "input",
4810
- "destructive"
4811
- ]);
4812
- function extractTailwindV4Colors(css) {
4813
- const blocks = themeBlocks(css);
4814
- const custom = collectCustomProperties(css);
4815
- const fromTheme = {};
4816
- for (const block of blocks) {
4817
- COLOR_PROP.lastIndex = 0;
4818
- let match;
4819
- while ((match = COLOR_PROP.exec(block)) !== null) {
4820
- const name = match[1];
4821
- const raw = match[2];
4822
- if (name === void 0 || raw === void 0 || shouldSkip(name)) continue;
4823
- fromTheme[name] = raw.trim();
4824
- }
4825
- }
4826
- const resolved = {};
4827
- for (const [name, raw] of Object.entries(fromTheme)) {
4828
- const hex = resolveToHex(raw, custom, 0);
4829
- if (hex !== void 0) resolved[name] = hex;
4830
- }
4831
- return mapBrandColors(resolved);
4832
- }
4833
- function extractTailwindV3Colors(source) {
4834
- const found = {};
4835
- const re = /\b(primary|brand|accent|background|foreground|muted|secondary)\b\s*:\s*["'`]([^"'`]+)["'`]/g;
4836
- let match;
4837
- while ((match = re.exec(source)) !== null) {
4838
- const name = match[1];
4839
- const raw = match[2];
4840
- if (name === void 0 || raw === void 0) continue;
4841
- const hex = parseCssColor(raw);
4842
- if (hex !== void 0) found[name] = hex;
4843
- }
4844
- return mapBrandColors(found);
4845
- }
4846
- function themeBlocks(css) {
4847
- const blocks = [];
4848
- const re = /@theme(?:\s+inline)?\s*\{/g;
4849
- let match;
4850
- while ((match = re.exec(css)) !== null) {
4851
- const open = css.indexOf("{", match.index);
4852
- if (open === -1) break;
4853
- const close = matchingBrace(css, open);
4854
- if (close === -1) break;
4855
- blocks.push(css.slice(open + 1, close));
4856
- }
4857
- return blocks;
4858
- }
4859
- function matchingBrace(source, openIndex) {
4860
- let depth = 0;
4861
- for (let i = openIndex; i < source.length; i += 1) {
4862
- const ch = source[i];
4863
- if (ch === "{") depth += 1;
4864
- else if (ch === "}") {
4865
- depth -= 1;
4866
- if (depth === 0) return i;
4867
- }
4868
- }
4869
- return -1;
4870
- }
4871
- function collectCustomProperties(css) {
4872
- const props = /* @__PURE__ */ new Map();
4873
- CUSTOM_PROP.lastIndex = 0;
4874
- let match;
4875
- while ((match = CUSTOM_PROP.exec(css)) !== null) {
4876
- const name = match[1];
4877
- const value = match[2];
4878
- if (name !== void 0 && value !== void 0) props.set(`--${name}`, value.trim());
4879
- }
4880
- return props;
4881
- }
4882
- function resolveToHex(raw, custom, depth) {
4883
- if (depth > 3) return;
4884
- const direct = parseCssColor(raw);
4885
- if (direct !== void 0) return direct;
4886
- const varMatch = /^var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,[^)]+)?\)$/.exec(raw);
4887
- if (varMatch === null) return;
4888
- const ref = varMatch[1];
4889
- if (ref === void 0) return;
4890
- const next = custom.get(ref);
4891
- if (next === void 0) return;
4892
- return resolveToHex(next, custom, depth + 1);
4893
- }
4894
- function shouldSkip(name) {
4895
- const root = name.split("-")[0];
4896
- return root !== void 0 && SKIP_PREFIXES.has(root);
4897
- }
4898
- function mapBrandColors(found) {
4899
- const out = {};
4900
- const background = found.background ?? found.bg;
4901
- if (background !== void 0) out.background = background;
4902
- const foreground = found.foreground ?? found.fg;
4903
- if (foreground !== void 0) out.foreground = foreground;
4904
- const muted = found["muted-foreground"] ?? found.muted;
4905
- if (muted !== void 0) out.muted = muted;
4906
- const primary = found.primary ?? found.brand;
4907
- if (primary !== void 0) out.primary = primary;
4908
- const accent = found.accent ?? found.secondary;
4909
- if (accent !== void 0) out.accent = accent;
4910
- return out;
4911
- }
4912
-
4913
5076
  //#endregion
4914
5077
  //#region src/brand/detect.ts
4915
- const execFileAsync = promisify(execFile);
4916
- const SKIP_DIRS = /* @__PURE__ */ new Set([
4917
- "node_modules",
4918
- ".git",
4919
- "dist",
4920
- "coverage",
4921
- ".shipseal",
4922
- ".next",
4923
- "build"
4924
- ]);
5078
+ /** Path helpers, kept local so this module stays free of `node:path` and reaches the browser. */
5079
+ function basename(path) {
5080
+ return path.split("/").pop() ?? path;
5081
+ }
4925
5082
  const packageSchema = z.object({
4926
5083
  private: z.boolean().optional(),
4927
5084
  name: z.string().optional(),
@@ -4929,20 +5086,24 @@ const packageSchema = z.object({
4929
5086
  homepage: z.string().optional(),
4930
5087
  repository: z.union([z.string(), z.object({ url: z.string().optional() })]).optional()
4931
5088
  });
4932
- async function detectBrand(cwd) {
5089
+ /**
5090
+ * Detection over any source. `root` is only used to make reported paths readable, so the
5091
+ * GitHub implementation can pass the repository slug.
5092
+ */
5093
+ async function detectBrandFrom(files, cwd = ".") {
4933
5094
  const sources = [];
4934
5095
  const notes = [];
4935
- const pkg = await readPackage(cwd);
4936
- const readme = await readMaybe(join(cwd, "README.md"));
5096
+ const pkg = await readPackage(files);
5097
+ const readme = await files.read("README.md");
4937
5098
  const name = detectName(pkg, readme, cwd, sources);
4938
5099
  const tagline = detectTagline(pkg, readme, sources);
4939
- const url = await detectUrl(pkg, cwd, sources);
5100
+ const url = await detectUrl(files, pkg, cwd, sources);
4940
5101
  const logo = findLogoPair(cwd);
4941
5102
  if (logo !== void 0) sources.push({
4942
5103
  field: "logo",
4943
5104
  source: logo.light
4944
5105
  });
4945
- const colors = await detectColors(cwd, logo?.light, sources, notes);
5106
+ const colors = await detectColorsFrom(files, cwd, logo?.light, sources, notes, dominantLogoColor);
4946
5107
  const contrast = ensureForegroundContrast(colors.background, colors.foreground);
4947
5108
  if (contrast.adjusted) {
4948
5109
  colors.foreground = contrast.foreground;
@@ -5032,7 +5193,7 @@ function detectTagline(pkg, readme, sources) {
5032
5193
  }
5033
5194
  }
5034
5195
  }
5035
- async function detectUrl(pkg, cwd, sources) {
5196
+ async function detectUrl(files, pkg, cwd, sources) {
5036
5197
  if (pkg?.homepage !== void 0 && pkg.homepage.length > 0) {
5037
5198
  sources.push({
5038
5199
  field: "url",
@@ -5048,7 +5209,7 @@ async function detectUrl(pkg, cwd, sources) {
5048
5209
  });
5049
5210
  return repo;
5050
5211
  }
5051
- const remote = await gitRemote(cwd);
5212
+ const remote = await files.gitRemote?.();
5052
5213
  if (remote !== void 0) {
5053
5214
  sources.push({
5054
5215
  field: "url",
@@ -5057,122 +5218,8 @@ async function detectUrl(pkg, cwd, sources) {
5057
5218
  return remote;
5058
5219
  }
5059
5220
  }
5060
- async function detectColors(cwd, logoPath, sources, notes) {
5061
- const merged = {};
5062
- const files = await listFiles(cwd, 4);
5063
- const cssFiles = files.filter((file) => file.endsWith(".css"));
5064
- const cssContents = await Promise.all(cssFiles.map(async (file) => ({
5065
- file,
5066
- css: await readMaybe(file)
5067
- })));
5068
- for (const { file, css } of cssContents) {
5069
- if (css === void 0) continue;
5070
- const fromTheme = extractTailwindV4Colors(css);
5071
- const fromRoot = extractCssRootColors(css);
5072
- const rel = relative(cwd, file);
5073
- applyColors(merged, fromTheme, sources, `Tailwind v4 @theme in ${rel}`);
5074
- applyColors(merged, fromRoot, sources, `:root variables in ${rel}`);
5075
- }
5076
- const configFiles = files.filter((file) => basename(file).startsWith("tailwind.config."));
5077
- const configContents = await Promise.all(configFiles.map(async (file) => ({
5078
- file,
5079
- source: await readMaybe(file)
5080
- })));
5081
- for (const { file, source } of configContents) {
5082
- if (source === void 0) continue;
5083
- applyColors(merged, extractTailwindV3Colors(source), sources, `Tailwind config ${relative(cwd, file)}`);
5084
- }
5085
- const tokenFile = files.find((file) => file.endsWith(".tokens.json"));
5086
- if (tokenFile !== void 0) {
5087
- const raw = await readMaybe(tokenFile);
5088
- if (raw !== void 0) try {
5089
- const parsed = JSON.parse(raw);
5090
- applyColors(merged, extractDtcgColors(parsed), sources, relative(cwd, tokenFile));
5091
- } catch (error) {
5092
- if (error instanceof SyntaxError) notes.push(`Could not parse design tokens file ${relative(cwd, tokenFile)}.`);
5093
- else throw error;
5094
- }
5095
- }
5096
- if (merged.primary === void 0 && logoPath !== void 0) {
5097
- const fromPng = logoPath.endsWith(".png") ? await dominantLogoColor(join(cwd, logoPath)) : void 0;
5098
- if (fromPng !== void 0) {
5099
- merged.primary = fromPng;
5100
- sources.push({
5101
- field: "colors.primary",
5102
- source: `dominant color in ${logoPath}`
5103
- });
5104
- }
5105
- const svg = logoPath.endsWith(".svg") ? await readMaybe(join(cwd, logoPath)) : void 0;
5106
- if (svg !== void 0) {
5107
- const fromLogo = firstNonNeutralSvgColor(svg);
5108
- if (fromLogo !== void 0) {
5109
- merged.primary = fromLogo;
5110
- sources.push({
5111
- field: "colors.primary",
5112
- source: `SVG fill in ${logoPath}`
5113
- });
5114
- }
5115
- }
5116
- }
5117
- const fellBack = [
5118
- "background",
5119
- "foreground",
5120
- "muted",
5121
- "primary",
5122
- "accent"
5123
- ].filter((field) => merged[field] === void 0);
5124
- 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.`);
5125
- return {
5126
- background: merged.background ?? DEFAULT_BRAND_COLORS.background,
5127
- foreground: merged.foreground ?? DEFAULT_BRAND_COLORS.foreground,
5128
- muted: merged.muted ?? DEFAULT_BRAND_COLORS.muted,
5129
- primary: merged.primary ?? DEFAULT_BRAND_COLORS.primary,
5130
- accent: merged.accent ?? DEFAULT_BRAND_COLORS.accent
5131
- };
5132
- }
5133
- function applyReadableMuted(colors, sources, notes) {
5134
- if (colors.muted === void 0 || readableOnBackground(colors.background, colors.muted)) return;
5135
- colors.muted = DEFAULT_BRAND_COLORS.muted;
5136
- notes.push(`Muted text color failed WCAG AA large-text contrast (3:1) against background, so it was set to ${DEFAULT_BRAND_COLORS.muted}.`);
5137
- upsertSource(sources, "colors.muted", "contrast adjustment");
5138
- }
5139
- function applyReadableAccent(colors, sources, notes) {
5140
- if (colors.accent === void 0 || readableOnBackground(colors.background, colors.accent)) return;
5141
- colors.accent = colors.primary ?? DEFAULT_BRAND_COLORS.primary;
5142
- notes.push("Accent failed contrast against background, so it uses the primary color.");
5143
- upsertSource(sources, "colors.accent", "contrast adjustment");
5144
- }
5145
- function applyColors(target, incoming, sources, source) {
5146
- for (const key of [
5147
- "background",
5148
- "foreground",
5149
- "muted",
5150
- "primary",
5151
- "accent"
5152
- ]) {
5153
- const value = incoming[key];
5154
- if (value !== void 0 && target[key] === void 0) {
5155
- target[key] = value;
5156
- sources.push({
5157
- field: `colors.${key}`,
5158
- source
5159
- });
5160
- }
5161
- }
5162
- }
5163
- function upsertSource(sources, field, source) {
5164
- const existing = sources.find((entry) => entry.field === field);
5165
- if (existing !== void 0) {
5166
- existing.source = source;
5167
- return;
5168
- }
5169
- sources.push({
5170
- field,
5171
- source
5172
- });
5173
- }
5174
- async function readPackage(cwd) {
5175
- const raw = await readMaybe(join(cwd, "package.json"));
5221
+ async function readPackage(files) {
5222
+ const raw = await files.read("package.json");
5176
5223
  if (raw === void 0) return;
5177
5224
  try {
5178
5225
  const parsed = JSON.parse(raw);
@@ -5183,18 +5230,69 @@ async function readPackage(cwd) {
5183
5230
  throw error;
5184
5231
  }
5185
5232
  }
5186
- async function gitRemote(cwd) {
5187
- try {
5188
- const { stdout } = await execFileAsync("git", [
5189
- "remote",
5190
- "get-url",
5191
- "origin"
5192
- ], { cwd });
5193
- return normalizeGitUrl(stdout.trim());
5194
- } catch (error) {
5195
- if (error instanceof Error) return;
5196
- throw error;
5197
- }
5233
+ function stripScope(name) {
5234
+ const parts = name.split("/");
5235
+ return parts[parts.length - 1] ?? name;
5236
+ }
5237
+ function repositoryUrl(repository) {
5238
+ if (typeof repository === "string") return normalizeGitUrl(repository);
5239
+ if (repository?.url !== void 0) return normalizeGitUrl(repository.url);
5240
+ }
5241
+ function normalizeGitUrl(url) {
5242
+ const ssh = /^git@([^:]+):(.+)$/.exec(url);
5243
+ if (ssh !== null && ssh[1] !== void 0 && ssh[2] !== void 0) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
5244
+ return url.replace(/^git\+/, "").replace(/\.git$/, "");
5245
+ }
5246
+ /**
5247
+ * Dominant non-neutral color of a PNG logo, or undefined when the file cannot be read or
5248
+ * carries no color. Detection reports the color as not found rather than falling back to a
5249
+ * built-in default that would be presented to the user as "your brand".
5250
+ */
5251
+ async function dominantLogoColor(files, path) {
5252
+ const buffer = await files.readBinary(path);
5253
+ if (buffer === void 0) return;
5254
+ const png = decodePng(Buffer.from(buffer));
5255
+ if (png === void 0) return;
5256
+ return dominantNonNeutralColor(png);
5257
+ }
5258
+
5259
+ //#endregion
5260
+ //#region src/brand/detect-node.ts
5261
+ const execFileAsync = promisify(execFile);
5262
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
5263
+ "node_modules",
5264
+ ".git",
5265
+ "dist",
5266
+ "build",
5267
+ "coverage",
5268
+ ".next",
5269
+ ".astro",
5270
+ "out",
5271
+ "vendor"
5272
+ ]);
5273
+ async function detectBrand(cwd) {
5274
+ return detectBrandFrom(diskFiles(cwd), cwd);
5275
+ }
5276
+ /** The disk implementation of the port, used by the CLI. Paths stay absolute here. */
5277
+ function diskFiles(cwd, maxDepth = 4) {
5278
+ return {
5279
+ list: () => listFiles(cwd, maxDepth),
5280
+ read: async (path) => {
5281
+ try {
5282
+ return await readFile(isAbsolute(path) ? path : join(cwd, path), "utf8");
5283
+ } catch {
5284
+ return;
5285
+ }
5286
+ },
5287
+ gitRemote: () => gitRemote(cwd),
5288
+ readBinary: async (path) => {
5289
+ try {
5290
+ return await readFile(isAbsolute(path) ? path : join(cwd, path));
5291
+ } catch {
5292
+ return;
5293
+ }
5294
+ }
5295
+ };
5198
5296
  }
5199
5297
  async function listFiles(root, maxDepth) {
5200
5298
  const out = [];
@@ -5220,50 +5318,17 @@ async function listFiles(root, maxDepth) {
5220
5318
  await walk(root, maxDepth);
5221
5319
  return out;
5222
5320
  }
5223
- async function readMaybe(path) {
5224
- try {
5225
- return await readFile(path, "utf8");
5226
- } catch {
5227
- return;
5228
- }
5229
- }
5230
- function stripScope(name) {
5231
- const parts = name.split("/");
5232
- return parts[parts.length - 1] ?? name;
5233
- }
5234
- function repositoryUrl(repository) {
5235
- if (typeof repository === "string") return normalizeGitUrl(repository);
5236
- if (repository?.url !== void 0) return normalizeGitUrl(repository.url);
5237
- }
5238
- function normalizeGitUrl(url) {
5239
- const ssh = /^git@([^:]+):(.+)$/.exec(url);
5240
- if (ssh !== null && ssh[1] !== void 0 && ssh[2] !== void 0) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
5241
- return url.replace(/^git\+/, "").replace(/\.git$/, "");
5242
- }
5243
- /**
5244
- * Dominant non-neutral color of a PNG logo, or undefined when the file cannot be read or
5245
- * carries no color. Detection reports the color as not found rather than falling back to a
5246
- * built-in default that would be presented to the user as "your brand".
5247
- */
5248
- async function dominantLogoColor(path) {
5249
- let buffer;
5321
+ async function gitRemote(cwd) {
5250
5322
  try {
5251
- buffer = await readFile(path);
5252
- } catch {
5253
- return;
5254
- }
5255
- const png = decodePng(buffer);
5256
- if (png === void 0) return;
5257
- return dominantNonNeutralColor(png);
5258
- }
5259
- function firstNonNeutralSvgColor(svg) {
5260
- const re = /(?:fill|stroke)="([^"]+)"/g;
5261
- let match;
5262
- while ((match = re.exec(svg)) !== null) {
5263
- const raw = match[1];
5264
- if (raw === void 0 || raw === "none" || raw === "currentColor") continue;
5265
- const hex = parseCssColor(raw);
5266
- if (hex !== void 0 && !isNeutralHex(hex)) return hex;
5323
+ const { stdout } = await execFileAsync("git", [
5324
+ "remote",
5325
+ "get-url",
5326
+ "origin"
5327
+ ], { cwd });
5328
+ return normalizeGitUrl(stdout.trim());
5329
+ } catch (error) {
5330
+ if (error instanceof Error) return;
5331
+ throw error;
5267
5332
  }
5268
5333
  }
5269
5334