@buildinternet/uploads 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,8 +91,12 @@ attachments when one opens: automatically via the
91
91
  [GitHub App](https://uploads.sh/docs/github-app) webhook, or on the first
92
92
  `attach` after the PR exists (`--promote` forces it with no new files,
93
93
  `--no-promote` opts out). `uploads github link` inspects or claims the
94
- workspace↔repo binding the webhook path uses. Promoted staging is cleaned up
95
- server-side after ~7 days (~30 for branches that never got a PR).
94
+ workspace↔repo binding the webhook path uses. Promotion is copy-and-keep
95
+ the staged original is never deleted, so any URL already embedded keeps
96
+ serving — and staged objects follow only normal per-workspace retention and
97
+ explicit deletes. Promotion (auto or `--promote`) does skip files staged
98
+ more than 30 days before the PR opens, though; they're still there, just no
99
+ longer auto-promoted.
96
100
 
97
101
  **Bare `put` stages too, by default (issue #403):** on a non-default git
98
102
  branch, a `put` with none of
package/dist/github.d.ts CHANGED
@@ -123,6 +123,10 @@ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
123
123
  * practical signal (we don't re-fetch dimensions when rebuilding the comment).
124
124
  */
125
125
  export declare function attachmentImageWidth(filename: string): number;
126
+ /** Max display width for one image inside a before/after pair row — smaller
127
+ * than a standalone image so two side by side stay under GitHub's comment
128
+ * column width (and don't overflow on mobile). */
129
+ export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
126
130
  /**
127
131
  * Render the one marker-owned GitHub comment. When there are no galleries this
128
132
  * intentionally preserves the legacy attachment-only body byte-for-byte.
package/dist/github.js CHANGED
@@ -255,6 +255,120 @@ function metaCaptionMarkdown(meta) {
255
255
  return "";
256
256
  return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
257
257
  }
258
+ /** Extract the filename stem's before/after token (issue #419 fallback pairing).
259
+ * `base` is the stem lowercased with the token removed; `null` when the stem
260
+ * carries no recognizable before/after token. Requires a separator (`-`, `_`,
261
+ * or `.`) between the token and the rest of the name — except when the token
262
+ * IS the whole stem (`before.png`) — so `beforehand.png` doesn't false-match. */
263
+ // Token bounded by `-`, `_`, `.`, or stem start/end, so `hero-before.webp`
264
+ // and `paired-view-before-desktop.webp` match but `beforehand.webp` does
265
+ // not. Mirrors before-after.ts's TOKEN_RE (file page), applied to the stem.
266
+ const STEM_TOKEN_RE = /(^|[-_.])(before|after)($|[-_.])/i;
267
+ function filenameStemToken(name) {
268
+ const dot = name.lastIndexOf(".");
269
+ const stem = dot > 0 ? name.slice(0, dot) : name;
270
+ const m = STEM_TOKEN_RE.exec(stem);
271
+ if (!m)
272
+ return null;
273
+ const state = m[2].toLowerCase();
274
+ const tokenStart = m.index + m[1].length;
275
+ const tokenEnd = tokenStart + m[2].length;
276
+ // Base = stem with the token and one adjoining delimiter removed, so
277
+ // `paired-view-before-desktop` and `paired-view-after-desktop` both
278
+ // collapse to `paired-view-desktop` and group together.
279
+ const base = m[1].length > 0
280
+ ? stem.slice(0, m.index) + stem.slice(tokenEnd)
281
+ : stem.slice(tokenEnd + m[3].length);
282
+ return { base: base.toLowerCase(), state };
283
+ }
284
+ /**
285
+ * Pair up attachments for the before/after side-by-side row (issue #419).
286
+ * `isImageAt[i]` mirrors the renderer's own image test — only images pair;
287
+ * videos and non-image links render exactly as before.
288
+ *
289
+ * Priority order, checked independently per candidate item so rule 2 only
290
+ * ever claims items rule 1 left untouched:
291
+ * 1. Same `path` metadata (trimmed, not bare `/`), one item `state=before`
292
+ * and one `state=after`. Ambiguous groups (more than one of a state)
293
+ * don't pair — no way to know which side goes with which.
294
+ * 2. No usable `path` metadata: filename stems that differ only by a
295
+ * before/after token, same extension. Same ambiguity rule.
296
+ */
297
+ function pairAttachments(items, isImageAt) {
298
+ const partnerOf = new Map();
299
+ const roleOf = new Map();
300
+ const pair = (beforeIdx, afterIdx) => {
301
+ partnerOf.set(beforeIdx, afterIdx);
302
+ partnerOf.set(afterIdx, beforeIdx);
303
+ roleOf.set(beforeIdx, "before");
304
+ roleOf.set(afterIdx, "after");
305
+ };
306
+ // Priority 1: same path metadata, exactly one before + one after.
307
+ const pathGroups = new Map();
308
+ items.forEach((item, i) => {
309
+ if (!isImageAt[i])
310
+ return;
311
+ const path = item.meta?.path?.trim();
312
+ if (!path || path === "/")
313
+ return;
314
+ const state = item.meta?.state?.trim().toLowerCase();
315
+ if (state !== "before" && state !== "after")
316
+ return;
317
+ const g = pathGroups.get(path) ?? { before: [], after: [] };
318
+ g[state].push(i);
319
+ pathGroups.set(path, g);
320
+ });
321
+ for (const g of pathGroups.values()) {
322
+ if (g.before.length === 1 && g.after.length === 1)
323
+ pair(g.before[0], g.after[0]);
324
+ }
325
+ // Priority 2: no usable path metadata — filename stem token, same extension.
326
+ const stemGroups = new Map();
327
+ items.forEach((item, i) => {
328
+ if (!isImageAt[i] || partnerOf.has(i))
329
+ return;
330
+ const path = item.meta?.path?.trim();
331
+ if (path && path !== "/")
332
+ return; // usable path metadata — rule 1 owns this item
333
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
334
+ const tok = filenameStemToken(name);
335
+ if (!tok)
336
+ return;
337
+ const dot = name.lastIndexOf(".");
338
+ const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
339
+ const key = `${tok.base}${ext}`;
340
+ const g = stemGroups.get(key) ?? { before: [], after: [] };
341
+ g[tok.state].push(i);
342
+ stemGroups.set(key, g);
343
+ });
344
+ for (const g of stemGroups.values()) {
345
+ if (g.before.length === 1 && g.after.length === 1)
346
+ pair(g.before[0], g.after[0]);
347
+ }
348
+ return { partnerOf, roleOf };
349
+ }
350
+ /** Max display width for one image inside a before/after pair row — smaller
351
+ * than a standalone image so two side by side stay under GitHub's comment
352
+ * column width (and don't overflow on mobile). */
353
+ export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
354
+ function renderPairCell(item, label) {
355
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
356
+ const src = item.embedUrl ?? item.url;
357
+ const link = item.pageUrl ?? item.url;
358
+ const w = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
359
+ const alt = escapeHtmlAttr(name);
360
+ const href = escapeHtmlAttr((link ?? src));
361
+ const imgSrc = escapeHtmlAttr(src);
362
+ const caption = metaCaptionHtml(item.meta);
363
+ const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
364
+ return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>${captionHtml}</td>`;
365
+ }
366
+ /** One side-by-side before/after row (issue #419): a single HTML table so
367
+ * GitHub renders both images on one line, with `Before`/`After` labels and
368
+ * each side's usual path/state caption preserved underneath. */
369
+ function renderPairRow(beforeItem, afterItem) {
370
+ return `<table><tr>${renderPairCell(beforeItem, "Before")}${renderPairCell(afterItem, "After")}</tr></table>`;
371
+ }
258
372
  /**
259
373
  * Render the one marker-owned GitHub comment. When there are no galleries this
260
374
  * intentionally preserves the legacy attachment-only body byte-for-byte.
@@ -279,9 +393,36 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
279
393
  }
280
394
  if (sorted.length > 0 || sortedGalleries.length === 0)
281
395
  lines.push("### 📎 Attachments", "");
396
+ const isImageAt = sorted.map((item) => {
397
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
398
+ const src = item.embedUrl ?? item.url;
399
+ return Boolean(src) && inferContentType(name).startsWith("image/");
400
+ });
401
+ const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
402
+ const consumedByPair = new Set();
282
403
  let inlinedImages = 0;
283
404
  const overflowImages = [];
284
- for (const item of sorted) {
405
+ for (let idx = 0; idx < sorted.length; idx++) {
406
+ if (consumedByPair.has(idx))
407
+ continue;
408
+ const item = sorted[idx];
409
+ const partnerIdx = partnerOf.get(idx);
410
+ if (partnerIdx !== undefined) {
411
+ const partner = sorted[partnerIdx];
412
+ if (inlinedImages + 2 <= MAX_INLINE_ATTACHMENT_IMAGES) {
413
+ inlinedImages += 2;
414
+ consumedByPair.add(partnerIdx);
415
+ const beforeItem = roleOf.get(idx) === "before" ? item : partner;
416
+ const afterItem = roleOf.get(idx) === "before" ? partner : item;
417
+ lines.push(renderPairRow(beforeItem, afterItem), "");
418
+ continue;
419
+ }
420
+ // Cap already full for a two-image row — degrade this pair to two
421
+ // ordinary overflow entries rather than only half-rendering the row.
422
+ overflowImages.push(item, partner);
423
+ consumedByPair.add(partnerIdx);
424
+ continue;
425
+ }
285
426
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
286
427
  const stable = item.url;
287
428
  const src = item.embedUrl ?? item.url;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,