shipseal 0.0.8 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +1472 -105
- package/dist/cli.js.map +1 -1
- package/dist/studio-client.js +242 -0
- package/dist/studio-client.js.map +1 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, extname, isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { cac } from "cac";
|
|
5
|
-
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { appendFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
7
|
import { execFile } from "node:child_process";
|
|
9
8
|
import { promisify } from "node:util";
|
|
10
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -14,6 +13,8 @@ import { fromJsx } from "takumi-js/helpers/jsx";
|
|
|
14
13
|
import { createInterface } from "node:readline/promises";
|
|
15
14
|
import { stdin, stdout } from "node:process";
|
|
16
15
|
import { inflateSync } from "node:zlib";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { createServer } from "node:http";
|
|
17
18
|
|
|
18
19
|
//#region src/core/errors.ts
|
|
19
20
|
var ShipsealError = class extends Error {
|
|
@@ -62,7 +63,11 @@ const brandSchema = z.object({
|
|
|
62
63
|
}),
|
|
63
64
|
radius: z.number().nonnegative(),
|
|
64
65
|
theme: z.enum(["dark", "light"]),
|
|
65
|
-
style: z.
|
|
66
|
+
style: z.enum([
|
|
67
|
+
"minimal",
|
|
68
|
+
"editorial",
|
|
69
|
+
"terminal"
|
|
70
|
+
]),
|
|
66
71
|
tokens: z.string().nullable()
|
|
67
72
|
});
|
|
68
73
|
const DEFAULT_BRAND_COLORS = {
|
|
@@ -163,6 +168,12 @@ const FORMATS = {
|
|
|
163
168
|
safeZone: LANDSCAPE_SAFE_ZONE
|
|
164
169
|
}
|
|
165
170
|
};
|
|
171
|
+
const V1_FORMAT_IDS = [
|
|
172
|
+
"og",
|
|
173
|
+
"github-social",
|
|
174
|
+
"x",
|
|
175
|
+
"linkedin"
|
|
176
|
+
];
|
|
166
177
|
|
|
167
178
|
//#endregion
|
|
168
179
|
//#region src/config/schema.ts
|
|
@@ -175,7 +186,19 @@ const configSchema = z.object({
|
|
|
175
186
|
templates: z.array(z.string()).optional(),
|
|
176
187
|
maxHighlights: z.number().int().positive().optional(),
|
|
177
188
|
changelogPath: z.string().optional(),
|
|
178
|
-
snippet: z.string().nullable().optional()
|
|
189
|
+
snippet: z.string().nullable().optional(),
|
|
190
|
+
announce: z.enum([
|
|
191
|
+
"major",
|
|
192
|
+
"minor",
|
|
193
|
+
"patch"
|
|
194
|
+
]).optional(),
|
|
195
|
+
headline: z.string().nullable().optional(),
|
|
196
|
+
subheadline: z.string().nullable().optional(),
|
|
197
|
+
story: z.object({
|
|
198
|
+
upgrade: z.string().max(4e3).optional(),
|
|
199
|
+
before: z.string().optional(),
|
|
200
|
+
after: z.string().optional()
|
|
201
|
+
}).refine((value) => Boolean(value.before) === Boolean(value.after), { message: "Supply both story.before and story.after screenshot paths, or neither." }).optional()
|
|
179
202
|
}).optional(),
|
|
180
203
|
milestones: z.object({
|
|
181
204
|
stars: z.array(z.number().int().positive()).optional(),
|
|
@@ -213,6 +236,7 @@ const DEFAULT_CONFIG = {
|
|
|
213
236
|
],
|
|
214
237
|
maxHighlights: 4,
|
|
215
238
|
changelogPath: "CHANGELOG.md",
|
|
239
|
+
announce: "patch",
|
|
216
240
|
snippet: null
|
|
217
241
|
},
|
|
218
242
|
milestones: {
|
|
@@ -301,18 +325,16 @@ const COPY_LIMITS = {
|
|
|
301
325
|
|
|
302
326
|
//#endregion
|
|
303
327
|
//#region src/copy/deterministic.ts
|
|
304
|
-
function deterministicCopy(facts, maxHighlights = 4) {
|
|
328
|
+
function deterministicCopy(facts, maxHighlights = 4, displayName) {
|
|
305
329
|
const name = facts.project.name.value;
|
|
306
330
|
const version = facts.release?.version.value;
|
|
307
331
|
const features = (facts.release?.features ?? []).map((item) => item.value);
|
|
308
332
|
const fixes = (facts.release?.fixes ?? []).map((item) => item.value);
|
|
309
333
|
const breaking = (facts.release?.breaking ?? []).map((item) => item.value);
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
].map((line) => cleanLine(firstSentence(line))).find((line) => line.length <= HEADLINE_MAX_CHARS) ?? (version === void 0 ? name : `${name} ${version}`);
|
|
315
|
-
const tagline = facts.project.tagline?.value;
|
|
334
|
+
const headlineSource = [...breaking, ...features].map((line) => cleanLine(firstSentence(line))).find((line) => line.length <= HEADLINE_MAX_CHARS);
|
|
335
|
+
const title = displayName ?? name;
|
|
336
|
+
const headline = facts.release?.headline?.value ?? headlineSource ?? (version === void 0 ? title : `${title} ${version}`);
|
|
337
|
+
const tagline = facts.release?.subheadline?.value ?? facts.project.tagline?.value;
|
|
316
338
|
const subheadline = tagline !== void 0 && tagline.length > 0 ? cleanLine(tagline) : cleanLine(features[1] ?? fixes[1] ?? fixes[0] ?? `What's new in ${version ?? name}`);
|
|
317
339
|
const highlights = [];
|
|
318
340
|
const addHighlight = (line, prefix = "") => {
|
|
@@ -387,8 +409,22 @@ function firstSentence(text) {
|
|
|
387
409
|
* sentences, and the fitting pass comfortably handles more.
|
|
388
410
|
*/
|
|
389
411
|
const HEADLINE_MAX_CHARS = 72;
|
|
412
|
+
/**
|
|
413
|
+
* Emoji ranges, removed from anything that reaches a card.
|
|
414
|
+
*
|
|
415
|
+
* No font we register carries these glyphs, so `\u{1F310} Human-friendly ...` (got's real
|
|
416
|
+
* GitHub description) rendered a tofu box on shipseal.dev. Takumi ships an emoji helper, but it
|
|
417
|
+
* rewrites each emoji into an <img> pointing at jsDelivr, and `generate()` does no network.
|
|
418
|
+
*
|
|
419
|
+
* The ranges deliberately start at U+2600, which keeps the textual symbols people put in real
|
|
420
|
+
* product names: (c) U+00A9, (r) U+00AE and (tm) U+2122 all sit below it.
|
|
421
|
+
*/
|
|
422
|
+
const EMOJI = /[\u{1F000}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|\u200D/gu;
|
|
423
|
+
function stripEmoji(text) {
|
|
424
|
+
return text.replaceAll(EMOJI, "").replace(/\s+/g, " ").trim();
|
|
425
|
+
}
|
|
390
426
|
function cleanLine(text) {
|
|
391
|
-
const stripped = text.replace(/\s*\u2014\s*/g, ": ").replace(/\s+\u2013\s+/g, ", ").replaceAll("–", "-").replaceAll("!", ".").replace(/\s+/g, " ").trim();
|
|
427
|
+
const stripped = stripEmoji(text).replace(/\s*\u2014\s*/g, ": ").replace(/\s+\u2013\s+/g, ", ").replaceAll("–", "-").replaceAll("!", ".").replace(/\s+/g, " ").trim();
|
|
392
428
|
const noTrail = stripped.endsWith(".") ? stripped.slice(0, -1) : stripped;
|
|
393
429
|
const first = noTrail.at(0);
|
|
394
430
|
if (first === void 0) return noTrail;
|
|
@@ -396,7 +432,7 @@ function cleanLine(text) {
|
|
|
396
432
|
}
|
|
397
433
|
function ctaFor(facts) {
|
|
398
434
|
const npm = facts.project.npmPackage?.value;
|
|
399
|
-
if (npm !== void 0 && npm.length > 0) return `npm i ${npm}`;
|
|
435
|
+
if (npm !== void 0 && npm.length > 0) return facts.project.cli?.value === true ? `npx ${npm}@latest` : `npm i ${npm}`;
|
|
400
436
|
const url = facts.project.url?.value ?? facts.project.repo?.value;
|
|
401
437
|
if (url === void 0) return facts.project.name.value;
|
|
402
438
|
return url.replace(/^https?:\/\//, "");
|
|
@@ -551,7 +587,9 @@ const jsxs = jsx;
|
|
|
551
587
|
|
|
552
588
|
//#endregion
|
|
553
589
|
//#region src/templates/code-card.tsx
|
|
554
|
-
|
|
590
|
+
/** Advance width of a monospace glyph as a fraction of the font size. */
|
|
591
|
+
const MONO_ADVANCE = .6;
|
|
592
|
+
const propsSchema$5 = z.object({
|
|
555
593
|
headline: z.string(),
|
|
556
594
|
headingFamily: z.string(),
|
|
557
595
|
monoFamily: z.string(),
|
|
@@ -567,7 +605,7 @@ const codeCard = {
|
|
|
567
605
|
id: "code-card",
|
|
568
606
|
events: ["release"],
|
|
569
607
|
formats: ["x", "linkedin"],
|
|
570
|
-
propsSchema: propsSchema$
|
|
608
|
+
propsSchema: propsSchema$5,
|
|
571
609
|
slots(format) {
|
|
572
610
|
return { headline: {
|
|
573
611
|
maxLines: 1,
|
|
@@ -578,10 +616,10 @@ const codeCard = {
|
|
|
578
616
|
} };
|
|
579
617
|
},
|
|
580
618
|
slotText(raw) {
|
|
581
|
-
return { headline: propsSchema$
|
|
619
|
+
return { headline: propsSchema$5.parse(raw).headline };
|
|
582
620
|
},
|
|
583
621
|
slotFont(raw, slot) {
|
|
584
|
-
const props = propsSchema$
|
|
622
|
+
const props = propsSchema$5.parse(raw);
|
|
585
623
|
if (slot === "headline") return {
|
|
586
624
|
family: props.headingFamily,
|
|
587
625
|
weight: props.headingWeight,
|
|
@@ -615,10 +653,19 @@ const codeCard = {
|
|
|
615
653
|
};
|
|
616
654
|
},
|
|
617
655
|
render(raw, ctx) {
|
|
618
|
-
const props = propsSchema$
|
|
656
|
+
const props = propsSchema$5.parse(raw);
|
|
619
657
|
const colors = themeColors(ctx.brand, ctx.theme);
|
|
620
658
|
const pad = ctx.format.safeZone;
|
|
621
659
|
const headline = ctx.fitted.headline;
|
|
660
|
+
const boxPad = 28;
|
|
661
|
+
const lineHeight = 1.35;
|
|
662
|
+
const lines = props.lines.length === 0 ? 1 : props.lines.length;
|
|
663
|
+
const longest = Math.max(1, ...props.lines.map((line) => line.reduce((total, token) => total + token.text.length, 0)));
|
|
664
|
+
const headlineHeight = (headline?.fontSize ?? 36) * 1.25;
|
|
665
|
+
const attributionHeight = ctx.attribution ? 49.2 : 0;
|
|
666
|
+
const innerWidth = ctx.format.width - 2 * pad - 56;
|
|
667
|
+
const innerHeight = ctx.format.height - 2 * pad - headlineHeight - 24 - attributionHeight - 56;
|
|
668
|
+
const codeSize = Math.max(18, Math.min(44, Math.floor(Math.min(innerWidth / (longest * MONO_ADVANCE), innerHeight / (lines * lineHeight)))));
|
|
622
669
|
return /* @__PURE__ */ jsxs("div", {
|
|
623
670
|
style: {
|
|
624
671
|
display: "flex",
|
|
@@ -644,29 +691,36 @@ const codeCard = {
|
|
|
644
691
|
display: "flex",
|
|
645
692
|
flexDirection: "column",
|
|
646
693
|
flexGrow: 1,
|
|
647
|
-
|
|
648
|
-
borderRadius: props.radius,
|
|
649
|
-
padding: 28,
|
|
650
|
-
gap: 0
|
|
694
|
+
justifyContent: "center"
|
|
651
695
|
},
|
|
652
|
-
children:
|
|
696
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
653
697
|
style: {
|
|
654
698
|
display: "flex",
|
|
655
|
-
flexDirection: "
|
|
656
|
-
|
|
699
|
+
flexDirection: "column",
|
|
700
|
+
backgroundColor: colors.card,
|
|
701
|
+
borderRadius: props.radius,
|
|
702
|
+
padding: boxPad,
|
|
703
|
+
gap: 0
|
|
657
704
|
},
|
|
658
|
-
children:
|
|
705
|
+
children: props.lines.map((line, lineIndex) => /* @__PURE__ */ jsx("div", {
|
|
659
706
|
style: {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
color: token.color,
|
|
664
|
-
whiteSpace: "pre",
|
|
665
|
-
lineHeight: 1.35
|
|
707
|
+
display: "flex",
|
|
708
|
+
flexDirection: "row",
|
|
709
|
+
flexWrap: "nowrap"
|
|
666
710
|
},
|
|
667
|
-
children: token
|
|
668
|
-
|
|
669
|
-
|
|
711
|
+
children: line.map((token, tokenIndex) => /* @__PURE__ */ jsx("div", {
|
|
712
|
+
style: {
|
|
713
|
+
fontFamily: props.monoFamily,
|
|
714
|
+
fontWeight: props.monoWeight,
|
|
715
|
+
fontSize: codeSize,
|
|
716
|
+
color: token.color,
|
|
717
|
+
whiteSpace: "pre",
|
|
718
|
+
lineHeight
|
|
719
|
+
},
|
|
720
|
+
children: token.text
|
|
721
|
+
}, `${String(lineIndex)}-${String(tokenIndex)}`))
|
|
722
|
+
}, String(lineIndex)))
|
|
723
|
+
})
|
|
670
724
|
}),
|
|
671
725
|
ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
672
726
|
style: {
|
|
@@ -735,7 +789,7 @@ const rowSchema = z.object({
|
|
|
735
789
|
changeText: z.string(),
|
|
736
790
|
regression: z.boolean()
|
|
737
791
|
});
|
|
738
|
-
const propsSchema$
|
|
792
|
+
const propsSchema$4 = z.object({
|
|
739
793
|
title: z.string(),
|
|
740
794
|
note: z.string(),
|
|
741
795
|
rows: z.array(rowSchema),
|
|
@@ -749,7 +803,7 @@ const bench = {
|
|
|
749
803
|
id: "bench",
|
|
750
804
|
events: ["bench"],
|
|
751
805
|
formats: ["x", "linkedin"],
|
|
752
|
-
propsSchema: propsSchema$
|
|
806
|
+
propsSchema: propsSchema$4,
|
|
753
807
|
slots(format) {
|
|
754
808
|
const width = format.width - 2 * format.safeZone;
|
|
755
809
|
const slots = { title: {
|
|
@@ -769,7 +823,7 @@ const bench = {
|
|
|
769
823
|
return slots;
|
|
770
824
|
},
|
|
771
825
|
slotText(raw) {
|
|
772
|
-
const props = propsSchema$
|
|
826
|
+
const props = propsSchema$4.parse(raw);
|
|
773
827
|
const text = { title: props.title };
|
|
774
828
|
props.rows.forEach((row, index) => {
|
|
775
829
|
text[`change-${String(index)}`] = row.changeText;
|
|
@@ -777,7 +831,7 @@ const bench = {
|
|
|
777
831
|
return text;
|
|
778
832
|
},
|
|
779
833
|
slotFont(raw, slot) {
|
|
780
|
-
const props = propsSchema$
|
|
834
|
+
const props = propsSchema$4.parse(raw);
|
|
781
835
|
if (slot === "title") return {
|
|
782
836
|
family: props.headingFamily,
|
|
783
837
|
weight: props.headingWeight,
|
|
@@ -820,7 +874,7 @@ const bench = {
|
|
|
820
874
|
};
|
|
821
875
|
},
|
|
822
876
|
render(raw, ctx) {
|
|
823
|
-
const props = propsSchema$
|
|
877
|
+
const props = propsSchema$4.parse(raw);
|
|
824
878
|
const colors = themeColors(ctx.brand, ctx.theme);
|
|
825
879
|
const pad = ctx.format.safeZone;
|
|
826
880
|
const title = ctx.fitted.title;
|
|
@@ -952,7 +1006,7 @@ function formatMetric(value) {
|
|
|
952
1006
|
|
|
953
1007
|
//#endregion
|
|
954
1008
|
//#region src/templates/milestone.tsx
|
|
955
|
-
const propsSchema$
|
|
1009
|
+
const propsSchema$3 = z.object({
|
|
956
1010
|
name: z.string(),
|
|
957
1011
|
numberText: z.string(),
|
|
958
1012
|
metricLabel: z.string(),
|
|
@@ -972,7 +1026,7 @@ const milestone = {
|
|
|
972
1026
|
"x",
|
|
973
1027
|
"linkedin"
|
|
974
1028
|
],
|
|
975
|
-
propsSchema: propsSchema$
|
|
1029
|
+
propsSchema: propsSchema$3,
|
|
976
1030
|
slots(format) {
|
|
977
1031
|
const width = format.width - 2 * format.safeZone;
|
|
978
1032
|
return {
|
|
@@ -993,14 +1047,14 @@ const milestone = {
|
|
|
993
1047
|
};
|
|
994
1048
|
},
|
|
995
1049
|
slotText(raw) {
|
|
996
|
-
const props = propsSchema$
|
|
1050
|
+
const props = propsSchema$3.parse(raw);
|
|
997
1051
|
return {
|
|
998
1052
|
number: props.numberText,
|
|
999
1053
|
thankYou: props.thankYou
|
|
1000
1054
|
};
|
|
1001
1055
|
},
|
|
1002
1056
|
slotFont(raw, slot) {
|
|
1003
|
-
const props = propsSchema$
|
|
1057
|
+
const props = propsSchema$3.parse(raw);
|
|
1004
1058
|
if (slot === "number") return {
|
|
1005
1059
|
family: props.headingFamily,
|
|
1006
1060
|
weight: props.headingWeight,
|
|
@@ -1044,7 +1098,7 @@ const milestone = {
|
|
|
1044
1098
|
};
|
|
1045
1099
|
},
|
|
1046
1100
|
render(raw, ctx) {
|
|
1047
|
-
const props = propsSchema$
|
|
1101
|
+
const props = propsSchema$3.parse(raw);
|
|
1048
1102
|
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1049
1103
|
const pad = ctx.format.safeZone;
|
|
1050
1104
|
const number = ctx.fitted.number;
|
|
@@ -1138,9 +1192,39 @@ const milestone = {
|
|
|
1138
1192
|
}
|
|
1139
1193
|
};
|
|
1140
1194
|
|
|
1195
|
+
//#endregion
|
|
1196
|
+
//#region src/templates/primitives/seal.ts
|
|
1197
|
+
/**
|
|
1198
|
+
* The core promise, made visible on the card.
|
|
1199
|
+
*
|
|
1200
|
+
* "sealed · 7 commits · 2026-09-11" turns "every number has a source" from a claim on the
|
|
1201
|
+
* website into a mark in the feed. Every segment is a fact, so rule 1 holds: a missing fact
|
|
1202
|
+
* drops its segment and is reported, it never becomes a placeholder or an estimate.
|
|
1203
|
+
*/
|
|
1204
|
+
function sealStamp(facts) {
|
|
1205
|
+
const segments = ["sealed"];
|
|
1206
|
+
const missing = [];
|
|
1207
|
+
const commits = facts.release?.commitCount?.value;
|
|
1208
|
+
if (commits === void 0) missing.push({
|
|
1209
|
+
fact: "release.commitCount",
|
|
1210
|
+
effect: "seal stamp omits the commit count"
|
|
1211
|
+
});
|
|
1212
|
+
else segments.push(`${formatCount$1(commits)} ${commits === 1 ? "commit" : "commits"}`);
|
|
1213
|
+
const date = facts.release?.date?.value;
|
|
1214
|
+
if (date === void 0) missing.push({
|
|
1215
|
+
fact: "release.date",
|
|
1216
|
+
effect: "seal stamp omits the date"
|
|
1217
|
+
});
|
|
1218
|
+
else segments.push(date);
|
|
1219
|
+
return {
|
|
1220
|
+
segments,
|
|
1221
|
+
missing
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1141
1225
|
//#endregion
|
|
1142
1226
|
//#region src/templates/release-hero.tsx
|
|
1143
|
-
const propsSchema$
|
|
1227
|
+
const propsSchema$2 = z.object({
|
|
1144
1228
|
name: z.string(),
|
|
1145
1229
|
version: z.string(),
|
|
1146
1230
|
headline: z.string(),
|
|
@@ -1152,7 +1236,8 @@ const propsSchema$1 = z.object({
|
|
|
1152
1236
|
bodyWeight: z.number(),
|
|
1153
1237
|
radius: z.number(),
|
|
1154
1238
|
showCta: z.boolean(),
|
|
1155
|
-
showLogo: z.boolean()
|
|
1239
|
+
showLogo: z.boolean(),
|
|
1240
|
+
seal: z.array(z.string())
|
|
1156
1241
|
});
|
|
1157
1242
|
const releaseHero = {
|
|
1158
1243
|
id: "release-hero",
|
|
@@ -1163,7 +1248,7 @@ const releaseHero = {
|
|
|
1163
1248
|
"x",
|
|
1164
1249
|
"linkedin"
|
|
1165
1250
|
],
|
|
1166
|
-
propsSchema: propsSchema$
|
|
1251
|
+
propsSchema: propsSchema$2,
|
|
1167
1252
|
slots(format) {
|
|
1168
1253
|
const width = format.width - 2 * format.safeZone;
|
|
1169
1254
|
return {
|
|
@@ -1191,7 +1276,7 @@ const releaseHero = {
|
|
|
1191
1276
|
};
|
|
1192
1277
|
},
|
|
1193
1278
|
slotText(raw) {
|
|
1194
|
-
const props = propsSchema$
|
|
1279
|
+
const props = propsSchema$2.parse(raw);
|
|
1195
1280
|
return {
|
|
1196
1281
|
headline: props.headline,
|
|
1197
1282
|
subheadline: props.subheadline,
|
|
@@ -1199,7 +1284,7 @@ const releaseHero = {
|
|
|
1199
1284
|
};
|
|
1200
1285
|
},
|
|
1201
1286
|
slotFont(raw, slot) {
|
|
1202
|
-
const props = propsSchema$
|
|
1287
|
+
const props = propsSchema$2.parse(raw);
|
|
1203
1288
|
if (slot === "headline") return {
|
|
1204
1289
|
family: props.headingFamily,
|
|
1205
1290
|
weight: props.headingWeight,
|
|
@@ -1222,6 +1307,8 @@ const releaseHero = {
|
|
|
1222
1307
|
fact: "brand.logo",
|
|
1223
1308
|
effect: "logo hidden"
|
|
1224
1309
|
});
|
|
1310
|
+
const seal = sealStamp(facts);
|
|
1311
|
+
missing.push(...seal.missing);
|
|
1225
1312
|
const version = facts.release?.version.value ?? "";
|
|
1226
1313
|
return {
|
|
1227
1314
|
props: {
|
|
@@ -1236,13 +1323,14 @@ const releaseHero = {
|
|
|
1236
1323
|
bodyWeight: brand.fonts.body.weight,
|
|
1237
1324
|
radius: brand.radius,
|
|
1238
1325
|
showCta: copy.cta.length > 0,
|
|
1326
|
+
seal: seal.segments,
|
|
1239
1327
|
showLogo
|
|
1240
1328
|
},
|
|
1241
1329
|
missing
|
|
1242
1330
|
};
|
|
1243
1331
|
},
|
|
1244
1332
|
render(raw, ctx) {
|
|
1245
|
-
const props = propsSchema$
|
|
1333
|
+
const props = propsSchema$2.parse(raw);
|
|
1246
1334
|
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1247
1335
|
const pad = ctx.format.safeZone;
|
|
1248
1336
|
const headline = ctx.fitted.headline;
|
|
@@ -1257,9 +1345,24 @@ const releaseHero = {
|
|
|
1257
1345
|
width: "100%",
|
|
1258
1346
|
height: "100%",
|
|
1259
1347
|
backgroundColor: colors.background,
|
|
1260
|
-
padding: pad
|
|
1348
|
+
padding: pad,
|
|
1349
|
+
position: "relative"
|
|
1261
1350
|
},
|
|
1262
1351
|
children: [
|
|
1352
|
+
props.showLogo && ctx.logoSrc !== void 0 ? /* @__PURE__ */ jsx("div", {
|
|
1353
|
+
style: {
|
|
1354
|
+
display: "flex",
|
|
1355
|
+
position: "absolute",
|
|
1356
|
+
right: -Math.round(ctx.format.height * .12),
|
|
1357
|
+
top: Math.round(ctx.format.height * .18),
|
|
1358
|
+
opacity: .06
|
|
1359
|
+
},
|
|
1360
|
+
children: /* @__PURE__ */ jsx("img", {
|
|
1361
|
+
src: ctx.logoSrc,
|
|
1362
|
+
width: Math.round(ctx.format.height * .72),
|
|
1363
|
+
height: Math.round(ctx.format.height * .72)
|
|
1364
|
+
})
|
|
1365
|
+
}) : void 0,
|
|
1263
1366
|
/* @__PURE__ */ jsxs("div", {
|
|
1264
1367
|
style: {
|
|
1265
1368
|
display: "flex",
|
|
@@ -1301,7 +1404,7 @@ const releaseHero = {
|
|
|
1301
1404
|
fontFamily: props.bodyFamily,
|
|
1302
1405
|
fontWeight: props.bodyWeight,
|
|
1303
1406
|
fontSize: 22,
|
|
1304
|
-
color: colors.
|
|
1407
|
+
color: colors.foreground
|
|
1305
1408
|
},
|
|
1306
1409
|
children: `v${props.version}`
|
|
1307
1410
|
})
|
|
@@ -1360,15 +1463,31 @@ const releaseHero = {
|
|
|
1360
1463
|
},
|
|
1361
1464
|
children: cta?.text ?? props.cta
|
|
1362
1465
|
})
|
|
1363
|
-
}) : /* @__PURE__ */ jsx("div", {}),
|
|
1466
|
+
}) : /* @__PURE__ */ jsx("div", {}), /* @__PURE__ */ jsxs("div", {
|
|
1364
1467
|
style: {
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1468
|
+
display: "flex",
|
|
1469
|
+
flexDirection: "column",
|
|
1470
|
+
alignItems: "flex-end",
|
|
1471
|
+
gap: 6
|
|
1369
1472
|
},
|
|
1370
|
-
children:
|
|
1371
|
-
|
|
1473
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1474
|
+
style: {
|
|
1475
|
+
fontFamily: props.bodyFamily,
|
|
1476
|
+
fontWeight: props.headingWeight,
|
|
1477
|
+
fontSize: 20,
|
|
1478
|
+
color: colors.primary
|
|
1479
|
+
},
|
|
1480
|
+
children: props.seal.join(" · ")
|
|
1481
|
+
}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
1482
|
+
style: {
|
|
1483
|
+
fontFamily: props.bodyFamily,
|
|
1484
|
+
fontWeight: props.bodyWeight,
|
|
1485
|
+
fontSize: 18,
|
|
1486
|
+
color: colors.muted
|
|
1487
|
+
},
|
|
1488
|
+
children: "made with shipseal.dev"
|
|
1489
|
+
}) : void 0]
|
|
1490
|
+
})]
|
|
1372
1491
|
})
|
|
1373
1492
|
]
|
|
1374
1493
|
});
|
|
@@ -1377,7 +1496,7 @@ const releaseHero = {
|
|
|
1377
1496
|
|
|
1378
1497
|
//#endregion
|
|
1379
1498
|
//#region src/templates/release-highlights.tsx
|
|
1380
|
-
const propsSchema = z.object({
|
|
1499
|
+
const propsSchema$1 = z.object({
|
|
1381
1500
|
name: z.string(),
|
|
1382
1501
|
version: z.string(),
|
|
1383
1502
|
highlights: z.array(z.string()),
|
|
@@ -1391,7 +1510,7 @@ const releaseHighlights = {
|
|
|
1391
1510
|
id: "release-highlights",
|
|
1392
1511
|
events: ["release"],
|
|
1393
1512
|
formats: ["x", "linkedin"],
|
|
1394
|
-
propsSchema,
|
|
1513
|
+
propsSchema: propsSchema$1,
|
|
1395
1514
|
slots(format) {
|
|
1396
1515
|
const width = format.width - 2 * format.safeZone - 48;
|
|
1397
1516
|
const slots = {};
|
|
@@ -1405,7 +1524,7 @@ const releaseHighlights = {
|
|
|
1405
1524
|
return slots;
|
|
1406
1525
|
},
|
|
1407
1526
|
slotText(raw) {
|
|
1408
|
-
const props = propsSchema.parse(raw);
|
|
1527
|
+
const props = propsSchema$1.parse(raw);
|
|
1409
1528
|
const text = {};
|
|
1410
1529
|
props.highlights.forEach((line, index) => {
|
|
1411
1530
|
text[`highlight-${String(index)}`] = line;
|
|
@@ -1413,7 +1532,7 @@ const releaseHighlights = {
|
|
|
1413
1532
|
return text;
|
|
1414
1533
|
},
|
|
1415
1534
|
slotFont(raw) {
|
|
1416
|
-
const props = propsSchema.parse(raw);
|
|
1535
|
+
const props = propsSchema$1.parse(raw);
|
|
1417
1536
|
return {
|
|
1418
1537
|
family: props.bodyFamily,
|
|
1419
1538
|
weight: props.bodyWeight,
|
|
@@ -1441,7 +1560,7 @@ const releaseHighlights = {
|
|
|
1441
1560
|
};
|
|
1442
1561
|
},
|
|
1443
1562
|
render(raw, ctx) {
|
|
1444
|
-
const props = propsSchema.parse(raw);
|
|
1563
|
+
const props = propsSchema$1.parse(raw);
|
|
1445
1564
|
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1446
1565
|
const pad = ctx.format.safeZone;
|
|
1447
1566
|
const title = props.version.length > 0 ? `What's new in v${props.version}` : "What's new";
|
|
@@ -1535,6 +1654,413 @@ const releaseHighlights = {
|
|
|
1535
1654
|
}
|
|
1536
1655
|
};
|
|
1537
1656
|
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region src/facts/schema.ts
|
|
1659
|
+
const SOURCE_IDS = [
|
|
1660
|
+
"git",
|
|
1661
|
+
"package-json",
|
|
1662
|
+
"readme",
|
|
1663
|
+
"changelog",
|
|
1664
|
+
"github-api",
|
|
1665
|
+
"npm-api",
|
|
1666
|
+
"bench-file",
|
|
1667
|
+
"user-config",
|
|
1668
|
+
"brand"
|
|
1669
|
+
];
|
|
1670
|
+
const sourceIdSchema = z.enum(SOURCE_IDS);
|
|
1671
|
+
const provenanceSchema = z.object({
|
|
1672
|
+
source: sourceIdSchema,
|
|
1673
|
+
ref: z.string().min(1),
|
|
1674
|
+
fetchedAt: z.iso.datetime()
|
|
1675
|
+
});
|
|
1676
|
+
function factSchema(valueSchema) {
|
|
1677
|
+
return z.object({
|
|
1678
|
+
value: valueSchema,
|
|
1679
|
+
provenance: provenanceSchema
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
const stringFact = factSchema(z.string());
|
|
1683
|
+
const numberFact = factSchema(z.number());
|
|
1684
|
+
const stringListFact = factSchema(z.array(z.string()));
|
|
1685
|
+
const codeSnippetFact = factSchema(z.object({
|
|
1686
|
+
code: z.string(),
|
|
1687
|
+
lang: z.string()
|
|
1688
|
+
}));
|
|
1689
|
+
const betterFact = factSchema(z.enum(["lower", "higher"]));
|
|
1690
|
+
const storyPageSchema = z.object({
|
|
1691
|
+
kind: z.enum([
|
|
1692
|
+
"cover",
|
|
1693
|
+
"change",
|
|
1694
|
+
"code",
|
|
1695
|
+
"comparison",
|
|
1696
|
+
"upgrade"
|
|
1697
|
+
]),
|
|
1698
|
+
title: stringFact,
|
|
1699
|
+
body: stringFact,
|
|
1700
|
+
before: stringFact.optional(),
|
|
1701
|
+
after: stringFact.optional()
|
|
1702
|
+
});
|
|
1703
|
+
const factsSchema = z.object({
|
|
1704
|
+
story: z.array(storyPageSchema).optional(),
|
|
1705
|
+
project: z.object({
|
|
1706
|
+
name: stringFact,
|
|
1707
|
+
tagline: stringFact.optional(),
|
|
1708
|
+
url: stringFact.optional(),
|
|
1709
|
+
repo: stringFact.optional(),
|
|
1710
|
+
npmPackage: stringFact.optional(),
|
|
1711
|
+
cli: factSchema(z.boolean()).optional(),
|
|
1712
|
+
license: stringFact.optional()
|
|
1713
|
+
}),
|
|
1714
|
+
release: z.object({
|
|
1715
|
+
version: stringFact,
|
|
1716
|
+
tag: stringFact,
|
|
1717
|
+
kind: factSchema(z.enum([
|
|
1718
|
+
"major",
|
|
1719
|
+
"minor",
|
|
1720
|
+
"patch"
|
|
1721
|
+
])).optional(),
|
|
1722
|
+
headline: stringFact.optional(),
|
|
1723
|
+
subheadline: stringFact.optional(),
|
|
1724
|
+
previousVersion: stringFact.optional(),
|
|
1725
|
+
date: stringFact,
|
|
1726
|
+
features: z.array(stringFact),
|
|
1727
|
+
fixes: z.array(stringFact),
|
|
1728
|
+
breaking: z.array(stringFact),
|
|
1729
|
+
commitCount: numberFact.optional(),
|
|
1730
|
+
contributors: stringListFact.optional(),
|
|
1731
|
+
codeSnippet: codeSnippetFact.optional()
|
|
1732
|
+
}).optional(),
|
|
1733
|
+
metrics: z.object({
|
|
1734
|
+
stars: numberFact.optional(),
|
|
1735
|
+
weeklyDownloads: numberFact.optional(),
|
|
1736
|
+
contributorCount: numberFact.optional()
|
|
1737
|
+
}).optional(),
|
|
1738
|
+
bench: z.object({
|
|
1739
|
+
title: stringFact,
|
|
1740
|
+
metrics: z.array(z.object({
|
|
1741
|
+
label: stringFact,
|
|
1742
|
+
before: numberFact,
|
|
1743
|
+
after: numberFact,
|
|
1744
|
+
unit: stringFact,
|
|
1745
|
+
better: betterFact
|
|
1746
|
+
})),
|
|
1747
|
+
note: stringFact.optional()
|
|
1748
|
+
}).optional(),
|
|
1749
|
+
milestone: z.object({
|
|
1750
|
+
metric: stringFact,
|
|
1751
|
+
threshold: numberFact
|
|
1752
|
+
}).optional()
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
//#endregion
|
|
1756
|
+
//#region src/templates/story-page.tsx
|
|
1757
|
+
const propsSchema = z.object({
|
|
1758
|
+
page: storyPageSchema,
|
|
1759
|
+
name: z.string(),
|
|
1760
|
+
version: z.string(),
|
|
1761
|
+
heading: z.string(),
|
|
1762
|
+
body: z.string(),
|
|
1763
|
+
mono: z.string(),
|
|
1764
|
+
weight: z.number()
|
|
1765
|
+
});
|
|
1766
|
+
const HEADER_HEIGHT = 36;
|
|
1767
|
+
const BODY_PAD = 24;
|
|
1768
|
+
const CODE_LINE_HEIGHT = 1.35;
|
|
1769
|
+
const CODE_MIN_FONT_SIZE = 14;
|
|
1770
|
+
const FOOTER_ALLOWANCE = 144;
|
|
1771
|
+
const TALL_THRESHOLD = 900;
|
|
1772
|
+
function geometry(format) {
|
|
1773
|
+
const tall = format.height > TALL_THRESHOLD;
|
|
1774
|
+
const titleHeight = tall ? 220 : 112;
|
|
1775
|
+
return {
|
|
1776
|
+
tall,
|
|
1777
|
+
titleHeight,
|
|
1778
|
+
width: format.width - 2 * format.safeZone,
|
|
1779
|
+
bodyHeight: format.height - 2 * format.safeZone - titleHeight - FOOTER_ALLOWANCE
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
const NO_BORDER = "0px solid transparent";
|
|
1783
|
+
/**
|
|
1784
|
+
* Monospace `pre` text does not wrap, so the generic line-count fitter cannot keep it inside
|
|
1785
|
+
* the card: it reports two lines that fit and the long one runs off the right edge. Mono
|
|
1786
|
+
* glyphs are a constant fraction of the em, so the widest line gives a size directly. Same
|
|
1787
|
+
* approach as code-card, which is why MONO_ADVANCE is shared rather than copied.
|
|
1788
|
+
*/
|
|
1789
|
+
function codeFontSize(text, box, max) {
|
|
1790
|
+
const lines = text.split("\n");
|
|
1791
|
+
const longest = Math.max(1, ...lines.map((line) => line.length));
|
|
1792
|
+
const byWidth = box.width / (longest * MONO_ADVANCE);
|
|
1793
|
+
const byHeight = box.height / (lines.length * CODE_LINE_HEIGHT);
|
|
1794
|
+
return Math.max(CODE_MIN_FONT_SIZE, Math.min(max, Math.floor(Math.min(byWidth, byHeight))));
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* A body the reader is meant to copy and run: a code snippet, or upgrade instructions the
|
|
1798
|
+
* maintainer supplied. Generated upgrade prose (a link to the release notes) is not one, so
|
|
1799
|
+
* the provenance decides rather than the page kind alone.
|
|
1800
|
+
*/
|
|
1801
|
+
function isCommand(page) {
|
|
1802
|
+
return page.kind === "code" || page.kind === "upgrade" && page.body.provenance.source === "user-config";
|
|
1803
|
+
}
|
|
1804
|
+
const storyPage = {
|
|
1805
|
+
id: "story-page",
|
|
1806
|
+
events: ["release"],
|
|
1807
|
+
formats: [
|
|
1808
|
+
"og",
|
|
1809
|
+
"github-social",
|
|
1810
|
+
"x",
|
|
1811
|
+
"linkedin",
|
|
1812
|
+
"square",
|
|
1813
|
+
"portrait",
|
|
1814
|
+
"producthunt"
|
|
1815
|
+
],
|
|
1816
|
+
propsSchema,
|
|
1817
|
+
slots(format) {
|
|
1818
|
+
const { width, titleHeight, bodyHeight, tall } = geometry(format);
|
|
1819
|
+
return {
|
|
1820
|
+
name: {
|
|
1821
|
+
maxLines: 1,
|
|
1822
|
+
maxFontSize: 24,
|
|
1823
|
+
minFontSize: 18,
|
|
1824
|
+
step: 2,
|
|
1825
|
+
box: { width: width - 300 }
|
|
1826
|
+
},
|
|
1827
|
+
version: {
|
|
1828
|
+
maxLines: 1,
|
|
1829
|
+
maxFontSize: 22,
|
|
1830
|
+
minFontSize: 16,
|
|
1831
|
+
step: 2,
|
|
1832
|
+
box: { width: 220 }
|
|
1833
|
+
},
|
|
1834
|
+
title: {
|
|
1835
|
+
maxLines: tall ? 3 : 2,
|
|
1836
|
+
maxFontSize: tall ? 64 : 48,
|
|
1837
|
+
minFontSize: tall ? 36 : 30,
|
|
1838
|
+
step: 2,
|
|
1839
|
+
box: {
|
|
1840
|
+
width: width - 32,
|
|
1841
|
+
height: titleHeight
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
body: {
|
|
1845
|
+
maxLines: Math.floor(bodyHeight / 30),
|
|
1846
|
+
maxFontSize: tall ? 34 : 28,
|
|
1847
|
+
minFontSize: 20,
|
|
1848
|
+
step: 2,
|
|
1849
|
+
box: {
|
|
1850
|
+
width: width - 48,
|
|
1851
|
+
height: bodyHeight - 48
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
};
|
|
1855
|
+
},
|
|
1856
|
+
slotText(raw) {
|
|
1857
|
+
const props = propsSchema.parse(raw);
|
|
1858
|
+
return {
|
|
1859
|
+
name: props.name,
|
|
1860
|
+
version: props.version,
|
|
1861
|
+
title: props.page.title.value,
|
|
1862
|
+
body: props.page.body.value
|
|
1863
|
+
};
|
|
1864
|
+
},
|
|
1865
|
+
slotFont(raw, slot) {
|
|
1866
|
+
const props = propsSchema.parse(raw);
|
|
1867
|
+
const mono = slot === "body" && isCommand(props.page);
|
|
1868
|
+
return {
|
|
1869
|
+
family: mono ? props.mono : slot === "title" ? props.heading : props.body,
|
|
1870
|
+
weight: slot === "title" ? props.weight : 400,
|
|
1871
|
+
lineHeight: mono ? 1.35 : 1.2,
|
|
1872
|
+
whiteSpace: mono ? "pre" : "normal"
|
|
1873
|
+
};
|
|
1874
|
+
},
|
|
1875
|
+
buildProps(facts, _copy, brand) {
|
|
1876
|
+
const page = facts.story?.[0];
|
|
1877
|
+
if (page === void 0) throw new ShipsealError("story-page.no-page", "The story-page template has no story page to render.", "Run shipseal story to build a story pack. Do not list story-page in release.templates.");
|
|
1878
|
+
return {
|
|
1879
|
+
props: {
|
|
1880
|
+
page,
|
|
1881
|
+
name: brand.name,
|
|
1882
|
+
version: facts.release?.tag.value ?? "",
|
|
1883
|
+
heading: brand.style === "terminal" ? brand.fonts.mono.family : brand.fonts.heading.family,
|
|
1884
|
+
body: brand.fonts.body.family,
|
|
1885
|
+
mono: brand.fonts.mono.family,
|
|
1886
|
+
weight: brand.fonts.heading.weight
|
|
1887
|
+
},
|
|
1888
|
+
missing: []
|
|
1889
|
+
};
|
|
1890
|
+
},
|
|
1891
|
+
render(raw, ctx) {
|
|
1892
|
+
const props = propsSchema.parse(raw);
|
|
1893
|
+
const colors = themeColors(ctx.brand, ctx.theme);
|
|
1894
|
+
const { tall, bodyHeight, width } = geometry(ctx.format);
|
|
1895
|
+
const editorial = ctx.brand.style === "editorial";
|
|
1896
|
+
const terminal = ctx.brand.style === "terminal";
|
|
1897
|
+
const code = isCommand(props.page);
|
|
1898
|
+
const comparison = props.page.kind === "comparison";
|
|
1899
|
+
const title = ctx.fitted.title;
|
|
1900
|
+
const body = ctx.fitted.body;
|
|
1901
|
+
const hasBody = props.page.body.value.trim().length > 0;
|
|
1902
|
+
const bodyText = body?.text ?? props.page.body.value;
|
|
1903
|
+
const bodyFontSize = code ? codeFontSize(bodyText, {
|
|
1904
|
+
width: width - 48,
|
|
1905
|
+
height: bodyHeight - 48
|
|
1906
|
+
}, tall ? 34 : 28) : body?.fontSize ?? 28;
|
|
1907
|
+
const paneWidth = tall ? width : (width - 16) / 2;
|
|
1908
|
+
const paneHeight = tall ? (bodyHeight - 16) / 2 : bodyHeight;
|
|
1909
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1910
|
+
style: {
|
|
1911
|
+
display: "flex",
|
|
1912
|
+
flexDirection: "column",
|
|
1913
|
+
width: "100%",
|
|
1914
|
+
height: "100%",
|
|
1915
|
+
backgroundColor: colors.background,
|
|
1916
|
+
color: colors.foreground,
|
|
1917
|
+
padding: ctx.format.safeZone,
|
|
1918
|
+
gap: 24,
|
|
1919
|
+
borderTop: editorial ? `16px solid ${colors.primary}` : NO_BORDER
|
|
1920
|
+
},
|
|
1921
|
+
children: [
|
|
1922
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1923
|
+
style: {
|
|
1924
|
+
display: "flex",
|
|
1925
|
+
justifyContent: "space-between",
|
|
1926
|
+
alignItems: "center",
|
|
1927
|
+
height: HEADER_HEIGHT,
|
|
1928
|
+
flexShrink: 0,
|
|
1929
|
+
borderBottom: terminal ? `1px solid ${colors.muted}` : NO_BORDER,
|
|
1930
|
+
paddingBottom: terminal ? 12 : 0
|
|
1931
|
+
},
|
|
1932
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
1933
|
+
style: {
|
|
1934
|
+
display: "flex",
|
|
1935
|
+
gap: 12,
|
|
1936
|
+
alignItems: "center"
|
|
1937
|
+
},
|
|
1938
|
+
children: [ctx.logoSrc !== void 0 ? /* @__PURE__ */ jsx("img", {
|
|
1939
|
+
src: ctx.logoSrc,
|
|
1940
|
+
width: 32,
|
|
1941
|
+
height: 32
|
|
1942
|
+
}) : void 0, /* @__PURE__ */ jsx("div", {
|
|
1943
|
+
style: {
|
|
1944
|
+
fontFamily: terminal ? props.mono : props.body,
|
|
1945
|
+
fontWeight: 400,
|
|
1946
|
+
fontSize: ctx.fitted.name?.fontSize ?? 24,
|
|
1947
|
+
color: colors.primary
|
|
1948
|
+
},
|
|
1949
|
+
children: ctx.fitted.name?.text ?? props.name
|
|
1950
|
+
})]
|
|
1951
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1952
|
+
style: {
|
|
1953
|
+
fontFamily: props.mono,
|
|
1954
|
+
fontWeight: 400,
|
|
1955
|
+
fontSize: ctx.fitted.version?.fontSize ?? 22,
|
|
1956
|
+
color: colors.muted
|
|
1957
|
+
},
|
|
1958
|
+
children: ctx.fitted.version?.text ?? props.version
|
|
1959
|
+
})]
|
|
1960
|
+
}),
|
|
1961
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1962
|
+
style: {
|
|
1963
|
+
display: "flex",
|
|
1964
|
+
flexDirection: "column",
|
|
1965
|
+
flexGrow: 1,
|
|
1966
|
+
justifyContent: "center",
|
|
1967
|
+
gap: 24,
|
|
1968
|
+
minHeight: 0
|
|
1969
|
+
},
|
|
1970
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1971
|
+
style: {
|
|
1972
|
+
display: "flex",
|
|
1973
|
+
alignItems: "center",
|
|
1974
|
+
flexShrink: 0,
|
|
1975
|
+
paddingLeft: editorial ? 24 : 0,
|
|
1976
|
+
borderLeft: editorial ? `8px solid ${colors.primary}` : NO_BORDER
|
|
1977
|
+
},
|
|
1978
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1979
|
+
style: {
|
|
1980
|
+
fontFamily: props.heading,
|
|
1981
|
+
fontWeight: props.weight,
|
|
1982
|
+
fontSize: title?.fontSize ?? 48,
|
|
1983
|
+
lineHeight: 1.2,
|
|
1984
|
+
maxWidth: width - (editorial ? 32 : 0)
|
|
1985
|
+
},
|
|
1986
|
+
children: title?.text ?? props.page.title.value
|
|
1987
|
+
})
|
|
1988
|
+
}), comparison ? /* @__PURE__ */ jsx("div", {
|
|
1989
|
+
style: {
|
|
1990
|
+
display: "flex",
|
|
1991
|
+
flexDirection: tall ? "column" : "row",
|
|
1992
|
+
gap: 16,
|
|
1993
|
+
height: bodyHeight
|
|
1994
|
+
},
|
|
1995
|
+
children: ["before", "after"].map((label) => /* @__PURE__ */ jsxs("div", {
|
|
1996
|
+
style: {
|
|
1997
|
+
display: "flex",
|
|
1998
|
+
flexDirection: "column",
|
|
1999
|
+
gap: 8,
|
|
2000
|
+
width: paneWidth,
|
|
2001
|
+
height: paneHeight
|
|
2002
|
+
},
|
|
2003
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2004
|
+
style: {
|
|
2005
|
+
fontFamily: props.body,
|
|
2006
|
+
fontSize: 20,
|
|
2007
|
+
color: colors.muted
|
|
2008
|
+
},
|
|
2009
|
+
children: label === "before" ? "Before" : "After"
|
|
2010
|
+
}), /* @__PURE__ */ jsx("img", {
|
|
2011
|
+
src: `story-${label}`,
|
|
2012
|
+
width: paneWidth,
|
|
2013
|
+
height: paneHeight - HEADER_HEIGHT,
|
|
2014
|
+
style: { objectFit: "contain" }
|
|
2015
|
+
})]
|
|
2016
|
+
}, label))
|
|
2017
|
+
}) : hasBody ? /* @__PURE__ */ jsx("div", {
|
|
2018
|
+
style: {
|
|
2019
|
+
display: "flex",
|
|
2020
|
+
flexDirection: "column",
|
|
2021
|
+
justifyContent: "flex-start",
|
|
2022
|
+
maxHeight: bodyHeight,
|
|
2023
|
+
padding: terminal || code ? BODY_PAD : 0,
|
|
2024
|
+
backgroundColor: terminal || code ? colors.card : "transparent",
|
|
2025
|
+
borderRadius: terminal ? 0 : ctx.brand.radius,
|
|
2026
|
+
borderLeft: terminal ? `3px solid ${colors.primary}` : NO_BORDER
|
|
2027
|
+
},
|
|
2028
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
2029
|
+
style: {
|
|
2030
|
+
fontFamily: code ? props.mono : props.body,
|
|
2031
|
+
fontWeight: 400,
|
|
2032
|
+
fontSize: bodyFontSize,
|
|
2033
|
+
lineHeight: code ? CODE_LINE_HEIGHT : 1.2,
|
|
2034
|
+
whiteSpace: code ? "pre" : "normal",
|
|
2035
|
+
color: colors.foreground
|
|
2036
|
+
},
|
|
2037
|
+
children: bodyText
|
|
2038
|
+
})
|
|
2039
|
+
}) : void 0]
|
|
2040
|
+
}),
|
|
2041
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2042
|
+
style: {
|
|
2043
|
+
display: "flex",
|
|
2044
|
+
justifyContent: "space-between",
|
|
2045
|
+
flexShrink: 0,
|
|
2046
|
+
fontFamily: props.mono,
|
|
2047
|
+
fontWeight: 400,
|
|
2048
|
+
fontSize: 16,
|
|
2049
|
+
color: colors.muted
|
|
2050
|
+
},
|
|
2051
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2052
|
+
style: { fontFamily: props.mono },
|
|
2053
|
+
children: comparison ? "Supplied screenshots" : "Sources in manifest.json"
|
|
2054
|
+
}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
|
|
2055
|
+
style: { fontFamily: props.mono },
|
|
2056
|
+
children: "made with shipseal.dev"
|
|
2057
|
+
}) : void 0]
|
|
2058
|
+
})
|
|
2059
|
+
]
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
|
|
1538
2064
|
//#endregion
|
|
1539
2065
|
//#region src/templates/registry.ts
|
|
1540
2066
|
const templates = [
|
|
@@ -1542,7 +2068,8 @@ const templates = [
|
|
|
1542
2068
|
releaseHighlights,
|
|
1543
2069
|
codeCard,
|
|
1544
2070
|
milestone,
|
|
1545
|
-
bench
|
|
2071
|
+
bench,
|
|
2072
|
+
storyPage
|
|
1546
2073
|
];
|
|
1547
2074
|
function getTemplate(id) {
|
|
1548
2075
|
const found = templates.find((template) => template.id === id);
|
|
@@ -1552,6 +2079,14 @@ function getTemplate(id) {
|
|
|
1552
2079
|
|
|
1553
2080
|
//#endregion
|
|
1554
2081
|
//#region src/core/generate.ts
|
|
2082
|
+
/**
|
|
2083
|
+
* Digest via WebCrypto, not `node:crypto`. The browser demo imports `generate()`, so nothing
|
|
2084
|
+
* in the pure core may reach for a Node builtin. Do not "simplify" this back to createHash.
|
|
2085
|
+
*/
|
|
2086
|
+
async function sha256Hex(bytes) {
|
|
2087
|
+
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes));
|
|
2088
|
+
return Array.from(new Uint8Array(digest)).map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
2089
|
+
}
|
|
1555
2090
|
async function generate(input) {
|
|
1556
2091
|
const templateIds = templateIdsFor(input.event, input.config);
|
|
1557
2092
|
const formatIds = input.config.formats ?? [
|
|
@@ -1613,8 +2148,10 @@ async function generate(input) {
|
|
|
1613
2148
|
src: "shipseal-logo",
|
|
1614
2149
|
data: logo
|
|
1615
2150
|
}];
|
|
2151
|
+
if (input.images !== void 0) renderOpts.images = [...renderOpts.images ?? [], ...input.images];
|
|
1616
2152
|
const bytes = await input.renderer.render(node, renderOpts);
|
|
1617
2153
|
const fileName = outputName(template.id, formatId, theme, omitThemeSuffix, imageFormat);
|
|
2154
|
+
const sha256 = await sha256Hex(bytes);
|
|
1618
2155
|
files.push({
|
|
1619
2156
|
fileName,
|
|
1620
2157
|
template: template.id,
|
|
@@ -1623,7 +2160,7 @@ async function generate(input) {
|
|
|
1623
2160
|
width: format.width,
|
|
1624
2161
|
height: format.height,
|
|
1625
2162
|
bytes,
|
|
1626
|
-
sha256
|
|
2163
|
+
sha256
|
|
1627
2164
|
});
|
|
1628
2165
|
}
|
|
1629
2166
|
}
|
|
@@ -1745,6 +2282,7 @@ function mergeFacts(parts) {
|
|
|
1745
2282
|
if (project.url !== void 0) facts.project.url = project.url;
|
|
1746
2283
|
if (project.repo !== void 0) facts.project.repo = project.repo;
|
|
1747
2284
|
if (project.npmPackage !== void 0) facts.project.npmPackage = project.npmPackage;
|
|
2285
|
+
if (project.cli !== void 0) facts.project.cli = project.cli;
|
|
1748
2286
|
if (project.license !== void 0) facts.project.license = project.license;
|
|
1749
2287
|
if (isCompleteRelease(release)) {
|
|
1750
2288
|
facts.release = {
|
|
@@ -1755,6 +2293,9 @@ function mergeFacts(parts) {
|
|
|
1755
2293
|
fixes: release.fixes ?? [],
|
|
1756
2294
|
breaking: release.breaking ?? []
|
|
1757
2295
|
};
|
|
2296
|
+
if (release.kind !== void 0) facts.release.kind = release.kind;
|
|
2297
|
+
if (release.headline !== void 0) facts.release.headline = release.headline;
|
|
2298
|
+
if (release.subheadline !== void 0) facts.release.subheadline = release.subheadline;
|
|
1758
2299
|
if (release.previousVersion !== void 0) facts.release.previousVersion = release.previousVersion;
|
|
1759
2300
|
if (release.commitCount !== void 0) facts.release.commitCount = release.commitCount;
|
|
1760
2301
|
if (release.contributors !== void 0) facts.release.contributors = release.contributors;
|
|
@@ -1890,7 +2431,7 @@ async function collectReadme(cwd) {
|
|
|
1890
2431
|
ref: "README.md H1",
|
|
1891
2432
|
fetchedAt
|
|
1892
2433
|
});
|
|
1893
|
-
if (tagline !== void 0) project.tagline = fact(tagline, {
|
|
2434
|
+
if (tagline !== void 0) project.tagline = fact(stripEmoji(tagline), {
|
|
1894
2435
|
source: "readme",
|
|
1895
2436
|
ref: "README.md first paragraph",
|
|
1896
2437
|
fetchedAt
|
|
@@ -2028,6 +2569,17 @@ async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
|
|
|
2028
2569
|
fixes,
|
|
2029
2570
|
breaking
|
|
2030
2571
|
} };
|
|
2572
|
+
for (const slot of ["headline", "subheadline"]) {
|
|
2573
|
+
const text = new RegExp(`<!--\\s*shipseal:\\s*${slot}\\s+"([^"]+)"\\s*-->`).exec(section.body)?.[1]?.trim();
|
|
2574
|
+
if (text !== void 0 && text.length > 0) out.release = {
|
|
2575
|
+
...out.release,
|
|
2576
|
+
[slot]: fact(text, {
|
|
2577
|
+
source: "user-config",
|
|
2578
|
+
ref: `${changelogPath} ${section.heading} shipseal:${slot} marker`,
|
|
2579
|
+
fetchedAt
|
|
2580
|
+
})
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2031
2583
|
const snippet = extractFirstCodeFence(section.body);
|
|
2032
2584
|
if (snippet !== void 0) out.release = {
|
|
2033
2585
|
...out.release,
|
|
@@ -2071,7 +2623,7 @@ function findVersionSection(markdown, version) {
|
|
|
2071
2623
|
}
|
|
2072
2624
|
}
|
|
2073
2625
|
function cleanChangelogItem(item) {
|
|
2074
|
-
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(/\.$/, "");
|
|
2626
|
+
return item.replace(/^\s*[-*]\s*/, "").replace(/\[`[a-f0-9]{7,40}`]\([^)]+\)/gi, "").replace(/^[a-f0-9]{7,40}:\s*/i, "").replace(/\(#\d+\)/g, "").replace(/\[#\d+]\([^)]+\)/g, "").replace(/Thanks\s+\[@[\w-]+]\([^)]+\)!?\s*-?\s*/gi, "").replace(/\[@[\w-]+]\([^)]+\)/g, "").replace(/\(@[\w-]+\)/g, "").replace(/\s+by\s+@[\w-]+/gi, "").replace(/@[\w-]+/g, "").replace(/^Thanks\s*!?\s*-?\s*/i, "").replace(/^\s*[-*]\s*/, "").replaceAll(/`([^`]+)`/g, "$1").replaceAll("`", "").replace(/\s+/g, " ").trim().replace(/\.$/, "");
|
|
2075
2627
|
}
|
|
2076
2628
|
function headingIncludesVersion(heading, version) {
|
|
2077
2629
|
const unwrapped = heading.replace(/[[\]]/g, " ");
|
|
@@ -2201,11 +2753,19 @@ async function collectGit(cwd, event) {
|
|
|
2201
2753
|
fetchedAt
|
|
2202
2754
|
})
|
|
2203
2755
|
};
|
|
2204
|
-
if (previousTag !== void 0)
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2756
|
+
if (previousTag !== void 0) {
|
|
2757
|
+
release.previousVersion = fact(versionFromTag(previousTag), {
|
|
2758
|
+
source: "git",
|
|
2759
|
+
ref: `git tag ${previousTag}`,
|
|
2760
|
+
fetchedAt
|
|
2761
|
+
});
|
|
2762
|
+
const kind = releaseKind(versionFromTag(previousTag), versionFromTag(tag));
|
|
2763
|
+
if (kind !== void 0) release.kind = fact(kind, {
|
|
2764
|
+
source: "git",
|
|
2765
|
+
ref: `semver ${previousTag}..${tag}`,
|
|
2766
|
+
fetchedAt
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2209
2769
|
if (names.length > 0) release.contributors = fact(names, {
|
|
2210
2770
|
source: "git",
|
|
2211
2771
|
ref: `git log ${range} unique authors`,
|
|
@@ -2294,6 +2854,30 @@ function githubSlug(url) {
|
|
|
2294
2854
|
const cleaned = url.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
2295
2855
|
return /github\.com[/:]([^/]+\/[^/]+)$/.exec(cleaned)?.[1];
|
|
2296
2856
|
}
|
|
2857
|
+
/**
|
|
2858
|
+
* How significant is this release, by semver alone?
|
|
2859
|
+
*
|
|
2860
|
+
* Compared against the previous tag rather than read from a changelog heading, so it is a fact
|
|
2861
|
+
* with a reference someone can re-run. Returns undefined when either tag is not semver, or when
|
|
2862
|
+
* the two are equal: a caller should treat "unknown" as "announce it", never as "skip it".
|
|
2863
|
+
*/
|
|
2864
|
+
function releaseKind(previous, current) {
|
|
2865
|
+
const before = parseSemver(previous);
|
|
2866
|
+
const after = parseSemver(current);
|
|
2867
|
+
if (before === void 0 || after === void 0) return;
|
|
2868
|
+
if (after[0] !== before[0]) return "major";
|
|
2869
|
+
if (after[1] !== before[1]) return "minor";
|
|
2870
|
+
return after[2] === before[2] ? void 0 : "patch";
|
|
2871
|
+
}
|
|
2872
|
+
function parseSemver(value) {
|
|
2873
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.replace(/^v/, ""));
|
|
2874
|
+
if (match === null) return;
|
|
2875
|
+
return [
|
|
2876
|
+
Number(match[1]),
|
|
2877
|
+
Number(match[2]),
|
|
2878
|
+
Number(match[3])
|
|
2879
|
+
];
|
|
2880
|
+
}
|
|
2297
2881
|
|
|
2298
2882
|
//#endregion
|
|
2299
2883
|
//#region src/sources/github.ts
|
|
@@ -2326,7 +2910,7 @@ async function collectGithub(options) {
|
|
|
2326
2910
|
};
|
|
2327
2911
|
if (repo.description !== void 0 && repo.description !== null && repo.description.length > 0) out.project = {
|
|
2328
2912
|
...out.project,
|
|
2329
|
-
tagline: fact(repo.description, {
|
|
2913
|
+
tagline: fact(stripEmoji(repo.description), {
|
|
2330
2914
|
source: "github-api",
|
|
2331
2915
|
ref: `GET /repos/${slug.owner}/${slug.repo} description`,
|
|
2332
2916
|
fetchedAt
|
|
@@ -2492,6 +3076,7 @@ async function npmPackageExists(name, fetchImpl = fetch) {
|
|
|
2492
3076
|
const pkgSchema = z.object({
|
|
2493
3077
|
private: z.boolean().optional(),
|
|
2494
3078
|
name: z.string().optional(),
|
|
3079
|
+
bin: z.union([z.string(), z.record(z.string(), z.string())]).optional(),
|
|
2495
3080
|
description: z.string().optional(),
|
|
2496
3081
|
version: z.string().optional(),
|
|
2497
3082
|
homepage: z.string().optional(),
|
|
@@ -2521,7 +3106,7 @@ async function collectPackageJson(cwd, packagePath = "package.json") {
|
|
|
2521
3106
|
ref: rawName === void 0 ? `${packagePath} directory name` : `${packagePath}#name`,
|
|
2522
3107
|
fetchedAt
|
|
2523
3108
|
}) };
|
|
2524
|
-
if (pkg.data.description !== void 0 && pkg.data.description.length > 0) project.tagline = fact(pkg.data.description, {
|
|
3109
|
+
if (pkg.data.description !== void 0 && pkg.data.description.length > 0) project.tagline = fact(stripEmoji(pkg.data.description), {
|
|
2525
3110
|
source: "package-json",
|
|
2526
3111
|
ref: `${packagePath}#description`,
|
|
2527
3112
|
fetchedAt
|
|
@@ -2543,6 +3128,11 @@ async function collectPackageJson(cwd, packagePath = "package.json") {
|
|
|
2543
3128
|
ref: `${packagePath}#name`,
|
|
2544
3129
|
fetchedAt
|
|
2545
3130
|
});
|
|
3131
|
+
if (pkg.data.bin !== void 0 && pkg.data.private !== true) project.cli = fact(true, {
|
|
3132
|
+
source: "package-json",
|
|
3133
|
+
ref: `${packagePath}#bin`,
|
|
3134
|
+
fetchedAt
|
|
3135
|
+
});
|
|
2546
3136
|
if (pkg.data.license !== void 0) project.license = fact(pkg.data.license, {
|
|
2547
3137
|
source: "package-json",
|
|
2548
3138
|
ref: `${packagePath}#license`,
|
|
@@ -2597,9 +3187,23 @@ async function collectFacts(options) {
|
|
|
2597
3187
|
const changelog = await collectBestChangelog(options.cwd, version, options.changelogPath ?? "CHANGELOG.md", options.packagePath);
|
|
2598
3188
|
const readme = await collectReadme(options.cwd);
|
|
2599
3189
|
const snippet = options.snippet !== void 0 && options.snippet !== null && options.snippet.length > 0 ? await collectConfiguredSnippet(options.cwd, options.snippet) : {};
|
|
3190
|
+
const workspacePkg = pkg.project?.npmPackage === void 0 ? await collectWorkspaceNpmPackage(options.cwd, options.skipNetwork === true ? {} : { verify: async (name) => npmPackageExists(name, options.fetchImpl ?? fetch) }) : {};
|
|
3191
|
+
const overrides = {};
|
|
3192
|
+
for (const slot of ["headline", "subheadline"]) {
|
|
3193
|
+
const text = options[slot]?.trim();
|
|
3194
|
+
if (text !== void 0 && text.length > 0) overrides.release = {
|
|
3195
|
+
...overrides.release,
|
|
3196
|
+
[slot]: fact(text, {
|
|
3197
|
+
source: "user-config",
|
|
3198
|
+
ref: `--${slot} or release.${slot}`,
|
|
3199
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3200
|
+
})
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
2600
3203
|
const parts = [
|
|
3204
|
+
overrides,
|
|
2601
3205
|
pkg,
|
|
2602
|
-
|
|
3206
|
+
workspacePkg,
|
|
2603
3207
|
remote,
|
|
2604
3208
|
git,
|
|
2605
3209
|
snippet,
|
|
@@ -2656,16 +3260,19 @@ async function collectWorkspaceNpmPackage(cwd, options = {}) {
|
|
|
2656
3260
|
const paths = await workspaceManifests(cwd);
|
|
2657
3261
|
const found = (await Promise.all(paths.map((path) => collectPackageJson(cwd, path)))).map((facts, index) => ({
|
|
2658
3262
|
name: facts.project?.npmPackage?.value,
|
|
3263
|
+
cli: facts.project?.cli,
|
|
2659
3264
|
path: paths[index] ?? ""
|
|
2660
3265
|
})).filter((entry) => entry.name !== void 0);
|
|
2661
3266
|
const only = found.length === 1 ? found[0] : void 0;
|
|
2662
3267
|
if (only === void 0) return {};
|
|
2663
3268
|
if (options.verify !== void 0 && await options.verify(only.name) === false) return {};
|
|
2664
|
-
|
|
3269
|
+
const project = { npmPackage: fact(only.name, {
|
|
2665
3270
|
source: "package-json",
|
|
2666
3271
|
ref: `${only.path}#name`,
|
|
2667
3272
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2668
|
-
}) }
|
|
3273
|
+
}) };
|
|
3274
|
+
if (only.cli !== void 0) project.cli = only.cli;
|
|
3275
|
+
return { project };
|
|
2669
3276
|
}
|
|
2670
3277
|
async function workspaceManifests(cwd) {
|
|
2671
3278
|
return (await workspaceChangelogs(cwd)).map((path) => path.replace(/CHANGELOG\.md$/, "package.json"));
|
|
@@ -2693,27 +3300,18 @@ function gitRepoFromParts(pkg, readme) {
|
|
|
2693
3300
|
//#endregion
|
|
2694
3301
|
//#region src/render/takumi.ts
|
|
2695
3302
|
const GEIST_MONO_FAMILY = "Geist Mono";
|
|
2696
|
-
|
|
2697
|
-
|
|
3303
|
+
/** Path of the vendored font, relative to the package root. Resolved in `takumi-node.ts`. */
|
|
3304
|
+
const FONT_REL = "assets/fonts/GeistMono[wght].ttf";
|
|
3305
|
+
/** Both runtimes end here: one adapter, one registered font, whoever supplied the bytes. */
|
|
3306
|
+
async function createRendererWithFont(font) {
|
|
2698
3307
|
const renderer = new Renderer();
|
|
2699
|
-
const fontPath = join(resolvePackageRoot(), FONT_REL);
|
|
2700
|
-
const data = await readFile(fontPath);
|
|
2701
3308
|
await renderer.registerFont({
|
|
2702
3309
|
name: GEIST_MONO_FAMILY,
|
|
2703
|
-
data,
|
|
3310
|
+
data: font,
|
|
2704
3311
|
generic: "monospace"
|
|
2705
3312
|
});
|
|
2706
3313
|
return new TakumiRenderer(renderer);
|
|
2707
3314
|
}
|
|
2708
|
-
function resolvePackageRoot(from = import.meta.url) {
|
|
2709
|
-
let dir = dirname(fileURLToPath(from));
|
|
2710
|
-
for (;;) {
|
|
2711
|
-
if (existsSync(join(dir, "package.json")) && existsSync(join(dir, FONT_REL))) return dir;
|
|
2712
|
-
const parent = dirname(dir);
|
|
2713
|
-
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.");
|
|
2714
|
-
dir = parent;
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
3315
|
function countLines(node) {
|
|
2718
3316
|
const ys = /* @__PURE__ */ new Set();
|
|
2719
3317
|
const walk = (current) => {
|
|
@@ -2882,6 +3480,23 @@ function asReactElementLike(element) {
|
|
|
2882
3480
|
};
|
|
2883
3481
|
}
|
|
2884
3482
|
|
|
3483
|
+
//#endregion
|
|
3484
|
+
//#region src/render/takumi-node.ts
|
|
3485
|
+
async function createTakumiRenderer() {
|
|
3486
|
+
const fontPath = join(resolvePackageRoot(), FONT_REL);
|
|
3487
|
+
const data = await readFile(fontPath);
|
|
3488
|
+
return createRendererWithFont(data);
|
|
3489
|
+
}
|
|
3490
|
+
function resolvePackageRoot(from = import.meta.url) {
|
|
3491
|
+
let dir = dirname(fileURLToPath(from));
|
|
3492
|
+
for (;;) {
|
|
3493
|
+
if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "assets/fonts/GeistMono[wght].ttf"))) return dir;
|
|
3494
|
+
const parent = dirname(dir);
|
|
3495
|
+
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.");
|
|
3496
|
+
dir = parent;
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
|
|
2885
3500
|
//#endregion
|
|
2886
3501
|
//#region src/copy/number-guard.ts
|
|
2887
3502
|
function allowedNumbers(facts) {
|
|
@@ -3112,6 +3727,7 @@ function flattenFacts(facts) {
|
|
|
3112
3727
|
if (typeof value === "object" && value !== null) for (const [key, nested] of Object.entries(value)) walk(prefix.length === 0 ? key : `${prefix}.${key}`, nested);
|
|
3113
3728
|
};
|
|
3114
3729
|
walk("project", facts.project);
|
|
3730
|
+
if (facts.story !== void 0) walk("story", facts.story);
|
|
3115
3731
|
if (facts.release !== void 0) walk("release", facts.release);
|
|
3116
3732
|
if (facts.metrics !== void 0) walk("metrics", facts.metrics);
|
|
3117
3733
|
if (facts.bench !== void 0) walk("bench", facts.bench);
|
|
@@ -3827,12 +4443,12 @@ function allVariables(css) {
|
|
|
3827
4443
|
return vars;
|
|
3828
4444
|
}
|
|
3829
4445
|
/**
|
|
3830
|
-
* Resolve a declaration to a hex
|
|
4446
|
+
* Resolve a declaration to a hex color.
|
|
3831
4447
|
*
|
|
3832
|
-
* Handles two things a plain
|
|
4448
|
+
* Handles two things a plain color parser does not. First, `var(--brand-500)` indirection,
|
|
3833
4449
|
* up to three hops within the same file. Second, bare channel lists: shadcn writes
|
|
3834
4450
|
* `--background: 0 0% 100%` and applies it as `hsl(var(--background))`, so the value is only
|
|
3835
|
-
* a
|
|
4451
|
+
* a color once wrapped. Percent signs on the last two channels mean HSL, three plain
|
|
3836
4452
|
* numbers mean RGB.
|
|
3837
4453
|
*/
|
|
3838
4454
|
function resolveColor(raw, vars, depth = 0) {
|
|
@@ -3993,7 +4609,7 @@ const SIGNATURE = Buffer.from([
|
|
|
3993
4609
|
26,
|
|
3994
4610
|
10
|
|
3995
4611
|
]);
|
|
3996
|
-
/** Channels per pixel for each PNG
|
|
4612
|
+
/** Channels per pixel for each PNG color type. Index is the color type. */
|
|
3997
4613
|
const CHANNELS = {
|
|
3998
4614
|
0: 1,
|
|
3999
4615
|
2: 3,
|
|
@@ -4143,10 +4759,10 @@ function toRgba(data, width, height, colorType, bytesPerSample, palette, palette
|
|
|
4143
4759
|
return pixels;
|
|
4144
4760
|
}
|
|
4145
4761
|
/**
|
|
4146
|
-
* Most common non-neutral
|
|
4762
|
+
* Most common non-neutral color in a logo, as a hex string.
|
|
4147
4763
|
*
|
|
4148
4764
|
* Nearly transparent pixels are ignored so a transparent background does not win, and greys
|
|
4149
|
-
* are ignored because a logo's black wordmark is not its brand
|
|
4765
|
+
* are ignored because a logo's black wordmark is not its brand color. Colors are bucketed
|
|
4150
4766
|
* into 16 levels per channel so anti-aliased edges group with the solid fill they belong to,
|
|
4151
4767
|
* then the winning bucket reports the average of the real pixels inside it rather than the
|
|
4152
4768
|
* bucket centre, which would drift the hue.
|
|
@@ -4483,7 +5099,7 @@ async function detectColors(cwd, logoPath, sources, notes) {
|
|
|
4483
5099
|
merged.primary = fromPng;
|
|
4484
5100
|
sources.push({
|
|
4485
5101
|
field: "colors.primary",
|
|
4486
|
-
source: `dominant
|
|
5102
|
+
source: `dominant color in ${logoPath}`
|
|
4487
5103
|
});
|
|
4488
5104
|
}
|
|
4489
5105
|
const svg = logoPath.endsWith(".svg") ? await readMaybe(join(cwd, logoPath)) : void 0;
|
|
@@ -4625,8 +5241,8 @@ function normalizeGitUrl(url) {
|
|
|
4625
5241
|
return url.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
4626
5242
|
}
|
|
4627
5243
|
/**
|
|
4628
|
-
* Dominant non-neutral
|
|
4629
|
-
* carries no
|
|
5244
|
+
* Dominant non-neutral color of a PNG logo, or undefined when the file cannot be read or
|
|
5245
|
+
* carries no color. Detection reports the color as not found rather than falling back to a
|
|
4630
5246
|
* built-in default that would be presented to the user as "your brand".
|
|
4631
5247
|
*/
|
|
4632
5248
|
async function dominantLogoColor(path) {
|
|
@@ -4898,6 +5514,12 @@ function missingMetric(metric) {
|
|
|
4898
5514
|
|
|
4899
5515
|
//#endregion
|
|
4900
5516
|
//#region src/commands/release.ts
|
|
5517
|
+
/** Lowest kind worth announcing, most significant first. */
|
|
5518
|
+
const ANNOUNCE_ORDER = [
|
|
5519
|
+
"major",
|
|
5520
|
+
"minor",
|
|
5521
|
+
"patch"
|
|
5522
|
+
];
|
|
4901
5523
|
async function runRelease(flags) {
|
|
4902
5524
|
const cwd = flags.cwd;
|
|
4903
5525
|
const config = overlayReleaseConfig(await loadConfig(cwd), flags);
|
|
@@ -4923,9 +5545,25 @@ async function runRelease(flags) {
|
|
|
4923
5545
|
if (config.release?.snippet !== void 0) collectOpts.snippet = config.release.snippet;
|
|
4924
5546
|
if (env.githubToken !== void 0) collectOpts.githubToken = env.githubToken;
|
|
4925
5547
|
if (env.fetchImpl !== void 0) collectOpts.fetchImpl = env.fetchImpl;
|
|
5548
|
+
const headline = flags.headline ?? config.release?.headline ?? void 0;
|
|
5549
|
+
if (headline !== void 0) collectOpts.headline = headline;
|
|
5550
|
+
const subheadline = flags.subheadline ?? config.release?.subheadline ?? void 0;
|
|
5551
|
+
if (subheadline !== void 0) collectOpts.subheadline = subheadline;
|
|
4926
5552
|
const facts = await collectFacts(collectOpts);
|
|
5553
|
+
const announce = config.release?.announce ?? "patch";
|
|
5554
|
+
const kind = facts.release?.kind?.value;
|
|
5555
|
+
if (kind !== void 0 && ANNOUNCE_ORDER.indexOf(kind) > ANNOUNCE_ORDER.indexOf(announce)) return {
|
|
5556
|
+
dryRun: flags.dryRun === true,
|
|
5557
|
+
skipped: true,
|
|
5558
|
+
message: `This is a ${kind} release and release.announce is "${announce}". Nothing to generate.`,
|
|
5559
|
+
facts,
|
|
5560
|
+
copy: deterministicCopy(facts, config.release?.maxHighlights, brand.name),
|
|
5561
|
+
copyMode: "deterministic",
|
|
5562
|
+
warnings: [],
|
|
5563
|
+
exitCode: 0
|
|
5564
|
+
};
|
|
4927
5565
|
const noCopy = flags.copy === false;
|
|
4928
|
-
const resolved = await resolveCopy(facts, config, noCopy, deterministicCopy(facts, config.release?.maxHighlights));
|
|
5566
|
+
const resolved = await resolveCopy(facts, config, noCopy, deterministicCopy(facts, config.release?.maxHighlights, brand.name));
|
|
4929
5567
|
if (flags.dryRun === true) {
|
|
4930
5568
|
const dry = {
|
|
4931
5569
|
dryRun: true,
|
|
@@ -4977,6 +5615,691 @@ function overlayReleaseConfig(config, flags) {
|
|
|
4977
5615
|
});
|
|
4978
5616
|
}
|
|
4979
5617
|
|
|
5618
|
+
//#endregion
|
|
5619
|
+
//#region src/core/story.ts
|
|
5620
|
+
const DEFAULT_CHANGE_PAGES = 4;
|
|
5621
|
+
const STORY_FORMATS$1 = "portrait, square, og, github-social, x, linkedin, or producthunt";
|
|
5622
|
+
function storyFacts(facts, config, brand, generatedAt) {
|
|
5623
|
+
const release = facts.release;
|
|
5624
|
+
if (release === void 0) throw new ShipsealError("story.no-release", "No release facts are available for a story.", "Choose a published release or pass --tag to a local repository.");
|
|
5625
|
+
const copy = deterministicCopy(facts, config.release?.maxHighlights, brand.name);
|
|
5626
|
+
const configured = (value, ref) => fact(value, {
|
|
5627
|
+
source: "user-config",
|
|
5628
|
+
ref,
|
|
5629
|
+
fetchedAt: generatedAt
|
|
5630
|
+
});
|
|
5631
|
+
const changes = [
|
|
5632
|
+
...release.breaking,
|
|
5633
|
+
...release.features,
|
|
5634
|
+
...release.fixes
|
|
5635
|
+
];
|
|
5636
|
+
const headlineSource = release.headline ?? changes.find((item) => cleanLine(firstSentence(item.value)) === copy.headline) ?? release.version;
|
|
5637
|
+
const pages = [{
|
|
5638
|
+
kind: "cover",
|
|
5639
|
+
title: fact(copy.headline, headlineSource.provenance),
|
|
5640
|
+
body: release.subheadline ?? facts.project.tagline ?? (brand.tagline === void 0 ? fact("Release notes", release.tag.provenance) : fact(brand.tagline, {
|
|
5641
|
+
source: "user-config",
|
|
5642
|
+
ref: "brand.json#tagline",
|
|
5643
|
+
fetchedAt: generatedAt
|
|
5644
|
+
}))
|
|
5645
|
+
}];
|
|
5646
|
+
for (const item of changes.slice(0, config.release?.maxHighlights ?? DEFAULT_CHANGE_PAGES)) {
|
|
5647
|
+
const collapsed = item.value.replaceAll(/\s+/g, " ").trim();
|
|
5648
|
+
const sentence = firstSentence(collapsed);
|
|
5649
|
+
const remainder = collapsed.slice(sentence.length).trim();
|
|
5650
|
+
pages.push({
|
|
5651
|
+
kind: "change",
|
|
5652
|
+
title: fact(cleanLine(sentence), item.provenance),
|
|
5653
|
+
body: fact(remainder.length > 0 ? cleanLine(remainder) : "", item.provenance)
|
|
5654
|
+
});
|
|
5655
|
+
}
|
|
5656
|
+
if (release.codeSnippet !== void 0) pages.push({
|
|
5657
|
+
kind: "code",
|
|
5658
|
+
title: fact("Code example", release.codeSnippet.provenance),
|
|
5659
|
+
body: fact(release.codeSnippet.value.code, release.codeSnippet.provenance)
|
|
5660
|
+
});
|
|
5661
|
+
const options = config.release?.story;
|
|
5662
|
+
if (options?.before !== void 0 && options.after !== void 0) pages.push({
|
|
5663
|
+
kind: "comparison",
|
|
5664
|
+
title: configured("Before and after", "release.story.before + release.story.after"),
|
|
5665
|
+
body: configured("Supplied screenshots", "release.story"),
|
|
5666
|
+
before: configured(options.before, "release.story.before"),
|
|
5667
|
+
after: configured(options.after, "release.story.after")
|
|
5668
|
+
});
|
|
5669
|
+
const hasUpgrade = options?.upgrade !== void 0 && options.upgrade.trim().length > 0;
|
|
5670
|
+
let upgrade;
|
|
5671
|
+
if (hasUpgrade && options?.upgrade !== void 0) upgrade = configured(options.upgrade.trim(), "release.story.upgrade");
|
|
5672
|
+
else {
|
|
5673
|
+
const repo = facts.project.repo;
|
|
5674
|
+
upgrade = repo === void 0 ? fact(copy.cta, facts.project.url?.provenance ?? facts.project.name.provenance) : fact(`Read the release notes at github.com/${repo.value}/releases/tag/${encodeURIComponent(release.tag.value)}`, repo.provenance);
|
|
5675
|
+
}
|
|
5676
|
+
pages.push({
|
|
5677
|
+
kind: "upgrade",
|
|
5678
|
+
title: fact(hasUpgrade ? "How to upgrade" : "Get the release", upgrade.provenance),
|
|
5679
|
+
body: upgrade
|
|
5680
|
+
});
|
|
5681
|
+
return {
|
|
5682
|
+
...facts,
|
|
5683
|
+
story: pages
|
|
5684
|
+
};
|
|
5685
|
+
}
|
|
5686
|
+
async function generateStory(input) {
|
|
5687
|
+
const facts = storyFacts(input.facts, input.config, input.brand, input.generatedAt);
|
|
5688
|
+
const pages = facts.story ?? [];
|
|
5689
|
+
const result = {
|
|
5690
|
+
files: [],
|
|
5691
|
+
warnings: [],
|
|
5692
|
+
missing: [],
|
|
5693
|
+
facts,
|
|
5694
|
+
copy: input.copy,
|
|
5695
|
+
copyMode: "deterministic",
|
|
5696
|
+
generatedAt: input.generatedAt,
|
|
5697
|
+
computed: {}
|
|
5698
|
+
};
|
|
5699
|
+
const formats = input.config.formats ?? ["portrait"];
|
|
5700
|
+
if (formats.length === 0 || formats.includes("readme-banner")) throw new ShipsealError("story.bad-format", "The story format selection contains no supported output or includes readme-banner.", `Use ${STORY_FORMATS$1}.`);
|
|
5701
|
+
for (const [index, page] of pages.entries()) {
|
|
5702
|
+
const rendered = await generate({
|
|
5703
|
+
...input,
|
|
5704
|
+
facts: {
|
|
5705
|
+
...facts,
|
|
5706
|
+
story: [page]
|
|
5707
|
+
},
|
|
5708
|
+
config: {
|
|
5709
|
+
...input.config,
|
|
5710
|
+
formats,
|
|
5711
|
+
release: {
|
|
5712
|
+
...input.config.release,
|
|
5713
|
+
templates: ["story-page"]
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
5716
|
+
});
|
|
5717
|
+
const id = `story-${String(index + 1).padStart(2, "0")}-${page.kind}`;
|
|
5718
|
+
result.files.push(...rendered.files.map((file) => ({
|
|
5719
|
+
...file,
|
|
5720
|
+
fileName: file.fileName.replace("story-page", id)
|
|
5721
|
+
})));
|
|
5722
|
+
result.warnings.push(...rendered.warnings.map((warning) => ({
|
|
5723
|
+
...warning,
|
|
5724
|
+
slot: `page-${String(index + 1)}.${warning.slot}`
|
|
5725
|
+
})));
|
|
5726
|
+
result.missing.push(...rendered.missing);
|
|
5727
|
+
result.computed[`story.pages[${String(index)}]`] = {
|
|
5728
|
+
value: {
|
|
5729
|
+
kind: page.kind,
|
|
5730
|
+
files: result.files.filter((file) => file.fileName.startsWith(id)).map((file) => file.fileName)
|
|
5731
|
+
},
|
|
5732
|
+
computedFrom: [`story[${String(index)}].title`, `story[${String(index)}].body`]
|
|
5733
|
+
};
|
|
5734
|
+
}
|
|
5735
|
+
if (facts.release?.codeSnippet === void 0) result.missing.push({
|
|
5736
|
+
template: "story-page",
|
|
5737
|
+
fact: "release.codeSnippet",
|
|
5738
|
+
effect: "code page omitted"
|
|
5739
|
+
});
|
|
5740
|
+
if (pages.every((page) => page.kind !== "change")) result.missing.push({
|
|
5741
|
+
template: "story-page",
|
|
5742
|
+
fact: "release.features",
|
|
5743
|
+
effect: "change pages omitted because release notes have no changes"
|
|
5744
|
+
});
|
|
5745
|
+
return result;
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5748
|
+
//#endregion
|
|
5749
|
+
//#region src/outputs/downloads.ts
|
|
5750
|
+
const encode = (value) => new TextEncoder().encode(value);
|
|
5751
|
+
const isJpeg = (bytes) => bytes[0] === 255 && bytes[1] === 216;
|
|
5752
|
+
function joinBytes(parts) {
|
|
5753
|
+
const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
5754
|
+
let offset = 0;
|
|
5755
|
+
for (const part of parts) {
|
|
5756
|
+
result.set(part, offset);
|
|
5757
|
+
offset += part.length;
|
|
5758
|
+
}
|
|
5759
|
+
return result;
|
|
5760
|
+
}
|
|
5761
|
+
/**
|
|
5762
|
+
* Image-only PDF: each JPEG becomes one page as a DCTDecode stream, so the page is the exact
|
|
5763
|
+
* rendered layout rather than a reflow of it. Pages are placed at half the pixel size, which
|
|
5764
|
+
* puts a 1080x1350 render on a 540x675pt page at 144dpi.
|
|
5765
|
+
*
|
|
5766
|
+
* Object numbering: 1 is the catalog, 2 the page tree, then each page contributes three
|
|
5767
|
+
* objects (page, image, content stream) at 3 + index * 3.
|
|
5768
|
+
*/
|
|
5769
|
+
function carouselPdf(pages) {
|
|
5770
|
+
if (pages.length === 0 || pages.some((page) => !isJpeg(page.bytes))) throw new ShipsealError("pdf.invalid-pages", "A carousel requires nonempty JPEG pages.", "Render the story as JPEG before building the PDF.");
|
|
5771
|
+
const objects = [encode("<< /Type /Catalog /Pages 2 0 R >>"), encode(`<< /Type /Pages /Count ${String(pages.length)} /Kids [${pages.map((_, index) => `${String(3 + index * 3)} 0 R`).join(" ")}] >>`)];
|
|
5772
|
+
for (const [index, page] of pages.entries()) {
|
|
5773
|
+
const id = 3 + index * 3;
|
|
5774
|
+
const width = page.width / 2;
|
|
5775
|
+
const height = page.height / 2;
|
|
5776
|
+
objects.push(encode(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${String(width)} ${String(height)}] /Resources << /XObject << /Image ${String(id + 1)} 0 R >> >> /Contents ${String(id + 2)} 0 R >>`));
|
|
5777
|
+
objects.push(joinBytes([
|
|
5778
|
+
encode(`<< /Type /XObject /Subtype /Image /Width ${String(page.width)} /Height ${String(page.height)} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${String(page.bytes.length)} >>\nstream\n`),
|
|
5779
|
+
page.bytes,
|
|
5780
|
+
encode("\nendstream")
|
|
5781
|
+
]));
|
|
5782
|
+
const content = `q ${String(width)} 0 0 ${String(height)} 0 0 cm /Image Do Q`;
|
|
5783
|
+
objects.push(encode(`<< /Length ${String(encode(content).length)} >>\nstream\n${content}\nendstream`));
|
|
5784
|
+
}
|
|
5785
|
+
const header = encode("%PDF-1.4\n%Shipseal\n");
|
|
5786
|
+
const parts = [header];
|
|
5787
|
+
const offsets = [];
|
|
5788
|
+
let position = header.length;
|
|
5789
|
+
for (const [index, object] of objects.entries()) {
|
|
5790
|
+
offsets.push(position);
|
|
5791
|
+
const bytes = joinBytes([
|
|
5792
|
+
encode(`${String(index + 1)} 0 obj\n`),
|
|
5793
|
+
object,
|
|
5794
|
+
encode("\nendobj\n")
|
|
5795
|
+
]);
|
|
5796
|
+
parts.push(bytes);
|
|
5797
|
+
position += bytes.length;
|
|
5798
|
+
}
|
|
5799
|
+
const size = objects.length + 1;
|
|
5800
|
+
parts.push(encode(`xref\n0 ${String(size)}\n0000000000 65535 f \n` + offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("") + `trailer\n<< /Size ${String(size)} /Root 1 0 R >>\nstartxref\n${String(position)}\n%%EOF\n`));
|
|
5801
|
+
return joinBytes(parts);
|
|
5802
|
+
}
|
|
5803
|
+
function crc32(bytes) {
|
|
5804
|
+
let value = 4294967295;
|
|
5805
|
+
for (const byte of bytes) {
|
|
5806
|
+
value ^= byte;
|
|
5807
|
+
for (let bit = 0; bit < 8; bit += 1) value = value >>> 1 ^ ((value & 1) === 1 ? 3988292384 : 0);
|
|
5808
|
+
}
|
|
5809
|
+
return (value ^ 4294967295) >>> 0;
|
|
5810
|
+
}
|
|
5811
|
+
/**
|
|
5812
|
+
* ZIP with STORE and no compression: PNG and JPEG are already compressed, so deflating them
|
|
5813
|
+
* costs time and saves nothing. Every timestamp is the fixed DOS date 1980-01-01 (0x0021) so
|
|
5814
|
+
* the same inputs always produce the same archive bytes.
|
|
5815
|
+
*/
|
|
5816
|
+
function zipFiles(files) {
|
|
5817
|
+
const FIXED_DOS_DATE = 33;
|
|
5818
|
+
const names = /* @__PURE__ */ new Set();
|
|
5819
|
+
const local = [];
|
|
5820
|
+
const central = [];
|
|
5821
|
+
let offset = 0;
|
|
5822
|
+
for (const file of files) {
|
|
5823
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(file.name) || names.has(file.name)) throw new ShipsealError("zip.bad-name", "An archive file name is unsafe or duplicated.", "Use unique file names without path separators.");
|
|
5824
|
+
names.add(file.name);
|
|
5825
|
+
const name = encode(file.name);
|
|
5826
|
+
const checksum = crc32(file.bytes);
|
|
5827
|
+
const header = /* @__PURE__ */ new Uint8Array(30);
|
|
5828
|
+
const headerView = new DataView(header.buffer);
|
|
5829
|
+
headerView.setUint32(0, 67324752, true);
|
|
5830
|
+
headerView.setUint16(4, 20, true);
|
|
5831
|
+
headerView.setUint16(12, FIXED_DOS_DATE, true);
|
|
5832
|
+
headerView.setUint32(14, checksum, true);
|
|
5833
|
+
headerView.setUint32(18, file.bytes.length, true);
|
|
5834
|
+
headerView.setUint32(22, file.bytes.length, true);
|
|
5835
|
+
headerView.setUint16(26, name.length, true);
|
|
5836
|
+
local.push(header, name, file.bytes);
|
|
5837
|
+
const entry = /* @__PURE__ */ new Uint8Array(46);
|
|
5838
|
+
const entryView = new DataView(entry.buffer);
|
|
5839
|
+
entryView.setUint32(0, 33639248, true);
|
|
5840
|
+
entryView.setUint16(4, 20, true);
|
|
5841
|
+
entryView.setUint16(6, 20, true);
|
|
5842
|
+
entryView.setUint16(14, FIXED_DOS_DATE, true);
|
|
5843
|
+
entryView.setUint32(16, checksum, true);
|
|
5844
|
+
entryView.setUint32(20, file.bytes.length, true);
|
|
5845
|
+
entryView.setUint32(24, file.bytes.length, true);
|
|
5846
|
+
entryView.setUint16(28, name.length, true);
|
|
5847
|
+
entryView.setUint32(42, offset, true);
|
|
5848
|
+
central.push(entry, name);
|
|
5849
|
+
offset += header.length + name.length + file.bytes.length;
|
|
5850
|
+
}
|
|
5851
|
+
const end = /* @__PURE__ */ new Uint8Array(22);
|
|
5852
|
+
const endView = new DataView(end.buffer);
|
|
5853
|
+
endView.setUint32(0, 101010256, true);
|
|
5854
|
+
endView.setUint16(8, files.length, true);
|
|
5855
|
+
endView.setUint16(10, files.length, true);
|
|
5856
|
+
endView.setUint32(12, central.reduce((sum, part) => sum + part.length, 0), true);
|
|
5857
|
+
endView.setUint32(16, offset, true);
|
|
5858
|
+
return joinBytes([
|
|
5859
|
+
...local,
|
|
5860
|
+
...central,
|
|
5861
|
+
end
|
|
5862
|
+
]);
|
|
5863
|
+
}
|
|
5864
|
+
|
|
5865
|
+
//#endregion
|
|
5866
|
+
//#region src/studio/pack.ts
|
|
5867
|
+
/** Every format the `story-page` template declares, in the order the selector lists them. */
|
|
5868
|
+
const STORY_FORMATS = [
|
|
5869
|
+
"portrait",
|
|
5870
|
+
"square",
|
|
5871
|
+
"og",
|
|
5872
|
+
"github-social",
|
|
5873
|
+
"x",
|
|
5874
|
+
"linkedin",
|
|
5875
|
+
"producthunt"
|
|
5876
|
+
];
|
|
5877
|
+
const encodeJson = (value) => new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`);
|
|
5878
|
+
const selectionSchema = z.object({
|
|
5879
|
+
pack: z.enum(["story", "release"]).default("story"),
|
|
5880
|
+
style: brandSchema.shape.style,
|
|
5881
|
+
theme: z.enum(["dark", "light"]),
|
|
5882
|
+
accent: brandSchema.shape.colors.shape.primary,
|
|
5883
|
+
headline: z.string().max(500).default(""),
|
|
5884
|
+
upgrade: z.string().max(4e3).default(""),
|
|
5885
|
+
format: z.enum([...STORY_FORMATS, "all"]).default("portrait")
|
|
5886
|
+
}).strict();
|
|
5887
|
+
function selectedConfig(config, selection) {
|
|
5888
|
+
const formats = selection.format === "all" ? selection.pack === "story" ? [...STORY_FORMATS] : [...V1_FORMAT_IDS] : [selection.format];
|
|
5889
|
+
const landscape = new Set(V1_FORMAT_IDS);
|
|
5890
|
+
if (selection.pack === "release" && formats.some((format) => !landscape.has(format))) throw new ShipsealError("preview.format", "The standard release templates do not support this format.", "Choose a landscape format or switch to a story pack.");
|
|
5891
|
+
return {
|
|
5892
|
+
...config,
|
|
5893
|
+
formats,
|
|
5894
|
+
release: {
|
|
5895
|
+
...config.release,
|
|
5896
|
+
headline: selection.headline || null,
|
|
5897
|
+
story: {
|
|
5898
|
+
...config.release?.story,
|
|
5899
|
+
upgrade: selection.upgrade
|
|
5900
|
+
}
|
|
5901
|
+
},
|
|
5902
|
+
output: { imageFormat: "png" }
|
|
5903
|
+
};
|
|
5904
|
+
}
|
|
5905
|
+
function selectedBrand(brand, selection) {
|
|
5906
|
+
return {
|
|
5907
|
+
...brand,
|
|
5908
|
+
style: selection.style,
|
|
5909
|
+
colors: {
|
|
5910
|
+
...brand.colors,
|
|
5911
|
+
primary: selection.accent
|
|
5912
|
+
}
|
|
5913
|
+
};
|
|
5914
|
+
}
|
|
5915
|
+
async function buildStudioPack(input) {
|
|
5916
|
+
const release = input.facts.release;
|
|
5917
|
+
if (release === void 0) throw new ShipsealError("preview.no-release", "No release is selected.", "Load a repository release first.");
|
|
5918
|
+
const config = selectedConfig(input.config, input.selection);
|
|
5919
|
+
const brand = selectedBrand(input.brand, input.selection);
|
|
5920
|
+
const facts = {
|
|
5921
|
+
...input.facts,
|
|
5922
|
+
release: { ...release }
|
|
5923
|
+
};
|
|
5924
|
+
if (input.selection.headline) facts.release = {
|
|
5925
|
+
...release,
|
|
5926
|
+
headline: fact(input.selection.headline, {
|
|
5927
|
+
source: "user-config",
|
|
5928
|
+
ref: "release.headline",
|
|
5929
|
+
fetchedAt: input.generatedAt
|
|
5930
|
+
})
|
|
5931
|
+
};
|
|
5932
|
+
const event = {
|
|
5933
|
+
kind: "release",
|
|
5934
|
+
tag: release.tag.value
|
|
5935
|
+
};
|
|
5936
|
+
const request = {
|
|
5937
|
+
event,
|
|
5938
|
+
facts,
|
|
5939
|
+
brand,
|
|
5940
|
+
config,
|
|
5941
|
+
copy: deterministicCopy(facts, config.release?.maxHighlights, brand.name),
|
|
5942
|
+
copyMode: "deterministic",
|
|
5943
|
+
renderer: input.renderer,
|
|
5944
|
+
themes: [input.selection.theme],
|
|
5945
|
+
generatedAt: input.generatedAt
|
|
5946
|
+
};
|
|
5947
|
+
if (input.logos !== void 0) request.logos = input.logos;
|
|
5948
|
+
if (input.images !== void 0) request.images = input.images;
|
|
5949
|
+
const result = await (input.selection.pack === "story" ? generateStory : generate)(request);
|
|
5950
|
+
const manifest = buildManifest({
|
|
5951
|
+
result,
|
|
5952
|
+
event,
|
|
5953
|
+
brand,
|
|
5954
|
+
shipsealVersion: input.version
|
|
5955
|
+
});
|
|
5956
|
+
manifest.brand.source = input.brandSource ?? ".shipseal/brand.json + preview selections";
|
|
5957
|
+
const downloads = result.files.map((file) => ({
|
|
5958
|
+
name: file.fileName,
|
|
5959
|
+
bytes: file.bytes
|
|
5960
|
+
}));
|
|
5961
|
+
if (input.selection.pack === "story") {
|
|
5962
|
+
const pdfFormat = config.formats?.[0] ?? "portrait";
|
|
5963
|
+
const jpeg = await generateStory({
|
|
5964
|
+
...request,
|
|
5965
|
+
config: {
|
|
5966
|
+
...config,
|
|
5967
|
+
formats: [pdfFormat],
|
|
5968
|
+
output: { imageFormat: "jpeg" }
|
|
5969
|
+
}
|
|
5970
|
+
});
|
|
5971
|
+
const pdf = carouselPdf(jpeg.files);
|
|
5972
|
+
downloads.push({
|
|
5973
|
+
name: "story.pdf",
|
|
5974
|
+
bytes: pdf
|
|
5975
|
+
});
|
|
5976
|
+
manifest.computed["story.pdf"] = {
|
|
5977
|
+
value: {
|
|
5978
|
+
format: pdfFormat,
|
|
5979
|
+
pages: jpeg.files.length,
|
|
5980
|
+
bytes: pdf.length
|
|
5981
|
+
},
|
|
5982
|
+
computedFrom: ["story"]
|
|
5983
|
+
};
|
|
5984
|
+
}
|
|
5985
|
+
downloads.push({
|
|
5986
|
+
name: "brand.json",
|
|
5987
|
+
bytes: encodeJson(brand)
|
|
5988
|
+
}, {
|
|
5989
|
+
name: "config.json",
|
|
5990
|
+
bytes: encodeJson(config)
|
|
5991
|
+
}, {
|
|
5992
|
+
name: "manifest.json",
|
|
5993
|
+
bytes: encodeJson(manifest)
|
|
5994
|
+
});
|
|
5995
|
+
return {
|
|
5996
|
+
result,
|
|
5997
|
+
manifest,
|
|
5998
|
+
downloads,
|
|
5999
|
+
archive: zipFiles(downloads),
|
|
6000
|
+
brand,
|
|
6001
|
+
config
|
|
6002
|
+
};
|
|
6003
|
+
}
|
|
6004
|
+
|
|
6005
|
+
//#endregion
|
|
6006
|
+
//#region src/commands/story.ts
|
|
6007
|
+
const STORY_IMAGE_LABELS = ["before", "after"];
|
|
6008
|
+
const MAX_STORY_IMAGE_BYTES = 5e6;
|
|
6009
|
+
/**
|
|
6010
|
+
* Screenshots are supplied by the maintainer, never generated or captured here: no headless
|
|
6011
|
+
* browser is a hard rule (AGENTS.md rule 2).
|
|
6012
|
+
*/
|
|
6013
|
+
async function readStoryImage(cwd, label, path) {
|
|
6014
|
+
let data;
|
|
6015
|
+
try {
|
|
6016
|
+
data = await readFile(resolve(cwd, path));
|
|
6017
|
+
} catch (error) {
|
|
6018
|
+
throw new ShipsealError("story.image-missing", `Could not read the ${label} screenshot: ${path}.`, "Set release.story.before and release.story.after to existing PNG or JPEG files.", { cause: error });
|
|
6019
|
+
}
|
|
6020
|
+
const png = data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71;
|
|
6021
|
+
const jpeg = data[0] === 255 && data[1] === 216;
|
|
6022
|
+
if (!png && !jpeg || data.length > MAX_STORY_IMAGE_BYTES) throw new ShipsealError("story.image-invalid", `The ${label} screenshot is not a supported image or exceeds the size limit.`, "Use a PNG or JPEG smaller than 5 MB.");
|
|
6023
|
+
return {
|
|
6024
|
+
src: `story-${label}`,
|
|
6025
|
+
data
|
|
6026
|
+
};
|
|
6027
|
+
}
|
|
6028
|
+
async function loadStoryImages(cwd, config) {
|
|
6029
|
+
const configured = STORY_IMAGE_LABELS.flatMap((label) => {
|
|
6030
|
+
const path = config.release?.story?.[label];
|
|
6031
|
+
return path === void 0 ? [] : [{
|
|
6032
|
+
label,
|
|
6033
|
+
path
|
|
6034
|
+
}];
|
|
6035
|
+
});
|
|
6036
|
+
return Promise.all(configured.map((image) => readStoryImage(cwd, image.label, image.path)));
|
|
6037
|
+
}
|
|
6038
|
+
async function runStory(flags) {
|
|
6039
|
+
const brand = await loadBrand(flags.cwd);
|
|
6040
|
+
const config = await loadConfig(flags.cwd);
|
|
6041
|
+
const tag = flags.tag ?? await gitCurrentTag(flags.cwd);
|
|
6042
|
+
if (tag === void 0) throw new ShipsealError("story.no-tag", "No release tag was found.", "Pass --tag with an existing release tag.");
|
|
6043
|
+
const parsed = selectionSchema.safeParse({
|
|
6044
|
+
pack: "story",
|
|
6045
|
+
style: flags.style ?? brand.style,
|
|
6046
|
+
theme: flags.theme ?? brand.theme,
|
|
6047
|
+
accent: brand.colors.primary ?? "#ff4d4d",
|
|
6048
|
+
headline: flags.headline ?? config.release?.headline ?? "",
|
|
6049
|
+
upgrade: config.release?.story?.upgrade ?? "",
|
|
6050
|
+
format: flags.format ?? "portrait"
|
|
6051
|
+
});
|
|
6052
|
+
if (!parsed.success) {
|
|
6053
|
+
const fields = [...new Set(parsed.error.issues.map((issue) => issue.path.join(".")))];
|
|
6054
|
+
throw new ShipsealError("story.bad-options", `These story options are not valid: ${fields.join(", ")}.`, "Use --format portrait, square, og, github-social, x, linkedin, producthunt or all; --style minimal, editorial or terminal; --theme dark or light.", { cause: parsed.error });
|
|
6055
|
+
}
|
|
6056
|
+
const selection = parsed.data;
|
|
6057
|
+
const collectOptions = {
|
|
6058
|
+
cwd: flags.cwd,
|
|
6059
|
+
event: {
|
|
6060
|
+
kind: "release",
|
|
6061
|
+
tag
|
|
6062
|
+
},
|
|
6063
|
+
skipNetwork: true
|
|
6064
|
+
};
|
|
6065
|
+
if (flags.package !== void 0) collectOptions.packagePath = flags.package;
|
|
6066
|
+
if (config.release?.changelogPath !== void 0) collectOptions.changelogPath = config.release.changelogPath;
|
|
6067
|
+
if (config.release?.snippet !== void 0) collectOptions.snippet = config.release.snippet;
|
|
6068
|
+
const input = {
|
|
6069
|
+
facts: await collectFacts(collectOptions),
|
|
6070
|
+
brand,
|
|
6071
|
+
config,
|
|
6072
|
+
selection,
|
|
6073
|
+
renderer: await createTakumiRenderer(),
|
|
6074
|
+
version: readShipsealVersion(),
|
|
6075
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6076
|
+
images: await loadStoryImages(flags.cwd, config),
|
|
6077
|
+
brandSource: ".shipseal/brand.json + story flags"
|
|
6078
|
+
};
|
|
6079
|
+
const logos = await loadLogos(flags.cwd, brand);
|
|
6080
|
+
if (logos !== void 0) input.logos = logos;
|
|
6081
|
+
const pack = await buildStudioPack(input);
|
|
6082
|
+
const dir = resolve(flags.cwd, flags.out ?? config.outputDir ?? ".shipseal/output", `story-${tag.replaceAll(/[^a-zA-Z0-9_.-]/g, "-")}`);
|
|
6083
|
+
await mkdir(dir, { recursive: true });
|
|
6084
|
+
await Promise.all(pack.downloads.map((file) => writeFile(join(dir, file.name), file.bytes)));
|
|
6085
|
+
await writeFile(join(dir, "story.zip"), pack.archive);
|
|
6086
|
+
return {
|
|
6087
|
+
dir,
|
|
6088
|
+
manifest: pack.manifest,
|
|
6089
|
+
exitCode: flags.strict === true && pack.manifest.warnings.length > 0 ? 2 : 0
|
|
6090
|
+
};
|
|
6091
|
+
}
|
|
6092
|
+
|
|
6093
|
+
//#endregion
|
|
6094
|
+
//#region src/commands/preview.ts
|
|
6095
|
+
/** Localhost only, so the page is served inline rather than from a template file. */
|
|
6096
|
+
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
6097
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Shipseal preview</title>
|
|
6098
|
+
</head><body style="margin:0;background:#0c0c0f"><main id="studio"></main><script type="module">
|
|
6099
|
+
import { mountStudio } from '/studio-client.js';
|
|
6100
|
+
const token = new URL(location.href).searchParams.get('token');
|
|
6101
|
+
async function api(path, value) {
|
|
6102
|
+
const response = await fetch(path, {
|
|
6103
|
+
method: value === undefined ? 'GET' : 'POST',
|
|
6104
|
+
headers: { 'Content-Type': 'application/json', 'X-Shipseal-Token': token },
|
|
6105
|
+
...(value === undefined ? {} : { body: JSON.stringify(value) }),
|
|
6106
|
+
});
|
|
6107
|
+
const body = await response.json();
|
|
6108
|
+
if (!response.ok) throw new Error(body.error);
|
|
6109
|
+
return body;
|
|
6110
|
+
}
|
|
6111
|
+
mountStudio(document.getElementById('studio'), {
|
|
6112
|
+
mode: 'local',
|
|
6113
|
+
load: () => api('/api/state'),
|
|
6114
|
+
render: (value) => api('/api/render', value),
|
|
6115
|
+
save: (value) => api('/api/save', value),
|
|
6116
|
+
});
|
|
6117
|
+
<\/script></body></html>`;
|
|
6118
|
+
const PAGE_HEADERS = {
|
|
6119
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
6120
|
+
"Cache-Control": "no-store",
|
|
6121
|
+
"Referrer-Policy": "no-referrer",
|
|
6122
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'"
|
|
6123
|
+
};
|
|
6124
|
+
function json(response, status, value) {
|
|
6125
|
+
response.writeHead(status, {
|
|
6126
|
+
"Content-Type": "application/json",
|
|
6127
|
+
"Cache-Control": "no-store",
|
|
6128
|
+
"X-Content-Type-Options": "nosniff"
|
|
6129
|
+
});
|
|
6130
|
+
response.end(JSON.stringify(value));
|
|
6131
|
+
}
|
|
6132
|
+
async function readSelection(request) {
|
|
6133
|
+
let text = "";
|
|
6134
|
+
for await (const value of request) {
|
|
6135
|
+
if (!Buffer.isBuffer(value)) throw new ShipsealError("preview.body", "The request body is not valid bytes.", "Reload the preview.");
|
|
6136
|
+
text += value.toString("utf8");
|
|
6137
|
+
if (text.length > 16384) throw new ShipsealError("preview.body-size", "The preview request is too large.", "Shorten the headline or upgrade instructions.");
|
|
6138
|
+
}
|
|
6139
|
+
try {
|
|
6140
|
+
const raw = JSON.parse(text);
|
|
6141
|
+
return selectionSchema.parse(raw);
|
|
6142
|
+
} catch (error) {
|
|
6143
|
+
throw new ShipsealError("preview.selection", "The preview options are invalid.", "Reload the preview and choose supported options.", { cause: error });
|
|
6144
|
+
}
|
|
6145
|
+
}
|
|
6146
|
+
/** Write through a temporary file so an interrupted save never truncates the original. */
|
|
6147
|
+
async function writeAtomic(destination, value) {
|
|
6148
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
6149
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
6150
|
+
await rename(temporary, destination);
|
|
6151
|
+
}
|
|
6152
|
+
async function startPreview(options) {
|
|
6153
|
+
const resolvedTag = options.tag ?? await gitCurrentTag(options.cwd);
|
|
6154
|
+
if (resolvedTag === void 0) throw new ShipsealError("preview.no-tag", "No release tag was found.", "Pass --tag with an existing release tag.");
|
|
6155
|
+
const tag = resolvedTag;
|
|
6156
|
+
await loadBrand(options.cwd);
|
|
6157
|
+
const token = randomUUID();
|
|
6158
|
+
let origin = "";
|
|
6159
|
+
async function handle(request, response) {
|
|
6160
|
+
if (request.headers.host !== new URL(origin).host) {
|
|
6161
|
+
json(response, 403, { error: "Invalid preview host. Open the localhost URL printed by Shipseal." });
|
|
6162
|
+
return;
|
|
6163
|
+
}
|
|
6164
|
+
const url = new URL(request.url ?? "/", origin);
|
|
6165
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
6166
|
+
response.writeHead(200, PAGE_HEADERS);
|
|
6167
|
+
response.end(PAGE);
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
if (request.method === "GET" && url.pathname === "/studio-client.js") {
|
|
6171
|
+
const script = await readFile(join(resolvePackageRoot(), "dist", "studio-client.js"));
|
|
6172
|
+
response.writeHead(200, {
|
|
6173
|
+
"Content-Type": "text/javascript",
|
|
6174
|
+
"Cache-Control": "no-store"
|
|
6175
|
+
});
|
|
6176
|
+
response.end(script);
|
|
6177
|
+
return;
|
|
6178
|
+
}
|
|
6179
|
+
if (request.headers["x-shipseal-token"] !== token || request.method === "POST" && request.headers.origin !== origin) {
|
|
6180
|
+
json(response, 403, { error: "This preview request is not authorized. Reload the localhost URL printed by Shipseal." });
|
|
6181
|
+
return;
|
|
6182
|
+
}
|
|
6183
|
+
const brand = await loadBrand(options.cwd);
|
|
6184
|
+
const config = await loadConfig(options.cwd);
|
|
6185
|
+
const collect = {
|
|
6186
|
+
cwd: options.cwd,
|
|
6187
|
+
event: {
|
|
6188
|
+
kind: "release",
|
|
6189
|
+
tag
|
|
6190
|
+
},
|
|
6191
|
+
skipNetwork: true
|
|
6192
|
+
};
|
|
6193
|
+
if (options.package !== void 0) collect.packagePath = options.package;
|
|
6194
|
+
if (config.release?.changelogPath !== void 0) collect.changelogPath = config.release.changelogPath;
|
|
6195
|
+
if (config.release?.snippet !== void 0) collect.snippet = config.release.snippet;
|
|
6196
|
+
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
6197
|
+
const facts = await collectFacts(collect);
|
|
6198
|
+
json(response, 200, {
|
|
6199
|
+
name: brand.name,
|
|
6200
|
+
style: brand.style,
|
|
6201
|
+
theme: brand.theme,
|
|
6202
|
+
accent: brand.colors.primary ?? "#ff4d4d",
|
|
6203
|
+
headline: config.release?.headline ?? "",
|
|
6204
|
+
upgrade: config.release?.story?.upgrade ?? "",
|
|
6205
|
+
headlines: [...facts.release?.breaking ?? [], ...facts.release?.features ?? []].map((item) => cleanLine(firstSentence(item.value))),
|
|
6206
|
+
notes: ["Local facts come from your checkout. Change files and refresh this page to reload them. Screenshots use release.story.before and release.story.after in config.json."],
|
|
6207
|
+
releases: [{
|
|
6208
|
+
tag,
|
|
6209
|
+
label: tag
|
|
6210
|
+
}],
|
|
6211
|
+
tag
|
|
6212
|
+
});
|
|
6213
|
+
return;
|
|
6214
|
+
}
|
|
6215
|
+
if (request.method !== "POST") {
|
|
6216
|
+
json(response, 404, { error: "Preview route not found." });
|
|
6217
|
+
return;
|
|
6218
|
+
}
|
|
6219
|
+
const selection = await readSelection(request);
|
|
6220
|
+
if (url.pathname === "/api/save") {
|
|
6221
|
+
const nextConfig = selectedConfig(config, selection);
|
|
6222
|
+
nextConfig.formats = config.formats;
|
|
6223
|
+
await Promise.all([writeAtomic(join(options.cwd, ".shipseal", "brand.json"), selectedBrand(brand, selection)), writeAtomic(join(options.cwd, ".shipseal", "config.json"), nextConfig)]);
|
|
6224
|
+
json(response, 200, { saved: true });
|
|
6225
|
+
return;
|
|
6226
|
+
}
|
|
6227
|
+
if (url.pathname !== "/api/render") {
|
|
6228
|
+
json(response, 404, { error: "Preview route not found." });
|
|
6229
|
+
return;
|
|
6230
|
+
}
|
|
6231
|
+
const packInput = {
|
|
6232
|
+
facts: await collectFacts(collect),
|
|
6233
|
+
brand,
|
|
6234
|
+
config,
|
|
6235
|
+
selection,
|
|
6236
|
+
renderer: await createTakumiRenderer(),
|
|
6237
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6238
|
+
version: readShipsealVersion(),
|
|
6239
|
+
images: await loadStoryImages(options.cwd, config)
|
|
6240
|
+
};
|
|
6241
|
+
const logos = await loadLogos(options.cwd, brand);
|
|
6242
|
+
if (logos !== void 0) packInput.logos = logos;
|
|
6243
|
+
const pack = await buildStudioPack(packInput);
|
|
6244
|
+
const output = {
|
|
6245
|
+
files: pack.result.files.map((file) => ({
|
|
6246
|
+
name: file.fileName,
|
|
6247
|
+
data: Buffer.from(file.bytes).toString("base64"),
|
|
6248
|
+
width: file.width,
|
|
6249
|
+
height: file.height
|
|
6250
|
+
})),
|
|
6251
|
+
archive: Buffer.from(pack.archive).toString("base64"),
|
|
6252
|
+
manifest: pack.manifest
|
|
6253
|
+
};
|
|
6254
|
+
const pdf = pack.downloads.find((file) => file.name === "story.pdf");
|
|
6255
|
+
if (pdf !== void 0) output.pdf = Buffer.from(pdf.bytes).toString("base64");
|
|
6256
|
+
json(response, 200, output);
|
|
6257
|
+
}
|
|
6258
|
+
const server = createServer((request, response) => {
|
|
6259
|
+
handle(request, response).catch((error) => {
|
|
6260
|
+
json(response, 400, { error: error instanceof ShipsealError ? formatError(error) : `${error instanceof Error ? error.message : "Preview failed"}. Check the files and retry.` });
|
|
6261
|
+
});
|
|
6262
|
+
});
|
|
6263
|
+
await new Promise((accept, reject) => {
|
|
6264
|
+
server.once("error", reject);
|
|
6265
|
+
server.listen(options.port ?? 4175, "127.0.0.1", () => {
|
|
6266
|
+
server.removeListener("error", reject);
|
|
6267
|
+
accept();
|
|
6268
|
+
});
|
|
6269
|
+
});
|
|
6270
|
+
const address = server.address();
|
|
6271
|
+
if (address === null || typeof address === "string") throw new ShipsealError("preview.listen", "The preview server could not start.", "Choose another --port and retry.");
|
|
6272
|
+
origin = `http://127.0.0.1:${String(address.port)}`;
|
|
6273
|
+
return {
|
|
6274
|
+
server,
|
|
6275
|
+
url: `${origin}/?token=${token}`,
|
|
6276
|
+
token
|
|
6277
|
+
};
|
|
6278
|
+
}
|
|
6279
|
+
async function runPreview(options) {
|
|
6280
|
+
let preview;
|
|
6281
|
+
try {
|
|
6282
|
+
preview = await startPreview(options);
|
|
6283
|
+
} catch (error) {
|
|
6284
|
+
if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") throw new ShipsealError("preview.port", "The preview port is already in use.", "Pass --port with another available port.", { cause: error });
|
|
6285
|
+
throw error;
|
|
6286
|
+
}
|
|
6287
|
+
process.stdout.write(`Preview: ${preview.url}\nPress Ctrl+C to stop.\n`);
|
|
6288
|
+
await new Promise((accept) => {
|
|
6289
|
+
const stop = () => {
|
|
6290
|
+
preview.server.close();
|
|
6291
|
+
preview.server.closeAllConnections();
|
|
6292
|
+
};
|
|
6293
|
+
process.once("SIGINT", stop);
|
|
6294
|
+
process.once("SIGTERM", stop);
|
|
6295
|
+
preview.server.once("close", () => {
|
|
6296
|
+
process.removeListener("SIGINT", stop);
|
|
6297
|
+
process.removeListener("SIGTERM", stop);
|
|
6298
|
+
accept();
|
|
6299
|
+
});
|
|
6300
|
+
});
|
|
6301
|
+
}
|
|
6302
|
+
|
|
4980
6303
|
//#endregion
|
|
4981
6304
|
//#region src/cli.ts
|
|
4982
6305
|
async function runCli(argv = process.argv) {
|
|
@@ -5004,6 +6327,46 @@ async function runCli(argv = process.argv) {
|
|
|
5004
6327
|
for (const note of result.detection.notes) process.stdout.write(` note: ${note}\n`);
|
|
5005
6328
|
}
|
|
5006
6329
|
});
|
|
6330
|
+
cli.command("story", "Generate an ordered release story, PDF carousel and ZIP").option("--tag <tag>", "Release tag").option("--format <format>", "portrait, square, og, github-social, x, linkedin, producthunt, or all").option("--style <style>", "minimal, editorial, or terminal").option("--theme <theme>", "dark or light").option("--headline <text>", "Override the cover headline").option("--out <dir>", "Output directory").option("--package <path>", "package.json path for monorepos").option("--strict", "Fit warnings exit with code 2").option("--no-copy", "Use source text only (stories always use deterministic copy)").action(async (flags) => {
|
|
6331
|
+
const options = {
|
|
6332
|
+
cwd: stringFlag(flags.cwd, process.cwd()),
|
|
6333
|
+
strict: flags.strict === true
|
|
6334
|
+
};
|
|
6335
|
+
for (const key of [
|
|
6336
|
+
"tag",
|
|
6337
|
+
"format",
|
|
6338
|
+
"style",
|
|
6339
|
+
"theme",
|
|
6340
|
+
"headline",
|
|
6341
|
+
"out",
|
|
6342
|
+
"package"
|
|
6343
|
+
]) {
|
|
6344
|
+
const value = optionalString(flags[key]);
|
|
6345
|
+
if (value !== void 0) options[key] = value;
|
|
6346
|
+
}
|
|
6347
|
+
const result = await runStory(options);
|
|
6348
|
+
process.exitCode = result.exitCode;
|
|
6349
|
+
if (flags.json === true) writeJson(result);
|
|
6350
|
+
else if (flags.quiet !== true) writeHumanResult({
|
|
6351
|
+
...result,
|
|
6352
|
+
dryRun: false,
|
|
6353
|
+
facts: result.manifest.facts,
|
|
6354
|
+
warnings: result.manifest.warnings
|
|
6355
|
+
});
|
|
6356
|
+
});
|
|
6357
|
+
cli.command("preview", "Preview release and story packs locally, then save your choices").option("--tag <tag>", "Release tag").option("--port <port>", "Localhost port (default: 4175)").option("--package <path>", "package.json path for monorepos").action(async (flags) => {
|
|
6358
|
+
const options = { cwd: stringFlag(flags.cwd, process.cwd()) };
|
|
6359
|
+
for (const key of ["tag", "package"]) {
|
|
6360
|
+
const value = optionalString(flags[key]);
|
|
6361
|
+
if (value !== void 0) options[key] = value;
|
|
6362
|
+
}
|
|
6363
|
+
if (flags.port !== void 0) {
|
|
6364
|
+
const port = Number(flags.port);
|
|
6365
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ShipsealError("preview.port", "The preview port is invalid.", "Pass an integer from 0 through 65535.");
|
|
6366
|
+
options.port = port;
|
|
6367
|
+
}
|
|
6368
|
+
await runPreview(options);
|
|
6369
|
+
});
|
|
5007
6370
|
cli.command("doctor", "Check Node, git, brand, fonts, and Takumi").action(async (flags) => {
|
|
5008
6371
|
const result = await runDoctor(stringFlag(flags.cwd, process.cwd()));
|
|
5009
6372
|
if (flags.json === true) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -5014,7 +6377,7 @@ async function runCli(argv = process.argv) {
|
|
|
5014
6377
|
}
|
|
5015
6378
|
if (!result.ok) process.exitCode = 1;
|
|
5016
6379
|
});
|
|
5017
|
-
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) => {
|
|
6380
|
+
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("--headline <text>", "Headline for the cards, overriding the changelog").option("--subheadline <text>", "Subheadline for the cards").option("--upload", "Upload PNG files to the GitHub release").action(async (flags) => {
|
|
5018
6381
|
const releaseFlags = {
|
|
5019
6382
|
cwd: stringFlag(flags.cwd, process.cwd()),
|
|
5020
6383
|
strict: flags.strict === true,
|
|
@@ -5023,6 +6386,10 @@ async function runCli(argv = process.argv) {
|
|
|
5023
6386
|
applySharedFlags(releaseFlags, flags);
|
|
5024
6387
|
const tag = optionalString(flags.tag);
|
|
5025
6388
|
if (tag !== void 0) releaseFlags.tag = tag;
|
|
6389
|
+
const headline = optionalString(flags.headline);
|
|
6390
|
+
if (headline !== void 0) releaseFlags.headline = headline;
|
|
6391
|
+
const subheadline = optionalString(flags.subheadline);
|
|
6392
|
+
if (subheadline !== void 0) releaseFlags.subheadline = subheadline;
|
|
5026
6393
|
const from = optionalString(flags.from);
|
|
5027
6394
|
if (from !== void 0) releaseFlags.from = from;
|
|
5028
6395
|
const templates = optionalString(flags.templates);
|