@buildinternet/uploads 0.12.1 → 0.13.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.
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
@@ -10,7 +10,7 @@ import { ghMetadataFromTarget } from "../github.js";
10
10
  import { execRunner } from "../github-gh.js";
11
11
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
12
12
  import { writeJson, writeStdout } from "../io.js";
13
- import { captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
13
+ import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
14
14
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
15
15
 
16
16
  Capture a URL or a local .html file and host it — a hosted, PR-embeddable
@@ -43,6 +43,12 @@ Options:
43
43
  own prefers-color-scheme queries won't flip)
44
44
  --wait <load|domcontentloaded|networkidle|ms> Settle strategy (default: load); a millisecond
45
45
  count is local-only — use --via local
46
+ --hide <css> Hide matching elements before capture (repeatable)
47
+ --no-hide-dev-tools Don't auto-hide framework dev toolbars (auto-hidden on localhost/private)
48
+ --reduced-motion Emulate prefers-reduced-motion: reduce so animations settle (best-effort
49
+ on --via remote — neutralizes animations via injected CSS)
50
+ --eval <js> Run JS in the page after settle, before capture (--via local only)
51
+ --init-script <file> Inject a JS file before navigation (--via local only)
46
52
  --out <file> Also write the PNG to a local file
47
53
  --no-upload Skip hosting; requires --out (local file only)
48
54
  --destination <id> Typed root: screenshots | gh | f
@@ -129,6 +135,26 @@ captureImpl = captureScreenshot) {
129
135
  const fullPage = flagBool(parsed.flags, "--full-page");
130
136
  const colorScheme = colorSchemeFromFlags(parsed.flags);
131
137
  const waitUntil = parseWaitUntil(flagString(parsed.flags, "--wait"));
138
+ const hide = flagValues(parsed.flags, "--hide");
139
+ // Fail fast before capture, using the shared policy (throws UploadsError
140
+ // code USAGE → exit 2, same as UsageError) so there's one source of truth.
141
+ for (const sel of hide)
142
+ assertHideSelector(sel);
143
+ // --no-hide-dev-tools opts out of auto-hiding framework toolbars; undefined
144
+ // lets captureScreenshot apply its localhost-aware default.
145
+ const hideDevTools = flagBool(parsed.flags, "--no-hide-dev-tools") ? false : undefined;
146
+ const reducedMotion = flagBool(parsed.flags, "--reduced-motion");
147
+ const evalJs = flagString(parsed.flags, "--eval");
148
+ const initScriptPath = flagString(parsed.flags, "--init-script");
149
+ let initScript;
150
+ if (initScriptPath !== undefined) {
151
+ try {
152
+ initScript = readFileSync(initScriptPath, "utf8");
153
+ }
154
+ catch (err) {
155
+ throw new UsageError(`could not read --init-script ${initScriptPath}: ${err instanceof Error ? err.message : String(err)}`);
156
+ }
157
+ }
132
158
  const outFile = flagString(parsed.flags, "--out");
133
159
  const noUpload = flagBool(parsed.flags, "--no-upload");
134
160
  if (noUpload && !outFile)
@@ -207,6 +233,11 @@ captureImpl = captureScreenshot) {
207
233
  fullPage,
208
234
  colorScheme,
209
235
  waitUntil,
236
+ hide,
237
+ hideDevTools,
238
+ reducedMotion,
239
+ evalJs,
240
+ initScript,
210
241
  apiUrl: ctx.config.apiUrl,
211
242
  token: ctx.config.token,
212
243
  });
@@ -1,5 +1,6 @@
1
1
  export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
