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.
@@ -0,0 +1,7 @@
1
+ import { r as Options } from "./types-CsakQe1p.mjs";
2
+ import { MarkdownExit } from "markdown-exit";
3
+
4
+ //#region src/index.d.ts
5
+ declare function image(md: MarkdownExit, userOptions: Options): void;
6
+ //#endregion
7
+ export { image };
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 };
@@ -0,0 +1,2 @@
1
+ import { a as ResolvedOptions, i as ProgressiveOptions, n as LazyOptions, r as Options, t as CachePathOption } from "./types-CsakQe1p.mjs";
2
+ export { CachePathOption, LazyOptions, Options, ProgressiveOptions, ResolvedOptions };
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
+ }