shipseal 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,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.literal("minimal"),
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 headline = [
311
- ...breaking,
312
- ...features,
313
- ...fixes
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 = "") => {
@@ -396,7 +418,7 @@ function cleanLine(text) {
396
418
  }
397
419
  function ctaFor(facts) {
398
420
  const npm = facts.project.npmPackage?.value;
399
- if (npm !== void 0 && npm.length > 0) return `npm i ${npm}`;
421
+ if (npm !== void 0 && npm.length > 0) return facts.project.cli?.value === true ? `npx ${npm}@latest` : `npm i ${npm}`;
400
422
  const url = facts.project.url?.value ?? facts.project.repo?.value;
401
423
  if (url === void 0) return facts.project.name.value;
402
424
  return url.replace(/^https?:\/\//, "");
@@ -551,7 +573,9 @@ const jsxs = jsx;
551
573
 
552
574
  //#endregion
553
575
  //#region src/templates/code-card.tsx
554
- const propsSchema$4 = z.object({
576
+ /** Advance width of a monospace glyph as a fraction of the font size. */
577
+ const MONO_ADVANCE = .6;
578
+ const propsSchema$5 = z.object({
555
579
  headline: z.string(),
556
580
  headingFamily: z.string(),
557
581
  monoFamily: z.string(),
@@ -567,7 +591,7 @@ const codeCard = {
567
591
  id: "code-card",
568
592
  events: ["release"],
569
593
  formats: ["x", "linkedin"],
570
- propsSchema: propsSchema$4,
594
+ propsSchema: propsSchema$5,
571
595
  slots(format) {
572
596
  return { headline: {
573
597
  maxLines: 1,
@@ -578,10 +602,10 @@ const codeCard = {
578
602
  } };
579
603
  },
580
604
  slotText(raw) {
581
- return { headline: propsSchema$4.parse(raw).headline };
605
+ return { headline: propsSchema$5.parse(raw).headline };
582
606
  },
583
607
  slotFont(raw, slot) {
584
- const props = propsSchema$4.parse(raw);
608
+ const props = propsSchema$5.parse(raw);
585
609
  if (slot === "headline") return {
586
610
  family: props.headingFamily,
587
611
  weight: props.headingWeight,
@@ -615,10 +639,19 @@ const codeCard = {
615
639
  };
616
640
  },
617
641
  render(raw, ctx) {
618
- const props = propsSchema$4.parse(raw);
642
+ const props = propsSchema$5.parse(raw);
619
643
  const colors = themeColors(ctx.brand, ctx.theme);
620
644
  const pad = ctx.format.safeZone;
621
645
  const headline = ctx.fitted.headline;
646
+ const boxPad = 28;
647
+ const lineHeight = 1.35;
648
+ const lines = props.lines.length === 0 ? 1 : props.lines.length;
649
+ const longest = Math.max(1, ...props.lines.map((line) => line.reduce((total, token) => total + token.text.length, 0)));
650
+ const headlineHeight = (headline?.fontSize ?? 36) * 1.25;
651
+ const attributionHeight = ctx.attribution ? 49.2 : 0;
652
+ const innerWidth = ctx.format.width - 2 * pad - 56;
653
+ const innerHeight = ctx.format.height - 2 * pad - headlineHeight - 24 - attributionHeight - 56;
654
+ const codeSize = Math.max(18, Math.min(44, Math.floor(Math.min(innerWidth / (longest * MONO_ADVANCE), innerHeight / (lines * lineHeight)))));
622
655
  return /* @__PURE__ */ jsxs("div", {
623
656
  style: {
624
657
  display: "flex",
@@ -644,29 +677,36 @@ const codeCard = {
644
677
  display: "flex",
645
678
  flexDirection: "column",
646
679
  flexGrow: 1,
647
- backgroundColor: colors.card,
648
- borderRadius: props.radius,
649
- padding: 28,
650
- gap: 0
680
+ justifyContent: "center"
651
681
  },
652
- children: props.lines.map((line, lineIndex) => /* @__PURE__ */ jsx("div", {
682
+ children: /* @__PURE__ */ jsx("div", {
653
683
  style: {
654
684
  display: "flex",
655
- flexDirection: "row",
656
- flexWrap: "nowrap"
685
+ flexDirection: "column",
686
+ backgroundColor: colors.card,
687
+ borderRadius: props.radius,
688
+ padding: boxPad,
689
+ gap: 0
657
690
  },
658
- children: line.map((token, tokenIndex) => /* @__PURE__ */ jsx("div", {
691
+ children: props.lines.map((line, lineIndex) => /* @__PURE__ */ jsx("div", {
659
692
  style: {
660
- fontFamily: props.monoFamily,
661
- fontWeight: props.monoWeight,
662
- fontSize: 22,
663
- color: token.color,
664
- whiteSpace: "pre",
665
- lineHeight: 1.35
693
+ display: "flex",
694
+ flexDirection: "row",
695
+ flexWrap: "nowrap"
666
696
  },
667
- children: token.text
668
- }, `${String(lineIndex)}-${String(tokenIndex)}`))
669
- }, String(lineIndex)))
697
+ children: line.map((token, tokenIndex) => /* @__PURE__ */ jsx("div", {
698
+ style: {
699
+ fontFamily: props.monoFamily,
700
+ fontWeight: props.monoWeight,
701
+ fontSize: codeSize,
702
+ color: token.color,
703
+ whiteSpace: "pre",
704
+ lineHeight
705
+ },
706
+ children: token.text
707
+ }, `${String(lineIndex)}-${String(tokenIndex)}`))
708
+ }, String(lineIndex)))
709
+ })
670
710
  }),
671
711
  ctx.attribution ? /* @__PURE__ */ jsx("div", {
672
712
  style: {
@@ -735,7 +775,7 @@ const rowSchema = z.object({
735
775
  changeText: z.string(),
736
776
  regression: z.boolean()
737
777
  });
738
- const propsSchema$3 = z.object({
778
+ const propsSchema$4 = z.object({
739
779
  title: z.string(),
740
780
  note: z.string(),
741
781
  rows: z.array(rowSchema),
@@ -749,7 +789,7 @@ const bench = {
749
789
  id: "bench",
750
790
  events: ["bench"],
751
791
  formats: ["x", "linkedin"],
752
- propsSchema: propsSchema$3,
792
+ propsSchema: propsSchema$4,
753
793
  slots(format) {
754
794
  const width = format.width - 2 * format.safeZone;
755
795
  const slots = { title: {
@@ -769,7 +809,7 @@ const bench = {
769
809
  return slots;
770
810
  },
771
811
  slotText(raw) {
772
- const props = propsSchema$3.parse(raw);
812
+ const props = propsSchema$4.parse(raw);
773
813
  const text = { title: props.title };
774
814
  props.rows.forEach((row, index) => {
775
815
  text[`change-${String(index)}`] = row.changeText;
@@ -777,7 +817,7 @@ const bench = {
777
817
  return text;
778
818
  },
779
819
  slotFont(raw, slot) {
780
- const props = propsSchema$3.parse(raw);
820
+ const props = propsSchema$4.parse(raw);
781
821
  if (slot === "title") return {
782
822
  family: props.headingFamily,
783
823
  weight: props.headingWeight,
@@ -820,7 +860,7 @@ const bench = {
820
860
  };
821
861
  },
822
862
  render(raw, ctx) {
823
- const props = propsSchema$3.parse(raw);
863
+ const props = propsSchema$4.parse(raw);
824
864
  const colors = themeColors(ctx.brand, ctx.theme);
825
865
  const pad = ctx.format.safeZone;
826
866
  const title = ctx.fitted.title;
@@ -952,7 +992,7 @@ function formatMetric(value) {
952
992
 
953
993
  //#endregion
954
994
  //#region src/templates/milestone.tsx
955
- const propsSchema$2 = z.object({
995
+ const propsSchema$3 = z.object({
956
996
  name: z.string(),
957
997
  numberText: z.string(),
958
998
  metricLabel: z.string(),
@@ -972,7 +1012,7 @@ const milestone = {
972
1012
  "x",
973
1013
  "linkedin"
974
1014
  ],
975
- propsSchema: propsSchema$2,
1015
+ propsSchema: propsSchema$3,
976
1016
  slots(format) {
977
1017
  const width = format.width - 2 * format.safeZone;
978
1018
  return {
@@ -993,14 +1033,14 @@ const milestone = {
993
1033
  };
994
1034
  },
995
1035
  slotText(raw) {
996
- const props = propsSchema$2.parse(raw);
1036
+ const props = propsSchema$3.parse(raw);
997
1037
  return {
998
1038
  number: props.numberText,
999
1039
  thankYou: props.thankYou
1000
1040
  };
1001
1041
  },
1002
1042
  slotFont(raw, slot) {
1003
- const props = propsSchema$2.parse(raw);
1043
+ const props = propsSchema$3.parse(raw);
1004
1044
  if (slot === "number") return {
1005
1045
  family: props.headingFamily,
1006
1046
  weight: props.headingWeight,
@@ -1044,7 +1084,7 @@ const milestone = {
1044
1084
  };
1045
1085
  },
1046
1086
  render(raw, ctx) {
1047
- const props = propsSchema$2.parse(raw);
1087
+ const props = propsSchema$3.parse(raw);
1048
1088
  const colors = themeColors(ctx.brand, ctx.theme);
1049
1089
  const pad = ctx.format.safeZone;
1050
1090
  const number = ctx.fitted.number;
@@ -1138,9 +1178,39 @@ const milestone = {
1138
1178
  }
1139
1179
  };
1140
1180
 
1181
+ //#endregion
1182
+ //#region src/templates/primitives/seal.ts
1183
+ /**
1184
+ * The core promise, made visible on the card.
1185
+ *
1186
+ * "sealed · 7 commits · 2026-09-11" turns "every number has a source" from a claim on the
1187
+ * website into a mark in the feed. Every segment is a fact, so rule 1 holds: a missing fact
1188
+ * drops its segment and is reported, it never becomes a placeholder or an estimate.
1189
+ */
1190
+ function sealStamp(facts) {
1191
+ const segments = ["sealed"];
1192
+ const missing = [];
1193
+ const commits = facts.release?.commitCount?.value;
1194
+ if (commits === void 0) missing.push({
1195
+ fact: "release.commitCount",
1196
+ effect: "seal stamp omits the commit count"
1197
+ });
1198
+ else segments.push(`${formatCount$1(commits)} ${commits === 1 ? "commit" : "commits"}`);
1199
+ const date = facts.release?.date?.value;
1200
+ if (date === void 0) missing.push({
1201
+ fact: "release.date",
1202
+ effect: "seal stamp omits the date"
1203
+ });
1204
+ else segments.push(date);
1205
+ return {
1206
+ segments,
1207
+ missing
1208
+ };
1209
+ }
1210
+
1141
1211
  //#endregion
1142
1212
  //#region src/templates/release-hero.tsx
1143
- const propsSchema$1 = z.object({
1213
+ const propsSchema$2 = z.object({
1144
1214
  name: z.string(),
1145
1215
  version: z.string(),
1146
1216
  headline: z.string(),
@@ -1152,7 +1222,8 @@ const propsSchema$1 = z.object({
1152
1222
  bodyWeight: z.number(),
1153
1223
  radius: z.number(),
1154
1224
  showCta: z.boolean(),
1155
- showLogo: z.boolean()
1225
+ showLogo: z.boolean(),
1226
+ seal: z.array(z.string())
1156
1227
  });
1157
1228
  const releaseHero = {
1158
1229
  id: "release-hero",
@@ -1163,7 +1234,7 @@ const releaseHero = {
1163
1234
  "x",
1164
1235
  "linkedin"
1165
1236
  ],
1166
- propsSchema: propsSchema$1,
1237
+ propsSchema: propsSchema$2,
1167
1238
  slots(format) {
1168
1239
  const width = format.width - 2 * format.safeZone;
1169
1240
  return {
@@ -1191,7 +1262,7 @@ const releaseHero = {
1191
1262
  };
1192
1263
  },
1193
1264
  slotText(raw) {
1194
- const props = propsSchema$1.parse(raw);
1265
+ const props = propsSchema$2.parse(raw);
1195
1266
  return {
1196
1267
  headline: props.headline,
1197
1268
  subheadline: props.subheadline,
@@ -1199,7 +1270,7 @@ const releaseHero = {
1199
1270
  };
1200
1271
  },
1201
1272
  slotFont(raw, slot) {
1202
- const props = propsSchema$1.parse(raw);
1273
+ const props = propsSchema$2.parse(raw);
1203
1274
  if (slot === "headline") return {
1204
1275
  family: props.headingFamily,
1205
1276
  weight: props.headingWeight,
@@ -1222,6 +1293,8 @@ const releaseHero = {
1222
1293
  fact: "brand.logo",
1223
1294
  effect: "logo hidden"
1224
1295
  });
1296
+ const seal = sealStamp(facts);
1297
+ missing.push(...seal.missing);
1225
1298
  const version = facts.release?.version.value ?? "";
1226
1299
  return {
1227
1300
  props: {
@@ -1236,13 +1309,14 @@ const releaseHero = {
1236
1309
  bodyWeight: brand.fonts.body.weight,
1237
1310
  radius: brand.radius,
1238
1311
  showCta: copy.cta.length > 0,
1312
+ seal: seal.segments,
1239
1313
  showLogo
1240
1314
  },
1241
1315
  missing
1242
1316
  };
1243
1317
  },
1244
1318
  render(raw, ctx) {
1245
- const props = propsSchema$1.parse(raw);
1319
+ const props = propsSchema$2.parse(raw);
1246
1320
  const colors = themeColors(ctx.brand, ctx.theme);
1247
1321
  const pad = ctx.format.safeZone;
1248
1322
  const headline = ctx.fitted.headline;
@@ -1257,9 +1331,24 @@ const releaseHero = {
1257
1331
  width: "100%",
1258
1332
  height: "100%",
1259
1333
  backgroundColor: colors.background,
1260
- padding: pad
1334
+ padding: pad,
1335
+ position: "relative"
1261
1336
  },
1262
1337
  children: [
1338
+ props.showLogo && ctx.logoSrc !== void 0 ? /* @__PURE__ */ jsx("div", {
1339
+ style: {
1340
+ display: "flex",
1341
+ position: "absolute",
1342
+ right: -Math.round(ctx.format.height * .12),
1343
+ top: Math.round(ctx.format.height * .18),
1344
+ opacity: .06
1345
+ },
1346
+ children: /* @__PURE__ */ jsx("img", {
1347
+ src: ctx.logoSrc,
1348
+ width: Math.round(ctx.format.height * .72),
1349
+ height: Math.round(ctx.format.height * .72)
1350
+ })
1351
+ }) : void 0,
1263
1352
  /* @__PURE__ */ jsxs("div", {
1264
1353
  style: {
1265
1354
  display: "flex",
@@ -1301,7 +1390,7 @@ const releaseHero = {
1301
1390
  fontFamily: props.bodyFamily,
1302
1391
  fontWeight: props.bodyWeight,
1303
1392
  fontSize: 22,
1304
- color: colors.primary
1393
+ color: colors.foreground
1305
1394
  },
1306
1395
  children: `v${props.version}`
1307
1396
  })
@@ -1360,15 +1449,31 @@ const releaseHero = {
1360
1449
  },
1361
1450
  children: cta?.text ?? props.cta
1362
1451
  })
1363
- }) : /* @__PURE__ */ jsx("div", {}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
1452
+ }) : /* @__PURE__ */ jsx("div", {}), /* @__PURE__ */ jsxs("div", {
1364
1453
  style: {
1365
- fontFamily: props.bodyFamily,
1366
- fontWeight: props.bodyWeight,
1367
- fontSize: 18,
1368
- color: colors.muted
1454
+ display: "flex",
1455
+ flexDirection: "column",
1456
+ alignItems: "flex-end",
1457
+ gap: 6
1369
1458
  },
1370
- children: "made with shipseal.dev"
1371
- }) : void 0]
1459
+ children: [/* @__PURE__ */ jsx("div", {
1460
+ style: {
1461
+ fontFamily: props.bodyFamily,
1462
+ fontWeight: props.headingWeight,
1463
+ fontSize: 20,
1464
+ color: colors.primary
1465
+ },
1466
+ children: props.seal.join(" · ")
1467
+ }), ctx.attribution ? /* @__PURE__ */ jsx("div", {
1468
+ style: {
1469
+ fontFamily: props.bodyFamily,
1470
+ fontWeight: props.bodyWeight,
1471
+ fontSize: 18,
1472
+ color: colors.muted
1473
+ },
1474
+ children: "made with shipseal.dev"
1475
+ }) : void 0]
1476
+ })]
1372
1477
  })
1373
1478
  ]
1374
1479
  });
@@ -1377,7 +1482,7 @@ const releaseHero = {
1377
1482
 
1378
1483
  //#endregion
1379
1484
  //#region src/templates/release-highlights.tsx
1380
- const propsSchema = z.object({
1485
+ const propsSchema$1 = z.object({
1381
1486
  name: z.string(),
1382
1487
  version: z.string(),
1383
1488
  highlights: z.array(z.string()),
@@ -1391,7 +1496,7 @@ const releaseHighlights = {
1391
1496
  id: "release-highlights",
1392
1497
  events: ["release"],
1393
1498
  formats: ["x", "linkedin"],
1394
- propsSchema,
1499
+ propsSchema: propsSchema$1,
1395
1500
  slots(format) {
1396
1501
  const width = format.width - 2 * format.safeZone - 48;
1397
1502
  const slots = {};
@@ -1405,7 +1510,7 @@ const releaseHighlights = {
1405
1510
  return slots;
1406
1511
  },
1407
1512
  slotText(raw) {
1408
- const props = propsSchema.parse(raw);
1513
+ const props = propsSchema$1.parse(raw);
1409
1514
  const text = {};
1410
1515
  props.highlights.forEach((line, index) => {
1411
1516
  text[`highlight-${String(index)}`] = line;
@@ -1413,7 +1518,7 @@ const releaseHighlights = {
1413
1518
  return text;
1414
1519
  },
1415
1520
  slotFont(raw) {
1416
- const props = propsSchema.parse(raw);
1521
+ const props = propsSchema$1.parse(raw);
1417
1522
  return {
1418
1523
  family: props.bodyFamily,
1419
1524
  weight: props.bodyWeight,
@@ -1441,7 +1546,7 @@ const releaseHighlights = {
1441
1546
  };
1442
1547
  },
1443
1548
  render(raw, ctx) {
1444
- const props = propsSchema.parse(raw);
1549
+ const props = propsSchema$1.parse(raw);
1445
1550
  const colors = themeColors(ctx.brand, ctx.theme);
1446
1551
  const pad = ctx.format.safeZone;
1447
1552
  const title = props.version.length > 0 ? `What's new in v${props.version}` : "What's new";
@@ -1535,6 +1640,404 @@ const releaseHighlights = {
1535
1640
  }
1536
1641
  };
1537
1642
 
1643
+ //#endregion
1644
+ //#region src/facts/schema.ts
1645
+ const SOURCE_IDS = [
1646
+ "git",
1647
+ "package-json",
1648
+ "readme",
1649
+ "changelog",
1650
+ "github-api",
1651
+ "npm-api",
1652
+ "bench-file",
1653
+ "user-config",
1654
+ "brand"
1655
+ ];
1656
+ const sourceIdSchema = z.enum(SOURCE_IDS);
1657
+ const provenanceSchema = z.object({
1658
+ source: sourceIdSchema,
1659
+ ref: z.string().min(1),
1660
+ fetchedAt: z.iso.datetime()
1661
+ });
1662
+ function factSchema(valueSchema) {
1663
+ return z.object({
1664
+ value: valueSchema,
1665
+ provenance: provenanceSchema
1666
+ });
1667
+ }
1668
+ const stringFact = factSchema(z.string());
1669
+ const numberFact = factSchema(z.number());
1670
+ const stringListFact = factSchema(z.array(z.string()));
1671
+ const codeSnippetFact = factSchema(z.object({
1672
+ code: z.string(),
1673
+ lang: z.string()
1674
+ }));
1675
+ const betterFact = factSchema(z.enum(["lower", "higher"]));
1676
+ const storyPageSchema = z.object({
1677
+ kind: z.enum([
1678
+ "cover",
1679
+ "change",
1680
+ "code",
1681
+ "comparison",
1682
+ "upgrade"
1683
+ ]),
1684
+ title: stringFact,
1685
+ body: stringFact,
1686
+ before: stringFact.optional(),
1687
+ after: stringFact.optional()
1688
+ });
1689
+ const factsSchema = z.object({
1690
+ story: z.array(storyPageSchema).optional(),
1691
+ project: z.object({
1692
+ name: stringFact,
1693
+ tagline: stringFact.optional(),
1694
+ url: stringFact.optional(),
1695
+ repo: stringFact.optional(),
1696
+ npmPackage: stringFact.optional(),
1697
+ cli: factSchema(z.boolean()).optional(),
1698
+ license: stringFact.optional()
1699
+ }),
1700
+ release: z.object({
1701
+ version: stringFact,
1702
+ tag: stringFact,
1703
+ kind: factSchema(z.enum([
1704
+ "major",
1705
+ "minor",
1706
+ "patch"
1707
+ ])).optional(),
1708
+ headline: stringFact.optional(),
1709
+ subheadline: stringFact.optional(),
1710
+ previousVersion: stringFact.optional(),
1711
+ date: stringFact,
1712
+ features: z.array(stringFact),
1713
+ fixes: z.array(stringFact),
1714
+ breaking: z.array(stringFact),
1715
+ commitCount: numberFact.optional(),
1716
+ contributors: stringListFact.optional(),
1717
+ codeSnippet: codeSnippetFact.optional()
1718
+ }).optional(),
1719
+ metrics: z.object({
1720
+ stars: numberFact.optional(),
1721
+ weeklyDownloads: numberFact.optional(),
1722
+ contributorCount: numberFact.optional()
1723
+ }).optional(),
1724
+ bench: z.object({
1725
+ title: stringFact,
1726
+ metrics: z.array(z.object({
1727
+ label: stringFact,
1728
+ before: numberFact,
1729
+ after: numberFact,
1730
+ unit: stringFact,
1731
+ better: betterFact
1732
+ })),
1733
+ note: stringFact.optional()
1734
+ }).optional(),
1735
+ milestone: z.object({
1736
+ metric: stringFact,
1737
+ threshold: numberFact
1738
+ }).optional()
1739
+ });
1740
+
1741
+ //#endregion
1742
+ //#region src/templates/story-page.tsx
1743
+ const propsSchema = z.object({
1744
+ page: storyPageSchema,
1745
+ name: z.string(),
1746
+ version: z.string(),
1747
+ heading: z.string(),
1748
+ body: z.string(),
1749
+ mono: z.string(),
1750
+ weight: z.number()
1751
+ });
1752
+ const HEADER_HEIGHT = 36;
1753
+ const BODY_PAD = 24;
1754
+ const CODE_LINE_HEIGHT = 1.35;
1755
+ const CODE_MIN_FONT_SIZE = 14;
1756
+ const FOOTER_ALLOWANCE = 144;
1757
+ const TALL_THRESHOLD = 900;
1758
+ function geometry(format) {
1759
+ const tall = format.height > TALL_THRESHOLD;
1760
+ const titleHeight = tall ? 220 : 112;
1761
+ return {
1762
+ tall,
1763
+ titleHeight,
1764
+ width: format.width - 2 * format.safeZone,
1765
+ bodyHeight: format.height - 2 * format.safeZone - titleHeight - FOOTER_ALLOWANCE
1766
+ };
1767
+ }
1768
+ const NO_BORDER = "0px solid transparent";
1769
+ /**
1770
+ * Monospace `pre` text does not wrap, so the generic line-count fitter cannot keep it inside
1771
+ * the card: it reports two lines that fit and the long one runs off the right edge. Mono
1772
+ * glyphs are a constant fraction of the em, so the widest line gives a size directly. Same
1773
+ * approach as code-card, which is why MONO_ADVANCE is shared rather than copied.
1774
+ */
1775
+ function codeFontSize(text, box, max) {
1776
+ const lines = text.split("\n");
1777
+ const longest = Math.max(1, ...lines.map((line) => line.length));
1778
+ const byWidth = box.width / (longest * MONO_ADVANCE);
1779
+ const byHeight = box.height / (lines.length * CODE_LINE_HEIGHT);
1780
+ return Math.max(CODE_MIN_FONT_SIZE, Math.min(max, Math.floor(Math.min(byWidth, byHeight))));
1781
+ }
1782
+ /**
1783
+ * A body the reader is meant to copy and run: a code snippet, or upgrade instructions the
1784
+ * maintainer supplied. Generated upgrade prose (a link to the release notes) is not one, so
1785
+ * the provenance decides rather than the page kind alone.
1786
+ */
1787
+ function isCommand(page) {
1788
+ return page.kind === "code" || page.kind === "upgrade" && page.body.provenance.source === "user-config";
1789
+ }
1790
+ const storyPage = {
1791
+ id: "story-page",
1792
+ events: ["release"],
1793
+ formats: [
1794
+ "og",
1795
+ "github-social",
1796
+ "x",
1797
+ "linkedin",
1798
+ "square",
1799
+ "portrait",
1800
+ "producthunt"
1801
+ ],
1802
+ propsSchema,
1803
+ slots(format) {
1804
+ const { width, titleHeight, bodyHeight, tall } = geometry(format);
1805
+ return {
1806
+ name: {
1807
+ maxLines: 1,
1808
+ maxFontSize: 24,
1809
+ minFontSize: 18,
1810
+ step: 2,
1811
+ box: { width: width - 300 }
1812
+ },
1813
+ version: {
1814
+ maxLines: 1,
1815
+ maxFontSize: 22,
1816
+ minFontSize: 16,
1817
+ step: 2,
1818
+ box: { width: 220 }
1819
+ },
1820
+ title: {
1821
+ maxLines: tall ? 3 : 2,
1822
+ maxFontSize: tall ? 64 : 48,
1823
+ minFontSize: 28,
1824
+ step: 2,
1825
+ box: {
1826
+ width: width - 32,
1827
+ height: titleHeight
1828
+ }
1829
+ },
1830
+ body: {
1831
+ maxLines: Math.floor(bodyHeight / 30),
1832
+ maxFontSize: tall ? 34 : 28,
1833
+ minFontSize: 20,
1834
+ step: 2,
1835
+ box: {
1836
+ width: width - 48,
1837
+ height: bodyHeight - 48
1838
+ }
1839
+ }
1840
+ };
1841
+ },
1842
+ slotText(raw) {
1843
+ const props = propsSchema.parse(raw);
1844
+ return {
1845
+ name: props.name,
1846
+ version: props.version,
1847
+ title: props.page.title.value,
1848
+ body: props.page.body.value
1849
+ };
1850
+ },
1851
+ slotFont(raw, slot) {
1852
+ const props = propsSchema.parse(raw);
1853
+ const mono = slot === "body" && isCommand(props.page);
1854
+ return {
1855
+ family: mono ? props.mono : slot === "title" ? props.heading : props.body,
1856
+ weight: slot === "title" ? props.weight : 400,
1857
+ lineHeight: mono ? 1.35 : 1.2,
1858
+ whiteSpace: mono ? "pre" : "normal"
1859
+ };
1860
+ },
1861
+ buildProps(facts, _copy, brand) {
1862
+ const page = facts.story?.[0];
1863
+ 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.");
1864
+ return {
1865
+ props: {
1866
+ page,
1867
+ name: brand.name,
1868
+ version: facts.release?.tag.value ?? "",
1869
+ heading: brand.style === "terminal" ? brand.fonts.mono.family : brand.fonts.heading.family,
1870
+ body: brand.fonts.body.family,
1871
+ mono: brand.fonts.mono.family,
1872
+ weight: brand.fonts.heading.weight
1873
+ },
1874
+ missing: []
1875
+ };
1876
+ },
1877
+ render(raw, ctx) {
1878
+ const props = propsSchema.parse(raw);
1879
+ const colors = themeColors(ctx.brand, ctx.theme);
1880
+ const { tall, titleHeight, bodyHeight, width } = geometry(ctx.format);
1881
+ const editorial = ctx.brand.style === "editorial";
1882
+ const terminal = ctx.brand.style === "terminal";
1883
+ const code = isCommand(props.page);
1884
+ const comparison = props.page.kind === "comparison";
1885
+ const title = ctx.fitted.title;
1886
+ const body = ctx.fitted.body;
1887
+ const hasBody = props.page.body.value.trim().length > 0;
1888
+ const bodyText = body?.text ?? props.page.body.value;
1889
+ const bodyFontSize = code ? codeFontSize(bodyText, {
1890
+ width: width - 48,
1891
+ height: bodyHeight - 48
1892
+ }, tall ? 34 : 28) : body?.fontSize ?? 28;
1893
+ const paneWidth = tall ? width : (width - 16) / 2;
1894
+ const paneHeight = tall ? (bodyHeight - 16) / 2 : bodyHeight;
1895
+ return /* @__PURE__ */ jsxs("div", {
1896
+ style: {
1897
+ display: "flex",
1898
+ flexDirection: "column",
1899
+ width: "100%",
1900
+ height: "100%",
1901
+ backgroundColor: colors.background,
1902
+ color: colors.foreground,
1903
+ padding: ctx.format.safeZone,
1904
+ gap: 24,
1905
+ borderTop: editorial ? `16px solid ${colors.primary}` : NO_BORDER
1906
+ },
1907
+ children: [
1908
+ /* @__PURE__ */ jsxs("div", {
1909
+ style: {
1910
+ display: "flex",
1911
+ justifyContent: "space-between",
1912
+ alignItems: "center",
1913
+ height: HEADER_HEIGHT,
1914
+ borderBottom: terminal ? `1px solid ${colors.muted}` : NO_BORDER,
1915
+ paddingBottom: terminal ? 12 : 0
1916
+ },
1917
+ children: [/* @__PURE__ */ jsxs("div", {
1918
+ style: {
1919
+ display: "flex",
1920
+ gap: 12,
1921
+ alignItems: "center"
1922
+ },
1923
+ children: [ctx.logoSrc !== void 0 ? /* @__PURE__ */ jsx("img", {
1924
+ src: ctx.logoSrc,
1925
+ width: 32,
1926
+ height: 32
1927
+ }) : void 0, /* @__PURE__ */ jsx("div", {
1928
+ style: {
1929
+ fontFamily: terminal ? props.mono : props.body,
1930
+ fontWeight: 400,
1931
+ fontSize: ctx.fitted.name?.fontSize ?? 24,
1932
+ color: colors.primary
1933
+ },
1934
+ children: ctx.fitted.name?.text ?? props.name
1935
+ })]
1936
+ }), /* @__PURE__ */ jsx("div", {
1937
+ style: {
1938
+ fontFamily: props.mono,
1939
+ fontWeight: 400,
1940
+ fontSize: ctx.fitted.version?.fontSize ?? 22,
1941
+ color: colors.muted
1942
+ },
1943
+ children: ctx.fitted.version?.text ?? props.version
1944
+ })]
1945
+ }),
1946
+ /* @__PURE__ */ jsx("div", {
1947
+ style: {
1948
+ display: "flex",
1949
+ alignItems: "center",
1950
+ height: titleHeight,
1951
+ flexShrink: 0,
1952
+ paddingLeft: editorial ? 24 : 0,
1953
+ borderLeft: editorial ? `8px solid ${colors.primary}` : NO_BORDER
1954
+ },
1955
+ children: /* @__PURE__ */ jsx("div", {
1956
+ style: {
1957
+ fontFamily: props.heading,
1958
+ fontWeight: props.weight,
1959
+ fontSize: title?.fontSize ?? 48,
1960
+ lineHeight: 1.2,
1961
+ maxWidth: width - (editorial ? 32 : 0)
1962
+ },
1963
+ children: title?.text ?? props.page.title.value
1964
+ })
1965
+ }),
1966
+ comparison ? /* @__PURE__ */ jsx("div", {
1967
+ style: {
1968
+ display: "flex",
1969
+ flexDirection: tall ? "column" : "row",
1970
+ gap: 16,
1971
+ height: bodyHeight
1972
+ },
1973
+ children: ["before", "after"].map((label) => /* @__PURE__ */ jsxs("div", {
1974
+ style: {
1975
+ display: "flex",
1976
+ flexDirection: "column",
1977
+ gap: 8,
1978
+ width: paneWidth,
1979
+ height: paneHeight
1980
+ },
1981
+ children: [/* @__PURE__ */ jsx("div", {
1982
+ style: {
1983
+ fontFamily: props.body,
1984
+ fontSize: 20,
1985
+ color: colors.muted
1986
+ },
1987
+ children: label === "before" ? "Before" : "After"
1988
+ }), /* @__PURE__ */ jsx("img", {
1989
+ src: `story-${label}`,
1990
+ width: paneWidth,
1991
+ height: paneHeight - HEADER_HEIGHT,
1992
+ style: { objectFit: "contain" }
1993
+ })]
1994
+ }, label))
1995
+ }) : hasBody ? /* @__PURE__ */ jsx("div", {
1996
+ style: {
1997
+ display: "flex",
1998
+ flexDirection: "column",
1999
+ justifyContent: "flex-start",
2000
+ height: bodyHeight,
2001
+ padding: BODY_PAD,
2002
+ backgroundColor: terminal || code ? colors.card : "transparent",
2003
+ borderRadius: terminal ? 0 : ctx.brand.radius,
2004
+ borderLeft: terminal ? `3px solid ${colors.primary}` : NO_BORDER
2005
+ },
2006
+ children: /* @__PURE__ */ jsx("div", {
2007
+ style: {
2008
+ fontFamily: code ? props.mono : props.body,
2009
+ fontWeight: 400,
2010
+ fontSize: bodyFontSize,
2011
+ lineHeight: code ? CODE_LINE_HEIGHT : 1.2,
2012
+ whiteSpace: code ? "pre" : "normal",
2013
+ color: colors.foreground
2014
+ },
2015
+ children: bodyText
2016
+ })
2017
+ }) : void 0,
2018
+ /* @__PURE__ */ jsxs("div", {
2019
+ style: {
2020
+ display: "flex",
2021
+ justifyContent: "space-between",
2022
+ marginTop: "auto",
2023
+ fontFamily: props.mono,
2024
+ fontWeight: 400,
2025
+ fontSize: 16,
2026
+ color: colors.muted
2027
+ },
2028
+ children: [/* @__PURE__ */ jsx("div", {
2029
+ style: { fontFamily: props.mono },
2030
+ children: comparison ? "Supplied screenshots" : "Sources in manifest.json"
2031
+ }), ctx.attribution ? /* @__PURE__ */ jsx("div", {
2032
+ style: { fontFamily: props.mono },
2033
+ children: "made with shipseal.dev"
2034
+ }) : void 0]
2035
+ })
2036
+ ]
2037
+ });
2038
+ }
2039
+ };
2040
+
1538
2041
  //#endregion
1539
2042
  //#region src/templates/registry.ts
1540
2043
  const templates = [
@@ -1542,7 +2045,8 @@ const templates = [
1542
2045
  releaseHighlights,
1543
2046
  codeCard,
1544
2047
  milestone,
1545
- bench
2048
+ bench,
2049
+ storyPage
1546
2050
  ];
1547
2051
  function getTemplate(id) {
1548
2052
  const found = templates.find((template) => template.id === id);
@@ -1552,6 +2056,14 @@ function getTemplate(id) {
1552
2056
 
1553
2057
  //#endregion
1554
2058
  //#region src/core/generate.ts
2059
+ /**
2060
+ * Digest via WebCrypto, not `node:crypto`. The browser demo imports `generate()`, so nothing
2061
+ * in the pure core may reach for a Node builtin. Do not "simplify" this back to createHash.
2062
+ */
2063
+ async function sha256Hex(bytes) {
2064
+ const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes));
2065
+ return Array.from(new Uint8Array(digest)).map((value) => value.toString(16).padStart(2, "0")).join("");
2066
+ }
1555
2067
  async function generate(input) {
1556
2068
  const templateIds = templateIdsFor(input.event, input.config);
1557
2069
  const formatIds = input.config.formats ?? [
@@ -1613,8 +2125,10 @@ async function generate(input) {
1613
2125
  src: "shipseal-logo",
1614
2126
  data: logo
1615
2127
  }];
2128
+ if (input.images !== void 0) renderOpts.images = [...renderOpts.images ?? [], ...input.images];
1616
2129
  const bytes = await input.renderer.render(node, renderOpts);
1617
2130
  const fileName = outputName(template.id, formatId, theme, omitThemeSuffix, imageFormat);
2131
+ const sha256 = await sha256Hex(bytes);
1618
2132
  files.push({
1619
2133
  fileName,
1620
2134
  template: template.id,
@@ -1623,7 +2137,7 @@ async function generate(input) {
1623
2137
  width: format.width,
1624
2138
  height: format.height,
1625
2139
  bytes,
1626
- sha256: createHash("sha256").update(bytes).digest("hex")
2140
+ sha256
1627
2141
  });
1628
2142
  }
1629
2143
  }
@@ -1745,6 +2259,7 @@ function mergeFacts(parts) {
1745
2259
  if (project.url !== void 0) facts.project.url = project.url;
1746
2260
  if (project.repo !== void 0) facts.project.repo = project.repo;
1747
2261
  if (project.npmPackage !== void 0) facts.project.npmPackage = project.npmPackage;
2262
+ if (project.cli !== void 0) facts.project.cli = project.cli;
1748
2263
  if (project.license !== void 0) facts.project.license = project.license;
1749
2264
  if (isCompleteRelease(release)) {
1750
2265
  facts.release = {
@@ -1755,6 +2270,9 @@ function mergeFacts(parts) {
1755
2270
  fixes: release.fixes ?? [],
1756
2271
  breaking: release.breaking ?? []
1757
2272
  };
2273
+ if (release.kind !== void 0) facts.release.kind = release.kind;
2274
+ if (release.headline !== void 0) facts.release.headline = release.headline;
2275
+ if (release.subheadline !== void 0) facts.release.subheadline = release.subheadline;
1758
2276
  if (release.previousVersion !== void 0) facts.release.previousVersion = release.previousVersion;
1759
2277
  if (release.commitCount !== void 0) facts.release.commitCount = release.commitCount;
1760
2278
  if (release.contributors !== void 0) facts.release.contributors = release.contributors;
@@ -2028,6 +2546,17 @@ async function collectChangelog(cwd, version, changelogPath = "CHANGELOG.md") {
2028
2546
  fixes,
2029
2547
  breaking
2030
2548
  } };
2549
+ for (const slot of ["headline", "subheadline"]) {
2550
+ const text = new RegExp(`<!--\\s*shipseal:\\s*${slot}\\s+"([^"]+)"\\s*-->`).exec(section.body)?.[1]?.trim();
2551
+ if (text !== void 0 && text.length > 0) out.release = {
2552
+ ...out.release,
2553
+ [slot]: fact(text, {
2554
+ source: "user-config",
2555
+ ref: `${changelogPath} ${section.heading} shipseal:${slot} marker`,
2556
+ fetchedAt
2557
+ })
2558
+ };
2559
+ }
2031
2560
  const snippet = extractFirstCodeFence(section.body);
2032
2561
  if (snippet !== void 0) out.release = {
2033
2562
  ...out.release,
@@ -2071,7 +2600,7 @@ function findVersionSection(markdown, version) {
2071
2600
  }
2072
2601
  }
2073
2602
  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(/\.$/, "");
2603
+ 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
2604
  }
2076
2605
  function headingIncludesVersion(heading, version) {
2077
2606
  const unwrapped = heading.replace(/[[\]]/g, " ");
@@ -2201,11 +2730,19 @@ async function collectGit(cwd, event) {
2201
2730
  fetchedAt
2202
2731
  })
2203
2732
  };
2204
- if (previousTag !== void 0) release.previousVersion = fact(versionFromTag(previousTag), {
2205
- source: "git",
2206
- ref: `git tag ${previousTag}`,
2207
- fetchedAt
2208
- });
2733
+ if (previousTag !== void 0) {
2734
+ release.previousVersion = fact(versionFromTag(previousTag), {
2735
+ source: "git",
2736
+ ref: `git tag ${previousTag}`,
2737
+ fetchedAt
2738
+ });
2739
+ const kind = releaseKind(versionFromTag(previousTag), versionFromTag(tag));
2740
+ if (kind !== void 0) release.kind = fact(kind, {
2741
+ source: "git",
2742
+ ref: `semver ${previousTag}..${tag}`,
2743
+ fetchedAt
2744
+ });
2745
+ }
2209
2746
  if (names.length > 0) release.contributors = fact(names, {
2210
2747
  source: "git",
2211
2748
  ref: `git log ${range} unique authors`,
@@ -2294,6 +2831,30 @@ function githubSlug(url) {
2294
2831
  const cleaned = url.replace(/^git\+/, "").replace(/\.git$/, "");
2295
2832
  return /github\.com[/:]([^/]+\/[^/]+)$/.exec(cleaned)?.[1];
2296
2833
  }
2834
+ /**
2835
+ * How significant is this release, by semver alone?
2836
+ *
2837
+ * Compared against the previous tag rather than read from a changelog heading, so it is a fact
2838
+ * with a reference someone can re-run. Returns undefined when either tag is not semver, or when
2839
+ * the two are equal: a caller should treat "unknown" as "announce it", never as "skip it".
2840
+ */
2841
+ function releaseKind(previous, current) {
2842
+ const before = parseSemver(previous);
2843
+ const after = parseSemver(current);
2844
+ if (before === void 0 || after === void 0) return;
2845
+ if (after[0] !== before[0]) return "major";
2846
+ if (after[1] !== before[1]) return "minor";
2847
+ return after[2] === before[2] ? void 0 : "patch";
2848
+ }
2849
+ function parseSemver(value) {
2850
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.replace(/^v/, ""));
2851
+ if (match === null) return;
2852
+ return [
2853
+ Number(match[1]),
2854
+ Number(match[2]),
2855
+ Number(match[3])
2856
+ ];
2857
+ }
2297
2858
 
2298
2859
  //#endregion
2299
2860
  //#region src/sources/github.ts
@@ -2492,6 +3053,7 @@ async function npmPackageExists(name, fetchImpl = fetch) {
2492
3053
  const pkgSchema = z.object({
2493
3054
  private: z.boolean().optional(),
2494
3055
  name: z.string().optional(),
3056
+ bin: z.union([z.string(), z.record(z.string(), z.string())]).optional(),
2495
3057
  description: z.string().optional(),
2496
3058
  version: z.string().optional(),
2497
3059
  homepage: z.string().optional(),
@@ -2543,6 +3105,11 @@ async function collectPackageJson(cwd, packagePath = "package.json") {
2543
3105
  ref: `${packagePath}#name`,
2544
3106
  fetchedAt
2545
3107
  });
3108
+ if (pkg.data.bin !== void 0 && pkg.data.private !== true) project.cli = fact(true, {
3109
+ source: "package-json",
3110
+ ref: `${packagePath}#bin`,
3111
+ fetchedAt
3112
+ });
2546
3113
  if (pkg.data.license !== void 0) project.license = fact(pkg.data.license, {
2547
3114
  source: "package-json",
2548
3115
  ref: `${packagePath}#license`,
@@ -2597,9 +3164,23 @@ async function collectFacts(options) {
2597
3164
  const changelog = await collectBestChangelog(options.cwd, version, options.changelogPath ?? "CHANGELOG.md", options.packagePath);
2598
3165
  const readme = await collectReadme(options.cwd);
2599
3166
  const snippet = options.snippet !== void 0 && options.snippet !== null && options.snippet.length > 0 ? await collectConfiguredSnippet(options.cwd, options.snippet) : {};
3167
+ const workspacePkg = pkg.project?.npmPackage === void 0 ? await collectWorkspaceNpmPackage(options.cwd, options.skipNetwork === true ? {} : { verify: async (name) => npmPackageExists(name, options.fetchImpl ?? fetch) }) : {};
3168
+ const overrides = {};
3169
+ for (const slot of ["headline", "subheadline"]) {
3170
+ const text = options[slot]?.trim();
3171
+ if (text !== void 0 && text.length > 0) overrides.release = {
3172
+ ...overrides.release,
3173
+ [slot]: fact(text, {
3174
+ source: "user-config",
3175
+ ref: `--${slot} or release.${slot}`,
3176
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
3177
+ })
3178
+ };
3179
+ }
2600
3180
  const parts = [
3181
+ overrides,
2601
3182
  pkg,
2602
- pkg.project?.npmPackage === void 0 ? await collectWorkspaceNpmPackage(options.cwd, options.skipNetwork === true ? {} : { verify: async (name) => npmPackageExists(name, options.fetchImpl ?? fetch) }) : {},
3183
+ workspacePkg,
2603
3184
  remote,
2604
3185
  git,
2605
3186
  snippet,
@@ -2656,16 +3237,19 @@ async function collectWorkspaceNpmPackage(cwd, options = {}) {
2656
3237
  const paths = await workspaceManifests(cwd);
2657
3238
  const found = (await Promise.all(paths.map((path) => collectPackageJson(cwd, path)))).map((facts, index) => ({
2658
3239
  name: facts.project?.npmPackage?.value,
3240
+ cli: facts.project?.cli,
2659
3241
  path: paths[index] ?? ""
2660
3242
  })).filter((entry) => entry.name !== void 0);
2661
3243
  const only = found.length === 1 ? found[0] : void 0;
2662
3244
  if (only === void 0) return {};
2663
3245
  if (options.verify !== void 0 && await options.verify(only.name) === false) return {};
2664
- return { project: { npmPackage: fact(only.name, {
3246
+ const project = { npmPackage: fact(only.name, {
2665
3247
  source: "package-json",
2666
3248
  ref: `${only.path}#name`,
2667
3249
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2668
- }) } };
3250
+ }) };
3251
+ if (only.cli !== void 0) project.cli = only.cli;
3252
+ return { project };
2669
3253
  }
2670
3254
  async function workspaceManifests(cwd) {
2671
3255
  return (await workspaceChangelogs(cwd)).map((path) => path.replace(/CHANGELOG\.md$/, "package.json"));
@@ -2693,27 +3277,18 @@ function gitRepoFromParts(pkg, readme) {
2693
3277
  //#endregion
2694
3278
  //#region src/render/takumi.ts
2695
3279
  const GEIST_MONO_FAMILY = "Geist Mono";
2696
- const FONT_REL = join("assets", "fonts", "GeistMono[wght].ttf");
2697
- async function createTakumiRenderer() {
3280
+ /** Path of the vendored font, relative to the package root. Resolved in `takumi-node.ts`. */
3281
+ const FONT_REL = "assets/fonts/GeistMono[wght].ttf";
3282
+ /** Both runtimes end here: one adapter, one registered font, whoever supplied the bytes. */
3283
+ async function createRendererWithFont(font) {
2698
3284
  const renderer = new Renderer();
2699
- const fontPath = join(resolvePackageRoot(), FONT_REL);
2700
- const data = await readFile(fontPath);
2701
3285
  await renderer.registerFont({
2702
3286
  name: GEIST_MONO_FAMILY,
2703
- data,
3287
+ data: font,
2704
3288
  generic: "monospace"
2705
3289
  });
2706
3290
  return new TakumiRenderer(renderer);
2707
3291
  }
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
3292
  function countLines(node) {
2718
3293
  const ys = /* @__PURE__ */ new Set();
2719
3294
  const walk = (current) => {
@@ -2882,6 +3457,23 @@ function asReactElementLike(element) {
2882
3457
  };
2883
3458
  }
2884
3459
 
3460
+ //#endregion
3461
+ //#region src/render/takumi-node.ts
3462
+ async function createTakumiRenderer() {
3463
+ const fontPath = join(resolvePackageRoot(), FONT_REL);
3464
+ const data = await readFile(fontPath);
3465
+ return createRendererWithFont(data);
3466
+ }
3467
+ function resolvePackageRoot(from = import.meta.url) {
3468
+ let dir = dirname(fileURLToPath(from));
3469
+ for (;;) {
3470
+ if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "assets/fonts/GeistMono[wght].ttf"))) return dir;
3471
+ const parent = dirname(dir);
3472
+ 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.");
3473
+ dir = parent;
3474
+ }
3475
+ }
3476
+
2885
3477
  //#endregion
2886
3478
  //#region src/copy/number-guard.ts
2887
3479
  function allowedNumbers(facts) {
@@ -3112,6 +3704,7 @@ function flattenFacts(facts) {
3112
3704
  if (typeof value === "object" && value !== null) for (const [key, nested] of Object.entries(value)) walk(prefix.length === 0 ? key : `${prefix}.${key}`, nested);
3113
3705
  };
3114
3706
  walk("project", facts.project);
3707
+ if (facts.story !== void 0) walk("story", facts.story);
3115
3708
  if (facts.release !== void 0) walk("release", facts.release);
3116
3709
  if (facts.metrics !== void 0) walk("metrics", facts.metrics);
3117
3710
  if (facts.bench !== void 0) walk("bench", facts.bench);
@@ -3827,12 +4420,12 @@ function allVariables(css) {
3827
4420
  return vars;
3828
4421
  }
3829
4422
  /**
3830
- * Resolve a declaration to a hex colour.
4423
+ * Resolve a declaration to a hex color.
3831
4424
  *
3832
- * Handles two things a plain colour parser does not. First, `var(--brand-500)` indirection,
4425
+ * Handles two things a plain color parser does not. First, `var(--brand-500)` indirection,
3833
4426
  * up to three hops within the same file. Second, bare channel lists: shadcn writes
3834
4427
  * `--background: 0 0% 100%` and applies it as `hsl(var(--background))`, so the value is only
3835
- * a colour once wrapped. Percent signs on the last two channels mean HSL, three plain
4428
+ * a color once wrapped. Percent signs on the last two channels mean HSL, three plain
3836
4429
  * numbers mean RGB.
3837
4430
  */
3838
4431
  function resolveColor(raw, vars, depth = 0) {
@@ -3993,7 +4586,7 @@ const SIGNATURE = Buffer.from([
3993
4586
  26,
3994
4587
  10
3995
4588
  ]);
3996
- /** Channels per pixel for each PNG colour type. Index is the colour type. */
4589
+ /** Channels per pixel for each PNG color type. Index is the color type. */
3997
4590
  const CHANNELS = {
3998
4591
  0: 1,
3999
4592
  2: 3,
@@ -4143,10 +4736,10 @@ function toRgba(data, width, height, colorType, bytesPerSample, palette, palette
4143
4736
  return pixels;
4144
4737
  }
4145
4738
  /**
4146
- * Most common non-neutral colour in a logo, as a hex string.
4739
+ * Most common non-neutral color in a logo, as a hex string.
4147
4740
  *
4148
4741
  * 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 colour. Colours are bucketed
4742
+ * are ignored because a logo's black wordmark is not its brand color. Colors are bucketed
4150
4743
  * into 16 levels per channel so anti-aliased edges group with the solid fill they belong to,
4151
4744
  * then the winning bucket reports the average of the real pixels inside it rather than the
4152
4745
  * bucket centre, which would drift the hue.
@@ -4483,7 +5076,7 @@ async function detectColors(cwd, logoPath, sources, notes) {
4483
5076
  merged.primary = fromPng;
4484
5077
  sources.push({
4485
5078
  field: "colors.primary",
4486
- source: `dominant colour in ${logoPath}`
5079
+ source: `dominant color in ${logoPath}`
4487
5080
  });
4488
5081
  }
4489
5082
  const svg = logoPath.endsWith(".svg") ? await readMaybe(join(cwd, logoPath)) : void 0;
@@ -4625,8 +5218,8 @@ function normalizeGitUrl(url) {
4625
5218
  return url.replace(/^git\+/, "").replace(/\.git$/, "");
4626
5219
  }
4627
5220
  /**
4628
- * Dominant non-neutral colour of a PNG logo, or undefined when the file cannot be read or
4629
- * carries no colour. Detection reports the colour as not found rather than falling back to a
5221
+ * Dominant non-neutral color of a PNG logo, or undefined when the file cannot be read or
5222
+ * carries no color. Detection reports the color as not found rather than falling back to a
4630
5223
  * built-in default that would be presented to the user as "your brand".
4631
5224
  */
4632
5225
  async function dominantLogoColor(path) {
@@ -4898,6 +5491,12 @@ function missingMetric(metric) {
4898
5491
 
4899
5492
  //#endregion
4900
5493
  //#region src/commands/release.ts
5494
+ /** Lowest kind worth announcing, most significant first. */
5495
+ const ANNOUNCE_ORDER = [
5496
+ "major",
5497
+ "minor",
5498
+ "patch"
5499
+ ];
4901
5500
  async function runRelease(flags) {
4902
5501
  const cwd = flags.cwd;
4903
5502
  const config = overlayReleaseConfig(await loadConfig(cwd), flags);
@@ -4923,9 +5522,25 @@ async function runRelease(flags) {
4923
5522
  if (config.release?.snippet !== void 0) collectOpts.snippet = config.release.snippet;
4924
5523
  if (env.githubToken !== void 0) collectOpts.githubToken = env.githubToken;
4925
5524
  if (env.fetchImpl !== void 0) collectOpts.fetchImpl = env.fetchImpl;
5525
+ const headline = flags.headline ?? config.release?.headline ?? void 0;
5526
+ if (headline !== void 0) collectOpts.headline = headline;
5527
+ const subheadline = flags.subheadline ?? config.release?.subheadline ?? void 0;
5528
+ if (subheadline !== void 0) collectOpts.subheadline = subheadline;
4926
5529
  const facts = await collectFacts(collectOpts);
5530
+ const announce = config.release?.announce ?? "patch";
5531
+ const kind = facts.release?.kind?.value;
5532
+ if (kind !== void 0 && ANNOUNCE_ORDER.indexOf(kind) > ANNOUNCE_ORDER.indexOf(announce)) return {
5533
+ dryRun: flags.dryRun === true,
5534
+ skipped: true,
5535
+ message: `This is a ${kind} release and release.announce is "${announce}". Nothing to generate.`,
5536
+ facts,
5537
+ copy: deterministicCopy(facts, config.release?.maxHighlights, brand.name),
5538
+ copyMode: "deterministic",
5539
+ warnings: [],
5540
+ exitCode: 0
5541
+ };
4927
5542
  const noCopy = flags.copy === false;
4928
- const resolved = await resolveCopy(facts, config, noCopy, deterministicCopy(facts, config.release?.maxHighlights));
5543
+ const resolved = await resolveCopy(facts, config, noCopy, deterministicCopy(facts, config.release?.maxHighlights, brand.name));
4929
5544
  if (flags.dryRun === true) {
4930
5545
  const dry = {
4931
5546
  dryRun: true,
@@ -4977,6 +5592,687 @@ function overlayReleaseConfig(config, flags) {
4977
5592
  });
4978
5593
  }
4979
5594
 
5595
+ //#endregion
5596
+ //#region src/core/story.ts
5597
+ const DEFAULT_CHANGE_PAGES = 4;
5598
+ const STORY_FORMATS$1 = "portrait, square, og, github-social, x, linkedin, or producthunt";
5599
+ function storyFacts(facts, config, name, generatedAt) {
5600
+ const release = facts.release;
5601
+ 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.");
5602
+ const copy = deterministicCopy(facts, config.release?.maxHighlights, name);
5603
+ const configured = (value, ref) => fact(value, {
5604
+ source: "user-config",
5605
+ ref,
5606
+ fetchedAt: generatedAt
5607
+ });
5608
+ const changes = [
5609
+ ...release.breaking,
5610
+ ...release.features,
5611
+ ...release.fixes
5612
+ ];
5613
+ const headlineSource = release.headline ?? changes.find((item) => cleanLine(firstSentence(item.value)) === copy.headline) ?? release.version;
5614
+ const pages = [{
5615
+ kind: "cover",
5616
+ title: fact(copy.headline, headlineSource.provenance),
5617
+ body: release.subheadline ?? facts.project.tagline ?? fact("Release notes", release.tag.provenance)
5618
+ }];
5619
+ for (const item of changes.slice(0, config.release?.maxHighlights ?? DEFAULT_CHANGE_PAGES)) {
5620
+ const collapsed = item.value.replaceAll(/\s+/g, " ").trim();
5621
+ const sentence = firstSentence(collapsed);
5622
+ const remainder = collapsed.slice(sentence.length).trim();
5623
+ pages.push({
5624
+ kind: "change",
5625
+ title: fact(cleanLine(sentence), item.provenance),
5626
+ body: fact(remainder.length > 0 ? cleanLine(remainder) : "", item.provenance)
5627
+ });
5628
+ }
5629
+ if (release.codeSnippet !== void 0) pages.push({
5630
+ kind: "code",
5631
+ title: fact("Code example", release.codeSnippet.provenance),
5632
+ body: fact(release.codeSnippet.value.code, release.codeSnippet.provenance)
5633
+ });
5634
+ const options = config.release?.story;
5635
+ if (options?.before !== void 0 && options.after !== void 0) pages.push({
5636
+ kind: "comparison",
5637
+ title: configured("Before and after", "release.story.before + release.story.after"),
5638
+ body: configured("Supplied screenshots", "release.story"),
5639
+ before: configured(options.before, "release.story.before"),
5640
+ after: configured(options.after, "release.story.after")
5641
+ });
5642
+ const hasUpgrade = options?.upgrade !== void 0 && options.upgrade.trim().length > 0;
5643
+ let upgrade;
5644
+ if (hasUpgrade && options?.upgrade !== void 0) upgrade = configured(options.upgrade.trim(), "release.story.upgrade");
5645
+ else {
5646
+ const repo = facts.project.repo;
5647
+ 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);
5648
+ }
5649
+ pages.push({
5650
+ kind: "upgrade",
5651
+ title: fact(hasUpgrade ? "How to upgrade" : "Get the release", upgrade.provenance),
5652
+ body: upgrade
5653
+ });
5654
+ return {
5655
+ ...facts,
5656
+ story: pages
5657
+ };
5658
+ }
5659
+ async function generateStory(input) {
5660
+ const facts = storyFacts(input.facts, input.config, input.brand.name, input.generatedAt);
5661
+ const pages = facts.story ?? [];
5662
+ const result = {
5663
+ files: [],
5664
+ warnings: [],
5665
+ missing: [],
5666
+ facts,
5667
+ copy: input.copy,
5668
+ copyMode: "deterministic",
5669
+ generatedAt: input.generatedAt,
5670
+ computed: {}
5671
+ };
5672
+ const formats = input.config.formats ?? ["portrait"];
5673
+ 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}.`);
5674
+ for (const [index, page] of pages.entries()) {
5675
+ const rendered = await generate({
5676
+ ...input,
5677
+ facts: {
5678
+ ...facts,
5679
+ story: [page]
5680
+ },
5681
+ config: {
5682
+ ...input.config,
5683
+ formats,
5684
+ release: {
5685
+ ...input.config.release,
5686
+ templates: ["story-page"]
5687
+ }
5688
+ }
5689
+ });
5690
+ const id = `story-${String(index + 1).padStart(2, "0")}-${page.kind}`;
5691
+ result.files.push(...rendered.files.map((file) => ({
5692
+ ...file,
5693
+ fileName: file.fileName.replace("story-page", id)
5694
+ })));
5695
+ result.warnings.push(...rendered.warnings.map((warning) => ({
5696
+ ...warning,
5697
+ slot: `page-${String(index + 1)}.${warning.slot}`
5698
+ })));
5699
+ result.missing.push(...rendered.missing);
5700
+ result.computed[`story.pages[${String(index)}]`] = {
5701
+ value: {
5702
+ kind: page.kind,
5703
+ files: result.files.filter((file) => file.fileName.startsWith(id)).map((file) => file.fileName)
5704
+ },
5705
+ computedFrom: [`story[${String(index)}].title`, `story[${String(index)}].body`]
5706
+ };
5707
+ }
5708
+ if (facts.release?.codeSnippet === void 0) result.missing.push({
5709
+ template: "story-page",
5710
+ fact: "release.codeSnippet",
5711
+ effect: "code page omitted"
5712
+ });
5713
+ if (pages.every((page) => page.kind !== "change")) result.missing.push({
5714
+ template: "story-page",
5715
+ fact: "release.features",
5716
+ effect: "change pages omitted because release notes have no changes"
5717
+ });
5718
+ return result;
5719
+ }
5720
+
5721
+ //#endregion
5722
+ //#region src/outputs/downloads.ts
5723
+ const encode = (value) => new TextEncoder().encode(value);
5724
+ const isJpeg = (bytes) => bytes[0] === 255 && bytes[1] === 216;
5725
+ function joinBytes(parts) {
5726
+ const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
5727
+ let offset = 0;
5728
+ for (const part of parts) {
5729
+ result.set(part, offset);
5730
+ offset += part.length;
5731
+ }
5732
+ return result;
5733
+ }
5734
+ /**
5735
+ * Image-only PDF: each JPEG becomes one page as a DCTDecode stream, so the page is the exact
5736
+ * rendered layout rather than a reflow of it. Pages are placed at half the pixel size, which
5737
+ * puts a 1080x1350 render on a 540x675pt page at 144dpi.
5738
+ *
5739
+ * Object numbering: 1 is the catalog, 2 the page tree, then each page contributes three
5740
+ * objects (page, image, content stream) at 3 + index * 3.
5741
+ */
5742
+ function carouselPdf(pages) {
5743
+ 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.");
5744
+ 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(" ")}] >>`)];
5745
+ for (const [index, page] of pages.entries()) {
5746
+ const id = 3 + index * 3;
5747
+ const width = page.width / 2;
5748
+ const height = page.height / 2;
5749
+ 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 >>`));
5750
+ objects.push(joinBytes([
5751
+ 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`),
5752
+ page.bytes,
5753
+ encode("\nendstream")
5754
+ ]));
5755
+ const content = `q ${String(width)} 0 0 ${String(height)} 0 0 cm /Image Do Q`;
5756
+ objects.push(encode(`<< /Length ${String(encode(content).length)} >>\nstream\n${content}\nendstream`));
5757
+ }
5758
+ const header = encode("%PDF-1.4\n%Shipseal\n");
5759
+ const parts = [header];
5760
+ const offsets = [];
5761
+ let position = header.length;
5762
+ for (const [index, object] of objects.entries()) {
5763
+ offsets.push(position);
5764
+ const bytes = joinBytes([
5765
+ encode(`${String(index + 1)} 0 obj\n`),
5766
+ object,
5767
+ encode("\nendobj\n")
5768
+ ]);
5769
+ parts.push(bytes);
5770
+ position += bytes.length;
5771
+ }
5772
+ const size = objects.length + 1;
5773
+ 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`));
5774
+ return joinBytes(parts);
5775
+ }
5776
+ function crc32(bytes) {
5777
+ let value = 4294967295;
5778
+ for (const byte of bytes) {
5779
+ value ^= byte;
5780
+ for (let bit = 0; bit < 8; bit += 1) value = value >>> 1 ^ ((value & 1) === 1 ? 3988292384 : 0);
5781
+ }
5782
+ return (value ^ 4294967295) >>> 0;
5783
+ }
5784
+ /**
5785
+ * ZIP with STORE and no compression: PNG and JPEG are already compressed, so deflating them
5786
+ * costs time and saves nothing. Every timestamp is the fixed DOS date 1980-01-01 (0x0021) so
5787
+ * the same inputs always produce the same archive bytes.
5788
+ */
5789
+ function zipFiles(files) {
5790
+ const FIXED_DOS_DATE = 33;
5791
+ const names = /* @__PURE__ */ new Set();
5792
+ const local = [];
5793
+ const central = [];
5794
+ let offset = 0;
5795
+ for (const file of files) {
5796
+ 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.");
5797
+ names.add(file.name);
5798
+ const name = encode(file.name);
5799
+ const checksum = crc32(file.bytes);
5800
+ const header = /* @__PURE__ */ new Uint8Array(30);
5801
+ const headerView = new DataView(header.buffer);
5802
+ headerView.setUint32(0, 67324752, true);
5803
+ headerView.setUint16(4, 20, true);
5804
+ headerView.setUint16(12, FIXED_DOS_DATE, true);
5805
+ headerView.setUint32(14, checksum, true);
5806
+ headerView.setUint32(18, file.bytes.length, true);
5807
+ headerView.setUint32(22, file.bytes.length, true);
5808
+ headerView.setUint16(26, name.length, true);
5809
+ local.push(header, name, file.bytes);
5810
+ const entry = /* @__PURE__ */ new Uint8Array(46);
5811
+ const entryView = new DataView(entry.buffer);
5812
+ entryView.setUint32(0, 33639248, true);
5813
+ entryView.setUint16(4, 20, true);
5814
+ entryView.setUint16(6, 20, true);
5815
+ entryView.setUint16(14, FIXED_DOS_DATE, true);
5816
+ entryView.setUint32(16, checksum, true);
5817
+ entryView.setUint32(20, file.bytes.length, true);
5818
+ entryView.setUint32(24, file.bytes.length, true);
5819
+ entryView.setUint16(28, name.length, true);
5820
+ entryView.setUint32(42, offset, true);
5821
+ central.push(entry, name);
5822
+ offset += header.length + name.length + file.bytes.length;
5823
+ }
5824
+ const end = /* @__PURE__ */ new Uint8Array(22);
5825
+ const endView = new DataView(end.buffer);
5826
+ endView.setUint32(0, 101010256, true);
5827
+ endView.setUint16(8, files.length, true);
5828
+ endView.setUint16(10, files.length, true);
5829
+ endView.setUint32(12, central.reduce((sum, part) => sum + part.length, 0), true);
5830
+ endView.setUint32(16, offset, true);
5831
+ return joinBytes([
5832
+ ...local,
5833
+ ...central,
5834
+ end
5835
+ ]);
5836
+ }
5837
+
5838
+ //#endregion
5839
+ //#region src/studio/pack.ts
5840
+ /** Every format the `story-page` template declares, in the order the selector lists them. */
5841
+ const STORY_FORMATS = [
5842
+ "portrait",
5843
+ "square",
5844
+ "og",
5845
+ "github-social",
5846
+ "x",
5847
+ "linkedin",
5848
+ "producthunt"
5849
+ ];
5850
+ const encodeJson = (value) => new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`);
5851
+ const selectionSchema = z.object({
5852
+ pack: z.enum(["story", "release"]).default("story"),
5853
+ style: brandSchema.shape.style,
5854
+ theme: z.enum(["dark", "light"]),
5855
+ accent: brandSchema.shape.colors.shape.primary,
5856
+ headline: z.string().max(500).default(""),
5857
+ upgrade: z.string().max(4e3).default(""),
5858
+ format: z.enum([...STORY_FORMATS, "all"]).default("portrait")
5859
+ }).strict();
5860
+ function selectedConfig(config, selection) {
5861
+ const formats = selection.format === "all" ? selection.pack === "story" ? [...STORY_FORMATS] : [...V1_FORMAT_IDS] : [selection.format];
5862
+ const landscape = new Set(V1_FORMAT_IDS);
5863
+ 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.");
5864
+ return {
5865
+ ...config,
5866
+ formats,
5867
+ release: {
5868
+ ...config.release,
5869
+ headline: selection.headline || null,
5870
+ story: {
5871
+ ...config.release?.story,
5872
+ upgrade: selection.upgrade
5873
+ }
5874
+ },
5875
+ output: { imageFormat: "png" }
5876
+ };
5877
+ }
5878
+ function selectedBrand(brand, selection) {
5879
+ return {
5880
+ ...brand,
5881
+ style: selection.style,
5882
+ colors: {
5883
+ ...brand.colors,
5884
+ primary: selection.accent
5885
+ }
5886
+ };
5887
+ }
5888
+ async function buildStudioPack(input) {
5889
+ const release = input.facts.release;
5890
+ if (release === void 0) throw new ShipsealError("preview.no-release", "No release is selected.", "Load a repository release first.");
5891
+ const config = selectedConfig(input.config, input.selection);
5892
+ const brand = selectedBrand(input.brand, input.selection);
5893
+ const facts = {
5894
+ ...input.facts,
5895
+ release: { ...release }
5896
+ };
5897
+ if (input.selection.headline) facts.release = {
5898
+ ...release,
5899
+ headline: fact(input.selection.headline, {
5900
+ source: "user-config",
5901
+ ref: "release.headline",
5902
+ fetchedAt: input.generatedAt
5903
+ })
5904
+ };
5905
+ const event = {
5906
+ kind: "release",
5907
+ tag: release.tag.value
5908
+ };
5909
+ const request = {
5910
+ event,
5911
+ facts,
5912
+ brand,
5913
+ config,
5914
+ copy: deterministicCopy(facts, config.release?.maxHighlights, brand.name),
5915
+ copyMode: "deterministic",
5916
+ renderer: input.renderer,
5917
+ themes: [input.selection.theme],
5918
+ generatedAt: input.generatedAt
5919
+ };
5920
+ if (input.logos !== void 0) request.logos = input.logos;
5921
+ if (input.images !== void 0) request.images = input.images;
5922
+ const result = await (input.selection.pack === "story" ? generateStory : generate)(request);
5923
+ const manifest = buildManifest({
5924
+ result,
5925
+ event,
5926
+ brand,
5927
+ shipsealVersion: input.version
5928
+ });
5929
+ manifest.brand.source = input.brandSource ?? ".shipseal/brand.json + preview selections";
5930
+ const downloads = result.files.map((file) => ({
5931
+ name: file.fileName,
5932
+ bytes: file.bytes
5933
+ }));
5934
+ if (input.selection.pack === "story") {
5935
+ const pdfFormat = config.formats?.[0] ?? "portrait";
5936
+ const jpeg = await generateStory({
5937
+ ...request,
5938
+ config: {
5939
+ ...config,
5940
+ formats: [pdfFormat],
5941
+ output: { imageFormat: "jpeg" }
5942
+ }
5943
+ });
5944
+ const pdf = carouselPdf(jpeg.files);
5945
+ downloads.push({
5946
+ name: "story.pdf",
5947
+ bytes: pdf
5948
+ });
5949
+ manifest.computed["story.pdf"] = {
5950
+ value: {
5951
+ format: pdfFormat,
5952
+ pages: jpeg.files.length,
5953
+ bytes: pdf.length
5954
+ },
5955
+ computedFrom: ["story"]
5956
+ };
5957
+ }
5958
+ downloads.push({
5959
+ name: "brand.json",
5960
+ bytes: encodeJson(brand)
5961
+ }, {
5962
+ name: "config.json",
5963
+ bytes: encodeJson(config)
5964
+ }, {
5965
+ name: "manifest.json",
5966
+ bytes: encodeJson(manifest)
5967
+ });
5968
+ return {
5969
+ result,
5970
+ manifest,
5971
+ downloads,
5972
+ archive: zipFiles(downloads),
5973
+ brand,
5974
+ config
5975
+ };
5976
+ }
5977
+
5978
+ //#endregion
5979
+ //#region src/commands/story.ts
5980
+ const STORY_IMAGE_LABELS = ["before", "after"];
5981
+ const MAX_STORY_IMAGE_BYTES = 5e6;
5982
+ /**
5983
+ * Screenshots are supplied by the maintainer, never generated or captured here: no headless
5984
+ * browser is a hard rule (AGENTS.md rule 2).
5985
+ */
5986
+ async function readStoryImage(cwd, label, path) {
5987
+ let data;
5988
+ try {
5989
+ data = await readFile(resolve(cwd, path));
5990
+ } catch (error) {
5991
+ 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 });
5992
+ }
5993
+ const png = data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71;
5994
+ const jpeg = data[0] === 255 && data[1] === 216;
5995
+ 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.");
5996
+ return {
5997
+ src: `story-${label}`,
5998
+ data
5999
+ };
6000
+ }
6001
+ async function loadStoryImages(cwd, config) {
6002
+ const configured = STORY_IMAGE_LABELS.flatMap((label) => {
6003
+ const path = config.release?.story?.[label];
6004
+ return path === void 0 ? [] : [{
6005
+ label,
6006
+ path
6007
+ }];
6008
+ });
6009
+ return Promise.all(configured.map((image) => readStoryImage(cwd, image.label, image.path)));
6010
+ }
6011
+ async function runStory(flags) {
6012
+ const brand = await loadBrand(flags.cwd);
6013
+ const config = await loadConfig(flags.cwd);
6014
+ const tag = flags.tag ?? await gitCurrentTag(flags.cwd);
6015
+ if (tag === void 0) throw new ShipsealError("story.no-tag", "No release tag was found.", "Pass --tag with an existing release tag.");
6016
+ const parsed = selectionSchema.safeParse({
6017
+ pack: "story",
6018
+ style: flags.style ?? brand.style,
6019
+ theme: flags.theme ?? brand.theme,
6020
+ accent: brand.colors.primary ?? "#ff4d4d",
6021
+ headline: flags.headline ?? config.release?.headline ?? "",
6022
+ upgrade: config.release?.story?.upgrade ?? "",
6023
+ format: flags.format ?? "portrait"
6024
+ });
6025
+ if (!parsed.success) {
6026
+ const fields = [...new Set(parsed.error.issues.map((issue) => issue.path.join(".")))];
6027
+ 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 });
6028
+ }
6029
+ const selection = parsed.data;
6030
+ const collectOptions = {
6031
+ cwd: flags.cwd,
6032
+ event: {
6033
+ kind: "release",
6034
+ tag
6035
+ },
6036
+ skipNetwork: true
6037
+ };
6038
+ if (flags.package !== void 0) collectOptions.packagePath = flags.package;
6039
+ if (config.release?.changelogPath !== void 0) collectOptions.changelogPath = config.release.changelogPath;
6040
+ if (config.release?.snippet !== void 0) collectOptions.snippet = config.release.snippet;
6041
+ const input = {
6042
+ facts: await collectFacts(collectOptions),
6043
+ brand,
6044
+ config,
6045
+ selection,
6046
+ renderer: await createTakumiRenderer(),
6047
+ version: readShipsealVersion(),
6048
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6049
+ images: await loadStoryImages(flags.cwd, config),
6050
+ brandSource: ".shipseal/brand.json + story flags"
6051
+ };
6052
+ const logos = await loadLogos(flags.cwd, brand);
6053
+ if (logos !== void 0) input.logos = logos;
6054
+ const pack = await buildStudioPack(input);
6055
+ const dir = resolve(flags.cwd, flags.out ?? config.outputDir ?? ".shipseal/output", `story-${tag.replaceAll(/[^a-zA-Z0-9_.-]/g, "-")}`);
6056
+ await mkdir(dir, { recursive: true });
6057
+ await Promise.all(pack.downloads.map((file) => writeFile(join(dir, file.name), file.bytes)));
6058
+ await writeFile(join(dir, "story.zip"), pack.archive);
6059
+ return {
6060
+ dir,
6061
+ manifest: pack.manifest,
6062
+ exitCode: flags.strict === true && pack.manifest.warnings.length > 0 ? 2 : 0
6063
+ };
6064
+ }
6065
+
6066
+ //#endregion
6067
+ //#region src/commands/preview.ts
6068
+ /** Localhost only, so the page is served inline rather than from a template file. */
6069
+ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8">
6070
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>Shipseal preview</title>
6071
+ </head><body style="margin:0;background:#0c0c0f"><main id="studio"></main><script type="module">
6072
+ import { mountStudio } from '/studio-client.js';
6073
+ const token = new URL(location.href).searchParams.get('token');
6074
+ async function api(path, value) {
6075
+ const response = await fetch(path, {
6076
+ method: value === undefined ? 'GET' : 'POST',
6077
+ headers: { 'Content-Type': 'application/json', 'X-Shipseal-Token': token },
6078
+ ...(value === undefined ? {} : { body: JSON.stringify(value) }),
6079
+ });
6080
+ const body = await response.json();
6081
+ if (!response.ok) throw new Error(body.error);
6082
+ return body;
6083
+ }
6084
+ mountStudio(document.getElementById('studio'), {
6085
+ mode: 'local',
6086
+ load: () => api('/api/state'),
6087
+ render: (value) => api('/api/render', value),
6088
+ save: (value) => api('/api/save', value),
6089
+ });
6090
+ <\/script></body></html>`;
6091
+ const PAGE_HEADERS = {
6092
+ "Content-Type": "text/html; charset=utf-8",
6093
+ "Cache-Control": "no-store",
6094
+ "Referrer-Policy": "no-referrer",
6095
+ "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'"
6096
+ };
6097
+ function json(response, status, value) {
6098
+ response.writeHead(status, {
6099
+ "Content-Type": "application/json",
6100
+ "Cache-Control": "no-store",
6101
+ "X-Content-Type-Options": "nosniff"
6102
+ });
6103
+ response.end(JSON.stringify(value));
6104
+ }
6105
+ async function readSelection(request) {
6106
+ let text = "";
6107
+ for await (const value of request) {
6108
+ if (!Buffer.isBuffer(value)) throw new ShipsealError("preview.body", "The request body is not valid bytes.", "Reload the preview.");
6109
+ text += value.toString("utf8");
6110
+ if (text.length > 16384) throw new ShipsealError("preview.body-size", "The preview request is too large.", "Shorten the headline or upgrade instructions.");
6111
+ }
6112
+ try {
6113
+ const raw = JSON.parse(text);
6114
+ return selectionSchema.parse(raw);
6115
+ } catch (error) {
6116
+ throw new ShipsealError("preview.selection", "The preview options are invalid.", "Reload the preview and choose supported options.", { cause: error });
6117
+ }
6118
+ }
6119
+ /** Write through a temporary file so an interrupted save never truncates the original. */
6120
+ async function writeAtomic(destination, value) {
6121
+ const temporary = `${destination}.${randomUUID()}.tmp`;
6122
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
6123
+ await rename(temporary, destination);
6124
+ }
6125
+ async function startPreview(options) {
6126
+ const resolvedTag = options.tag ?? await gitCurrentTag(options.cwd);
6127
+ if (resolvedTag === void 0) throw new ShipsealError("preview.no-tag", "No release tag was found.", "Pass --tag with an existing release tag.");
6128
+ const tag = resolvedTag;
6129
+ await loadBrand(options.cwd);
6130
+ const token = randomUUID();
6131
+ let origin = "";
6132
+ async function handle(request, response) {
6133
+ if (request.headers.host !== new URL(origin).host) {
6134
+ json(response, 403, { error: "Invalid preview host. Open the localhost URL printed by Shipseal." });
6135
+ return;
6136
+ }
6137
+ const url = new URL(request.url ?? "/", origin);
6138
+ if (request.method === "GET" && url.pathname === "/") {
6139
+ response.writeHead(200, PAGE_HEADERS);
6140
+ response.end(PAGE);
6141
+ return;
6142
+ }
6143
+ if (request.method === "GET" && url.pathname === "/studio-client.js") {
6144
+ const script = await readFile(join(resolvePackageRoot(), "dist", "studio-client.js"));
6145
+ response.writeHead(200, {
6146
+ "Content-Type": "text/javascript",
6147
+ "Cache-Control": "no-store"
6148
+ });
6149
+ response.end(script);
6150
+ return;
6151
+ }
6152
+ if (request.headers["x-shipseal-token"] !== token || request.method === "POST" && request.headers.origin !== origin) {
6153
+ json(response, 403, { error: "This preview request is not authorized. Reload the localhost URL printed by Shipseal." });
6154
+ return;
6155
+ }
6156
+ const brand = await loadBrand(options.cwd);
6157
+ const config = await loadConfig(options.cwd);
6158
+ const collect = {
6159
+ cwd: options.cwd,
6160
+ event: {
6161
+ kind: "release",
6162
+ tag
6163
+ },
6164
+ skipNetwork: true
6165
+ };
6166
+ if (options.package !== void 0) collect.packagePath = options.package;
6167
+ if (config.release?.changelogPath !== void 0) collect.changelogPath = config.release.changelogPath;
6168
+ if (config.release?.snippet !== void 0) collect.snippet = config.release.snippet;
6169
+ if (request.method === "GET" && url.pathname === "/api/state") {
6170
+ const facts = await collectFacts(collect);
6171
+ json(response, 200, {
6172
+ name: brand.name,
6173
+ style: brand.style,
6174
+ theme: brand.theme,
6175
+ accent: brand.colors.primary ?? "#ff4d4d",
6176
+ headline: config.release?.headline ?? "",
6177
+ upgrade: config.release?.story?.upgrade ?? "",
6178
+ headlines: [...facts.release?.breaking ?? [], ...facts.release?.features ?? []].map((item) => cleanLine(firstSentence(item.value))),
6179
+ 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."],
6180
+ releases: [{
6181
+ tag,
6182
+ label: tag
6183
+ }],
6184
+ tag
6185
+ });
6186
+ return;
6187
+ }
6188
+ if (request.method !== "POST") {
6189
+ json(response, 404, { error: "Preview route not found." });
6190
+ return;
6191
+ }
6192
+ const selection = await readSelection(request);
6193
+ if (url.pathname === "/api/save") {
6194
+ const nextConfig = selectedConfig(config, selection);
6195
+ nextConfig.formats = config.formats;
6196
+ await Promise.all([writeAtomic(join(options.cwd, ".shipseal", "brand.json"), selectedBrand(brand, selection)), writeAtomic(join(options.cwd, ".shipseal", "config.json"), nextConfig)]);
6197
+ json(response, 200, { saved: true });
6198
+ return;
6199
+ }
6200
+ if (url.pathname !== "/api/render") {
6201
+ json(response, 404, { error: "Preview route not found." });
6202
+ return;
6203
+ }
6204
+ const packInput = {
6205
+ facts: await collectFacts(collect),
6206
+ brand,
6207
+ config,
6208
+ selection,
6209
+ renderer: await createTakumiRenderer(),
6210
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6211
+ version: readShipsealVersion(),
6212
+ images: await loadStoryImages(options.cwd, config)
6213
+ };
6214
+ const logos = await loadLogos(options.cwd, brand);
6215
+ if (logos !== void 0) packInput.logos = logos;
6216
+ const pack = await buildStudioPack(packInput);
6217
+ const output = {
6218
+ files: pack.result.files.map((file) => ({
6219
+ name: file.fileName,
6220
+ data: Buffer.from(file.bytes).toString("base64"),
6221
+ width: file.width,
6222
+ height: file.height
6223
+ })),
6224
+ archive: Buffer.from(pack.archive).toString("base64"),
6225
+ manifest: pack.manifest
6226
+ };
6227
+ const pdf = pack.downloads.find((file) => file.name === "story.pdf");
6228
+ if (pdf !== void 0) output.pdf = Buffer.from(pdf.bytes).toString("base64");
6229
+ json(response, 200, output);
6230
+ }
6231
+ const server = createServer((request, response) => {
6232
+ handle(request, response).catch((error) => {
6233
+ json(response, 400, { error: error instanceof ShipsealError ? formatError(error) : `${error instanceof Error ? error.message : "Preview failed"}. Check the files and retry.` });
6234
+ });
6235
+ });
6236
+ await new Promise((accept, reject) => {
6237
+ server.once("error", reject);
6238
+ server.listen(options.port ?? 4175, "127.0.0.1", () => {
6239
+ server.removeListener("error", reject);
6240
+ accept();
6241
+ });
6242
+ });
6243
+ const address = server.address();
6244
+ if (address === null || typeof address === "string") throw new ShipsealError("preview.listen", "The preview server could not start.", "Choose another --port and retry.");
6245
+ origin = `http://127.0.0.1:${String(address.port)}`;
6246
+ return {
6247
+ server,
6248
+ url: `${origin}/?token=${token}`,
6249
+ token
6250
+ };
6251
+ }
6252
+ async function runPreview(options) {
6253
+ let preview;
6254
+ try {
6255
+ preview = await startPreview(options);
6256
+ } catch (error) {
6257
+ 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 });
6258
+ throw error;
6259
+ }
6260
+ process.stdout.write(`Preview: ${preview.url}\nPress Ctrl+C to stop.\n`);
6261
+ await new Promise((accept) => {
6262
+ const stop = () => {
6263
+ preview.server.close();
6264
+ preview.server.closeAllConnections();
6265
+ };
6266
+ process.once("SIGINT", stop);
6267
+ process.once("SIGTERM", stop);
6268
+ preview.server.once("close", () => {
6269
+ process.removeListener("SIGINT", stop);
6270
+ process.removeListener("SIGTERM", stop);
6271
+ accept();
6272
+ });
6273
+ });
6274
+ }
6275
+
4980
6276
  //#endregion
4981
6277
  //#region src/cli.ts
4982
6278
  async function runCli(argv = process.argv) {
@@ -5004,6 +6300,46 @@ async function runCli(argv = process.argv) {
5004
6300
  for (const note of result.detection.notes) process.stdout.write(` note: ${note}\n`);
5005
6301
  }
5006
6302
  });
6303
+ 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) => {
6304
+ const options = {
6305
+ cwd: stringFlag(flags.cwd, process.cwd()),
6306
+ strict: flags.strict === true
6307
+ };
6308
+ for (const key of [
6309
+ "tag",
6310
+ "format",
6311
+ "style",
6312
+ "theme",
6313
+ "headline",
6314
+ "out",
6315
+ "package"
6316
+ ]) {
6317
+ const value = optionalString(flags[key]);
6318
+ if (value !== void 0) options[key] = value;
6319
+ }
6320
+ const result = await runStory(options);
6321
+ process.exitCode = result.exitCode;
6322
+ if (flags.json === true) writeJson(result);
6323
+ else if (flags.quiet !== true) writeHumanResult({
6324
+ ...result,
6325
+ dryRun: false,
6326
+ facts: result.manifest.facts,
6327
+ warnings: result.manifest.warnings
6328
+ });
6329
+ });
6330
+ 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) => {
6331
+ const options = { cwd: stringFlag(flags.cwd, process.cwd()) };
6332
+ for (const key of ["tag", "package"]) {
6333
+ const value = optionalString(flags[key]);
6334
+ if (value !== void 0) options[key] = value;
6335
+ }
6336
+ if (flags.port !== void 0) {
6337
+ const port = Number(flags.port);
6338
+ 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.");
6339
+ options.port = port;
6340
+ }
6341
+ await runPreview(options);
6342
+ });
5007
6343
  cli.command("doctor", "Check Node, git, brand, fonts, and Takumi").action(async (flags) => {
5008
6344
  const result = await runDoctor(stringFlag(flags.cwd, process.cwd()));
5009
6345
  if (flags.json === true) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -5014,7 +6350,7 @@ async function runCli(argv = process.argv) {
5014
6350
  }
5015
6351
  if (!result.ok) process.exitCode = 1;
5016
6352
  });
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) => {
6353
+ 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
6354
  const releaseFlags = {
5019
6355
  cwd: stringFlag(flags.cwd, process.cwd()),
5020
6356
  strict: flags.strict === true,
@@ -5023,6 +6359,10 @@ async function runCli(argv = process.argv) {
5023
6359
  applySharedFlags(releaseFlags, flags);
5024
6360
  const tag = optionalString(flags.tag);
5025
6361
  if (tag !== void 0) releaseFlags.tag = tag;
6362
+ const headline = optionalString(flags.headline);
6363
+ if (headline !== void 0) releaseFlags.headline = headline;
6364
+ const subheadline = optionalString(flags.subheadline);
6365
+ if (subheadline !== void 0) releaseFlags.subheadline = subheadline;
5026
6366
  const from = optionalString(flags.from);
5027
6367
  if (from !== void 0) releaseFlags.from = from;
5028
6368
  const templates = optionalString(flags.templates);