shipseal 0.0.1 → 0.0.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/README.md +5 -2
- package/dist/cli.js +4934 -8
- package/dist/cli.js.map +1 -1
- package/package.json +9 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,4939 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { cac } from "cac";
|
|
5
|
+
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { Renderer } from "takumi-js/node";
|
|
12
|
+
import { container, text } from "takumi-js/helpers";
|
|
13
|
+
import { fromJsx } from "takumi-js/helpers/jsx";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
15
|
+
import { stdin, stdout } from "node:process";
|
|
16
|
+
import { inflateSync } from "node:zlib";
|
|
17
|
+
|
|
18
|
+
//#region src/core/errors.ts
|
|
19
|
+
var ShipsealError = class extends Error {
|
|
20
|
+
code;
|
|
21
|
+
fix;
|
|
22
|
+
constructor(code, message, fix, options) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.name = "ShipsealError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.fix = fix;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function formatError(error) {
|
|
30
|
+
return `${error.message}\nFix: ${error.fix}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/brand/schema.ts
|
|
35
|
+
const hexColorSchema = z.string().regex(/^#([\da-f]{6})$/i, "Expected a 6-digit hex color like #0b0b0c");
|
|
36
|
+
const fontFaceSchema = z.object({
|
|
37
|
+
family: z.string().min(1),
|
|
38
|
+
weight: z.number().int().min(100).max(900),
|
|
39
|
+
file: z.string().min(1).optional()
|
|
40
|
+
});
|
|
41
|
+
const brandSchema = z.object({
|
|
42
|
+
$schema: z.string().optional(),
|
|
43
|
+
version: z.literal(1),
|
|
44
|
+
name: z.string().min(1),
|
|
45
|
+
tagline: z.string().optional(),
|
|
46
|
+
url: z.string().optional(),
|
|
47
|
+
logo: z.object({
|
|
48
|
+
light: z.string().min(1),
|
|
49
|
+
dark: z.string().min(1).optional()
|
|
50
|
+
}).optional(),
|
|
51
|
+
colors: z.object({
|
|
52
|
+
background: hexColorSchema,
|
|
53
|
+
foreground: hexColorSchema,
|
|
54
|
+
muted: hexColorSchema.optional(),
|
|
55
|
+
primary: hexColorSchema.optional(),
|
|
56
|
+
accent: hexColorSchema.optional()
|
|
57
|
+
}),
|
|
58
|
+
fonts: z.object({
|
|
59
|
+
heading: fontFaceSchema,
|
|
60
|
+
body: fontFaceSchema,
|
|
61
|
+
mono: fontFaceSchema
|
|
62
|
+
}),
|
|
63
|
+
radius: z.number().nonnegative(),
|
|
64
|
+
theme: z.enum(["dark", "light"]),
|
|
65
|
+
style: z.literal("minimal"),
|
|
66
|
+
tokens: z.string().nullable()
|
|
67
|
+
});
|
|
68
|
+
const DEFAULT_BRAND_COLORS = {
|
|
69
|
+
background: "#0b0b0c",
|
|
70
|
+
foreground: "#fafafa",
|
|
71
|
+
muted: "#a1a1aa",
|
|
72
|
+
primary: "#ff4d4d",
|
|
73
|
+
accent: "#fbbf24"
|
|
74
|
+
};
|
|
75
|
+
const DEFAULT_BRAND_FONTS = {
|
|
76
|
+
heading: {
|
|
77
|
+
family: "Geist",
|
|
78
|
+
weight: 700
|
|
79
|
+
},
|
|
80
|
+
body: {
|
|
81
|
+
family: "Geist",
|
|
82
|
+
weight: 400
|
|
83
|
+
},
|
|
84
|
+
mono: {
|
|
85
|
+
family: "Geist Mono",
|
|
86
|
+
weight: 400
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/brand/load.ts
|
|
92
|
+
async function loadBrand(cwd) {
|
|
93
|
+
const path = join(cwd, ".shipseal", "brand.json");
|
|
94
|
+
try {
|
|
95
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
96
|
+
return brandSchema.parse(raw);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new ShipsealError("brand.missing", "No .shipseal/brand.json found.", "Run shipseal init to detect brand colors and write brand.json.");
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/formats.ts
|
|
105
|
+
const FORMAT_IDS = [
|
|
106
|
+
"og",
|
|
107
|
+
"github-social",
|
|
108
|
+
"x",
|
|
109
|
+
"linkedin",
|
|
110
|
+
"square",
|
|
111
|
+
"portrait",
|
|
112
|
+
"producthunt",
|
|
113
|
+
"readme-banner"
|
|
114
|
+
];
|
|
115
|
+
const LANDSCAPE_SAFE_ZONE = 64;
|
|
116
|
+
const FORMATS = {
|
|
117
|
+
og: {
|
|
118
|
+
id: "og",
|
|
119
|
+
width: 1200,
|
|
120
|
+
height: 630,
|
|
121
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
122
|
+
},
|
|
123
|
+
"github-social": {
|
|
124
|
+
id: "github-social",
|
|
125
|
+
width: 1280,
|
|
126
|
+
height: 640,
|
|
127
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
128
|
+
},
|
|
129
|
+
x: {
|
|
130
|
+
id: "x",
|
|
131
|
+
width: 1200,
|
|
132
|
+
height: 675,
|
|
133
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
134
|
+
},
|
|
135
|
+
linkedin: {
|
|
136
|
+
id: "linkedin",
|
|
137
|
+
width: 1200,
|
|
138
|
+
height: 627,
|
|
139
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
140
|
+
},
|
|
141
|
+
square: {
|
|
142
|
+
id: "square",
|
|
143
|
+
width: 1080,
|
|
144
|
+
height: 1080,
|
|
145
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
146
|
+
},
|
|
147
|
+
portrait: {
|
|
148
|
+
id: "portrait",
|
|
149
|
+
width: 1080,
|
|
150
|
+
height: 1350,
|
|
151
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
152
|
+
},
|
|
153
|
+
producthunt: {
|
|
154
|
+
id: "producthunt",
|
|
155
|
+
width: 1270,
|
|
156
|
+
height: 760,
|
|
157
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
158
|
+
},
|
|
159
|
+
"readme-banner": {
|
|
160
|
+
id: "readme-banner",
|
|
161
|
+
width: 1280,
|
|
162
|
+
height: 400,
|
|
163
|
+
safeZone: LANDSCAPE_SAFE_ZONE
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/config/schema.ts
|
|
169
|
+
const configSchema = z.object({
|
|
170
|
+
$schema: z.string().optional(),
|
|
171
|
+
version: z.literal(1),
|
|
172
|
+
outputDir: z.string().optional(),
|
|
173
|
+
formats: z.array(z.enum(FORMAT_IDS)).optional(),
|
|
174
|
+
release: z.object({
|
|
175
|
+
templates: z.array(z.string()).optional(),
|
|
176
|
+
maxHighlights: z.number().int().positive().optional(),
|
|
177
|
+
changelogPath: z.string().optional(),
|
|
178
|
+
snippet: z.string().nullable().optional()
|
|
179
|
+
}).optional(),
|
|
180
|
+
milestones: z.object({
|
|
181
|
+
stars: z.array(z.number().int().positive()).optional(),
|
|
182
|
+
downloads: z.array(z.number().int().positive()).optional(),
|
|
183
|
+
contributors: z.array(z.number().int().positive()).optional()
|
|
184
|
+
}).optional(),
|
|
185
|
+
bench: z.object({ file: z.string().optional() }).optional(),
|
|
186
|
+
copy: z.object({
|
|
187
|
+
llm: z.boolean().optional(),
|
|
188
|
+
provider: z.string().nullable().optional(),
|
|
189
|
+
model: z.string().nullable().optional(),
|
|
190
|
+
maxRetries: z.number().int().nonnegative().optional()
|
|
191
|
+
}).optional(),
|
|
192
|
+
output: z.object({ imageFormat: z.enum([
|
|
193
|
+
"png",
|
|
194
|
+
"webp",
|
|
195
|
+
"jpeg"
|
|
196
|
+
]).optional() }).optional(),
|
|
197
|
+
attribution: z.boolean().optional()
|
|
198
|
+
});
|
|
199
|
+
const DEFAULT_CONFIG = {
|
|
200
|
+
version: 1,
|
|
201
|
+
outputDir: ".shipseal/output",
|
|
202
|
+
formats: [
|
|
203
|
+
"og",
|
|
204
|
+
"github-social",
|
|
205
|
+
"x",
|
|
206
|
+
"linkedin"
|
|
207
|
+
],
|
|
208
|
+
release: {
|
|
209
|
+
templates: [
|
|
210
|
+
"release-hero",
|
|
211
|
+
"release-highlights",
|
|
212
|
+
"code-card"
|
|
213
|
+
],
|
|
214
|
+
maxHighlights: 4,
|
|
215
|
+
changelogPath: "CHANGELOG.md",
|
|
216
|
+
snippet: null
|
|
217
|
+
},
|
|
218
|
+
milestones: {
|
|
219
|
+
stars: [
|
|
220
|
+
100,
|
|
221
|
+
250,
|
|
222
|
+
500,
|
|
223
|
+
1e3,
|
|
224
|
+
2500,
|
|
225
|
+
5e3,
|
|
226
|
+
1e4
|
|
227
|
+
],
|
|
228
|
+
downloads: [
|
|
229
|
+
1e3,
|
|
230
|
+
1e4,
|
|
231
|
+
1e5,
|
|
232
|
+
1e6
|
|
233
|
+
],
|
|
234
|
+
contributors: [
|
|
235
|
+
10,
|
|
236
|
+
25,
|
|
237
|
+
50,
|
|
238
|
+
100
|
|
239
|
+
]
|
|
240
|
+
},
|
|
241
|
+
bench: { file: ".shipseal/bench.json" },
|
|
242
|
+
copy: {
|
|
243
|
+
llm: false,
|
|
244
|
+
provider: null,
|
|
245
|
+
model: null,
|
|
246
|
+
maxRetries: 2
|
|
247
|
+
},
|
|
248
|
+
output: { imageFormat: "png" },
|
|
249
|
+
attribution: true
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/config/load.ts
|
|
254
|
+
async function loadConfig(cwd) {
|
|
255
|
+
const path = join(cwd, ".shipseal", "config.json");
|
|
256
|
+
try {
|
|
257
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
258
|
+
const parsed = configSchema.parse(raw);
|
|
259
|
+
return mergeConfig(DEFAULT_CONFIG, parsed);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return DEFAULT_CONFIG;
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function mergeConfig(base, overlay) {
|
|
266
|
+
return {
|
|
267
|
+
...base,
|
|
268
|
+
...overlay,
|
|
269
|
+
release: {
|
|
270
|
+
...base.release,
|
|
271
|
+
...overlay.release
|
|
272
|
+
},
|
|
273
|
+
milestones: {
|
|
274
|
+
...base.milestones,
|
|
275
|
+
...overlay.milestones
|
|
276
|
+
},
|
|
277
|
+
bench: {
|
|
278
|
+
...base.bench,
|
|
279
|
+
...overlay.bench
|
|
280
|
+
},
|
|
281
|
+
copy: {
|
|
282
|
+
...base.copy,
|
|
283
|
+
...overlay.copy
|
|
284
|
+
},
|
|
285
|
+
output: {
|
|
286
|
+
...base.output,
|
|
287
|
+
...overlay.output
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/copy/slots.ts
|
|
294
|
+
const COPY_LIMITS = {
|
|
295
|
+
headline: 48,
|
|
296
|
+
subheadline: 90,
|
|
297
|
+
highlight: 56,
|
|
298
|
+
cta: 32,
|
|
299
|
+
milestoneLine: 48
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/copy/deterministic.ts
|
|
304
|
+
function deterministicCopy(facts, maxHighlights = 4) {
|
|
305
|
+
const name = facts.project.name.value;
|
|
306
|
+
const version = facts.release?.version.value;
|
|
307
|
+
const features = (facts.release?.features ?? []).map((item) => item.value);
|
|
308
|
+
const fixes = (facts.release?.fixes ?? []).map((item) => item.value);
|
|
309
|
+
const breaking = (facts.release?.breaking ?? []).map((item) => item.value);
|
|
310
|
+
const headlineSource = features[0];
|
|
311
|
+
const headline = headlineSource !== void 0 ? cleanLine(headlineSource) : version === void 0 ? name : `${name} ${version}`;
|
|
312
|
+
const tagline = facts.project.tagline?.value;
|
|
313
|
+
const subheadline = tagline !== void 0 && tagline.length > 0 ? cleanLine(tagline) : cleanLine(features[1] ?? fixes[0] ?? `What's new in ${version ?? name}`);
|
|
314
|
+
const highlights = [];
|
|
315
|
+
for (const line of features) {
|
|
316
|
+
if (highlights.length >= maxHighlights) break;
|
|
317
|
+
highlights.push(cleanLine(line));
|
|
318
|
+
}
|
|
319
|
+
for (const line of fixes) {
|
|
320
|
+
if (highlights.length >= maxHighlights) break;
|
|
321
|
+
highlights.push(cleanLine(line));
|
|
322
|
+
}
|
|
323
|
+
for (const line of breaking) {
|
|
324
|
+
if (highlights.length >= maxHighlights) break;
|
|
325
|
+
highlights.push(`Breaking: ${cleanLine(line)}`);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
headline,
|
|
329
|
+
subheadline,
|
|
330
|
+
highlights,
|
|
331
|
+
cta: ctaFor(facts)
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function formatCount$1(value) {
|
|
335
|
+
return value.toLocaleString("en-US");
|
|
336
|
+
}
|
|
337
|
+
function milestoneCopy(facts, metric, threshold) {
|
|
338
|
+
const formatted = formatCount$1(threshold);
|
|
339
|
+
const line = metric === "stars" ? `Thank you for ${formatted} stars` : metric === "downloads" ? `${formatted} weekly downloads` : `Thank you, ${formatted} contributors`;
|
|
340
|
+
const tagline = facts.project.tagline?.value;
|
|
341
|
+
return {
|
|
342
|
+
headline: facts.project.name.value,
|
|
343
|
+
subheadline: tagline !== void 0 && tagline.length > 0 ? cleanLine(tagline) : "",
|
|
344
|
+
highlights: [],
|
|
345
|
+
cta: ctaFor(facts),
|
|
346
|
+
milestoneLine: line
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function benchCopy(facts) {
|
|
350
|
+
const title = facts.bench?.title.value ?? facts.project.name.value;
|
|
351
|
+
const note = facts.bench?.note?.value;
|
|
352
|
+
return {
|
|
353
|
+
headline: title,
|
|
354
|
+
subheadline: note !== void 0 && note.length > 0 ? note : facts.project.tagline?.value ?? "",
|
|
355
|
+
highlights: [],
|
|
356
|
+
cta: ctaFor(facts)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function cleanLine(text) {
|
|
360
|
+
const stripped = text.replaceAll("—", ":").replaceAll("–", "-").replaceAll("!", ".").trim();
|
|
361
|
+
const noTrail = stripped.endsWith(".") ? stripped.slice(0, -1) : stripped;
|
|
362
|
+
const first = noTrail.at(0);
|
|
363
|
+
if (first === void 0) return noTrail;
|
|
364
|
+
return first.toUpperCase() + noTrail.slice(1);
|
|
365
|
+
}
|
|
366
|
+
function ctaFor(facts) {
|
|
367
|
+
const npm = facts.project.npmPackage?.value;
|
|
368
|
+
if (npm !== void 0 && npm.length > 0) return `npm i ${npm}`;
|
|
369
|
+
const url = facts.project.url?.value ?? facts.project.repo?.value;
|
|
370
|
+
if (url === void 0) return facts.project.name.value;
|
|
371
|
+
return url.replace(/^https?:\/\//, "");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/bench/percent.ts
|
|
376
|
+
function percentChange(before, after, better) {
|
|
377
|
+
if (before === 0) {
|
|
378
|
+
if (after === 0) return {
|
|
379
|
+
signed: 0,
|
|
380
|
+
absPercent: 0,
|
|
381
|
+
regression: false
|
|
382
|
+
};
|
|
383
|
+
const improved = better === "higher" ? after > 0 : after < 0;
|
|
384
|
+
return {
|
|
385
|
+
signed: improved ? 100 : -100,
|
|
386
|
+
absPercent: 100,
|
|
387
|
+
regression: !improved
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const raw = better === "lower" ? (before - after) / before : (after - before) / before;
|
|
391
|
+
const signed = Math.round(raw * 100);
|
|
392
|
+
return {
|
|
393
|
+
signed,
|
|
394
|
+
absPercent: Math.abs(signed),
|
|
395
|
+
regression: signed < 0
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function changePhrase(unit, better, change) {
|
|
399
|
+
if (change.absPercent === 0) return "unchanged";
|
|
400
|
+
return `${String(change.absPercent)}% ${deltaWord(unit, better, change.regression)}`;
|
|
401
|
+
}
|
|
402
|
+
function deltaWord(unit, better, regression) {
|
|
403
|
+
if (better === "higher") return regression ? "lower" : "higher";
|
|
404
|
+
const normalized = unit.toLowerCase();
|
|
405
|
+
if (normalized === "ms" || normalized === "s" || normalized === "sec" || normalized === "seconds") return regression ? "slower" : "faster";
|
|
406
|
+
if (normalized === "kb" || normalized === "mb" || normalized === "b" || normalized === "bytes" || normalized === "gzip") return regression ? "larger" : "smaller";
|
|
407
|
+
return regression ? "worse" : "better";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/fit/fit-text.ts
|
|
412
|
+
async function fitText(renderer, text, spec, font) {
|
|
413
|
+
const sizes = fontSizes(spec);
|
|
414
|
+
for (const fontSize of sizes) {
|
|
415
|
+
const measured = await renderer.measureText(text, measureOpts$1(spec, font, fontSize));
|
|
416
|
+
if (measured.lines <= spec.maxLines) return {
|
|
417
|
+
fontSize,
|
|
418
|
+
text,
|
|
419
|
+
lines: measured.lines,
|
|
420
|
+
truncated: false
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const fontSize = spec.minFontSize;
|
|
424
|
+
const truncated = await truncateToFit(renderer, text, spec, font, fontSize);
|
|
425
|
+
return {
|
|
426
|
+
fontSize,
|
|
427
|
+
text: truncated,
|
|
428
|
+
lines: (await renderer.measureText(truncated, measureOpts$1(spec, font, fontSize))).lines,
|
|
429
|
+
truncated: truncated !== text
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function fontSizes(spec) {
|
|
433
|
+
const sizes = [];
|
|
434
|
+
for (let size = spec.maxFontSize; size >= spec.minFontSize; size -= spec.step) sizes.push(size);
|
|
435
|
+
if (sizes[sizes.length - 1] !== spec.minFontSize) sizes.push(spec.minFontSize);
|
|
436
|
+
return sizes;
|
|
437
|
+
}
|
|
438
|
+
function measureOpts$1(spec, font, fontSize) {
|
|
439
|
+
const opts = {
|
|
440
|
+
fontFamily: font.family,
|
|
441
|
+
fontSize,
|
|
442
|
+
maxWidth: spec.box.width,
|
|
443
|
+
lineHeight: font.lineHeight
|
|
444
|
+
};
|
|
445
|
+
if (font.weight !== void 0) opts.fontWeight = font.weight;
|
|
446
|
+
if (font.whiteSpace !== void 0) opts.whiteSpace = font.whiteSpace;
|
|
447
|
+
return opts;
|
|
448
|
+
}
|
|
449
|
+
async function truncateToFit(renderer, text, spec, font, fontSize) {
|
|
450
|
+
const words = text.trim().length === 0 ? [] : text.trim().split(/\s+/);
|
|
451
|
+
if (words.length === 0) return text;
|
|
452
|
+
const fits = async (candidate) => {
|
|
453
|
+
return (await renderer.measureText(candidate, measureOpts$1(spec, font, fontSize))).lines <= spec.maxLines;
|
|
454
|
+
};
|
|
455
|
+
for (let count = words.length - 1; count >= 1; count -= 1) {
|
|
456
|
+
const candidate = `${words.slice(0, count).join(" ")}...`;
|
|
457
|
+
if (await fits(candidate)) return candidate;
|
|
458
|
+
}
|
|
459
|
+
const first = words[0];
|
|
460
|
+
if (first === void 0) return "...";
|
|
461
|
+
for (let length = first.length; length >= 1; length -= 1) {
|
|
462
|
+
const candidate = `${first.slice(0, length)}...`;
|
|
463
|
+
if (await fits(candidate)) return candidate;
|
|
464
|
+
}
|
|
465
|
+
return "...";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/templates/theme.ts
|
|
470
|
+
function themeColors(brand, theme) {
|
|
471
|
+
const primary = brand.colors.primary ?? DEFAULT_BRAND_COLORS.primary;
|
|
472
|
+
const accent = brand.colors.accent ?? DEFAULT_BRAND_COLORS.accent;
|
|
473
|
+
const muted = brand.colors.muted ?? DEFAULT_BRAND_COLORS.muted;
|
|
474
|
+
if (theme === "dark") {
|
|
475
|
+
const background = brand.theme === "dark" ? brand.colors.background : brand.colors.foreground;
|
|
476
|
+
const foreground = brand.theme === "dark" ? brand.colors.foreground : brand.colors.background;
|
|
477
|
+
return {
|
|
478
|
+
background,
|
|
479
|
+
foreground,
|
|
480
|
+
muted: brand.theme === "dark" ? muted : DEFAULT_BRAND_COLORS.muted,
|
|
481
|
+
primary,
|
|
482
|
+
accent,
|
|
483
|
+
card: mix(background, foreground, .08)
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const background = brand.theme === "light" ? brand.colors.background : brand.colors.foreground;
|
|
487
|
+
const foreground = brand.theme === "light" ? brand.colors.foreground : brand.colors.background;
|
|
488
|
+
return {
|
|
489
|
+
background,
|
|
490
|
+
foreground,
|
|
491
|
+
muted: brand.theme === "light" ? muted : "#52525b",
|
|
492
|
+
primary,
|
|
493
|
+
accent,
|
|
494
|
+
card: mix(background, foreground, .06)
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function mix(a, b, amount) {
|
|
498
|
+
const ar = Number.parseInt(a.slice(1, 3), 16);
|
|
499
|
+
const ag = Number.parseInt(a.slice(3, 5), 16);
|
|
500
|
+
const ab = Number.parseInt(a.slice(5, 7), 16);
|
|
501
|
+
const br = Number.parseInt(b.slice(1, 3), 16);
|
|
502
|
+
const bg = Number.parseInt(b.slice(3, 5), 16);
|
|
503
|
+
const bb = Number.parseInt(b.slice(5, 7), 16);
|
|
504
|
+
return `#${toHex(ar + (br - ar) * amount)}${toHex(ag + (bg - ag) * amount)}${toHex(ab + (bb - ab) * amount)}`;
|
|
505
|
+
}
|
|
506
|
+
function toHex(value) {
|
|
507
|
+
return Math.round(value).toString(16).padStart(2, "0");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/takumi-jsx/jsx-runtime.ts
|
|
512
|
+
function jsx(type, props, key) {
|
|
513
|
+
return {
|
|
514
|
+
type,
|
|
515
|
+
props,
|
|
516
|
+
key: key ?? null
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const jsxs = jsx;
|
|
520
|
+
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region src/templates/code-card.tsx
|
|
523
|
+
const propsSchema$4 = z.object({
|
|
524
|
+
headline: z.string(),
|
|
525
|
+
headingFamily: z.string(),
|
|
526
|
+
monoFamily: z.string(),
|
|
527
|
+
headingWeight: z.number(),
|
|
528
|
+
monoWeight: z.number(),
|
|
529
|
+
radius: z.number(),
|
|
530
|
+
lines: z.array(z.array(z.object({
|
|
531
|
+
text: z.string(),
|
|
532
|
+
color: z.string()
|
|
533
|
+
})))
|
|
534
|
+
});
|
|
535
|
+
const codeCard = {
|
|
536
|
+
id: "code-card",
|
|
537
|
+
events: ["release"],
|
|
538
|
+
formats: ["x", "linkedin"],
|
|
539
|
+
propsSchema: propsSchema$4,
|
|
540
|
+
slots(format) {
|
|
541
|
+
return { headline: {
|
|
542
|
+
maxLines: 1,
|
|
543
|
+
maxFontSize: 40,
|
|
544
|
+
minFontSize: 28,
|
|
545
|
+
step: 2,
|
|
546
|
+
box: { width: format.width - 2 * format.safeZone }
|
|
547
|
+
} };
|
|
548
|
+
},
|
|
549
|
+
slotText(raw) {
|
|
550
|
+
return { headline: propsSchema$4.parse(raw).headline };
|
|
551
|
+
},
|
|
552
|
+
slotFont(raw, slot) {
|
|
553
|
+
const props = propsSchema$4.parse(raw);
|
|
554
|
+
if (slot === "headline") return {
|
|
555
|
+
family: props.headingFamily,
|
|
556
|
+
weight: props.headingWeight,
|
|
557
|
+
lineHeight: 1.1
|
|
558
|
+
};
|
|
559
|
+
return {
|
|
560
|
+
family: props.monoFamily,
|
|
561
|
+
weight: props.monoWeight,
|
|
562
|
+
lineHeight: 1.35,
|
|
563
|
+
whiteSpace: "pre"
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
buildProps(facts, copy, brand) {
|
|
567
|
+
const missing = [];
|
|
568
|
+
const lines = copy.codeLines ?? [];
|
|
569
|
+
if (lines.length === 0) missing.push({
|
|
570
|
+
fact: "release.codeSnippet",
|
|
571
|
+
effect: "code card uses empty snippet"
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
props: {
|
|
575
|
+
headline: copy.headline,
|
|
576
|
+
headingFamily: brand.fonts.heading.family,
|
|
577
|
+
monoFamily: brand.fonts.mono.family,
|
|
578
|
+
headingWeight: brand.fonts.heading.weight,
|
|
579
|
+
monoWeight: brand.fonts.mono.weight,
|
|
580
|
+
radius: brand.radius,
|
|
581
|
+
lines
|
|
582
|
+
},
|
|
583
|
+
missing
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
render(raw, ctx) {
|
|
587
|
+
const props = propsSchema$4.parse(raw);
|
|
588
|
+
const colors = themeColors(ctx.brand, ctx.theme);
|
|
589
|
+
const pad = ctx.format.safeZone;
|
|
590
|
+
const headline = ctx.fitted.headline;
|
|
591
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
592
|
+
style: {
|
|
593
|
+
display: "flex",
|
|
594
|
+
flexDirection: "column",
|
|
595
|
+
width: "100%",
|
|
596
|
+
height: "100%",
|
|
597
|
+
backgroundColor: colors.background,
|
|
598
|
+
padding: pad,
|
|
599
|
+
gap: 24
|
|
600
|
+
},
|
|
601
|
+
children: [
|
|
602
|
+
/* @__PURE__ */ jsx("div", {
|
|
603
|
+
style: {
|
|
604
|
+
fontFamily: props.headingFamily,
|
|
605
|
+
fontWeight: props.headingWeight,
|
|
606
|
+
fontSize: headline?.fontSize ?? 36,
|
|
607
|
+
color: colors.foreground
|
|
608
|
+
},
|
|
609
|
+
children: headline?.text ?? props.headline
|
|
610
|
+
}),
|
|
611
|
+
/* @__PURE__ */ jsx("div", {
|
|
612
|
+
style: {
|
|
613
|
+
display: "flex",
|
|
614
|
+
flexDirection: "column",
|
|
615
|
+
flexGrow: 1,
|
|
616
|
+
backgroundColor: colors.card,
|
|
617
|
+
borderRadius: props.radius,
|
|
618
|
+
padding: 28,
|
|
619
|
+
gap: 0
|
|
620
|
+
},
|
|
621
|
+
children: props.lines.map((line, lineIndex) => /* @__PURE__ */ jsx("div", {
|
|
622
|
+
style: {
|
|
623
|
+
display: "flex",
|
|
624
|
+
flexDirection: "row",
|
|
625
|
+
flexWrap: "nowrap"
|
|
626
|
+
},
|
|
627
|
+
children: line.map((token, tokenIndex) => /* @__PURE__ */ jsx("div", {
|
|
628
|
+
style: {
|
|
629
|
+
fontFamily: props.monoFamily,
|
|
630
|
+
fontWeight: props.monoWeight,
|
|
631
|
+
fontSize: 22,
|
|
632
|
+
color: token.color,
|
|
633
|
+
whiteSpace: "pre",
|
|
634
|
+
lineHeight: 1.35
|
|
635
|
+
},
|
|
636
|
+
children: token.text
|
|
637
|
+
}, `${String(lineIndex)}-${String(tokenIndex)}`))
|
|
638
|
+
}, String(lineIndex)))
|
|
639
|
+
}),
|
|
640
|
+
ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
641
|
+
style: {
|
|
642
|
+
fontFamily: props.headingFamily,
|
|
643
|
+
fontWeight: 400,
|
|
644
|
+
fontSize: 18,
|
|
645
|
+
color: colors.muted
|
|
646
|
+
},
|
|
647
|
+
children: "made with shipseal.dev"
|
|
648
|
+
}) : void 0
|
|
649
|
+
]
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
function emptyCodeLines() {
|
|
654
|
+
return [[{
|
|
655
|
+
text: " ",
|
|
656
|
+
color: "#e6edf3"
|
|
657
|
+
}]];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
//#endregion
|
|
661
|
+
//#region src/templates/highlight.ts
|
|
662
|
+
const MAX_LINES = 14;
|
|
663
|
+
async function highlightCode(code, lang, theme) {
|
|
664
|
+
const clipped = code.split("\n").slice(0, MAX_LINES).join("\n");
|
|
665
|
+
const fallbackColor = theme === "dark" ? "#e6edf3" : "#1f2328";
|
|
666
|
+
try {
|
|
667
|
+
const { codeToTokens } = await import("shiki");
|
|
668
|
+
return (await codeToTokens(clipped, {
|
|
669
|
+
lang: mapLang(lang),
|
|
670
|
+
theme: theme === "dark" ? "github-dark" : "github-light"
|
|
671
|
+
})).tokens.map((line) => {
|
|
672
|
+
if (line.length === 0) return [{
|
|
673
|
+
text: " ",
|
|
674
|
+
color: fallbackColor
|
|
675
|
+
}];
|
|
676
|
+
return line.map((token) => ({
|
|
677
|
+
text: token.content,
|
|
678
|
+
color: token.color ?? fallbackColor
|
|
679
|
+
}));
|
|
680
|
+
});
|
|
681
|
+
} catch {
|
|
682
|
+
return clipped.split("\n").map((line) => [{
|
|
683
|
+
text: line.length === 0 ? " " : line,
|
|
684
|
+
color: fallbackColor
|
|
685
|
+
}]);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function mapLang(lang) {
|
|
689
|
+
const key = lang.toLowerCase();
|
|
690
|
+
if (key === "javascript" || key === "js") return "js";
|
|
691
|
+
if (key === "tsx") return "tsx";
|
|
692
|
+
if (key === "jsx") return "jsx";
|
|
693
|
+
if (key === "json") return "json";
|
|
694
|
+
if (key === "bash" || key === "shell" || key === "sh") return "bash";
|
|
695
|
+
return "ts";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/templates/bench.tsx
|
|
700
|
+
const rowSchema = z.object({
|
|
701
|
+
label: z.string(),
|
|
702
|
+
beforeText: z.string(),
|
|
703
|
+
afterText: z.string(),
|
|
704
|
+
changeText: z.string(),
|
|
705
|
+
regression: z.boolean()
|
|
706
|
+
});
|
|
707
|
+
const propsSchema$3 = z.object({
|
|
708
|
+
title: z.string(),
|
|
709
|
+
note: z.string(),
|
|
710
|
+
rows: z.array(rowSchema),
|
|
711
|
+
headingFamily: z.string(),
|
|
712
|
+
bodyFamily: z.string(),
|
|
713
|
+
headingWeight: z.number(),
|
|
714
|
+
bodyWeight: z.number(),
|
|
715
|
+
radius: z.number()
|
|
716
|
+
});
|
|
717
|
+
const bench = {
|
|
718
|
+
id: "bench",
|
|
719
|
+
events: ["bench"],
|
|
720
|
+
formats: ["x", "linkedin"],
|
|
721
|
+
propsSchema: propsSchema$3,
|
|
722
|
+
slots(format) {
|
|
723
|
+
const width = format.width - 2 * format.safeZone;
|
|
724
|
+
const slots = { title: {
|
|
725
|
+
maxLines: 2,
|
|
726
|
+
maxFontSize: 48,
|
|
727
|
+
minFontSize: 28,
|
|
728
|
+
step: 2,
|
|
729
|
+
box: { width }
|
|
730
|
+
} };
|
|
731
|
+
for (let index = 0; index < 3; index += 1) slots[`change-${String(index)}`] = {
|
|
732
|
+
maxLines: 1,
|
|
733
|
+
maxFontSize: 36,
|
|
734
|
+
minFontSize: 22,
|
|
735
|
+
step: 2,
|
|
736
|
+
box: { width: Math.min(360, width) }
|
|
737
|
+
};
|
|
738
|
+
return slots;
|
|
739
|
+
},
|
|
740
|
+
slotText(raw) {
|
|
741
|
+
const props = propsSchema$3.parse(raw);
|
|
742
|
+
const text = { title: props.title };
|
|
743
|
+
props.rows.forEach((row, index) => {
|
|
744
|
+
text[`change-${String(index)}`] = row.changeText;
|
|
745
|
+
});
|
|
746
|
+
return text;
|
|
747
|
+
},
|
|
748
|
+
slotFont(raw, slot) {
|
|
749
|
+
const props = propsSchema$3.parse(raw);
|
|
750
|
+
if (slot === "title") return {
|
|
751
|
+
family: props.headingFamily,
|
|
752
|
+
weight: props.headingWeight,
|
|
753
|
+
lineHeight: 1.15
|
|
754
|
+
};
|
|
755
|
+
return {
|
|
756
|
+
family: props.headingFamily,
|
|
757
|
+
weight: props.headingWeight,
|
|
758
|
+
lineHeight: 1.1
|
|
759
|
+
};
|
|
760
|
+
},
|
|
761
|
+
buildProps(facts, copy, brand) {
|
|
762
|
+
const missing = [];
|
|
763
|
+
if (facts.bench === void 0) missing.push({
|
|
764
|
+
fact: "bench",
|
|
765
|
+
effect: "metrics hidden"
|
|
766
|
+
});
|
|
767
|
+
const rows = facts.bench?.metrics.map((metric) => {
|
|
768
|
+
const change = percentChange(metric.before.value, metric.after.value, metric.better.value);
|
|
769
|
+
return {
|
|
770
|
+
label: metric.label.value,
|
|
771
|
+
beforeText: `${formatMetric(metric.before.value)} ${metric.unit.value}`,
|
|
772
|
+
afterText: `${formatMetric(metric.after.value)} ${metric.unit.value}`,
|
|
773
|
+
changeText: changePhrase(metric.unit.value, metric.better.value, change),
|
|
774
|
+
regression: change.regression
|
|
775
|
+
};
|
|
776
|
+
}) ?? [];
|
|
777
|
+
return {
|
|
778
|
+
props: {
|
|
779
|
+
title: copy.headline,
|
|
780
|
+
note: copy.subheadline,
|
|
781
|
+
rows,
|
|
782
|
+
headingFamily: brand.fonts.heading.family,
|
|
783
|
+
bodyFamily: brand.fonts.body.family,
|
|
784
|
+
headingWeight: brand.fonts.heading.weight,
|
|
785
|
+
bodyWeight: brand.fonts.body.weight,
|
|
786
|
+
radius: brand.radius
|
|
787
|
+
},
|
|
788
|
+
missing
|
|
789
|
+
};
|
|
790
|
+
},
|
|
791
|
+
render(raw, ctx) {
|
|
792
|
+
const props = propsSchema$3.parse(raw);
|
|
793
|
+
const colors = themeColors(ctx.brand, ctx.theme);
|
|
794
|
+
const pad = ctx.format.safeZone;
|
|
795
|
+
const title = ctx.fitted.title;
|
|
796
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
797
|
+
style: {
|
|
798
|
+
display: "flex",
|
|
799
|
+
flexDirection: "column",
|
|
800
|
+
justifyContent: "space-between",
|
|
801
|
+
width: "100%",
|
|
802
|
+
height: "100%",
|
|
803
|
+
backgroundColor: colors.background,
|
|
804
|
+
padding: pad
|
|
805
|
+
},
|
|
806
|
+
children: [
|
|
807
|
+
/* @__PURE__ */ jsx("div", {
|
|
808
|
+
style: {
|
|
809
|
+
fontFamily: props.headingFamily,
|
|
810
|
+
fontWeight: props.headingWeight,
|
|
811
|
+
fontSize: title?.fontSize ?? 40,
|
|
812
|
+
color: colors.foreground,
|
|
813
|
+
lineHeight: 1.15
|
|
814
|
+
},
|
|
815
|
+
children: title?.text ?? props.title
|
|
816
|
+
}),
|
|
817
|
+
/* @__PURE__ */ jsx("div", {
|
|
818
|
+
style: {
|
|
819
|
+
display: "flex",
|
|
820
|
+
flexDirection: "column",
|
|
821
|
+
gap: 20
|
|
822
|
+
},
|
|
823
|
+
children: props.rows.map((row, index) => {
|
|
824
|
+
const change = ctx.fitted[`change-${String(index)}`];
|
|
825
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
826
|
+
style: {
|
|
827
|
+
display: "flex",
|
|
828
|
+
flexDirection: "row",
|
|
829
|
+
justifyContent: "space-between",
|
|
830
|
+
alignItems: "center",
|
|
831
|
+
backgroundColor: colors.card,
|
|
832
|
+
borderRadius: props.radius,
|
|
833
|
+
paddingTop: 16,
|
|
834
|
+
paddingBottom: 16,
|
|
835
|
+
paddingLeft: 24,
|
|
836
|
+
paddingRight: 24
|
|
837
|
+
},
|
|
838
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
839
|
+
style: {
|
|
840
|
+
display: "flex",
|
|
841
|
+
flexDirection: "column",
|
|
842
|
+
gap: 6
|
|
843
|
+
},
|
|
844
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
845
|
+
style: {
|
|
846
|
+
fontFamily: props.bodyFamily,
|
|
847
|
+
fontWeight: props.bodyWeight,
|
|
848
|
+
fontSize: 22,
|
|
849
|
+
color: colors.muted
|
|
850
|
+
},
|
|
851
|
+
children: row.label
|
|
852
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
853
|
+
style: {
|
|
854
|
+
fontFamily: props.bodyFamily,
|
|
855
|
+
fontWeight: props.bodyWeight,
|
|
856
|
+
fontSize: 24,
|
|
857
|
+
color: colors.foreground
|
|
858
|
+
},
|
|
859
|
+
children: `${row.beforeText} to ${row.afterText}`
|
|
860
|
+
})]
|
|
861
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
862
|
+
style: {
|
|
863
|
+
display: "flex",
|
|
864
|
+
flexDirection: "column",
|
|
865
|
+
alignItems: "flex-end",
|
|
866
|
+
gap: 4
|
|
867
|
+
},
|
|
868
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
869
|
+
style: {
|
|
870
|
+
fontFamily: props.headingFamily,
|
|
871
|
+
fontWeight: props.headingWeight,
|
|
872
|
+
fontSize: change?.fontSize ?? 32,
|
|
873
|
+
color: row.regression ? colors.primary : colors.accent
|
|
874
|
+
},
|
|
875
|
+
children: change?.text ?? row.changeText
|
|
876
|
+
}), row.regression ? /* @__PURE__ */ jsx("div", {
|
|
877
|
+
style: {
|
|
878
|
+
fontFamily: props.bodyFamily,
|
|
879
|
+
fontWeight: props.bodyWeight,
|
|
880
|
+
fontSize: 18,
|
|
881
|
+
color: colors.primary
|
|
882
|
+
},
|
|
883
|
+
children: "regression"
|
|
884
|
+
}) : void 0]
|
|
885
|
+
})]
|
|
886
|
+
}, row.label);
|
|
887
|
+
})
|
|
888
|
+
}),
|
|
889
|
+
/* @__PURE__ */ jsxs("div", {
|
|
890
|
+
style: {
|
|
891
|
+
display: "flex",
|
|
892
|
+
flexDirection: "row",
|
|
893
|
+
justifyContent: "space-between",
|
|
894
|
+
alignItems: "flex-end"
|
|
895
|
+
},
|
|
896
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
897
|
+
style: {
|
|
898
|
+
fontFamily: props.bodyFamily,
|
|
899
|
+
fontWeight: props.bodyWeight,
|
|
900
|
+
fontSize: 20,
|
|
901
|
+
color: colors.muted
|
|
902
|
+
},
|
|
903
|
+
children: props.note
|
|
904
|
+
}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
905
|
+
style: {
|
|
906
|
+
fontFamily: props.bodyFamily,
|
|
907
|
+
fontWeight: props.bodyWeight,
|
|
908
|
+
fontSize: 18,
|
|
909
|
+
color: colors.muted
|
|
910
|
+
},
|
|
911
|
+
children: "made with shipseal.dev"
|
|
912
|
+
}) : void 0]
|
|
913
|
+
})
|
|
914
|
+
]
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
function formatMetric(value) {
|
|
919
|
+
return String(value);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region src/templates/milestone.tsx
|
|
924
|
+
const propsSchema$2 = z.object({
|
|
925
|
+
name: z.string(),
|
|
926
|
+
numberText: z.string(),
|
|
927
|
+
metricLabel: z.string(),
|
|
928
|
+
thankYou: z.string(),
|
|
929
|
+
headingFamily: z.string(),
|
|
930
|
+
bodyFamily: z.string(),
|
|
931
|
+
headingWeight: z.number(),
|
|
932
|
+
bodyWeight: z.number(),
|
|
933
|
+
radius: z.number(),
|
|
934
|
+
showLogo: z.boolean()
|
|
935
|
+
});
|
|
936
|
+
const milestone = {
|
|
937
|
+
id: "milestone",
|
|
938
|
+
events: ["milestone"],
|
|
939
|
+
formats: [
|
|
940
|
+
"og",
|
|
941
|
+
"x",
|
|
942
|
+
"linkedin"
|
|
943
|
+
],
|
|
944
|
+
propsSchema: propsSchema$2,
|
|
945
|
+
slots(format) {
|
|
946
|
+
const width = format.width - 2 * format.safeZone;
|
|
947
|
+
return {
|
|
948
|
+
number: {
|
|
949
|
+
maxLines: 1,
|
|
950
|
+
maxFontSize: 160,
|
|
951
|
+
minFontSize: 72,
|
|
952
|
+
step: 8,
|
|
953
|
+
box: { width }
|
|
954
|
+
},
|
|
955
|
+
thankYou: {
|
|
956
|
+
maxLines: 2,
|
|
957
|
+
maxFontSize: 36,
|
|
958
|
+
minFontSize: 24,
|
|
959
|
+
step: 2,
|
|
960
|
+
box: { width }
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
},
|
|
964
|
+
slotText(raw) {
|
|
965
|
+
const props = propsSchema$2.parse(raw);
|
|
966
|
+
return {
|
|
967
|
+
number: props.numberText,
|
|
968
|
+
thankYou: props.thankYou
|
|
969
|
+
};
|
|
970
|
+
},
|
|
971
|
+
slotFont(raw, slot) {
|
|
972
|
+
const props = propsSchema$2.parse(raw);
|
|
973
|
+
if (slot === "number") return {
|
|
974
|
+
family: props.headingFamily,
|
|
975
|
+
weight: props.headingWeight,
|
|
976
|
+
lineHeight: 1
|
|
977
|
+
};
|
|
978
|
+
return {
|
|
979
|
+
family: props.bodyFamily,
|
|
980
|
+
weight: props.bodyWeight,
|
|
981
|
+
lineHeight: 1.25
|
|
982
|
+
};
|
|
983
|
+
},
|
|
984
|
+
buildProps(facts, copy, brand) {
|
|
985
|
+
const missing = [];
|
|
986
|
+
const threshold = facts.milestone?.threshold.value;
|
|
987
|
+
if (threshold === void 0) missing.push({
|
|
988
|
+
fact: "milestone.threshold",
|
|
989
|
+
effect: "number hidden"
|
|
990
|
+
});
|
|
991
|
+
const metric = facts.milestone?.metric.value;
|
|
992
|
+
const metricLabel = metric === "downloads" ? "weekly downloads" : metric === "contributors" ? "contributors" : "stars";
|
|
993
|
+
const showLogo = brand.logo !== void 0;
|
|
994
|
+
if (!showLogo) missing.push({
|
|
995
|
+
fact: "brand.logo",
|
|
996
|
+
effect: "logo hidden"
|
|
997
|
+
});
|
|
998
|
+
const thankYou = copy.milestoneLine ?? (threshold === void 0 ? "" : `Thank you for ${formatCount$1(threshold)} ${metricLabel}`);
|
|
999
|
+
return {
|
|
1000
|
+
props: {
|
|
1001
|
+
name: brand.name,
|
|
1002
|
+
numberText: threshold === void 0 ? "" : formatCount$1(threshold),
|
|
1003
|
+
metricLabel,
|
|
1004
|
+
thankYou,
|
|
1005
|
+
headingFamily: brand.fonts.heading.family,
|
|
1006
|
+
bodyFamily: brand.fonts.body.family,
|
|
1007
|
+
headingWeight: brand.fonts.heading.weight,
|
|
1008
|
+
bodyWeight: brand.fonts.body.weight,
|
|
1009
|
+
radius: brand.radius,
|
|
1010
|
+
showLogo
|
|
1011
|
+
},
|
|
1012
|
+
missing
|
|
1013
|
+
};
|
|
1014
|
+
},
|
|
1015
|
+
render(raw, ctx) {
|
|
1016
|
+
const props = propsSchema$2.parse(raw);
|
|
1017
|
+
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1018
|
+
const pad = ctx.format.safeZone;
|
|
1019
|
+
const number = ctx.fitted.number;
|
|
1020
|
+
const thanks = ctx.fitted.thankYou;
|
|
1021
|
+
const compact = ctx.format.height <= 640;
|
|
1022
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1023
|
+
style: {
|
|
1024
|
+
display: "flex",
|
|
1025
|
+
flexDirection: "column",
|
|
1026
|
+
justifyContent: "space-between",
|
|
1027
|
+
width: "100%",
|
|
1028
|
+
height: "100%",
|
|
1029
|
+
backgroundColor: colors.background,
|
|
1030
|
+
padding: pad
|
|
1031
|
+
},
|
|
1032
|
+
children: [
|
|
1033
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1034
|
+
style: {
|
|
1035
|
+
display: "flex",
|
|
1036
|
+
flexDirection: "row",
|
|
1037
|
+
alignItems: "center",
|
|
1038
|
+
gap: 20
|
|
1039
|
+
},
|
|
1040
|
+
children: [props.showLogo && ctx.logoSrc !== void 0 ? /* @__PURE__ */ jsx("img", {
|
|
1041
|
+
src: ctx.logoSrc,
|
|
1042
|
+
width: compact ? 56 : 72,
|
|
1043
|
+
height: compact ? 56 : 72
|
|
1044
|
+
}) : void 0, /* @__PURE__ */ jsx("div", {
|
|
1045
|
+
style: {
|
|
1046
|
+
fontFamily: props.headingFamily,
|
|
1047
|
+
fontWeight: props.headingWeight,
|
|
1048
|
+
fontSize: 28,
|
|
1049
|
+
color: colors.foreground
|
|
1050
|
+
},
|
|
1051
|
+
children: props.name
|
|
1052
|
+
})]
|
|
1053
|
+
}),
|
|
1054
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1055
|
+
style: {
|
|
1056
|
+
display: "flex",
|
|
1057
|
+
flexDirection: "column",
|
|
1058
|
+
gap: 12
|
|
1059
|
+
},
|
|
1060
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1061
|
+
style: {
|
|
1062
|
+
fontFamily: props.headingFamily,
|
|
1063
|
+
fontWeight: props.headingWeight,
|
|
1064
|
+
fontSize: number?.fontSize ?? 140,
|
|
1065
|
+
color: colors.foreground,
|
|
1066
|
+
lineHeight: 1
|
|
1067
|
+
},
|
|
1068
|
+
children: number?.text ?? props.numberText
|
|
1069
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1070
|
+
style: {
|
|
1071
|
+
fontFamily: props.bodyFamily,
|
|
1072
|
+
fontWeight: props.headingWeight,
|
|
1073
|
+
fontSize: 32,
|
|
1074
|
+
color: colors.primary
|
|
1075
|
+
},
|
|
1076
|
+
children: props.metricLabel
|
|
1077
|
+
})]
|
|
1078
|
+
}),
|
|
1079
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1080
|
+
style: {
|
|
1081
|
+
display: "flex",
|
|
1082
|
+
flexDirection: "row",
|
|
1083
|
+
justifyContent: "space-between",
|
|
1084
|
+
alignItems: "flex-end"
|
|
1085
|
+
},
|
|
1086
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1087
|
+
style: {
|
|
1088
|
+
fontFamily: props.bodyFamily,
|
|
1089
|
+
fontWeight: props.bodyWeight,
|
|
1090
|
+
fontSize: thanks?.fontSize ?? 28,
|
|
1091
|
+
color: colors.muted,
|
|
1092
|
+
lineHeight: 1.25
|
|
1093
|
+
},
|
|
1094
|
+
children: thanks?.text ?? props.thankYou
|
|
1095
|
+
}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
1096
|
+
style: {
|
|
1097
|
+
fontFamily: props.bodyFamily,
|
|
1098
|
+
fontWeight: props.bodyWeight,
|
|
1099
|
+
fontSize: 18,
|
|
1100
|
+
color: colors.muted
|
|
1101
|
+
},
|
|
1102
|
+
children: "made with shipseal.dev"
|
|
1103
|
+
}) : void 0]
|
|
1104
|
+
})
|
|
1105
|
+
]
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/templates/release-hero.tsx
|
|
1112
|
+
const propsSchema$1 = z.object({
|
|
1113
|
+
name: z.string(),
|
|
1114
|
+
version: z.string(),
|
|
1115
|
+
headline: z.string(),
|
|
1116
|
+
subheadline: z.string(),
|
|
1117
|
+
cta: z.string(),
|
|
1118
|
+
headingFamily: z.string(),
|
|
1119
|
+
bodyFamily: z.string(),
|
|
1120
|
+
headingWeight: z.number(),
|
|
1121
|
+
bodyWeight: z.number(),
|
|
1122
|
+
radius: z.number(),
|
|
1123
|
+
showCta: z.boolean(),
|
|
1124
|
+
showLogo: z.boolean()
|
|
1125
|
+
});
|
|
1126
|
+
const releaseHero = {
|
|
1127
|
+
id: "release-hero",
|
|
1128
|
+
events: ["release"],
|
|
1129
|
+
formats: [
|
|
1130
|
+
"og",
|
|
1131
|
+
"github-social",
|
|
1132
|
+
"x",
|
|
1133
|
+
"linkedin"
|
|
1134
|
+
],
|
|
1135
|
+
propsSchema: propsSchema$1,
|
|
1136
|
+
slots(format) {
|
|
1137
|
+
const width = format.width - 2 * format.safeZone;
|
|
1138
|
+
return {
|
|
1139
|
+
headline: {
|
|
1140
|
+
maxLines: 2,
|
|
1141
|
+
maxFontSize: 72,
|
|
1142
|
+
minFontSize: 48,
|
|
1143
|
+
step: 4,
|
|
1144
|
+
box: { width }
|
|
1145
|
+
},
|
|
1146
|
+
subheadline: {
|
|
1147
|
+
maxLines: 2,
|
|
1148
|
+
maxFontSize: 32,
|
|
1149
|
+
minFontSize: 24,
|
|
1150
|
+
step: 2,
|
|
1151
|
+
box: { width }
|
|
1152
|
+
},
|
|
1153
|
+
cta: {
|
|
1154
|
+
maxLines: 1,
|
|
1155
|
+
maxFontSize: 28,
|
|
1156
|
+
minFontSize: 22,
|
|
1157
|
+
step: 2,
|
|
1158
|
+
box: { width: Math.min(480, width) }
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
},
|
|
1162
|
+
slotText(raw) {
|
|
1163
|
+
const props = propsSchema$1.parse(raw);
|
|
1164
|
+
return {
|
|
1165
|
+
headline: props.headline,
|
|
1166
|
+
subheadline: props.subheadline,
|
|
1167
|
+
cta: props.cta
|
|
1168
|
+
};
|
|
1169
|
+
},
|
|
1170
|
+
slotFont(raw, slot) {
|
|
1171
|
+
const props = propsSchema$1.parse(raw);
|
|
1172
|
+
if (slot === "headline") return {
|
|
1173
|
+
family: props.headingFamily,
|
|
1174
|
+
weight: props.headingWeight,
|
|
1175
|
+
lineHeight: 1.1
|
|
1176
|
+
};
|
|
1177
|
+
return {
|
|
1178
|
+
family: props.bodyFamily,
|
|
1179
|
+
weight: props.bodyWeight,
|
|
1180
|
+
lineHeight: 1.25
|
|
1181
|
+
};
|
|
1182
|
+
},
|
|
1183
|
+
buildProps(facts, copy, brand) {
|
|
1184
|
+
const missing = [];
|
|
1185
|
+
if (facts.project.npmPackage === void 0 && facts.project.url === void 0) missing.push({
|
|
1186
|
+
fact: "project.npmPackage",
|
|
1187
|
+
effect: "cta uses project name"
|
|
1188
|
+
});
|
|
1189
|
+
const showLogo = brand.logo !== void 0;
|
|
1190
|
+
if (!showLogo) missing.push({
|
|
1191
|
+
fact: "brand.logo",
|
|
1192
|
+
effect: "logo hidden"
|
|
1193
|
+
});
|
|
1194
|
+
const version = facts.release?.version.value ?? "";
|
|
1195
|
+
return {
|
|
1196
|
+
props: {
|
|
1197
|
+
name: brand.name,
|
|
1198
|
+
version,
|
|
1199
|
+
headline: copy.headline,
|
|
1200
|
+
subheadline: copy.subheadline,
|
|
1201
|
+
cta: copy.cta,
|
|
1202
|
+
headingFamily: brand.fonts.heading.family,
|
|
1203
|
+
bodyFamily: brand.fonts.body.family,
|
|
1204
|
+
headingWeight: brand.fonts.heading.weight,
|
|
1205
|
+
bodyWeight: brand.fonts.body.weight,
|
|
1206
|
+
radius: brand.radius,
|
|
1207
|
+
showCta: copy.cta.length > 0,
|
|
1208
|
+
showLogo
|
|
1209
|
+
},
|
|
1210
|
+
missing
|
|
1211
|
+
};
|
|
1212
|
+
},
|
|
1213
|
+
render(raw, ctx) {
|
|
1214
|
+
const props = propsSchema$1.parse(raw);
|
|
1215
|
+
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1216
|
+
const pad = ctx.format.safeZone;
|
|
1217
|
+
const headline = ctx.fitted.headline;
|
|
1218
|
+
const sub = ctx.fitted.subheadline;
|
|
1219
|
+
const cta = ctx.fitted.cta;
|
|
1220
|
+
const compact = ctx.format.height <= 640;
|
|
1221
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1222
|
+
style: {
|
|
1223
|
+
display: "flex",
|
|
1224
|
+
flexDirection: "column",
|
|
1225
|
+
justifyContent: "space-between",
|
|
1226
|
+
width: "100%",
|
|
1227
|
+
height: "100%",
|
|
1228
|
+
backgroundColor: colors.background,
|
|
1229
|
+
padding: pad
|
|
1230
|
+
},
|
|
1231
|
+
children: [
|
|
1232
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1233
|
+
style: {
|
|
1234
|
+
display: "flex",
|
|
1235
|
+
flexDirection: "row",
|
|
1236
|
+
alignItems: "center",
|
|
1237
|
+
gap: 20
|
|
1238
|
+
},
|
|
1239
|
+
children: [props.showLogo && ctx.logoSrc !== void 0 ? /* @__PURE__ */ jsx("img", {
|
|
1240
|
+
src: ctx.logoSrc,
|
|
1241
|
+
width: compact ? 56 : 72,
|
|
1242
|
+
height: compact ? 56 : 72
|
|
1243
|
+
}) : void 0, /* @__PURE__ */ jsxs("div", {
|
|
1244
|
+
style: {
|
|
1245
|
+
display: "flex",
|
|
1246
|
+
flexDirection: "column",
|
|
1247
|
+
gap: 8
|
|
1248
|
+
},
|
|
1249
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1250
|
+
style: {
|
|
1251
|
+
fontFamily: props.headingFamily,
|
|
1252
|
+
fontWeight: props.headingWeight,
|
|
1253
|
+
fontSize: 28,
|
|
1254
|
+
color: colors.foreground
|
|
1255
|
+
},
|
|
1256
|
+
children: props.name
|
|
1257
|
+
}), props.version.length > 0 ? /* @__PURE__ */ jsx("div", {
|
|
1258
|
+
style: {
|
|
1259
|
+
display: "flex",
|
|
1260
|
+
alignItems: "center",
|
|
1261
|
+
backgroundColor: colors.card,
|
|
1262
|
+
borderRadius: props.radius,
|
|
1263
|
+
paddingTop: 6,
|
|
1264
|
+
paddingBottom: 6,
|
|
1265
|
+
paddingLeft: 14,
|
|
1266
|
+
paddingRight: 14
|
|
1267
|
+
},
|
|
1268
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1269
|
+
style: {
|
|
1270
|
+
fontFamily: props.bodyFamily,
|
|
1271
|
+
fontWeight: props.bodyWeight,
|
|
1272
|
+
fontSize: 22,
|
|
1273
|
+
color: colors.primary
|
|
1274
|
+
},
|
|
1275
|
+
children: `v${props.version}`
|
|
1276
|
+
})
|
|
1277
|
+
}) : void 0]
|
|
1278
|
+
})]
|
|
1279
|
+
}),
|
|
1280
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1281
|
+
style: {
|
|
1282
|
+
display: "flex",
|
|
1283
|
+
flexDirection: "column",
|
|
1284
|
+
gap: 16
|
|
1285
|
+
},
|
|
1286
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1287
|
+
style: {
|
|
1288
|
+
fontFamily: props.headingFamily,
|
|
1289
|
+
fontWeight: props.headingWeight,
|
|
1290
|
+
fontSize: headline?.fontSize ?? 64,
|
|
1291
|
+
color: colors.foreground,
|
|
1292
|
+
lineHeight: 1.1
|
|
1293
|
+
},
|
|
1294
|
+
children: headline?.text ?? props.headline
|
|
1295
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1296
|
+
style: {
|
|
1297
|
+
fontFamily: props.bodyFamily,
|
|
1298
|
+
fontWeight: props.bodyWeight,
|
|
1299
|
+
fontSize: sub?.fontSize ?? 28,
|
|
1300
|
+
color: colors.muted,
|
|
1301
|
+
lineHeight: 1.25
|
|
1302
|
+
},
|
|
1303
|
+
children: sub?.text ?? props.subheadline
|
|
1304
|
+
})]
|
|
1305
|
+
}),
|
|
1306
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1307
|
+
style: {
|
|
1308
|
+
display: "flex",
|
|
1309
|
+
flexDirection: "row",
|
|
1310
|
+
justifyContent: "space-between",
|
|
1311
|
+
alignItems: "flex-end"
|
|
1312
|
+
},
|
|
1313
|
+
children: [props.showCta ? /* @__PURE__ */ jsx("div", {
|
|
1314
|
+
style: {
|
|
1315
|
+
display: "flex",
|
|
1316
|
+
backgroundColor: colors.primary,
|
|
1317
|
+
borderRadius: props.radius,
|
|
1318
|
+
paddingTop: 14,
|
|
1319
|
+
paddingBottom: 14,
|
|
1320
|
+
paddingLeft: 24,
|
|
1321
|
+
paddingRight: 24
|
|
1322
|
+
},
|
|
1323
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1324
|
+
style: {
|
|
1325
|
+
fontFamily: props.bodyFamily,
|
|
1326
|
+
fontWeight: props.headingWeight,
|
|
1327
|
+
fontSize: cta?.fontSize ?? 24,
|
|
1328
|
+
color: colors.background
|
|
1329
|
+
},
|
|
1330
|
+
children: cta?.text ?? props.cta
|
|
1331
|
+
})
|
|
1332
|
+
}) : /* @__PURE__ */ jsx("div", {}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
1333
|
+
style: {
|
|
1334
|
+
fontFamily: props.bodyFamily,
|
|
1335
|
+
fontWeight: props.bodyWeight,
|
|
1336
|
+
fontSize: 18,
|
|
1337
|
+
color: colors.muted
|
|
1338
|
+
},
|
|
1339
|
+
children: "made with shipseal.dev"
|
|
1340
|
+
}) : void 0]
|
|
1341
|
+
})
|
|
1342
|
+
]
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/templates/release-highlights.tsx
|
|
1349
|
+
const propsSchema = z.object({
|
|
1350
|
+
name: z.string(),
|
|
1351
|
+
version: z.string(),
|
|
1352
|
+
highlights: z.array(z.string()),
|
|
1353
|
+
headingFamily: z.string(),
|
|
1354
|
+
bodyFamily: z.string(),
|
|
1355
|
+
headingWeight: z.number(),
|
|
1356
|
+
bodyWeight: z.number(),
|
|
1357
|
+
radius: z.number()
|
|
1358
|
+
});
|
|
1359
|
+
const releaseHighlights = {
|
|
1360
|
+
id: "release-highlights",
|
|
1361
|
+
events: ["release"],
|
|
1362
|
+
formats: ["x", "linkedin"],
|
|
1363
|
+
propsSchema,
|
|
1364
|
+
slots(format) {
|
|
1365
|
+
const width = format.width - 2 * format.safeZone - 48;
|
|
1366
|
+
const slots = {};
|
|
1367
|
+
for (let index = 0; index < 4; index += 1) slots[`highlight-${String(index)}`] = {
|
|
1368
|
+
maxLines: 2,
|
|
1369
|
+
maxFontSize: 32,
|
|
1370
|
+
minFontSize: 24,
|
|
1371
|
+
step: 2,
|
|
1372
|
+
box: { width }
|
|
1373
|
+
};
|
|
1374
|
+
return slots;
|
|
1375
|
+
},
|
|
1376
|
+
slotText(raw) {
|
|
1377
|
+
const props = propsSchema.parse(raw);
|
|
1378
|
+
const text = {};
|
|
1379
|
+
props.highlights.forEach((line, index) => {
|
|
1380
|
+
text[`highlight-${String(index)}`] = line;
|
|
1381
|
+
});
|
|
1382
|
+
return text;
|
|
1383
|
+
},
|
|
1384
|
+
slotFont(raw) {
|
|
1385
|
+
const props = propsSchema.parse(raw);
|
|
1386
|
+
return {
|
|
1387
|
+
family: props.bodyFamily,
|
|
1388
|
+
weight: props.bodyWeight,
|
|
1389
|
+
lineHeight: 1.25
|
|
1390
|
+
};
|
|
1391
|
+
},
|
|
1392
|
+
buildProps(facts, copy, brand) {
|
|
1393
|
+
const missing = [];
|
|
1394
|
+
if (copy.highlights.length === 0) missing.push({
|
|
1395
|
+
fact: "release.features",
|
|
1396
|
+
effect: "highlights list empty"
|
|
1397
|
+
});
|
|
1398
|
+
return {
|
|
1399
|
+
props: {
|
|
1400
|
+
name: brand.name,
|
|
1401
|
+
version: facts.release?.version.value ?? "",
|
|
1402
|
+
highlights: copy.highlights,
|
|
1403
|
+
headingFamily: brand.fonts.heading.family,
|
|
1404
|
+
bodyFamily: brand.fonts.body.family,
|
|
1405
|
+
headingWeight: brand.fonts.heading.weight,
|
|
1406
|
+
bodyWeight: brand.fonts.body.weight,
|
|
1407
|
+
radius: brand.radius
|
|
1408
|
+
},
|
|
1409
|
+
missing
|
|
1410
|
+
};
|
|
1411
|
+
},
|
|
1412
|
+
render(raw, ctx) {
|
|
1413
|
+
const props = propsSchema.parse(raw);
|
|
1414
|
+
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1415
|
+
const pad = ctx.format.safeZone;
|
|
1416
|
+
const title = props.version.length > 0 ? `What's new in v${props.version}` : "What's new";
|
|
1417
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1418
|
+
style: {
|
|
1419
|
+
display: "flex",
|
|
1420
|
+
flexDirection: "column",
|
|
1421
|
+
width: "100%",
|
|
1422
|
+
height: "100%",
|
|
1423
|
+
backgroundColor: colors.background,
|
|
1424
|
+
padding: pad,
|
|
1425
|
+
gap: 28
|
|
1426
|
+
},
|
|
1427
|
+
children: [
|
|
1428
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1429
|
+
style: {
|
|
1430
|
+
display: "flex",
|
|
1431
|
+
flexDirection: "column",
|
|
1432
|
+
gap: 8
|
|
1433
|
+
},
|
|
1434
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1435
|
+
style: {
|
|
1436
|
+
fontFamily: props.bodyFamily,
|
|
1437
|
+
fontWeight: props.bodyWeight,
|
|
1438
|
+
fontSize: 22,
|
|
1439
|
+
color: colors.primary
|
|
1440
|
+
},
|
|
1441
|
+
children: props.name
|
|
1442
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1443
|
+
style: {
|
|
1444
|
+
fontFamily: props.headingFamily,
|
|
1445
|
+
fontWeight: props.headingWeight,
|
|
1446
|
+
fontSize: 48,
|
|
1447
|
+
color: colors.foreground
|
|
1448
|
+
},
|
|
1449
|
+
children: title
|
|
1450
|
+
})]
|
|
1451
|
+
}),
|
|
1452
|
+
/* @__PURE__ */ jsx("div", {
|
|
1453
|
+
style: {
|
|
1454
|
+
display: "flex",
|
|
1455
|
+
flexDirection: "column",
|
|
1456
|
+
gap: 16
|
|
1457
|
+
},
|
|
1458
|
+
children: props.highlights.map((line, index) => {
|
|
1459
|
+
const fitted = ctx.fitted[`highlight-${String(index)}`];
|
|
1460
|
+
const breaking = line.startsWith("Breaking:");
|
|
1461
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1462
|
+
style: {
|
|
1463
|
+
display: "flex",
|
|
1464
|
+
flexDirection: "row",
|
|
1465
|
+
alignItems: "flex-start",
|
|
1466
|
+
gap: 16,
|
|
1467
|
+
backgroundColor: colors.card,
|
|
1468
|
+
borderRadius: props.radius,
|
|
1469
|
+
padding: 18
|
|
1470
|
+
},
|
|
1471
|
+
children: [/* @__PURE__ */ jsx("div", { style: {
|
|
1472
|
+
width: 12,
|
|
1473
|
+
height: 12,
|
|
1474
|
+
borderRadius: 6,
|
|
1475
|
+
backgroundColor: breaking ? colors.accent : colors.primary,
|
|
1476
|
+
marginTop: 10
|
|
1477
|
+
} }), /* @__PURE__ */ jsx("div", {
|
|
1478
|
+
style: {
|
|
1479
|
+
fontFamily: props.bodyFamily,
|
|
1480
|
+
fontWeight: props.bodyWeight,
|
|
1481
|
+
fontSize: fitted?.fontSize ?? 28,
|
|
1482
|
+
color: colors.foreground,
|
|
1483
|
+
lineHeight: 1.25
|
|
1484
|
+
},
|
|
1485
|
+
children: fitted?.text ?? line
|
|
1486
|
+
})]
|
|
1487
|
+
}, String(index));
|
|
1488
|
+
})
|
|
1489
|
+
}),
|
|
1490
|
+
ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
1491
|
+
style: {
|
|
1492
|
+
fontFamily: props.bodyFamily,
|
|
1493
|
+
fontWeight: props.bodyWeight,
|
|
1494
|
+
fontSize: 18,
|
|
1495
|
+
color: colors.muted,
|
|
1496
|
+
marginTop: "auto"
|
|
1497
|
+
},
|
|
1498
|
+
children: "made with shipseal.dev"
|
|
1499
|
+
}) : void 0
|
|
1500
|
+
]
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region src/templates/registry.ts
|
|
1507
|
+
const templates = [
|
|
1508
|
+
releaseHero,
|
|
1509
|
+
releaseHighlights,
|
|
1510
|
+
codeCard,
|
|
1511
|
+
milestone,
|
|
1512
|
+
bench
|
|
1513
|
+
];
|
|
1514
|
+
function getTemplate(id) {
|
|
1515
|
+
const found = templates.find((template) => template.id === id);
|
|
1516
|
+
if (found === void 0) throw new ShipsealError("template.unknown", `Unknown template "${id}".`, `Use one of: ${templates.map((template) => template.id).join(", ")}.`);
|
|
1517
|
+
return found;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
//#endregion
|
|
1521
|
+
//#region src/core/generate.ts
|
|
1522
|
+
async function generate(input) {
|
|
1523
|
+
const templateIds = templateIdsFor(input.event, input.config);
|
|
1524
|
+
const formatIds = input.config.formats ?? [
|
|
1525
|
+
"og",
|
|
1526
|
+
"github-social",
|
|
1527
|
+
"x",
|
|
1528
|
+
"linkedin"
|
|
1529
|
+
];
|
|
1530
|
+
const imageFormat = input.config.output?.imageFormat ?? "png";
|
|
1531
|
+
const attribution = input.config.attribution !== false;
|
|
1532
|
+
const files = [];
|
|
1533
|
+
const warnings = [];
|
|
1534
|
+
const missing = [];
|
|
1535
|
+
const recordedMissing = /* @__PURE__ */ new Set();
|
|
1536
|
+
const omitThemeSuffix = input.themes.length === 1;
|
|
1537
|
+
for (const templateId of templateIds) {
|
|
1538
|
+
const template = getTemplate(templateId);
|
|
1539
|
+
if (!template.events.includes(input.event.kind)) continue;
|
|
1540
|
+
for (const formatId of formatIds) {
|
|
1541
|
+
if (!template.formats.includes(formatId)) continue;
|
|
1542
|
+
const format = FORMATS[formatId];
|
|
1543
|
+
for (const theme of input.themes) {
|
|
1544
|
+
const copy = await copyForTemplate(template.id, input.copy, input.facts, theme);
|
|
1545
|
+
const built = template.buildProps(input.facts, copy, input.brand);
|
|
1546
|
+
const props = template.propsSchema.parse(built.props);
|
|
1547
|
+
if (!recordedMissing.has(template.id)) {
|
|
1548
|
+
recordedMissing.add(template.id);
|
|
1549
|
+
for (const item of built.missing) missing.push({
|
|
1550
|
+
template: template.id,
|
|
1551
|
+
fact: item.fact,
|
|
1552
|
+
effect: item.effect
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
const { fitted, truncated } = await fitSlots(input.renderer, template, props, format);
|
|
1556
|
+
for (const slot of truncated) warnings.push({
|
|
1557
|
+
type: "fit-warning",
|
|
1558
|
+
template: template.id,
|
|
1559
|
+
format: formatId,
|
|
1560
|
+
slot,
|
|
1561
|
+
action: "truncated"
|
|
1562
|
+
});
|
|
1563
|
+
const logo = logoForTheme(input.logos, theme);
|
|
1564
|
+
const ctx = {
|
|
1565
|
+
format,
|
|
1566
|
+
theme,
|
|
1567
|
+
brand: input.brand,
|
|
1568
|
+
fitted,
|
|
1569
|
+
attribution
|
|
1570
|
+
};
|
|
1571
|
+
if (logo !== void 0) ctx.logoSrc = "shipseal-logo";
|
|
1572
|
+
const jsx = template.render(props, ctx);
|
|
1573
|
+
const node = await input.renderer.fromJsx(jsx);
|
|
1574
|
+
const renderOpts = {
|
|
1575
|
+
width: format.width,
|
|
1576
|
+
height: format.height,
|
|
1577
|
+
format: imageFormat
|
|
1578
|
+
};
|
|
1579
|
+
if (logo !== void 0) renderOpts.images = [{
|
|
1580
|
+
src: "shipseal-logo",
|
|
1581
|
+
data: logo
|
|
1582
|
+
}];
|
|
1583
|
+
const bytes = await input.renderer.render(node, renderOpts);
|
|
1584
|
+
const fileName = outputName(template.id, formatId, theme, omitThemeSuffix, imageFormat);
|
|
1585
|
+
files.push({
|
|
1586
|
+
fileName,
|
|
1587
|
+
template: template.id,
|
|
1588
|
+
format: formatId,
|
|
1589
|
+
theme,
|
|
1590
|
+
width: format.width,
|
|
1591
|
+
height: format.height,
|
|
1592
|
+
bytes,
|
|
1593
|
+
sha256: createHash("sha256").update(bytes).digest("hex")
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return {
|
|
1599
|
+
files,
|
|
1600
|
+
warnings,
|
|
1601
|
+
missing,
|
|
1602
|
+
facts: input.facts,
|
|
1603
|
+
copy: input.copy,
|
|
1604
|
+
copyMode: input.copyMode,
|
|
1605
|
+
generatedAt: input.generatedAt,
|
|
1606
|
+
computed: computedFromFacts(input.facts)
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
function templateIdsFor(event, config) {
|
|
1610
|
+
if (event.kind === "milestone") return ["milestone"];
|
|
1611
|
+
if (event.kind === "bench") return ["bench"];
|
|
1612
|
+
return config.release?.templates ?? [
|
|
1613
|
+
"release-hero",
|
|
1614
|
+
"release-highlights",
|
|
1615
|
+
"code-card"
|
|
1616
|
+
];
|
|
1617
|
+
}
|
|
1618
|
+
function computedFromFacts(facts) {
|
|
1619
|
+
const out = {};
|
|
1620
|
+
if (facts.bench === void 0) return out;
|
|
1621
|
+
facts.bench.metrics.forEach((metric, index) => {
|
|
1622
|
+
const change = percentChange(metric.before.value, metric.after.value, metric.better.value);
|
|
1623
|
+
const prefix = `bench.metrics[${String(index)}]`;
|
|
1624
|
+
out[`${prefix}.percent`] = {
|
|
1625
|
+
value: change.signed,
|
|
1626
|
+
computedFrom: [
|
|
1627
|
+
`${prefix}.before`,
|
|
1628
|
+
`${prefix}.after`,
|
|
1629
|
+
`${prefix}.better`
|
|
1630
|
+
]
|
|
1631
|
+
};
|
|
1632
|
+
});
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
async function fitSlots(renderer, template, props, format) {
|
|
1636
|
+
const specs = template.slots(format);
|
|
1637
|
+
const texts = template.slotText(props);
|
|
1638
|
+
const fitted = {};
|
|
1639
|
+
const truncated = [];
|
|
1640
|
+
for (const [slot, spec] of Object.entries(specs)) {
|
|
1641
|
+
const text = texts[slot];
|
|
1642
|
+
if (text === void 0) continue;
|
|
1643
|
+
const font = template.slotFont(props, slot);
|
|
1644
|
+
const fitFont = {
|
|
1645
|
+
family: font.family,
|
|
1646
|
+
lineHeight: font.lineHeight,
|
|
1647
|
+
weight: font.weight
|
|
1648
|
+
};
|
|
1649
|
+
if (font.whiteSpace !== void 0) fitFont.whiteSpace = font.whiteSpace;
|
|
1650
|
+
const result = await fitText(renderer, text, spec, fitFont);
|
|
1651
|
+
fitted[slot] = result;
|
|
1652
|
+
if (result.truncated) truncated.push(slot);
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
fitted,
|
|
1656
|
+
truncated
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
function outputName(template, format, theme, omitTheme, imageFormat) {
|
|
1660
|
+
return `${template}-${format}${omitTheme ? "" : `-${theme}`}.${imageFormat}`;
|
|
1661
|
+
}
|
|
1662
|
+
function logoForTheme(logos, theme) {
|
|
1663
|
+
if (logos === void 0) return;
|
|
1664
|
+
if (theme === "dark") return logos.dark ?? logos.light;
|
|
1665
|
+
return logos.light ?? logos.dark;
|
|
1666
|
+
}
|
|
1667
|
+
async function copyForTemplate(templateId, copy, facts, theme) {
|
|
1668
|
+
if (templateId !== "code-card") return copy;
|
|
1669
|
+
const snippet = facts.release?.codeSnippet?.value;
|
|
1670
|
+
if (snippet === void 0) return {
|
|
1671
|
+
...copy,
|
|
1672
|
+
codeLines: emptyCodeLines()
|
|
1673
|
+
};
|
|
1674
|
+
return {
|
|
1675
|
+
...copy,
|
|
1676
|
+
codeLines: await highlightCode(snippet.code, snippet.lang, theme)
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region src/facts/merge.ts
|
|
1682
|
+
function mergeFacts(parts) {
|
|
1683
|
+
const project = {};
|
|
1684
|
+
const release = {};
|
|
1685
|
+
const metrics = {};
|
|
1686
|
+
const milestone = {};
|
|
1687
|
+
let bench;
|
|
1688
|
+
for (const part of parts) {
|
|
1689
|
+
fillObject(project, part.project);
|
|
1690
|
+
mergeRelease(release, part.release);
|
|
1691
|
+
fillObject(metrics, part.metrics);
|
|
1692
|
+
fillObject(milestone, part.milestone);
|
|
1693
|
+
if (part.bench !== void 0) bench = part.bench;
|
|
1694
|
+
}
|
|
1695
|
+
if (project.name === void 0) throw new ShipsealError("facts.missing-name", "Could not determine the project name.", "Add a name field to package.json, or an H1 in README.md.");
|
|
1696
|
+
const facts = { project: { name: project.name } };
|
|
1697
|
+
if (project.tagline !== void 0) facts.project.tagline = project.tagline;
|
|
1698
|
+
if (project.url !== void 0) facts.project.url = project.url;
|
|
1699
|
+
if (project.repo !== void 0) facts.project.repo = project.repo;
|
|
1700
|
+
if (project.npmPackage !== void 0) facts.project.npmPackage = project.npmPackage;
|
|
1701
|
+
if (project.license !== void 0) facts.project.license = project.license;
|
|
1702
|
+
if (isCompleteRelease(release)) {
|
|
1703
|
+
facts.release = {
|
|
1704
|
+
version: release.version,
|
|
1705
|
+
tag: release.tag,
|
|
1706
|
+
date: release.date,
|
|
1707
|
+
features: release.features ?? [],
|
|
1708
|
+
fixes: release.fixes ?? [],
|
|
1709
|
+
breaking: release.breaking ?? []
|
|
1710
|
+
};
|
|
1711
|
+
if (release.previousVersion !== void 0) facts.release.previousVersion = release.previousVersion;
|
|
1712
|
+
if (release.commitCount !== void 0) facts.release.commitCount = release.commitCount;
|
|
1713
|
+
if (release.contributors !== void 0) facts.release.contributors = release.contributors;
|
|
1714
|
+
if (release.codeSnippet !== void 0) facts.release.codeSnippet = release.codeSnippet;
|
|
1715
|
+
}
|
|
1716
|
+
if (Object.keys(metrics).length > 0) facts.metrics = metrics;
|
|
1717
|
+
if (bench !== void 0) facts.bench = bench;
|
|
1718
|
+
if (milestone.metric !== void 0 && milestone.threshold !== void 0) facts.milestone = {
|
|
1719
|
+
metric: milestone.metric,
|
|
1720
|
+
threshold: milestone.threshold
|
|
1721
|
+
};
|
|
1722
|
+
return facts;
|
|
1723
|
+
}
|
|
1724
|
+
function mergeRelease(target, overlay) {
|
|
1725
|
+
if (overlay === void 0) return;
|
|
1726
|
+
fillObject(target, overlay, [
|
|
1727
|
+
"features",
|
|
1728
|
+
"fixes",
|
|
1729
|
+
"breaking"
|
|
1730
|
+
]);
|
|
1731
|
+
replaceList(target, overlay, "features");
|
|
1732
|
+
replaceList(target, overlay, "fixes");
|
|
1733
|
+
replaceList(target, overlay, "breaking");
|
|
1734
|
+
}
|
|
1735
|
+
function replaceList(target, overlay, key) {
|
|
1736
|
+
const next = overlay[key];
|
|
1737
|
+
if (next !== void 0) target[key] = next;
|
|
1738
|
+
}
|
|
1739
|
+
function fillObject(target, overlay, skip = []) {
|
|
1740
|
+
if (overlay === void 0) return;
|
|
1741
|
+
const skipped = new Set(skip);
|
|
1742
|
+
for (const key of Object.keys(overlay)) {
|
|
1743
|
+
if (skipped.has(key)) continue;
|
|
1744
|
+
const value = overlay[key];
|
|
1745
|
+
if (value !== void 0 && target[key] === void 0) target[key] = value;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
function isCompleteRelease(release) {
|
|
1749
|
+
return release.version !== void 0 && release.tag !== void 0 && release.date !== void 0;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
//#endregion
|
|
1753
|
+
//#region src/facts/fact.ts
|
|
1754
|
+
function fact(value, provenance) {
|
|
1755
|
+
const fetchedAt = provenance.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1756
|
+
return {
|
|
1757
|
+
value,
|
|
1758
|
+
provenance: {
|
|
1759
|
+
source: provenance.source,
|
|
1760
|
+
ref: provenance.ref,
|
|
1761
|
+
fetchedAt
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
//#endregion
|
|
1767
|
+
//#region src/sources/bench-file.ts
|
|
1768
|
+
const metricSchema = z.object({
|
|
1769
|
+
label: z.string().min(1),
|
|
1770
|
+
before: z.number().finite(),
|
|
1771
|
+
after: z.number().finite(),
|
|
1772
|
+
unit: z.string().min(1),
|
|
1773
|
+
better: z.enum(["lower", "higher"])
|
|
1774
|
+
});
|
|
1775
|
+
const benchFileSchema = z.object({
|
|
1776
|
+
title: z.string().min(1),
|
|
1777
|
+
metrics: z.array(metricSchema).min(1).max(3),
|
|
1778
|
+
note: z.string().optional()
|
|
1779
|
+
});
|
|
1780
|
+
async function collectBenchFile(cwd, filePath = ".shipseal/bench.json") {
|
|
1781
|
+
const resolved = isAbsolute(filePath) ? filePath : join(cwd, filePath);
|
|
1782
|
+
let raw;
|
|
1783
|
+
try {
|
|
1784
|
+
raw = await readFile(resolved, "utf8");
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
throw new ShipsealError("bench.missing-file", `Benchmark file not found: ${filePath}.`, "Write .shipseal/bench.json (title, metrics with before/after/unit/better), or pass --file <path>.", { cause: error });
|
|
1787
|
+
}
|
|
1788
|
+
let json;
|
|
1789
|
+
try {
|
|
1790
|
+
json = JSON.parse(raw);
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
throw new ShipsealError("bench.invalid-json", `Benchmark file is not valid JSON: ${filePath}.`, "Fix the JSON syntax, then retry.", { cause: error });
|
|
1793
|
+
}
|
|
1794
|
+
const parsed = benchFileSchema.safeParse(json);
|
|
1795
|
+
if (!parsed.success) throw new ShipsealError("bench.invalid-shape", `Benchmark file is missing required fields: ${filePath}.`, "Each metric needs label, before, after, unit, and better (\"lower\" or \"higher\"). At most 3 metrics.");
|
|
1796
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1797
|
+
const ref = filePath;
|
|
1798
|
+
const bench = {
|
|
1799
|
+
title: fact(parsed.data.title, {
|
|
1800
|
+
source: "bench-file",
|
|
1801
|
+
ref: `${ref}#title`,
|
|
1802
|
+
fetchedAt
|
|
1803
|
+
}),
|
|
1804
|
+
metrics: parsed.data.metrics.map((metric, index) => ({
|
|
1805
|
+
label: fact(metric.label, {
|
|
1806
|
+
source: "bench-file",
|
|
1807
|
+
ref: `${ref}#metrics[${String(index)}].label`,
|
|
1808
|
+
fetchedAt
|
|
1809
|
+
}),
|
|
1810
|
+
before: fact(metric.before, {
|
|
1811
|
+
source: "bench-file",
|
|
1812
|
+
ref: `${ref}#metrics[${String(index)}].before`,
|
|
1813
|
+
fetchedAt
|
|
1814
|
+
}),
|
|
1815
|
+
after: fact(metric.after, {
|
|
1816
|
+
source: "bench-file",
|
|
1817
|
+
ref: `${ref}#metrics[${String(index)}].after`,
|
|
1818
|
+
fetchedAt
|
|
1819
|
+
}),
|
|
1820
|
+
unit: fact(metric.unit, {
|
|
1821
|
+
source: "bench-file",
|
|
1822
|
+
ref: `${ref}#metrics[${String(index)}].unit`,
|
|
1823
|
+
fetchedAt
|
|
1824
|
+
}),
|
|
1825
|
+
better: fact(metric.better, {
|
|
1826
|
+
source: "bench-file",
|
|
1827
|
+
ref: `${ref}#metrics[${String(index)}].better`,
|
|
1828
|
+
fetchedAt
|
|
1829
|
+
})
|
|
1830
|
+
}))
|
|
1831
|
+
};
|
|
1832
|
+
if (parsed.data.note !== void 0) bench.note = fact(parsed.data.note, {
|
|
1833
|
+
source: "bench-file",
|
|
1834
|
+
ref: `${ref}#note`,
|
|
1835
|
+
fetchedAt
|
|
1836
|
+
});
|
|
1837
|
+
return { bench };
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
//#endregion
|
|
1841
|
+
//#region src/sources/changelog.ts
|
|
1842
|
+
async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
|
|
1843
|
+
if (version === void 0) return {};
|
|
1844
|
+
let markdown;
|
|
1845
|
+
try {
|
|
1846
|
+
markdown = await readFile(join(cwd, changelogPath), "utf8");
|
|
1847
|
+
} catch {
|
|
1848
|
+
return {};
|
|
1849
|
+
}
|
|
1850
|
+
const section = findVersionSection(markdown, version);
|
|
1851
|
+
if (section === void 0) return {};
|
|
1852
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1853
|
+
const features = [];
|
|
1854
|
+
const fixes = [];
|
|
1855
|
+
const breaking = [];
|
|
1856
|
+
for (const group of parseGroups(section.body)) {
|
|
1857
|
+
const bucket = bucketForHeading(group.heading);
|
|
1858
|
+
if (bucket === void 0) continue;
|
|
1859
|
+
for (const item of group.items) {
|
|
1860
|
+
const cleaned = cleanChangelogItem(item);
|
|
1861
|
+
if (cleaned.length === 0) continue;
|
|
1862
|
+
const entry = fact(cleaned, {
|
|
1863
|
+
source: "changelog",
|
|
1864
|
+
ref: `${changelogPath} ${section.heading} ${group.heading}: ${item}`,
|
|
1865
|
+
fetchedAt
|
|
1866
|
+
});
|
|
1867
|
+
if (bucket === "features") features.push(entry);
|
|
1868
|
+
else if (bucket === "fixes") fixes.push(entry);
|
|
1869
|
+
else breaking.push(entry);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
const out = { release: {
|
|
1873
|
+
version: fact(version.replace(/^v/, ""), {
|
|
1874
|
+
source: "changelog",
|
|
1875
|
+
ref: `${changelogPath} ${section.heading}`,
|
|
1876
|
+
fetchedAt
|
|
1877
|
+
}),
|
|
1878
|
+
tag: fact(version.startsWith("v") ? version : `v${version}`, {
|
|
1879
|
+
source: "changelog",
|
|
1880
|
+
ref: `${changelogPath} ${section.heading}`,
|
|
1881
|
+
fetchedAt
|
|
1882
|
+
}),
|
|
1883
|
+
features,
|
|
1884
|
+
fixes,
|
|
1885
|
+
breaking
|
|
1886
|
+
} };
|
|
1887
|
+
if (section.date !== void 0) out.release = {
|
|
1888
|
+
...out.release,
|
|
1889
|
+
date: fact(section.date, {
|
|
1890
|
+
source: "changelog",
|
|
1891
|
+
ref: `${changelogPath} ${section.heading}`,
|
|
1892
|
+
fetchedAt
|
|
1893
|
+
})
|
|
1894
|
+
};
|
|
1895
|
+
return out;
|
|
1896
|
+
}
|
|
1897
|
+
function findVersionSection(markdown, version) {
|
|
1898
|
+
const needle = version.replace(/^v/, "");
|
|
1899
|
+
const matches = [...markdown.matchAll(/^##\s+(.+)$/gm)];
|
|
1900
|
+
for (let index = 0; index < matches.length; index += 1) {
|
|
1901
|
+
const match = matches[index];
|
|
1902
|
+
if (match === void 0) continue;
|
|
1903
|
+
const heading = match[1];
|
|
1904
|
+
if (heading === void 0 || !headingIncludesVersion(heading, needle)) continue;
|
|
1905
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
1906
|
+
const end = matches[index + 1]?.index ?? markdown.length;
|
|
1907
|
+
const date = extractDate(heading);
|
|
1908
|
+
const body = markdown.slice(start, end);
|
|
1909
|
+
if (date === void 0) return {
|
|
1910
|
+
heading,
|
|
1911
|
+
body
|
|
1912
|
+
};
|
|
1913
|
+
return {
|
|
1914
|
+
heading,
|
|
1915
|
+
body,
|
|
1916
|
+
date
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
function cleanChangelogItem(item) {
|
|
1921
|
+
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*/, "").replace(/\s+/g, " ").trim().replace(/\.$/, "");
|
|
1922
|
+
}
|
|
1923
|
+
function headingIncludesVersion(heading, version) {
|
|
1924
|
+
const unwrapped = heading.replace(/[[\]]/g, " ");
|
|
1925
|
+
return new RegExp(`(?:^|\\s)v?${escapeRegExp(version)}(?:\\s|$)`).test(unwrapped);
|
|
1926
|
+
}
|
|
1927
|
+
function extractDate(heading) {
|
|
1928
|
+
return /(\d{4}-\d{2}-\d{2})/.exec(heading)?.[1];
|
|
1929
|
+
}
|
|
1930
|
+
function parseGroups(body) {
|
|
1931
|
+
const groups = [];
|
|
1932
|
+
const matches = [...body.matchAll(/^###\s+(.+)$/gm)];
|
|
1933
|
+
if (matches.length === 0) return [{
|
|
1934
|
+
heading: "Added",
|
|
1935
|
+
items: listItems(body)
|
|
1936
|
+
}];
|
|
1937
|
+
for (let index = 0; index < matches.length; index += 1) {
|
|
1938
|
+
const match = matches[index];
|
|
1939
|
+
if (match === void 0) continue;
|
|
1940
|
+
const heading = match[1];
|
|
1941
|
+
if (heading === void 0) continue;
|
|
1942
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
1943
|
+
const end = matches[index + 1]?.index ?? body.length;
|
|
1944
|
+
groups.push({
|
|
1945
|
+
heading,
|
|
1946
|
+
items: listItems(body.slice(start, end))
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
return groups;
|
|
1950
|
+
}
|
|
1951
|
+
function listItems(block) {
|
|
1952
|
+
const items = [];
|
|
1953
|
+
let current;
|
|
1954
|
+
for (const raw of block.split("\n")) {
|
|
1955
|
+
const trimmed = raw.trim();
|
|
1956
|
+
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
|
1957
|
+
if (current !== void 0) items.push(current);
|
|
1958
|
+
current = trimmed;
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
if (current !== void 0 && trimmed.length > 0 && !trimmed.startsWith("#")) current = `${current} ${trimmed}`;
|
|
1962
|
+
}
|
|
1963
|
+
if (current !== void 0) items.push(current);
|
|
1964
|
+
return items;
|
|
1965
|
+
}
|
|
1966
|
+
function bucketForHeading(heading) {
|
|
1967
|
+
const key = heading.trim().toLowerCase();
|
|
1968
|
+
if (key === "added" || key === "changed" || key === "minor changes" || key === "features") return "features";
|
|
1969
|
+
if (key === "fixed" || key === "patch changes" || key === "security" || key === "fixes") return "fixes";
|
|
1970
|
+
if (key === "removed" || key === "major changes" || key === "breaking" || key === "breaking changes") return "breaking";
|
|
1971
|
+
}
|
|
1972
|
+
function escapeRegExp(value) {
|
|
1973
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
//#endregion
|
|
1977
|
+
//#region src/sources/git.ts
|
|
1978
|
+
const execFileAsync$3 = promisify(execFile);
|
|
1979
|
+
async function collectGit(cwd, event) {
|
|
1980
|
+
if (await git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") return {};
|
|
1981
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1982
|
+
const tag = event.tag ?? await gitCurrentTag(cwd);
|
|
1983
|
+
if (tag === void 0) return {};
|
|
1984
|
+
if (await git(cwd, [
|
|
1985
|
+
"rev-parse",
|
|
1986
|
+
"--verify",
|
|
1987
|
+
`${tag}^{commit}`
|
|
1988
|
+
]) === void 0) return {};
|
|
1989
|
+
const version = versionFromTag(tag);
|
|
1990
|
+
const tags = await gitLines(cwd, ["tag", "--sort=-v:refname"]);
|
|
1991
|
+
const tagIndex = tags.indexOf(tag);
|
|
1992
|
+
const previousTag = event.previousTag ?? (tagIndex >= 0 ? tags[tagIndex + 1] : tags.find((candidate) => candidate !== tag));
|
|
1993
|
+
const range = previousTag === void 0 ? tag : `${previousTag}..${tag}`;
|
|
1994
|
+
const commits = (await git(cwd, [
|
|
1995
|
+
"log",
|
|
1996
|
+
range,
|
|
1997
|
+
"--pretty=format:%s %an %ae %h %cI"
|
|
1998
|
+
]) ?? "").split("\n").filter((line) => line.length > 0);
|
|
1999
|
+
const features = [];
|
|
2000
|
+
const fixes = [];
|
|
2001
|
+
const breaking = [];
|
|
2002
|
+
const contributors = /* @__PURE__ */ new Map();
|
|
2003
|
+
for (const line of commits) {
|
|
2004
|
+
const [subject, author, email, hash] = line.split(" ");
|
|
2005
|
+
if (subject === void 0 || hash === void 0) continue;
|
|
2006
|
+
const parsed = parseConventional(subject);
|
|
2007
|
+
const display = parsed.display;
|
|
2008
|
+
const ref = `git log ${range} ${hash}`;
|
|
2009
|
+
const item = fact(display, {
|
|
2010
|
+
source: "git",
|
|
2011
|
+
ref,
|
|
2012
|
+
fetchedAt
|
|
2013
|
+
});
|
|
2014
|
+
if (parsed.breaking) breaking.push(item);
|
|
2015
|
+
else if (parsed.type === "feat") features.push(item);
|
|
2016
|
+
else if (parsed.type === "fix" || parsed.type === "perf") fixes.push(item);
|
|
2017
|
+
if (email !== void 0 && author !== void 0 && !isBot(author, email)) contributors.set(email, author);
|
|
2018
|
+
}
|
|
2019
|
+
const date = (await git(cwd, [
|
|
2020
|
+
"log",
|
|
2021
|
+
"-1",
|
|
2022
|
+
"--format=%cI",
|
|
2023
|
+
tag
|
|
2024
|
+
]) ?? fetchedAt).slice(0, 10);
|
|
2025
|
+
const names = [...contributors.values()];
|
|
2026
|
+
const release = {
|
|
2027
|
+
version: fact(version, {
|
|
2028
|
+
source: "git",
|
|
2029
|
+
ref: `git tag ${tag}`,
|
|
2030
|
+
fetchedAt
|
|
2031
|
+
}),
|
|
2032
|
+
tag: fact(tag, {
|
|
2033
|
+
source: "git",
|
|
2034
|
+
ref: `git tag ${tag}`,
|
|
2035
|
+
fetchedAt
|
|
2036
|
+
}),
|
|
2037
|
+
date: fact(date, {
|
|
2038
|
+
source: "git",
|
|
2039
|
+
ref: `git log -1 --format=%cI ${tag}`,
|
|
2040
|
+
fetchedAt
|
|
2041
|
+
}),
|
|
2042
|
+
features,
|
|
2043
|
+
fixes,
|
|
2044
|
+
breaking,
|
|
2045
|
+
commitCount: fact(commits.length, {
|
|
2046
|
+
source: "git",
|
|
2047
|
+
ref: `git rev-list --count ${range}`,
|
|
2048
|
+
fetchedAt
|
|
2049
|
+
})
|
|
2050
|
+
};
|
|
2051
|
+
if (previousTag !== void 0) release.previousVersion = fact(versionFromTag(previousTag), {
|
|
2052
|
+
source: "git",
|
|
2053
|
+
ref: `git tag ${previousTag}`,
|
|
2054
|
+
fetchedAt
|
|
2055
|
+
});
|
|
2056
|
+
if (names.length > 0) release.contributors = fact(names, {
|
|
2057
|
+
source: "git",
|
|
2058
|
+
ref: `git log ${range} unique authors`,
|
|
2059
|
+
fetchedAt
|
|
2060
|
+
});
|
|
2061
|
+
return { release };
|
|
2062
|
+
}
|
|
2063
|
+
function parseConventional(subject) {
|
|
2064
|
+
const match = /^(?<type>\w+)(?<scope>\([^)]+\))?(?<bang>!)?:\s*(?<rest>.+)$/.exec(subject);
|
|
2065
|
+
if (match === null || match.groups === void 0) return {
|
|
2066
|
+
type: void 0,
|
|
2067
|
+
display: displaySubject(subject),
|
|
2068
|
+
breaking: false
|
|
2069
|
+
};
|
|
2070
|
+
const type = match.groups.type;
|
|
2071
|
+
const rest = match.groups.rest ?? subject;
|
|
2072
|
+
const breaking = match.groups.bang === "!" || /BREAKING CHANGE:/.test(subject);
|
|
2073
|
+
return {
|
|
2074
|
+
type,
|
|
2075
|
+
display: displaySubject(rest),
|
|
2076
|
+
breaking
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
function versionFromTag(tag) {
|
|
2080
|
+
const at = tag.lastIndexOf("@");
|
|
2081
|
+
if (at >= 0 && at < tag.length - 1) return tag.slice(at + 1).replace(/^v/, "");
|
|
2082
|
+
return tag.replace(/^v/, "");
|
|
2083
|
+
}
|
|
2084
|
+
async function gitCurrentTag(cwd) {
|
|
2085
|
+
return git(cwd, [
|
|
2086
|
+
"describe",
|
|
2087
|
+
"--tags",
|
|
2088
|
+
"--abbrev=0"
|
|
2089
|
+
]);
|
|
2090
|
+
}
|
|
2091
|
+
async function git(cwd, args) {
|
|
2092
|
+
try {
|
|
2093
|
+
const { stdout } = await execFileAsync$3("git", args, { cwd });
|
|
2094
|
+
const trimmed = stdout.trim();
|
|
2095
|
+
return trimmed.length === 0 ? void 0 : trimmed;
|
|
2096
|
+
} catch {
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
async function gitLines(cwd, args) {
|
|
2101
|
+
const out = await git(cwd, args);
|
|
2102
|
+
return out === void 0 ? [] : out.split("\n").filter((line) => line.length > 0);
|
|
2103
|
+
}
|
|
2104
|
+
function isBot(author, email) {
|
|
2105
|
+
const haystack = `${author} ${email}`.toLowerCase();
|
|
2106
|
+
return author.endsWith("[bot]") || haystack.includes("dependabot") || haystack.includes("renovate") || haystack.includes("github-actions");
|
|
2107
|
+
}
|
|
2108
|
+
function displaySubject(text) {
|
|
2109
|
+
const cleaned = cleanChangelogItem(text);
|
|
2110
|
+
const first = cleaned.at(0);
|
|
2111
|
+
if (first === void 0) return cleaned;
|
|
2112
|
+
return first.toUpperCase() + cleaned.slice(1);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
//#endregion
|
|
2116
|
+
//#region src/sources/github.ts
|
|
2117
|
+
const API_VERSION = "2022-11-28";
|
|
2118
|
+
const API_ROOT$1 = "https://api.github.com";
|
|
2119
|
+
const repoSchema = z.object({
|
|
2120
|
+
stargazers_count: z.number().int().nonnegative(),
|
|
2121
|
+
description: z.string().nullable().optional(),
|
|
2122
|
+
homepage: z.string().nullable().optional(),
|
|
2123
|
+
license: z.object({ spdx_id: z.string().nullable() }).nullable().optional()
|
|
2124
|
+
});
|
|
2125
|
+
const releaseSchema = z.object({ body: z.string().nullable().optional() });
|
|
2126
|
+
const contributorsSchema = z.array(z.unknown());
|
|
2127
|
+
async function collectGithub(options) {
|
|
2128
|
+
const slug = parseRepo(options.repo);
|
|
2129
|
+
if (slug === void 0) return {};
|
|
2130
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2131
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
2132
|
+
const cache = options.cache ?? /* @__PURE__ */ new Map();
|
|
2133
|
+
const repoResponse = await githubGet(`${API_ROOT$1}/repos/${slug.owner}/${slug.repo}`, options.token, fetchImpl, cache);
|
|
2134
|
+
if (repoResponse === void 0) return {};
|
|
2135
|
+
const repo = repoSchema.parse(repoResponse.json);
|
|
2136
|
+
const out = {
|
|
2137
|
+
metrics: { stars: fact(repo.stargazers_count, {
|
|
2138
|
+
source: "github-api",
|
|
2139
|
+
ref: `GET /repos/${slug.owner}/${slug.repo} stargazers_count`,
|
|
2140
|
+
fetchedAt
|
|
2141
|
+
}) },
|
|
2142
|
+
project: {}
|
|
2143
|
+
};
|
|
2144
|
+
if (repo.description !== void 0 && repo.description !== null && repo.description.length > 0) out.project = {
|
|
2145
|
+
...out.project,
|
|
2146
|
+
tagline: fact(repo.description, {
|
|
2147
|
+
source: "github-api",
|
|
2148
|
+
ref: `GET /repos/${slug.owner}/${slug.repo} description`,
|
|
2149
|
+
fetchedAt
|
|
2150
|
+
})
|
|
2151
|
+
};
|
|
2152
|
+
if (repo.homepage !== void 0 && repo.homepage !== null && repo.homepage.length > 0) out.project = {
|
|
2153
|
+
...out.project,
|
|
2154
|
+
url: fact(repo.homepage, {
|
|
2155
|
+
source: "github-api",
|
|
2156
|
+
ref: `GET /repos/${slug.owner}/${slug.repo} homepage`,
|
|
2157
|
+
fetchedAt
|
|
2158
|
+
})
|
|
2159
|
+
};
|
|
2160
|
+
const license = repo.license?.spdx_id;
|
|
2161
|
+
if (license !== void 0 && license !== null && license !== "NOASSERTION") out.project = {
|
|
2162
|
+
...out.project,
|
|
2163
|
+
license: fact(license, {
|
|
2164
|
+
source: "github-api",
|
|
2165
|
+
ref: `GET /repos/${slug.owner}/${slug.repo} license.spdx_id`,
|
|
2166
|
+
fetchedAt
|
|
2167
|
+
})
|
|
2168
|
+
};
|
|
2169
|
+
if (options.tag !== void 0) {
|
|
2170
|
+
const releaseResponse = await githubGet(`${API_ROOT$1}/repos/${slug.owner}/${slug.repo}/releases/tags/${encodeURIComponent(options.tag)}`, options.token, fetchImpl, cache, { notFoundOk: true });
|
|
2171
|
+
if (releaseResponse !== void 0) {
|
|
2172
|
+
const features = featuresFromReleaseBody(releaseSchema.parse(releaseResponse.json).body ?? "", options.tag, fetchedAt);
|
|
2173
|
+
if (features.length > 0) out.release = { features };
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const contributors = await githubGet(`${API_ROOT$1}/repos/${slug.owner}/${slug.repo}/contributors?per_page=1&anon=true`, options.token, fetchImpl, cache, { notFoundOk: true });
|
|
2177
|
+
if (contributors !== void 0) {
|
|
2178
|
+
contributorsSchema.parse(contributors.json);
|
|
2179
|
+
const listed = Array.isArray(contributors.json) ? contributors.json.length : 0;
|
|
2180
|
+
const fromLink = lastPageFromLink(contributors.link);
|
|
2181
|
+
out.metrics = {
|
|
2182
|
+
...out.metrics,
|
|
2183
|
+
contributorCount: fact(fromLink ?? listed, {
|
|
2184
|
+
source: "github-api",
|
|
2185
|
+
ref: `GET /repos/${slug.owner}/${slug.repo}/contributors`,
|
|
2186
|
+
fetchedAt
|
|
2187
|
+
})
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
return out;
|
|
2191
|
+
}
|
|
2192
|
+
function parseRepo(value) {
|
|
2193
|
+
const trimmed = value.trim().replace(/\.git$/, "");
|
|
2194
|
+
const match = /^(?:https?:\/\/github\.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
|
2195
|
+
if (match === null || match[1] === void 0 || match[2] === void 0) return;
|
|
2196
|
+
return {
|
|
2197
|
+
owner: match[1],
|
|
2198
|
+
repo: match[2]
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
function featuresFromReleaseBody(body, tag, fetchedAt) {
|
|
2202
|
+
return body.split("\n").map((line) => line.replace(/^\s*[-*]\s*/, "").trim()).filter((line) => line.length > 0 && !line.startsWith("#")).slice(0, 8).map((line) => fact(line.replace(/\.$/, ""), {
|
|
2203
|
+
source: "github-api",
|
|
2204
|
+
ref: `GET /repos/.../releases/tags/${tag} body`,
|
|
2205
|
+
fetchedAt
|
|
2206
|
+
}));
|
|
2207
|
+
}
|
|
2208
|
+
async function githubGet(url, token, fetchImpl, cache, opts = {}) {
|
|
2209
|
+
const cached = cache.get(url);
|
|
2210
|
+
if (cached !== void 0) return cached;
|
|
2211
|
+
const headers = {
|
|
2212
|
+
Accept: "application/vnd.github+json",
|
|
2213
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
2214
|
+
"User-Agent": "shipseal"
|
|
2215
|
+
};
|
|
2216
|
+
if (token !== void 0 && token.length > 0) headers.Authorization = `Bearer ${token}`;
|
|
2217
|
+
let response;
|
|
2218
|
+
try {
|
|
2219
|
+
response = await fetchImpl(url, { headers });
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
throw new ShipsealError("github.network", "Could not reach the GitHub API.", "Check network access, then retry. Unauthenticated calls are optional; facts still come from git and package.json.", { cause: error });
|
|
2222
|
+
}
|
|
2223
|
+
if (response.status === 404 && opts.notFoundOk === true) return;
|
|
2224
|
+
if (response.status === 403 || response.status === 429) throw new ShipsealError("github.rate-limit", "GitHub API rate limit exceeded.", "Set GITHUB_TOKEN in the environment to raise the limit, then retry.");
|
|
2225
|
+
if (!response.ok) throw new ShipsealError("github.http", `GitHub API returned HTTP ${String(response.status)} for ${url}.`, "Confirm the repository exists and GITHUB_TOKEN can read it.");
|
|
2226
|
+
const entry = {
|
|
2227
|
+
json: await response.json(),
|
|
2228
|
+
link: response.headers.get("link")
|
|
2229
|
+
};
|
|
2230
|
+
cache.set(url, entry);
|
|
2231
|
+
return entry;
|
|
2232
|
+
}
|
|
2233
|
+
function lastPageFromLink(link) {
|
|
2234
|
+
if (link === null) return;
|
|
2235
|
+
const match = /[?&]page=(\d+)>;\s*rel="last"/.exec(link);
|
|
2236
|
+
if (match === null || match[1] === void 0) return;
|
|
2237
|
+
return Number.parseInt(match[1], 10);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
//#endregion
|
|
2241
|
+
//#region src/sources/npm.ts
|
|
2242
|
+
const API_ROOT = "https://api.npmjs.org/downloads/point/last-week";
|
|
2243
|
+
const pointSchema = z.object({
|
|
2244
|
+
downloads: z.number().int().nonnegative(),
|
|
2245
|
+
start: z.string().optional(),
|
|
2246
|
+
end: z.string().optional(),
|
|
2247
|
+
package: z.string().optional()
|
|
2248
|
+
});
|
|
2249
|
+
async function collectNpm(options) {
|
|
2250
|
+
const name = options.npmPackage.trim();
|
|
2251
|
+
if (name.length === 0) return {};
|
|
2252
|
+
const url = `${API_ROOT}/${encodeURIComponent(name)}`;
|
|
2253
|
+
const cache = options.cache ?? /* @__PURE__ */ new Map();
|
|
2254
|
+
const json = cache.get(url) ?? await npmGet(url, options.fetchImpl ?? fetch, cache);
|
|
2255
|
+
if (json === void 0) return {};
|
|
2256
|
+
const point = pointSchema.parse(json);
|
|
2257
|
+
return { metrics: { weeklyDownloads: fact(point.downloads, {
|
|
2258
|
+
source: "npm-api",
|
|
2259
|
+
ref: `GET /downloads/point/last-week/${name} downloads`,
|
|
2260
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2261
|
+
}) } };
|
|
2262
|
+
}
|
|
2263
|
+
async function npmGet(url, fetchImpl, cache) {
|
|
2264
|
+
let response;
|
|
2265
|
+
try {
|
|
2266
|
+
response = await fetchImpl(url, { headers: {
|
|
2267
|
+
Accept: "application/json",
|
|
2268
|
+
"User-Agent": "shipseal"
|
|
2269
|
+
} });
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
throw new ShipsealError("npm.network", "Could not reach the npm downloads API.", "Check network access, then retry. Weekly downloads are optional; other facts still render.", { cause: error });
|
|
2272
|
+
}
|
|
2273
|
+
if (response.status === 404) return;
|
|
2274
|
+
if (response.status === 403 || response.status === 429) throw new ShipsealError("npm.rate-limit", "npm downloads API rate limit exceeded.", "Wait and retry. Milestone cards can still use stars and contributors without downloads.");
|
|
2275
|
+
if (!response.ok) throw new ShipsealError("npm.http", `npm downloads API returned HTTP ${String(response.status)} for ${url}.`, "Confirm the package name in package.json is published on npm.");
|
|
2276
|
+
const json = await response.json();
|
|
2277
|
+
cache.set(url, json);
|
|
2278
|
+
return json;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
//#endregion
|
|
2282
|
+
//#region src/sources/package-json.ts
|
|
2283
|
+
const pkgSchema = z.object({
|
|
2284
|
+
name: z.string().optional(),
|
|
2285
|
+
description: z.string().optional(),
|
|
2286
|
+
version: z.string().optional(),
|
|
2287
|
+
homepage: z.string().optional(),
|
|
2288
|
+
license: z.string().optional(),
|
|
2289
|
+
repository: z.union([z.string(), z.object({ url: z.string().optional() })]).optional()
|
|
2290
|
+
});
|
|
2291
|
+
async function collectPackageJson(cwd, packagePath = "package.json") {
|
|
2292
|
+
const path = join(cwd, packagePath);
|
|
2293
|
+
let raw;
|
|
2294
|
+
try {
|
|
2295
|
+
raw = await readFile(path, "utf8");
|
|
2296
|
+
} catch {
|
|
2297
|
+
return {};
|
|
2298
|
+
}
|
|
2299
|
+
let parsed;
|
|
2300
|
+
try {
|
|
2301
|
+
parsed = JSON.parse(raw);
|
|
2302
|
+
} catch {
|
|
2303
|
+
return {};
|
|
2304
|
+
}
|
|
2305
|
+
const pkg = pkgSchema.safeParse(parsed);
|
|
2306
|
+
if (!pkg.success) return {};
|
|
2307
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2308
|
+
const project = { name: fact(stripScope$1(pkg.data.name ?? basenameFromPath(cwd)), {
|
|
2309
|
+
source: "package-json",
|
|
2310
|
+
ref: `${packagePath}#name`,
|
|
2311
|
+
fetchedAt
|
|
2312
|
+
}) };
|
|
2313
|
+
if (pkg.data.description !== void 0 && pkg.data.description.length > 0) project.tagline = fact(pkg.data.description, {
|
|
2314
|
+
source: "package-json",
|
|
2315
|
+
ref: `${packagePath}#description`,
|
|
2316
|
+
fetchedAt
|
|
2317
|
+
});
|
|
2318
|
+
const url = pkg.data.homepage ?? repositoryUrl$1(pkg.data.repository);
|
|
2319
|
+
if (url !== void 0) project.url = fact(url, {
|
|
2320
|
+
source: "package-json",
|
|
2321
|
+
ref: pkg.data.homepage !== void 0 ? `${packagePath}#homepage` : `${packagePath}#repository`,
|
|
2322
|
+
fetchedAt
|
|
2323
|
+
});
|
|
2324
|
+
const repo = repoSlug(pkg.data.repository);
|
|
2325
|
+
if (repo !== void 0) project.repo = fact(repo, {
|
|
2326
|
+
source: "package-json",
|
|
2327
|
+
ref: `${packagePath}#repository`,
|
|
2328
|
+
fetchedAt
|
|
2329
|
+
});
|
|
2330
|
+
if (pkg.data.name !== void 0) project.npmPackage = fact(pkg.data.name, {
|
|
2331
|
+
source: "package-json",
|
|
2332
|
+
ref: `${packagePath}#name`,
|
|
2333
|
+
fetchedAt
|
|
2334
|
+
});
|
|
2335
|
+
if (pkg.data.license !== void 0) project.license = fact(pkg.data.license, {
|
|
2336
|
+
source: "package-json",
|
|
2337
|
+
ref: `${packagePath}#license`,
|
|
2338
|
+
fetchedAt
|
|
2339
|
+
});
|
|
2340
|
+
const out = { project };
|
|
2341
|
+
if (pkg.data.version !== void 0) out.release = { version: fact(pkg.data.version.replace(/^v/, ""), {
|
|
2342
|
+
source: "package-json",
|
|
2343
|
+
ref: `${packagePath}#version`,
|
|
2344
|
+
fetchedAt
|
|
2345
|
+
}) };
|
|
2346
|
+
return out;
|
|
2347
|
+
}
|
|
2348
|
+
function stripScope$1(name) {
|
|
2349
|
+
const parts = name.split("/");
|
|
2350
|
+
return parts[parts.length - 1] ?? name;
|
|
2351
|
+
}
|
|
2352
|
+
function basenameFromPath(cwd) {
|
|
2353
|
+
const parts = cwd.split(/[/\\]/).filter((part) => part.length > 0);
|
|
2354
|
+
return parts[parts.length - 1] ?? "project";
|
|
2355
|
+
}
|
|
2356
|
+
function repositoryUrl$1(repository) {
|
|
2357
|
+
if (typeof repository === "string") return normalizeGitUrl$1(repository);
|
|
2358
|
+
if (repository?.url !== void 0) return normalizeGitUrl$1(repository.url);
|
|
2359
|
+
}
|
|
2360
|
+
function repoSlug(repository) {
|
|
2361
|
+
const url = repositoryUrl$1(repository);
|
|
2362
|
+
if (url === void 0) return;
|
|
2363
|
+
return /github\.com[/:]([^/]+\/[^/]+)$/.exec(url.replace(/\.git$/, ""))?.[1];
|
|
2364
|
+
}
|
|
2365
|
+
const SHORTHAND = /^(?:github:)?([\w.-]+\/[\w.-]+)$/;
|
|
2366
|
+
function normalizeGitUrl$1(url) {
|
|
2367
|
+
const shorthand = SHORTHAND.exec(url);
|
|
2368
|
+
if (shorthand?.[1] !== void 0) return `https://github.com/${shorthand[1].replace(/\.git$/, "")}`;
|
|
2369
|
+
const ssh = /^git@([^:]+):(.+)$/.exec(url);
|
|
2370
|
+
if (ssh !== null && ssh[1] !== void 0 && ssh[2] !== void 0) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
|
|
2371
|
+
return url.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
//#endregion
|
|
2375
|
+
//#region src/sources/readme.ts
|
|
2376
|
+
async function collectReadme(cwd) {
|
|
2377
|
+
let markdown;
|
|
2378
|
+
try {
|
|
2379
|
+
markdown = await readFile(join(cwd, "README.md"), "utf8");
|
|
2380
|
+
} catch {
|
|
2381
|
+
return {};
|
|
2382
|
+
}
|
|
2383
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2384
|
+
const out = {};
|
|
2385
|
+
const h1 = extractH1(markdown);
|
|
2386
|
+
const tagline = extractTagline(markdown);
|
|
2387
|
+
const snippet = extractFirstCodeFence(markdown);
|
|
2388
|
+
const project = {};
|
|
2389
|
+
if (h1 !== void 0) project.name = fact(h1, {
|
|
2390
|
+
source: "readme",
|
|
2391
|
+
ref: "README.md H1",
|
|
2392
|
+
fetchedAt
|
|
2393
|
+
});
|
|
2394
|
+
if (tagline !== void 0) project.tagline = fact(tagline, {
|
|
2395
|
+
source: "readme",
|
|
2396
|
+
ref: "README.md first paragraph",
|
|
2397
|
+
fetchedAt
|
|
2398
|
+
});
|
|
2399
|
+
if (project.name !== void 0 || project.tagline !== void 0) out.project = project;
|
|
2400
|
+
if (snippet !== void 0) out.release = { codeSnippet: fact(snippet, {
|
|
2401
|
+
source: "readme",
|
|
2402
|
+
ref: "README.md first fenced code block",
|
|
2403
|
+
fetchedAt
|
|
2404
|
+
}) };
|
|
2405
|
+
return out;
|
|
2406
|
+
}
|
|
2407
|
+
async function collectConfiguredSnippet(cwd, spec) {
|
|
2408
|
+
const parsed = parseSnippetSpec(spec);
|
|
2409
|
+
if (parsed === void 0) return {};
|
|
2410
|
+
let contents;
|
|
2411
|
+
try {
|
|
2412
|
+
contents = await readFile(join(cwd, parsed.path), "utf8");
|
|
2413
|
+
} catch {
|
|
2414
|
+
return {};
|
|
2415
|
+
}
|
|
2416
|
+
const slice = contents.split(/\r?\n/).slice(parsed.start - 1, parsed.end).join("\n");
|
|
2417
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2418
|
+
return { release: { codeSnippet: fact({
|
|
2419
|
+
code: slice,
|
|
2420
|
+
lang: langFromPath(parsed.path)
|
|
2421
|
+
}, {
|
|
2422
|
+
source: "user-config",
|
|
2423
|
+
ref: spec,
|
|
2424
|
+
fetchedAt
|
|
2425
|
+
}) } };
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Strip fenced code and HTML before reading prose out of a README.
|
|
2429
|
+
*
|
|
2430
|
+
* Skipping blocks that merely *start* with a fence is not enough. A `#` comment inside a YAML
|
|
2431
|
+
* example reads as a markdown H1, and a blank line inside a fence makes its body look like a
|
|
2432
|
+
* paragraph. Shipseal's own README hit both: the detected name became
|
|
2433
|
+
* ".github/workflows/shipseal.yml" and the tagline became a chunk of workflow YAML.
|
|
2434
|
+
*/
|
|
2435
|
+
function stripNonProse(markdown) {
|
|
2436
|
+
return markdown.replace(/```[\s\S]*?```/g, "").replace(/~~~[\s\S]*?~~~/g, "").replace(/<[^>]+>/g, "");
|
|
2437
|
+
}
|
|
2438
|
+
function extractH1(markdown) {
|
|
2439
|
+
const heading = /^#\s+(.+)$/m.exec(stripNonProse(markdown))?.[1];
|
|
2440
|
+
if (heading === void 0) return;
|
|
2441
|
+
return heading.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
|
|
2442
|
+
}
|
|
2443
|
+
function extractTagline(markdown) {
|
|
2444
|
+
const prose = stripNonProse(markdown);
|
|
2445
|
+
const h1 = /^#\s+.+$/m.exec(prose);
|
|
2446
|
+
const start = h1 === null ? 0 : (h1.index ?? 0) + h1[0].length;
|
|
2447
|
+
for (const block of prose.slice(start).split(/\n\s*\n/)) {
|
|
2448
|
+
const cleaned = stripBadges(block).trim();
|
|
2449
|
+
if (cleaned.length >= 20 && !cleaned.startsWith("#") && !cleaned.startsWith("```")) return cleaned;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
function extractFirstCodeFence(markdown) {
|
|
2453
|
+
const match = /```([a-zA-Z0-9+-]+)\n([\s\S]*?)```/.exec(markdown);
|
|
2454
|
+
if (match === null || match[1] === void 0 || match[2] === void 0) return;
|
|
2455
|
+
return {
|
|
2456
|
+
lang: match[1],
|
|
2457
|
+
code: match[2].replace(/\n$/, "")
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
function parseSnippetSpec(spec) {
|
|
2461
|
+
const match = /^(?<path>.+)#L(?<start>\d+)(?:-L(?<end>\d+))?$/.exec(spec);
|
|
2462
|
+
if (match === null || match.groups === void 0) return;
|
|
2463
|
+
const path = match.groups.path;
|
|
2464
|
+
const start = Number.parseInt(match.groups.start ?? "0", 10);
|
|
2465
|
+
const end = Number.parseInt(match.groups.end ?? match.groups.start ?? "0", 10);
|
|
2466
|
+
if (path === void 0 || start < 1 || end < start) return;
|
|
2467
|
+
return {
|
|
2468
|
+
path,
|
|
2469
|
+
start,
|
|
2470
|
+
end
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
function langFromPath(path) {
|
|
2474
|
+
const ext = extname(path).replace(".", "");
|
|
2475
|
+
if (ext === "ts") return "ts";
|
|
2476
|
+
if (ext === "tsx") return "tsx";
|
|
2477
|
+
if (ext === "js" || ext === "mjs" || ext === "cjs") return "js";
|
|
2478
|
+
if (ext === "jsx") return "jsx";
|
|
2479
|
+
return ext.length > 0 ? ext : "text";
|
|
2480
|
+
}
|
|
2481
|
+
function stripBadges(text) {
|
|
2482
|
+
return text.replace(/\[!\[[^\]]*]\([^)]+\)]\([^)]+\)/g, "").replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/<img[^>]*>/gi, "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
//#endregion
|
|
2486
|
+
//#region src/sources/collect.ts
|
|
2487
|
+
async function collectFacts(options) {
|
|
2488
|
+
const pkg = await collectPackageJson(options.cwd, options.packagePath ?? "package.json");
|
|
2489
|
+
const tag = options.event.kind === "release" ? options.event.tag : void 0;
|
|
2490
|
+
const previousTag = options.event.kind === "release" ? options.event.previousTag : void 0;
|
|
2491
|
+
const gitEvent = {};
|
|
2492
|
+
if (tag !== void 0) gitEvent.tag = tag;
|
|
2493
|
+
if (previousTag !== void 0) gitEvent.previousTag = previousTag;
|
|
2494
|
+
const git = await collectGit(options.cwd, gitEvent);
|
|
2495
|
+
const version = git.release?.version?.value ?? pkg.release?.version?.value ?? (tag === void 0 ? void 0 : versionFromTag(tag));
|
|
2496
|
+
const changelog = await collectBestChangelog(options.cwd, version, options.changelogPath ?? "CHANGELOG.md", options.packagePath);
|
|
2497
|
+
const readme = await collectReadme(options.cwd);
|
|
2498
|
+
const parts = [
|
|
2499
|
+
pkg,
|
|
2500
|
+
git,
|
|
2501
|
+
readme,
|
|
2502
|
+
changelog,
|
|
2503
|
+
options.snippet !== void 0 && options.snippet !== null && options.snippet.length > 0 ? await collectConfiguredSnippet(options.cwd, options.snippet) : {}
|
|
2504
|
+
];
|
|
2505
|
+
if (options.event.kind === "bench") parts.push(await collectBenchFile(options.cwd, options.benchFile ?? ".shipseal/bench.json"));
|
|
2506
|
+
if (options.skipNetwork !== true) {
|
|
2507
|
+
const repo = pkg.project?.repo?.value ?? gitRepoFromParts(pkg, readme);
|
|
2508
|
+
if (repo !== void 0) {
|
|
2509
|
+
const githubOpts = { repo };
|
|
2510
|
+
const githubTag = git.release?.tag?.value ?? tag;
|
|
2511
|
+
if (githubTag !== void 0) githubOpts.tag = githubTag;
|
|
2512
|
+
if (options.githubToken !== void 0) githubOpts.token = options.githubToken;
|
|
2513
|
+
if (options.fetchImpl !== void 0) githubOpts.fetchImpl = options.fetchImpl;
|
|
2514
|
+
const github = await collectGithub(githubOpts);
|
|
2515
|
+
const githubWithoutFeatures = {};
|
|
2516
|
+
if (github.project !== void 0) githubWithoutFeatures.project = github.project;
|
|
2517
|
+
if (github.metrics !== void 0) githubWithoutFeatures.metrics = github.metrics;
|
|
2518
|
+
parts.push(githubWithoutFeatures);
|
|
2519
|
+
const mergedPreview = mergeFacts(parts);
|
|
2520
|
+
if (options.event.kind === "release" && (mergedPreview.release?.features.length ?? 0) === 0 && github.release?.features !== void 0 && github.release.features.length > 0) parts.push({ release: { features: github.release.features } });
|
|
2521
|
+
}
|
|
2522
|
+
const npmPackage = pkg.project?.npmPackage?.value;
|
|
2523
|
+
if (npmPackage !== void 0) {
|
|
2524
|
+
const npmOpts = { npmPackage };
|
|
2525
|
+
if (options.fetchImpl !== void 0) npmOpts.fetchImpl = options.fetchImpl;
|
|
2526
|
+
parts.push(await collectNpm(npmOpts));
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
const facts = mergeFacts(parts);
|
|
2530
|
+
if (options.event.kind === "release" && facts.release === void 0) throw new ShipsealError("facts.missing-release", "Could not collect release facts.", "Create a git tag, pass --tag, or add a matching version section to CHANGELOG.md.");
|
|
2531
|
+
return facts;
|
|
2532
|
+
}
|
|
2533
|
+
async function collectBestChangelog(cwd, version, changelogPath, packagePath) {
|
|
2534
|
+
const paths = [changelogPath];
|
|
2535
|
+
if (packagePath !== void 0) {
|
|
2536
|
+
const sibling = join(dirname(packagePath), "CHANGELOG.md");
|
|
2537
|
+
if (!paths.includes(sibling)) paths.push(sibling);
|
|
2538
|
+
}
|
|
2539
|
+
return (await Promise.all(paths.map((path) => collectChangelog(cwd, version, path)))).find((facts) => changelogHasNotes(facts)) ?? {};
|
|
2540
|
+
}
|
|
2541
|
+
function changelogHasNotes(facts) {
|
|
2542
|
+
const release = facts.release;
|
|
2543
|
+
if (release === void 0) return false;
|
|
2544
|
+
return (release.features?.length ?? 0) > 0 || (release.fixes?.length ?? 0) > 0 || (release.breaking?.length ?? 0) > 0;
|
|
2545
|
+
}
|
|
2546
|
+
function gitRepoFromParts(pkg, readme) {
|
|
2547
|
+
return pkg.project?.repo?.value ?? pkg.project?.url?.value ?? readme.project?.url?.value;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
//#endregion
|
|
2551
|
+
//#region src/render/takumi.ts
|
|
2552
|
+
const GEIST_MONO_FAMILY = "Geist Mono";
|
|
2553
|
+
const FONT_REL = join("assets", "fonts", "GeistMono[wght].ttf");
|
|
2554
|
+
async function createTakumiRenderer() {
|
|
2555
|
+
const renderer = new Renderer();
|
|
2556
|
+
const fontPath = join(resolvePackageRoot(), FONT_REL);
|
|
2557
|
+
const data = await readFile(fontPath);
|
|
2558
|
+
await renderer.registerFont({
|
|
2559
|
+
name: GEIST_MONO_FAMILY,
|
|
2560
|
+
data,
|
|
2561
|
+
generic: "monospace"
|
|
2562
|
+
});
|
|
2563
|
+
return new TakumiRenderer(renderer);
|
|
2564
|
+
}
|
|
2565
|
+
function resolvePackageRoot(from = import.meta.url) {
|
|
2566
|
+
let dir = dirname(fileURLToPath(from));
|
|
2567
|
+
for (;;) {
|
|
2568
|
+
if (existsSync(join(dir, "package.json")) && existsSync(join(dir, FONT_REL))) return dir;
|
|
2569
|
+
const parent = dirname(dir);
|
|
2570
|
+
if (parent === dir) throw new ShipsealError("render.font-missing", `Could not find vendored font ${FONT_REL}.`, "Reinstall shipseal so assets/fonts/GeistMono[wght].ttf is present next to package.json.");
|
|
2571
|
+
dir = parent;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
function countLines(node) {
|
|
2575
|
+
const ys = /* @__PURE__ */ new Set();
|
|
2576
|
+
const walk = (current) => {
|
|
2577
|
+
for (const run of current.runs) ys.add(Math.round(run.y));
|
|
2578
|
+
for (const child of current.children) walk(child);
|
|
2579
|
+
};
|
|
2580
|
+
walk(node);
|
|
2581
|
+
return ys.size;
|
|
2582
|
+
}
|
|
2583
|
+
function testCardNode() {
|
|
2584
|
+
return container({
|
|
2585
|
+
style: {
|
|
2586
|
+
width: "100%",
|
|
2587
|
+
height: "100%",
|
|
2588
|
+
display: "flex",
|
|
2589
|
+
flexDirection: "column",
|
|
2590
|
+
justifyContent: "center",
|
|
2591
|
+
padding: 64,
|
|
2592
|
+
backgroundColor: "#0b0b0c"
|
|
2593
|
+
},
|
|
2594
|
+
children: [text("Shipseal", {
|
|
2595
|
+
fontFamily: "Geist",
|
|
2596
|
+
fontSize: 72,
|
|
2597
|
+
fontWeight: 700,
|
|
2598
|
+
color: "#fafafa"
|
|
2599
|
+
}), text("Every release, sealed and ready to share", {
|
|
2600
|
+
fontFamily: GEIST_MONO_FAMILY,
|
|
2601
|
+
fontSize: 28,
|
|
2602
|
+
fontWeight: 400,
|
|
2603
|
+
color: "#a1a1aa"
|
|
2604
|
+
})]
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
var TakumiRenderer = class {
|
|
2608
|
+
renderer;
|
|
2609
|
+
constructor(renderer) {
|
|
2610
|
+
this.renderer = renderer;
|
|
2611
|
+
}
|
|
2612
|
+
async fromJsx(element) {
|
|
2613
|
+
const result = await fromJsx(asReactElementLike(element));
|
|
2614
|
+
return pack(result.node, result.css);
|
|
2615
|
+
}
|
|
2616
|
+
async render(node, opts) {
|
|
2617
|
+
const packed = unpack(node);
|
|
2618
|
+
const extras = { css: opts.css ?? packed.css };
|
|
2619
|
+
if (opts.images !== void 0) extras.images = opts.images;
|
|
2620
|
+
const buffer = await renderWithFormat(this.renderer, packed.node, opts, extras);
|
|
2621
|
+
return Uint8Array.from(buffer);
|
|
2622
|
+
}
|
|
2623
|
+
async measure(node, opts) {
|
|
2624
|
+
const packed = unpack(node);
|
|
2625
|
+
const measured = await this.renderer.measure(packed.node, measureOpts(opts));
|
|
2626
|
+
return {
|
|
2627
|
+
lines: countLines(measured),
|
|
2628
|
+
width: measured.width,
|
|
2629
|
+
height: measured.height
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
async measureText(value, opts) {
|
|
2633
|
+
const measured = await this.renderer.measure(asNode(textMeasureNode(value, opts)));
|
|
2634
|
+
return {
|
|
2635
|
+
lines: countLines(measured),
|
|
2636
|
+
width: measured.width,
|
|
2637
|
+
height: measured.height
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
function renderWithFormat(renderer, node, opts, extras) {
|
|
2642
|
+
const width = opts.width;
|
|
2643
|
+
const height = opts.height;
|
|
2644
|
+
const css = extras.css;
|
|
2645
|
+
const images = extras.images;
|
|
2646
|
+
if (opts.format === "webp") {
|
|
2647
|
+
if (images !== void 0) return renderer.render(node, {
|
|
2648
|
+
width,
|
|
2649
|
+
height,
|
|
2650
|
+
format: "webp",
|
|
2651
|
+
css,
|
|
2652
|
+
images
|
|
2653
|
+
});
|
|
2654
|
+
return renderer.render(node, {
|
|
2655
|
+
width,
|
|
2656
|
+
height,
|
|
2657
|
+
format: "webp",
|
|
2658
|
+
css
|
|
2659
|
+
});
|
|
2660
|
+
}
|
|
2661
|
+
if (opts.format === "jpeg") {
|
|
2662
|
+
if (images !== void 0) return renderer.render(node, {
|
|
2663
|
+
width,
|
|
2664
|
+
height,
|
|
2665
|
+
format: "jpeg",
|
|
2666
|
+
css,
|
|
2667
|
+
images
|
|
2668
|
+
});
|
|
2669
|
+
return renderer.render(node, {
|
|
2670
|
+
width,
|
|
2671
|
+
height,
|
|
2672
|
+
format: "jpeg",
|
|
2673
|
+
css
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
if (images !== void 0) return renderer.render(node, {
|
|
2677
|
+
width,
|
|
2678
|
+
height,
|
|
2679
|
+
format: "png",
|
|
2680
|
+
css,
|
|
2681
|
+
images
|
|
2682
|
+
});
|
|
2683
|
+
return renderer.render(node, {
|
|
2684
|
+
width,
|
|
2685
|
+
height,
|
|
2686
|
+
format: "png",
|
|
2687
|
+
css
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
function measureOpts(opts) {
|
|
2691
|
+
const out = {};
|
|
2692
|
+
if (opts?.width !== void 0) out.width = opts.width;
|
|
2693
|
+
if (opts?.height !== void 0) out.height = opts.height;
|
|
2694
|
+
return out;
|
|
2695
|
+
}
|
|
2696
|
+
function textMeasureNode(value, opts) {
|
|
2697
|
+
const style = {
|
|
2698
|
+
fontFamily: opts.fontFamily,
|
|
2699
|
+
fontSize: opts.fontSize,
|
|
2700
|
+
lineHeight: opts.lineHeight,
|
|
2701
|
+
whiteSpace: opts.whiteSpace ?? "normal",
|
|
2702
|
+
maxWidth: opts.maxWidth
|
|
2703
|
+
};
|
|
2704
|
+
if (opts.fontWeight !== void 0) style.fontWeight = opts.fontWeight;
|
|
2705
|
+
return text(value, style);
|
|
2706
|
+
}
|
|
2707
|
+
function asNode(node) {
|
|
2708
|
+
return unpack(node).node;
|
|
2709
|
+
}
|
|
2710
|
+
function pack(node, css = []) {
|
|
2711
|
+
return {
|
|
2712
|
+
__shipseal: true,
|
|
2713
|
+
node,
|
|
2714
|
+
css
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
function unpack(node) {
|
|
2718
|
+
if (isPacked(node)) return node;
|
|
2719
|
+
if (isTakumiNode(node)) return {
|
|
2720
|
+
__shipseal: true,
|
|
2721
|
+
node,
|
|
2722
|
+
css: []
|
|
2723
|
+
};
|
|
2724
|
+
throw new ShipsealError("render.invalid-node", "render/measure received a value that is not a Takumi node tree.", "Pass a node built by the renderer adapter (fromJsx, measureText, or testCardNode).");
|
|
2725
|
+
}
|
|
2726
|
+
function isPacked(node) {
|
|
2727
|
+
return typeof node === "object" && node !== null && "__shipseal" in node;
|
|
2728
|
+
}
|
|
2729
|
+
function isTakumiNode(node) {
|
|
2730
|
+
return typeof node === "object" && node !== null && "type" in node;
|
|
2731
|
+
}
|
|
2732
|
+
function asReactElementLike(element) {
|
|
2733
|
+
if (typeof element !== "object" || element === null || !("type" in element)) throw new ShipsealError("render.invalid-jsx", "fromJsx received a value that is not a JSX element.", "Return JSX from a template render function.");
|
|
2734
|
+
const record = element;
|
|
2735
|
+
return {
|
|
2736
|
+
type: "type" in record ? record.type : void 0,
|
|
2737
|
+
props: "props" in record ? record.props : null,
|
|
2738
|
+
key: "key" in record && typeof record.key === "string" ? record.key : null
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
//#endregion
|
|
2743
|
+
//#region src/copy/number-guard.ts
|
|
2744
|
+
function allowedNumbers(facts) {
|
|
2745
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
2746
|
+
const visit = (value) => {
|
|
2747
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
2748
|
+
addNumber(allowed, value);
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
if (typeof value === "string") {
|
|
2752
|
+
for (const match of value.match(/\d+(?:\.\d+)*/g) ?? []) allowed.add(match);
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
if (Array.isArray(value)) {
|
|
2756
|
+
for (const item of value) visit(item);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
if (typeof value === "object" && value !== null) {
|
|
2760
|
+
if ("value" in value && "provenance" in value) {
|
|
2761
|
+
visit(value.value);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
for (const nested of Object.values(value)) visit(nested);
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
visit(facts);
|
|
2768
|
+
return allowed;
|
|
2769
|
+
}
|
|
2770
|
+
function unsourcedDigits(text, allowed) {
|
|
2771
|
+
const found = [];
|
|
2772
|
+
const withoutGrouped = text.replace(/\d{1,3}(?:,\d{3})+/g, (match) => {
|
|
2773
|
+
const compact = match.replaceAll(",", "");
|
|
2774
|
+
if (!allowed.has(match) && !allowed.has(compact)) found.push(match);
|
|
2775
|
+
return " ";
|
|
2776
|
+
});
|
|
2777
|
+
for (const match of withoutGrouped.match(/\d+(?:\.\d+)*/g) ?? []) if (!allowed.has(match)) found.push(match);
|
|
2778
|
+
return found;
|
|
2779
|
+
}
|
|
2780
|
+
function guardCopy(copy, facts) {
|
|
2781
|
+
const allowed = allowedNumbers(facts);
|
|
2782
|
+
const slots = [
|
|
2783
|
+
["headline", copy.headline],
|
|
2784
|
+
["subheadline", copy.subheadline],
|
|
2785
|
+
["cta", copy.cta]
|
|
2786
|
+
];
|
|
2787
|
+
if (copy.milestoneLine !== void 0) slots.push(["milestoneLine", copy.milestoneLine]);
|
|
2788
|
+
copy.highlights.forEach((line, index) => {
|
|
2789
|
+
slots.push([`highlights[${String(index)}]`, line]);
|
|
2790
|
+
});
|
|
2791
|
+
for (const [slot, text] of slots) {
|
|
2792
|
+
const digits = unsourcedDigits(text, allowed);
|
|
2793
|
+
if (digits.length > 0) return {
|
|
2794
|
+
ok: false,
|
|
2795
|
+
slot,
|
|
2796
|
+
digits
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
return { ok: true };
|
|
2800
|
+
}
|
|
2801
|
+
function addNumber(allowed, value) {
|
|
2802
|
+
allowed.add(String(value));
|
|
2803
|
+
if (Number.isInteger(value)) allowed.add(value.toLocaleString("en-US"));
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
//#endregion
|
|
2807
|
+
//#region src/copy/llm.ts
|
|
2808
|
+
const llmCopySchema = z.object({
|
|
2809
|
+
headline: z.string(),
|
|
2810
|
+
subheadline: z.string(),
|
|
2811
|
+
highlights: z.array(z.string()).max(4),
|
|
2812
|
+
cta: z.string()
|
|
2813
|
+
});
|
|
2814
|
+
async function llmCopy(facts, options, fallback = deterministicCopy(facts)) {
|
|
2815
|
+
const payload = factsForLlm(facts);
|
|
2816
|
+
let lastError;
|
|
2817
|
+
const retries = Math.max(0, options.maxRetries);
|
|
2818
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
2819
|
+
let parsed;
|
|
2820
|
+
try {
|
|
2821
|
+
const raw = await requestCopy(payload, options);
|
|
2822
|
+
parsed = llmCopySchema.parse(JSON.parse(raw));
|
|
2823
|
+
} catch (error) {
|
|
2824
|
+
lastError = error instanceof Error ? error.message : "LLM request failed";
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
const expanded = expandPlaceholders(parsed, facts);
|
|
2828
|
+
const styled = {
|
|
2829
|
+
headline: stripStyle(expanded.headline).slice(0, COPY_LIMITS.headline),
|
|
2830
|
+
subheadline: stripStyle(expanded.subheadline).slice(0, COPY_LIMITS.subheadline),
|
|
2831
|
+
highlights: expanded.highlights.map((line) => stripStyle(line).slice(0, COPY_LIMITS.highlight)),
|
|
2832
|
+
cta: stripStyle(expanded.cta).slice(0, COPY_LIMITS.cta)
|
|
2833
|
+
};
|
|
2834
|
+
const guarded = guardCopy(styled, facts);
|
|
2835
|
+
if (!guarded.ok) {
|
|
2836
|
+
lastError = `Number guard rejected ${guarded.slot}: ${guarded.digits.join(", ")}`;
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2839
|
+
return {
|
|
2840
|
+
copy: styled,
|
|
2841
|
+
mode: "llm"
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
return {
|
|
2845
|
+
copy: fallback,
|
|
2846
|
+
mode: "deterministic",
|
|
2847
|
+
warning: lastError ?? "LLM copy failed; using deterministic copy"
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
function factsForLlm(facts) {
|
|
2851
|
+
return {
|
|
2852
|
+
project: {
|
|
2853
|
+
name: facts.project.name.value,
|
|
2854
|
+
tagline: facts.project.tagline?.value,
|
|
2855
|
+
npmPackage: facts.project.npmPackage?.value,
|
|
2856
|
+
url: facts.project.url?.value
|
|
2857
|
+
},
|
|
2858
|
+
release: facts.release === void 0 ? void 0 : {
|
|
2859
|
+
version: "{version}",
|
|
2860
|
+
features: facts.release.features.map((item) => item.value),
|
|
2861
|
+
fixes: facts.release.fixes.map((item) => item.value),
|
|
2862
|
+
breaking: facts.release.breaking.map((item) => item.value)
|
|
2863
|
+
},
|
|
2864
|
+
metrics: {
|
|
2865
|
+
stars: facts.metrics?.stars === void 0 ? void 0 : "{stars}",
|
|
2866
|
+
weeklyDownloads: facts.metrics?.weeklyDownloads === void 0 ? void 0 : "{weeklyDownloads}",
|
|
2867
|
+
contributorCount: facts.metrics?.contributorCount === void 0 ? void 0 : "{contributorCount}"
|
|
2868
|
+
}
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
function expandPlaceholders(copy, facts) {
|
|
2872
|
+
const map = {
|
|
2873
|
+
"{version}": facts.release?.version.value ?? "",
|
|
2874
|
+
"{stars}": facts.metrics?.stars === void 0 ? "" : formatCount(facts.metrics.stars.value),
|
|
2875
|
+
"{weeklyDownloads}": facts.metrics?.weeklyDownloads === void 0 ? "" : formatCount(facts.metrics.weeklyDownloads.value),
|
|
2876
|
+
"{contributorCount}": facts.metrics?.contributorCount === void 0 ? "" : formatCount(facts.metrics.contributorCount.value)
|
|
2877
|
+
};
|
|
2878
|
+
const expand = (text) => {
|
|
2879
|
+
let out = text;
|
|
2880
|
+
for (const [token, value] of Object.entries(map)) out = out.replaceAll(token, value);
|
|
2881
|
+
return out;
|
|
2882
|
+
};
|
|
2883
|
+
return {
|
|
2884
|
+
headline: expand(copy.headline),
|
|
2885
|
+
subheadline: expand(copy.subheadline),
|
|
2886
|
+
highlights: copy.highlights.map(expand),
|
|
2887
|
+
cta: expand(copy.cta)
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
function stripStyle(text) {
|
|
2891
|
+
return text.replaceAll("—", ":").replaceAll("–", "-").replaceAll("!", ".");
|
|
2892
|
+
}
|
|
2893
|
+
function formatCount(value) {
|
|
2894
|
+
return value.toLocaleString("en-US");
|
|
2895
|
+
}
|
|
2896
|
+
async function requestCopy(facts, options) {
|
|
2897
|
+
const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
|
2898
|
+
const response = await (options.fetchImpl ?? fetch)(`${base}/chat/completions`, {
|
|
2899
|
+
method: "POST",
|
|
2900
|
+
headers: {
|
|
2901
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
2902
|
+
"Content-Type": "application/json"
|
|
2903
|
+
},
|
|
2904
|
+
body: JSON.stringify({
|
|
2905
|
+
model: options.model,
|
|
2906
|
+
response_format: { type: "json_object" },
|
|
2907
|
+
messages: [{
|
|
2908
|
+
role: "system",
|
|
2909
|
+
content: "Write short release-card copy as JSON with keys headline, subheadline, highlights, cta. No em dashes, no exclamation marks. Use placeholders like {version} for numbers. Never invent numbers."
|
|
2910
|
+
}, {
|
|
2911
|
+
role: "user",
|
|
2912
|
+
content: JSON.stringify(facts)
|
|
2913
|
+
}]
|
|
2914
|
+
})
|
|
2915
|
+
});
|
|
2916
|
+
if (!response.ok) throw new ShipsealError("copy.llm-http", `LLM provider returned HTTP ${String(response.status)}.`, "Check SHIPSEAL_LLM_API_KEY, copy.model, and SHIPSEAL_LLM_BASE_URL.");
|
|
2917
|
+
const json = await response.json();
|
|
2918
|
+
const first = z.object({ choices: z.array(z.object({ message: z.object({ content: z.string() }) })).min(1) }).parse(json).choices[0];
|
|
2919
|
+
if (first === void 0) throw new ShipsealError("copy.llm-empty", "LLM returned no choices.", "Retry, or run with --no-copy.");
|
|
2920
|
+
return first.message.content;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
//#endregion
|
|
2924
|
+
//#region src/outputs/manifest.ts
|
|
2925
|
+
function buildManifest(input) {
|
|
2926
|
+
return {
|
|
2927
|
+
shipseal: input.shipsealVersion,
|
|
2928
|
+
event: input.event,
|
|
2929
|
+
generatedAt: input.result.generatedAt,
|
|
2930
|
+
brand: {
|
|
2931
|
+
name: input.brand.name,
|
|
2932
|
+
theme: input.brand.theme,
|
|
2933
|
+
source: ".shipseal/brand.json"
|
|
2934
|
+
},
|
|
2935
|
+
copy: { mode: input.result.copyMode },
|
|
2936
|
+
files: input.result.files.map((file) => ({
|
|
2937
|
+
path: file.fileName,
|
|
2938
|
+
template: file.template,
|
|
2939
|
+
format: file.format,
|
|
2940
|
+
width: file.width,
|
|
2941
|
+
height: file.height,
|
|
2942
|
+
bytes: file.bytes.byteLength,
|
|
2943
|
+
sha256: file.sha256
|
|
2944
|
+
})),
|
|
2945
|
+
facts: flattenFacts(input.result.facts),
|
|
2946
|
+
computed: input.result.computed,
|
|
2947
|
+
warnings: input.result.warnings,
|
|
2948
|
+
missing: input.result.missing
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
function flattenFacts(facts) {
|
|
2952
|
+
const out = {};
|
|
2953
|
+
const walk = (prefix, value) => {
|
|
2954
|
+
if (isFact(value)) {
|
|
2955
|
+
out[prefix] = {
|
|
2956
|
+
value: value.value,
|
|
2957
|
+
source: value.provenance.source,
|
|
2958
|
+
ref: value.provenance.ref,
|
|
2959
|
+
fetchedAt: value.provenance.fetchedAt
|
|
2960
|
+
};
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
if (Array.isArray(value)) {
|
|
2964
|
+
value.forEach((item, index) => {
|
|
2965
|
+
walk(`${prefix}[${String(index)}]`, item);
|
|
2966
|
+
});
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
if (typeof value === "object" && value !== null) for (const [key, nested] of Object.entries(value)) walk(prefix.length === 0 ? key : `${prefix}.${key}`, nested);
|
|
2970
|
+
};
|
|
2971
|
+
walk("project", facts.project);
|
|
2972
|
+
if (facts.release !== void 0) walk("release", facts.release);
|
|
2973
|
+
if (facts.metrics !== void 0) walk("metrics", facts.metrics);
|
|
2974
|
+
if (facts.bench !== void 0) walk("bench", facts.bench);
|
|
2975
|
+
if (facts.milestone !== void 0) walk("milestone", facts.milestone);
|
|
2976
|
+
return out;
|
|
2977
|
+
}
|
|
2978
|
+
function isFact(value) {
|
|
2979
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2980
|
+
if (!("value" in value) || !("provenance" in value)) return false;
|
|
2981
|
+
const provenance = value.provenance;
|
|
2982
|
+
return typeof provenance === "object" && provenance !== null && "source" in provenance && "ref" in provenance && "fetchedAt" in provenance;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
//#endregion
|
|
2986
|
+
//#region src/outputs/files.ts
|
|
2987
|
+
async function writePack(input) {
|
|
2988
|
+
const dir = join(input.outDir, input.eventId);
|
|
2989
|
+
await mkdir(dir, { recursive: true });
|
|
2990
|
+
await Promise.all(input.result.files.map((file) => writeFile(join(dir, file.fileName), file.bytes)));
|
|
2991
|
+
const manifest = buildManifest({
|
|
2992
|
+
result: input.result,
|
|
2993
|
+
event: input.event,
|
|
2994
|
+
brand: input.brand,
|
|
2995
|
+
shipsealVersion: input.shipsealVersion
|
|
2996
|
+
});
|
|
2997
|
+
await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
2998
|
+
return {
|
|
2999
|
+
dir,
|
|
3000
|
+
manifest
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
function resolveOutputDir(cwd, configured) {
|
|
3004
|
+
return isAbsolute(configured) ? configured : join(cwd, configured);
|
|
3005
|
+
}
|
|
3006
|
+
function eventId(event, generatedAt = "") {
|
|
3007
|
+
if (event.kind === "release") return event.tag;
|
|
3008
|
+
if (event.kind === "milestone") return `milestone-${event.metric}-${String(event.threshold)}`;
|
|
3009
|
+
const day = generatedAt.slice(0, 10);
|
|
3010
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? `bench-${day}` : "bench";
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
//#endregion
|
|
3014
|
+
//#region src/outputs/github-release.ts
|
|
3015
|
+
const execFileAsync$2 = promisify(execFile);
|
|
3016
|
+
async function uploadReleaseAssets(options) {
|
|
3017
|
+
if (options.files.length === 0) return;
|
|
3018
|
+
const exec = options.exec ?? execFileAsync$2;
|
|
3019
|
+
const args = [
|
|
3020
|
+
"release",
|
|
3021
|
+
"upload",
|
|
3022
|
+
options.tag,
|
|
3023
|
+
...options.files,
|
|
3024
|
+
"--clobber"
|
|
3025
|
+
];
|
|
3026
|
+
if (options.repo !== void 0) args.push("--repo", options.repo);
|
|
3027
|
+
const env = { ...process.env };
|
|
3028
|
+
if (options.token !== void 0) {
|
|
3029
|
+
env.GH_TOKEN = options.token;
|
|
3030
|
+
env.GITHUB_TOKEN = options.token;
|
|
3031
|
+
}
|
|
3032
|
+
try {
|
|
3033
|
+
await exec("gh", args, { env });
|
|
3034
|
+
} catch (error) {
|
|
3035
|
+
throw new ShipsealError("github.upload", `Could not upload assets to GitHub release ${options.tag}.`, "Confirm gh is installed, GITHUB_TOKEN can write contents, and the release exists.", { cause: error });
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
async function appendJobSummary(path, manifest, dir) {
|
|
3039
|
+
await appendFile(path, `${jobSummaryMarkdown(manifest, dir)}\n`, "utf8");
|
|
3040
|
+
}
|
|
3041
|
+
function jobSummaryMarkdown(manifest, dir) {
|
|
3042
|
+
const lines = [
|
|
3043
|
+
"## Shipseal",
|
|
3044
|
+
"",
|
|
3045
|
+
`Generated **${String(manifest.files.length)}** files in \`${dir}\`.`,
|
|
3046
|
+
""
|
|
3047
|
+
];
|
|
3048
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
3049
|
+
const tag = manifest.event.kind === "release" ? manifest.event.tag : void 0;
|
|
3050
|
+
if (repo !== void 0 && tag !== void 0) for (const file of manifest.files) {
|
|
3051
|
+
const url = `https://github.com/${repo}/releases/download/${tag}/${file.path}`;
|
|
3052
|
+
lines.push(``, "");
|
|
3053
|
+
}
|
|
3054
|
+
else {
|
|
3055
|
+
lines.push("| File | Template | Format |", "|---|---|---|");
|
|
3056
|
+
for (const file of manifest.files) lines.push(`| ${file.path} | ${file.template} | ${file.format} |`);
|
|
3057
|
+
lines.push("");
|
|
3058
|
+
}
|
|
3059
|
+
lines.push("### Facts", "");
|
|
3060
|
+
for (const [key, entry] of Object.entries(manifest.facts)) lines.push(`- \`${key}\`: ${JSON.stringify(entry.value)} (${entry.source})`);
|
|
3061
|
+
if (Object.keys(manifest.computed).length > 0) {
|
|
3062
|
+
lines.push("", "### Computed", "");
|
|
3063
|
+
for (const [key, entry] of Object.entries(manifest.computed)) lines.push(`- \`${key}\`: ${JSON.stringify(entry.value)}`);
|
|
3064
|
+
}
|
|
3065
|
+
if (manifest.warnings.length > 0) {
|
|
3066
|
+
lines.push("", "### Warnings", "");
|
|
3067
|
+
for (const warning of manifest.warnings) lines.push(`- ${warning.template} ${warning.format} ${warning.slot}: ${warning.action}`);
|
|
3068
|
+
}
|
|
3069
|
+
lines.push("");
|
|
3070
|
+
return lines.join("\n");
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
//#endregion
|
|
3074
|
+
//#region src/commands/shared.ts
|
|
3075
|
+
function overlayFormats(config, flags) {
|
|
3076
|
+
const overlay = { ...config };
|
|
3077
|
+
if (flags.formats !== void 0) overlay.formats = parseFormats(flags.formats);
|
|
3078
|
+
if (flags.out !== void 0) overlay.outputDir = flags.out;
|
|
3079
|
+
return mergeConfig(DEFAULT_CONFIG, overlay);
|
|
3080
|
+
}
|
|
3081
|
+
function parseFormats(list) {
|
|
3082
|
+
const formats = [];
|
|
3083
|
+
for (const part of list.split(",")) {
|
|
3084
|
+
const id = part.trim();
|
|
3085
|
+
if (id.length === 0) continue;
|
|
3086
|
+
const match = FORMAT_IDS.find((format) => format === id);
|
|
3087
|
+
if (match === void 0) throw new ShipsealError("release.bad-format", `Unknown format "${id}".`, `Use a comma-separated list from: ${FORMAT_IDS.join(", ")}.`);
|
|
3088
|
+
formats.push(match);
|
|
3089
|
+
}
|
|
3090
|
+
return formats;
|
|
3091
|
+
}
|
|
3092
|
+
function resolveThemes(flag, brandTheme) {
|
|
3093
|
+
if (flag === "both") return ["dark", "light"];
|
|
3094
|
+
if (flag === "dark" || flag === "light") return [flag];
|
|
3095
|
+
return [brandTheme];
|
|
3096
|
+
}
|
|
3097
|
+
async function resolveCopy(facts, config, noCopy, fallback) {
|
|
3098
|
+
if (noCopy || config.copy?.llm !== true) return {
|
|
3099
|
+
copy: fallback,
|
|
3100
|
+
copyMode: "deterministic"
|
|
3101
|
+
};
|
|
3102
|
+
const apiKey = process.env.SHIPSEAL_LLM_API_KEY;
|
|
3103
|
+
if (apiKey === void 0 || apiKey.length === 0) throw new ShipsealError("copy.missing-key", "copy.llm is enabled but SHIPSEAL_LLM_API_KEY is not set.", "Set SHIPSEAL_LLM_API_KEY, or pass --no-copy for deterministic copy.");
|
|
3104
|
+
const llmOpts = {
|
|
3105
|
+
apiKey,
|
|
3106
|
+
model: config.copy.model ?? "gpt-4o-mini",
|
|
3107
|
+
maxRetries: config.copy.maxRetries ?? 2
|
|
3108
|
+
};
|
|
3109
|
+
if (process.env.SHIPSEAL_LLM_BASE_URL !== void 0) llmOpts.baseUrl = process.env.SHIPSEAL_LLM_BASE_URL;
|
|
3110
|
+
const result = await llmCopy(facts, llmOpts, fallback);
|
|
3111
|
+
const out = {
|
|
3112
|
+
copy: result.copy,
|
|
3113
|
+
copyMode: result.mode
|
|
3114
|
+
};
|
|
3115
|
+
if (result.warning !== void 0) out.warning = result.warning;
|
|
3116
|
+
return out;
|
|
3117
|
+
}
|
|
3118
|
+
async function loadLogos(cwd, brand) {
|
|
3119
|
+
if (brand.logo === void 0) return;
|
|
3120
|
+
const logos = { light: await readLogo(cwd, brand.logo.light) };
|
|
3121
|
+
if (brand.logo.dark !== void 0) logos.dark = await readLogo(cwd, brand.logo.dark);
|
|
3122
|
+
return logos;
|
|
3123
|
+
}
|
|
3124
|
+
async function readLogo(cwd, path) {
|
|
3125
|
+
const resolved = isAbsolute(path) ? path : join(cwd, path);
|
|
3126
|
+
return new Uint8Array(await readFile(resolved));
|
|
3127
|
+
}
|
|
3128
|
+
function readShipsealVersion() {
|
|
3129
|
+
const raw = JSON.parse(readFileSync(join(resolvePackageRoot(), "package.json"), "utf8"));
|
|
3130
|
+
if (typeof raw === "object" && raw !== null && "version" in raw && typeof raw.version === "string") return raw.version;
|
|
3131
|
+
return "0.0.0";
|
|
3132
|
+
}
|
|
3133
|
+
function collectEnv(flags) {
|
|
3134
|
+
const out = { skipNetwork: flags.dryRun === true && process.env.GITHUB_TOKEN === void 0 };
|
|
3135
|
+
if (process.env.GITHUB_TOKEN !== void 0) out.githubToken = process.env.GITHUB_TOKEN;
|
|
3136
|
+
if (flags.package !== void 0) out.packagePath = flags.package;
|
|
3137
|
+
if (flags.fetchImpl !== void 0) out.fetchImpl = flags.fetchImpl;
|
|
3138
|
+
return out;
|
|
3139
|
+
}
|
|
3140
|
+
async function finishPack(input) {
|
|
3141
|
+
const generatedAt = input.result.generatedAt;
|
|
3142
|
+
const written = await writePack({
|
|
3143
|
+
outDir: resolveOutputDir(input.flags.cwd, input.flags.out ?? input.config.outputDir ?? ".shipseal/output"),
|
|
3144
|
+
eventId: eventId(input.event, generatedAt),
|
|
3145
|
+
result: input.result,
|
|
3146
|
+
event: input.event,
|
|
3147
|
+
brand: input.brand,
|
|
3148
|
+
shipsealVersion: readShipsealVersion()
|
|
3149
|
+
});
|
|
3150
|
+
if (input.flags.upload === true && input.event.kind === "release") {
|
|
3151
|
+
const files = input.result.files.map((file) => join(written.dir, file.fileName));
|
|
3152
|
+
const uploadOpts = {
|
|
3153
|
+
tag: input.event.tag,
|
|
3154
|
+
files
|
|
3155
|
+
};
|
|
3156
|
+
if (process.env.GITHUB_REPOSITORY !== void 0) uploadOpts.repo = process.env.GITHUB_REPOSITORY;
|
|
3157
|
+
if (process.env.GITHUB_TOKEN !== void 0) uploadOpts.token = process.env.GITHUB_TOKEN;
|
|
3158
|
+
await uploadReleaseAssets(uploadOpts);
|
|
3159
|
+
}
|
|
3160
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
3161
|
+
if (summaryPath !== void 0 && summaryPath.length > 0) await appendJobSummary(summaryPath, written.manifest, written.dir);
|
|
3162
|
+
const exitCode = input.flags.strict === true && input.result.warnings.length > 0 ? 2 : 0;
|
|
3163
|
+
const done = {
|
|
3164
|
+
dryRun: false,
|
|
3165
|
+
facts: input.facts,
|
|
3166
|
+
copy: input.copy,
|
|
3167
|
+
copyMode: input.copyMode,
|
|
3168
|
+
dir: written.dir,
|
|
3169
|
+
manifest: written.manifest,
|
|
3170
|
+
warnings: input.result.warnings,
|
|
3171
|
+
exitCode
|
|
3172
|
+
};
|
|
3173
|
+
if (input.copyWarning !== void 0) done.copyWarning = input.copyWarning;
|
|
3174
|
+
return done;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
//#endregion
|
|
3178
|
+
//#region src/commands/bench.ts
|
|
3179
|
+
async function runBench(flags) {
|
|
3180
|
+
const cwd = flags.cwd;
|
|
3181
|
+
const config = overlayFormats(await loadConfig(cwd), flags);
|
|
3182
|
+
const brand = await loadBrand(cwd);
|
|
3183
|
+
const file = flags.file ?? config.bench?.file ?? ".shipseal/bench.json";
|
|
3184
|
+
const event = {
|
|
3185
|
+
kind: "bench",
|
|
3186
|
+
file
|
|
3187
|
+
};
|
|
3188
|
+
const env = collectEnv(flags);
|
|
3189
|
+
const collectOpts = {
|
|
3190
|
+
cwd,
|
|
3191
|
+
event,
|
|
3192
|
+
skipNetwork: true,
|
|
3193
|
+
benchFile: file
|
|
3194
|
+
};
|
|
3195
|
+
if (env.packagePath !== void 0) collectOpts.packagePath = env.packagePath;
|
|
3196
|
+
const facts = await collectFacts(collectOpts);
|
|
3197
|
+
const resolved = await resolveCopy(facts, config, flags.copy === false, benchCopy(facts));
|
|
3198
|
+
if (flags.dryRun === true) {
|
|
3199
|
+
const dry = {
|
|
3200
|
+
dryRun: true,
|
|
3201
|
+
facts,
|
|
3202
|
+
copy: resolved.copy,
|
|
3203
|
+
copyMode: resolved.copyMode,
|
|
3204
|
+
warnings: [],
|
|
3205
|
+
exitCode: 0
|
|
3206
|
+
};
|
|
3207
|
+
if (resolved.warning !== void 0) dry.copyWarning = resolved.warning;
|
|
3208
|
+
return dry;
|
|
3209
|
+
}
|
|
3210
|
+
const renderer = await createTakumiRenderer();
|
|
3211
|
+
const generateInput = {
|
|
3212
|
+
event,
|
|
3213
|
+
facts,
|
|
3214
|
+
brand,
|
|
3215
|
+
config,
|
|
3216
|
+
copy: resolved.copy,
|
|
3217
|
+
copyMode: resolved.copyMode,
|
|
3218
|
+
renderer,
|
|
3219
|
+
themes: resolveThemes(flags.themes, brand.theme),
|
|
3220
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3221
|
+
};
|
|
3222
|
+
const logos = await loadLogos(cwd, brand);
|
|
3223
|
+
if (logos !== void 0) generateInput.logos = logos;
|
|
3224
|
+
const packInput = {
|
|
3225
|
+
flags,
|
|
3226
|
+
config,
|
|
3227
|
+
event,
|
|
3228
|
+
result: await generate(generateInput),
|
|
3229
|
+
brand,
|
|
3230
|
+
copy: resolved.copy,
|
|
3231
|
+
copyMode: resolved.copyMode,
|
|
3232
|
+
facts
|
|
3233
|
+
};
|
|
3234
|
+
if (resolved.warning !== void 0) packInput.copyWarning = resolved.warning;
|
|
3235
|
+
return finishPack(packInput);
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
//#endregion
|
|
3239
|
+
//#region src/commands/doctor.ts
|
|
3240
|
+
const execFileAsync$1 = promisify(execFile);
|
|
3241
|
+
const MIN_NODE_MAJOR = 22;
|
|
3242
|
+
async function runDoctor(cwd) {
|
|
3243
|
+
const checks = [];
|
|
3244
|
+
checks.push(checkNode());
|
|
3245
|
+
checks.push(await checkGit(cwd));
|
|
3246
|
+
checks.push(await checkShallow(cwd));
|
|
3247
|
+
checks.push(await checkBrand(cwd));
|
|
3248
|
+
checks.push(await checkFont());
|
|
3249
|
+
checks.push(await checkLogo(cwd));
|
|
3250
|
+
checks.push(checkGithubToken());
|
|
3251
|
+
checks.push(await checkRender());
|
|
3252
|
+
return {
|
|
3253
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
3254
|
+
checks
|
|
3255
|
+
};
|
|
3256
|
+
}
|
|
3257
|
+
function checkNode() {
|
|
3258
|
+
const version = process.versions.node;
|
|
3259
|
+
if (Number.parseInt(version.split(".")[0] ?? "0", 10) >= MIN_NODE_MAJOR) return {
|
|
3260
|
+
name: "node",
|
|
3261
|
+
status: "pass",
|
|
3262
|
+
message: `Node.js ${version}`
|
|
3263
|
+
};
|
|
3264
|
+
return {
|
|
3265
|
+
name: "node",
|
|
3266
|
+
status: "fail",
|
|
3267
|
+
message: `Node.js ${version} is below the minimum of ${MIN_NODE_MAJOR}.`,
|
|
3268
|
+
fix: `Install Node.js ${MIN_NODE_MAJOR} or newer.`
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
async function checkGit(cwd) {
|
|
3272
|
+
try {
|
|
3273
|
+
const { stdout } = await execFileAsync$1("git", ["--version"], { cwd });
|
|
3274
|
+
return {
|
|
3275
|
+
name: "git",
|
|
3276
|
+
status: "pass",
|
|
3277
|
+
message: stdout.trim()
|
|
3278
|
+
};
|
|
3279
|
+
} catch {
|
|
3280
|
+
return {
|
|
3281
|
+
name: "git",
|
|
3282
|
+
status: "fail",
|
|
3283
|
+
message: "git is not available on PATH.",
|
|
3284
|
+
fix: "Install git and ensure it is on PATH."
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
async function checkShallow(cwd) {
|
|
3289
|
+
try {
|
|
3290
|
+
const { stdout } = await execFileAsync$1("git", ["rev-parse", "--is-shallow-repository"], { cwd });
|
|
3291
|
+
if (stdout.trim() === "true") return {
|
|
3292
|
+
name: "clone-depth",
|
|
3293
|
+
status: "fail",
|
|
3294
|
+
message: "This git clone is shallow, so release facts will be incomplete.",
|
|
3295
|
+
fix: "Re-clone with a full history, or in GitHub Actions set fetch-depth: 0."
|
|
3296
|
+
};
|
|
3297
|
+
return {
|
|
3298
|
+
name: "clone-depth",
|
|
3299
|
+
status: "pass",
|
|
3300
|
+
message: "Full git history is available."
|
|
3301
|
+
};
|
|
3302
|
+
} catch {
|
|
3303
|
+
return {
|
|
3304
|
+
name: "clone-depth",
|
|
3305
|
+
status: "info",
|
|
3306
|
+
message: "Could not determine whether the clone is shallow."
|
|
3307
|
+
};
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
async function checkBrand(cwd) {
|
|
3311
|
+
const path = join(cwd, ".shipseal", "brand.json");
|
|
3312
|
+
if (!existsSync(path)) return {
|
|
3313
|
+
name: "brand.json",
|
|
3314
|
+
status: "fail",
|
|
3315
|
+
message: ".shipseal/brand.json is missing.",
|
|
3316
|
+
fix: "Run shipseal init to detect a brand kit."
|
|
3317
|
+
};
|
|
3318
|
+
try {
|
|
3319
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
3320
|
+
const parsed = brandSchema.safeParse(raw);
|
|
3321
|
+
if (!parsed.success) return {
|
|
3322
|
+
name: "brand.json",
|
|
3323
|
+
status: "fail",
|
|
3324
|
+
message: "brand.json did not match the v1 schema.",
|
|
3325
|
+
fix: parsed.error.issues.map((issue) => issue.message).join("; ")
|
|
3326
|
+
};
|
|
3327
|
+
return {
|
|
3328
|
+
name: "brand.json",
|
|
3329
|
+
status: "pass",
|
|
3330
|
+
message: `Brand ${parsed.data.name} is valid.`
|
|
3331
|
+
};
|
|
3332
|
+
} catch (error) {
|
|
3333
|
+
if (error instanceof SyntaxError) return {
|
|
3334
|
+
name: "brand.json",
|
|
3335
|
+
status: "fail",
|
|
3336
|
+
message: "brand.json is not valid JSON.",
|
|
3337
|
+
fix: "Fix the JSON syntax, or re-run shipseal init --force."
|
|
3338
|
+
};
|
|
3339
|
+
throw error;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
async function checkFont() {
|
|
3343
|
+
try {
|
|
3344
|
+
const root = resolvePackageRoot();
|
|
3345
|
+
const fontPath = join(root, "assets", "fonts", "GeistMono[wght].ttf");
|
|
3346
|
+
if (!existsSync(fontPath)) return {
|
|
3347
|
+
name: "fonts",
|
|
3348
|
+
status: "fail",
|
|
3349
|
+
message: "Vendored Geist Mono file is missing.",
|
|
3350
|
+
fix: "Reinstall shipseal so assets/fonts/GeistMono[wght].ttf is present."
|
|
3351
|
+
};
|
|
3352
|
+
return {
|
|
3353
|
+
name: "fonts",
|
|
3354
|
+
status: "pass",
|
|
3355
|
+
message: `Geist Mono found at ${fontPath}`
|
|
3356
|
+
};
|
|
3357
|
+
} catch {
|
|
3358
|
+
return {
|
|
3359
|
+
name: "fonts",
|
|
3360
|
+
status: "fail",
|
|
3361
|
+
message: "Could not resolve the vendored Geist Mono font.",
|
|
3362
|
+
fix: "Reinstall shipseal so assets/fonts/GeistMono[wght].ttf is present."
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
async function checkLogo(cwd) {
|
|
3367
|
+
const path = join(cwd, ".shipseal", "brand.json");
|
|
3368
|
+
if (!existsSync(path)) return {
|
|
3369
|
+
name: "logo",
|
|
3370
|
+
status: "info",
|
|
3371
|
+
message: "Skipped; brand.json is missing."
|
|
3372
|
+
};
|
|
3373
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
3374
|
+
const parsed = brandSchema.safeParse(raw);
|
|
3375
|
+
if (!parsed.success || parsed.data.logo === void 0) return {
|
|
3376
|
+
name: "logo",
|
|
3377
|
+
status: "info",
|
|
3378
|
+
message: "No logo path in brand.json."
|
|
3379
|
+
};
|
|
3380
|
+
const light = join(cwd, parsed.data.logo.light);
|
|
3381
|
+
if (!existsSync(light)) return {
|
|
3382
|
+
name: "logo",
|
|
3383
|
+
status: "fail",
|
|
3384
|
+
message: `Logo file ${parsed.data.logo.light} is not readable.`,
|
|
3385
|
+
fix: "Point brand.json logo.light at an existing SVG or PNG, or re-run init."
|
|
3386
|
+
};
|
|
3387
|
+
return {
|
|
3388
|
+
name: "logo",
|
|
3389
|
+
status: "pass",
|
|
3390
|
+
message: `Logo readable at ${parsed.data.logo.light}`
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
function checkGithubToken() {
|
|
3394
|
+
if (process.env.GITHUB_TOKEN !== void 0 && process.env.GITHUB_TOKEN.length > 0) return {
|
|
3395
|
+
name: "GITHUB_TOKEN",
|
|
3396
|
+
status: "info",
|
|
3397
|
+
message: "GITHUB_TOKEN is set."
|
|
3398
|
+
};
|
|
3399
|
+
return {
|
|
3400
|
+
name: "GITHUB_TOKEN",
|
|
3401
|
+
status: "info",
|
|
3402
|
+
message: "GITHUB_TOKEN is not set. Unauthenticated GitHub API calls have a low rate limit."
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
async function checkRender() {
|
|
3406
|
+
try {
|
|
3407
|
+
const png = await (await createTakumiRenderer()).render(testCardNode(), {
|
|
3408
|
+
width: FORMATS.og.width,
|
|
3409
|
+
height: FORMATS.og.height,
|
|
3410
|
+
format: "png"
|
|
3411
|
+
});
|
|
3412
|
+
if (png.length < 8 || png[0] !== 137 || png[1] !== 80) return {
|
|
3413
|
+
name: "takumi",
|
|
3414
|
+
status: "fail",
|
|
3415
|
+
message: "Takumi rendered bytes that are not a PNG.",
|
|
3416
|
+
fix: "File an issue with the doctor output and your OS/arch."
|
|
3417
|
+
};
|
|
3418
|
+
return {
|
|
3419
|
+
name: "takumi",
|
|
3420
|
+
status: "pass",
|
|
3421
|
+
message: `Rendered a ${FORMATS.og.width}x${FORMATS.og.height} test card (${png.length} bytes).`
|
|
3422
|
+
};
|
|
3423
|
+
} catch (error) {
|
|
3424
|
+
return {
|
|
3425
|
+
name: "takumi",
|
|
3426
|
+
status: "fail",
|
|
3427
|
+
message: error instanceof Error ? error.message : "Takumi failed to render a test card.",
|
|
3428
|
+
fix: "Check that the native Takumi binary installed for this platform."
|
|
3429
|
+
};
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
//#endregion
|
|
3434
|
+
//#region src/brand/color.ts
|
|
3435
|
+
const HEX = /^#([\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i;
|
|
3436
|
+
function parseCssColor(value) {
|
|
3437
|
+
const trimmed = value.trim();
|
|
3438
|
+
const hex = normalizeHex(trimmed);
|
|
3439
|
+
if (hex !== void 0) return hex;
|
|
3440
|
+
const oklch = parseOklch(trimmed);
|
|
3441
|
+
if (oklch !== void 0) return oklchToHex(oklch.l, oklch.c, oklch.h);
|
|
3442
|
+
const rgb = parseRgb(trimmed);
|
|
3443
|
+
if (rgb !== void 0) return rgbToHex(rgb[0], rgb[1], rgb[2]);
|
|
3444
|
+
const hsl = parseHsl(trimmed);
|
|
3445
|
+
if (hsl !== void 0) return rgbToHex(...hslToRgb(hsl[0], hsl[1], hsl[2]));
|
|
3446
|
+
}
|
|
3447
|
+
function normalizeHex(value) {
|
|
3448
|
+
const match = HEX.exec(value.trim());
|
|
3449
|
+
if (match === null) return;
|
|
3450
|
+
const raw = match[1];
|
|
3451
|
+
if (raw === void 0) return;
|
|
3452
|
+
if (raw.length === 3) return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`.toLowerCase();
|
|
3453
|
+
return `#${raw.slice(0, 6).toLowerCase()}`;
|
|
3454
|
+
}
|
|
3455
|
+
function oklchToHex(l, c, h) {
|
|
3456
|
+
const hr = h * Math.PI / 180;
|
|
3457
|
+
const a = c * Math.cos(hr);
|
|
3458
|
+
const b = c * Math.sin(hr);
|
|
3459
|
+
const coneL = l + .3963377774 * a + .2158037573 * b;
|
|
3460
|
+
const coneM = l - .1055613458 * a - .0638541728 * b;
|
|
3461
|
+
const coneS = l - .0894841775 * a - 1.291485548 * b;
|
|
3462
|
+
const l3 = coneL * coneL * coneL;
|
|
3463
|
+
const m3 = coneM * coneM * coneM;
|
|
3464
|
+
const s3 = coneS * coneS * coneS;
|
|
3465
|
+
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + .2309699292 * s3;
|
|
3466
|
+
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - .3413193965 * s3;
|
|
3467
|
+
const bLin = -.0041960863 * l3 - .7034186147 * m3 + 1.707614701 * s3;
|
|
3468
|
+
return rgbToHex(srgbEncode(r) * 255, srgbEncode(g) * 255, srgbEncode(bLin) * 255);
|
|
3469
|
+
}
|
|
3470
|
+
function contrastRatio(hexA, hexB) {
|
|
3471
|
+
const a = relativeLuminance(hexA);
|
|
3472
|
+
const b = relativeLuminance(hexB);
|
|
3473
|
+
const [hi, lo] = a > b ? [a, b] : [b, a];
|
|
3474
|
+
return (hi + .05) / (lo + .05);
|
|
3475
|
+
}
|
|
3476
|
+
const LARGE_TEXT_CONTRAST = 3;
|
|
3477
|
+
function ensureForegroundContrast(background, foreground) {
|
|
3478
|
+
if (contrastRatio(background, foreground) >= 3) return {
|
|
3479
|
+
foreground,
|
|
3480
|
+
adjusted: false
|
|
3481
|
+
};
|
|
3482
|
+
const light = "#fafafa";
|
|
3483
|
+
const dark = "#0b0b0c";
|
|
3484
|
+
return {
|
|
3485
|
+
foreground: contrastRatio(background, light) >= contrastRatio(background, dark) ? light : dark,
|
|
3486
|
+
adjusted: true
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
function readableOnBackground(background, candidate) {
|
|
3490
|
+
return contrastRatio(background, candidate) >= 3;
|
|
3491
|
+
}
|
|
3492
|
+
function srgbEncode(channel) {
|
|
3493
|
+
const abs = Math.abs(channel);
|
|
3494
|
+
const encoded = abs <= .0031308 ? 12.92 * abs : 1.055 * abs ** (1 / 2.4) - .055;
|
|
3495
|
+
return Math.sign(channel) * encoded;
|
|
3496
|
+
}
|
|
3497
|
+
function rgbToHex(r, g, b) {
|
|
3498
|
+
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
|
3499
|
+
}
|
|
3500
|
+
function toByte(n) {
|
|
3501
|
+
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
|
|
3502
|
+
}
|
|
3503
|
+
function relativeLuminance(hex) {
|
|
3504
|
+
const rgb = hexToRgb(hex);
|
|
3505
|
+
if (rgb === void 0) return 0;
|
|
3506
|
+
const lin = rgb.map((channel) => {
|
|
3507
|
+
const s = channel / 255;
|
|
3508
|
+
return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
|
|
3509
|
+
});
|
|
3510
|
+
return .2126 * (lin[0] ?? 0) + .7152 * (lin[1] ?? 0) + .0722 * (lin[2] ?? 0);
|
|
3511
|
+
}
|
|
3512
|
+
function isNeutralHex(hex) {
|
|
3513
|
+
const rgb = hexToRgb(hex);
|
|
3514
|
+
if (rgb === void 0) return true;
|
|
3515
|
+
return Math.max(rgb[0], rgb[1], rgb[2]) - Math.min(rgb[0], rgb[1], rgb[2]) < 16;
|
|
3516
|
+
}
|
|
3517
|
+
function hexToRgb(hex) {
|
|
3518
|
+
const normalized = normalizeHex(hex);
|
|
3519
|
+
if (normalized === void 0) return;
|
|
3520
|
+
return [
|
|
3521
|
+
Number.parseInt(normalized.slice(1, 3), 16),
|
|
3522
|
+
Number.parseInt(normalized.slice(3, 5), 16),
|
|
3523
|
+
Number.parseInt(normalized.slice(5, 7), 16)
|
|
3524
|
+
];
|
|
3525
|
+
}
|
|
3526
|
+
function parseOklch(value) {
|
|
3527
|
+
const match = /^oklch\(\s*([^/)]+?)(?:\s*\/\s*[^)]+)?\s*\)$/i.exec(value);
|
|
3528
|
+
if (match === null) return;
|
|
3529
|
+
const body = match[1];
|
|
3530
|
+
if (body === void 0) return;
|
|
3531
|
+
const parts = splitCssArgs(body);
|
|
3532
|
+
if (parts.length < 3) return;
|
|
3533
|
+
const l = parseLightness(parts[0] ?? "");
|
|
3534
|
+
const c = parseNumber(parts[1] ?? "");
|
|
3535
|
+
const h = parseHue(parts[2] ?? "");
|
|
3536
|
+
if (l === void 0 || c === void 0 || h === void 0) return;
|
|
3537
|
+
return {
|
|
3538
|
+
l,
|
|
3539
|
+
c,
|
|
3540
|
+
h
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
function parseRgb(value) {
|
|
3544
|
+
const match = /^rgba?\(\s*([^/)]+?)(?:\s*\/\s*[^)]+)?\s*\)$/i.exec(value);
|
|
3545
|
+
if (match === null) return;
|
|
3546
|
+
const parts = splitCssArgs(match[1] ?? "");
|
|
3547
|
+
if (parts.length < 3) return;
|
|
3548
|
+
const r = parseRgbChannel(parts[0] ?? "");
|
|
3549
|
+
const g = parseRgbChannel(parts[1] ?? "");
|
|
3550
|
+
const b = parseRgbChannel(parts[2] ?? "");
|
|
3551
|
+
if (r === void 0 || g === void 0 || b === void 0) return;
|
|
3552
|
+
return [
|
|
3553
|
+
r,
|
|
3554
|
+
g,
|
|
3555
|
+
b
|
|
3556
|
+
];
|
|
3557
|
+
}
|
|
3558
|
+
function parseHsl(value) {
|
|
3559
|
+
const match = /^hsla?\(\s*([^/)]+?)(?:\s*\/\s*[^)]+)?\s*\)$/i.exec(value);
|
|
3560
|
+
if (match === null) return;
|
|
3561
|
+
const parts = splitCssArgs(match[1] ?? "");
|
|
3562
|
+
if (parts.length < 3) return;
|
|
3563
|
+
const h = parseHue(parts[0] ?? "");
|
|
3564
|
+
const s = parsePercent(parts[1] ?? "");
|
|
3565
|
+
const l = parsePercent(parts[2] ?? "");
|
|
3566
|
+
if (h === void 0 || s === void 0 || l === void 0) return;
|
|
3567
|
+
return [
|
|
3568
|
+
h,
|
|
3569
|
+
s,
|
|
3570
|
+
l
|
|
3571
|
+
];
|
|
3572
|
+
}
|
|
3573
|
+
function hslToRgb(h, s, l) {
|
|
3574
|
+
const sat = s / 100;
|
|
3575
|
+
const light = l / 100;
|
|
3576
|
+
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
|
3577
|
+
const hp = (h % 360 + 360) % 360 / 60;
|
|
3578
|
+
const x = c * (1 - Math.abs(hp % 2 - 1));
|
|
3579
|
+
let r = 0;
|
|
3580
|
+
let g = 0;
|
|
3581
|
+
let b = 0;
|
|
3582
|
+
if (hp < 1) {
|
|
3583
|
+
r = c;
|
|
3584
|
+
g = x;
|
|
3585
|
+
} else if (hp < 2) {
|
|
3586
|
+
r = x;
|
|
3587
|
+
g = c;
|
|
3588
|
+
} else if (hp < 3) {
|
|
3589
|
+
g = c;
|
|
3590
|
+
b = x;
|
|
3591
|
+
} else if (hp < 4) {
|
|
3592
|
+
g = x;
|
|
3593
|
+
b = c;
|
|
3594
|
+
} else if (hp < 5) {
|
|
3595
|
+
r = x;
|
|
3596
|
+
b = c;
|
|
3597
|
+
} else {
|
|
3598
|
+
r = c;
|
|
3599
|
+
b = x;
|
|
3600
|
+
}
|
|
3601
|
+
const m = light - c / 2;
|
|
3602
|
+
return [
|
|
3603
|
+
(r + m) * 255,
|
|
3604
|
+
(g + m) * 255,
|
|
3605
|
+
(b + m) * 255
|
|
3606
|
+
];
|
|
3607
|
+
}
|
|
3608
|
+
function splitCssArgs(body) {
|
|
3609
|
+
return body.split(/[\s,]+/).map((part) => part.trim()).filter((part) => part.length > 0);
|
|
3610
|
+
}
|
|
3611
|
+
function parseLightness(token) {
|
|
3612
|
+
if (token.endsWith("%")) {
|
|
3613
|
+
const n = Number.parseFloat(token.slice(0, -1));
|
|
3614
|
+
return Number.isFinite(n) ? n / 100 : void 0;
|
|
3615
|
+
}
|
|
3616
|
+
return parseNumber(token);
|
|
3617
|
+
}
|
|
3618
|
+
function parseHue(token) {
|
|
3619
|
+
if (token === "none") return 0;
|
|
3620
|
+
if (token.endsWith("deg")) return parseNumber(token.slice(0, -3));
|
|
3621
|
+
if (token.endsWith("rad")) {
|
|
3622
|
+
const n = parseNumber(token.slice(0, -3));
|
|
3623
|
+
return n === void 0 ? void 0 : n * 180 / Math.PI;
|
|
3624
|
+
}
|
|
3625
|
+
if (token.endsWith("turn")) {
|
|
3626
|
+
const n = parseNumber(token.slice(0, -4));
|
|
3627
|
+
return n === void 0 ? void 0 : n * 360;
|
|
3628
|
+
}
|
|
3629
|
+
if (token.endsWith("grad")) {
|
|
3630
|
+
const n = parseNumber(token.slice(0, -4));
|
|
3631
|
+
return n === void 0 ? void 0 : n * .9;
|
|
3632
|
+
}
|
|
3633
|
+
return parseNumber(token);
|
|
3634
|
+
}
|
|
3635
|
+
function parsePercent(token) {
|
|
3636
|
+
if (!token.endsWith("%")) return parseNumber(token);
|
|
3637
|
+
return parseNumber(token.slice(0, -1));
|
|
3638
|
+
}
|
|
3639
|
+
function parseRgbChannel(token) {
|
|
3640
|
+
if (token.endsWith("%")) {
|
|
3641
|
+
const n = parseNumber(token.slice(0, -1));
|
|
3642
|
+
return n === void 0 ? void 0 : n / 100 * 255;
|
|
3643
|
+
}
|
|
3644
|
+
return parseNumber(token);
|
|
3645
|
+
}
|
|
3646
|
+
function parseNumber(token) {
|
|
3647
|
+
if (token === "none") return 0;
|
|
3648
|
+
const n = Number.parseFloat(token);
|
|
3649
|
+
return Number.isFinite(n) ? n : void 0;
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
//#endregion
|
|
3653
|
+
//#region src/brand/css-vars.ts
|
|
3654
|
+
const PROP = /--(primary|brand|accent|background|foreground|muted-foreground|muted)\s*:\s*([^;]+);/g;
|
|
3655
|
+
function extractCssRootColors(css) {
|
|
3656
|
+
const fromRoot = extractBlockColors(css, /:root\s*\{/g);
|
|
3657
|
+
const fromDark = extractBlockColors(css, /\.dark\s*\{/g);
|
|
3658
|
+
return mapFoundColors({
|
|
3659
|
+
...fromRoot,
|
|
3660
|
+
...fromDark
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
function extractBlockColors(css, blockRe) {
|
|
3664
|
+
const found = {};
|
|
3665
|
+
blockRe.lastIndex = 0;
|
|
3666
|
+
let match;
|
|
3667
|
+
while ((match = blockRe.exec(css)) !== null) {
|
|
3668
|
+
const open = css.indexOf("{", match.index);
|
|
3669
|
+
if (open === -1) break;
|
|
3670
|
+
const close = matchingBrace$1(css, open);
|
|
3671
|
+
if (close === -1) break;
|
|
3672
|
+
const body = css.slice(open + 1, close);
|
|
3673
|
+
PROP.lastIndex = 0;
|
|
3674
|
+
let prop;
|
|
3675
|
+
while ((prop = PROP.exec(body)) !== null) {
|
|
3676
|
+
const name = prop[1];
|
|
3677
|
+
const raw = prop[2];
|
|
3678
|
+
if (name === void 0 || raw === void 0) continue;
|
|
3679
|
+
const hex = parseCssColor(raw.trim());
|
|
3680
|
+
if (hex !== void 0) found[name] = hex;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
return found;
|
|
3684
|
+
}
|
|
3685
|
+
function mapFoundColors(found) {
|
|
3686
|
+
const out = {};
|
|
3687
|
+
const background = found.background;
|
|
3688
|
+
if (background !== void 0) out.background = background;
|
|
3689
|
+
const foreground = found.foreground;
|
|
3690
|
+
if (foreground !== void 0) out.foreground = foreground;
|
|
3691
|
+
const muted = found["muted-foreground"] ?? found.muted;
|
|
3692
|
+
if (muted !== void 0) out.muted = muted;
|
|
3693
|
+
const primary = found.primary ?? found.brand;
|
|
3694
|
+
if (primary !== void 0) out.primary = primary;
|
|
3695
|
+
if (found.accent !== void 0) out.accent = found.accent;
|
|
3696
|
+
return out;
|
|
3697
|
+
}
|
|
3698
|
+
function matchingBrace$1(source, openIndex) {
|
|
3699
|
+
let depth = 0;
|
|
3700
|
+
for (let i = openIndex; i < source.length; i += 1) {
|
|
3701
|
+
const ch = source[i];
|
|
3702
|
+
if (ch === "{") depth += 1;
|
|
3703
|
+
else if (ch === "}") {
|
|
3704
|
+
depth -= 1;
|
|
3705
|
+
if (depth === 0) return i;
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
return -1;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
//#endregion
|
|
3712
|
+
//#region src/brand/dtcg.ts
|
|
3713
|
+
function extractDtcgColors(tokens) {
|
|
3714
|
+
const found = {};
|
|
3715
|
+
walk(tokens, [], found);
|
|
3716
|
+
const out = {};
|
|
3717
|
+
const background = found.background ?? found.bg;
|
|
3718
|
+
if (background !== void 0) out.background = background;
|
|
3719
|
+
const foreground = found.foreground ?? found.fg;
|
|
3720
|
+
if (foreground !== void 0) out.foreground = foreground;
|
|
3721
|
+
if (found.muted !== void 0) out.muted = found.muted;
|
|
3722
|
+
const primary = found.primary ?? found.brand;
|
|
3723
|
+
if (primary !== void 0) out.primary = primary;
|
|
3724
|
+
const accent = found.accent ?? found.secondary;
|
|
3725
|
+
if (accent !== void 0) out.accent = accent;
|
|
3726
|
+
return out;
|
|
3727
|
+
}
|
|
3728
|
+
function walk(node, path, found) {
|
|
3729
|
+
if (!isRecord(node)) return;
|
|
3730
|
+
if (node.$type === "color") {
|
|
3731
|
+
const hex = colorValue(node.$value);
|
|
3732
|
+
const leaf = path[path.length - 1];
|
|
3733
|
+
if (hex !== void 0 && leaf !== void 0 && found[leaf] === void 0) found[leaf] = hex;
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
for (const [key, value] of Object.entries(node)) {
|
|
3737
|
+
if (key.startsWith("$")) continue;
|
|
3738
|
+
walk(value, [...path, key], found);
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
function colorValue(value) {
|
|
3742
|
+
if (typeof value === "string") return parseCssColor(value);
|
|
3743
|
+
if (isRecord(value) && typeof value.hex === "string") return parseCssColor(value.hex);
|
|
3744
|
+
}
|
|
3745
|
+
function isRecord(value) {
|
|
3746
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
//#endregion
|
|
3750
|
+
//#region src/brand/logo.ts
|
|
3751
|
+
const DIRECTORIES = [
|
|
5
3752
|
"",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
""
|
|
9
|
-
|
|
3753
|
+
"assets",
|
|
3754
|
+
"assets/brand",
|
|
3755
|
+
"public",
|
|
3756
|
+
"public/brand",
|
|
3757
|
+
".github",
|
|
3758
|
+
"docs",
|
|
3759
|
+
"static",
|
|
3760
|
+
"branding"
|
|
3761
|
+
];
|
|
3762
|
+
const ICON_NAMES = ["icon.svg", "icon.png"];
|
|
3763
|
+
const NAMES = [
|
|
3764
|
+
"logo.svg",
|
|
3765
|
+
"logo.png",
|
|
3766
|
+
"logo-light.svg",
|
|
3767
|
+
"logo-light.png"
|
|
3768
|
+
];
|
|
3769
|
+
const FALLBACKS = [...ICON_NAMES, "favicon.svg"];
|
|
3770
|
+
function findLogo(cwd) {
|
|
3771
|
+
for (const dir of DIRECTORIES) for (const name of NAMES) {
|
|
3772
|
+
const relative = dir === "" ? name : join(dir, name);
|
|
3773
|
+
if (existsSync(join(cwd, relative))) return relative;
|
|
3774
|
+
}
|
|
3775
|
+
for (const dir of DIRECTORIES) for (const name of FALLBACKS) {
|
|
3776
|
+
const relative = dir === "" ? name : join(dir, name);
|
|
3777
|
+
if (existsSync(join(cwd, relative))) return relative;
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
function findLogoPair(cwd) {
|
|
3781
|
+
const light = findLogo(cwd);
|
|
3782
|
+
if (light === void 0) return;
|
|
3783
|
+
const darkNames = ["logo-dark.svg", "logo-dark.png"];
|
|
3784
|
+
for (const dir of DIRECTORIES) for (const name of darkNames) {
|
|
3785
|
+
const relative = dir === "" ? name : join(dir, name);
|
|
3786
|
+
if (existsSync(join(cwd, relative))) return {
|
|
3787
|
+
light,
|
|
3788
|
+
dark: relative
|
|
3789
|
+
};
|
|
3790
|
+
}
|
|
3791
|
+
return { light };
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
//#endregion
|
|
3795
|
+
//#region src/brand/png.ts
|
|
3796
|
+
const SIGNATURE = Buffer.from([
|
|
3797
|
+
137,
|
|
3798
|
+
80,
|
|
3799
|
+
78,
|
|
3800
|
+
71,
|
|
3801
|
+
13,
|
|
3802
|
+
10,
|
|
3803
|
+
26,
|
|
3804
|
+
10
|
|
3805
|
+
]);
|
|
3806
|
+
/** Channels per pixel for each PNG colour type. Index is the colour type. */
|
|
3807
|
+
const CHANNELS = {
|
|
3808
|
+
0: 1,
|
|
3809
|
+
2: 3,
|
|
3810
|
+
3: 1,
|
|
3811
|
+
4: 2,
|
|
3812
|
+
6: 4
|
|
3813
|
+
};
|
|
3814
|
+
function decodePng(buffer) {
|
|
3815
|
+
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) return;
|
|
3816
|
+
let width = 0;
|
|
3817
|
+
let height = 0;
|
|
3818
|
+
let bitDepth = 0;
|
|
3819
|
+
let colorType = 0;
|
|
3820
|
+
let interlace = 0;
|
|
3821
|
+
let palette;
|
|
3822
|
+
let paletteAlpha;
|
|
3823
|
+
const idat = [];
|
|
3824
|
+
let offset = 8;
|
|
3825
|
+
while (offset + 8 <= buffer.length) {
|
|
3826
|
+
const length = buffer.readUInt32BE(offset);
|
|
3827
|
+
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
|
3828
|
+
const start = offset + 8;
|
|
3829
|
+
const end = start + length;
|
|
3830
|
+
if (end > buffer.length) return;
|
|
3831
|
+
if (type === "IHDR") {
|
|
3832
|
+
width = buffer.readUInt32BE(start);
|
|
3833
|
+
height = buffer.readUInt32BE(start + 4);
|
|
3834
|
+
bitDepth = buffer[start + 8] ?? 0;
|
|
3835
|
+
colorType = buffer[start + 9] ?? 0;
|
|
3836
|
+
interlace = buffer[start + 12] ?? 0;
|
|
3837
|
+
} else if (type === "PLTE") palette = buffer.subarray(start, end);
|
|
3838
|
+
else if (type === "tRNS") paletteAlpha = buffer.subarray(start, end);
|
|
3839
|
+
else if (type === "IDAT") idat.push(buffer.subarray(start, end));
|
|
3840
|
+
else if (type === "IEND") break;
|
|
3841
|
+
offset = end + 4;
|
|
3842
|
+
}
|
|
3843
|
+
const channels = CHANNELS[colorType];
|
|
3844
|
+
if (width === 0 || height === 0 || channels === void 0 || interlace !== 0 || bitDepth !== 8 && bitDepth !== 16 || idat.length === 0 || colorType === 3 && palette === void 0) return;
|
|
3845
|
+
if (width * height > 64e6) return;
|
|
3846
|
+
let raw;
|
|
3847
|
+
try {
|
|
3848
|
+
raw = inflateSync(Buffer.concat(idat));
|
|
3849
|
+
} catch {
|
|
3850
|
+
return;
|
|
3851
|
+
}
|
|
3852
|
+
const bytesPerSample = bitDepth / 8;
|
|
3853
|
+
const bpp = channels * bytesPerSample;
|
|
3854
|
+
const stride = width * bpp;
|
|
3855
|
+
if (raw.length < height * (stride + 1)) return;
|
|
3856
|
+
const unfiltered = unfilter(raw, height, stride, bpp);
|
|
3857
|
+
if (unfiltered === void 0) return;
|
|
3858
|
+
return {
|
|
3859
|
+
width,
|
|
3860
|
+
height,
|
|
3861
|
+
pixels: toRgba(unfiltered, width, height, colorType, bytesPerSample, palette, paletteAlpha)
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3864
|
+
/** Reverse the per-scanline filters defined in the PNG spec. */
|
|
3865
|
+
function unfilter(raw, height, stride, bpp) {
|
|
3866
|
+
const out = Buffer.alloc(height * stride);
|
|
3867
|
+
let pos = 0;
|
|
3868
|
+
for (let y = 0; y < height; y++) {
|
|
3869
|
+
const filter = raw[pos];
|
|
3870
|
+
pos += 1;
|
|
3871
|
+
if (filter === void 0 || filter > 4) return;
|
|
3872
|
+
const rowStart = y * stride;
|
|
3873
|
+
const prevStart = rowStart - stride;
|
|
3874
|
+
for (let x = 0; x < stride; x++) {
|
|
3875
|
+
const value = raw[pos + x] ?? 0;
|
|
3876
|
+
const a = x >= bpp ? out[rowStart + x - bpp] ?? 0 : 0;
|
|
3877
|
+
const b = y > 0 ? out[prevStart + x] ?? 0 : 0;
|
|
3878
|
+
const c = x >= bpp && y > 0 ? out[prevStart + x - bpp] ?? 0 : 0;
|
|
3879
|
+
let restored;
|
|
3880
|
+
switch (filter) {
|
|
3881
|
+
case 1:
|
|
3882
|
+
restored = value + a;
|
|
3883
|
+
break;
|
|
3884
|
+
case 2:
|
|
3885
|
+
restored = value + b;
|
|
3886
|
+
break;
|
|
3887
|
+
case 3:
|
|
3888
|
+
restored = value + Math.floor((a + b) / 2);
|
|
3889
|
+
break;
|
|
3890
|
+
case 4:
|
|
3891
|
+
restored = value + paeth(a, b, c);
|
|
3892
|
+
break;
|
|
3893
|
+
default: restored = value;
|
|
3894
|
+
}
|
|
3895
|
+
out[rowStart + x] = restored & 255;
|
|
3896
|
+
}
|
|
3897
|
+
pos += stride;
|
|
3898
|
+
}
|
|
3899
|
+
return out;
|
|
3900
|
+
}
|
|
3901
|
+
function paeth(a, b, c) {
|
|
3902
|
+
const p = a + b - c;
|
|
3903
|
+
const pa = Math.abs(p - a);
|
|
3904
|
+
const pb = Math.abs(p - b);
|
|
3905
|
+
const pc = Math.abs(p - c);
|
|
3906
|
+
if (pa <= pb && pa <= pc) return a;
|
|
3907
|
+
return pb <= pc ? b : c;
|
|
3908
|
+
}
|
|
3909
|
+
function toRgba(data, width, height, colorType, bytesPerSample, palette, paletteAlpha) {
|
|
3910
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
3911
|
+
const bpp = (CHANNELS[colorType] ?? 4) * bytesPerSample;
|
|
3912
|
+
for (let i = 0; i < width * height; i++) {
|
|
3913
|
+
const at = (channel) => data[i * bpp + channel * bytesPerSample] ?? 0;
|
|
3914
|
+
const out = i * 4;
|
|
3915
|
+
switch (colorType) {
|
|
3916
|
+
case 0: {
|
|
3917
|
+
const g = at(0);
|
|
3918
|
+
pixels[out] = g;
|
|
3919
|
+
pixels[out + 1] = g;
|
|
3920
|
+
pixels[out + 2] = g;
|
|
3921
|
+
pixels[out + 3] = 255;
|
|
3922
|
+
break;
|
|
3923
|
+
}
|
|
3924
|
+
case 2:
|
|
3925
|
+
pixels[out] = at(0);
|
|
3926
|
+
pixels[out + 1] = at(1);
|
|
3927
|
+
pixels[out + 2] = at(2);
|
|
3928
|
+
pixels[out + 3] = 255;
|
|
3929
|
+
break;
|
|
3930
|
+
case 3: {
|
|
3931
|
+
const index = at(0);
|
|
3932
|
+
pixels[out] = palette?.[index * 3] ?? 0;
|
|
3933
|
+
pixels[out + 1] = palette?.[index * 3 + 1] ?? 0;
|
|
3934
|
+
pixels[out + 2] = palette?.[index * 3 + 2] ?? 0;
|
|
3935
|
+
pixels[out + 3] = paletteAlpha?.[index] ?? 255;
|
|
3936
|
+
break;
|
|
3937
|
+
}
|
|
3938
|
+
case 4: {
|
|
3939
|
+
const g = at(0);
|
|
3940
|
+
pixels[out] = g;
|
|
3941
|
+
pixels[out + 1] = g;
|
|
3942
|
+
pixels[out + 2] = g;
|
|
3943
|
+
pixels[out + 3] = at(1);
|
|
3944
|
+
break;
|
|
3945
|
+
}
|
|
3946
|
+
default:
|
|
3947
|
+
pixels[out] = at(0);
|
|
3948
|
+
pixels[out + 1] = at(1);
|
|
3949
|
+
pixels[out + 2] = at(2);
|
|
3950
|
+
pixels[out + 3] = at(3);
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
return pixels;
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* Most common non-neutral colour in a logo, as a hex string.
|
|
3957
|
+
*
|
|
3958
|
+
* Nearly transparent pixels are ignored so a transparent background does not win, and greys
|
|
3959
|
+
* are ignored because a logo's black wordmark is not its brand colour. Colours are bucketed
|
|
3960
|
+
* into 16 levels per channel so anti-aliased edges group with the solid fill they belong to,
|
|
3961
|
+
* then the winning bucket reports the average of the real pixels inside it rather than the
|
|
3962
|
+
* bucket centre, which would drift the hue.
|
|
3963
|
+
*/
|
|
3964
|
+
function dominantNonNeutralColor(png) {
|
|
3965
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
3966
|
+
for (let i = 0; i < png.pixels.length; i += 4) {
|
|
3967
|
+
if ((png.pixels[i + 3] ?? 0) < 128) continue;
|
|
3968
|
+
const r = png.pixels[i] ?? 0;
|
|
3969
|
+
const g = png.pixels[i + 1] ?? 0;
|
|
3970
|
+
const b = png.pixels[i + 2] ?? 0;
|
|
3971
|
+
if (Math.max(r, g, b) - Math.min(r, g, b) < 16) continue;
|
|
3972
|
+
const key = r >> 4 << 8 | g >> 4 << 4 | b >> 4;
|
|
3973
|
+
const bucket = buckets.get(key);
|
|
3974
|
+
if (bucket === void 0) buckets.set(key, {
|
|
3975
|
+
count: 1,
|
|
3976
|
+
r,
|
|
3977
|
+
g,
|
|
3978
|
+
b
|
|
3979
|
+
});
|
|
3980
|
+
else {
|
|
3981
|
+
bucket.count += 1;
|
|
3982
|
+
bucket.r += r;
|
|
3983
|
+
bucket.g += g;
|
|
3984
|
+
bucket.b += b;
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
let best;
|
|
3988
|
+
for (const bucket of buckets.values()) if (best === void 0 || bucket.count > best.count) best = bucket;
|
|
3989
|
+
if (best === void 0) return;
|
|
3990
|
+
const channel = (total) => Math.round(total / best.count).toString(16).padStart(2, "0");
|
|
3991
|
+
return `#${channel(best.r)}${channel(best.g)}${channel(best.b)}`;
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
//#endregion
|
|
3995
|
+
//#region src/brand/tailwind.ts
|
|
3996
|
+
const COLOR_PROP = /--color-([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
|
|
3997
|
+
const CUSTOM_PROP = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
|
|
3998
|
+
const SKIP_PREFIXES = /* @__PURE__ */ new Set([
|
|
3999
|
+
"chart",
|
|
4000
|
+
"sidebar",
|
|
4001
|
+
"ring",
|
|
4002
|
+
"border",
|
|
4003
|
+
"input",
|
|
4004
|
+
"destructive"
|
|
4005
|
+
]);
|
|
4006
|
+
function extractTailwindV4Colors(css) {
|
|
4007
|
+
const blocks = themeBlocks(css);
|
|
4008
|
+
const custom = collectCustomProperties(css);
|
|
4009
|
+
const fromTheme = {};
|
|
4010
|
+
for (const block of blocks) {
|
|
4011
|
+
COLOR_PROP.lastIndex = 0;
|
|
4012
|
+
let match;
|
|
4013
|
+
while ((match = COLOR_PROP.exec(block)) !== null) {
|
|
4014
|
+
const name = match[1];
|
|
4015
|
+
const raw = match[2];
|
|
4016
|
+
if (name === void 0 || raw === void 0 || shouldSkip(name)) continue;
|
|
4017
|
+
fromTheme[name] = raw.trim();
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
const resolved = {};
|
|
4021
|
+
for (const [name, raw] of Object.entries(fromTheme)) {
|
|
4022
|
+
const hex = resolveToHex(raw, custom, 0);
|
|
4023
|
+
if (hex !== void 0) resolved[name] = hex;
|
|
4024
|
+
}
|
|
4025
|
+
return mapBrandColors(resolved);
|
|
4026
|
+
}
|
|
4027
|
+
function extractTailwindV3Colors(source) {
|
|
4028
|
+
const found = {};
|
|
4029
|
+
const re = /\b(primary|brand|accent|background|foreground|muted|secondary)\b\s*:\s*["'`]([^"'`]+)["'`]/g;
|
|
4030
|
+
let match;
|
|
4031
|
+
while ((match = re.exec(source)) !== null) {
|
|
4032
|
+
const name = match[1];
|
|
4033
|
+
const raw = match[2];
|
|
4034
|
+
if (name === void 0 || raw === void 0) continue;
|
|
4035
|
+
const hex = parseCssColor(raw);
|
|
4036
|
+
if (hex !== void 0) found[name] = hex;
|
|
4037
|
+
}
|
|
4038
|
+
return mapBrandColors(found);
|
|
4039
|
+
}
|
|
4040
|
+
function themeBlocks(css) {
|
|
4041
|
+
const blocks = [];
|
|
4042
|
+
const re = /@theme(?:\s+inline)?\s*\{/g;
|
|
4043
|
+
let match;
|
|
4044
|
+
while ((match = re.exec(css)) !== null) {
|
|
4045
|
+
const open = css.indexOf("{", match.index);
|
|
4046
|
+
if (open === -1) break;
|
|
4047
|
+
const close = matchingBrace(css, open);
|
|
4048
|
+
if (close === -1) break;
|
|
4049
|
+
blocks.push(css.slice(open + 1, close));
|
|
4050
|
+
}
|
|
4051
|
+
return blocks;
|
|
4052
|
+
}
|
|
4053
|
+
function matchingBrace(source, openIndex) {
|
|
4054
|
+
let depth = 0;
|
|
4055
|
+
for (let i = openIndex; i < source.length; i += 1) {
|
|
4056
|
+
const ch = source[i];
|
|
4057
|
+
if (ch === "{") depth += 1;
|
|
4058
|
+
else if (ch === "}") {
|
|
4059
|
+
depth -= 1;
|
|
4060
|
+
if (depth === 0) return i;
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
return -1;
|
|
4064
|
+
}
|
|
4065
|
+
function collectCustomProperties(css) {
|
|
4066
|
+
const props = /* @__PURE__ */ new Map();
|
|
4067
|
+
CUSTOM_PROP.lastIndex = 0;
|
|
4068
|
+
let match;
|
|
4069
|
+
while ((match = CUSTOM_PROP.exec(css)) !== null) {
|
|
4070
|
+
const name = match[1];
|
|
4071
|
+
const value = match[2];
|
|
4072
|
+
if (name !== void 0 && value !== void 0) props.set(`--${name}`, value.trim());
|
|
4073
|
+
}
|
|
4074
|
+
return props;
|
|
4075
|
+
}
|
|
4076
|
+
function resolveToHex(raw, custom, depth) {
|
|
4077
|
+
if (depth > 3) return;
|
|
4078
|
+
const direct = parseCssColor(raw);
|
|
4079
|
+
if (direct !== void 0) return direct;
|
|
4080
|
+
const varMatch = /^var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,[^)]+)?\)$/.exec(raw);
|
|
4081
|
+
if (varMatch === null) return;
|
|
4082
|
+
const ref = varMatch[1];
|
|
4083
|
+
if (ref === void 0) return;
|
|
4084
|
+
const next = custom.get(ref);
|
|
4085
|
+
if (next === void 0) return;
|
|
4086
|
+
return resolveToHex(next, custom, depth + 1);
|
|
4087
|
+
}
|
|
4088
|
+
function shouldSkip(name) {
|
|
4089
|
+
const root = name.split("-")[0];
|
|
4090
|
+
return root !== void 0 && SKIP_PREFIXES.has(root);
|
|
4091
|
+
}
|
|
4092
|
+
function mapBrandColors(found) {
|
|
4093
|
+
const out = {};
|
|
4094
|
+
const background = found.background ?? found.bg;
|
|
4095
|
+
if (background !== void 0) out.background = background;
|
|
4096
|
+
const foreground = found.foreground ?? found.fg;
|
|
4097
|
+
if (foreground !== void 0) out.foreground = foreground;
|
|
4098
|
+
const muted = found["muted-foreground"] ?? found.muted;
|
|
4099
|
+
if (muted !== void 0) out.muted = muted;
|
|
4100
|
+
const primary = found.primary ?? found.brand;
|
|
4101
|
+
if (primary !== void 0) out.primary = primary;
|
|
4102
|
+
const accent = found.accent ?? found.secondary;
|
|
4103
|
+
if (accent !== void 0) out.accent = accent;
|
|
4104
|
+
return out;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
//#endregion
|
|
4108
|
+
//#region src/brand/detect.ts
|
|
4109
|
+
const execFileAsync = promisify(execFile);
|
|
4110
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
4111
|
+
"node_modules",
|
|
4112
|
+
".git",
|
|
4113
|
+
"dist",
|
|
4114
|
+
"coverage",
|
|
4115
|
+
".shipseal",
|
|
4116
|
+
".next",
|
|
4117
|
+
"build"
|
|
4118
|
+
]);
|
|
4119
|
+
const packageSchema = z.object({
|
|
4120
|
+
private: z.boolean().optional(),
|
|
4121
|
+
name: z.string().optional(),
|
|
4122
|
+
description: z.string().optional(),
|
|
4123
|
+
homepage: z.string().optional(),
|
|
4124
|
+
repository: z.union([z.string(), z.object({ url: z.string().optional() })]).optional()
|
|
4125
|
+
});
|
|
4126
|
+
async function detectBrand(cwd) {
|
|
4127
|
+
const sources = [];
|
|
4128
|
+
const notes = [];
|
|
4129
|
+
const pkg = await readPackage(cwd);
|
|
4130
|
+
const readme = await readMaybe(join(cwd, "README.md"));
|
|
4131
|
+
const name = detectName(pkg, readme, cwd, sources);
|
|
4132
|
+
const tagline = detectTagline(pkg, readme, sources);
|
|
4133
|
+
const url = await detectUrl(pkg, cwd, sources);
|
|
4134
|
+
const logo = findLogoPair(cwd);
|
|
4135
|
+
if (logo !== void 0) sources.push({
|
|
4136
|
+
field: "logo",
|
|
4137
|
+
source: logo.light
|
|
4138
|
+
});
|
|
4139
|
+
const colors = await detectColors(cwd, logo?.light, sources, notes);
|
|
4140
|
+
const contrast = ensureForegroundContrast(colors.background, colors.foreground);
|
|
4141
|
+
if (contrast.adjusted) {
|
|
4142
|
+
colors.foreground = contrast.foreground;
|
|
4143
|
+
notes.push(`Foreground failed WCAG AA large-text contrast (3:1) against background, so it was set to ${contrast.foreground}.`);
|
|
4144
|
+
upsertSource(sources, "colors.foreground", "contrast adjustment");
|
|
4145
|
+
}
|
|
4146
|
+
applyReadableMuted(colors, sources, notes);
|
|
4147
|
+
applyReadableAccent(colors, sources, notes);
|
|
4148
|
+
const brand = {
|
|
4149
|
+
version: 1,
|
|
4150
|
+
name,
|
|
4151
|
+
colors,
|
|
4152
|
+
fonts: {
|
|
4153
|
+
heading: { ...DEFAULT_BRAND_FONTS.heading },
|
|
4154
|
+
body: { ...DEFAULT_BRAND_FONTS.body },
|
|
4155
|
+
mono: { ...DEFAULT_BRAND_FONTS.mono }
|
|
4156
|
+
},
|
|
4157
|
+
radius: 16,
|
|
4158
|
+
theme: "dark",
|
|
4159
|
+
style: "minimal",
|
|
4160
|
+
tokens: null
|
|
4161
|
+
};
|
|
4162
|
+
if (tagline !== void 0) brand.tagline = tagline;
|
|
4163
|
+
if (url !== void 0) brand.url = url;
|
|
4164
|
+
if (logo !== void 0) brand.logo = logo.dark === void 0 ? { light: logo.light } : {
|
|
4165
|
+
light: logo.light,
|
|
4166
|
+
dark: logo.dark
|
|
4167
|
+
};
|
|
4168
|
+
sources.push({
|
|
4169
|
+
field: "fonts",
|
|
4170
|
+
source: "built-in Geist / vendored Geist Mono"
|
|
4171
|
+
});
|
|
4172
|
+
return {
|
|
4173
|
+
brand,
|
|
4174
|
+
sources,
|
|
4175
|
+
notes
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
4178
|
+
function detectName(pkg, readme, cwd, sources) {
|
|
4179
|
+
if (pkg?.name !== void 0 && pkg.private !== true) {
|
|
4180
|
+
const fromPkg = stripScope(pkg.name);
|
|
4181
|
+
const h1 = readme === void 0 ? void 0 : extractH1(readme);
|
|
4182
|
+
if (h1 !== void 0 && h1.toLowerCase() === fromPkg.toLowerCase() && h1 !== fromPkg) {
|
|
4183
|
+
sources.push({
|
|
4184
|
+
field: "name",
|
|
4185
|
+
source: "README.md H1"
|
|
4186
|
+
});
|
|
4187
|
+
return h1;
|
|
4188
|
+
}
|
|
4189
|
+
sources.push({
|
|
4190
|
+
field: "name",
|
|
4191
|
+
source: "package.json#name"
|
|
4192
|
+
});
|
|
4193
|
+
return fromPkg;
|
|
4194
|
+
}
|
|
4195
|
+
const h1 = readme === void 0 ? void 0 : extractH1(readme);
|
|
4196
|
+
if (h1 !== void 0) {
|
|
4197
|
+
sources.push({
|
|
4198
|
+
field: "name",
|
|
4199
|
+
source: "README.md H1"
|
|
4200
|
+
});
|
|
4201
|
+
return h1;
|
|
4202
|
+
}
|
|
4203
|
+
sources.push({
|
|
4204
|
+
field: "name",
|
|
4205
|
+
source: "directory name"
|
|
4206
|
+
});
|
|
4207
|
+
const fromDir = basename(cwd);
|
|
4208
|
+
return fromDir.length > 0 ? fromDir : "project";
|
|
4209
|
+
}
|
|
4210
|
+
function detectTagline(pkg, readme, sources) {
|
|
4211
|
+
if (pkg?.description !== void 0 && pkg.description.trim().length > 0) {
|
|
4212
|
+
sources.push({
|
|
4213
|
+
field: "tagline",
|
|
4214
|
+
source: "package.json#description"
|
|
4215
|
+
});
|
|
4216
|
+
return pkg.description.trim();
|
|
4217
|
+
}
|
|
4218
|
+
if (readme !== void 0) {
|
|
4219
|
+
const tagline = extractTagline(readme);
|
|
4220
|
+
if (tagline !== void 0) {
|
|
4221
|
+
sources.push({
|
|
4222
|
+
field: "tagline",
|
|
4223
|
+
source: "README.md first paragraph"
|
|
4224
|
+
});
|
|
4225
|
+
return tagline;
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
async function detectUrl(pkg, cwd, sources) {
|
|
4230
|
+
if (pkg?.homepage !== void 0 && pkg.homepage.length > 0) {
|
|
4231
|
+
sources.push({
|
|
4232
|
+
field: "url",
|
|
4233
|
+
source: "package.json#homepage"
|
|
4234
|
+
});
|
|
4235
|
+
return pkg.homepage;
|
|
4236
|
+
}
|
|
4237
|
+
const repo = repositoryUrl(pkg?.repository);
|
|
4238
|
+
if (repo !== void 0) {
|
|
4239
|
+
sources.push({
|
|
4240
|
+
field: "url",
|
|
4241
|
+
source: "package.json#repository"
|
|
4242
|
+
});
|
|
4243
|
+
return repo;
|
|
4244
|
+
}
|
|
4245
|
+
const remote = await gitRemote(cwd);
|
|
4246
|
+
if (remote !== void 0) {
|
|
4247
|
+
sources.push({
|
|
4248
|
+
field: "url",
|
|
4249
|
+
source: "git remote origin"
|
|
4250
|
+
});
|
|
4251
|
+
return remote;
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
async function detectColors(cwd, logoPath, sources, notes) {
|
|
4255
|
+
const merged = {};
|
|
4256
|
+
const files = await listFiles(cwd, 4);
|
|
4257
|
+
const cssFiles = files.filter((file) => file.endsWith(".css"));
|
|
4258
|
+
const cssContents = await Promise.all(cssFiles.map(async (file) => ({
|
|
4259
|
+
file,
|
|
4260
|
+
css: await readMaybe(file)
|
|
4261
|
+
})));
|
|
4262
|
+
for (const { file, css } of cssContents) {
|
|
4263
|
+
if (css === void 0) continue;
|
|
4264
|
+
const fromTheme = extractTailwindV4Colors(css);
|
|
4265
|
+
const fromRoot = extractCssRootColors(css);
|
|
4266
|
+
const rel = relative(cwd, file);
|
|
4267
|
+
applyColors(merged, fromTheme, sources, `Tailwind v4 @theme in ${rel}`);
|
|
4268
|
+
applyColors(merged, fromRoot, sources, `:root variables in ${rel}`);
|
|
4269
|
+
}
|
|
4270
|
+
const configFiles = files.filter((file) => basename(file).startsWith("tailwind.config."));
|
|
4271
|
+
const configContents = await Promise.all(configFiles.map(async (file) => ({
|
|
4272
|
+
file,
|
|
4273
|
+
source: await readMaybe(file)
|
|
4274
|
+
})));
|
|
4275
|
+
for (const { file, source } of configContents) {
|
|
4276
|
+
if (source === void 0) continue;
|
|
4277
|
+
applyColors(merged, extractTailwindV3Colors(source), sources, `Tailwind config ${relative(cwd, file)}`);
|
|
4278
|
+
}
|
|
4279
|
+
const tokenFile = files.find((file) => file.endsWith(".tokens.json"));
|
|
4280
|
+
if (tokenFile !== void 0) {
|
|
4281
|
+
const raw = await readMaybe(tokenFile);
|
|
4282
|
+
if (raw !== void 0) try {
|
|
4283
|
+
const parsed = JSON.parse(raw);
|
|
4284
|
+
applyColors(merged, extractDtcgColors(parsed), sources, relative(cwd, tokenFile));
|
|
4285
|
+
} catch (error) {
|
|
4286
|
+
if (error instanceof SyntaxError) notes.push(`Could not parse design tokens file ${relative(cwd, tokenFile)}.`);
|
|
4287
|
+
else throw error;
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
if (merged.primary === void 0 && logoPath !== void 0) {
|
|
4291
|
+
const fromPng = logoPath.endsWith(".png") ? await dominantLogoColor(join(cwd, logoPath)) : void 0;
|
|
4292
|
+
if (fromPng !== void 0) {
|
|
4293
|
+
merged.primary = fromPng;
|
|
4294
|
+
sources.push({
|
|
4295
|
+
field: "colors.primary",
|
|
4296
|
+
source: `dominant colour in ${logoPath}`
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
4299
|
+
const svg = logoPath.endsWith(".svg") ? await readMaybe(join(cwd, logoPath)) : void 0;
|
|
4300
|
+
if (svg !== void 0) {
|
|
4301
|
+
const fromLogo = firstNonNeutralSvgColor(svg);
|
|
4302
|
+
if (fromLogo !== void 0) {
|
|
4303
|
+
merged.primary = fromLogo;
|
|
4304
|
+
sources.push({
|
|
4305
|
+
field: "colors.primary",
|
|
4306
|
+
source: `SVG fill in ${logoPath}`
|
|
4307
|
+
});
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
const fellBack = [
|
|
4312
|
+
"background",
|
|
4313
|
+
"foreground",
|
|
4314
|
+
"muted",
|
|
4315
|
+
"primary",
|
|
4316
|
+
"accent"
|
|
4317
|
+
].filter((field) => merged[field] === void 0);
|
|
4318
|
+
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.`);
|
|
4319
|
+
return {
|
|
4320
|
+
background: merged.background ?? DEFAULT_BRAND_COLORS.background,
|
|
4321
|
+
foreground: merged.foreground ?? DEFAULT_BRAND_COLORS.foreground,
|
|
4322
|
+
muted: merged.muted ?? DEFAULT_BRAND_COLORS.muted,
|
|
4323
|
+
primary: merged.primary ?? DEFAULT_BRAND_COLORS.primary,
|
|
4324
|
+
accent: merged.accent ?? DEFAULT_BRAND_COLORS.accent
|
|
4325
|
+
};
|
|
4326
|
+
}
|
|
4327
|
+
function applyReadableMuted(colors, sources, notes) {
|
|
4328
|
+
if (colors.muted === void 0 || readableOnBackground(colors.background, colors.muted)) return;
|
|
4329
|
+
colors.muted = DEFAULT_BRAND_COLORS.muted;
|
|
4330
|
+
notes.push(`Muted text color failed WCAG AA large-text contrast (3:1) against background, so it was set to ${DEFAULT_BRAND_COLORS.muted}.`);
|
|
4331
|
+
upsertSource(sources, "colors.muted", "contrast adjustment");
|
|
4332
|
+
}
|
|
4333
|
+
function applyReadableAccent(colors, sources, notes) {
|
|
4334
|
+
if (colors.accent === void 0 || readableOnBackground(colors.background, colors.accent)) return;
|
|
4335
|
+
colors.accent = colors.primary ?? DEFAULT_BRAND_COLORS.primary;
|
|
4336
|
+
notes.push("Accent failed contrast against background, so it uses the primary color.");
|
|
4337
|
+
upsertSource(sources, "colors.accent", "contrast adjustment");
|
|
4338
|
+
}
|
|
4339
|
+
function applyColors(target, incoming, sources, source) {
|
|
4340
|
+
for (const key of [
|
|
4341
|
+
"background",
|
|
4342
|
+
"foreground",
|
|
4343
|
+
"muted",
|
|
4344
|
+
"primary",
|
|
4345
|
+
"accent"
|
|
4346
|
+
]) {
|
|
4347
|
+
const value = incoming[key];
|
|
4348
|
+
if (value !== void 0 && target[key] === void 0) {
|
|
4349
|
+
target[key] = value;
|
|
4350
|
+
sources.push({
|
|
4351
|
+
field: `colors.${key}`,
|
|
4352
|
+
source
|
|
4353
|
+
});
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
function upsertSource(sources, field, source) {
|
|
4358
|
+
const existing = sources.find((entry) => entry.field === field);
|
|
4359
|
+
if (existing !== void 0) {
|
|
4360
|
+
existing.source = source;
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4363
|
+
sources.push({
|
|
4364
|
+
field,
|
|
4365
|
+
source
|
|
4366
|
+
});
|
|
4367
|
+
}
|
|
4368
|
+
async function readPackage(cwd) {
|
|
4369
|
+
const raw = await readMaybe(join(cwd, "package.json"));
|
|
4370
|
+
if (raw === void 0) return;
|
|
4371
|
+
try {
|
|
4372
|
+
const parsed = JSON.parse(raw);
|
|
4373
|
+
const result = packageSchema.safeParse(parsed);
|
|
4374
|
+
return result.success ? result.data : void 0;
|
|
4375
|
+
} catch (error) {
|
|
4376
|
+
if (error instanceof SyntaxError) return;
|
|
4377
|
+
throw error;
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
async function gitRemote(cwd) {
|
|
4381
|
+
try {
|
|
4382
|
+
const { stdout } = await execFileAsync("git", [
|
|
4383
|
+
"remote",
|
|
4384
|
+
"get-url",
|
|
4385
|
+
"origin"
|
|
4386
|
+
], { cwd });
|
|
4387
|
+
return normalizeGitUrl(stdout.trim());
|
|
4388
|
+
} catch (error) {
|
|
4389
|
+
if (error instanceof Error) return;
|
|
4390
|
+
throw error;
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
async function listFiles(root, maxDepth) {
|
|
4394
|
+
const out = [];
|
|
4395
|
+
const walk = async (dir, depth) => {
|
|
4396
|
+
if (depth < 0) return;
|
|
4397
|
+
let entries;
|
|
4398
|
+
try {
|
|
4399
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
4400
|
+
} catch {
|
|
4401
|
+
return;
|
|
4402
|
+
}
|
|
4403
|
+
const nested = [];
|
|
4404
|
+
for (const entry of entries) {
|
|
4405
|
+
if (entry.name.startsWith(".") && entry.name !== ".github") continue;
|
|
4406
|
+
const path = join(dir, entry.name);
|
|
4407
|
+
if (entry.isDirectory()) {
|
|
4408
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
4409
|
+
nested.push(walk(path, depth - 1));
|
|
4410
|
+
} else out.push(path);
|
|
4411
|
+
}
|
|
4412
|
+
await Promise.all(nested);
|
|
4413
|
+
};
|
|
4414
|
+
await walk(root, maxDepth);
|
|
4415
|
+
return out;
|
|
4416
|
+
}
|
|
4417
|
+
async function readMaybe(path) {
|
|
4418
|
+
try {
|
|
4419
|
+
return await readFile(path, "utf8");
|
|
4420
|
+
} catch {
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
function stripScope(name) {
|
|
4425
|
+
const parts = name.split("/");
|
|
4426
|
+
return parts[parts.length - 1] ?? name;
|
|
4427
|
+
}
|
|
4428
|
+
function repositoryUrl(repository) {
|
|
4429
|
+
if (typeof repository === "string") return normalizeGitUrl(repository);
|
|
4430
|
+
if (repository?.url !== void 0) return normalizeGitUrl(repository.url);
|
|
4431
|
+
}
|
|
4432
|
+
function normalizeGitUrl(url) {
|
|
4433
|
+
const ssh = /^git@([^:]+):(.+)$/.exec(url);
|
|
4434
|
+
if (ssh !== null && ssh[1] !== void 0 && ssh[2] !== void 0) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
|
|
4435
|
+
return url.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
4436
|
+
}
|
|
4437
|
+
/**
|
|
4438
|
+
* Dominant non-neutral colour of a PNG logo, or undefined when the file cannot be read or
|
|
4439
|
+
* carries no colour. Detection reports the colour as not found rather than falling back to a
|
|
4440
|
+
* built-in default that would be presented to the user as "your brand".
|
|
4441
|
+
*/
|
|
4442
|
+
async function dominantLogoColor(path) {
|
|
4443
|
+
let buffer;
|
|
4444
|
+
try {
|
|
4445
|
+
buffer = await readFile(path);
|
|
4446
|
+
} catch {
|
|
4447
|
+
return;
|
|
4448
|
+
}
|
|
4449
|
+
const png = decodePng(buffer);
|
|
4450
|
+
if (png === void 0) return;
|
|
4451
|
+
return dominantNonNeutralColor(png);
|
|
4452
|
+
}
|
|
4453
|
+
function firstNonNeutralSvgColor(svg) {
|
|
4454
|
+
const re = /(?:fill|stroke)="([^"]+)"/g;
|
|
4455
|
+
let match;
|
|
4456
|
+
while ((match = re.exec(svg)) !== null) {
|
|
4457
|
+
const raw = match[1];
|
|
4458
|
+
if (raw === void 0 || raw === "none" || raw === "currentColor") continue;
|
|
4459
|
+
const hex = parseCssColor(raw);
|
|
4460
|
+
if (hex !== void 0 && !isNeutralHex(hex)) return hex;
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
//#endregion
|
|
4465
|
+
//#region src/commands/init.ts
|
|
4466
|
+
async function runInit(options) {
|
|
4467
|
+
const detection = await detectBrand(options.cwd);
|
|
4468
|
+
const brandDir = join(options.cwd, ".shipseal");
|
|
4469
|
+
const brandPath = join(brandDir, "brand.json");
|
|
4470
|
+
const configPath = join(brandDir, "config.json");
|
|
4471
|
+
if (!options.force && (existsSync(brandPath) || existsSync(configPath))) throw new ShipsealError("init.exists", ".shipseal/brand.json or config.json already exists.", "Re-run with --force to overwrite, or edit the files by hand.");
|
|
4472
|
+
if (!options.yes) {
|
|
4473
|
+
if (!await confirmInit(detection)) throw new ShipsealError("init.cancelled", "Init cancelled; no files were written.", "Re-run shipseal init and confirm, or pass --yes to accept detections.");
|
|
4474
|
+
}
|
|
4475
|
+
const brand = brandSchema.parse(detection.brand);
|
|
4476
|
+
const config = configSchema.parse(DEFAULT_CONFIG);
|
|
4477
|
+
await mkdir(brandDir, { recursive: true });
|
|
4478
|
+
await writeFile(brandPath, `${JSON.stringify(brand, null, 2)}\n`, "utf8");
|
|
4479
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
4480
|
+
await ensureGitignore(options.cwd);
|
|
4481
|
+
const sampleDir = join(brandDir, "output", "sample");
|
|
4482
|
+
await mkdir(sampleDir, { recursive: true });
|
|
4483
|
+
const samplePath = join(sampleDir, "release-hero-og.png");
|
|
4484
|
+
const png = await (await createTakumiRenderer()).render(testCardNode(), {
|
|
4485
|
+
width: FORMATS.og.width,
|
|
4486
|
+
height: FORMATS.og.height,
|
|
4487
|
+
format: "png"
|
|
4488
|
+
});
|
|
4489
|
+
await writeFile(samplePath, png);
|
|
4490
|
+
return {
|
|
4491
|
+
brandPath,
|
|
4492
|
+
configPath,
|
|
4493
|
+
samplePath,
|
|
4494
|
+
detection
|
|
4495
|
+
};
|
|
4496
|
+
}
|
|
4497
|
+
async function confirmInit(detection) {
|
|
4498
|
+
if (!stdin.isTTY) throw new ShipsealError("init.unattended", "stdin is not a TTY, so init cannot ask for confirmation.", "Re-run with --yes to accept the detected brand and write files.");
|
|
4499
|
+
stdout.write("Detected brand:\n");
|
|
4500
|
+
for (const field of detection.sources) stdout.write(` ${field.field}: from ${field.source}\n`);
|
|
4501
|
+
for (const note of detection.notes) stdout.write(` note: ${note}\n`);
|
|
4502
|
+
const rl = createInterface({
|
|
4503
|
+
input: stdin,
|
|
4504
|
+
output: stdout
|
|
4505
|
+
});
|
|
4506
|
+
try {
|
|
4507
|
+
const answer = await rl.question("Write .shipseal/brand.json and config.json? [y/N] ");
|
|
4508
|
+
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
4509
|
+
} finally {
|
|
4510
|
+
rl.close();
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
async function ensureGitignore(cwd) {
|
|
4514
|
+
const path = join(cwd, ".gitignore");
|
|
4515
|
+
const entry = ".shipseal/output/";
|
|
4516
|
+
if (!existsSync(path)) {
|
|
4517
|
+
await writeFile(path, `${entry}\n`, "utf8");
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
4520
|
+
const current = await readFile(path, "utf8");
|
|
4521
|
+
if (current.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === ".shipseal/output")) return;
|
|
4522
|
+
const prefix = current.endsWith("\n") || current.length === 0 ? "" : "\n";
|
|
4523
|
+
await writeFile(path, `${current}${prefix}${entry}\n`, "utf8");
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
//#endregion
|
|
4527
|
+
//#region src/commands/milestone.ts
|
|
4528
|
+
async function runMilestone(flags) {
|
|
4529
|
+
const cwd = flags.cwd;
|
|
4530
|
+
const config = overlayFormats(await loadConfig(cwd), flags);
|
|
4531
|
+
const brand = await loadBrand(cwd);
|
|
4532
|
+
const placeholder = {
|
|
4533
|
+
kind: "milestone",
|
|
4534
|
+
metric: "stars",
|
|
4535
|
+
threshold: 0
|
|
4536
|
+
};
|
|
4537
|
+
const env = collectEnv(flags);
|
|
4538
|
+
const collectOpts = {
|
|
4539
|
+
cwd,
|
|
4540
|
+
event: placeholder,
|
|
4541
|
+
skipNetwork: env.skipNetwork
|
|
4542
|
+
};
|
|
4543
|
+
if (env.packagePath !== void 0) collectOpts.packagePath = env.packagePath;
|
|
4544
|
+
if (env.githubToken !== void 0) collectOpts.githubToken = env.githubToken;
|
|
4545
|
+
if (env.fetchImpl !== void 0) collectOpts.fetchImpl = env.fetchImpl;
|
|
4546
|
+
const collected = await collectFacts(collectOpts);
|
|
4547
|
+
const selected = selectMilestone(collected, config, parseMetric(flags.metric), parseThreshold(flags.threshold));
|
|
4548
|
+
if (selected === void 0) return {
|
|
4549
|
+
dryRun: flags.dryRun === true,
|
|
4550
|
+
skipped: true,
|
|
4551
|
+
message: "No milestone threshold crossed. Nothing to generate.",
|
|
4552
|
+
facts: collected,
|
|
4553
|
+
warnings: [],
|
|
4554
|
+
exitCode: 0
|
|
4555
|
+
};
|
|
4556
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4557
|
+
const metricKey = selected.metric === "downloads" ? "downloads" : selected.metric;
|
|
4558
|
+
const facts = {
|
|
4559
|
+
...collected,
|
|
4560
|
+
milestone: {
|
|
4561
|
+
metric: fact(selected.metric, {
|
|
4562
|
+
source: "user-config",
|
|
4563
|
+
ref: "shipseal milestone --metric",
|
|
4564
|
+
fetchedAt
|
|
4565
|
+
}),
|
|
4566
|
+
threshold: fact(selected.threshold, {
|
|
4567
|
+
source: "user-config",
|
|
4568
|
+
ref: `config.json milestones.${metricKey}`,
|
|
4569
|
+
fetchedAt
|
|
4570
|
+
})
|
|
4571
|
+
}
|
|
4572
|
+
};
|
|
4573
|
+
const event = {
|
|
4574
|
+
kind: "milestone",
|
|
4575
|
+
metric: selected.metric,
|
|
4576
|
+
threshold: selected.threshold
|
|
4577
|
+
};
|
|
4578
|
+
const fallback = milestoneCopy(facts, selected.metric, selected.threshold);
|
|
4579
|
+
const resolved = await resolveCopy(facts, config, flags.copy === false, fallback);
|
|
4580
|
+
if (flags.dryRun === true) {
|
|
4581
|
+
const dry = {
|
|
4582
|
+
dryRun: true,
|
|
4583
|
+
skipped: false,
|
|
4584
|
+
facts,
|
|
4585
|
+
copy: resolved.copy,
|
|
4586
|
+
copyMode: resolved.copyMode,
|
|
4587
|
+
warnings: [],
|
|
4588
|
+
exitCode: 0
|
|
4589
|
+
};
|
|
4590
|
+
if (resolved.warning !== void 0) dry.copyWarning = resolved.warning;
|
|
4591
|
+
return dry;
|
|
4592
|
+
}
|
|
4593
|
+
const renderer = await createTakumiRenderer();
|
|
4594
|
+
const generateInput = {
|
|
4595
|
+
event,
|
|
4596
|
+
facts,
|
|
4597
|
+
brand,
|
|
4598
|
+
config,
|
|
4599
|
+
copy: resolved.copy,
|
|
4600
|
+
copyMode: resolved.copyMode,
|
|
4601
|
+
renderer,
|
|
4602
|
+
themes: resolveThemes(flags.themes, brand.theme),
|
|
4603
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4604
|
+
};
|
|
4605
|
+
const logos = await loadLogos(cwd, brand);
|
|
4606
|
+
if (logos !== void 0) generateInput.logos = logos;
|
|
4607
|
+
const packInput = {
|
|
4608
|
+
flags,
|
|
4609
|
+
config,
|
|
4610
|
+
event,
|
|
4611
|
+
result: await generate(generateInput),
|
|
4612
|
+
brand,
|
|
4613
|
+
copy: resolved.copy,
|
|
4614
|
+
copyMode: resolved.copyMode,
|
|
4615
|
+
facts
|
|
4616
|
+
};
|
|
4617
|
+
if (resolved.warning !== void 0) packInput.copyWarning = resolved.warning;
|
|
4618
|
+
return {
|
|
4619
|
+
...await finishPack(packInput),
|
|
4620
|
+
skipped: false
|
|
4621
|
+
};
|
|
4622
|
+
}
|
|
4623
|
+
function highestCrossed(thresholds, current) {
|
|
4624
|
+
return thresholds.filter((item) => current >= item).toSorted((a, b) => b - a)[0];
|
|
4625
|
+
}
|
|
4626
|
+
function selectMilestone(facts, config, metric, threshold) {
|
|
4627
|
+
const metrics = metric === void 0 ? [
|
|
4628
|
+
"stars",
|
|
4629
|
+
"downloads",
|
|
4630
|
+
"contributors"
|
|
4631
|
+
] : [metric];
|
|
4632
|
+
const candidates = [];
|
|
4633
|
+
for (const item of metrics) {
|
|
4634
|
+
const current = currentValue(facts, item);
|
|
4635
|
+
if (current === void 0) {
|
|
4636
|
+
if (metric !== void 0) throw missingMetric(item);
|
|
4637
|
+
continue;
|
|
4638
|
+
}
|
|
4639
|
+
const list = thresholdsFor(config, item);
|
|
4640
|
+
if (threshold !== void 0) {
|
|
4641
|
+
if (metric !== void 0 || list.includes(threshold)) return {
|
|
4642
|
+
metric: item,
|
|
4643
|
+
threshold
|
|
4644
|
+
};
|
|
4645
|
+
continue;
|
|
4646
|
+
}
|
|
4647
|
+
const crossed = highestCrossed(list, current);
|
|
4648
|
+
if (crossed !== void 0) candidates.push({
|
|
4649
|
+
metric: item,
|
|
4650
|
+
threshold: crossed
|
|
4651
|
+
});
|
|
4652
|
+
}
|
|
4653
|
+
return candidates.toSorted((a, b) => b.threshold - a.threshold || metrics.indexOf(a.metric) - metrics.indexOf(b.metric))[0];
|
|
4654
|
+
}
|
|
4655
|
+
function currentValue(facts, metric) {
|
|
4656
|
+
if (metric === "stars") return facts.metrics?.stars?.value;
|
|
4657
|
+
if (metric === "downloads") return facts.metrics?.weeklyDownloads?.value;
|
|
4658
|
+
return facts.metrics?.contributorCount?.value;
|
|
4659
|
+
}
|
|
4660
|
+
function thresholdsFor(config, metric) {
|
|
4661
|
+
if (metric === "stars") return config.milestones?.stars ?? [];
|
|
4662
|
+
if (metric === "downloads") return config.milestones?.downloads ?? [];
|
|
4663
|
+
return config.milestones?.contributors ?? [];
|
|
4664
|
+
}
|
|
4665
|
+
function parseMetric(value) {
|
|
4666
|
+
if (value === void 0) return;
|
|
4667
|
+
if (value === "stars" || value === "downloads" || value === "contributors") return value;
|
|
4668
|
+
throw new ShipsealError("milestone.bad-metric", `Unknown metric "${value}".`, "Use --metric stars, --metric downloads, or --metric contributors.");
|
|
4669
|
+
}
|
|
4670
|
+
function parseThreshold(value) {
|
|
4671
|
+
if (value === void 0) return;
|
|
4672
|
+
const parsed = Number.parseInt(value, 10);
|
|
4673
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new ShipsealError("milestone.bad-threshold", `Invalid threshold "${value}".`, "Pass a positive integer, for example --threshold 1000.");
|
|
4674
|
+
return parsed;
|
|
4675
|
+
}
|
|
4676
|
+
function missingMetric(metric) {
|
|
4677
|
+
if (metric === "stars") return new ShipsealError("milestone.missing-stars", "Could not read GitHub stars for this repo.", "Set GITHUB_TOKEN, or confirm package.json repository points at a public GitHub repo.");
|
|
4678
|
+
if (metric === "downloads") return new ShipsealError("milestone.missing-downloads", "Could not read npm weekly downloads for this package.", "Confirm package.json name is published on npm, then retry without --dry-run.");
|
|
4679
|
+
return new ShipsealError("milestone.missing-contributors", "Could not read the GitHub contributor count for this repo.", "Set GITHUB_TOKEN, or confirm package.json repository points at a public GitHub repo.");
|
|
4680
|
+
}
|
|
4681
|
+
|
|
4682
|
+
//#endregion
|
|
4683
|
+
//#region src/commands/release.ts
|
|
4684
|
+
async function runRelease(flags) {
|
|
4685
|
+
const cwd = flags.cwd;
|
|
4686
|
+
const config = overlayReleaseConfig(await loadConfig(cwd), flags);
|
|
4687
|
+
const brand = await loadBrand(cwd);
|
|
4688
|
+
const tag = flags.tag ?? await gitCurrentTag(cwd);
|
|
4689
|
+
if (tag === void 0) throw new ShipsealError("release.no-tag", "No git tag found for this release.", "Create a git tag, or pass --tag vX.Y.Z.");
|
|
4690
|
+
const event = flags.from === void 0 ? {
|
|
4691
|
+
kind: "release",
|
|
4692
|
+
tag
|
|
4693
|
+
} : {
|
|
4694
|
+
kind: "release",
|
|
4695
|
+
tag,
|
|
4696
|
+
previousTag: flags.from
|
|
4697
|
+
};
|
|
4698
|
+
const env = collectEnv(flags);
|
|
4699
|
+
const collectOpts = {
|
|
4700
|
+
cwd,
|
|
4701
|
+
event,
|
|
4702
|
+
skipNetwork: env.skipNetwork
|
|
4703
|
+
};
|
|
4704
|
+
if (env.packagePath !== void 0) collectOpts.packagePath = env.packagePath;
|
|
4705
|
+
if (config.release?.changelogPath !== void 0) collectOpts.changelogPath = config.release.changelogPath;
|
|
4706
|
+
if (config.release?.snippet !== void 0) collectOpts.snippet = config.release.snippet;
|
|
4707
|
+
if (env.githubToken !== void 0) collectOpts.githubToken = env.githubToken;
|
|
4708
|
+
if (env.fetchImpl !== void 0) collectOpts.fetchImpl = env.fetchImpl;
|
|
4709
|
+
const facts = await collectFacts(collectOpts);
|
|
4710
|
+
const noCopy = flags.copy === false;
|
|
4711
|
+
const resolved = await resolveCopy(facts, config, noCopy, deterministicCopy(facts, config.release?.maxHighlights));
|
|
4712
|
+
if (flags.dryRun === true) {
|
|
4713
|
+
const dry = {
|
|
4714
|
+
dryRun: true,
|
|
4715
|
+
facts,
|
|
4716
|
+
copy: resolved.copy,
|
|
4717
|
+
copyMode: resolved.copyMode,
|
|
4718
|
+
warnings: [],
|
|
4719
|
+
exitCode: 0
|
|
4720
|
+
};
|
|
4721
|
+
if (resolved.warning !== void 0) dry.copyWarning = resolved.warning;
|
|
4722
|
+
return dry;
|
|
4723
|
+
}
|
|
4724
|
+
const renderer = await createTakumiRenderer();
|
|
4725
|
+
const generateInput = {
|
|
4726
|
+
event,
|
|
4727
|
+
facts,
|
|
4728
|
+
brand,
|
|
4729
|
+
config,
|
|
4730
|
+
copy: resolved.copy,
|
|
4731
|
+
copyMode: resolved.copyMode,
|
|
4732
|
+
renderer,
|
|
4733
|
+
themes: resolveThemes(flags.themes, brand.theme),
|
|
4734
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4735
|
+
};
|
|
4736
|
+
const logos = await loadLogos(cwd, brand);
|
|
4737
|
+
if (logos !== void 0) generateInput.logos = logos;
|
|
4738
|
+
const packInput = {
|
|
4739
|
+
flags,
|
|
4740
|
+
config,
|
|
4741
|
+
event,
|
|
4742
|
+
result: await generate(generateInput),
|
|
4743
|
+
brand,
|
|
4744
|
+
copy: resolved.copy,
|
|
4745
|
+
copyMode: resolved.copyMode,
|
|
4746
|
+
facts
|
|
4747
|
+
};
|
|
4748
|
+
if (resolved.warning !== void 0) packInput.copyWarning = resolved.warning;
|
|
4749
|
+
return finishPack(packInput);
|
|
4750
|
+
}
|
|
4751
|
+
function overlayReleaseConfig(config, flags) {
|
|
4752
|
+
const overlay = overlayFormats(config, flags);
|
|
4753
|
+
if (flags.templates === void 0) return overlay;
|
|
4754
|
+
return mergeConfig(DEFAULT_CONFIG, {
|
|
4755
|
+
...overlay,
|
|
4756
|
+
release: {
|
|
4757
|
+
...overlay.release,
|
|
4758
|
+
templates: flags.templates.split(",").map((item) => item.trim()).filter((item) => item.length > 0)
|
|
4759
|
+
}
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
//#endregion
|
|
4764
|
+
//#region src/cli.ts
|
|
4765
|
+
async function runCli(argv = process.argv) {
|
|
4766
|
+
process.exitCode = 0;
|
|
4767
|
+
const cli = cac("shipseal");
|
|
4768
|
+
cli.option("--cwd <path>", "Working directory", { default: process.cwd() });
|
|
4769
|
+
cli.option("--json", "Machine-readable JSON output");
|
|
4770
|
+
cli.option("--quiet", "Suppress non-error output");
|
|
4771
|
+
cli.option("--verbose", "Verbose output");
|
|
4772
|
+
cli.command("init", "Detect brand and write .shipseal/brand.json + config.json").option("--yes", "Accept detections without prompting").option("--force", "Overwrite existing brand.json and config.json").action(async (flags) => {
|
|
4773
|
+
const result = await runInit({
|
|
4774
|
+
cwd: stringFlag(flags.cwd, process.cwd()),
|
|
4775
|
+
yes: flags.yes === true,
|
|
4776
|
+
force: flags.force === true
|
|
4777
|
+
});
|
|
4778
|
+
if (flags.json === true) {
|
|
4779
|
+
process.stdout.write(`${JSON.stringify(result.detection.brand, null, 2)}\n`);
|
|
4780
|
+
return;
|
|
4781
|
+
}
|
|
4782
|
+
if (flags.quiet !== true) {
|
|
4783
|
+
process.stdout.write(`Wrote ${result.brandPath}\n`);
|
|
4784
|
+
process.stdout.write(`Wrote ${result.configPath}\n`);
|
|
4785
|
+
process.stdout.write(`Sample card: ${result.samplePath}\n`);
|
|
4786
|
+
for (const field of result.detection.sources) process.stdout.write(` ${field.field}: ${field.source}\n`);
|
|
4787
|
+
for (const note of result.detection.notes) process.stdout.write(` note: ${note}\n`);
|
|
4788
|
+
}
|
|
4789
|
+
});
|
|
4790
|
+
cli.command("doctor", "Check Node, git, brand, fonts, and Takumi").action(async (flags) => {
|
|
4791
|
+
const result = await runDoctor(stringFlag(flags.cwd, process.cwd()));
|
|
4792
|
+
if (flags.json === true) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
4793
|
+
else if (flags.quiet !== true) for (const check of result.checks) {
|
|
4794
|
+
const mark = check.status === "pass" ? "ok" : check.status === "fail" ? "FAIL" : "info";
|
|
4795
|
+
process.stdout.write(`[${mark}] ${check.name}: ${check.message}\n`);
|
|
4796
|
+
if (check.fix !== void 0 && check.status === "fail") process.stdout.write(` Fix: ${check.fix}\n`);
|
|
4797
|
+
}
|
|
4798
|
+
if (!result.ok) process.exitCode = 1;
|
|
4799
|
+
});
|
|
4800
|
+
cli.command("release", "Generate the release pack").option("--tag <tag>", "Release tag").option("--from <tag>", "Previous tag").option("--formats <list>", "Comma-separated formats").option("--templates <list>", "Comma-separated templates").option("--themes <dark|light|both>", "Themes to render").option("--copy", "Allow optional LLM copy (default: on when config.copy.llm is true)").option("--out <dir>", "Output directory").option("--strict", "Fit warnings exit with code 2").option("--dry-run", "Collect facts and print them; render nothing").option("--package <path>", "package.json path for monorepos").option("--upload", "Upload PNG files to the GitHub release").action(async (flags) => {
|
|
4801
|
+
const releaseFlags = {
|
|
4802
|
+
cwd: stringFlag(flags.cwd, process.cwd()),
|
|
4803
|
+
strict: flags.strict === true,
|
|
4804
|
+
dryRun: flags.dryRun === true
|
|
4805
|
+
};
|
|
4806
|
+
applySharedFlags(releaseFlags, flags);
|
|
4807
|
+
const tag = optionalString(flags.tag);
|
|
4808
|
+
if (tag !== void 0) releaseFlags.tag = tag;
|
|
4809
|
+
const from = optionalString(flags.from);
|
|
4810
|
+
if (from !== void 0) releaseFlags.from = from;
|
|
4811
|
+
const templates = optionalString(flags.templates);
|
|
4812
|
+
if (templates !== void 0) releaseFlags.templates = templates;
|
|
4813
|
+
const result = await runRelease(releaseFlags);
|
|
4814
|
+
process.exitCode = result.exitCode;
|
|
4815
|
+
writeCommandOutput(flags, result);
|
|
4816
|
+
});
|
|
4817
|
+
cli.command("milestone", "Generate a milestone card for the highest crossed threshold").option("--metric <stars|downloads|contributors>", "Metric to check").option("--threshold <n>", "Force a specific threshold").option("--formats <list>", "Comma-separated formats").option("--themes <dark|light|both>", "Themes to render").option("--copy", "Allow optional LLM copy (default: on when config.copy.llm is true)").option("--out <dir>", "Output directory").option("--strict", "Fit warnings exit with code 2").option("--dry-run", "Collect facts and print them; render nothing").option("--package <path>", "package.json path for monorepos").action(async (flags) => {
|
|
4818
|
+
const milestoneFlags = {
|
|
4819
|
+
cwd: stringFlag(flags.cwd, process.cwd()),
|
|
4820
|
+
strict: flags.strict === true,
|
|
4821
|
+
dryRun: flags.dryRun === true
|
|
4822
|
+
};
|
|
4823
|
+
applySharedFlags(milestoneFlags, flags);
|
|
4824
|
+
const metric = optionalString(flags.metric);
|
|
4825
|
+
if (metric !== void 0) milestoneFlags.metric = metric;
|
|
4826
|
+
const threshold = optionalString(flags.threshold);
|
|
4827
|
+
if (threshold !== void 0) milestoneFlags.threshold = threshold;
|
|
4828
|
+
const result = await runMilestone(milestoneFlags);
|
|
4829
|
+
process.exitCode = result.exitCode;
|
|
4830
|
+
if (flags.json === true) {
|
|
4831
|
+
writeJson(resultToJson(result));
|
|
4832
|
+
return;
|
|
4833
|
+
}
|
|
4834
|
+
if (flags.quiet === true) return;
|
|
4835
|
+
if (result.skipped) {
|
|
4836
|
+
process.stdout.write(`${result.message ?? "No milestone threshold crossed."}\n`);
|
|
4837
|
+
return;
|
|
4838
|
+
}
|
|
4839
|
+
writeHumanResult(result);
|
|
4840
|
+
});
|
|
4841
|
+
cli.command("bench", "Generate benchmark cards from bench JSON").option("--file <path>", "Path to bench JSON").option("--formats <list>", "Comma-separated formats").option("--themes <dark|light|both>", "Themes to render").option("--copy", "Allow optional LLM copy (default: on when config.copy.llm is true)").option("--out <dir>", "Output directory").option("--strict", "Fit warnings exit with code 2").option("--dry-run", "Collect facts and print them; render nothing").option("--package <path>", "package.json path for monorepos").action(async (flags) => {
|
|
4842
|
+
const benchFlags = {
|
|
4843
|
+
cwd: stringFlag(flags.cwd, process.cwd()),
|
|
4844
|
+
strict: flags.strict === true,
|
|
4845
|
+
dryRun: flags.dryRun === true
|
|
4846
|
+
};
|
|
4847
|
+
applySharedFlags(benchFlags, flags);
|
|
4848
|
+
const file = optionalString(flags.file);
|
|
4849
|
+
if (file !== void 0) benchFlags.file = file;
|
|
4850
|
+
const result = await runBench(benchFlags);
|
|
4851
|
+
process.exitCode = result.exitCode;
|
|
4852
|
+
writeCommandOutput(flags, result);
|
|
4853
|
+
});
|
|
4854
|
+
cli.help();
|
|
4855
|
+
cli.version(readVersion());
|
|
4856
|
+
try {
|
|
4857
|
+
cli.parse(argv, { run: false });
|
|
4858
|
+
await cli.runMatchedCommand();
|
|
4859
|
+
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
|
4860
|
+
} catch (error) {
|
|
4861
|
+
if (error instanceof ShipsealError) {
|
|
4862
|
+
process.stderr.write(`${formatError(error)}\n`);
|
|
4863
|
+
return 1;
|
|
4864
|
+
}
|
|
4865
|
+
if (error instanceof Error) {
|
|
4866
|
+
process.stderr.write(`${error.message}\n`);
|
|
4867
|
+
return 1;
|
|
4868
|
+
}
|
|
4869
|
+
throw error;
|
|
4870
|
+
}
|
|
4871
|
+
}
|
|
4872
|
+
function stringFlag(value, fallback) {
|
|
4873
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
4874
|
+
}
|
|
4875
|
+
function optionalString(value) {
|
|
4876
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
4877
|
+
}
|
|
4878
|
+
function applySharedFlags(target, flags) {
|
|
4879
|
+
const formats = optionalString(flags.formats);
|
|
4880
|
+
if (formats !== void 0) target.formats = formats;
|
|
4881
|
+
const themes = optionalString(flags.themes);
|
|
4882
|
+
if (themes !== void 0) target.themes = themes;
|
|
4883
|
+
if (flags.copy === false) target.copy = false;
|
|
4884
|
+
const out = optionalString(flags.out);
|
|
4885
|
+
if (out !== void 0) target.out = out;
|
|
4886
|
+
const pkg = optionalString(flags.package);
|
|
4887
|
+
if (pkg !== void 0) target.package = pkg;
|
|
4888
|
+
if (flags.upload === true) target.upload = true;
|
|
4889
|
+
}
|
|
4890
|
+
function writeCommandOutput(flags, result) {
|
|
4891
|
+
if (flags.json === true) {
|
|
4892
|
+
writeJson(resultToJson(result));
|
|
4893
|
+
return;
|
|
4894
|
+
}
|
|
4895
|
+
if (flags.quiet === true) return;
|
|
4896
|
+
writeHumanResult(result);
|
|
4897
|
+
}
|
|
4898
|
+
function resultToJson(result) {
|
|
4899
|
+
const payload = {};
|
|
4900
|
+
if (result.skipped === true) payload.skipped = true;
|
|
4901
|
+
if (result.message !== void 0) payload.message = result.message;
|
|
4902
|
+
if (result.dir !== void 0) payload.dir = result.dir;
|
|
4903
|
+
if (result.manifest !== void 0) payload.manifest = result.manifest;
|
|
4904
|
+
else payload.facts = result.facts;
|
|
4905
|
+
return payload;
|
|
4906
|
+
}
|
|
4907
|
+
function writeHumanResult(result) {
|
|
4908
|
+
if (result.skipped === true) {
|
|
4909
|
+
process.stdout.write(`${result.message ?? "Nothing to generate."}\n`);
|
|
4910
|
+
return;
|
|
4911
|
+
}
|
|
4912
|
+
if (result.dryRun) {
|
|
4913
|
+
process.stdout.write(`${JSON.stringify(result.facts, null, 2)}\n`);
|
|
4914
|
+
return;
|
|
4915
|
+
}
|
|
4916
|
+
if (result.dir !== void 0) process.stdout.write(`Wrote ${result.dir}\n`);
|
|
4917
|
+
if (result.copyWarning !== void 0) process.stdout.write(`Copy: ${result.copyWarning}\n`);
|
|
4918
|
+
for (const warning of result.warnings) process.stdout.write(`Warning: ${warning.template} ${warning.format} ${warning.slot} ${warning.action}\n`);
|
|
4919
|
+
}
|
|
4920
|
+
function writeJson(value) {
|
|
4921
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
4922
|
+
}
|
|
4923
|
+
function readVersion() {
|
|
4924
|
+
const raw = JSON.parse(readFileSync(join(resolvePackageRoot(), "package.json"), "utf8"));
|
|
4925
|
+
if (typeof raw === "object" && raw !== null && "version" in raw && typeof raw.version === "string") return raw.version;
|
|
4926
|
+
return "0.0.0";
|
|
4927
|
+
}
|
|
4928
|
+
if (shouldRun(process.argv[1])) runCli().then((code) => {
|
|
4929
|
+
process.exit(code);
|
|
4930
|
+
});
|
|
4931
|
+
function shouldRun(invoked) {
|
|
4932
|
+
if (invoked === void 0) return false;
|
|
4933
|
+
const normalized = invoked.replaceAll("\\", "/");
|
|
4934
|
+
return normalized.endsWith("/cli.js") || normalized.endsWith("/cli.ts") || normalized.endsWith("/shipseal");
|
|
4935
|
+
}
|
|
10
4936
|
|
|
11
4937
|
//#endregion
|
|
12
|
-
export {
|
|
4938
|
+
export { runCli };
|
|
13
4939
|
//# sourceMappingURL=cli.js.map
|