@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 +6 -1
- package/dist/cli-args.d.ts +3 -1
- package/dist/cli-args.js +6 -4
- package/dist/cli-catalog.js +1 -0
- package/dist/commands/completion.js +41 -17
- package/dist/commands/screenshot.js +28 -5
- package/dist/mcp/args.d.ts +3 -1
- package/dist/mcp/args.js +4 -3
- package/dist/mcp/tools.js +19 -1
- package/dist/screenshot-local.d.ts +18 -0
- package/dist/screenshot-local.js +36 -4
- package/dist/screenshot-remote.d.ts +19 -2
- package/dist/screenshot-remote.js +2 -2
- package/dist/screenshot.d.ts +37 -0
- package/dist/screenshot.js +33 -3
- package/package.json +3 -3
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.
|
|
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`
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/cli-catalog.js
CHANGED
|
@@ -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.
|
|
7
|
-
completes commands, subcommands,
|
|
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
|
-
#
|
|
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
|
-
//
|
|
144
|
-
|
|
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
|
|
148
|
-
if (g.flag === "-
|
|
149
|
-
return
|
|
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
|
-
|
|
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
|
|
547
|
-
//
|
|
548
|
-
//
|
|
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({
|
package/dist/mcp/args.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 {};
|
package/dist/screenshot-local.js
CHANGED
|
@@ -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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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)
|
package/dist/screenshot.d.ts
CHANGED
|
@@ -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
|
package/dist/screenshot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
+
"files-sdk": "^2.2.4",
|
|
52
52
|
"typescript": "^7.0.2",
|
|
53
53
|
"vitest": "^4.1.10"
|
|
54
54
|
},
|