@buildinternet/uploads 0.41.0 → 0.42.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
@@ -58,7 +58,12 @@ answers `did you mean: uploads meta set`. With `--json`, an unknown command
58
58
  returns `{ "error", "code": "USAGE", "didYouMean" }` on stdout.
59
59
 
60
60
  **Shell completion:** `uploads completion bash|zsh|fish` prints a script to
61
- stdout. Example (zsh): `uploads completion zsh > ~/.zsh/completions/_uploads`.
61
+ stdout. Save it where your shell looks for completions, then start a new shell.
62
+ For zsh, write the script to `~/.zsh/completions/_uploads`, then bind it at the
63
+ end of `~/.zshrc` with `fpath=(~/.zsh/completions $fpath)` and
64
+ `autoload -Uz _uploads && compdef _uploads uploads`. The `compdef` line matters:
65
+ a cached `compinit -C` never rescans fpath, so the file alone can go unnoticed.
66
+ `uploads completion --help` covers the rest.
62
67
 
63
68
  **Globals (before the command):** `--api-url`, `--token`, `--workspace` / `-w`,
64
69
  `--env-file`, `--json`, `--quiet`, `--version` / `-V`, `-h` / `--help`, `--all`
@@ -79,4 +79,6 @@ export declare function flagBool(flags: CommandFlags["flags"], name: string): bo
79
79
  export declare function flagValues(flags: CommandFlags["flags"], name: string): string[];
80
80
  /** Command-level workspace override (`--workspace` / `-w`). */
81
81
  export declare function commandWorkspace(flags: CommandFlags["flags"]): string | undefined;
