markdown-exit-s3-image 2.0.0 → 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,7 +1,59 @@
1
- import { promises } from "node:fs";
2
- import { thumbHashToDataURL } from "thumbhash";
3
1
  import sharp from "sharp";
2
+ import { thumbHashToDataURL } from "thumbhash";
3
+ import { promises } from "node:fs";
4
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
+ }
55
+
56
+ //#endregion
5
57
  //#region src/cache.ts
6
58
  var ImageCache = class {
7
59
  cache = {};
@@ -109,59 +161,26 @@ function resolveOptions(userOptions = {}) {
109
161
  }
110
162
 
111
163
  //#endregion
112
- //#region src/bitiful.ts
113
- /**
114
- * Bitiful CDN integration utilities
115
- */
116
- /**
117
- * Check if URL belongs to Bitiful CDN.
118
- */
119
- function isBitifulDomain(url, bitifulDomains) {
120
- try {
121
- const hostname = new URL(url).hostname;
122
- return bitifulDomains.some((domain) => hostname.includes(domain));
123
- } catch {
124
- return false;
125
- }
126
- }
127
- /**
128
- * Fetch dimension info from Bitiful API.
129
- */
130
- async function getBitifulDimension(imageUrl) {
131
- try {
132
- const baseUrl = imageUrl.split("?")[0];
133
- const response = await fetch(`${baseUrl}?fmt=info`);
134
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
135
- const { ImageWidth: width, ImageHeight: height } = await response.json();
136
- if (typeof width === "number" && typeof height === "number") return {
137
- width,
138
- height
139
- };
140
- return null;
141
- } catch (error) {
142
- console.warn(`[Bitiful] Dimension error for ${imageUrl}:`, error instanceof Error ? error.message : String(error));
143
- return null;
144
- }
145
- }
164
+ //#region src/index.ts
146
165
  /**
147
- * 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
148
174
  */
149
- async function getBitifulThumbhash(imageUrl) {
150
- try {
151
- const baseUrl = imageUrl.split("?")[0];
152
- const response = await fetch(`${baseUrl}?fmt=thumbhash`);
153
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
154
- const base64String = await response.text();
155
- const base64Data = thumbHashToDataURL(new Uint8Array(Buffer.from(base64String.trim(), "base64"))).replace(/^data:image\/\w+;base64,/, "");
156
- return `data:image/webp;base64,${(await sharp(Buffer.from(base64Data, "base64")).webp({ quality: 80 }).toBuffer()).toString("base64")}`;
157
- } catch (error) {
158
- console.warn(`[Bitiful] Thumbhash error for ${imageUrl}:`, error instanceof Error ? error.message : String(error));
159
- return null;
160
- }
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
+ };
161
183
  }
162
-
163
- //#endregion
164
- //#region src/index.ts
165
184
  /**
166
185
  * Check if file format should be ignored based on URL extension.
167
186
  */
@@ -171,24 +190,29 @@ function shouldIgnoreFormat(url, formats) {
171
190
  }
172
191
  function image(md, userOptions) {
173
192
  const options = resolveOptions(userOptions);
174
- if (!options.progressive.enable) return;
175
193
  const cachePath = options.cache_path;
176
- const cache = cachePath ? new ImageCache(cachePath) : null;
177
- if (cache) cache.load().catch((err) => {
178
- console.error("[ImageCache] Failed to load cache:", err);
179
- });
180
- process.once("beforeExit", () => {
181
- if (cache) cache.save();
182
- });
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
+ }
183
203
  const imageRule = md.renderer.rules.image;
184
204
  if (!imageRule) return;
185
205
  md.renderer.rules.image = async (tokens, idx, info, env, self) => {
186
206
  const token = tokens[idx];
187
207
  const src = token.attrGet("src");
188
- 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
+ }
189
213
  if (cache) {
190
214
  const cached = cache.get(src);
191
- 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);
192
216
  }
193
217
  let width;
194
218
  let height;
@@ -206,25 +230,39 @@ function image(md, userOptions) {
206
230
  height,
207
231
  dataURL: placeholderUrl
208
232
  });
209
- return buildImageHTML(token.content, src, options, width, height, placeholderUrl);
233
+ return buildImageHTML(parsedAlt, src, options, width, height, placeholderUrl);
210
234
  };
211
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
+ }
212
246
  function generateSrcset(src, width, srcsetWidths) {
213
247
  const validWidths = srcsetWidths.filter((w) => w < width).concat(width);
214
248
  return Array.from(new Set(validWidths)).sort((a, b) => a - b).map((w) => {
215
249
  return `${w === width ? src : src.includes("?") ? `${src}&w=${w}` : `${src}?w=${w}`} ${w}w`;
216
250
  }).join(", ");
217
251
  }
218
- function buildImageHTML(alt, src, options, width, height, dataURL) {
219
- 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);
220
256
  const mainImgAttrs = [
221
257
  `src="${src}"`,
222
- `alt="${alt}"`,
258
+ `alt="${parsedAlt.alt}"`,
223
259
  `srcset="${srcset}"`,
224
260
  `loading="lazy" decoding="async"`,
225
261
  `style="width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.6s ease-in-out;"`,
226
262
  `onload="this.style.opacity=1; setTimeout(() => { this.parentElement.style.backgroundImage='none'; }, 600);"`
227
263
  ].filter(Boolean).join(" ");
264
+ const aspectWidth = displayWidth;
265
+ const aspectHeight = displayHeight || Math.round(originalHeight / originalWidth * displayWidth);
228
266
  /**
229
267
  * 3. 返回包装后的 HTML
230
268
  * - 只有当有 dataURL 时才包装 div
@@ -234,11 +272,11 @@ function buildImageHTML(alt, src, options, width, height, dataURL) {
234
272
  position: relative;
235
273
  overflow: hidden;
236
274
  width: 100%;
237
- max-width: ${width}px;
275
+ max-width: ${displayWidth}px;
238
276
  background-image: url('${dataURL}');
239
277
  background-size: cover;
240
278
  background-repeat: no-repeat;
241
- aspect-ratio: ${width} / ${height};
279
+ aspect-ratio: ${aspectWidth} / ${aspectHeight};
242
280
  ">
243
281
  <img ${mainImgAttrs}>
244
282
  </div>`;
@@ -246,4 +284,4 @@ function buildImageHTML(alt, src, options, width, height, dataURL) {
246
284
  }
247
285
 
248
286
  //#endregion
249
- 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": "2.0.0",
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,12 +34,12 @@
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": {
@@ -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 };