@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 +6 -2
- package/dist/github.d.ts +4 -0
- package/dist/github.js +142 -1
- package/package.json +1 -1
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.
|
|
95
|
-
|
|
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 (
|
|
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;
|