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/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 = "") => {
@@ -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
- const propsSchema$4 = z.object({
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$4,
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$4.parse(raw).headline };
619
+ return { headline: propsSchema$5.parse(raw).headline };
582
620
  },
583
621
  slotFont(raw, slot) {
584
- const props = propsSchema$4.parse(raw);
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$4.parse(raw);
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
- backgroundColor: colors.card,
648
- borderRadius: props.radius,
649
- padding: 28,
650
- gap: 0
694
+ justifyContent: "center"
651
695
  },
652
- children: props.lines.map((line, lineIndex) => /* @__PURE__ */ jsx("div", {
696
+ children: /* @__PURE__ */ jsx("div", {
653
697
  style: {
654
698
  display: "flex",
655
- flexDirection: "row",
656
- flexWrap: "nowrap"
699
+ flexDirection: "column",
700
+ backgroundColor: colors.card,
701
+ borderRadius: props.radius,
702
+ padding: boxPad,
703
+ gap: 0
657
704
  },
658
- children: line.map((token, tokenIndex) => /* @__PURE__ */ jsx("div", {
705
+ children: props.lines.map((line, lineIndex) => /* @__PURE__ */ jsx("div", {
659
706
  style: {
660
- fontFamily: props.monoFamily,
661
- fontWeight: props.monoWeight,
662
- fontSize: 22,
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.text
668
- }, `${String(lineIndex)}-${String(tokenIndex)}`))
669
- }, String(lineIndex)))
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$3 = z.object({
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$3,
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$3.parse(raw);
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$3.parse(raw);
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$3.parse(raw);
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$2 = z.object({
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$2,
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$2.parse(raw);
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$2.parse(raw);
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$2.parse(raw);
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$1 = z.object({
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$1,
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$1.parse(raw);
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$1.parse(raw);
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$1.parse(raw);
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.primary
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", {}), ctx.attribution ? /* @__PURE__ */ jsx("div", {
1466
+ }) : /* @__PURE__ */ jsx("div", {}), /* @__PURE__ */ jsxs("div", {
1364
1467
  style: {
1365
- fontFamily: props.bodyFamily,
1366
- fontWeight: props.bodyWeight,
1367
- fontSize: 18,
1368
- color: colors.muted
1468
+ display: "flex",
1469
+ flexDirection: "column",
1470
+ alignItems: "flex-end",
1471
+ gap: 6
1369
1472
  },
1370
- children: "made with shipseal.dev"
1371
- }) : void 0]
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: createHash("sha256").update(bytes).digest("hex")
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) release.previousVersion = fact(versionFromTag(previousTag), {
2205
- source: "git",
2206
- ref: `git tag ${previousTag}`,
2207
- fetchedAt
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
- pkg.project?.npmPackage === void 0 ? await collectWorkspaceNpmPackage(options.cwd, options.skipNetwork === true ? {} : { verify: async (name) => npmPackageExists(name, options.fetchImpl ?? fetch) }) : {},
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
- return { project: { npmPackage: fact(only.name, {
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
- const FONT_REL = join("assets", "fonts", "GeistMono[wght].ttf");
2697
- async function createTakumiRenderer() {
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 colour.
4446
+ * Resolve a declaration to a hex color.
3831
4447
  *
3832
- * Handles two things a plain colour parser does not. First, `var(--brand-500)` indirection,
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 colour once wrapped. Percent signs on the last two channels mean HSL, three plain
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 colour type. Index is the colour type. */
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 colour in a logo, as a hex string.
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 colour. Colours are bucketed
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 colour in ${logoPath}`
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 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
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);