82
- export declare function flagInt(flags: CommandFlags["flags"], name: string, label: string): number | undefined;
82
+ export declare function flagInt(flags: CommandFlags["flags"], name: string, label: string, options?: {
83
+ allowZero?: boolean;
84
+ }): number | undefined;
package/dist/cli-args.js CHANGED
@@ -198,16 +198,18 @@ export function flagValues(flags, name) {
198
198
  export function commandWorkspace(flags) {
199
199
  return flagString(flags, "--workspace") ?? flagString(flags, "-w");
200
200
  }
201
- export function flagInt(flags, name, label) {
201
+ export function flagInt(flags, name, label, options) {
202
+ const allowZero = options?.allowZero ?? false;
203
+ const kind = allowZero ? "non-negative" : "positive";
202
204
  const raw = flagString(flags, name);
203
205
  if (raw === undefined)
204
206
  return undefined;
205
207
  if (!/^\d+$/.test(raw)) {
206
- throw new UsageError(`invalid ${label}: must be a positive integer (got ${raw})`);
208
+ throw new UsageError(`invalid ${label}: must be a ${kind} integer (got ${raw})`);
207
209
  }
208
210
  const n = Number.parseInt(raw, 10);
209
- if (!Number.isFinite(n) || n <= 0) {
210
- throw new UsageError(`invalid ${label}: must be a positive integer (got ${raw})`);
211
+ if (!Number.isFinite(n) || (allowZero ? n < 0 : n <= 0)) {
212
+ throw new UsageError(`invalid ${label}: must be a ${kind} integer (got ${raw})`);
211
213
  }
212
214
  return n;
213
215
  }
@@ -56,6 +56,7 @@ export const SCREENSHOT_FLAGS = [
56
56
  "--viewport",
57
57
  "--selector",
58
58
  "--full-page",
59
+ "--max-height",
59
60
  "--dark",
60
61
  "--light",
61
62
  "--wait",
@@ -3,8 +3,9 @@ import { ANNOTATE_FLAGS, COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_L
3
3
  import { writeCommandHelp } from "../cli-style.js";
4
4
  const HELP = `uploads completion <shell>
5
5
 
6
- Print a shell completion script to stdout. Source it or install it so Tab
7
- completes commands, subcommands, and common flags.
6
+ Print a shell completion script to stdout. Save it where your shell looks for
7
+ completions, then start a new shell. Tab then completes commands, subcommands,
8
+ and common flags.
8
9
 
9
10
  Shells:
10
11
  bash Bash (complete -F)
@@ -12,16 +13,36 @@ Shells:
12
13
  fish Fish (complete -c)
13
14
 
14
15
  Examples:
15
- # zsh (Oh My Zsh / fpath)
16
- uploads completion zsh > ~/.zsh/completions/_uploads
17
-
18
- # bash
19
- uploads completion bash > ~/.local/share/bash-completion/completions/uploads
20
- # or for the current session:
16
+ # bash for the current session:
21
17
  eval "$(uploads completion bash)"
18
+ # or save it:
19
+ uploads completion bash > ~/.local/share/bash-completion/completions/uploads
22
20
 
23
21
  # fish
24
22
  uploads completion fish > ~/.config/fish/completions/uploads.fish
23
+
24
+ # zsh — save the script:
25
+ uploads completion zsh > ~/.zsh/completions/_uploads
26
+ # then add these two lines to the END of ~/.zshrc:
27
+ # fpath=(~/.zsh/completions $fpath)
28
+ # autoload -Uz _uploads && compdef _uploads uploads
29
+
30
+ Zsh notes:
31
+ Saving the file into an fpath directory is often not enough. compinit caches
32
+ its scan in ~/.zcompdump, and \`compinit -C\` reuses that cache instead of
33
+ rescanning, so it never sees the new file. Oh My Zsh and several installers
34
+ call compinit that way. Some plugins also rewrite fpath as they load, which
35
+ drops entries added before them; zsh-autocomplete is one.
36
+
37
+ The \`compdef\` line avoids both problems, because it binds the function
38
+ directly instead of relying on a scan. Put it after any framework or plugin
39
+ setup. If completion still does nothing, delete ~/.zcompdump* and start a
40
+ new shell.
41
+
42
+ Check the result with: print $_comps[uploads] — it prints _uploads when the
43
+ completion is active, and nothing when it is not.
44
+
45
+ After you upgrade the CLI, generate the script again so it lists new commands.
25
46
  `;
26
47
  function bashScript() {
27
48
  const cmds = ROOT_COMMANDS.map((c) => c.name).join(" ");
@@ -140,18 +161,19 @@ function zshScript() {
140
161
  }).join("\n");
141
162
  const globalArgs = GLOBAL_FLAGS.map((g) => {
142
163
  const desc = g.summary.replace(/'/g, "'\\''");
143
- // Short and long flags as separate specs when needed.
144
- if (g.flag === "-w")
145
- return ` '(-w --workspace)'{-w,--workspace}'[${desc}]:workspace:'`;
164
+ // Pair each short flag with its long form, keeping the long form's cleaner
165
+ // summary; the short entry then contributes nothing of its own.
146
166
  if (g.flag === "--workspace")
147
- return null; // paired with -w
148
- if (g.flag === "-V")
149
- return ` '(-V --version)'{-V,--version}'[${desc}]'`;
167
+ return ` '(-w --workspace)'{-w,--workspace}'[${desc}]:workspace:'`;
168
+ if (g.flag === "-w")
169
+ return null; // paired with --workspace
150
170
  if (g.flag === "--version")
171
+ return ` '(-V --version)'{-V,--version}'[${desc}]'`;
172
+ if (g.flag === "-V")
151
173
  return null;
152
- if (g.flag === "-h")
153
- return ` '(-h --help)'{-h,--help}'[${desc}]'`;
154
174
  if (g.flag === "--help")
175
+ return ` '(-h --help)'{-h,--help}'[${desc}]'`;
176
+ if (g.flag === "-h")
155
177
  return null;
156
178
  if (g.flag === "--api-url")
157
179
  return ` '--api-url[${desc}]:url:'`;
@@ -162,7 +184,9 @@ function zshScript() {
162
184
  return ` '${g.flag}[${desc}]'`;
163
185
  })
164
186
  .filter(Boolean)
165
- .join("\n");
187
+ // Every spec but the last needs a line continuation — without them zsh ends
188
+ // the `_arguments` call after the first spec and tries to *run* the rest.
189
+ .join(" \\\n");
166
190
  const subCases = ROOT_COMMANDS.filter((c) => c.subcommands?.length)
167
191
  .map((c) => {
168
192
  const lines = c
@@ -13,7 +13,7 @@ import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
13
  import { mergeDerivedMeta } from "../metadata-vocab.js";
14
14
  import { writeSidecarMeta } from "../sidecar.js";
15
15
  import { readStdin, writeJson, writeStdout } from "../io.js";
16
- import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
16
+ import { assertHideSelector, captureScreenshot, clipHintText, DEFAULT_FULL_PAGE_MAX_HEIGHT, parseViewport, parseWaitUntil, } from "../screenshot.js";
17
17
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
18
18
 
19
19
  Capture a URL or a local .html file and host it — a hosted, PR-embeddable
@@ -59,6 +59,10 @@ Options:
59
59
  --viewport <WxH[@Sx]> Size + device scale factor (default: 1280x800@2)
60
60
  --selector <css> Capture one element instead of the viewport
61
61
  --full-page Capture the full scrollable page
62
+ --max-height <px> Cap on full-page capture height in CSS px (default: ${DEFAULT_FULL_PAGE_MAX_HEIGHT},
63
+ or 0 for uncapped). A page over the cap is clipped, with a note printed
64
+ to stderr and a --format json \`hint\`. Requires --full-page. Applied on
65
+ both --via local and --via remote so behavior matches.
62
66
  --dark / --light Emulate prefers-color-scheme (full media-query emulation on --via local
63
67
  only; --via remote just sets the CSS color-scheme property, so a page's
64
68
  own prefers-color-scheme queries won't flip)
@@ -182,6 +186,14 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
182
186
  const viewport = parseViewport(flagString(parsed.flags, "--viewport"));
183
187
  const selector = flagString(parsed.flags, "--selector");
184
188
  const fullPage = flagBool(parsed.flags, "--full-page");
189
+ // Unlike every other --…-px flag, 0 is a valid (uncapped) value here, not
190
+ // an error — flagInt's allowZero option covers it.
191
+ const maxHeightFlag = flagInt(parsed.flags, "--max-height", "--max-height", {
192
+ allowZero: true,
193
+ });
194
+ if (maxHeightFlag !== undefined && !fullPage) {
195
+ throw new UsageError("--max-height requires --full-page");
196
+ }
185
197
  const colorScheme = colorSchemeFromFlags(parsed.flags);
186
198
  const waitUntil = parseWaitUntil(flagString(parsed.flags, "--wait"));
187
199
  const hide = flagValues(parsed.flags, "--hide");
@@ -387,6 +399,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
387
399
  viewport,
388
400
  selector,
389
401
  fullPage,
402
+ maxHeight: maxHeightFlag,
390
403
  colorScheme,
391
404
  waitUntil,
392
405
  hide,
@@ -403,6 +416,14 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
403
416
  });
404
417
  if (logHuman)
405
418
  process.stderr.write(`>> captured via ${captured.backend} backend\n`);
419
+ // Full-page height cap note (issue #652): printed regardless of --format
420
+ // (only --quiet suppresses it) since it's directly actionable info about
421
+ // the image that was just captured, same as the upload-tail warnings below.
422
+ const clipHint = captured.capped?.clipped
423
+ ? clipHintText(captured.capped.maxHeightPx, "--max-height")
424
+ : undefined;
425
+ if (clipHint && !ctx.quiet)
426
+ process.stderr.write(`${clipHint}\n`);
406
427
  // Resolve selectors + render annotations before the frame/optimize/upload
407
428
  // pipeline runs — everything downstream (the --out write, the sidecar
408
429
  // hash, and the upload itself) should see the annotated bytes.
@@ -543,9 +564,11 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
543
564
  process.stderr.write(`${bindingWarning}\n`);
544
565
  process.stderr.write("\n");
545
566
  }
546
- // One JSON `hint` slot (mirrors bare put): the binding warning is more
547
- // actionable than the generic staging note, so it wins when both fire; a
548
- // replaced-object note (issue #618) is the lowest priority of the three
567
+ // One JSON `hint` slot (mirrors bare put): the clip note (issue #652) wins
568
+ // first it's about the just-captured image itself, more immediately
569
+ // actionable than the other three, which are about upload/staging
570
+ // mechanics. Then the binding warning, more actionable than the generic
571
+ // staging note; a replaced-object note (issue #618) is lowest priority —
549
572
  // it only surfaces when nothing else already claimed the slot. Since state
550
573
  // folds into the derived key, replaced + state means a same-side re-capture,
551
574
  // which is the intended replace-in-place flow — word it as informational,
@@ -553,7 +576,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
553
576
  const replacedHint = result.replaced && explicitMeta.state
554
577
  ? `re-capture replaced the previous state=${explicitMeta.state} object at ${result.key} — expected for repeat captures of the same URL + state`
555
578
  : undefined;
556
- const jsonHint = bindingWarning ?? stagingNote ?? replacedHint;
579
+ const jsonHint = clipHint ?? bindingWarning ?? stagingNote ?? replacedHint;
557
580
  switch (format) {
558
581
  case "json":
559
582
  await writeJson({
@@ -3,7 +3,9 @@ export declare function usage(msg: string): never;
3
3
  export declare function optString(args: ToolArgs, name: string): string | undefined;
4
4
  /** A boolean flag argument; missing/null reads as `false`. */
5
5
  export declare function optBool(args: ToolArgs, name: string): boolean;
6
- export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
6
+ export declare function optPosInt(args: ToolArgs, name: string, options?: {
7
+ allowZero?: boolean;
8
+ }): number | undefined;
7
9
  /** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
8
10
  export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
9
11
  /** A JSON-array argument of strings (e.g. a `delete` or `files` param). */
package/dist/mcp/args.js CHANGED
@@ -25,12 +25,13 @@ export function optBool(args, name) {
25
25
  usage(`${name} must be a boolean`);
26
26
  return v;
27
27
  }
28
- export function optPosInt(args, name) {
28
+ export function optPosInt(args, name, options) {
29
+ const allowZero = options?.allowZero ?? false;
29
30
  const v = args[name];
30
31
  if (v === undefined || v === null)
31
32
  return undefined;
32
- if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
33
- usage(`${name} must be a positive integer`);
33
+ if (typeof v !== "number" || !Number.isInteger(v) || (allowZero ? v < 0 : v <= 0)) {
34
+ usage(`${name} must be a ${allowZero ? "non-negative" : "positive"} integer`);
34
35
  }
35
36
  return v;
36
37
  }
package/dist/mcp/tools.js CHANGED
@@ -584,6 +584,12 @@ export function createUploadsMcpTools(opts) {
584
584
  },
585
585
  selector: { type: "string", description: "Capture one element instead of the viewport." },
586
586
  fullPage: { type: "boolean", description: "Capture the full scrollable page." },
587
+ maxHeight: {
588
+ type: "number",
589
+ description: "Cap on full-page capture height in CSS px (default: 5000, 0 = uncapped). A page over " +
590
+ "the cap is clipped, with a `hint` in the result. Requires fullPage. Applied on both " +
591
+ "via: local and via: remote so behavior matches.",
592
+ },
587
593
  colorScheme: {
588
594
  type: "string",
589
595
  description: "Emulate prefers-color-scheme: dark | light. Full media-query emulation requires via: \"local\" — the remote backend only sets the CSS color-scheme property and won't flip a page's own prefers-color-scheme queries.",
@@ -678,6 +684,12 @@ export function createUploadsMcpTools(opts) {
678
684
  if (colorSchemeArg && colorSchemeArg !== "dark" && colorSchemeArg !== "light") {
679
685
  usage("colorScheme must be dark or light");
680
686
  }
687
+ const fullPageArg = optBool(args, "fullPage");
688
+ // Unlike other px args, 0 is valid here (uncapped) — optPosInt's
689
+ // allowZero option covers it.
690
+ const maxHeightArg = optPosInt(args, "maxHeight", { allowZero: true });
691
+ if (maxHeightArg !== undefined && !fullPageArg)
692
+ usage("maxHeight requires fullPage");
681
693
  const target = ghTargetFromArgs(args, run);
682
694
  const wantComment = optBool(args, "comment");
683
695
  const dryRun = optBool(args, "dryRun");
@@ -778,7 +790,8 @@ export function createUploadsMcpTools(opts) {
778
790
  cdp: optString(args, "cdp"),
779
791
  viewport,
780
792
  selector: optString(args, "selector"),
781
- fullPage: optBool(args, "fullPage"),
793
+ fullPage: fullPageArg,
794
+ maxHeight: maxHeightArg,
782
795
  colorScheme: colorSchemeArg,
783
796
  waitUntil: screenshotModule.parseWaitUntil(optString(args, "wait")),
784
797
  hide: optStringArray(args, "hide"),
@@ -863,6 +876,11 @@ export function createUploadsMcpTools(opts) {
863
876
  frame: prepared.frame,
864
877
  gallery,
865
878
  ...(dryRun ? { dryRun: true } : {}),
879
+ // Full-page height cap note (issue #652), mirrors the CLI's stderr
880
+ // note + `hint` field.
881
+ ...(captured.capped?.clipped
882
+ ? { hint: screenshotModule.clipHintText(captured.capped.maxHeightPx, "maxHeight") }
883
+ : {}),
866
884
  };
867
885
  if (wantComment && target) {
868
886
  const { comment, commentError } = await syncComment(client, target, config.workspace);
@@ -48,6 +48,13 @@ export interface LocalCaptureOptions {
48
48
  };
49
49
  selector?: string;
50
50
  fullPage?: boolean;
51
+ /**
52
+ * Cap (CSS px) on full-page capture height (issue #652). Only consulted
53
+ * when `fullPage` is true; 0 or undefined means uncapped. When the page's
54
+ * measured scroll height exceeds this, the capture is clipped to exactly
55
+ * this height instead of the full scroll height.
56
+ */
57
+ maxHeightPx?: number;
51
58
  colorScheme?: "dark" | "light";
52
59
  /** "load" | "domcontentloaded" | "networkidle", or a millisecond settle delay. */
53
60
  waitUntil: "load" | "domcontentloaded" | "networkidle" | number;
@@ -86,6 +93,16 @@ export interface MeasuredBox {
86
93
  interface EvaluatablePage {
87
94
  evaluate<T>(fn: (selectors: string[]) => T, arg: string[]): Promise<T>;
88
95
  }
96
+ /**
97
+ * Measures the page's full scrollable height in CSS px — the same quantity
98
+ * a `fullPage: true` screenshot would otherwise capture in its entirety.
99
+ * `document.documentElement.scrollHeight` is the standard measure; `body`'s
100
+ * is included too since some pages (quirks-mode, non-standard layouts) only
101
+ * grow one or the other.
102
+ */
103
+ export declare function measureFullPageHeight(page: {
104
+ evaluate<T>(fn: () => T): Promise<T>;
105
+ }): Promise<number>;
89
106
  /**
90
107
  * Measures each selector's getBoundingClientRect on the page in a single
91
108
  * `page.evaluate` round-trip, scaling CSS pixels to device (raster) pixels so
@@ -99,5 +116,6 @@ export declare function measureSelectorBoxes(page: EvaluatablePage, selectors: r
99
116
  export declare function captureLocal(opts: LocalCaptureOptions): Promise<{
100
117
  png: Uint8Array;
101
118
  measures?: Record<string, MeasuredBox>;
119
+ clipped?: boolean;
102
120
  }>;
103
121
  export {};
@@ -203,6 +203,16 @@ export function detectLocalBrowser(roots = {}) {
203
203
  const winner = [...candidates].toSorted((a, b) => rank(a) - rank(b))[0];
204
204
  return { envOverride, candidates, winner };
205
205
  }
206
+ /**
207
+ * Measures the page's full scrollable height in CSS px — the same quantity
208
+ * a `fullPage: true` screenshot would otherwise capture in its entirety.
209
+ * `document.documentElement.scrollHeight` is the standard measure; `body`'s
210
+ * is included too since some pages (quirks-mode, non-standard layouts) only
211
+ * grow one or the other.
212
+ */
213
+ export async function measureFullPageHeight(page) {
214
+ return page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0));
215
+ }
206
216
  /**
207
217
  * Measures each selector's getBoundingClientRect on the page in a single
208
218
  * `page.evaluate` round-trip, scaling CSS pixels to device (raster) pixels so
@@ -349,11 +359,33 @@ export async function captureLocal(opts) {
349
359
  const measures = opts.measureSelectors && opts.measureSelectors.length > 0
350
360
  ? await measureSelectorBoxes(page, opts.measureSelectors, opts.viewport.deviceScaleFactor)
351
361
  : undefined;
352
- const png = opts.selector
353
- ? await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 })
354
- : await page.screenshot({ fullPage: opts.fullPage === true });
362
+ // Full-page height cap (issue #652): measure the actual scroll height
363
+ // first so a page under the cap keeps its normal exact-content-height
364
+ // fullPage capture — only a page that genuinely exceeds the cap gets
365
+ // clipped to it. `clip` (CSS px, captures beyond the current viewport)
366
+ // is the mechanism; it can't be combined with `fullPage: true`.
367
+ let clipped = false;
368
+ let png;
369
+ if (opts.selector) {
370
+ png = await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 });
371
+ }
372
+ else if (opts.fullPage && opts.maxHeightPx && opts.maxHeightPx > 0) {
373
+ const fullHeight = await measureFullPageHeight(page);
374
+ if (fullHeight > opts.maxHeightPx) {
375
+ clipped = true;
376
+ png = await page.screenshot({
377
+ clip: { x: 0, y: 0, width: opts.viewport.width, height: opts.maxHeightPx },
378
+ });
379
+ }
380
+ else {
381
+ png = await page.screenshot({ fullPage: true });
382
+ }
383
+ }
384
+ else {
385
+ png = await page.screenshot({ fullPage: opts.fullPage === true });
386
+ }
355
387
  // Buffer extends Uint8Array — return it as-is rather than copying.
356
- return { png, measures };
388
+ return { png, measures, clipped };
357
389
  }
358
390
  finally {
359
391
  await browser.close();
@@ -10,6 +10,14 @@ export interface RemoteRenderRequest {
10
10
  };
11
11
  selector?: string;
12
12
  fullPage?: boolean;
13
+ /**
14
+ * Cap (CSS px) on full-page capture height (issue #652). Only sent when
15
+ * `fullPage` is true and the cap is active (nonzero); the server applies a
16
+ * best-effort clamp (see apps/api's render.ts) and reports whether it
17
+ * actually kicked in via the `X-Uploads-Full-Page-Clipped` response header,
18
+ * surfaced back on the result as `clipped`.
19
+ */
20
+ maxHeight?: number;
13
21
  colorScheme?: "dark" | "light";
14
22
  waitUntil?: "load" | "domcontentloaded" | "networkidle" | number;
15
23
  /** CSS selectors hidden (display:none) server-side before capture. */
@@ -27,5 +35,14 @@ export interface RemoteRenderOptions {
27
35
  fetchImpl?: typeof fetch;
28
36
  timeoutMs?: number;
29
37
  }
30
- /** POST the render request; resolves with raw PNG bytes on 200. */
31
- export declare function captureRemote(body: RemoteRenderRequest, opts: RemoteRenderOptions): Promise<Uint8Array>;
38
+ export interface RemoteRenderResult {
39
+ png: Uint8Array;
40
+ /**
41
+ * True when the server's best-effort full-page height clamp actually
42
+ * kicked in (read from the `X-Uploads-Full-Page-Clipped` response header).
43
+ * Always false when `maxHeight` wasn't sent.
44
+ */
45
+ clipped: boolean;
46
+ }
47
+ /** POST the render request; resolves with raw PNG bytes (+ clip status) on 200. */
48
+ export declare function captureRemote(body: RemoteRenderRequest, opts: RemoteRenderOptions): Promise<RemoteRenderResult>;
@@ -32,7 +32,7 @@ function mapRenderError(status, message, code) {
32
32
  return new UploadsError(message, "RATE_LIMITED", status);
33
33
  return new UploadsError(message, "API_ERROR", status);
34
34
  }
35
- /** POST the render request; resolves with raw PNG bytes on 200. */
35
+ /** POST the render request; resolves with raw PNG bytes (+ clip status) on 200. */
36
36
  export async function captureRemote(body, opts) {
37
37
  if (body.html !== undefined) {
38
38
  const htmlBytes = new TextEncoder().encode(body.html).byteLength;
@@ -65,7 +65,7 @@ export async function captureRemote(body, opts) {
65
65
  if (bytes.byteLength === 0) {
66
66
  throw new UploadsError("render endpoint returned an empty response", "RENDER_FAILED");
67
67
  }
68
- return bytes;
68
+ return { png: bytes, clipped: res.headers.get("x-uploads-full-page-clipped") === "true" };
69
69
  }
70
70
  catch (err) {
71
71
  if (err instanceof UploadsError)
@@ -25,6 +25,16 @@ export interface ScreenshotViewport {
25
25
  deviceScaleFactor: number;
26
26
  }
27
27
  export declare const DEFAULT_SCREENSHOT_VIEWPORT: ScreenshotViewport;
28
+ /**
29
+ * Default cap (CSS px) on `--full-page` capture height (issue #652). Picked
30
+ * from the 4000-6000px range the issue proposed: generous enough to cover a
31
+ * long single-viewport marketing/docs page without clipping, but well short
32
+ * of the multi-thousand-entry-list case that motivated this (a 53-entry
33
+ * changelog page, PR #651) — those still clip, with a note pointing at
34
+ * `--max-height` to raise or remove the cap. `0` (via `--max-height 0`) means
35
+ * uncapped, for the rare case the full strip is genuinely wanted.
36
+ */
37
+ export declare const DEFAULT_FULL_PAGE_MAX_HEIGHT = 5000;
28
38
  /** Parses `WIDTHxHEIGHT[@SCALEx]`, e.g. "1280x800", "1280x800@2x", "1280x800@2". */
29
39
  export declare function parseViewport(raw: string | undefined): ScreenshotViewport;
30
40
  /** Parses `--wait`: "load" | "domcontentloaded" | "networkidle" | a millisecond count. */
@@ -63,6 +73,13 @@ export interface CaptureScreenshotOptions {
63
73
  viewport?: ScreenshotViewport;
64
74
  selector?: string;
65
75
  fullPage?: boolean;
76
+ /**
77
+ * Cap (CSS px) on `--full-page` capture height. Only meaningful with
78
+ * `fullPage: true`; ignored otherwise. `undefined` applies
79
+ * `DEFAULT_FULL_PAGE_MAX_HEIGHT`; `0` means uncapped. Wired through both
80
+ * capture backends so behavior matches regardless of `via` (issue #652).
81
+ */
82
+ maxHeight?: number;
66
83
  colorScheme?: "dark" | "light";
67
84
  waitUntil?: WaitUntil;
68
85
  /**
@@ -104,6 +121,8 @@ export interface CaptureScreenshotOptions {
104
121
  viewport: ScreenshotViewport;
105
122
  selector?: string;
106
123
  fullPage?: boolean;
124
+ /** CSS px cap on full-page height; 0 or undefined = uncapped. */
125
+ maxHeightPx?: number;
107
126
  colorScheme?: "dark" | "light";
108
127
  waitUntil: WaitUntil;
109
128
  hide?: string[];
@@ -117,6 +136,8 @@ export interface CaptureScreenshotOptions {
117
136
  }) => Promise<{
118
137
  png: Uint8Array;
119
138
  measures?: Record<string, MeasuredBox>;
139
+ /** Present when `fullPage` + a positive `maxHeightPx` were given. */
140
+ clipped?: boolean;
120
141
  }>;
121
142
  /** Injectable for tests: replaces the remote capture implementation. */
122
143
  captureRemoteImpl?: typeof captureRemote;
@@ -127,7 +148,23 @@ export interface CaptureScreenshotResult {
127
148
  backend: "local" | "remote";
128
149
  /** Present when `measureSelectors` was given and the local backend ran. */
129
150
  measures?: Record<string, MeasuredBox>;
151
+ /**
152
+ * Present when `fullPage` was requested with a nonzero max-height cap
153
+ * (explicit or default) — regardless of backend. `clipped` tells the
154
+ * caller whether the cap actually kicked in, so it can print the
155
+ * "exceeds Npx; clipped" note and JSON `hint` (issue #652).
156
+ */
157
+ capped?: {
158
+ maxHeightPx: number;
159
+ clipped: boolean;
160
+ };
130
161
  }
162
+ /**
163
+ * The "clipped by the max-height cap" note shared by the CLI's stderr
164
+ * message and the MCP tool's JSON `hint` field (issue #652) — identical
165
+ * except for how each surface names the flag to raise the cap.
166
+ */
167
+ export declare function clipHintText(maxHeightPx: number, flagName: string): string;
131
168
  /**
132
169
  * Folds a `--state` value into an auto-derived filename's stem, e.g.
133
170
  * `localhost-docs-mcp.png` + "before" → `localhost-docs-mcp-before.png`. This
@@ -48,6 +48,16 @@ export const DEFAULT_SCREENSHOT_VIEWPORT = {
48
48
  height: 800,
49
49
  deviceScaleFactor: 2,
50
50
  };
51
+ /**
52
+ * Default cap (CSS px) on `--full-page` capture height (issue #652). Picked
53
+ * from the 4000-6000px range the issue proposed: generous enough to cover a
54
+ * long single-viewport marketing/docs page without clipping, but well short
55
+ * of the multi-thousand-entry-list case that motivated this (a 53-entry
56
+ * changelog page, PR #651) — those still clip, with a note pointing at
57
+ * `--max-height` to raise or remove the cap. `0` (via `--max-height 0`) means
58
+ * uncapped, for the rare case the full strip is genuinely wanted.
59
+ */
60
+ export const DEFAULT_FULL_PAGE_MAX_HEIGHT = 5000;
51
61
  /** Parses `WIDTHxHEIGHT[@SCALEx]`, e.g. "1280x800", "1280x800@2x", "1280x800@2". */
52
62
  export function parseViewport(raw) {
53
63
  if (!raw)
@@ -146,6 +156,14 @@ export function classifyTarget(target) {
146
156
  }
147
157
  return { kind: "html-file", path: abs, html: readFileSync(abs, "utf8") };
148
158
  }
159
+ /**
160
+ * The "clipped by the max-height cap" note shared by the CLI's stderr
161
+ * message and the MCP tool's JSON `hint` field (issue #652) — identical
162
+ * except for how each surface names the flag to raise the cap.
163
+ */
164
+ export function clipHintText(maxHeightPx, flagName) {
165
+ return `full page exceeds ${maxHeightPx}px; clipped — use ${flagName} to raise`;
166
+ }
149
167
  /** Derives a filename from a URL (host+path) or the source .html filename. */
150
168
  function deriveFilename(target) {
151
169
  if (target.kind === "html-file") {
@@ -225,6 +243,14 @@ export async function captureScreenshot(opts) {
225
243
  for (const sel of opts.hide ?? [])
226
244
  assertHideSelector(sel);
227
245
  const hide = [...(opts.hide ?? []), ...(autoHideDevTools ? DEV_TOOLBAR_SELECTORS : [])];
246
+ // Full-page height cap (issue #652): only meaningful with fullPage. 0
247
+ // (explicit --max-height 0) means uncapped; undefined applies the default.
248
+ const effectiveMaxHeight = opts.fullPage ? (opts.maxHeight ?? DEFAULT_FULL_PAGE_MAX_HEIGHT) : 0;
249
+ // Shared by both backends below — only meaningful when fullPage capped at
250
+ // a positive height; `clipped` is the one thing that differs per backend.
251
+ const cappedFrom = (clipped) => opts.fullPage && effectiveMaxHeight > 0
252
+ ? { maxHeightPx: effectiveMaxHeight, clipped }
253
+ : undefined;
228
254
  // Populated only when auto-routing actually probes the filesystem, so it
229
255
  // can be threaded into captureLocalImpl below to avoid a second scan.
230
256
  let detected;
@@ -284,6 +310,7 @@ export async function captureScreenshot(opts) {
284
310
  viewport,
285
311
  selector: opts.selector,
286
312
  fullPage: opts.fullPage,
313
+ maxHeightPx: effectiveMaxHeight,
287
314
  colorScheme: opts.colorScheme,
288
315
  waitUntil,
289
316
  hide,
@@ -294,7 +321,8 @@ export async function captureScreenshot(opts) {
294
321
  detectRoots: opts.detectRoots,
295
322
  detectResult: detected,
296
323
  });
297
- return { png: localResult.png, filename, backend, measures: localResult.measures };
324
+ const capped = cappedFrom(localResult.clipped === true);
325
+ return { png: localResult.png, filename, backend, measures: localResult.measures, capped };
298
326
  }
299
327
  if (target.kind === "html-file") {
300
328
  const bytes = new TextEncoder().encode(target.html).byteLength;
@@ -303,15 +331,17 @@ export async function captureScreenshot(opts) {
303
331
  }
304
332
  }
305
333
  const captureRemoteImpl = opts.captureRemoteImpl ?? captureRemote;
306
- const png = await captureRemoteImpl({
334
+ const remoteResult = await captureRemoteImpl({
307
335
  ...(target.kind === "html-file" ? { html: target.html } : { url: target.url }),
308
336
  viewport,
309
337
  selector: opts.selector,
310
338
  fullPage: opts.fullPage,
339
+ ...(opts.fullPage && effectiveMaxHeight > 0 ? { maxHeight: effectiveMaxHeight } : {}),
311
340
  colorScheme: opts.colorScheme,
312
341
  waitUntil,
313
342
  ...(hide.length > 0 ? { hide } : {}),
314
343
  ...(opts.reducedMotion ? { reducedMotion: true } : {}),
315
344
  }, { apiUrl: opts.apiUrl, token: opts.token });
316
- return { png, filename, backend };
345
+ const capped = cappedFrom(remoteResult.clipped === true);
346
+ return { png: remoteResult.png, filename, backend, capped };
317
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.41.0",
3
+ "version": "0.42.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,
@@ -38,7 +38,7 @@
38
38
  "node": ">=22"
39
39
  },
40
40
  "peerDependencies": {
41
- "files-sdk": "^2.2.3"
41
+ "files-sdk": "^2.2.4"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "files-sdk": {
@@ -48,7 +48,7 @@
48
48
  "devDependencies": {
49
49
  "@types/node": "^26.1.0",
50
50
  "ai": "^6.0.0",
51
- "files-sdk": "^2.2.3",
51
+ "files-sdk": "^2.2.4",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },