@buildinternet/uploads 0.33.1 → 0.34.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/dist/commands.js CHANGED
@@ -5,6 +5,7 @@ import { createUploadsClient, } from "./client.js";
5
5
  import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
6
6
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
7
7
  import { buildMarkdown } from "./embed.js";
8
+ import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
8
9
  import { urlForGithubEmbed } from "./public-urls.js";
9
10
  import { UploadsError } from "./errors.js";
10
11
  import { writeJson, writeStdout } from "./io.js";
@@ -12,7 +13,7 @@ import { imageFactsFromBytes } from "./image-facts.js";
12
13
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
13
14
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
14
15
  import { mergeSidecarMeta } from "./sidecar.js";
15
- import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
16
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
16
17
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
17
18
  import { deriveRepoFromGit } from "./keys.js";
18
19
  import { resolvePutPrefix } from "./destinations.js";
@@ -104,9 +105,13 @@ Options:
104
105
  --format human|url|markdown|json
105
106
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
106
107
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
107
- --comment With --pr/--issue: update one managed comment with
108
- attachments and linked galleries. Posts as uploads-sh[bot]
109
- when the GitHub App is installed; otherwise via local gh.
108
+ --comment With --pr/--issue, the managed comment sync runs by
109
+ default; --comment is accepted as a no-op for
110
+ back-compat (kept redundant with the default).
111
+ --no-comment With --pr/--issue: skip updating the managed comment
112
+ with attachments and linked galleries. Otherwise it
113
+ posts as uploads-sh[bot] when the GitHub App is
114
+ installed, or via local gh as a fallback.
110
115
  --gallery <id> Add the uploaded object(s) to this public gallery
111
116
  --meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
112
117
  Re-uploading to an existing key WITH --meta replaces that file's
@@ -117,7 +122,7 @@ Options:
117
122
  --replace Allow overwriting an existing object on a strict (--key/default) key
118
123
  (or UPLOADS_OVERWRITE=1). No effect on --pr/--issue, which always overwrite.
119
124
  --dry-run Print key + public URL without uploading; reports if the key would replace
120
- (or, on a strict key, be refused). Not with --comment/--gallery
125
+ (or, on a strict key, be refused). Not with --gallery
121
126
 
122
127
  A bare put (no --pr/--issue/--key) on a non-default git branch prints a one-line
123
128
  nudge toward --pr/attach --branch (stderr in human mode, a "hint" field in
@@ -543,9 +548,31 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
543
548
  }
544
549
  }));
545
550
  const marker = attachmentsMarker(workspace);
551
+ // Honor a committed .uploads.yml on the working tree (issue #307). This CLI
552
+ // process has no server-side WorkspaceRecord in scope (see the note above),
553
+ // so it resolves against `null` workspace defaults — a documented
554
+ // divergence from the bot path (commands.ts:728 / apps/api's
555
+ // resolveRepoCommentOptions, which also layers the workspace's own
556
+ // githubComment* fields).
557
+ let renderOptions = AUTO_RENDER_OPTIONS;
558
+ try {
559
+ const root = run("git", ["rev-parse", "--show-toplevel"]).trim();
560
+ const { config } = readLocalRepoCommentConfig(root);
561
+ const { options } = resolveCommentOptions(config, null);
562
+ renderOptions = {
563
+ imageWidth: options.imageWidth,
564
+ maxInlineImages: options.maxInlineImages,
565
+ metaPath: options.metaPath,
566
+ metaState: options.metaState,
567
+ note: options.note,
568
+ };
569
+ }
570
+ catch {
571
+ // Not a git repo, or the config file couldn't be read — fall back to auto.
572
+ }
546
573
  // Append only on the local-gh path: bot posts already carry the uploads-sh
547
574
  // bot identity, so this note would be wrong there.
548
- const body = `${attachmentsCommentBody(items, previewGalleries, marker)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
575
+ const body = `${attachmentsCommentBody(items, previewGalleries, marker, renderOptions)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
549
576
  const count = items.length + previewGalleries.length;
550
577
  // Empty (count 0) renders the neutral empty-state body but must not create a
551
578
  // comment — it only rewrites one that already exists (`action: "skipped"`
@@ -1504,7 +1531,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1504
1531
  const destFlag = flagString(parsed.flags, "--destination");
1505
1532
  const prefixFlag = flagString(parsed.flags, "--prefix");
1506
1533
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
1507
- const wantComment = parsed.flags.has("--comment");
1534
+ // Comment sync runs by default with --pr/--issue (matches `attach`); opt
1535
+ // out with --no-comment. --comment is accepted as a redundant no-op for
1536
+ // back-compat with scripts written before this default flipped (#537).
1537
+ const wantComment = !parsed.flags.has("--no-comment");
1508
1538
  const galleryId = flagString(parsed.flags, "--gallery");
1509
1539
  const nameFlag = flagString(parsed.flags, "--name");
1510
1540
  const dryRun = flagBool(parsed.flags, "--dry-run");
@@ -1523,17 +1553,21 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1523
1553
  validateMetaMap(merged);
1524
1554
  return merged;
1525
1555
  })();
1526
- if (wantComment && typeof parsed.flags.get("--comment") === "string") {
1556
+ if (parsed.flags.has("--comment") && typeof parsed.flags.get("--comment") === "string") {
1527
1557
  throw new UsageError("--comment takes no value — place it after the file argument");
1528
1558
  }
1559
+ if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
1560
+ throw new UsageError("--no-comment takes no value");
1561
+ }
1529
1562
  if (parsed.flags.has("--auto") && typeof parsed.flags.get("--auto") === "string") {
1530
1563
  throw new UsageError("--auto takes no value");
1531
1564
  }
1532
1565
  if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") {
1533
1566
  throw new UsageError("--no-auto takes no value");
1534
1567
  }
1535
- if (wantComment && !ghTarget)
1536
- throw new UsageError("--comment requires --pr or --issue");
1568
+ if (parsed.flags.has("--no-comment") && !ghTarget) {
1569
+ throw new UsageError("--no-comment requires --pr or --issue");
1570
+ }
1537
1571
  if (multi) {
1538
1572
  if (keyHint)
1539
1573
  throw new UsageError("--key cannot be combined with multiple files");
@@ -1560,12 +1594,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1560
1594
  if (keyHint)
1561
1595
  throw new UsageError("--name cannot be combined with --key");
1562
1596
  }
1563
- if (dryRun) {
1564
- if (wantComment)
1565
- throw new UsageError("--dry-run cannot be combined with --comment");
1566
- if (galleryId)
1567
- throw new UsageError("--dry-run cannot be combined with --gallery");
1568
- }
1597
+ if (dryRun && galleryId)
1598
+ throw new UsageError("--dry-run cannot be combined with --gallery");
1569
1599
  let resolvedPrefix;
