markdown-exit-s3-image 0.0.1
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/dist/index.d.mts +7 -0
- package/dist/index.mjs +225 -0
- package/dist/types-CsakQe1p.d.mts +87 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +1 -0
- package/package.json +41 -0
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { rgbaToDataURL } from "thumbhash";
|
|
3
|
+
import { promises } from "node:fs";
|
|
4
|
+
|
|
5
|
+
//#region src/cache.ts
|
|
6
|
+
var ImageCache = class {
|
|
7
|
+
cache = {};
|
|
8
|
+
isDirty = false;
|
|
9
|
+
cacheFilePath;
|
|
10
|
+
stats = {
|
|
11
|
+
apiRequests: 0,
|
|
12
|
+
cacheHits: 0
|
|
13
|
+
};
|
|
14
|
+
constructor(cacheFile) {
|
|
15
|
+
this.cacheFile = cacheFile;
|
|
16
|
+
this.cacheFilePath = this.cacheFile;
|
|
17
|
+
}
|
|
18
|
+
async load() {
|
|
19
|
+
try {
|
|
20
|
+
await promises.access(this.cacheFilePath);
|
|
21
|
+
const cacheContent = await promises.readFile(this.cacheFilePath, "utf8");
|
|
22
|
+
this.cache = JSON.parse(cacheContent || "{}");
|
|
23
|
+
console.log(`[ImageCache] Cache loaded from ${this.cacheFilePath} with ${Object.keys(this.cache).length} items.`);
|
|
24
|
+
} catch (_) {
|
|
25
|
+
console.log("[ImageCache] Cache file not found or failed to read, starting with an empty cache.");
|
|
26
|
+
this.cache = {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async save() {
|
|
30
|
+
if (!this.isDirty) {
|
|
31
|
+
console.log("[ImageCache] No changes to save.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const cacheContent = JSON.stringify(this.cache, null, 2);
|
|
36
|
+
await promises.writeFile(this.cacheFilePath, cacheContent, "utf8");
|
|
37
|
+
this.isDirty = false;
|
|
38
|
+
console.log(`[ImageCache] Cache saved to ${this.cacheFilePath}.`);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error("[ImageCache] Failed to save cache file:", error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
get(key) {
|
|
44
|
+
if (this.cache[key]) {
|
|
45
|
+
this.stats.cacheHits++;
|
|
46
|
+
return this.cache[key];
|
|
47
|
+
}
|
|
48
|
+
this.stats.apiRequests++;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
set(key, value) {
|
|
52
|
+
if (JSON.stringify(this.cache[key]) !== JSON.stringify(value)) {
|
|
53
|
+
this.cache[key] = value;
|
|
54
|
+
this.isDirty = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
getStats() {
|
|
58
|
+
const totalRequests = this.stats.apiRequests + this.stats.cacheHits;
|
|
59
|
+
return {
|
|
60
|
+
totalItems: Object.keys(this.cache).length,
|
|
61
|
+
isDirty: this.isDirty,
|
|
62
|
+
...this.stats,
|
|
63
|
+
totalRequests,
|
|
64
|
+
cacheHitRate: totalRequests > 0 ? (this.stats.cacheHits / totalRequests * 100).toFixed(1) : "0.0"
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/options.ts
|
|
71
|
+
function resolveOptions(userOptions = {}) {
|
|
72
|
+
const defaultOptions = {
|
|
73
|
+
progressive: {
|
|
74
|
+
enable: true,
|
|
75
|
+
srcset_widths: [
|
|
76
|
+
400,
|
|
77
|
+
600,
|
|
78
|
+
800,
|
|
79
|
+
1200,
|
|
80
|
+
2e3,
|
|
81
|
+
3e3
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
lazy: {
|
|
85
|
+
enable: true,
|
|
86
|
+
skip_first: 2
|
|
87
|
+
},
|
|
88
|
+
supported_domains: [],
|
|
89
|
+
ignore_formats: ["svg"],
|
|
90
|
+
cache_path: null
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
...defaultOptions,
|
|
94
|
+
...userOptions,
|
|
95
|
+
progressive: {
|
|
96
|
+
...defaultOptions.progressive,
|
|
97
|
+
...userOptions.progressive
|
|
98
|
+
},
|
|
99
|
+
lazy: {
|
|
100
|
+
...defaultOptions.lazy,
|
|
101
|
+
...userOptions.lazy
|
|
102
|
+
},
|
|
103
|
+
supported_domains: userOptions.supported_domains ?? defaultOptions.supported_domains,
|
|
104
|
+
ignore_formats: userOptions.ignore_formats ?? defaultOptions.ignore_formats,
|
|
105
|
+
cache_path: userOptions.cache_path ?? defaultOptions.cache_path
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/index.ts
|
|
111
|
+
/**
|
|
112
|
+
* Determine whether a remote URL matches the supported domain list.
|
|
113
|
+
* Supports wildcards (*) for pattern matching.
|
|
114
|
+
*/
|
|
115
|
+
function isSupportedDomain(url, patterns) {
|
|
116
|
+
if (patterns.length === 0) return true;
|
|
117
|
+
try {
|
|
118
|
+
const hostname = new URL(url).hostname;
|
|
119
|
+
return patterns.some((pattern) => {
|
|
120
|
+
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*");
|
|
121
|
+
return new RegExp(`^${regexPattern}$`, "i").test(hostname);
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Check if file format should be ignored based on URL extension.
|
|
129
|
+
*/
|
|
130
|
+
function shouldIgnoreFormat(url, formats) {
|
|
131
|
+
const pathname = new URL(url).pathname.toLowerCase();
|
|
132
|
+
return formats.some((format) => pathname.endsWith(`.${format.toLowerCase()}`));
|
|
133
|
+
}
|
|
134
|
+
function image(md, userOptions) {
|
|
135
|
+
const options = resolveOptions(userOptions);
|
|
136
|
+
if (!options.progressive.enable) return;
|
|
137
|
+
const cachePath = options.cache_path;
|
|
138
|
+
const cache = cachePath ? new ImageCache(cachePath) : null;
|
|
139
|
+
let cacheLoaded = false;
|
|
140
|
+
let imageCount = 0;
|
|
141
|
+
process.once("beforeExit", () => {
|
|
142
|
+
if (cache) cache.save();
|
|
143
|
+
});
|
|
144
|
+
const imageRule = md.renderer.rules.image;
|
|
145
|
+
if (!imageRule) return;
|
|
146
|
+
md.renderer.rules.image = async (tokens, idx, info, env, self) => {
|
|
147
|
+
const token = tokens[idx];
|
|
148
|
+
const src = token.attrGet("src");
|
|
149
|
+
if (!src) return imageRule(tokens, idx, info, env, self);
|
|
150
|
+
if (!src.startsWith("http")) return imageRule(tokens, idx, info, env, self);
|
|
151
|
+
if (!isSupportedDomain(src, options.supported_domains)) return imageRule(tokens, idx, info, env, self);
|
|
152
|
+
if (shouldIgnoreFormat(src, options.ignore_formats)) return imageRule(tokens, idx, info, env, self);
|
|
153
|
+
if (!cacheLoaded && cache) {
|
|
154
|
+
await cache.load();
|
|
155
|
+
cacheLoaded = true;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
if (cache) {
|
|
159
|
+
const cached = cache.get(src);
|
|
160
|
+
if (cached) {
|
|
161
|
+
imageCount++;
|
|
162
|
+
return buildImageHTML(token.content, src, options, imageCount, cached.width, cached.height, cached.placeholder);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const response = await fetch(src);
|
|
166
|
+
const image$1 = sharp(Buffer.from(await response.arrayBuffer()));
|
|
167
|
+
const { width, height } = await image$1.metadata();
|
|
168
|
+
const { data: thumbnailBuffer, info: thumbnailInfo } = await image$1.resize(100, 100, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
169
|
+
const placeholderUrl = rgbaToDataURL(thumbnailInfo.width, thumbnailInfo.height, new Uint8Array(thumbnailBuffer));
|
|
170
|
+
if (cache) cache.set(src, {
|
|
171
|
+
width,
|
|
172
|
+
height,
|
|
173
|
+
placeholder: placeholderUrl
|
|
174
|
+
});
|
|
175
|
+
imageCount++;
|
|
176
|
+
return buildImageHTML(token.content, src, options, imageCount, width, height, placeholderUrl);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.error(`[markdown-exit-image] Error processing image ${src}:`, e);
|
|
179
|
+
return imageRule(tokens, idx, info, env, self);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function generateSrcset(src, width, srcsetWidths) {
|
|
184
|
+
const validWidths = srcsetWidths.filter((w) => w < width).concat(width);
|
|
185
|
+
return Array.from(new Set(validWidths)).sort((a, b) => a - b).map((w) => {
|
|
186
|
+
return `${w === width ? src : src.includes("?") ? `${src}&w=${w}` : `${src}?w=${w}`} ${w}w`;
|
|
187
|
+
}).join(", ");
|
|
188
|
+
}
|
|
189
|
+
function buildImageHTML(alt, src, options, imageIndex, width, height, dataURL) {
|
|
190
|
+
const srcset = generateSrcset(src, width, options.progressive.srcset_widths);
|
|
191
|
+
const shouldLazyLoad = options.lazy.enable && imageIndex > options.lazy.skip_first;
|
|
192
|
+
/**
|
|
193
|
+
* 4. 返回包装后的 HTML
|
|
194
|
+
* - 外层 div 控制最大宽度并占用占位空间
|
|
195
|
+
* - aspect-ratio 确保容器高度在图片加载前就已确定
|
|
196
|
+
* - background-image 放置低分辨率模糊预览图 (dataURL)
|
|
197
|
+
*/
|
|
198
|
+
return `
|
|
199
|
+
<div class="img-container" style="
|
|
200
|
+
position: relative;
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
aspect-ratio: ${width} / ${height};
|
|
203
|
+
max-width: ${width}px;
|
|
204
|
+
width: 100%;
|
|
205
|
+
background-image: url('${dataURL}');
|
|
206
|
+
background-size: cover;
|
|
207
|
+
background-repeat: no-repeat;
|
|
208
|
+
">
|
|
209
|
+
<img ${[
|
|
210
|
+
`src="${src}"`,
|
|
211
|
+
`alt="${alt}"`,
|
|
212
|
+
`width="${width}"`,
|
|
213
|
+
`height="${height}"`,
|
|
214
|
+
`srcset="${srcset}"`,
|
|
215
|
+
`sizes="${options.progressive.sizes || `(max-width: ${width}px) 100vw, ${width}px`}"`,
|
|
216
|
+
shouldLazyLoad ? "loading=\"lazy\" decoding=\"async\"" : "fetchpriority=\"high\"",
|
|
217
|
+
`style="width: 100%; height: auto; display: block; transition: opacity 0.4s; opacity: 0;"`,
|
|
218
|
+
`onload="this.style.opacity=1; this.parentElement.style.backgroundImage='none';"`
|
|
219
|
+
].filter(Boolean).join(" ")}>
|
|
220
|
+
</div>
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//#endregion
|
|
225
|
+
export { image };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type CachePathOption = string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Options controlling progressive image loading.
|
|
5
|
+
*/
|
|
6
|
+
interface ProgressiveOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Enable or disable progressive image loading.
|
|
9
|
+
* @default true
|
|
10
|
+
*/
|
|
11
|
+
enable?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* An array of widths to generate for the srcset attribute.
|
|
14
|
+
* @default [400, 600, 800, 1200, 2000, 3000]
|
|
15
|
+
*/
|
|
16
|
+
srcset_widths?: number[];
|
|
17
|
+
/**
|
|
18
|
+
* Optional value for the sizes attribute.
|
|
19
|
+
* @example "(max-width: 800px) 100vw, 800px"
|
|
20
|
+
*/
|
|
21
|
+
sizes?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options for controlling lazy loading behaviour.
|
|
25
|
+
*/
|
|
26
|
+
interface LazyOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Enable or disable lazy loading.
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
enable?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Number of images to skip before applying lazy loading.
|
|
34
|
+
* @default 2
|
|
35
|
+
*/
|
|
36
|
+
skip_first?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Configuration options consumed by the plugin.
|
|
40
|
+
*/
|
|
41
|
+
interface Options {
|
|
42
|
+
/**
|
|
43
|
+
* Options for progressive image generation.
|
|
44
|
+
*/
|
|
45
|
+
progressive?: ProgressiveOptions;
|
|
46
|
+
/**
|
|
47
|
+
* Domains that are eligible for processing. Supports wildcards.
|
|
48
|
+
* When empty, all remote domains are processed.
|
|
49
|
+
* @default []
|
|
50
|
+
* @example ["example.com", "*.cdn.example.com"]
|
|
51
|
+
*/
|
|
52
|
+
supported_domains?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Array of file format extensions to ignore (case-insensitive).
|
|
55
|
+
* @default ["svg"]
|
|
56
|
+
* @example ["svg", "gif"]
|
|
57
|
+
*/
|
|
58
|
+
ignore_formats?: string[];
|
|
59
|
+
/**
|
|
60
|
+
* Options for lazy loading.
|
|
61
|
+
*/
|
|
62
|
+
lazy?: LazyOptions;
|
|
63
|
+
/**
|
|
64
|
+
* Path to the cache file. When unset, caching is disabled.
|
|
65
|
+
* @default null
|
|
66
|
+
* @example ".cache/thumbhash-cache.json"
|
|
67
|
+
*/
|
|
68
|
+
cache_path?: CachePathOption;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fully resolved options with defaults applied.
|
|
72
|
+
*/
|
|
73
|
+
interface ResolvedOptions extends Options {
|
|
74
|
+
progressive: ProgressiveOptions & {
|
|
75
|
+
enable: boolean;
|
|
76
|
+
srcset_widths: number[];
|
|
77
|
+
};
|
|
78
|
+
lazy: LazyOptions & {
|
|
79
|
+
enable: boolean;
|
|
80
|
+
skip_first: number;
|
|
81
|
+
};
|
|
82
|
+
supported_domains: string[];
|
|
83
|
+
ignore_formats: string[];
|
|
84
|
+
cache_path: CachePathOption;
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
export { ResolvedOptions as a, ProgressiveOptions as i, LazyOptions as n, Options as r, CachePathOption as t };
|
package/dist/types.d.mts
ADDED
package/dist/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "markdown-exit-s3-image",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"author": "gnix_aij",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"import": "./dist/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./types": {
|
|
13
|
+
"types": "./dist/types.d.mts",
|
|
14
|
+
"import": "./dist/types.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"main": "dist/index.mjs",
|
|
19
|
+
"types": "dist/index.d.mts",
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"markdown-exit": "1.0.0-beta.6"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"sharp": "^0.34.5",
|
|
34
|
+
"thumbhash": "^0.1.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@tsconfig/node20": "^20.1.8",
|
|
38
|
+
"@types/node": "^24.10.1",
|
|
39
|
+
"tsdown": "^0.16.6"
|
|
40
|
+
}
|
|
41
|
+
}
|