2
2
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
3
+ export { mapBounded } from "../async.js";
3
4
  export interface McpTool {
4
5
  name: string;
5
6
  description: string;
@@ -12,6 +12,7 @@ import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
12
12
  import { ToolBatchError } from "./batch-error.js";
13
13
  export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
14
14
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
15
+ export { mapBounded } from "../async.js";
15
16
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
16
17
  const LATEST_PROTOCOL_VERSION = "2025-06-18";
17
18
  function response(id, result) {
@@ -115,7 +116,11 @@ export function createMcpServer(opts) {
115
116
  const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested)
116
117
  ? requested
117
118
  : LATEST_PROTOCOL_VERSION;
118
- return response(id, { protocolVersion, capabilities: { tools: {} }, serverInfo });
119
+ return response(id, {
120
+ protocolVersion,
121
+ capabilities: { tools: {} },
122
+ serverInfo,
123
+ });
119
124
  }
120
125
  case "ping":
121
126
  return response(id, {});
package/dist/mcp/tools.js CHANGED
@@ -546,6 +546,19 @@ export function createUploadsMcpTools(opts) {
546
546
  type: "string",
547
547
  description: 'Settle strategy: load (default) | domcontentloaded | networkidle | a millisecond count (millisecond counts are local-only — via: "local").',
548
548
  },
549
+ hide: {
550
+ type: "array",
551
+ items: { type: "string" },
552
+ description: "CSS selectors to hide (display:none) before capture. Works on both backends.",
553
+ },
554
+ noHideDevTools: {
555
+ type: "boolean",
556
+ description: "Don't auto-hide framework dev toolbars (Astro/Next/Nuxt/Vite), which are hidden by default for localhost/private-network targets.",
557
+ },
558
+ reducedMotion: {
559
+ type: "boolean",
560
+ description: 'Emulate prefers-reduced-motion: reduce so animations settle. Best-effort on via: "remote" (neutralizes animations via injected CSS).',
561
+ },
549
562
  key: {
550
563
  type: "string",
551
564
  description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.png). Cannot be combined with pr/issue.",
@@ -684,6 +697,9 @@ export function createUploadsMcpTools(opts) {
684
697
  fullPage: optBool(args, "fullPage"),
685
698
  colorScheme: colorSchemeArg,
686
699
  waitUntil: screenshotModule.parseWaitUntil(optString(args, "wait")),
700
+ hide: optStringArray(args, "hide"),
701
+ hideDevTools: optBool(args, "noHideDevTools") ? false : undefined,
702
+ reducedMotion: optBool(args, "reducedMotion"),
687
703
  apiUrl: config.apiUrl,
688
704
  token: config.token,
689
705
  });
@@ -51,6 +51,14 @@ export interface LocalCaptureOptions {
51
51
  colorScheme?: "dark" | "light";
52
52
  /** "load" | "domcontentloaded" | "networkidle", or a millisecond settle delay. */
53
53
  waitUntil: "load" | "domcontentloaded" | "networkidle" | number;
54
+ /** CSS selectors hidden (display:none) just before capture. */
55
+ hide?: string[];
56
+ /** Emulate prefers-reduced-motion: reduce so animations settle deterministically. */
57
+ reducedMotion?: boolean;
58
+ /** JS run via page.evaluate after settle, before capture. */
59
+ evalJs?: string;
60
+ /** JS injected via addInitScript before navigation. */
61
+ initScript?: string;
54
62
  timeoutMs?: number;
55
63
  detectRoots?: DetectRoots;
56
64
  /**
@@ -285,19 +285,37 @@ export async function captureLocal(opts) {
285
285
  viewport: { width: opts.viewport.width, height: opts.viewport.height },
286
286
  deviceScaleFactor: opts.viewport.deviceScaleFactor,
287
287
  colorScheme: opts.colorScheme,
288
+ reducedMotion: opts.reducedMotion ? "reduce" : undefined,
288
289
  });
290
+ // Runs before every navigation on this context — must be registered before
291
+ // the goto below so it's present when the page's own scripts first run.
292
+ if (opts.initScript)
293
+ await context.addInitScript({ content: opts.initScript });
289
294
  const page = usingCdp
290
295
  ? await context.newPage()
291
296
  : (context.pages()[0] ?? (await context.newPage()));
292
297
  if (usingCdp) {
293
298
  await page.setViewportSize({ width: opts.viewport.width, height: opts.viewport.height });
294
- if (opts.colorScheme)
295
- await page.emulateMedia({ colorScheme: opts.colorScheme });
299
+ // deviceScaleFactor + reducedMotion can't be set on an already-launched
300
+ // CDP context, but emulateMedia (colorScheme + reduced-motion) still can.
301
+ if (opts.colorScheme || opts.reducedMotion) {
302
+ await page.emulateMedia({
303
+ colorScheme: opts.colorScheme,
304
+ reducedMotion: opts.reducedMotion ? "reduce" : undefined,
305
+ });
306
+ }
296
307
  }
297
308
  const waitUntil = typeof opts.waitUntil === "string" ? opts.waitUntil : "load";
298
309
  await page.goto(opts.url, { waitUntil, timeout: opts.timeoutMs ?? 30_000 });
299
310
  if (typeof opts.waitUntil === "number")
300
311
  await page.waitForTimeout(opts.waitUntil);
312
+ // Hide overlays first, then run any user eval (which may depend on, or
313
+ // deliberately override, the hidden state).
314
+ if (opts.hide && opts.hide.length > 0) {
315
+ await page.addStyleTag({ content: `${opts.hide.join(",")}{display:none !important}` });
316
+ }
317
+ if (opts.evalJs)
318
+ await page.evaluate(opts.evalJs);
301
319
  const png = opts.selector
302
320
  ? await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 })
303
321
  : await page.screenshot({ fullPage: opts.fullPage === true });
@@ -12,6 +12,14 @@ export interface RemoteRenderRequest {
12
12
  fullPage?: boolean;
13
13
  colorScheme?: "dark" | "light";
14
14
  waitUntil?: "load" | "domcontentloaded" | "networkidle" | number;
15
+ /** CSS selectors hidden (display:none) server-side before capture. */
16
+ hide?: string[];
17
+ /**
18
+ * Best-effort reduced-motion on the remote backend: the render endpoint has
19
+ * no true media-feature emulation, so it neutralizes animations/transitions
20
+ * with an injected stylesheet (documented gap, like colorScheme).
21
+ */
22
+ reducedMotion?: boolean;
15
23
  }
