markdown-exit-s3-image 1.0.2 → 3.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 ADDED
@@ -0,0 +1,60 @@
1
+ # markdown-exit-s3-image
2
+
3
+ A [markdown-exit](https://github.com/Efterklang/markdown-exit) plugin for S3 images with Bitiful CDN integration.
4
+
5
+ ## Features
6
+
7
+ - **Progressive image loading** - Blurhash placeholders while images load
8
+ - **Obsidian-style sizing** - `![alt|width]` or `![alt|widthxheight]` syntax
9
+ - **Automatic srcset** - Responsive images with configurable widths
10
+ - **Smart caching** - JSON-based metadata caching
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install markdown-exit-s3-image
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```typescript
21
+ import { MarkdownExit } from "markdown-exit";
22
+ import { image } from "markdown-exit-s3-image";
23
+
24
+ const md = new MarkdownExit({ html: true });
25
+ md.use(image, {
26
+ bitiful_domains: ["demo.bitiful.com"],
27
+ progressive: { enable: true },
28
+ });
29
+
30
+ const html = await md.renderAsync(
31
+ "![girl](https://demo.bitiful.com/girl.jpeg)",
32
+ );
33
+ ```
34
+
35
+ ## Options
36
+
37
+ ```typescript
38
+ interface Options {
39
+ bitiful_domains: string[]; // Bitiful CDN domains
40
+ ignore_formats?: string[]; // Formats to skip (e.g., ["svg"])
41
+ progressive: {
42
+ enable: boolean; // Enable progressive loading
43
+ srcset_widths?: number[]; // Widths for srcset generation
44
+ };
45
+ cache_path?: string; // Path to cache file
46
+ }
47
+ ```
48
+
49
+ ## Obsidian Sizing
50
+
51
+ ```markdown
52
+ ![alt|300] <!-- width only -->
53
+ ![alt|640x480] <!-- width x height -->
54
+ ```
55
+
56
+ ## License
57
+
58
+ Credit: [Barbapapazes/markdown-exit-image: Erase images CLS automatically with this Markdown Exit plugin.](https://github.com/Barbapapazes/markdown-exit-image)
59
+
60
+ MIT
package/dist/index.d.mts CHANGED
@@ -1,7 +1,26 @@
1
- import { n as Options } from "./types-DK7PR0JE.mjs";
1
+ import { Options } from "./types.mjs";
2
2
  import { MarkdownExit } from "markdown-exit";
3
3
 
4
4
  //#region src/index.d.ts
5
+ /**
6
+ * Parsed result from Obsidian-style image alt text.
7
+ */
8
+ interface ParsedImageAlt {
9
+ alt: string;
10
+ width?: number;
11
+ height?: number;
12
+ }
13
+ /**
14
+ * Parse Obsidian-style image dimensions from alt text.
15
+ * Supports formats: `![alt|width]`, `![alt|widthxheight]`
16
+ * Examples:
17
+ * - `![image|300]` -> width: 300
18
+ * - `![image|300x200]` -> width: 300, height: 200
19
+ *
20
+ * @param content The image alt text content
21
+ * @returns Parsed result with alt text and optional dimensions
22
+ */
23
+ declare function parseObsidianImageAlt(content: string): ParsedImageAlt;
5
24
  declare function image(md: MarkdownExit, userOptions: Options): void;
6
25
  //#endregion
7
- export { image };
26
+ export { image, parseObsidianImageAlt };
package/dist/index.mjs CHANGED
@@ -1,6 +1,59 @@
1
- import { promises } from "node:fs";
1
+ import sharp from "sharp";
2
2
  import { thumbHashToDataURL } from "thumbhash";
3
+ import { promises } from "node:fs";
4
+
5
+ //#region src/bitiful.ts
6
+ /**
7
+ * Bitiful CDN integration utilities
8
+ */
9
+ /**
10
+ * Check if URL belongs to Bitiful CDN.
11
+ */
12
+ function isBitifulDomain(url, bitifulDomains) {
13
+ try {
14
+ const hostname = new URL(url).hostname;
15
+ return bitifulDomains.some((domain) => hostname.includes(domain));
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * Fetch dimension info from Bitiful API.
22
+ */
23
+ async function getBitifulDimension(imageUrl) {
24
+ try {
25
+ const baseUrl = imageUrl.split("?")[0];
26
+ const response = await fetch(`${baseUrl}?fmt=info`);
27
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
28
+ const { ImageWidth: width, ImageHeight: height } = await response.json();
29
+ if (typeof width === "number" && typeof height === "number") return {
30
+ width,
31
+ height
32
+ };
33
+ return null;
34
+ } catch (error) {
35
+ console.warn(`[Bitiful] Dimension error for ${imageUrl}:`, error instanceof Error ? error.message : String(error));
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * Fetch thumbhash data URL from Bitiful API.
41
+ */
42
+ async function getBitifulThumbhash(imageUrl) {
43
+ try {
44
+ const baseUrl = imageUrl.split("?")[0];
45
+ const response = await fetch(`${baseUrl}?fmt=thumbhash`);
46
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
47
+ const base64String = await response.text();
48
+ const base64Data = thumbHashToDataURL(new Uint8Array(Buffer.from(base64String.trim(), "base64"))).replace(/^data:image\/\w+;base64,/, "");
49
+ return `data:image/webp;base64,${(await sharp(Buffer.from(base64Data, "base64")).webp({ quality: 80 }).toBuffer()).toString("base64")}`;
50
+ } catch (error) {
51
+ console.warn(`[Bitiful] Thumbhash error for ${imageUrl}:`, error instanceof Error ? error.message : String(error));
52
+ return null;
53
+ }
54
+ }
3
55
 
56
+ //#endregion
4
57
  //#region src/cache.ts
5
58
  var ImageCache = class {
6
59
  cache = {};
@@ -108,58 +161,26 @@ function resolveOptions(userOptions = {}) {
108
161
  }
109
162
 
110
163
  //#endregion
111
- //#region src/bitiful.ts
112
- /**
113
- * Bitiful CDN integration utilities
114
- */
115
- /**
116
- * Check if URL belongs to Bitiful CDN.
117
- */
118
- function isBitifulDomain(url, bitifulDomains) {
119
- try {
120
- const hostname = new URL(url).hostname;
121
- return bitifulDomains.some((domain) => hostname.includes(domain));
122
- } catch {
123
- return false;
124
- }
125
- }
126
- /**
127
- * Fetch dimension info from Bitiful API.
128
- */
129
- async function getBitifulDimension(imageUrl) {
130
- try {
131
- const baseUrl = imageUrl.split("?")[0];
132
- const response = await fetch(`${baseUrl}?fmt=info`);
133
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
134
- const { ImageWidth: width, ImageHeight: height } = await response.json();
135
- if (typeof width === "number" && typeof height === "number") return {
136
- width,
137
- height
138
- };
139
- return null;
140
- } catch (error) {
141
- console.warn(`[Bitiful] Dimension error for ${imageUrl}:`, error instanceof Error ? error.message : String(error));
142
- return null;
143
- }
144
- }
164
+ //#region src/index.ts
145
165
  /**
146
- * Fetch thumbhash data URL from Bitiful API.
166
+ * Parse Obsidian-style image dimensions from alt text.
167
+ * Supports formats: `![alt|width]`, `![alt|widthxheight]`
168
+ * Examples:
169
+ * - `![image|300]` -> width: 300
170
+ * - `![image|300x200]` -> width: 300, height: 200
171
+ *
172
+ * @param content The image alt text content
173
+ * @returns Parsed result with alt text and optional dimensions
147
174
  */
148
- async function getBitifulThumbhash(imageUrl) {
149
- try {
150
- const baseUrl = imageUrl.split("?")[0];
151
- const response = await fetch(`${baseUrl}?fmt=thumbhash`);
152
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
153
- const base64String = await response.text();
154
- return thumbHashToDataURL(new Uint8Array(Buffer.from(base64String.trim(), "base64")));
155
- } catch (error) {
156
- console.warn(`[Bitiful] Thumbhash error for ${imageUrl}:`, error instanceof Error ? error.message : String(error));
157
- return null;
158
- }
175
+ function parseObsidianImageAlt(content) {
176
+ const match = content.trim().match(/^(.*?)\|(\d+)(?:x(\d+))?$/);
177
+ if (!match) return { alt: content };
178
+ return {
179
+ alt: match[1].trim(),
180
+ width: parseInt(match[2], 10),
181
+ height: match[3] ? parseInt(match[3], 10) : void 0
182
+ };
159
183
  }
160
-
161
- //#endregion
162
- //#region src/index.ts
163
184
  /**
164
185
  * Check if file format should be ignored based on URL extension.
165
186
  */
@@ -169,24 +190,29 @@ function shouldIgnoreFormat(url, formats) {
169
190
  }
170
191
  function image(md, userOptions) {
171
192
  const options = resolveOptions(userOptions);
172
- if (!options.progressive.enable) return;
173
193
  const cachePath = options.cache_path;
174
- const cache = cachePath ? new ImageCache(cachePath) : null;
175
- if (cache) cache.load().catch((err) => {
176
- console.error("[ImageCache] Failed to load cache:", err);
177
- });
178
- process.once("beforeExit", () => {
179
- if (cache) cache.save();
180
- });
194
+ const cache = cachePath && options.progressive.enable ? new ImageCache(cachePath) : null;
195
+ if (cache) {
196
+ cache.load().catch((err) => {
197
+ console.error("[ImageCache] Failed to load cache:", err);
198
+ });
199
+ process.once("beforeExit", () => {
200
+ cache.save();
201
+ });
202
+ }
181
203
  const imageRule = md.renderer.rules.image;
182
204
  if (!imageRule) return;
183
205
  md.renderer.rules.image = async (tokens, idx, info, env, self) => {
184
206
  const token = tokens[idx];
185
207
  const src = token.attrGet("src");
186
- if (!src || !src.startsWith("http") || !isBitifulDomain(src, options.bitiful_domains) || shouldIgnoreFormat(src, options.ignore_formats) || process.env.NODE_ENV == "development") return imageRule(tokens, idx, info, env, self);
208
+ const parsedAlt = parseObsidianImageAlt(token.content);
209
+ if (!(options.progressive.enable && src && src.startsWith("http") && isBitifulDomain(src, options.bitiful_domains) && !shouldIgnoreFormat(src, options.ignore_formats) && process.env.NODE_ENV !== "development")) {
210
+ if (parsedAlt.width) return applyDimensionToHTML(await imageRule(tokens, idx, info, env, self), parsedAlt.width, parsedAlt.height);
211
+ return imageRule(tokens, idx, info, env, self);
212
+ }
187
213
  if (cache) {
188
214
  const cached = cache.get(src);
189
- if (cached) return buildImageHTML(token.content, src, options, cached.width, cached.height, cached.dataURL);
215
+ if (cached) return buildImageHTML(parsedAlt, src, options, cached.width, cached.height, cached.dataURL);
190
216
  }
191
217
  let width;
192
218
  let height;
@@ -204,25 +230,39 @@ function image(md, userOptions) {
204
230
  height,
205
231
  dataURL: placeholderUrl
206
232
  });
207
- return buildImageHTML(token.content, src, options, width, height, placeholderUrl);
233
+ return buildImageHTML(parsedAlt, src, options, width, height, placeholderUrl);
208
234
  };
209
235
  }
236
+ /**
237
+ * Apply user-specified dimensions to existing HTML img tag.
238
+ */
239
+ function applyDimensionToHTML(html, width, height) {
240
+ if (!width) return html;
241
+ let style = `max-width: ${width}px; width: ${width}px;`;
242
+ if (height) style += ` height: ${height}px;`;
243
+ if (html.includes("<img")) return html.replace(/<img\s/, `<img style="${style}" `);
244
+ return html;
245
+ }
210
246
  function generateSrcset(src, width, srcsetWidths) {
211
247
  const validWidths = srcsetWidths.filter((w) => w < width).concat(width);
212
248
  return Array.from(new Set(validWidths)).sort((a, b) => a - b).map((w) => {
213
249
  return `${w === width ? src : src.includes("?") ? `${src}&w=${w}` : `${src}?w=${w}`} ${w}w`;
214
250
  }).join(", ");
215
251
  }
216
- function buildImageHTML(alt, src, options, width, height, dataURL) {
217
- const srcset = generateSrcset(src, width, options.progressive.srcset_widths);
252
+ function buildImageHTML(parsedAlt, src, options, originalWidth, originalHeight, dataURL) {
253
+ const displayWidth = parsedAlt.width || originalWidth;
254
+ const displayHeight = parsedAlt.height;
255
+ const srcset = generateSrcset(src, originalWidth, options.progressive.srcset_widths);
218
256
  const mainImgAttrs = [
219
257
  `src="${src}"`,
220
- `alt="${alt}"`,
258
+ `alt="${parsedAlt.alt}"`,
221
259
  `srcset="${srcset}"`,
222
260
  `loading="lazy" decoding="async"`,
223
261
  `style="width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.6s ease-in-out;"`,
224
262
  `onload="this.style.opacity=1; setTimeout(() => { this.parentElement.style.backgroundImage='none'; }, 600);"`
225
263
  ].filter(Boolean).join(" ");
264
+ const aspectWidth = displayWidth;
265
+ const aspectHeight = displayHeight || Math.round(originalHeight / originalWidth * displayWidth);
226
266
  /**
227
267
  * 3. 返回包装后的 HTML
228
268
  * - 只有当有 dataURL 时才包装 div
@@ -232,11 +272,11 @@ function buildImageHTML(alt, src, options, width, height, dataURL) {
232
272
  position: relative;
233
273
  overflow: hidden;
234
274
  width: 100%;
235
- max-width: ${width}px;
275
+ max-width: ${displayWidth}px;
236
276
  background-image: url('${dataURL}');
237
277
  background-size: cover;
238
278
  background-repeat: no-repeat;
239
- aspect-ratio: ${width} / ${height};
279
+ aspect-ratio: ${aspectWidth} / ${aspectHeight};
240
280
  ">
241
281
  <img ${mainImgAttrs}>
242
282
  </div>`;
@@ -244,4 +284,4 @@ function buildImageHTML(alt, src, options, width, height, dataURL) {
244
284
  }
245
285
 
246
286
  //#endregion
247
- export { image };
287
+ export { image, parseObsidianImageAlt };
package/dist/types.d.mts CHANGED
@@ -1,2 +1,62 @@
1
- import { i as ResolvedOptions, n as Options, r as ProgressiveOptions, t as CachePathOption } from "./types-DK7PR0JE.mjs";
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
+ * Configuration options consumed by the plugin.
25
+ */
26
+ interface Options {
27
+ /**
28
+ * Options for progressive image generation.
29
+ */
30
+ progressive?: ProgressiveOptions;
31
+ /**
32
+ * Domains that support Bitiful API for thumbhash and info.
33
+ * @default ["assets.vluv.space", "s3.bitiful.net", "bitiful.com"]
34
+ */
35
+ bitiful_domains?: string[];
36
+ /**
37
+ * Array of file format extensions to ignore (case-insensitive).
38
+ * @default ["svg"]
39
+ * @example ["svg", "gif"]
40
+ */
41
+ ignore_formats?: string[];
42
+ /**
43
+ * Path to the cache file. When unset, caching is disabled.
44
+ * @default null
45
+ * @example ".cache/thumbhash-cache.json"
46
+ */
47
+ cache_path?: CachePathOption;
48
+ }
49
+ /**
50
+ * Fully resolved options with defaults applied.
51
+ */
52
+ interface ResolvedOptions extends Options {
53
+ progressive: ProgressiveOptions & {
54
+ enable: boolean;
55
+ srcset_widths: number[];
56
+ };
57
+ bitiful_domains: string[];
58
+ ignore_formats: string[];
59
+ cache_path: CachePathOption;
60
+ }
61
+ //#endregion
2
62
  export { CachePathOption, Options, ProgressiveOptions, ResolvedOptions };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "markdown-exit-s3-image",
3
3
  "type": "module",
4
4
  "author": "GnixAij",
5
- "version": "1.0.2",
5
+ "version": "3.0.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -22,7 +22,8 @@
22
22
  "main": "dist/index.mjs",
23
23
  "types": "dist/index.d.mts",
24
24
  "files": [
25
- "dist"
25
+ "dist",
26
+ "README.md"
26
27
  ],
27
28
  "engines": {
28
29
  "node": ">=20"
@@ -33,15 +34,16 @@
33
34
  "test:clean": "rm -f test/cache.json test/output.html"
34
35
  },
35
36
  "peerDependencies": {
36
- "markdown-exit": "1.0.0-beta.6"
37
+ "markdown-exit": "1.0.0-beta.7"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@tsconfig/node20": "^20.1.8",
40
- "@types/node": "^24.10.1",
41
- "tsdown": "^0.16.6",
41
+ "@types/node": "^25.1.0",
42
+ "tsdown": "^0.20.1",
42
43
  "typescript": "^5.9.3"
43
44
  },
44
45
  "dependencies": {
46
+ "sharp": "^0.34.5",
45
47
  "thumbhash": "^0.1.1"
46
48
  }
47
49
  }
@@ -1,62 +0,0 @@
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
- * Configuration options consumed by the plugin.
25
- */
26
- interface Options {
27
- /**
28
- * Options for progressive image generation.
29
- */
30
- progressive?: ProgressiveOptions;
31
- /**
32
- * Domains that support Bitiful API for thumbhash and info.
33
- * @default ["assets.vluv.space", "s3.bitiful.net", "bitiful.com"]
34
- */
35
- bitiful_domains?: string[];
36
- /**
37
- * Array of file format extensions to ignore (case-insensitive).
38
- * @default ["svg"]
39
- * @example ["svg", "gif"]
40
- */
41
- ignore_formats?: string[];
42
- /**
43
- * Path to the cache file. When unset, caching is disabled.
44
- * @default null
45
- * @example ".cache/thumbhash-cache.json"
46
- */
47
- cache_path?: CachePathOption;
48
- }
49
- /**
50
- * Fully resolved options with defaults applied.
51
- */
52
- interface ResolvedOptions extends Options {
53
- progressive: ProgressiveOptions & {
54
- enable: boolean;
55
- srcset_widths: number[];
56
- };
57
- bitiful_domains: string[];
58
- ignore_formats: string[];
59
- cache_path: CachePathOption;
60
- }
61
- //#endregion
62
- export { ResolvedOptions as i, Options as n, ProgressiveOptions as r, CachePathOption as t };