shipseal 0.1.1 → 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,
@@ -1832,7 +1834,7 @@ const storyPage = {
1832
1834
  box: { width: 220 }
1833
1835
  },
1834
1836
  title: {
1835
- maxLines: tall ? 3 : 2,
1837
+ maxLines: tall ? 4 : 2,
1836
1838
  maxFontSize: tall ? 64 : 48,
1837
1839
  minFontSize: tall ? 36 : 30,
1838
1840
  step: 2,
@@ -2522,6 +2524,14 @@ function stripBadges(text) {
2522
2524
  return text.replace(/\[!\[[^\]]*]\([^)]+\)]\([^)]+\)/g, "").replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/<img[^>]*>/gi, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
2523
2525
  }
2524
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
+
2525
2535
  //#endregion
2526
2536
  //#region src/sources/changelog.ts
2527
2537
  async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
@@ -2623,7 +2633,7 @@ function findVersionSection(markdown, version) {
2623
2633
  }
2624
2634
  }
2625
2635
  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(/\.$/, "");
2636
+ return cleanReleaseLine(item);
2627
2637
  }
2628
2638
  function headingIncludesVersion(heading, version) {
2629
2639
  const unwrapped = heading.replace(/[[\]]/g, " ");
@@ -4552,6 +4562,254 @@ function isRecord(value) {
4552
4562
  return typeof value === "object" && value !== null && !Array.isArray(value);
4553
4563
  }
4554
4564
 
4565
+ //#endregion
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
+ }
4590
+ }
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;
4595
+ }
4596
+ return mapBrandColors(resolved);
4597
+ }
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;
4608
+ }
4609
+ return mapBrandColors(found);
4610
+ }
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));
4621
+ }
4622
+ return blocks;
4623
+ }
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
+
4555
4813
  //#endregion
4556
4814
  //#region src/brand/logo.ts
4557
4815
  const DIRECTORIES = [
@@ -4797,131 +5055,12 @@ function dominantNonNeutralColor(png) {
4797
5055
  return `#${channel(best.r)}${channel(best.g)}${channel(best.b)}`;
4798
5056
  }
4799
5057
 
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
5058
  //#endregion
4914
5059
  //#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
- ]);
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
+ }
4925
5064
  const packageSchema = z.object({
4926
5065
  private: z.boolean().optional(),
4927
5066
  name: z.string().optional(),
@@ -4929,20 +5068,24 @@ const packageSchema = z.object({
4929
5068
  homepage: z.string().optional(),
4930
5069
  repository: z.union([z.string(), z.object({ url: z.string().optional() })]).optional()
4931
5070
  });
4932
- 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 = ".") {
4933
5076
  const sources = [];
4934
5077
  const notes = [];
4935
- const pkg = await readPackage(cwd);
4936
- const readme = await readMaybe(join(cwd, "README.md"));
5078
+ const pkg = await readPackage(files);
5079
+ const readme = await files.read("README.md");
4937
5080
  const name = detectName(pkg, readme, cwd, sources);
4938
5081
  const tagline = detectTagline(pkg, readme, sources);
4939
- const url = await detectUrl(pkg, cwd, sources);
5082
+ const url = await detectUrl(files, pkg, cwd, sources);
4940
5083
  const logo = findLogoPair(cwd);
4941
5084
  if (logo !== void 0) sources.push({
4942
5085
  field: "logo",
4943
5086
  source: logo.light
4944
5087
  });
4945
- const colors = await detectColors(cwd, logo?.light, sources, notes);
5088
+ const colors = await detectColorsFrom(files, cwd, logo?.light, sources, notes, dominantLogoColor);
4946
5089
  const contrast = ensureForegroundContrast(colors.background, colors.foreground);
4947
5090
  if (contrast.adjusted) {
4948
5091
  colors.foreground = contrast.foreground;
@@ -5032,7 +5175,7 @@ function detectTagline(pkg, readme, sources) {
5032
5175
  }
5033
5176
  }
5034
5177
  }
5035
- async function detectUrl(pkg, cwd, sources) {
5178
+ async function detectUrl(files, pkg, cwd, sources) {
5036
5179
  if (pkg?.homepage !== void 0 && pkg.homepage.length > 0) {
5037
5180
  sources.push({
5038
5181
  field: "url",
@@ -5048,7 +5191,7 @@ async function detectUrl(pkg, cwd, sources) {
5048
5191
  });
5049
5192
  return repo;
5050
5193
  }
5051
- const remote = await gitRemote(cwd);
5194
+ const remote = await files.gitRemote?.();
5052
5195
  if (remote !== void 0) {
5053
5196
  sources.push({
5054
5197
  field: "url",
@@ -5057,122 +5200,8 @@ async function detectUrl(pkg, cwd, sources) {
5057
5200
  return remote;
5058
5201
  }
5059
5202
  }
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"));
5203
+ async function readPackage(files) {
5204
+ const raw = await files.read("package.json");
5176
5205
  if (raw === void 0) return;
5177
5206
  try {
5178
5207
  const parsed = JSON.parse(raw);
@@ -5183,18 +5212,69 @@ async function readPackage(cwd) {
5183
5212
  throw error;
5184
5213
  }
5185
5214
  }
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
- }
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
+ };
5198
5278
  }
5199
5279
  async function listFiles(root, maxDepth) {
5200
5280
  const out = [];
@@ -5220,50 +5300,17 @@ async function listFiles(root, maxDepth) {
5220
5300
  await walk(root, maxDepth);
5221
5301
  return out;
5222
5302
  }
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;
5303
+ async function gitRemote(cwd) {
5250
5304
  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;
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;
5267
5314
  }
5268
5315
  }
5269
5316