16
24
  export interface RemoteRenderOptions {
17
25
  apiUrl: string;
@@ -2,6 +2,23 @@ import { captureRemote } from "./screenshot-remote.js";
2
2
  import type { DetectRoots } from "./screenshot-local.js";
3
3
  export type ScreenshotBackend = "auto" | "local" | "remote";
4
4
  export type WaitUntil = "load" | "domcontentloaded" | "networkidle" | number;
5
+ /**
6
+ * Host selectors for framework dev toolbars/overlays that otherwise pollute a
7
+ * screenshot of a running dev server. Hidden (display:none) automatically when
8
+ * the target is a localhost/private URL, unless `--no-hide-dev-tools`. Hiding
9
+ * the custom-element host also hides its shadow-DOM contents, so a single
10
+ * host-level rule is enough for the web-component toolbars.
11
+ */
12
+ export declare const DEV_TOOLBAR_SELECTORS: readonly string[];
13
+ /**
14
+ * Reject a `--hide` selector that could break out of the generated
15
+ * `selector{display:none}` rule (or inject markup server-side). The selectors
16
+ * are the caller's own, so this guards against footguns, not a trust boundary.
17
+ * `@` is rejected too: a leading at-rule (e.g. `@import url(...);*`) needs no
18
+ * braces to smuggle an `@import` into the injected stylesheet — and `@` is
19
+ * never valid in a CSS selector anyway.
20
+ */
21
+ export declare function assertHideSelector(selector: string): void;
5
22
  export interface ScreenshotViewport {
6
23
  width: number;
7
24
  height: number;
@@ -41,6 +58,19 @@ export interface CaptureScreenshotOptions {
41
58
  fullPage?: boolean;
42
59
  colorScheme?: "dark" | "light";
43
60
  waitUntil?: WaitUntil;
61
+ /** Extra CSS selectors to hide (display:none) before capture. */
62
+ hide?: string[];
63
+ /**
64
+ * Auto-hide known framework dev toolbars. Defaults to on for localhost/
65
+ * private-network targets and off otherwise; pass `false` to opt out.
66
+ */
67
+ hideDevTools?: boolean;
68
+ /** Emulate prefers-reduced-motion: reduce so CSS/JS animations settle. */
69
+ reducedMotion?: boolean;
70
+ /** Run this JS in the page after settle, before capture (local backend only). */
71
+ evalJs?: string;
72
+ /** Inject this JS as an init script before navigation (local backend only). */
73
+ initScript?: string;
44
74
  apiUrl: string;
45
75
  token: string;
46
76
  /** Injectable for tests; forwarded to detectLocalBrowser. */
@@ -55,6 +85,10 @@ export interface CaptureScreenshotOptions {
55
85
  fullPage?: boolean;
56
86
  colorScheme?: "dark" | "light";
57
87
  waitUntil: WaitUntil;
88
+ hide?: string[];
89
+ reducedMotion?: boolean;
90
+ evalJs?: string;
91
+ initScript?: string;
58
92
  detectRoots?: DetectRoots;
59
93
  /** Pre-computed detection result from auto-routing, to avoid a second fs scan. */
60
94
  detectResult?: import("./screenshot-local.js").DetectResult;
@@ -13,6 +13,35 @@ import { basename, resolve as resolvePath } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import { UploadsError } from "./errors.js";
15
15
  import { captureRemote, MAX_REMOTE_HTML_BYTES } from "./screenshot-remote.js";
16
+ /**
17
+ * Host selectors for framework dev toolbars/overlays that otherwise pollute a
18
+ * screenshot of a running dev server. Hidden (display:none) automatically when
19
+ * the target is a localhost/private URL, unless `--no-hide-dev-tools`. Hiding
20
+ * the custom-element host also hides its shadow-DOM contents, so a single
21
+ * host-level rule is enough for the web-component toolbars.
22
+ */
23
+ export const DEV_TOOLBAR_SELECTORS = [
24
+ "astro-dev-toolbar", // Astro
25
+ "#__next-build-watcher", // Next.js (legacy build-activity indicator)
26
+ "nextjs-portal", // Next.js dev overlay / indicators (App Router)
27
+ "#nuxt-devtools-anchor", // Nuxt DevTools launcher
28
+ "#nuxt-devtools-container",
29
+ "vite-plugin-checker-error-overlay", // vite-plugin-checker overlay
30
+ "vite-error-overlay", // Vite HMR error overlay
31
+ ];
32
+ /**
33
+ * Reject a `--hide` selector that could break out of the generated
34
+ * `selector{display:none}` rule (or inject markup server-side). The selectors
35
+ * are the caller's own, so this guards against footguns, not a trust boundary.
36
+ * `@` is rejected too: a leading at-rule (e.g. `@import url(...);*`) needs no
37
+ * braces to smuggle an `@import` into the injected stylesheet — and `@` is
38
+ * never valid in a CSS selector anyway.
39
+ */
40
+ export function assertHideSelector(selector) {
41
+ if (selector.length === 0 || /[@{}<>]/.test(selector)) {
42
+ throw new UploadsError(`invalid hide selector: ${JSON.stringify(selector)} (a CSS selector, no @, {, }, <, or >)`, "USAGE");
43
+ }
44
+ }
16
45
  export const DEFAULT_SCREENSHOT_VIEWPORT = {
17
46
  width: 1280,
18
47
  height: 800,
@@ -159,6 +188,13 @@ export async function captureScreenshot(opts) {
159
188
  // is sent to the remote backend as an inline `html` body (though anything
160
189
  // it references via file:// or relative paths won't resolve there).
161
190
  const localOnly = target.kind === "url" && target.localOnly;
191
+ // Auto-hide framework dev toolbars only makes sense for a running dev server
192
+ // (a localhost/private URL); default off elsewhere. Combine with any
193
+ // explicit --hide selectors into one list shared by both backends.
194
+ const autoHideDevTools = opts.hideDevTools ?? localOnly;
195
+ for (const sel of opts.hide ?? [])
196
+ assertHideSelector(sel);
197
+ const hide = [...(opts.hide ?? []), ...(autoHideDevTools ? DEV_TOOLBAR_SELECTORS : [])];
162
198
  // Populated only when auto-routing actually probes the filesystem, so it
163
199
  // can be threaded into captureLocalImpl below to avoid a second scan.
164
200
  let detected;
@@ -192,6 +228,12 @@ export async function captureScreenshot(opts) {
192
228
  if (backend === "remote" && typeof waitUntil === "number") {
193
229
  throw new UploadsError(`numeric --wait (${waitUntil}ms) is local-only — use --via local, or one of load/domcontentloaded/networkidle for the remote backend`, "USAGE");
194
230
  }
231
+ // Arbitrary pre-capture JS runs only on the local backend — the shared
232
+ // remote renderer intentionally has no eval escape hatch (different security
233
+ // posture). Fail fast rather than silently dropping it.
234
+ if (backend === "remote" && (opts.evalJs !== undefined || opts.initScript !== undefined)) {
235
+ throw new UploadsError("--eval and --init-script are local-only — use --via local", "USAGE");
236
+ }
195
237
  if (backend === "local") {
196
238
  const captureLocalImpl = opts.captureLocalImpl ??
197
239
  (async (localOpts) => {
@@ -207,6 +249,10 @@ export async function captureScreenshot(opts) {
207
249
  fullPage: opts.fullPage,
208
250
  colorScheme: opts.colorScheme,
209
251
  waitUntil,
252
+ hide,
253
+ reducedMotion: opts.reducedMotion,
254
+ evalJs: opts.evalJs,
255
+ initScript: opts.initScript,
210
256
  detectRoots: opts.detectRoots,
211
257
  detectResult: detected,
212
258
  });
@@ -226,6 +272,8 @@ export async function captureScreenshot(opts) {
226
272
  fullPage: opts.fullPage,
227
273
  colorScheme: opts.colorScheme,
228
274
  waitUntil,
275
+ ...(hide.length > 0 ? { hide } : {}),
276
+ ...(opts.reducedMotion ? { reducedMotion: true } : {}),
229
277
  }, { apiUrl: opts.apiUrl, token: opts.token });
230
278
  return { png, filename, backend };
231
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.12.1",
3
+ "version": "0.13.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,