@buildinternet/uploads 0.1.1 → 0.3.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/LICENSE +21 -0
- package/README.md +46 -10
- package/dist/cli.js +39 -2
- package/dist/client.d.ts +48 -1
- package/dist/client.js +52 -12
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +133 -0
- package/dist/commands/mcp.d.ts +4 -0
- package/dist/commands/mcp.js +39 -0
- package/dist/commands.d.ts +63 -0
- package/dist/commands.js +337 -76
- package/dist/config-file.d.ts +5 -1
- package/dist/config-file.js +27 -2
- package/dist/destinations.d.ts +26 -0
- package/dist/destinations.js +47 -0
- package/dist/errors.d.ts +1 -1
- package/dist/frame.d.ts +36 -0
- package/dist/frame.js +245 -0
- package/dist/github.d.ts +11 -0
- package/dist/github.js +32 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/io.d.ts +3 -0
- package/dist/io.js +9 -0
- package/dist/mcp/args.d.ts +4 -0
- package/dist/mcp/args.js +26 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +109 -0
- package/dist/mcp/stdio.d.ts +3 -0
- package/dist/mcp/stdio.js +14 -0
- package/dist/mcp/tools.d.ts +10 -0
- package/dist/mcp/tools.js +522 -0
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.js +177 -0
- package/package.json +15 -9
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
2
|
+
export declare const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
3
|
+
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
4
|
+
export declare const DEFAULT_OPTIMIZE_QUALITY = 85;
|
|
5
|
+
export type OptimizeOutputFormat = "webp" | "jpeg";
|
|
6
|
+
export interface OptimizeImageOptions {
|
|
7
|
+
/** When false, returns the input unchanged. Default true. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
format?: OptimizeOutputFormat;
|
|
10
|
+
maxEdge?: number;
|
|
11
|
+
quality?: number;
|
|
12
|
+
/**
|
|
13
|
+
* When true, preserve EXIF/XMP/ICC (and related) from the input via sharp
|
|
14
|
+
* `withMetadata()`. Default false — strip for privacy and smaller embeds.
|
|
15
|
+
* Orientation is still applied so pixels match what the user saw.
|
|
16
|
+
*/
|
|
17
|
+
keepExif?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface OptimizeImageResult {
|
|
20
|
+
bytes: Uint8Array;
|
|
21
|
+
filename: string;
|
|
22
|
+
/** Suggested Content-Type for the body (API still sniffs magic bytes). */
|
|
23
|
+
contentType: string;
|
|
24
|
+
optimized: boolean;
|
|
25
|
+
/** Why bytes were left as-is when optimized is false. */
|
|
26
|
+
skippedReason?: string;
|
|
27
|
+
originalBytes: number;
|
|
28
|
+
outputBytes: number;
|
|
29
|
+
}
|
|
30
|
+
/** Replace a trailing image-looking extension, or append when missing. */
|
|
31
|
+
export declare function withImageExtension(name: string, ext: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Optimize still images for public embeds. Safe to call on any payload:
|
|
34
|
+
* non-images and unsupported types pass through.
|
|
35
|
+
*/
|
|
36
|
+
export declare function optimizeImageForUpload(bytes: Uint8Array, filename: string, opts?: OptimizeImageOptions): Promise<OptimizeImageResult>;
|
|
37
|
+
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
38
|
+
export declare function rewriteKeyExtension(key: string, filename: string): string;
|
package/dist/optimize.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side still-image optimization for put/attach.
|
|
3
|
+
*
|
|
4
|
+
* Default path for GitHub embeds: re-encode PNG/JPEG (and similar) to WebP,
|
|
5
|
+
* cap the long edge, keep the smaller of original vs optimized. Animated GIF,
|
|
6
|
+
* SVG, video, and non-images are left unchanged.
|
|
7
|
+
*/
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
10
|
+
export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
11
|
+
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
12
|
+
export const DEFAULT_OPTIMIZE_QUALITY = 85;
|
|
13
|
+
const IMAGE_EXT = new Set([
|
|
14
|
+
"png",
|
|
15
|
+
"jpg",
|
|
16
|
+
"jpeg",
|
|
17
|
+
"webp",
|
|
18
|
+
"gif",
|
|
19
|
+
"tif",
|
|
20
|
+
"tiff",
|
|
21
|
+
"avif",
|
|
22
|
+
"heic",
|
|
23
|
+
"heif",
|
|
24
|
+
]);
|
|
25
|
+
function extensionOf(name) {
|
|
26
|
+
const base = name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name;
|
|
27
|
+
const dot = base.lastIndexOf(".");
|
|
28
|
+
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
29
|
+
}
|
|
30
|
+
/** Replace a trailing image-looking extension, or append when missing. */
|
|
31
|
+
export function withImageExtension(name, ext) {
|
|
32
|
+
const clean = ext.replace(/^\./, "").toLowerCase();
|
|
33
|
+
const slash = name.lastIndexOf("/");
|
|
34
|
+
const dir = slash >= 0 ? name.slice(0, slash + 1) : "";
|
|
35
|
+
const base = slash >= 0 ? name.slice(slash + 1) : name;
|
|
36
|
+
const dot = base.lastIndexOf(".");
|
|
37
|
+
if (dot >= 0 && IMAGE_EXT.has(base.slice(dot + 1).toLowerCase())) {
|
|
38
|
+
return `${dir}${base.slice(0, dot)}.${clean}`;
|
|
39
|
+
}
|
|
40
|
+
return `${dir}${base}.${clean}`;
|
|
41
|
+
}
|
|
42
|
+
function contentTypeForFormat(format) {
|
|
43
|
+
return format === "jpeg" ? "image/jpeg" : "image/webp";
|
|
44
|
+
}
|
|
45
|
+
function looksLikeSvg(bytes, filename) {
|
|
46
|
+
if (extensionOf(filename) === "svg")
|
|
47
|
+
return true;
|
|
48
|
+
const head = new TextDecoder("utf-8", { fatal: false })
|
|
49
|
+
.decode(bytes.subarray(0, Math.min(bytes.length, 256)))
|
|
50
|
+
.trimStart()
|
|
51
|
+
.toLowerCase();
|
|
52
|
+
return head.startsWith("<?xml") || head.startsWith("<svg");
|
|
53
|
+
}
|
|
54
|
+
function passthrough(bytes, filename, contentType, skippedReason) {
|
|
55
|
+
return {
|
|
56
|
+
bytes,
|
|
57
|
+
filename,
|
|
58
|
+
contentType,
|
|
59
|
+
optimized: false,
|
|
60
|
+
skippedReason,
|
|
61
|
+
originalBytes: bytes.byteLength,
|
|
62
|
+
outputBytes: bytes.byteLength,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Optimize still images for public embeds. Safe to call on any payload:
|
|
67
|
+
* non-images and unsupported types pass through.
|
|
68
|
+
*/
|
|
69
|
+
export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
70
|
+
const originalBytes = bytes.byteLength;
|
|
71
|
+
if (opts.enabled === false) {
|
|
72
|
+
return passthrough(bytes, filename, guessContentType(filename), "disabled");
|
|
73
|
+
}
|
|
74
|
+
if (originalBytes === 0) {
|
|
75
|
+
return passthrough(bytes, filename, guessContentType(filename), "empty");
|
|
76
|
+
}
|
|
77
|
+
if (looksLikeSvg(bytes, filename)) {
|
|
78
|
+
return passthrough(bytes, filename, "image/svg+xml", "svg");
|
|
79
|
+
}
|
|
80
|
+
const format = opts.format ?? "webp";
|
|
81
|
+
const maxEdge = opts.maxEdge ?? DEFAULT_OPTIMIZE_MAX_EDGE;
|
|
82
|
+
const quality = opts.quality ?? DEFAULT_OPTIMIZE_QUALITY;
|
|
83
|
+
if (!Number.isFinite(maxEdge) || maxEdge < 1) {
|
|
84
|
+
throw new Error(`optimize maxEdge must be a positive number (got ${maxEdge})`);
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
|
|
87
|
+
throw new Error(`optimize quality must be 1–100 (got ${quality})`);
|
|
88
|
+
}
|
|
89
|
+
let image = sharp(bytes, { animated: true, failOn: "none" });
|
|
90
|
+
let meta;
|
|
91
|
+
try {
|
|
92
|
+
meta = await image.metadata();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return passthrough(bytes, filename, guessContentType(filename), "not_image");
|
|
96
|
+
}
|
|
97
|
+
if (!meta.format) {
|
|
98
|
+
return passthrough(bytes, filename, guessContentType(filename), "not_image");
|
|
99
|
+
}
|
|
100
|
+
// Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
|
|
101
|
+
if ((meta.pages ?? 1) > 1) {
|
|
102
|
+
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessContentType(filename), "animated");
|
|
103
|
+
}
|
|
104
|
+
if (meta.format === "gif") {
|
|
105
|
+
// Single-frame GIF can convert; multi-page already returned above.
|
|
106
|
+
}
|
|
107
|
+
const width = meta.width ?? 0;
|
|
108
|
+
const height = meta.height ?? 0;
|
|
109
|
+
if (width > 0 && height > 0) {
|
|
110
|
+
const longEdge = Math.max(width, height);
|
|
111
|
+
if (longEdge > maxEdge) {
|
|
112
|
+
image = image.resize({
|
|
113
|
+
width: width >= height ? maxEdge : undefined,
|
|
114
|
+
height: height > width ? maxEdge : undefined,
|
|
115
|
+
fit: "inside",
|
|
116
|
+
withoutEnlargement: true,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Apply EXIF orientation so stored pixels match what users saw.
|
|
121
|
+
image = image.rotate();
|
|
122
|
+
// Default: strip metadata (privacy + size). Opt in to keep EXIF/etc. for
|
|
123
|
+
// cases where image metadata is part of the discussion (e.g. photo forensics).
|
|
124
|
+
if (opts.keepExif) {
|
|
125
|
+
image = image.withMetadata();
|
|
126
|
+
}
|
|
127
|
+
let encoded;
|
|
128
|
+
try {
|
|
129
|
+
if (format === "jpeg") {
|
|
130
|
+
encoded = await image.jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
encoded = await image.webp({ quality, effort: 4 }).toBuffer();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return passthrough(bytes, filename, guessContentType(filename), "encode_failed");
|
|
138
|
+
}
|
|
139
|
+
if (encoded.byteLength >= originalBytes) {
|
|
140
|
+
return passthrough(bytes, filename, guessContentType(filename), "not_smaller");
|
|
141
|
+
}
|
|
142
|
+
const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
|
|
143
|
+
return {
|
|
144
|
+
bytes: new Uint8Array(encoded),
|
|
145
|
+
filename: outFilename,
|
|
146
|
+
contentType: contentTypeForFormat(format),
|
|
147
|
+
optimized: true,
|
|
148
|
+
originalBytes,
|
|
149
|
+
outputBytes: encoded.byteLength,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function guessContentType(filename) {
|
|
153
|
+
switch (extensionOf(filename)) {
|
|
154
|
+
case "png":
|
|
155
|
+
return "image/png";
|
|
156
|
+
case "jpg":
|
|
157
|
+
case "jpeg":
|
|
158
|
+
return "image/jpeg";
|
|
159
|
+
case "gif":
|
|
160
|
+
return "image/gif";
|
|
161
|
+
case "webp":
|
|
162
|
+
return "image/webp";
|
|
163
|
+
case "avif":
|
|
164
|
+
return "image/avif";
|
|
165
|
+
case "svg":
|
|
166
|
+
return "image/svg+xml";
|
|
167
|
+
default:
|
|
168
|
+
return "application/octet-stream";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
172
|
+
export function rewriteKeyExtension(key, filename) {
|
|
173
|
+
const ext = extensionOf(filename);
|
|
174
|
+
if (!ext || !IMAGE_EXT.has(ext))
|
|
175
|
+
return key;
|
|
176
|
+
return withImageExtension(key, ext);
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildinternet/uploads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"./agent": {
|
|
19
19
|
"types": "./dist/agent.d.ts",
|
|
20
20
|
"import": "./dist/agent.js"
|
|
21
|
+
},
|
|
22
|
+
"./mcp": {
|
|
23
|
+
"types": "./dist/mcp/server.d.ts",
|
|
24
|
+
"import": "./dist/mcp/server.js"
|
|
21
25
|
}
|
|
22
26
|
},
|
|
23
27
|
"bin": {
|
|
@@ -28,13 +32,6 @@
|
|
|
28
32
|
"dist",
|
|
29
33
|
"README.md"
|
|
30
34
|
],
|
|
31
|
-
"scripts": {
|
|
32
|
-
"test": "vitest run",
|
|
33
|
-
"typecheck": "tsc --noEmit",
|
|
34
|
-
"build": "tsc",
|
|
35
|
-
"pack:check": "node ./scripts/check-pack.mjs",
|
|
36
|
-
"prepublishOnly": "npm run build"
|
|
37
|
-
},
|
|
38
35
|
"engines": {
|
|
39
36
|
"node": ">=22"
|
|
40
37
|
},
|
|
@@ -56,5 +53,14 @@
|
|
|
56
53
|
"publishConfig": {
|
|
57
54
|
"access": "public",
|
|
58
55
|
"provenance": true
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"sharp": "^0.35.3"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
63
|
+
"build": "tsc",
|
|
64
|
+
"pack:check": "node ./scripts/check-pack.mjs"
|
|
59
65
|
}
|
|
60
|
-
}
|
|
66
|
+
}
|