@buildinternet/uploads 0.42.0 → 0.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,534 @@
1
+ /**
2
+ * GENERATED by packages/uploads/scripts/inline-shared.mjs — do not edit.
3
+ * Canonical source: packages/comment-render/src/index.ts
4
+ * The published CLI cannot import private @uploads/* packages, so this file
5
+ * is inlined into the tarball. Re-run the script after changing the source.
6
+ */
7
+ /** Non-safe chars → `-` for a single R2 key segment (owner/name/branch/…).
8
+ * Exported for reuse by other GitHub-key builders (github-ingest.ts) that
9
+ * need the identical sanitization rule — github-promote.ts keeps its own
10
+ * byte-identical private copy rather than importing this one. */
11
+ export function sanitizeKeySegment(s) {
12
+ return s.replace(/[^A-Za-z0-9._-]/g, "-");
13
+ }
14
+ export function ghKeyPrefix(target) {
15
+ const [owner, name] = target.repo.split("/");
16
+ return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
17
+ }
18
+ /** Literal root under which every private-repo attachment key lives. */
19
+ export const GH_PRIVATE_ROOT = "gh/private/";
20
+ /** Strict shape for a randomized private-repo prefix id: 32 lowercase hex chars. */
21
+ const PRIVATE_PREFIX_ID_RE = /^[0-9a-f]{32}$/;
22
+ /**
23
+ * Guard every private-key builder against a malformed `prefixId` — a
24
+ * caller bug here must never silently produce a guessable-ish key.
25
+ */
26
+ function assertPrivatePrefixId(prefixId) {
27
+ if (!PRIVATE_PREFIX_ID_RE.test(prefixId)) {
28
+ throw new Error(`invalid private prefix id: "${prefixId}" must be 32 lowercase hex characters`);
29
+ }
30
+ }
31
+ /**
32
+ * Private-repo key prefix: `gh/private/<32-hex-id>/<kind>/<num>/`.
33
+ * Deliberately omits the repo (unlike `ghKeyPrefix`) — the id is a random,
34
+ * unguessable per-repo prefix rather than an owner/name path, so callers
35
+ * that need the repo back must read `gh.repo` metadata (see
36
+ * `parseGhPrivateKey`, which cannot recover it from the key alone).
37
+ */
38
+ export function ghPrivateKeyPrefix(prefixId, target) {
39
+ assertPrivatePrefixId(prefixId);
40
+ return `${GH_PRIVATE_ROOT}${prefixId}/${target.kind}/${target.num}/`;
41
+ }
42
+ /** Private-repo attachment key: `ghPrivateKeyPrefix` + the sanitized filename. */
43
+ export function ghPrivateAttachmentKey(prefixId, target, filename) {
44
+ return `${ghPrivateKeyPrefix(prefixId, target)}${sanitizeKeySegment(filename)}`;
45
+ }
46
+ /**
47
+ * Private-repo branch-staged key prefix: `gh/private/<32-hex-id>/branch/`.
48
+ * Unlike `ghBranchKeyPrefix`, there is deliberately NO branch-name segment —
49
+ * the branch name itself is not embedded in a private-repo key.
50
+ */
51
+ export function ghPrivateBranchKeyPrefix(prefixId) {
52
+ assertPrivatePrefixId(prefixId);
53
+ return `${GH_PRIVATE_ROOT}${prefixId}/branch/`;
54
+ }
55
+ /** Private-repo branch-staged attachment key: `ghPrivateBranchKeyPrefix` + the sanitized filename. */
56
+ export function ghPrivateBranchAttachmentKey(prefixId, filename) {
57
+ return `${ghPrivateBranchKeyPrefix(prefixId)}${sanitizeKeySegment(filename)}`;
58
+ }
59
+ /**
60
+ * Inverse of `ghPrivateKeyPrefix`: parse the prefix id/kind/number back out
61
+ * of a private-repo attachment key, or undefined for any other key shape.
62
+ * Cannot recover the repo — callers that need it read `gh.repo` metadata.
63
+ */
64
+ export function parseGhPrivateKey(key) {
65
+ const match = /^gh\/private\/([0-9a-f]{32})\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
66
+ if (!match)
67
+ return undefined;
68
+ const [, prefixId, kind, num] = match;
69
+ return { prefixId, kind: kind, num: Number(num) };
70
+ }
71
+ /** GitHub-embed helper (content type). Copied from packages/uploads/src/embed.ts. */
72
+ function inferContentType(filename) {
73
+ const ext = filename.includes(".")
74
+ ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
75
+ : "";
76
+ switch (ext) {
77
+ case "png":
78
+ return "image/png";
79
+ case "jpg":
80
+ case "jpeg":
81
+ return "image/jpeg";
82
+ case "gif":
83
+ return "image/gif";
84
+ case "webp":
85
+ return "image/webp";
86
+ case "svg":
87
+ return "image/svg+xml";
88
+ case "mp4":
89
+ return "video/mp4";
90
+ default:
91
+ return "application/octet-stream";
92
+ }
93
+ }
94
+ /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
95
+ export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
96
+ /** Workspace slugs are `[a-z0-9-]`-ish; only markers built from a slug matching
97
+ * this are trusted as a distinct namespace — anything else (empty, unsafe
98
+ * chars) degrades to the shared legacy marker rather than emitting untrusted
99
+ * text into comment HTML. */
100
+ const WORKSPACE_SLUG_RE = /^[a-z0-9-]{1,64}$/;
101
+ /**
102
+ * Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
103
+ * two workspaces managing the same repo don't clobber each other's comment.
104
+ * Falls back to the shared legacy marker when `workspace` is missing or does
105
+ * not look like a safe slug — degrade, don't guess or risk breaking the
106
+ * comment's HTML.
107
+ */
108
+ export function attachmentsMarker(workspace) {
109
+ if (workspace && WORKSPACE_SLUG_RE.test(workspace)) {
110
+ return `<!-- uploads.sh:attachments ws=${workspace} -->`;
111
+ }
112
+ return ATTACHMENTS_MARKER;
113
+ }
114
+ /** Max attachments embedded as inline `<img>` tags before the rest collapse
115
+ * into a `<details>` link list. Keeps very large threads from becoming a wall
116
+ * of images. */
117
+ export const MAX_INLINE_ATTACHMENT_IMAGES = 16;
118
+ /** Today's behavior, expressed as options — the default for every caller that
119
+ * hasn't opted into repo comment config. */
120
+ export const AUTO_RENDER_OPTIONS = {
121
+ imageWidth: "auto",
122
+ maxInlineImages: MAX_INLINE_ATTACHMENT_IMAGES,
123
+ metaPath: true,
124
+ metaState: true,
125
+ note: null,
126
+ };
127
+ /** Dense (historical) default max width for images in the managed comment. */
128
+ export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
129
+ /** Portrait / device mockups — keep phones readable, not full-column. */
130
+ export const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
131
+ /** Wide UI / browser chrome. */
132
+ export const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
133
+ /** Dense pair-cell cap (side-by-side before/after). */
134
+ export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
135
+ /** Per-density widths for `imageWidth: "auto"`. Dense reuses the exports above. */
136
+ const WIDTH_BY_DENSITY = {
137
+ solo: { default: 720, portrait: 360, wide: 800, pair: 400 },
138
+ sparse: { default: 560, portrait: 300, wide: 720, pair: 380 },
139
+ dense: {
140
+ default: ATTACHMENT_IMAGE_WIDTH_DEFAULT,
141
+ portrait: ATTACHMENT_IMAGE_WIDTH_PORTRAIT,
142
+ wide: ATTACHMENT_IMAGE_WIDTH_WIDE,
143
+ pair: ATTACHMENT_IMAGE_WIDTH_PAIR,
144
+ },
145
+ };
146
+ /** Map an inlined-media count onto a density tier. */
147
+ export function attachmentDensityForCount(inlinedCount) {
148
+ if (inlinedCount <= 1)
149
+ return "solo";
150
+ if (inlinedCount <= 3)
151
+ return "sparse";
152
+ return "dense";
153
+ }
154
+ /** Pair-cell cap for the given density. */
155
+ export function attachmentPairWidth(density = "dense") {
156
+ return WIDTH_BY_DENSITY[density].pair;
157
+ }
158
+ /**
159
+ * Display width for a GitHub comment embed. Filenames are a weak but practical
160
+ * signal (we don't re-fetch dimensions when rebuilding the comment). `density`
161
+ * only affects managed-comment auto layout; other callers leave it `"dense"`.
162
+ */
163
+ export function attachmentImageWidth(filename, density = "dense") {
164
+ const table = WIDTH_BY_DENSITY[density];
165
+ const n = filename.toLowerCase();
166
+ if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n))
167
+ return table.wide;
168
+ if (/(?:^|[-_.])(phone|iphone|ipad|pixel|android|mobile|device)(?:[-_.]|$)/.test(n) ||
169
+ /iphone|pixel-?\d/.test(n)) {
170
+ return table.portrait;
171
+ }
172
+ return table.default;
173
+ }
174
+ /** `m:ss` under an hour, `h:mm:ss` at or above one. */
175
+ function formatDuration(seconds) {
176
+ const total = Math.floor(seconds);
177
+ const s = total % 60;
178
+ const m = Math.floor(total / 60) % 60;
179
+ const h = Math.floor(total / 3600);
180
+ const ss = String(s).padStart(2, "0");
181
+ if (h === 0)
182
+ return `${m}:${ss}`;
183
+ return `${h}:${String(m).padStart(2, "0")}:${ss}`;
184
+ }
185
+ /**
186
+ * Display width for a video poster. Real dimensions only *select* among the
187
+ * density table's tiers — a raw 1920 would blow out the comment column — and
188
+ * the result is capped at the real width so a small clip is never upscaled.
189
+ */
190
+ function posterImageWidth(videoMeta, filename, density = "dense") {
191
+ const w = videoMeta?.width ?? 0;
192
+ const h = videoMeta?.height ?? 0;
193
+ if (w <= 0 || h <= 0)
194
+ return attachmentImageWidth(filename, density);
195
+ const table = WIDTH_BY_DENSITY[density];
196
+ const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
197
+ return Math.min(chosen, w);
198
+ }
199
+ function escapeHtmlAttr(s) {
200
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
201
+ }
202
+ function escapeHtmlText(s) {
203
+ return escapeHtmlAttr(s).replace(/'/g, "&#39;").replace(/>/g, "&gt;");
204
+ }
205
+ /**
206
+ * Backslash-escape the markdown metacharacters that can appear in a metadata
207
+ * value. `~` is in the set because GitHub's strikethrough extension treats a
208
+ * matching pair of ONE or two tildes as markup, so an unescaped `/a~b~c` would
209
+ * render with `b` struck through.
210
+ */
211
+ function escapeMarkdownText(s) {
212
+ return s.replace(/([\\`*_[\]~])/g, "\\$1");
213
+ }
214
+ /**
215
+ * Collect path then state for a caption (issue #365). Bare `/` and
216
+ * whitespace-only values are omitted (issue #375). Empty when nothing usable.
217
+ */
218
+ function metaCaptionValues(meta, options) {
219
+ const values = [];
220
+ const path = meta?.path?.trim();
221
+ if (options.metaPath && path && path !== "/")
222
+ values.push(path);
223
+ const state = meta?.state?.trim();
224
+ if (options.metaState && state)
225
+ values.push(state);
226
+ return values;
227
+ }
228
+ /**
229
+ * Format path/state as code tokens. HTML → `<code>…</code>`; markdown →
230
+ * `` `…` `` (backslash-escape if the value itself contains a backtick).
231
+ * Returns `""` when there is nothing to say.
232
+ */
233
+ function formatMetaCaption(meta, options, mode) {
234
+ const values = metaCaptionValues(meta, options);
235
+ if (values.length === 0)
236
+ return "";
237
+ if (mode === "html") {
238
+ return values.map((v) => `<code>${escapeHtmlText(v)}</code>`).join(" · ");
239
+ }
240
+ return values
241
+ .map((v) => {
242
+ const esc = escapeHtmlText(v);
243
+ return esc.includes("`") ? escapeMarkdownText(esc) : `\`${esc}\``;
244
+ })
245
+ .join(" · ");
246
+ }
247
+ /** Resolved pixel width for an image site, or `null` meaning "omit the width
248
+ * attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
249
+ * `"full"` always omits; a number always wins. */
250
+ function resolvedWidth(autoPx, options) {
251
+ if (options.imageWidth === "auto")
252
+ return autoPx;
253
+ if (options.imageWidth === "full")
254
+ return null;
255
+ return options.imageWidth;
256
+ }
257
+ /** Every `<img>` tag in the managed comment goes through here so the
258
+ * omit-width-attribute case ("full") can't drift between call sites.
259
+ *
260
+ * `escapedAlt`/`escapedSrc` must already be attribute-escaped (via
261
+ * `escapeHtmlAttr`) by the caller — this function interpolates them as-is
262
+ * and does not escape them itself. */
263
+ function imgTag(w, escapedAlt, escapedSrc) {
264
+ const widthAttr = w === null ? "" : ` width="${w}"`;
265
+ return `<img${widthAttr} alt="${escapedAlt}" src="${escapedSrc}">`;
266
+ }
267
+ /** Extract the filename stem's before/after token (issue #419 fallback pairing).
268
+ * `base` is the stem lowercased with the token removed; `null` when the stem
269
+ * carries no recognizable before/after token. Requires a separator (`-`, `_`,
270
+ * or `.`) between the token and the rest of the name — except when the token
271
+ * IS the whole stem (`before.png`) — so `beforehand.png` doesn't false-match. */
272
+ // Token bounded by `-`, `_`, `.`, or stem start/end, so `hero-before.webp`
273
+ // and `paired-view-before-desktop.webp` match but `beforehand.webp` does
274
+ // not. Mirrors before-after.ts's TOKEN_RE (file page), applied to the stem.
275
+ const STEM_TOKEN_RE = /(^|[-_.])(before|after)($|[-_.])/i;
276
+ function filenameStemToken(name) {
277
+ const dot = name.lastIndexOf(".");
278
+ const stem = dot > 0 ? name.slice(0, dot) : name;
279
+ const m = STEM_TOKEN_RE.exec(stem);
280
+ if (!m)
281
+ return null;
282
+ const state = m[2].toLowerCase();
283
+ const tokenStart = m.index + m[1].length;
284
+ const tokenEnd = tokenStart + m[2].length;
285
+ // Base = stem with the token and one adjoining delimiter removed, so
286
+ // `paired-view-before-desktop` and `paired-view-after-desktop` both
287
+ // collapse to `paired-view-desktop` and group together.
288
+ const base = m[1].length > 0
289
+ ? stem.slice(0, m.index) + stem.slice(tokenEnd)
290
+ : stem.slice(tokenEnd + m[3].length);
291
+ return { base: base.toLowerCase(), state };
292
+ }
293
+ /**
294
+ * Pair up attachments for the before/after side-by-side row (issue #419).
295
+ * `isImageAt[i]` mirrors the renderer's own image test — only images pair;
296
+ * videos and non-image links render exactly as before.
297
+ *
298
+ * Priority order, checked independently per candidate item so rule 2 only
299
+ * ever claims items rule 1 left untouched:
300
+ * 1. Same `path` metadata (trimmed, not bare `/`), one item `state=before`
301
+ * and one `state=after`. Ambiguous groups (more than one of a state)
302
+ * don't pair — no way to know which side goes with which.
303
+ * 2. No usable `path` metadata: filename stems that differ only by a
304
+ * before/after token, same extension. Same ambiguity rule.
305
+ */
306
+ function pairAttachments(items, isImageAt) {
307
+ const partnerOf = new Map();
308
+ const roleOf = new Map();
309
+ const pair = (beforeIdx, afterIdx) => {
310
+ partnerOf.set(beforeIdx, afterIdx);
311
+ partnerOf.set(afterIdx, beforeIdx);
312
+ roleOf.set(beforeIdx, "before");
313
+ roleOf.set(afterIdx, "after");
314
+ };
315
+ // Priority 1: same path metadata, exactly one before + one after.
316
+ const pathGroups = new Map();
317
+ items.forEach((item, i) => {
318
+ if (!isImageAt[i])
319
+ return;
320
+ const path = item.meta?.path?.trim();
321
+ if (!path || path === "/")
322
+ return;
323
+ const state = item.meta?.state?.trim().toLowerCase();
324
+ if (state !== "before" && state !== "after")
325
+ return;
326
+ const g = pathGroups.get(path) ?? { before: [], after: [] };
327
+ g[state].push(i);
328
+ pathGroups.set(path, g);
329
+ });
330
+ for (const g of pathGroups.values()) {
331
+ if (g.before.length === 1 && g.after.length === 1)
332
+ pair(g.before[0], g.after[0]);
333
+ }
334
+ // Priority 2: no usable path metadata — filename stem token, same extension.
335
+ const stemGroups = new Map();
336
+ items.forEach((item, i) => {
337
+ if (!isImageAt[i] || partnerOf.has(i))
338
+ return;
339
+ const path = item.meta?.path?.trim();
340
+ if (path && path !== "/")
341
+ return; // usable path metadata — rule 1 owns this item
342
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
343
+ const tok = filenameStemToken(name);
344
+ if (!tok)
345
+ return;
346
+ const dot = name.lastIndexOf(".");
347
+ const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
348
+ const key = `${tok.base}${ext}`;
349
+ const g = stemGroups.get(key) ?? { before: [], after: [] };
350
+ g[tok.state].push(i);
351
+ stemGroups.set(key, g);
352
+ });
353
+ for (const g of stemGroups.values()) {
354
+ if (g.before.length === 1 && g.after.length === 1)
355
+ pair(g.before[0], g.after[0]);
356
+ }
357
+ return { partnerOf, roleOf };
358
+ }
359
+ function renderPairCell(item, label, options, density) {
360
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
361
+ const src = item.embedUrl ?? item.url;
362
+ const link = item.pageUrl ?? item.url;
363
+ const autoPx = Math.min(attachmentImageWidth(name, density), attachmentPairWidth(density));
364
+ const w = resolvedWidth(autoPx, options);
365
+ const alt = escapeHtmlAttr(name);
366
+ const href = escapeHtmlAttr((link ?? src));
367
+ const imgSrc = escapeHtmlAttr(src);
368
+ const caption = formatMetaCaption(item.meta, options, "html");
369
+ // Caption at body size (issue: <sub> made paths unreadably small); the
370
+ // Before/After label keeps <sub> as a deliberate small header.
371
+ const captionHtml = caption ? `<br>${caption}` : "";
372
+ return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}">${imgTag(w, alt, imgSrc)}</a>${captionHtml}</td>`;
373
+ }
374
+ /** One side-by-side before/after row (issue #419). */
375
+ function renderPairRow(beforeItem, afterItem, options, density) {
376
+ return `<table><tr>${renderPairCell(beforeItem, "Before", options, density)}${renderPairCell(afterItem, "After", options, density)}</tr></table>`;
377
+ }
378
+ /** How many image/poster items will fit under `maxInlineImages` (for density). */
379
+ function countInlinableMedia(sorted, maxInlineImages) {
380
+ let count = 0;
381
+ for (const item of sorted) {
382
+ if (count >= maxInlineImages)
383
+ break;
384
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
385
+ const src = item.embedUrl ?? item.url;
386
+ const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
387
+ const isPoster = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
388
+ if (isImage || isPoster)
389
+ count++;
390
+ }
391
+ return count;
392
+ }
393
+ /**
394
+ * Render the one marker-owned GitHub comment. When there are no galleries this
395
+ * intentionally preserves the legacy attachment-only body byte-for-byte.
396
+ */
397
+ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER, options = AUTO_RENDER_OPTIONS) {
398
+ // Non-mutating sort (equivalent to Array#toSorted) — the api worker's
399
+ // tsconfig targets lib ES2022, which predates Array#toSorted (ES2023);
400
+ // packages/uploads/src/github.ts uses toSorted directly.
401
+ const sorted = [...items].sort((a, b) => a.key.localeCompare(b.key));
402
+ const sortedGalleries = [...galleries].sort((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
403
+ const lines = [marker];
404
+ if (options.note)
405
+ lines.push(options.note, "");
406
+ if (sortedGalleries.length > 0) {
407
+ lines.push("### 🖼️ Galleries", "");
408
+ for (const gallery of sortedGalleries) {
409
+ const href = escapeHtmlAttr(gallery.url);
410
+ lines.push(`#### <a href="${href}">${escapeHtmlText(gallery.title)}</a>`);
411
+ for (const preview of gallery.previews ?? []) {
412
+ const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
413
+ const previewSrc = escapeHtmlAttr(preview.embedUrl ?? preview.url);
414
+ const previewW = resolvedWidth(320, options);
415
+ lines.push(`<a href="${previewHref}">${imgTag(previewW, escapeHtmlAttr(preview.alt), previewSrc)}</a>`);
416
+ }
417
+ lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
418
+ }
419
+ lines.push("");
420
+ }
421
+ const isImageAt = sorted.map((item) => {
422
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
423
+ const src = item.embedUrl ?? item.url;
424
+ return Boolean(src) && inferContentType(name).startsWith("image/");
425
+ });
426
+ const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
427
+ // One screenshot → large; a wall of shots → compact historical sizes.
428
+ const density = options.imageWidth === "auto"
429
+ ? attachmentDensityForCount(countInlinableMedia(sorted, options.maxInlineImages))
430
+ : "dense";
431
+ const consumedByPair = new Set();
432
+ let inlinedImages = 0;
433
+ const overflowImages = [];
434
+ for (let idx = 0; idx < sorted.length; idx++) {
435
+ if (consumedByPair.has(idx))
436
+ continue;
437
+ const item = sorted[idx];
438
+ const partnerIdx = partnerOf.get(idx);
439
+ if (partnerIdx !== undefined) {
440
+ const partner = sorted[partnerIdx];
441
+ if (inlinedImages + 2 <= options.maxInlineImages) {
442
+ inlinedImages += 2;
443
+ consumedByPair.add(partnerIdx);
444
+ const beforeItem = roleOf.get(idx) === "before" ? item : partner;
445
+ const afterItem = roleOf.get(idx) === "before" ? partner : item;
446
+ lines.push(renderPairRow(beforeItem, afterItem, options, density), "");
447
+ continue;
448
+ }
449
+ // Cap already full for a two-image row — degrade this pair to two
450
+ // ordinary overflow entries rather than only half-rendering the row.
451
+ overflowImages.push(item, partner);
452
+ consumedByPair.add(partnerIdx);
453
+ continue;
454
+ }
455
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
456
+ const stable = item.url;
457
+ const src = item.embedUrl ?? item.url;
458
+ const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
459
+ const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
460
+ const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
461
+ const inlines = isImage || isPosterVideo;
462
+ if (inlines && inlinedImages >= options.maxInlineImages) {
463
+ // Cap hit — defer to the collapsed overflow list below rather than
464
+ // embedding every remaining image inline.
465
+ overflowImages.push(item);
466
+ continue;
467
+ }
468
+ if (isPosterVideo) {
469
+ inlinedImages++;
470
+ const autoPx = posterImageWidth(item.videoMeta, name, density);
471
+ const w = resolvedWidth(autoPx, options);
472
+ const href = escapeHtmlAttr(link ?? item.posterUrl);
473
+ lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
474
+ // GitHub strips <video>, so a still frame needs an explicit affordance
475
+ // or it reads as a screenshot.
476
+ const parts = ["▶ Play video"];
477
+ if (item.videoMeta?.durationSeconds != null) {
478
+ parts.push(formatDuration(item.videoMeta.durationSeconds));
479
+ }
480
+ const metaCap = formatMetaCaption(item.meta, options, "html");
481
+ if (metaCap)
482
+ parts.push(metaCap);
483
+ lines.push(parts.join(" · "), "");
484
+ }
485
+ else if (isImage) {
486
+ inlinedImages++;
487
+ // Markdown ![]() has no width control — phone frames become full-column giants.
488
+ // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
489
+ const autoPx = attachmentImageWidth(name, density);
490
+ const w = resolvedWidth(autoPx, options);
491
+ const alt = escapeHtmlAttr(name);
492
+ const href = escapeHtmlAttr(link ?? src);
493
+ const imgSrc = escapeHtmlAttr(src);
494
+ lines.push(`<a href="${href}">${imgTag(w, alt, imgSrc)}</a>`);
495
+ const caption = formatMetaCaption(item.meta, options, "html");
496
+ // Body-size caption — <sub> rendered path/state metadata too small.
497
+ if (caption)
498
+ lines.push(caption);
499
+ lines.push("");
500
+ }
501
+ else if (link) {
502
+ const cap = formatMetaCaption(item.meta, options, "markdown");
503
+ lines.push(`- [${name}](${link})${cap ? ` · ${cap}` : ""}`);
504
+ }
505
+ else {
506
+ const cap = formatMetaCaption(item.meta, options, "markdown");
507
+ lines.push(`- ${name}${cap ? ` · ${cap}` : ""}`);
508
+ }
509
+ }
510
+ if (overflowImages.length > 0) {
511
+ const n = overflowImages.length;
512
+ lines.push(`<details><summary>${n} more attachment${n === 1 ? "" : "s"}</summary>`, "");
513
+ for (const item of overflowImages) {
514
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
515
+ const link = item.pageUrl ?? item.url;
516
+ const cap = formatMetaCaption(item.meta, options, "markdown");
517
+ const suffix = cap ? ` · ${cap}` : "";
518
+ lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
519
+ }
520
+ lines.push("", "</details>", "");
521
+ }
522
+ // Emptied state: a PR/issue whose attachments and galleries were all removed
523
+ // still keeps its managed comment (a later push can repopulate it) — show a
524
+ // neutral resting state rather than a bare footer. Only the truly-empty case
525
+ // (no attachments, no galleries); a galleries-only comment must not get this.
526
+ if (sorted.length === 0 && sortedGalleries.length === 0) {
527
+ lines.push("_No attachments are currently associated with this pull request._", "");
528
+ }
529
+ // Footer condensed to a single quiet line — the old two-line explainer
530
+ // ("re-uploading updates everywhere", full add-media flags) repeats on
531
+ // every PR and lost value with each appearance; details live in the docs.
532
+ lines.push('<sub>Maintained by <a href="https://uploads.sh">uploads.sh</a> · add media: <code>uploads put &lt;file&gt; --pr &lt;N&gt;</code> · <a href="https://uploads.sh/docs/github-app">docs</a></sub>');
533
+ return lines.join("\n");
534
+ }