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/README.md +1 -1
- package/dist/cli.js +351 -304
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
4916
|
-
|
|
4917
|
-
"
|
|
4918
|
-
|
|
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
|
-
|
|
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(
|
|
4936
|
-
const readme = await
|
|
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
|
|
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(
|
|
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
|
|
5061
|
-
const
|
|
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
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
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
|
|
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
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
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
|
|