1570
1600
  try {
1571
1601
  resolvedPrefix = resolvePutPrefix({
@@ -1760,7 +1790,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1760
1790
  }
1761
1791
  let comment;
1762
1792
  let commentError;
1763
- if (wantComment && ghTarget && uploads.length > 0) {
1793
+ if (wantComment && ghTarget && !dryRun && uploads.length > 0) {
1764
1794
  try {
1765
1795
  comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
1766
1796
  if (logHuman)
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Published-CLI COPY of the workspace parser/resolver (issue #307)
3
+ * (packages/comment-config/src/index.ts). The published CLI imports no
4
+ * @uploads/* package, so that private package cannot be shared here — its
5
+ * parser/resolver is copied verbatim below. Kept in sync by
6
+ * test/fixtures/comment-config-golden.json, asserted from both sides (this
7
+ * package's comment-config.test.ts and the canonical package's
8
+ * index.test.ts) — change both copies together. `readLocalRepoCommentConfig`
9
+ * below is CLI-only: it reads the six candidate config paths off the working
10
+ * tree (the server instead fetches them from GitHub's contents API —
11
+ * apps/api/src/repo-comment-config.ts).
12
+ */
13
+ export interface RepoCommentConfig {
14
+ imageWidth?: "auto" | "full" | number;
15
+ maxInlineImages?: number;
16
+ metaPath?: boolean;
17
+ metaState?: boolean;
18
+ linkToFilePage?: boolean;
19
+ note?: string;
20
+ }
21
+ export interface WorkspaceCommentDefaults {
22
+ imageWidth?: "full" | number;
23
+ maxInlineImages?: number;
24
+ showMetadata?: boolean;
25
+ linkToFilePage?: boolean;
26
+ note?: string;
27
+ }
28
+ export interface ResolvedCommentOptions {
29
+ imageWidth: "auto" | "full" | number;
30
+ maxInlineImages: number;
31
+ metaPath: boolean;
32
+ metaState: boolean;
33
+ linkToFilePage: boolean;
34
+ note: string | null;
35
+ }
36
+ export type OptionSource = "repo" | "workspace" | "auto";
37
+ export declare const AUTO_COMMENT_OPTIONS: ResolvedCommentOptions;
38
+ export declare const NOTE_MAX_CHARS = 500;
39
+ export declare function parseRepoCommentConfig(text: string, format: "yaml" | "json"): {
40
+ config: RepoCommentConfig | null;
41
+ warnings: string[];
42
+ };
43
+ export declare function resolveCommentOptions(repo: RepoCommentConfig | null, ws: WorkspaceCommentDefaults | null): {
44
+ options: ResolvedCommentOptions;
45
+ source: Record<keyof ResolvedCommentOptions, OptionSource>;
46
+ };
47
+ /**
48
+ * Candidate paths, checked in this order — the first hit wins. Must match
49
+ * the server's REPO_CONFIG_PATHS exactly (apps/api/src/repo-comment-config.ts)
50
+ * so a committed config resolves identically whether the bot or the local
51
+ * gh fallback renders the comment.
52
+ */
53
+ export declare const REPO_CONFIG_PATHS: readonly [".uploads.yml", ".uploads.yaml", ".uploads.json", ".github/uploads.yml", ".github/uploads.yaml", ".github/uploads.json"];
54
+ /**
55
+ * Read the repo's comment config off the local working tree — the CLI has no
56
+ * GitHub App installation token to fetch via the contents API, so this reads
57
+ * `rootDir` directly. Returns the first candidate that exists (readable or
58
+ * not); an unreadable file (permissions, or a directory at that path) is
59
+ * treated as absent and the search continues to the next candidate, matching
60
+ * a 404 in the server-side fetch.
61
+ */
62
+ export declare function readLocalRepoCommentConfig(rootDir: string): {
63
+ config: RepoCommentConfig | null;
64
+ path: string | null;
65
+ warnings: string[];
66
+ };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Published-CLI COPY of the workspace parser/resolver (issue #307)
3
+ * (packages/comment-config/src/index.ts). The published CLI imports no
4
+ * @uploads/* package, so that private package cannot be shared here — its
5
+ * parser/resolver is copied verbatim below. Kept in sync by
6
+ * test/fixtures/comment-config-golden.json, asserted from both sides (this
7
+ * package's comment-config.test.ts and the canonical package's
8
+ * index.test.ts) — change both copies together. `readLocalRepoCommentConfig`
9
+ * below is CLI-only: it reads the six candidate config paths off the working
10
+ * tree (the server instead fetches them from GitHub's contents API —
11
+ * apps/api/src/repo-comment-config.ts).
12
+ */
13
+ import fs from "node:fs";
14
+ import { join } from "node:path";
15
+ import { parse as parseYaml } from "yaml";
16
+ export const AUTO_COMMENT_OPTIONS = {
17
+ imageWidth: "auto",
18
+ maxInlineImages: 16, // MAX_INLINE_ATTACHMENT_IMAGES — renderer copies own the constant
19
+ metaPath: true,
20
+ metaState: true,
21
+ linkToFilePage: true,
22
+ note: null,
23
+ };
24
+ export const NOTE_MAX_CHARS = 500;
25
+ const WIDTH_MIN = 160;
26
+ const WIDTH_MAX = 1000;
27
+ const MAX_INLINE_MIN = 1;
28
+ const MAX_INLINE_MAX = 48;
29
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, Math.round(n)));
30
+ export function parseRepoCommentConfig(text, format) {
31
+ const warnings = [];
32
+ let root;
33
+ try {
34
+ root = format === "json" ? JSON.parse(text) : parseYaml(text);
35
+ }
36
+ catch {
37
+ return { config: null, warnings: ["config file could not be parsed; ignoring it"] };
38
+ }
39
+ if (typeof root !== "object" || root === null || Array.isArray(root)) {
40
+ return { config: null, warnings };
41
+ }
42
+ const comment = root.comment;
43
+ if (typeof comment !== "object" || comment === null || Array.isArray(comment)) {
44
+ return { config: null, warnings };
45
+ }
46
+ const c = comment;
47
+ const config = {};
48
+ // imageWidth: "auto" | "full" | finite number (clamped)
49
+ if ("imageWidth" in c) {
50
+ const v = c.imageWidth;
51
+ if (v === "auto" || v === "full")
52
+ config.imageWidth = v;
53
+ else if (typeof v === "number" && Number.isFinite(v))
54
+ config.imageWidth = clamp(v, WIDTH_MIN, WIDTH_MAX);
55
+ else
56
+ warnings.push(`imageWidth: expected "auto", "full", or a number; dropped`);
57
+ }
58
+ // maxInlineImages: finite number (clamped)
59
+ if ("maxInlineImages" in c) {
60
+ const v = c.maxInlineImages;
61
+ if (typeof v === "number" && Number.isFinite(v))
62
+ config.maxInlineImages = clamp(v, MAX_INLINE_MIN, MAX_INLINE_MAX);
63
+ else
64
+ warnings.push(`maxInlineImages: expected a number; dropped`);
65
+ }
66
+ // linkToFilePage: boolean
67
+ if ("linkToFilePage" in c) {
68
+ const v = c.linkToFilePage;
69
+ if (typeof v === "boolean")
70
+ config.linkToFilePage = v;
71
+ else
72
+ warnings.push(`linkToFilePage: expected a boolean; dropped`);
73
+ }
74
+ // meta.path / meta.state: booleans nested under `meta`
75
+ if ("meta" in c) {
76
+ const v = c.meta;
77
+ if (typeof v === "object" && v !== null && !Array.isArray(v)) {
78
+ const meta = v;
79
+ if ("path" in meta) {
80
+ if (typeof meta.path === "boolean")
81
+ config.metaPath = meta.path;
82
+ else
83
+ warnings.push(`meta.path: expected a boolean; dropped`);
84
+ }
85
+ if ("state" in meta) {
86
+ if (typeof meta.state === "boolean")
87
+ config.metaState = meta.state;
88
+ else
89
+ warnings.push(`meta.state: expected a boolean; dropped`);
90
+ }
91
+ }
92
+ else {
93
+ warnings.push(`meta: expected an object; dropped`);
94
+ }
95
+ }
96
+ // note: non-empty trimmed string, max NOTE_MAX_CHARS (never truncated)
97
+ if ("note" in c) {
98
+ const v = c.note;
99
+ if (typeof v === "string") {
100
+ const trimmed = v.trim();
101
+ if (trimmed.length === 0) {
102
+ // empty/whitespace note is treated as absent
103
+ }
104
+ else if (trimmed.length > NOTE_MAX_CHARS) {
105
+ warnings.push(`note: longer than ${NOTE_MAX_CHARS} characters; dropped (not truncated)`);
106
+ }
107
+ else {
108
+ config.note = trimmed;
109
+ }
110
+ }
111
+ else {
112
+ warnings.push(`note: expected a string; dropped`);
113
+ }
114
+ }
115
+ return { config, warnings };
116
+ }
117
+ export function resolveCommentOptions(repo, ws) {
118
+ const wsAsRepo = {
119
+ ...(ws?.imageWidth !== undefined ? { imageWidth: ws.imageWidth } : {}),
120
+ ...(ws?.maxInlineImages !== undefined ? { maxInlineImages: ws.maxInlineImages } : {}),
121
+ ...(ws?.showMetadata !== undefined
122
+ ? { metaPath: ws.showMetadata, metaState: ws.showMetadata }
123
+ : {}),
124
+ ...(ws?.linkToFilePage !== undefined ? { linkToFilePage: ws.linkToFilePage } : {}),
125
+ ...(ws?.note ? { note: ws.note } : {}),
126
+ };
127
+ const options = { ...AUTO_COMMENT_OPTIONS };
128
+ const source = Object.fromEntries(Object.keys(AUTO_COMMENT_OPTIONS).map((k) => [k, "auto"]));
129
+ const apply = (cfg, from) => {
130
+ for (const key of [
131
+ "imageWidth",
132
+ "maxInlineImages",
133
+ "metaPath",
134
+ "metaState",
135
+ "linkToFilePage",
136
+ ]) {
137
+ if (cfg[key] !== undefined && source[key] === "auto") {
138
+ options[key] = cfg[key];
139
+ source[key] = from;
140
+ }
141
+ }
142
+ if (cfg.note !== undefined && source.note === "auto") {
143
+ options.note = cfg.note;
144
+ source.note = from;
145
+ }
146
+ };
147
+ if (repo)
148
+ apply(repo, "repo");
149
+ apply(wsAsRepo, "workspace");
150
+ return { options, source };
151
+ }
152
+ /**
153
+ * Candidate paths, checked in this order — the first hit wins. Must match
154
+ * the server's REPO_CONFIG_PATHS exactly (apps/api/src/repo-comment-config.ts)
155
+ * so a committed config resolves identically whether the bot or the local
156
+ * gh fallback renders the comment.
157
+ */
158
+ export const REPO_CONFIG_PATHS = [
159
+ ".uploads.yml",
160
+ ".uploads.yaml",
161
+ ".uploads.json",
162
+ ".github/uploads.yml",
163
+ ".github/uploads.yaml",
164
+ ".github/uploads.json",
165
+ ];
166
+ /**
167
+ * Read the repo's comment config off the local working tree — the CLI has no
168
+ * GitHub App installation token to fetch via the contents API, so this reads
169
+ * `rootDir` directly. Returns the first candidate that exists (readable or
170
+ * not); an unreadable file (permissions, or a directory at that path) is
171
+ * treated as absent and the search continues to the next candidate, matching
172
+ * a 404 in the server-side fetch.
173
+ */
174
+ export function readLocalRepoCommentConfig(rootDir) {
175
+ for (const candidate of REPO_CONFIG_PATHS) {
176
+ let text;
177
+ try {
178
+ text = fs.readFileSync(join(rootDir, candidate), "utf8");
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ const format = candidate.endsWith(".json") ? "json" : "yaml";
184
+ const { config, warnings } = parseRepoCommentConfig(text, format);
185
+ return { config, path: candidate, warnings };
186
+ }
187
+ return { config: null, path: null, warnings: [] };
188
+ }
package/dist/github.d.ts CHANGED
@@ -74,6 +74,22 @@ export declare function attachmentsMarker(workspace?: string): string;
74
74
  * into a `<details>` link list. Keeps very large threads from becoming a wall
75
75
  * of images. */
76
76
  export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
77
+ /**
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"`
81
+ * omits the `width` attribute entirely; a number overrides every width site.
82
+ */
83
+ export interface CommentRenderOptions {
84
+ imageWidth: "auto" | "full" | number;
85
+ maxInlineImages: number;
86
+ metaPath: boolean;
87
+ metaState: boolean;
88
+ note: string | null;
89
+ }
90
+ /** Today's behavior, expressed as options — the default for every caller that
91
+ * hasn't opted into repo comment config. */
92
+ export declare const AUTO_RENDER_OPTIONS: CommentRenderOptions;
77
93
  export interface AttachmentItem {
78
94
  key: string;
79
95
  url: string | null;
@@ -137,7 +153,7 @@ export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
137
153
  * Render the one marker-owned GitHub comment. When there are no galleries this
138
154
  * intentionally preserves the legacy attachment-only body byte-for-byte.
139
155
  */
140
- export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string): string;
156
+ export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string, options?: CommentRenderOptions): string;
141
157
  /**
142
158
  * Extra footer line appended only on the local-`gh` fallback path (never on
143
159
  * bot-authored comments). Short note that the App isn't on this repo yet.
package/dist/github.js CHANGED
@@ -162,6 +162,15 @@ export function attachmentsMarker(workspace) {
162
162
  * into a `<details>` link list. Keeps very large threads from becoming a wall
163
163
  * of images. */
164
164
  export const MAX_INLINE_ATTACHMENT_IMAGES = 16;
165
+ /** Today's behavior, expressed as options — the default for every caller that
166
+ * hasn't opted into repo comment config. */
167
+ export const AUTO_RENDER_OPTIONS = {
168
+ imageWidth: "auto",
169
+ maxInlineImages: MAX_INLINE_ATTACHMENT_IMAGES,
170
+ metaPath: true,
171
+ metaState: true,
172
+ note: null,
173
+ };
165
174
  /** Default max width for images in the managed attachments comment (HTML img). */
166
175
  export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
167
176
  /** Portrait / device mockups — keep phones readable, not full-column. */
@@ -241,19 +250,19 @@ function escapeMarkdownText(s) {
241
250
  * alone it is a stray character, and as a prefix next to `state` it is
242
251
  * noise. Only exact `/` after trim is suppressed.
243
252
  */
244
- function metaCaptionParts(meta) {
253
+ function metaCaptionParts(meta, options) {
245
254
  const parts = [];
246
255
  const path = meta?.path?.trim();
247
- if (path && path !== "/")
256
+ if (options.metaPath && path && path !== "/")
248
257
  parts.push(path);
249
258
  const state = meta?.state?.trim();
250
- if (state)
259
+ if (options.metaState && state)
251
260
  parts.push(state);
252
261
  return parts;
253
262
  }
254
263
  /** `<sub>` caption body for an inline image, or null when there is nothing to say. */
255
- function metaCaptionHtml(meta) {
256
- const parts = metaCaptionParts(meta);
264
+ function metaCaptionHtml(meta, options) {
265
+ const parts = metaCaptionParts(meta, options);
257
266
  return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
258
267
  }
259
268
  /**
@@ -261,12 +270,32 @@ function metaCaptionHtml(meta) {
261
270
  * HTML-escapes first, then markdown-escapes: HTML escaping introduces no
262
271
  * backslashes or brackets, so the markdown pass cannot corrupt its entities.
263
272
  */
264
- function metaCaptionMarkdown(meta) {
265
- const parts = metaCaptionParts(meta);
273
+ function metaCaptionMarkdown(meta, options) {
274
+ const parts = metaCaptionParts(meta, options);
266
275
  if (parts.length === 0)
267
276
  return "";
268
277
  return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
269
278
  }
279
+ /** Resolved pixel width for an image site, or `null` meaning "omit the width
280
+ * attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
281
+ * `"full"` always omits; a number always wins. */
282
+ function resolvedWidth(autoPx, options) {
283
+ if (options.imageWidth === "auto")
284
+ return autoPx;
285
+ if (options.imageWidth === "full")
286
+ return null;
287
+ return options.imageWidth;
288
+ }
289
+ /** Every `<img>` tag in the managed comment goes through here so the
290
+ * omit-width-attribute case ("full") can't drift between call sites.
291
+ *
292
+ * `escapedAlt`/`escapedSrc` must already be attribute-escaped (via
293
+ * `escapeHtmlAttr`) by the caller — this function interpolates them as-is
294
+ * and does not escape them itself. */
295
+ function imgTag(w, escapedAlt, escapedSrc) {
296
+ const widthAttr = w === null ? "" : ` width="${w}"`;
297
+ return `<img${widthAttr} alt="${escapedAlt}" src="${escapedSrc}">`;
298
+ }
270
299
  /** Extract the filename stem's before/after token (issue #419 fallback pairing).
271
300
  * `base` is the stem lowercased with the token removed; `null` when the stem
272
301
  * carries no recognizable before/after token. Requires a separator (`-`, `_`,
@@ -363,32 +392,35 @@ function pairAttachments(items, isImageAt) {
363
392
  * than a standalone image so two side by side stay under GitHub's comment
364
393
  * column width (and don't overflow on mobile). */
365
394
  export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
366
- function renderPairCell(item, label) {
395
+ function renderPairCell(item, label, options) {
367
396
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
368
397
  const src = item.embedUrl ?? item.url;
369
398
  const link = item.pageUrl ?? item.url;
370
- const w = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
399
+ const autoPx = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
400
+ const w = resolvedWidth(autoPx, options);
371
401
  const alt = escapeHtmlAttr(name);
372
402
  const href = escapeHtmlAttr((link ?? src));
373
403
  const imgSrc = escapeHtmlAttr(src);
374
- const caption = metaCaptionHtml(item.meta);
404
+ const caption = metaCaptionHtml(item.meta, options);
375
405
  const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
376
- return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>${captionHtml}</td>`;
406
+ return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}">${imgTag(w, alt, imgSrc)}</a>${captionHtml}</td>`;
377
407
  }
378
408
  /** One side-by-side before/after row (issue #419): a single HTML table so
379
409
  * GitHub renders both images on one line, with `Before`/`After` labels and
380
410
  * each side's usual path/state caption preserved underneath. */
381
- function renderPairRow(beforeItem, afterItem) {
382
- return `<table><tr>${renderPairCell(beforeItem, "Before")}${renderPairCell(afterItem, "After")}</tr></table>`;
411
+ function renderPairRow(beforeItem, afterItem, options) {
412
+ return `<table><tr>${renderPairCell(beforeItem, "Before", options)}${renderPairCell(afterItem, "After", options)}</tr></table>`;
383
413
  }
384
414
  /**
385
415
  * Render the one marker-owned GitHub comment. When there are no galleries this
386
416
  * intentionally preserves the legacy attachment-only body byte-for-byte.
387
417
  */
388
- export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER) {
418
+ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER, options = AUTO_RENDER_OPTIONS) {
389
419
  const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
390
420
  const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
391
421
  const lines = [marker];
422
+ if (options.note)
423
+ lines.push(options.note, "");
392
424
  if (sortedGalleries.length > 0) {
393
425
  lines.push("### 🖼️ Galleries", "");
394
426
  for (const gallery of sortedGalleries) {
@@ -397,7 +429,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
397
429
  for (const preview of gallery.previews ?? []) {
398
430
  const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
399
431
  const previewSrc = escapeHtmlAttr(preview.embedUrl ?? preview.url);
400
- lines.push(`<a href="${previewHref}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${previewSrc}"></a>`);
432
+ const previewW = resolvedWidth(320, options);
433
+ lines.push(`<a href="${previewHref}">${imgTag(previewW, escapeHtmlAttr(preview.alt), previewSrc)}</a>`);
401
434
  }
402
435
  lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
403
436
  }
@@ -421,12 +454,12 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
421
454
  const partnerIdx = partnerOf.get(idx);
422
455
  if (partnerIdx !== undefined) {
423
456
  const partner = sorted[partnerIdx];
424
- if (inlinedImages + 2 <= MAX_INLINE_ATTACHMENT_IMAGES) {
457
+ if (inlinedImages + 2 <= options.maxInlineImages) {
425
458
  inlinedImages += 2;
426
459
  consumedByPair.add(partnerIdx);
427
460
  const beforeItem = roleOf.get(idx) === "before" ? item : partner;
428
461
  const afterItem = roleOf.get(idx) === "before" ? partner : item;
429
- lines.push(renderPairRow(beforeItem, afterItem), "");
462
+ lines.push(renderPairRow(beforeItem, afterItem, options), "");
430
463
  continue;
431
464
  }
432
465
  // Cap already full for a two-image row — degrade this pair to two
@@ -442,7 +475,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
442
475
  const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
443
476
  const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
444
477
  const inlines = isImage || isPosterVideo;
445
- if (inlines && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
478
+ if (inlines && inlinedImages >= options.maxInlineImages) {
446
479
  // Cap hit — defer to the collapsed overflow list below rather than
447
480
  // embedding every remaining image inline.
448
481
  overflowImages.push(item);
@@ -450,37 +483,39 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
450
483
  }
451
484
  if (isPosterVideo) {
452
485
  inlinedImages++;
453
- const w = posterImageWidth(item.videoMeta, name);
486
+ const autoPx = posterImageWidth(item.videoMeta, name);
487
+ const w = resolvedWidth(autoPx, options);
454
488
  const href = escapeHtmlAttr(link ?? item.posterUrl);
455
- lines.push(`<a href="${href}"><img width="${w}" alt="${escapeHtmlAttr(name)}" src="${escapeHtmlAttr(item.posterUrl)}"></a>`);
489
+ lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
456
490
  // GitHub strips <video>, so a still frame needs an explicit affordance
457
491
  // or it reads as a screenshot.
458
492
  const parts = ["▶ Play video"];
459
493
  if (item.videoMeta?.durationSeconds != null) {
460
494
  parts.push(formatDuration(item.videoMeta.durationSeconds));
461
495
  }
462
- parts.push(...metaCaptionParts(item.meta).map(escapeHtmlText));
496
+ parts.push(...metaCaptionParts(item.meta, options).map(escapeHtmlText));
463
497
  lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
464
498
  }
465
499
  else if (isImage) {
466
500
  inlinedImages++;
467
501
  // Markdown ![]() has no width control — phone frames become full-column giants.
468
502
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
469
- const w = attachmentImageWidth(name);
503
+ const autoPx = attachmentImageWidth(name);
504
+ const w = resolvedWidth(autoPx, options);
470
505
  const alt = escapeHtmlAttr(name);
471
506
  const href = escapeHtmlAttr(link ?? src);
472
507
  const imgSrc = escapeHtmlAttr(src);
473
- lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
474
- const caption = metaCaptionHtml(item.meta);
508
+ lines.push(`<a href="${href}">${imgTag(w, alt, imgSrc)}</a>`);
509
+ const caption = metaCaptionHtml(item.meta, options);
475
510
  if (caption)
476
511
  lines.push(`<sub>${caption}</sub>`);
477
512
  lines.push("");
478
513
  }
479
514
  else if (link) {
480
- lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta)}`);
515
+ lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta, options)}`);
481
516
  }
482
517
  else {
483
- lines.push(`- ${name}${metaCaptionMarkdown(item.meta)}`);
518
+ lines.push(`- ${name}${metaCaptionMarkdown(item.meta, options)}`);
484
519
  }
485
520
  }
486
521
  if (overflowImages.length > 0) {
@@ -489,7 +524,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
489
524
  for (const item of overflowImages) {
490
525
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
491
526
  const link = item.pageUrl ?? item.url;
492
- const suffix = metaCaptionMarkdown(item.meta);
527
+ const suffix = metaCaptionMarkdown(item.meta, options);
493
528
  lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
494
529
  }
495
530
  lines.push("", "</details>", "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.33.1",
3
+ "version": "0.34.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,
@@ -61,7 +61,8 @@
61
61
  "opentype.js": "^2.0.0",
62
62
  "perfect-freehand": "^1.2.3",
63
63
  "roughjs": "^4.6.6",
64
- "sharp": "^0.35.3"
64
+ "sharp": "^0.35.3",
65
+ "yaml": "^2.6.0"
65
66
  },
66
67
  "optionalDependencies": {
67
68
  "playwright-core": "^1.61.1"