@buildinternet/uploads 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -243,7 +243,7 @@ export interface FindGalleriesByReferenceOptions {
243
243
  * anyway, defeating the point of the server-side gate, so the CLI surfaces
244
244
  * the decline instead.
245
245
  */
246
- export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "not_authorized" | "unavailable";
246
+ export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "not_authorized" | "actor_not_authorized" | "unavailable";
247
247
  export type GithubCommentResult = {
248
248
  posted: true;
249
249
  action: "created" | "updated" | "skipped";
@@ -165,8 +165,10 @@ export interface AttachmentsCommentResult {
165
165
  export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
166
166
  /**
167
167
  * Thrown by `syncAttachmentsComment` when the server declines with
168
- * `not_authorized` (issue #297 baseline control) — this repo is bound to a
169
- * different workspace. Deliberately not caught by the generic "bot endpoint
168
+ * `not_authorized` (issue #297 baseline control — this repo is bound to a
169
+ * different workspace) or `actor_not_authorized` (issue #297 control 2 the
170
+ * workspace requires the caller to be on the target PR/issue thread).
171
+ * Deliberately not caught by the generic "bot endpoint
170
172
  * unreachable" fallback below: falling back to gh here would let the
171
173
  * human's own credentials post anyway, defeating the point of the
172
174
  * server-side gate.
@@ -490,6 +492,19 @@ export interface DoctorReport {
490
492
  scopes?: string[];
491
493
  /** Workspace/token mismatch warning (also present in hints). */
492
494
  warning?: string;
495
+ /**
496
+ * Bring-your-own-bucket storage status (issue #583 Phase 3). `GET
497
+ * /me/workspaces/:name/storage` is session-gated (Better Auth cookie or
498
+ * bearer via the AUTH service — see `session-auth.ts`); the CLI only ever
499
+ * holds a minted `up_<workspace>_…` workspace token, never a session
500
+ * bearer, so doctor cannot reach that route today. Until a token-authed
501
+ * read path exists, this is an honest "can't check from here" rather than
502
+ * a fabricated mode.
503
+ */
504
+ storage: {
505
+ checked: false;
506
+ note: string;
507
+ };
493
508
  hints: string[];
494
509
  /** `screenshot`'s local-browser detection (fs scans only — never launches a browser). */
495
510
  browser: {
package/dist/commands.js CHANGED
@@ -447,8 +447,10 @@ export function commentViaSuffix(via) {
447
447
  }
448
448
  /**
449
449
  * Thrown by `syncAttachmentsComment` when the server declines with
450
- * `not_authorized` (issue #297 baseline control) — this repo is bound to a
451
- * different workspace. Deliberately not caught by the generic "bot endpoint
450
+ * `not_authorized` (issue #297 baseline control — this repo is bound to a
451
+ * different workspace) or `actor_not_authorized` (issue #297 control 2 the
452
+ * workspace requires the caller to be on the target PR/issue thread).
453
+ * Deliberately not caught by the generic "bot endpoint
452
454
  * unreachable" fallback below: falling back to gh here would let the
453
455
  * human's own credentials post anyway, defeating the point of the
454
456
  * server-side gate.
@@ -485,6 +487,14 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
485
487
  `Run \`uploads github link --status --repo ${target.repo}\` to see who owns the ` +
486
488
  `binding, use that workspace instead, or post the comment manually with gh.`);
487
489
  }
490
+ // Actor-on-PR gate (issue #297 control 2, workspace opt-in): same
491
+ // no-gh-fallback rule as not_authorized — the workspace explicitly asked
492
+ // the server to hold this line, so the CLI shouldn't route around it.
493
+ if (bot.reason === "actor_not_authorized") {
494
+ throw new GithubCommentAuthorizationError(`${bot.message ?? `You are not an actor on ${target.repo}#${target.num}.`}\n` +
495
+ `Ask an authorized thread participant to run this, or post the ` +
496
+ `comment manually with gh.`);
497
+ }
488
498
  // Installed-but-unapproved is a fixable misconfiguration, not a silent
489
499
  // degrade: tell the user (and how to fix it) before falling back to gh.
490
500
  if (bot.reason === "forbidden" && bot.message) {
@@ -1384,7 +1394,7 @@ export function mergeStagingMeta(base, target) {
1384
1394
  * for both the human-mode stderr line and the JSON `hint` field.
1385
1395
  */
1386
1396
  export function putStagingNoteText(branch) {
1387
- return (`note: staged for branch ${branch} — auto-attaches to this branch's PR when it opens ` +
1397
+ return (`note: staged for branch ${branch} — auto-comments to pull request when opened ` +
1388
1398
  `(or run: uploads attach --promote once it exists). Use --ref/--prefix for a plain dated upload.`);
1389
1399
  }
1390
1400
  /**
@@ -2921,6 +2931,10 @@ export async function buildDoctorReport(config, client, detectRoots) {
2921
2931
  usage,
2922
2932
  scopes,
2923
2933
  warning: mismatch,
2934
+ storage: {
2935
+ checked: false,
2936
+ note: "not checked from the CLI — storage settings (shared vs. bring-your-own-bucket) live behind a signed-in session; sign in on the web (Account → workspace → Settings) to view mode and verification status",
2937
+ },
2924
2938
  hints,
2925
2939
  browser,
2926
2940
  };
@@ -2957,6 +2971,7 @@ export async function runDoctor(ctx, args, help = false) {
2957
2971
  else {
2958
2972
  lines.push(`browser: ${report.browser.note ?? "not supported in this runtime"}`);
2959
2973
  }
2974
+ lines.push(`storage: ${report.storage.note}`);
2960
2975
  if (report.warning)
2961
2976
  lines.push(`warning: ${report.warning}`);
2962
2977
  for (const h of report.hints)
package/dist/github.d.ts CHANGED
@@ -76,8 +76,8 @@ export declare function attachmentsMarker(workspace?: string): string;
76
76
  export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
77
77
  /**
78
78
  * Per-render knobs for the managed comment (issue #307), sourced from repo
79
- * comment config. `imageWidth: "auto"` preserves today's per-item width
80
- * heuristics (`attachmentImageWidth`/`posterImageWidth`/pair cap); `"full"`
79
+ * comment config. `imageWidth: "auto"` uses per-item filename heuristics plus
80
+ * density-aware sizing (solo/sparse/dense from the inlined count); `"full"`
81
81
  * omits the `width` attribute entirely; a number overrides every width site.
82
82
  */
83
83
  export interface CommentRenderOptions {
@@ -134,21 +134,29 @@ export interface GalleryCommentItem {
134
134
  itemUrl?: string;
135
135
  }[];
136
136
  }
137
- /** Default max width for images in the managed attachments comment (HTML img). */
137
+ /**
138
+ * How crowded the managed comment is. Sparse comments (one shot, a single
139
+ * before/after) get larger embeds; dense comments keep compact historical sizes.
140
+ */
141
+ export type AttachmentDensity = "solo" | "sparse" | "dense";
142
+ /** Dense (historical) default max width for images in the managed comment. */
138
143
  export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
139
144
  /** Portrait / device mockups — keep phones readable, not full-column. */
140
145
  export declare const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
141
146
  /** Wide UI / browser chrome. */
142
147
  export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
148
+ /** Dense pair-cell cap (side-by-side before/after). */
149
+ export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
150
+ /** Map an inlined-media count onto a density tier. */
151
+ export declare function attachmentDensityForCount(inlinedCount: number): AttachmentDensity;
152
+ /** Pair-cell cap for the given density. */
153
+ export declare function attachmentPairWidth(density?: AttachmentDensity): number;
143
154
  /**
144
- * Pick a display width for GitHub comment embeds. Filenames are a weak but
145
- * practical signal (we don't re-fetch dimensions when rebuilding the comment).
155
+ * Display width for a GitHub comment embed. Filenames are a weak but practical
156
+ * signal (we don't re-fetch dimensions when rebuilding the comment). `density`
157
+ * only affects managed-comment auto layout; other callers leave it `"dense"`.
146
158
  */
147
- export declare function attachmentImageWidth(filename: string): number;
148
- /** Max display width for one image inside a before/after pair row — smaller
149
- * than a standalone image so two side by side stay under GitHub's comment
150
- * column width (and don't overflow on mobile). */
151
- export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
159
+ export declare function attachmentImageWidth(filename: string, density?: AttachmentDensity): number;
152
160
  /**
153
161
  * Render the one marker-owned GitHub comment. When there are no galleries this
154
162
  * intentionally preserves the legacy attachment-only body byte-for-byte.
package/dist/github.js CHANGED
@@ -171,26 +171,52 @@ export const AUTO_RENDER_OPTIONS = {
171
171
  metaState: true,
172
172
  note: null,
173
173
  };
174
- /** Default max width for images in the managed attachments comment (HTML img). */
174
+ /** Dense (historical) default max width for images in the managed comment. */
175
175
  export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
176
176
  /** Portrait / device mockups — keep phones readable, not full-column. */
177
177
  export const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
178
178
  /** Wide UI / browser chrome. */
179
179
  export const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
180
+ /** Dense pair-cell cap (side-by-side before/after). */
181
+ export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
182
+ /** Per-density widths for `imageWidth: "auto"`. Dense reuses the exports above. */
183
+ const WIDTH_BY_DENSITY = {
184
+ solo: { default: 720, portrait: 360, wide: 800, pair: 400 },
185
+ sparse: { default: 560, portrait: 300, wide: 720, pair: 380 },
186
+ dense: {
187
+ default: ATTACHMENT_IMAGE_WIDTH_DEFAULT,
188
+ portrait: ATTACHMENT_IMAGE_WIDTH_PORTRAIT,
189
+ wide: ATTACHMENT_IMAGE_WIDTH_WIDE,
190
+ pair: ATTACHMENT_IMAGE_WIDTH_PAIR,
191
+ },
192
+ };
193
+ /** Map an inlined-media count onto a density tier. */
194
+ export function attachmentDensityForCount(inlinedCount) {
195
+ if (inlinedCount <= 1)
196
+ return "solo";
197
+ if (inlinedCount <= 3)
198
+ return "sparse";
199
+ return "dense";
200
+ }
201
+ /** Pair-cell cap for the given density. */
202
+ export function attachmentPairWidth(density = "dense") {
203
+ return WIDTH_BY_DENSITY[density].pair;
204
+ }
180
205
  /**
181
- * Pick a display width for GitHub comment embeds. Filenames are a weak but
182
- * practical signal (we don't re-fetch dimensions when rebuilding the comment).
206
+ * Display width for a GitHub comment embed. Filenames are a weak but practical
207
+ * signal (we don't re-fetch dimensions when rebuilding the comment). `density`
208
+ * only affects managed-comment auto layout; other callers leave it `"dense"`.
183
209
  */
184
- export function attachmentImageWidth(filename) {
210
+ export function attachmentImageWidth(filename, density = "dense") {
211
+ const table = WIDTH_BY_DENSITY[density];
185
212
  const n = filename.toLowerCase();
186
- if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n)) {
187
- return ATTACHMENT_IMAGE_WIDTH_WIDE;
188
- }
213
+ if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n))
214
+ return table.wide;
189
215
  if (/(?:^|[-_.])(phone|iphone|ipad|pixel|android|mobile|device)(?:[-_.]|$)/.test(n) ||
190
216
  /iphone|pixel-?\d/.test(n)) {
191
- return ATTACHMENT_IMAGE_WIDTH_PORTRAIT;
217
+ return table.portrait;
192
218
  }
193
- return ATTACHMENT_IMAGE_WIDTH_DEFAULT;
219
+ return table.default;
194
220
  }
195
221
  /** `m:ss` under an hour, `h:mm:ss` at or above one. */
196
222
  function formatDuration(seconds) {
@@ -205,19 +231,16 @@ function formatDuration(seconds) {
205
231
  }
206
232
  /**
207
233
  * Display width for a video poster. Real dimensions only *select* among the
208
- * width constants — a raw 1920 would blow out the comment column — and the
209
- * result is capped at the real width so a small clip is never upscaled.
234
+ * density table's tiers — a raw 1920 would blow out the comment column — and
235
+ * the result is capped at the real width so a small clip is never upscaled.
210
236
  */
211
- function posterImageWidth(videoMeta, filename) {
237
+ function posterImageWidth(videoMeta, filename, density = "dense") {
212
238
  const w = videoMeta?.width ?? 0;
213
239
  const h = videoMeta?.height ?? 0;
214
240
  if (w <= 0 || h <= 0)
215
- return attachmentImageWidth(filename);
216
- const chosen = h > w
217
- ? ATTACHMENT_IMAGE_WIDTH_PORTRAIT
218
- : w / h >= 16 / 9
219
- ? ATTACHMENT_IMAGE_WIDTH_WIDE
220
- : ATTACHMENT_IMAGE_WIDTH_DEFAULT;
241
+ return attachmentImageWidth(filename, density);
242
+ const table = WIDTH_BY_DENSITY[density];
243
+ const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
221
244
  return Math.min(chosen, w);
222
245
  }
223
246
  function escapeHtmlAttr(s) {
@@ -236,45 +259,37 @@ function escapeMarkdownText(s) {
236
259
  return s.replace(/([\\`*_[\]~])/g, "\\$1");
237
260
  }
238
261
  /**
239
- * An attachment's caption parts `path`, then `state` (issue #365). Empty
240
- * when neither is usable, so callers emit nothing at all and a body with no
241
- * metadata stays byte-identical to the pre-#365 render.
242
- *
243
- * Neither value is pre-sanitized: metadata values are printable ASCII up to
244
- * 512 chars, and while the CLI validates `--state` against a closed enum,
245
- * `PATCH /v1/:workspace/files/:key` can set any valid metadata value. A
246
- * whitespace-only value passes that validation (length-1 printable ASCII), so
247
- * treat it as absent rather than rendering a dangling separator.
248
- *
249
- * Bare `/` is stored/searchable but omitted from captions (issue #375) —
250
- * alone it is a stray character, and as a prefix next to `state` it is
251
- * noise. Only exact `/` after trim is suppressed.
262
+ * Collect path then state for a caption (issue #365). Bare `/` and
263
+ * whitespace-only values are omitted (issue #375). Empty when nothing usable.
252
264
  */
253
- function metaCaptionParts(meta, options) {
254
- const parts = [];
265
+ function metaCaptionValues(meta, options) {
266
+ const values = [];
255
267
  const path = meta?.path?.trim();
256
268
  if (options.metaPath && path && path !== "/")
257
- parts.push(path);
269
+ values.push(path);
258
270
  const state = meta?.state?.trim();
259
271
  if (options.metaState && state)
260
- parts.push(state);
261
- return parts;
262
- }
263
- /** `<sub>` caption body for an inline image, or null when there is nothing to say. */
264
- function metaCaptionHtml(meta, options) {
265
- const parts = metaCaptionParts(meta, options);
266
- return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
272
+ values.push(state);
273
+ return values;
267
274
  }
268
275
  /**
269
- * ` · …` suffix for a markdown list row, or `""` when there is nothing to add.
270
- * HTML-escapes first, then markdown-escapes: HTML escaping introduces no
271
- * backslashes or brackets, so the markdown pass cannot corrupt its entities.
276
+ * Format path/state as code tokens. HTML `<code>…</code>`; markdown
277
+ * `` `…` `` (backslash-escape if the value itself contains a backtick).
278
+ * Returns `""` when there is nothing to say.
272
279
  */
273
- function metaCaptionMarkdown(meta, options) {
274
- const parts = metaCaptionParts(meta, options);
275
- if (parts.length === 0)
280
+ function formatMetaCaption(meta, options, mode) {
281
+ const values = metaCaptionValues(meta, options);
282
+ if (values.length === 0)
276
283
  return "";
277
- return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
284
+ if (mode === "html") {
285
+ return values.map((v) => `<code>${escapeHtmlText(v)}</code>`).join(" · ");
286
+ }
287
+ return values
288
+ .map((v) => {
289
+ const esc = escapeHtmlText(v);
290
+ return esc.includes("`") ? escapeMarkdownText(esc) : `\`${esc}\``;
291
+ })
292
+ .join(" · ");
278
293
  }
279
294
  /** Resolved pixel width for an image site, or `null` meaning "omit the width
280
295
  * attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
@@ -388,28 +403,37 @@ function pairAttachments(items, isImageAt) {
388
403
  }
389
404
  return { partnerOf, roleOf };
390
405
  }
391
- /** Max display width for one image inside a before/after pair row — smaller
392
- * than a standalone image so two side by side stay under GitHub's comment
393
- * column width (and don't overflow on mobile). */
394
- export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
395
- function renderPairCell(item, label, options) {
406
+ function renderPairCell(item, label, options, density) {
396
407
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
397
408
  const src = item.embedUrl ?? item.url;
398
409
  const link = item.pageUrl ?? item.url;
399
- const autoPx = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
410
+ const autoPx = Math.min(attachmentImageWidth(name, density), attachmentPairWidth(density));
400
411
  const w = resolvedWidth(autoPx, options);
401
412
  const alt = escapeHtmlAttr(name);
402
413
  const href = escapeHtmlAttr((link ?? src));
403
414
  const imgSrc = escapeHtmlAttr(src);
404
- const caption = metaCaptionHtml(item.meta, options);
415
+ const caption = formatMetaCaption(item.meta, options, "html");
405
416
  const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
406
417
  return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}">${imgTag(w, alt, imgSrc)}</a>${captionHtml}</td>`;
407
418
  }
408
- /** One side-by-side before/after row (issue #419): a single HTML table so
409
- * GitHub renders both images on one line, with `Before`/`After` labels and
410
- * each side's usual path/state caption preserved underneath. */
411
- function renderPairRow(beforeItem, afterItem, options) {
412
- return `<table><tr>${renderPairCell(beforeItem, "Before", options)}${renderPairCell(afterItem, "After", options)}</tr></table>`;
419
+ /** One side-by-side before/after row (issue #419). */
420
+ function renderPairRow(beforeItem, afterItem, options, density) {
421
+ return `<table><tr>${renderPairCell(beforeItem, "Before", options, density)}${renderPairCell(afterItem, "After", options, density)}</tr></table>`;
422
+ }
423
+ /** How many image/poster items will fit under `maxInlineImages` (for density). */
424
+ function countInlinableMedia(sorted, maxInlineImages) {
425
+ let count = 0;
426
+ for (const item of sorted) {
427
+ if (count >= maxInlineImages)
428
+ break;
429
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
430
+ const src = item.embedUrl ?? item.url;
431
+ const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
432
+ const isPoster = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
433
+ if (isImage || isPoster)
434
+ count++;
435
+ }
436
+ return count;
413
437
  }
414
438
  /**
415
439
  * Render the one marker-owned GitHub comment. When there are no galleries this
@@ -436,14 +460,16 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
436
460
  }
437
461
  lines.push("");
438
462
  }
439
- if (sorted.length > 0 || sortedGalleries.length === 0)
440
- lines.push("### 📎 Attachments", "");
441
463
  const isImageAt = sorted.map((item) => {
442
464
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
443
465
  const src = item.embedUrl ?? item.url;
444
466
  return Boolean(src) && inferContentType(name).startsWith("image/");
445
467
  });
446
468
  const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
469
+ // One screenshot → large; a wall of shots → compact historical sizes.
470
+ const density = options.imageWidth === "auto"
471
+ ? attachmentDensityForCount(countInlinableMedia(sorted, options.maxInlineImages))
472
+ : "dense";
447
473
  const consumedByPair = new Set();
448
474
  let inlinedImages = 0;
449
475
  const overflowImages = [];
@@ -459,7 +485,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
459
485
  consumedByPair.add(partnerIdx);
460
486
  const beforeItem = roleOf.get(idx) === "before" ? item : partner;
461
487
  const afterItem = roleOf.get(idx) === "before" ? partner : item;
462
- lines.push(renderPairRow(beforeItem, afterItem, options), "");
488
+ lines.push(renderPairRow(beforeItem, afterItem, options, density), "");
463
489
  continue;
464
490
  }
465
491
  // Cap already full for a two-image row — degrade this pair to two
@@ -483,7 +509,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
483
509
  }
484
510
  if (isPosterVideo) {
485
511
  inlinedImages++;
486
- const autoPx = posterImageWidth(item.videoMeta, name);
512
+ const autoPx = posterImageWidth(item.videoMeta, name, density);
487
513
  const w = resolvedWidth(autoPx, options);
488
514
  const href = escapeHtmlAttr(link ?? item.posterUrl);
489
515
  lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
@@ -493,29 +519,33 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
493
519
  if (item.videoMeta?.durationSeconds != null) {
494
520
  parts.push(formatDuration(item.videoMeta.durationSeconds));
495
521
  }
496
- parts.push(...metaCaptionParts(item.meta, options).map(escapeHtmlText));
522
+ const metaCap = formatMetaCaption(item.meta, options, "html");
523
+ if (metaCap)
524
+ parts.push(metaCap);
497
525
  lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
498
526
  }
499
527
  else if (isImage) {
500
528
  inlinedImages++;
501
529
  // Markdown ![]() has no width control — phone frames become full-column giants.
502
530
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
503
- const autoPx = attachmentImageWidth(name);
531
+ const autoPx = attachmentImageWidth(name, density);
504
532
  const w = resolvedWidth(autoPx, options);
505
533
  const alt = escapeHtmlAttr(name);
506
534
  const href = escapeHtmlAttr(link ?? src);
507
535
  const imgSrc = escapeHtmlAttr(src);
508
536
  lines.push(`<a href="${href}">${imgTag(w, alt, imgSrc)}</a>`);
509
- const caption = metaCaptionHtml(item.meta, options);
537
+ const caption = formatMetaCaption(item.meta, options, "html");
510
538
  if (caption)
511
539
  lines.push(`<sub>${caption}</sub>`);
512
540
  lines.push("");
513
541
  }
514
542
  else if (link) {
515
- lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta, options)}`);
543
+ const cap = formatMetaCaption(item.meta, options, "markdown");
544
+ lines.push(`- [${name}](${link})${cap ? ` · ${cap}` : ""}`);
516
545
  }
517
546
  else {
518
- lines.push(`- ${name}${metaCaptionMarkdown(item.meta, options)}`);
547
+ const cap = formatMetaCaption(item.meta, options, "markdown");
548
+ lines.push(`- ${name}${cap ? ` · ${cap}` : ""}`);
519
549
  }
520
550
  }
521
551
  if (overflowImages.length > 0) {
@@ -524,16 +554,16 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
524
554
  for (const item of overflowImages) {
525
555
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
526
556
  const link = item.pageUrl ?? item.url;
527
- const suffix = metaCaptionMarkdown(item.meta, options);
557
+ const cap = formatMetaCaption(item.meta, options, "markdown");
558
+ const suffix = cap ? ` · ${cap}` : "";
528
559
  lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
529
560
  }
530
561
  lines.push("", "</details>", "");
531
562
  }
532
563
  // Emptied state: a PR/issue whose attachments and galleries were all removed
533
564
  // still keeps its managed comment (a later push can repopulate it) — show a
534
- // neutral resting state under the heading rather than a bare heading. Only
535
- // the truly-empty case (no attachments, no galleries); a galleries-only
536
- // comment has no Attachments heading and must not get this line.
565
+ // neutral resting state rather than a bare footer. Only the truly-empty case
566
+ // (no attachments, no galleries); a galleries-only comment must not get this.
537
567
  if (sorted.length === 0 && sortedGalleries.length === 0) {
538
568
  lines.push("_No attachments are currently associated with this pull request._", "");
539
569
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,