@rog0x/mcp-image-tools 1.0.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 +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +247 -0
- package/dist/tools/favicon-finder.d.ts +12 -0
- package/dist/tools/favicon-finder.js +177 -0
- package/dist/tools/image-meta.d.ts +14 -0
- package/dist/tools/image-meta.js +50 -0
- package/dist/tools/og-image.d.ts +18 -0
- package/dist/tools/og-image.js +95 -0
- package/dist/tools/placeholder-generator.d.ts +26 -0
- package/dist/tools/placeholder-generator.js +50 -0
- package/dist/tools/responsive-images.d.ts +22 -0
- package/dist/tools/responsive-images.js +101 -0
- package/package.json +37 -0
- package/src/index.ts +271 -0
- package/src/tools/favicon-finder.ts +193 -0
- package/src/tools/image-meta.ts +64 -0
- package/src/tools/og-image.ts +122 -0
- package/src/tools/placeholder-generator.ts +81 -0
- package/src/tools/responsive-images.ts +127 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
export interface FaviconResult {
|
|
2
|
+
domain: string;
|
|
3
|
+
favicons: FaviconEntry[];
|
|
4
|
+
checkedLocations: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface FaviconEntry {
|
|
8
|
+
url: string;
|
|
9
|
+
source: string;
|
|
10
|
+
type: string | null;
|
|
11
|
+
sizes: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchText(url: string, timeoutMs = 10000): Promise<{ text: string; ok: boolean }> {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
redirect: "follow",
|
|
21
|
+
headers: { "User-Agent": "MCP-Image-Tools/1.0" },
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) return { text: "", ok: false };
|
|
24
|
+
const text = await response.text();
|
|
25
|
+
return { text, ok: true };
|
|
26
|
+
} catch {
|
|
27
|
+
return { text: "", ok: false };
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function checkUrl(url: string, timeoutMs = 8000): Promise<boolean> {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
method: "HEAD",
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
redirect: "follow",
|
|
41
|
+
});
|
|
42
|
+
return response.ok;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
} finally {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractLinkTags(html: string, baseUrl: string): FaviconEntry[] {
|
|
51
|
+
const entries: FaviconEntry[] = [];
|
|
52
|
+
// Match <link> tags with rel containing "icon" or "apple-touch-icon"
|
|
53
|
+
const linkRegex = /<link\s+[^>]*>/gi;
|
|
54
|
+
let match: RegExpExecArray | null;
|
|
55
|
+
|
|
56
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
57
|
+
const tag = match[0];
|
|
58
|
+
const relMatch = tag.match(/rel\s*=\s*["']([^"']+)["']/i);
|
|
59
|
+
if (!relMatch) continue;
|
|
60
|
+
|
|
61
|
+
const rel = relMatch[1].toLowerCase();
|
|
62
|
+
if (!rel.includes("icon")) continue;
|
|
63
|
+
|
|
64
|
+
const hrefMatch = tag.match(/href\s*=\s*["']([^"']+)["']/i);
|
|
65
|
+
if (!hrefMatch) continue;
|
|
66
|
+
|
|
67
|
+
const href = hrefMatch[1];
|
|
68
|
+
const typeMatch = tag.match(/type\s*=\s*["']([^"']+)["']/i);
|
|
69
|
+
const sizesMatch = tag.match(/sizes\s*=\s*["']([^"']+)["']/i);
|
|
70
|
+
|
|
71
|
+
let resolvedUrl: string;
|
|
72
|
+
try {
|
|
73
|
+
resolvedUrl = new URL(href, baseUrl).href;
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
entries.push({
|
|
79
|
+
url: resolvedUrl,
|
|
80
|
+
source: `HTML link[rel="${rel}"]`,
|
|
81
|
+
type: typeMatch ? typeMatch[1] : null,
|
|
82
|
+
sizes: sizesMatch ? sizesMatch[1] : null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractManifestIcons(manifestJson: string, baseUrl: string): FaviconEntry[] {
|
|
90
|
+
const entries: FaviconEntry[] = [];
|
|
91
|
+
try {
|
|
92
|
+
const manifest = JSON.parse(manifestJson);
|
|
93
|
+
const icons = manifest.icons as Array<{ src: string; sizes?: string; type?: string }>;
|
|
94
|
+
if (!Array.isArray(icons)) return entries;
|
|
95
|
+
|
|
96
|
+
for (const icon of icons) {
|
|
97
|
+
if (!icon.src) continue;
|
|
98
|
+
let resolvedUrl: string;
|
|
99
|
+
try {
|
|
100
|
+
resolvedUrl = new URL(icon.src, baseUrl).href;
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
entries.push({
|
|
105
|
+
url: resolvedUrl,
|
|
106
|
+
source: "manifest.json",
|
|
107
|
+
type: icon.type || null,
|
|
108
|
+
sizes: icon.sizes || null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Invalid JSON
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function findFavicons(websiteUrl: string): Promise<FaviconResult> {
|
|
118
|
+
const parsed = new URL(websiteUrl);
|
|
119
|
+
const origin = parsed.origin;
|
|
120
|
+
const domain = parsed.hostname;
|
|
121
|
+
const checkedLocations: string[] = [];
|
|
122
|
+
const favicons: FaviconEntry[] = [];
|
|
123
|
+
const seenUrls = new Set<string>();
|
|
124
|
+
|
|
125
|
+
function addFavicon(entry: FaviconEntry): void {
|
|
126
|
+
if (!seenUrls.has(entry.url)) {
|
|
127
|
+
seenUrls.add(entry.url);
|
|
128
|
+
favicons.push(entry);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 1. Check /favicon.ico
|
|
133
|
+
const faviconIcoUrl = `${origin}/favicon.ico`;
|
|
134
|
+
checkedLocations.push(faviconIcoUrl);
|
|
135
|
+
const icoExists = await checkUrl(faviconIcoUrl);
|
|
136
|
+
if (icoExists) {
|
|
137
|
+
addFavicon({
|
|
138
|
+
url: faviconIcoUrl,
|
|
139
|
+
source: "default /favicon.ico",
|
|
140
|
+
type: "image/x-icon",
|
|
141
|
+
sizes: null,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. Parse HTML for link tags
|
|
146
|
+
checkedLocations.push(websiteUrl);
|
|
147
|
+
const { text: html, ok: htmlOk } = await fetchText(websiteUrl);
|
|
148
|
+
if (htmlOk) {
|
|
149
|
+
const htmlFavicons = extractLinkTags(html, websiteUrl);
|
|
150
|
+
for (const f of htmlFavicons) {
|
|
151
|
+
addFavicon(f);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 3. Check for manifest.json reference
|
|
155
|
+
const manifestMatch = html.match(/<link\s+[^>]*rel\s*=\s*["']manifest["'][^>]*href\s*=\s*["']([^"']+)["']/i)
|
|
156
|
+
|| html.match(/<link\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']manifest["']/i);
|
|
157
|
+
|
|
158
|
+
if (manifestMatch) {
|
|
159
|
+
let manifestUrl: string;
|
|
160
|
+
try {
|
|
161
|
+
manifestUrl = new URL(manifestMatch[1], websiteUrl).href;
|
|
162
|
+
} catch {
|
|
163
|
+
manifestUrl = "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (manifestUrl) {
|
|
167
|
+
checkedLocations.push(manifestUrl);
|
|
168
|
+
const { text: manifestText, ok: manifestOk } = await fetchText(manifestUrl);
|
|
169
|
+
if (manifestOk) {
|
|
170
|
+
const manifestIcons = extractManifestIcons(manifestText, manifestUrl);
|
|
171
|
+
for (const f of manifestIcons) {
|
|
172
|
+
addFavicon(f);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. Check common manifest location
|
|
180
|
+
const defaultManifest = `${origin}/manifest.json`;
|
|
181
|
+
if (!checkedLocations.includes(defaultManifest)) {
|
|
182
|
+
checkedLocations.push(defaultManifest);
|
|
183
|
+
const { text: manifestText, ok: manifestOk } = await fetchText(defaultManifest);
|
|
184
|
+
if (manifestOk) {
|
|
185
|
+
const manifestIcons = extractManifestIcons(manifestText, defaultManifest);
|
|
186
|
+
for (const f of manifestIcons) {
|
|
187
|
+
addFavicon(f);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { domain, favicons, checkedLocations };
|
|
193
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface ImageMetadata {
|
|
2
|
+
url: string;
|
|
3
|
+
contentType: string | null;
|
|
4
|
+
fileSize: number | null;
|
|
5
|
+
fileSizeFormatted: string | null;
|
|
6
|
+
lastModified: string | null;
|
|
7
|
+
etag: string | null;
|
|
8
|
+
cacheControl: string | null;
|
|
9
|
+
acceptRanges: string | null;
|
|
10
|
+
server: string | null;
|
|
11
|
+
accessTime: string;
|
|
12
|
+
statusCode: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatBytes(bytes: number): string {
|
|
16
|
+
if (bytes === 0) return "0 B";
|
|
17
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
18
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
19
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function getImageMetadata(url: string): Promise<ImageMetadata> {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Use HEAD request first to avoid downloading the entire image
|
|
28
|
+
let response = await fetch(url, {
|
|
29
|
+
method: "HEAD",
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
redirect: "follow",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Some servers reject HEAD, fall back to GET with range
|
|
35
|
+
if (response.status === 405 || response.status === 403) {
|
|
36
|
+
response = await fetch(url, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers: { Range: "bytes=0-0" },
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
redirect: "follow",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const headers = response.headers;
|
|
45
|
+
const contentLength = headers.get("content-length");
|
|
46
|
+
const fileSize = contentLength ? parseInt(contentLength, 10) : null;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
url,
|
|
50
|
+
contentType: headers.get("content-type"),
|
|
51
|
+
fileSize,
|
|
52
|
+
fileSizeFormatted: fileSize !== null ? formatBytes(fileSize) : null,
|
|
53
|
+
lastModified: headers.get("last-modified"),
|
|
54
|
+
etag: headers.get("etag"),
|
|
55
|
+
cacheControl: headers.get("cache-control"),
|
|
56
|
+
acceptRanges: headers.get("accept-ranges"),
|
|
57
|
+
server: headers.get("server"),
|
|
58
|
+
accessTime: new Date().toISOString(),
|
|
59
|
+
statusCode: response.status,
|
|
60
|
+
};
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export interface OgImageResult {
|
|
2
|
+
url: string;
|
|
3
|
+
ogImage: string | null;
|
|
4
|
+
ogImageAlt: string | null;
|
|
5
|
+
ogImageWidth: string | null;
|
|
6
|
+
ogImageHeight: string | null;
|
|
7
|
+
ogImageType: string | null;
|
|
8
|
+
twitterImage: string | null;
|
|
9
|
+
twitterCard: string | null;
|
|
10
|
+
appleTouchIcon: string | null;
|
|
11
|
+
allImages: ImageEntry[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ImageEntry {
|
|
15
|
+
url: string;
|
|
16
|
+
source: string;
|
|
17
|
+
alt: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractMetaContent(html: string, property: string): string | null {
|
|
21
|
+
// Match both property="..." and name="..." attributes
|
|
22
|
+
const patterns = [
|
|
23
|
+
new RegExp(`<meta\\s+[^>]*property\\s*=\\s*["']${escapeRegex(property)}["'][^>]*content\\s*=\\s*["']([^"']+)["']`, "i"),
|
|
24
|
+
new RegExp(`<meta\\s+[^>]*content\\s*=\\s*["']([^"']+)["'][^>]*property\\s*=\\s*["']${escapeRegex(property)}["']`, "i"),
|
|
25
|
+
new RegExp(`<meta\\s+[^>]*name\\s*=\\s*["']${escapeRegex(property)}["'][^>]*content\\s*=\\s*["']([^"']+)["']`, "i"),
|
|
26
|
+
new RegExp(`<meta\\s+[^>]*content\\s*=\\s*["']([^"']+)["'][^>]*name\\s*=\\s*["']${escapeRegex(property)}["']`, "i"),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const pattern of patterns) {
|
|
30
|
+
const match = html.match(pattern);
|
|
31
|
+
if (match) return match[1];
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function escapeRegex(str: string): string {
|
|
37
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractAppleTouchIcon(html: string, baseUrl: string): string | null {
|
|
41
|
+
const match = html.match(/<link\s+[^>]*rel\s*=\s*["']apple-touch-icon["'][^>]*href\s*=\s*["']([^"']+)["']/i)
|
|
42
|
+
|| html.match(/<link\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']apple-touch-icon["']/i);
|
|
43
|
+
|
|
44
|
+
if (match) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(match[1], baseUrl).href;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveUrl(href: string | null, baseUrl: string): string | null {
|
|
55
|
+
if (!href) return null;
|
|
56
|
+
try {
|
|
57
|
+
return new URL(href, baseUrl).href;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function extractOgImage(url: string): Promise<OgImageResult> {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
redirect: "follow",
|
|
71
|
+
headers: { "User-Agent": "MCP-Image-Tools/1.0" },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const html = await response.text();
|
|
79
|
+
const allImages: ImageEntry[] = [];
|
|
80
|
+
|
|
81
|
+
// Extract OG image properties
|
|
82
|
+
const ogImage = resolveUrl(extractMetaContent(html, "og:image"), url);
|
|
83
|
+
const ogImageAlt = extractMetaContent(html, "og:image:alt");
|
|
84
|
+
const ogImageWidth = extractMetaContent(html, "og:image:width");
|
|
85
|
+
const ogImageHeight = extractMetaContent(html, "og:image:height");
|
|
86
|
+
const ogImageType = extractMetaContent(html, "og:image:type");
|
|
87
|
+
|
|
88
|
+
if (ogImage) {
|
|
89
|
+
allImages.push({ url: ogImage, source: "og:image", alt: ogImageAlt });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Extract Twitter card image
|
|
93
|
+
const twitterImage = resolveUrl(extractMetaContent(html, "twitter:image"), url);
|
|
94
|
+
const twitterCard = extractMetaContent(html, "twitter:card");
|
|
95
|
+
|
|
96
|
+
if (twitterImage && twitterImage !== ogImage) {
|
|
97
|
+
allImages.push({ url: twitterImage, source: "twitter:image", alt: null });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Extract Apple touch icon
|
|
101
|
+
const appleTouchIcon = extractAppleTouchIcon(html, url);
|
|
102
|
+
|
|
103
|
+
if (appleTouchIcon) {
|
|
104
|
+
allImages.push({ url: appleTouchIcon, source: "apple-touch-icon", alt: null });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
url,
|
|
109
|
+
ogImage,
|
|
110
|
+
ogImageAlt,
|
|
111
|
+
ogImageWidth,
|
|
112
|
+
ogImageHeight,
|
|
113
|
+
ogImageType,
|
|
114
|
+
twitterImage,
|
|
115
|
+
twitterCard,
|
|
116
|
+
appleTouchIcon,
|
|
117
|
+
allImages,
|
|
118
|
+
};
|
|
119
|
+
} finally {
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface PlaceholderConfig {
|
|
2
|
+
width: number;
|
|
3
|
+
height?: number;
|
|
4
|
+
backgroundColor?: string;
|
|
5
|
+
textColor?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
format?: "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg";
|
|
8
|
+
font?: string;
|
|
9
|
+
fontSize?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PlaceholderResult {
|
|
13
|
+
url: string;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
backgroundColor: string;
|
|
17
|
+
textColor: string;
|
|
18
|
+
text: string;
|
|
19
|
+
format: string;
|
|
20
|
+
htmlImg: string;
|
|
21
|
+
markdownImg: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generatePlaceholder(config: PlaceholderConfig): PlaceholderResult {
|
|
25
|
+
const width = Math.max(1, Math.min(config.width, 4000));
|
|
26
|
+
const height = config.height ? Math.max(1, Math.min(config.height, 4000)) : width;
|
|
27
|
+
const bgColor = (config.backgroundColor || "cccccc").replace(/^#/, "");
|
|
28
|
+
const txtColor = (config.textColor || "333333").replace(/^#/, "");
|
|
29
|
+
const format = config.format || "png";
|
|
30
|
+
const text = config.text || `${width}x${height}`;
|
|
31
|
+
|
|
32
|
+
// Build placehold.co URL
|
|
33
|
+
let url = `https://placehold.co/${width}x${height}/${bgColor}/${txtColor}`;
|
|
34
|
+
|
|
35
|
+
// Add format
|
|
36
|
+
url += `.${format}`;
|
|
37
|
+
|
|
38
|
+
// Add query params
|
|
39
|
+
const params = new URLSearchParams();
|
|
40
|
+
if (config.text) {
|
|
41
|
+
params.set("text", config.text);
|
|
42
|
+
}
|
|
43
|
+
if (config.font) {
|
|
44
|
+
params.set("font", config.font);
|
|
45
|
+
}
|
|
46
|
+
if (config.fontSize) {
|
|
47
|
+
params.set("font-size", config.fontSize.toString());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const queryString = params.toString();
|
|
51
|
+
if (queryString) {
|
|
52
|
+
url += `?${queryString}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const alt = `Placeholder ${width}x${height}`;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
url,
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
backgroundColor: `#${bgColor}`,
|
|
62
|
+
textColor: `#${txtColor}`,
|
|
63
|
+
text,
|
|
64
|
+
format,
|
|
65
|
+
htmlImg: `<img src="${url}" alt="${alt}" width="${width}" height="${height}" />`,
|
|
66
|
+
markdownImg: ``,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function generatePlaceholderSet(
|
|
71
|
+
sizes: Array<{ width: number; height?: number }>,
|
|
72
|
+
options?: Omit<PlaceholderConfig, "width" | "height">
|
|
73
|
+
): PlaceholderResult[] {
|
|
74
|
+
return sizes.map((size) =>
|
|
75
|
+
generatePlaceholder({
|
|
76
|
+
...options,
|
|
77
|
+
width: size.width,
|
|
78
|
+
height: size.height,
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export interface ResponsiveConfig {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
alt?: string;
|
|
4
|
+
widths?: number[];
|
|
5
|
+
sizes?: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
loading?: "lazy" | "eager";
|
|
8
|
+
formats?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResponsiveResult {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
variants: ImageVariant[];
|
|
14
|
+
srcsetAttr: string;
|
|
15
|
+
imgTag: string;
|
|
16
|
+
pictureTag: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ImageVariant {
|
|
20
|
+
url: string;
|
|
21
|
+
width: number;
|
|
22
|
+
descriptor: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inferImageFormat(url: string): string {
|
|
26
|
+
const pathname = new URL(url).pathname.toLowerCase();
|
|
27
|
+
if (pathname.endsWith(".webp")) return "webp";
|
|
28
|
+
if (pathname.endsWith(".avif")) return "avif";
|
|
29
|
+
if (pathname.endsWith(".png")) return "png";
|
|
30
|
+
if (pathname.endsWith(".gif")) return "gif";
|
|
31
|
+
if (pathname.endsWith(".svg")) return "svg";
|
|
32
|
+
return "jpeg";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildVariantUrl(baseUrl: string, width: number): string {
|
|
36
|
+
// Append width parameter - handles both existing and new query strings
|
|
37
|
+
const url = new URL(baseUrl);
|
|
38
|
+
url.searchParams.set("w", width.toString());
|
|
39
|
+
return url.href;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeHtml(str: string): string {
|
|
43
|
+
return str
|
|
44
|
+
.replace(/&/g, "&")
|
|
45
|
+
.replace(/"/g, """)
|
|
46
|
+
.replace(/</g, "<")
|
|
47
|
+
.replace(/>/g, ">");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function generateResponsiveImages(config: ResponsiveConfig): ResponsiveResult {
|
|
51
|
+
const defaultWidths = [320, 640, 768, 1024, 1280, 1536, 1920];
|
|
52
|
+
const widths = config.widths || defaultWidths;
|
|
53
|
+
const alt = config.alt || "";
|
|
54
|
+
const sizes = config.sizes || "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw";
|
|
55
|
+
const loading = config.loading || "lazy";
|
|
56
|
+
const className = config.className || "";
|
|
57
|
+
const formats = config.formats || ["webp", inferImageFormat(config.baseUrl)];
|
|
58
|
+
const sortedWidths = [...widths].sort((a, b) => a - b);
|
|
59
|
+
|
|
60
|
+
// Generate variants
|
|
61
|
+
const variants: ImageVariant[] = sortedWidths.map((width) => ({
|
|
62
|
+
url: buildVariantUrl(config.baseUrl, width),
|
|
63
|
+
width,
|
|
64
|
+
descriptor: `${width}w`,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Build srcset attribute
|
|
68
|
+
const srcsetAttr = variants
|
|
69
|
+
.map((v) => `${v.url} ${v.descriptor}`)
|
|
70
|
+
.join(",\n ");
|
|
71
|
+
|
|
72
|
+
// Build simple <img> tag with srcset
|
|
73
|
+
const classAttr = className ? ` class="${escapeHtml(className)}"` : "";
|
|
74
|
+
const imgTag = [
|
|
75
|
+
`<img`,
|
|
76
|
+
` src="${escapeHtml(config.baseUrl)}"`,
|
|
77
|
+
` srcset="${srcsetAttr}"`,
|
|
78
|
+
` sizes="${escapeHtml(sizes)}"`,
|
|
79
|
+
` alt="${escapeHtml(alt)}"`,
|
|
80
|
+
` loading="${loading}"`,
|
|
81
|
+
className ? ` class="${escapeHtml(className)}"` : null,
|
|
82
|
+
`/>`,
|
|
83
|
+
]
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join("\n");
|
|
86
|
+
|
|
87
|
+
// Build <picture> element with multiple formats
|
|
88
|
+
const sourceElements = formats.map((format) => {
|
|
89
|
+
const formatVariants = sortedWidths.map((width) => {
|
|
90
|
+
const url = new URL(config.baseUrl);
|
|
91
|
+
url.searchParams.set("w", width.toString());
|
|
92
|
+
url.searchParams.set("fm", format);
|
|
93
|
+
return `${url.href} ${width}w`;
|
|
94
|
+
});
|
|
95
|
+
const formatSrcset = formatVariants.join(",\n ");
|
|
96
|
+
const mimeType =
|
|
97
|
+
format === "jpg" || format === "jpeg"
|
|
98
|
+
? "image/jpeg"
|
|
99
|
+
: format === "avif"
|
|
100
|
+
? "image/avif"
|
|
101
|
+
: format === "webp"
|
|
102
|
+
? "image/webp"
|
|
103
|
+
: format === "png"
|
|
104
|
+
? "image/png"
|
|
105
|
+
: `image/${format}`;
|
|
106
|
+
return ` <source\n type="${mimeType}"\n srcset="${formatSrcset}"\n sizes="${escapeHtml(sizes)}"\n />`;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const pictureTag = [
|
|
110
|
+
`<picture${classAttr}>`,
|
|
111
|
+
...sourceElements,
|
|
112
|
+
` <img`,
|
|
113
|
+
` src="${escapeHtml(config.baseUrl)}"`,
|
|
114
|
+
` alt="${escapeHtml(alt)}"`,
|
|
115
|
+
` loading="${loading}"`,
|
|
116
|
+
` />`,
|
|
117
|
+
`</picture>`,
|
|
118
|
+
].join("\n");
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
baseUrl: config.baseUrl,
|
|
122
|
+
variants,
|
|
123
|
+
srcsetAttr,
|
|
124
|
+
imgTag,
|
|
125
|
+
pictureTag,
|
|
126
|
+
};
|
|
127
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"]
|
|
14
|
+
}
|