rehype-og-card 3.1.16 → 4.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 CHANGED
@@ -2,20 +2,20 @@
2
2
 
3
3
  Rehype plugin to convert text links to link cards inspired by [remark-link-card](https://github.com/gladevise/remark-link-card). Bare links are converted to link cards, but not links in lists or non-bare links.
4
4
 
5
- | Features | remark-link-card | rehype-og-card |
6
- | :--------------------------------------------- | :---------------------------------------------------------------------------------------: | :------------: |
7
- | Plugin type | Remark | Rehype |
8
- | TypeScript support | ❌ | ✅ |
9
- | Convert bare links to link cards | ✅ | ✅ |
10
- | Shorten URLs displayed on link cards | ✅ | ✅ |
11
- | Server-side cache for images | ✅ | ✅ |
12
- | Cache images with long URLs | ❌ ([implemented](https://github.com/gladevise/remark-link-card/pull/16) but not released) | ✅ |
13
- | Build cache for Open Graph metadata and images | ❌ | ✅ |
14
- | Exclude links in lists from conversion | ❌ | ✅ |
15
- | Exclude specific domains from conversion | ❌ | ✅ |
16
- | Lazy loading images | ❌ | ✅ |
17
- | Async decoding images | ❌ | ✅ |
18
- | Customize the crawler's user agent string | ❌ | ✅ |
5
+ | Features | remark-link-card | rehype-og-card |
6
+ | :--------------------------------------------- | :----------------------------------------------------------------------------------------: | :------------: |
7
+ | Plugin type | Remark | Rehype |
8
+ | TypeScript support | ❌ | ✅ |
9
+ | Convert bare links to link cards | ✅ | ✅ |
10
+ | Shorten URLs displayed on link cards | ✅ | ✅ |
11
+ | Server-side cache for images | ✅ | ✅ |
12
+ | Cache images with long URLs | ❌ ([implemented](https://github.com/gladevise/remark-link-card/pull/16) but not released) | ✅ |
13
+ | Build cache for Open Graph metadata and images | ❌ | ✅ |
14
+ | Exclude links in lists from conversion | ❌ | ✅ |
15
+ | Exclude specific domains from conversion | ❌ | ✅ |
16
+ | Lazy loading images | ❌ | ✅ |
17
+ | Async decoding images | ❌ | ✅ |
18
+ | Customize the crawler's user agent string | ❌ | ✅ |
19
19
 
20
20
  ## Installation
21
21
 
@@ -113,7 +113,7 @@ Whether to cache the OG metadata and images for the next build. `buildCache` opt
113
113
  Path to save the build cache. For Astro projects, specify the `./node_modules/.astro` directory.
114
114
 
115
115
  - Type: `string`
116
- - Default: `"./.cache/og-card"`
116
+ - Default: `"./node_modules/.cache"`
117
117
 
118
118
  ### `buildCacheMaxAge`
119
119
 
@@ -200,3 +200,24 @@ Whether to shorten the displayed URL.
200
200
 
201
201
  - Type: `boolean`
202
202
  - Default: `true`
203
+
204
+ ### `svgHandling`
205
+
206
+ How to handle SVG OG images and favicons when `serverCache` is enabled.
207
+
208
+ - Type: `"omit" | "preserve" | "rasterize"`
209
+ - Default: `"rasterize"`
210
+
211
+ | Value | Behavior |
212
+ | ------------- | ---------------------------------------------------------------------- |
213
+ | `"omit"` | Treat SVG as a missing image. The rest of the card is still generated. |
214
+ | `"preserve"` | Save the original SVG without modification. |
215
+ | `"rasterize"` | Convert SVG to a PNG. Omit the image if conversion fails. |
216
+
217
+ If the `"rasterize"` option is selected, external resources referenced by the target SVG image will not be loaded. If `serverCache: false` is set, the image URL remains unchanged in the output, and the `svgHandling` settings are not applied.
218
+
219
+ #### Security Considerations
220
+
221
+ rehype-og-card embeds images using `<img>` tags. If `svgHandling: "preserve"` is selected, there is a risk that any scripts contained within an embedded SVG file could be executed if a user opens that file directly. It is recommended to clear the server cache after switching the `svgHandling` mode.
222
+
223
+ When `serverCache: true` is enabled, your server controls the response headers for cached images. Please ensure that the correct `Content-Type` and `X-Content-Type-Options: nosniff` headers are included when serving these images.
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { convertTextToAnchorElement, createOGCard, isAnchorElement, isTextNode } from "./util/hast.js";
2
- import { checkFileExistsSync, createDirectorySync } from "./util/file.js";
3
2
  import { downloadImage, getOGData, isValidURL } from "./util/network.js";
3
+ import { join, posix } from "node:path";
4
4
  import { restoreBuildCache, restoreOGDataBuildCache, saveBuildCacheFile, saveOGDataBuildCache } from "./util/cache.js";
5
+ import { createDirectorySync } from "./util/file.js";
6
+ import { existsSync } from "node:fs";
5
7
  import { isElement } from "hast-util-is-element";
6
- import path from "path";
8
+ // oxlint-disable-next-line import/max-dependencies
7
9
  import { visitParents } from "unist-util-visit-parents";
8
10
  // oxlint-disable-next-line no-magic-numbers
9
11
  const ONE_DAY_MS = 24 * 60 * 60 * 1000;
@@ -23,7 +25,8 @@ const DEFAULT_OPTIONS = {
23
25
  serverCache: true,
24
26
  serverCacheMaxAge: DEFAULT_CACHE_MAX_AGE_MS,
25
27
  serverCachePath: "./public",
26
- shortenURL: true
28
+ shortenURL: true,
29
+ svgHandling: "rasterize"
27
30
  };
28
31
  /**
29
32
  * Rehype plugin to create OG card from bare links.
@@ -39,9 +42,9 @@ const rehypeOGCard = (options) => {
39
42
  if (mergedOptions.buildCache && !mergedOptions.serverCache) {
40
43
  throw new Error("[rehype-og-card] `buildCache` option requires `serverCache` option to be enabled.");
41
44
  }
42
- mergedOptions.serverCachePath = path.posix.join(mergedOptions.serverCachePath, "./rehype-og-card/");
43
- mergedOptions.buildCachePath = path.posix.join(mergedOptions.buildCachePath, "./rehype-og-card/");
44
- const buildCacheExists = checkFileExistsSync(mergedOptions.buildCachePath);
45
+ mergedOptions.serverCachePath = posix.join(mergedOptions.serverCachePath, "./rehype-og-card/");
46
+ mergedOptions.buildCachePath = posix.join(mergedOptions.buildCachePath, "./rehype-og-card/");
47
+ const buildCacheExists = existsSync(mergedOptions.buildCachePath);
45
48
  if (mergedOptions.buildCache) {
46
49
  if (buildCacheExists) {
47
50
  restoreBuildCache(mergedOptions.buildCachePath, mergedOptions.serverCachePath, mergedOptions.buildCacheMaxAge);
@@ -105,22 +108,23 @@ const rehypeOGCard = (options) => {
105
108
  if (!OGData)
106
109
  return;
107
110
  if (mergedOptions.buildCache) {
108
- void saveOGDataBuildCache(anchorNode.properties.href, OGData, mergedOptions.buildCachePath);
111
+ await saveOGDataBuildCache(anchorNode.properties.href, OGData, mergedOptions.buildCachePath);
109
112
  }
110
113
  }
111
114
  if (OGData.OGImageURL && mergedOptions.serverCache) {
112
115
  const filename = await downloadImage({
113
116
  cacheMaxAge: mergedOptions.serverCacheMaxAge,
114
117
  directory: mergedOptions.serverCachePath,
118
+ svgHandling: mergedOptions.svgHandling,
115
119
  url: OGData.OGImageURL,
116
120
  userAgent: mergedOptions.crawlerUserAgent
117
121
  });
118
122
  if (filename) {
119
- OGData.OGImageURL = path.posix.join("/rehype-og-card", filename);
123
+ OGData.OGImageURL = posix.join("/rehype-og-card", filename);
120
124
  if (mergedOptions.buildCache) {
121
- const downloadedFilePath = path.join(mergedOptions.serverCachePath, filename);
122
- const buildCachePath = path.join(mergedOptions.buildCachePath, filename);
123
- void saveBuildCacheFile(downloadedFilePath, buildCachePath);
125
+ const downloadedFilePath = join(mergedOptions.serverCachePath, filename);
126
+ const buildCachePath = join(mergedOptions.buildCachePath, filename);
127
+ await saveBuildCacheFile(downloadedFilePath, buildCachePath);
124
128
  }
125
129
  }
126
130
  else {
@@ -131,15 +135,16 @@ const rehypeOGCard = (options) => {
131
135
  const filename = await downloadImage({
132
136
  cacheMaxAge: mergedOptions.serverCacheMaxAge,
133
137
  directory: mergedOptions.serverCachePath,
138
+ svgHandling: mergedOptions.svgHandling,
134
139
  url: OGData.faviconURL,
135
140
  userAgent: mergedOptions.crawlerUserAgent
136
141
  });
137
142
  if (filename) {
138
- OGData.faviconURL = path.posix.join("/rehype-og-card", filename);
143
+ OGData.faviconURL = posix.join("/rehype-og-card", filename);
139
144
  if (mergedOptions.buildCache) {
140
- const downloadedFilePath = path.join(mergedOptions.serverCachePath, filename);
141
- const buildCachePath = path.join(mergedOptions.buildCachePath, filename);
142
- void saveBuildCacheFile(downloadedFilePath, buildCachePath);
145
+ const downloadedFilePath = join(mergedOptions.serverCachePath, filename);
146
+ const buildCachePath = join(mergedOptions.buildCachePath, filename);
147
+ await saveBuildCacheFile(downloadedFilePath, buildCachePath);
143
148
  }
144
149
  }
145
150
  else {
package/dist/types.d.ts CHANGED
@@ -39,6 +39,18 @@ interface RehypeOGCardOptions {
39
39
  * @default true
40
40
  */
41
41
  serverCache?: boolean;
42
+ /**
43
+ * How to handle SVG OG images and favicons when server caching is enabled.
44
+ *
45
+ * - `"omit"`: Treat SVG as a missing image. The rest of the card is still generated.
46
+ * - `"preserve"`: Save the original SVG without modification.
47
+ * - `"rasterize"`: Convert SVG to a PNG. Omit the image if conversion fails.
48
+ *
49
+ * If the `"rasterize"` option is selected, external resources referenced by the target SVG image will not be loaded.
50
+ * If `serverCache: false` is set, the image URL remains unchanged in the output, and the `svgHandling` settings are not applied.
51
+ * @default "rasterize"
52
+ */
53
+ svgHandling?: "omit" | "preserve" | "rasterize";
42
54
  /**
43
55
  * Path to save the server cache.
44
56
  * Specify the directory whose contents will be copied to the root.
@@ -1,10 +1,10 @@
1
1
  /* oxlint-disable max-lines */
2
2
  import { parseCacheIndex } from "../types.js";
3
3
  import { checkFileExists, generateFilename } from "./file.js";
4
- import fs from "fs/promises";
5
- import fsSync from "fs";
6
- import path from "path";
7
- import { setTimeout } from "timers/promises";
4
+ import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { copyFile, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { setTimeout } from "node:timers/promises";
8
8
  import { type } from "arktype";
9
9
  /**
10
10
  * Check whether a cache entry is expired.
@@ -60,10 +60,10 @@ const acquireCacheIndexLock = async (directory) => {
60
60
  */
61
61
  const tryAcquire = async (attempt) => {
62
62
  try {
63
- const handle = await fs.open(lockPath, "wx");
63
+ const handle = await open(lockPath, "wx");
64
64
  await handle.close();
65
65
  return async () => {
66
- await fs.rm(lockPath, { force: true });
66
+ await rm(lockPath, { force: true });
67
67
  };
68
68
  }
69
69
  catch (error) {
@@ -96,10 +96,10 @@ const acquireCacheIndexLockSync = (directory) => {
96
96
  */
97
97
  const tryAcquire = (attempt) => {
98
98
  try {
99
- const fd = fsSync.openSync(lockPath, "wx");
100
- fsSync.closeSync(fd);
99
+ const fd = openSync(lockPath, "wx");
100
+ closeSync(fd);
101
101
  return () => {
102
- fsSync.rmSync(lockPath, { force: true });
102
+ rmSync(lockPath, { force: true });
103
103
  };
104
104
  }
105
105
  catch (error) {
@@ -122,7 +122,7 @@ const acquireCacheIndexLockSync = (directory) => {
122
122
  const readCacheIndexSync = (directory) => {
123
123
  const indexPath = getCacheIndexPath(directory);
124
124
  try {
125
- const raw = fsSync.readFileSync(indexPath, "utf-8");
125
+ const raw = readFileSync(indexPath, "utf-8");
126
126
  const validated = parseCacheIndex(raw);
127
127
  if (validated instanceof type.errors)
128
128
  return {};
@@ -142,8 +142,8 @@ const writeCacheIndexSync = (directory, index) => {
142
142
  const tempPath = `${indexPath}.tmp`;
143
143
  const releaseLock = acquireCacheIndexLockSync(directory);
144
144
  try {
145
- fsSync.writeFileSync(tempPath, JSON.stringify(index));
146
- fsSync.renameSync(tempPath, indexPath);
145
+ writeFileSync(tempPath, JSON.stringify(index));
146
+ renameSync(tempPath, indexPath);
147
147
  }
148
148
  finally {
149
149
  releaseLock?.();
@@ -160,7 +160,7 @@ const readCacheIndex = async (directory) => {
160
160
  if (!exists)
161
161
  return {};
162
162
  try {
163
- const raw = await fs.readFile(indexPath, "utf-8");
163
+ const raw = await readFile(indexPath, "utf-8");
164
164
  const validated = parseCacheIndex(raw);
165
165
  if (validated instanceof type.errors)
166
166
  return {};
@@ -180,8 +180,8 @@ const writeCacheIndex = async (directory, index) => {
180
180
  const tempPath = `${indexPath}.tmp`;
181
181
  const releaseLock = await acquireCacheIndexLock(directory);
182
182
  try {
183
- await fs.writeFile(tempPath, JSON.stringify(index));
184
- await fs.rename(tempPath, indexPath);
183
+ await writeFile(tempPath, JSON.stringify(index));
184
+ await rename(tempPath, indexPath);
185
185
  }
186
186
  finally {
187
187
  await releaseLock?.();
@@ -194,7 +194,7 @@ const writeCacheIndex = async (directory, index) => {
194
194
  */
195
195
  const removeCacheEntrySync = (directory, filename) => {
196
196
  const filePath = path.join(directory, filename);
197
- fsSync.rmSync(filePath, { force: true });
197
+ rmSync(filePath, { force: true });
198
198
  };
199
199
  /**
200
200
  * Remove expired cache files synchronously.
@@ -205,11 +205,11 @@ const removeCacheEntrySync = (directory, filename) => {
205
205
  const pruneExpiredCacheFilesSync = (directory, maxAgeMs) => {
206
206
  if (maxAgeMs === false)
207
207
  return;
208
- if (!fsSync.existsSync(directory))
208
+ if (!existsSync(directory))
209
209
  return;
210
210
  const index = readCacheIndexSync(directory);
211
211
  let indexUpdated = false;
212
- const entries = fsSync.readdirSync(directory, { withFileTypes: true });
212
+ const entries = readdirSync(directory, { withFileTypes: true });
213
213
  for (const entry of entries) {
214
214
  const entryPath = path.join(directory, entry.name);
215
215
  if (entry.isDirectory()) {
@@ -222,19 +222,19 @@ const pruneExpiredCacheFilesSync = (directory, maxAgeMs) => {
222
222
  continue;
223
223
  if (entry.name.endsWith(".json")) {
224
224
  try {
225
- const raw = fsSync.readFileSync(entryPath, "utf-8");
225
+ const raw = readFileSync(entryPath, "utf-8");
226
226
  const data = JSON.parse(raw);
227
227
  if (typeof data.cachedAt !== "number") {
228
- fsSync.rmSync(entryPath, { force: true });
228
+ rmSync(entryPath, { force: true });
229
229
  // oxlint-disable-next-line no-continue
230
230
  continue;
231
231
  }
232
232
  if (isCacheExpired(data.cachedAt, maxAgeMs)) {
233
- fsSync.rmSync(entryPath, { force: true });
233
+ rmSync(entryPath, { force: true });
234
234
  }
235
235
  }
236
236
  catch {
237
- fsSync.rmSync(entryPath, { force: true });
237
+ rmSync(entryPath, { force: true });
238
238
  }
239
239
  // oxlint-disable-next-line no-continue
240
240
  continue;
@@ -258,20 +258,15 @@ const pruneExpiredCacheFilesSync = (directory, maxAgeMs) => {
258
258
  */
259
259
  const saveBuildCacheFile = async (serverCachePath, buildCachePath) => {
260
260
  const serverCacheExists = await checkFileExists(serverCachePath);
261
- const buildCacheExists = await checkFileExists(buildCachePath);
262
- if (serverCacheExists && !buildCacheExists) {
263
- await fs.copyFile(serverCachePath, buildCachePath);
264
- }
265
- const buildCacheExistsAfter = await checkFileExists(buildCachePath);
266
- if (!buildCacheExistsAfter)
261
+ if (!serverCacheExists)
267
262
  return;
263
+ await copyFile(serverCachePath, buildCachePath);
268
264
  const serverDirectory = path.dirname(serverCachePath);
269
265
  const buildDirectory = path.dirname(buildCachePath);
270
266
  const filename = path.basename(buildCachePath);
271
267
  const serverIndex = readCacheIndexSync(serverDirectory);
272
- const cachedAt = typeof serverIndex[filename]?.createdAt === "number" ? serverIndex[filename].createdAt : Date.now();
273
268
  const buildIndex = readCacheIndexSync(buildDirectory);
274
- buildIndex[filename] = { createdAt: cachedAt };
269
+ buildIndex[filename] = { createdAt: serverIndex[filename]?.createdAt ?? Date.now() };
275
270
  writeCacheIndexSync(buildDirectory, buildIndex);
276
271
  };
277
272
  /**
@@ -281,13 +276,13 @@ const saveBuildCacheFile = async (serverCachePath, buildCachePath) => {
281
276
  * @param maxAgeMs Cache expiration time in milliseconds.
282
277
  */
283
278
  const restoreBuildCache = (buildCachePath, serverCachePath, maxAgeMs) => {
284
- if (!fsSync.existsSync(buildCachePath))
279
+ if (!existsSync(buildCachePath))
285
280
  return;
286
281
  pruneExpiredCacheFilesSync(buildCachePath, maxAgeMs);
287
- if (!fsSync.existsSync(serverCachePath)) {
288
- fsSync.mkdirSync(serverCachePath, { recursive: true });
282
+ if (!existsSync(serverCachePath)) {
283
+ mkdirSync(serverCachePath, { recursive: true });
289
284
  }
290
- fsSync.cpSync(buildCachePath, serverCachePath, { recursive: true });
285
+ cpSync(buildCachePath, serverCachePath, { recursive: true });
291
286
  };
292
287
  /**
293
288
  * Save OG data to build cache.
@@ -300,10 +295,10 @@ const saveOGDataBuildCache = async (url, OGData, buildCachePath) => {
300
295
  const savePath = `${path.join(buildCachePath, filename)}.json`;
301
296
  const cacheDirectoryExists = await checkFileExists(buildCachePath);
302
297
  if (!cacheDirectoryExists) {
303
- await fs.mkdir(buildCachePath, { recursive: true });
298
+ await mkdir(buildCachePath, { recursive: true });
304
299
  }
305
300
  const payload = { ...OGData, cachedAt: Date.now() };
306
- await fs.writeFile(savePath, JSON.stringify(payload));
301
+ await writeFile(savePath, JSON.stringify(payload));
307
302
  };
308
303
  /**
309
304
  * Restore OG data from build cache.
@@ -316,18 +311,18 @@ const restoreOGDataBuildCache = async (url, buildCachePath, maxAgeMs) => {
316
311
  const filename = generateFilename(url, false);
317
312
  const savePath = `${path.join(buildCachePath, filename)}.json`;
318
313
  try {
319
- const data = await fs.readFile(savePath, "utf-8");
314
+ const data = await readFile(savePath, "utf-8");
320
315
  const parsed = JSON.parse(data);
321
316
  if (typeof parsed.cachedAt !== "number") {
322
317
  if (maxAgeMs === false) {
323
318
  const { cachedAt: __, ...rest } = parsed;
324
319
  return rest;
325
320
  }
326
- await fs.rm(savePath, { force: true });
321
+ await rm(savePath, { force: true });
327
322
  return null;
328
323
  }
329
324
  if (isCacheExpired(parsed.cachedAt, maxAgeMs)) {
330
- await fs.rm(savePath, { force: true });
325
+ await rm(savePath, { force: true });
331
326
  return null;
332
327
  }
333
328
  const { cachedAt: __, ...rest } = parsed;
@@ -4,12 +4,6 @@
4
4
  * @returns `true` if the file exists, `false` otherwise.
5
5
  */
6
6
  declare const checkFileExists: (filePath: string) => Promise<boolean>;
7
- /**
8
- * Check if the file exists.
9
- * @param filePath Path to check.
10
- * @returns `true` if the file exists, `false` otherwise.
11
- */
12
- declare const checkFileExistsSync: (filePath: string) => boolean;
13
7
  /**
14
8
  * Create directory.
15
9
  * @param dir Directory path.
@@ -28,4 +22,4 @@ declare const copyDirectory: (source: string, destination: string) => void;
28
22
  * @returns Generated filename.
29
23
  */
30
24
  declare const generateFilename: (url: string, extension?: boolean) => string;
31
- export { checkFileExists, checkFileExistsSync, createDirectorySync, copyDirectory, generateFilename };
25
+ export { checkFileExists, createDirectorySync, copyDirectory, generateFilename };
package/dist/util/file.js CHANGED
@@ -1,6 +1,7 @@
1
- import { createHash } from "crypto";
2
- import fs from "fs";
3
- import path from "path";
1
+ import { cp, existsSync, mkdirSync } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
4
5
  /**
5
6
  * Check if the file exists.
6
7
  * @param filePath Path to check.
@@ -8,36 +9,19 @@ import path from "path";
8
9
  */
9
10
  const checkFileExists = async (filePath) => {
10
11
  try {
11
- await fs.promises.access(filePath);
12
+ await access(filePath);
12
13
  return true;
13
14
  }
14
15
  catch {
15
16
  return false;
16
17
  }
17
18
  };
18
- /**
19
- * Check if the file exists.
20
- * @param filePath Path to check.
21
- * @returns `true` if the file exists, `false` otherwise.
22
- */
23
- const checkFileExistsSync = (filePath) => {
24
- let result = false;
25
- fs.access(filePath, (error) => {
26
- result = !error;
27
- });
28
- return result;
29
- };
30
19
  /**
31
20
  * Create directory.
32
21
  * @param dir Directory path.
33
22
  */
34
23
  const createDirectorySync = (dir) => {
35
- fs.mkdir(dir, { recursive: true }, (error) => {
36
- if (error) {
37
- // oxlint-disable-next-line no-console
38
- console.error("[rehype-og-card] Failed to copy files: ", error);
39
- }
40
- });
24
+ mkdirSync(dir, { recursive: true });
41
25
  };
42
26
  /**
43
27
  * Copy directory from source to destination.
@@ -45,14 +29,14 @@ const createDirectorySync = (dir) => {
45
29
  * @param destination Destination directory.
46
30
  */
47
31
  const copyDirectory = (source, destination) => {
48
- const sourceExists = checkFileExistsSync(source);
32
+ const sourceExists = existsSync(source);
49
33
  if (!sourceExists)
50
34
  return;
51
- const destinationExists = checkFileExistsSync(destination);
35
+ const destinationExists = existsSync(destination);
52
36
  if (!destinationExists) {
53
37
  createDirectorySync(destination);
54
38
  }
55
- fs.cp(source, destination, { recursive: true }, (error) => {
39
+ cp(source, destination, { recursive: true }, (error) => {
56
40
  if (error) {
57
41
  // oxlint-disable-next-line no-console
58
42
  console.error("[rehype-og-card] Failed to copy files: ", error);
@@ -75,4 +59,4 @@ const generateFilename = (url, extension = true) => {
75
59
  const filename = extension ? hash + extensionString : hash;
76
60
  return filename;
77
61
  };
78
- export { checkFileExists, checkFileExistsSync, createDirectorySync, copyDirectory, generateFilename };
62
+ export { checkFileExists, createDirectorySync, copyDirectory, generateFilename };
@@ -1,4 +1,4 @@
1
- import type { OGCardData } from "../types.js";
1
+ import type { OGCardData, RehypeOGCardOptions } from "../types.js";
2
2
  /**
3
3
  * Check if the URL is valid.
4
4
  * @param url URL to check.
@@ -30,6 +30,11 @@ interface DownloadImageOptions {
30
30
  * Set to `false` to disable expiration.
31
31
  */
32
32
  cacheMaxAge: number | false;
33
+ /**
34
+ * How to handle SVG images.
35
+ * @default "rasterize"
36
+ */
37
+ svgHandling?: RehypeOGCardOptions["svgHandling"];
33
38
  }
34
39
  /**
35
40
  * Download image from given URL. If the image already exists, return the filename.
@@ -1,8 +1,9 @@
1
1
  import { checkFileExists, generateFilename } from "./file.js";
2
2
  import { isCacheExpired, readCacheIndex, writeCacheIndex } from "./cache.js";
3
3
  import fetch from "node-fetch";
4
- import fs from "fs/promises";
5
- import path from "path";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { rasterizeSVG } from "./svg.js";
6
7
  import scraper from "open-graph-scraper";
7
8
  /**
8
9
  * Check if the URL is valid.
@@ -67,6 +68,28 @@ const getOGData = async (url, userAgent) => {
67
68
  return null;
68
69
  }
69
70
  };
71
+ /**
72
+ * Restore an unexpired cached image.
73
+ * @param options Image cache options.
74
+ * @param filenames Possible cached image filenames.
75
+ * @returns Cached filename, or `null` if no valid file exists.
76
+ */
77
+ const restoreImageCache = async (options, filenames) => {
78
+ const index = await readCacheIndex(options.directory);
79
+ const cached = await Promise.all([...filenames].map(async (filename) => {
80
+ const cachedPath = path.join(options.directory, filename);
81
+ if (!(await checkFileExists(cachedPath)))
82
+ return null;
83
+ const cachedAt = index[filename]?.createdAt;
84
+ if (options.cacheMaxAge === false ||
85
+ (typeof cachedAt === "number" && !isCacheExpired(cachedAt, options.cacheMaxAge))) {
86
+ return filename;
87
+ }
88
+ await fs.rm(cachedPath, { force: true });
89
+ return null;
90
+ }));
91
+ return cached.find((filename) => filename !== null) ?? null;
92
+ };
70
93
  /**
71
94
  * Download image from given URL. If the image already exists, return the filename.
72
95
  * @param options Options to download image.
@@ -80,26 +103,13 @@ const downloadImage = async (options) => {
80
103
  return null;
81
104
  }
82
105
  try {
83
- const filename = generateFilename(options.url);
84
- const savePath = path.posix.join(options.directory, filename);
85
- // If the file already exists, return the filename.
86
- const fileExists = await checkFileExists(savePath);
87
- if (fileExists) {
88
- if (options.cacheMaxAge === false)
89
- return filename;
90
- const index = await readCacheIndex(options.directory);
91
- const entry = index[filename];
92
- const cachedAt = entry?.createdAt;
93
- const maxAge = options.cacheMaxAge;
94
- if (typeof cachedAt === "number" && !isCacheExpired(cachedAt, maxAge))
95
- return filename;
96
- if (typeof entry !== "undefined") {
97
- // oxlint-disable-next-line @typescript-eslint/no-dynamic-delete
98
- delete index[filename];
99
- await writeCacheIndex(options.directory, index);
100
- }
101
- await fs.rm(savePath, { force: true });
102
- }
106
+ const svgHandling = options.svgHandling ?? "rasterize";
107
+ const stem = generateFilename(options.url, false);
108
+ let filename = generateFilename(options.url);
109
+ const candidates = new Set([filename, `${stem}.png`]);
110
+ const cachedFilename = await restoreImageCache(options, candidates);
111
+ if (cachedFilename)
112
+ return cachedFilename;
103
113
  const response = await fetch(options.url, {
104
114
  headers: {
105
115
  "user-agent": options.userAgent
@@ -110,19 +120,25 @@ const downloadImage = async (options) => {
110
120
  if (!response.ok)
111
121
  return null;
112
122
  const contentType = response.headers.get("content-type");
113
- const normalizedContentType = contentType?.split(";")[0]?.trim().toLowerCase();
114
- if (!normalizedContentType?.startsWith("image/")) {
123
+ const normalizedContentType = contentType?.replace(/;.*/u, "").trim().toLowerCase();
124
+ const isSVG = normalizedContentType === "image/svg+xml" ||
125
+ [options.url, response.url].some((url) => path.extname(new URL(url).pathname).toLowerCase() === ".svg");
126
+ if (isSVG && svgHandling === "omit")
127
+ return null;
128
+ if (!isSVG && !normalizedContentType?.startsWith("image/")) {
115
129
  // oxlint-disable-next-line no-console
116
130
  console.error("[rehype-og-card] Invalid image content type:", contentType ?? "unknown", "for", options.url);
117
131
  return null;
118
132
  }
119
133
  const arrayBuffer = await response.arrayBuffer();
120
134
  const buffer = Buffer.from(arrayBuffer);
121
- const saveDirectoryExists = await checkFileExists(options.directory);
122
- if (!saveDirectoryExists) {
123
- await fs.mkdir(options.directory, { recursive: true });
135
+ const shouldRasterize = isSVG && svgHandling === "rasterize";
136
+ const imageBuffer = shouldRasterize ? await rasterizeSVG(buffer) : buffer;
137
+ if (shouldRasterize) {
138
+ filename = `${stem}.png`;
124
139
  }
125
- await fs.writeFile(savePath, buffer);
140
+ await fs.mkdir(options.directory, { recursive: true });
141
+ await fs.writeFile(path.join(options.directory, filename), imageBuffer);
126
142
  const cachedAt = Date.now();
127
143
  const index = await readCacheIndex(options.directory);
128
144
  index[filename] = { createdAt: cachedAt };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Convert SVG to PNG without loading resources referenced by the SVG.
3
+ * @param buffer Downloaded SVG.
4
+ * @returns PNG within the link card image bounds.
5
+ */
6
+ declare const rasterizeSVG: (buffer: Buffer) => Promise<Buffer>;
7
+ export { rasterizeSVG };
@@ -0,0 +1,20 @@
1
+ import sharp from "sharp";
2
+ const MAX_WIDTH = 1200;
3
+ const MAX_HEIGHT = 628;
4
+ /**
5
+ * Convert SVG to PNG without loading resources referenced by the SVG.
6
+ * @param buffer Downloaded SVG.
7
+ * @returns PNG within the link card image bounds.
8
+ */
9
+ const rasterizeSVG = async (buffer) =>
10
+ // Buffer input leaves librsvg's base URL unset, preventing external file references.
11
+ sharp(buffer)
12
+ .resize({
13
+ fit: "inside",
14
+ height: MAX_HEIGHT,
15
+ width: MAX_WIDTH,
16
+ withoutEnlargement: true
17
+ })
18
+ .png()
19
+ .toBuffer();
20
+ export { rasterizeSVG };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rehype-og-card",
3
- "version": "3.1.16",
3
+ "version": "4.0.0",
4
4
  "description": "Rehype plugin to convert text links to link cards",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "scripts": {
15
15
  "test": "vitest",
16
- "lint": "oxlint ./src",
16
+ "lint": "oxlint src",
17
17
  "format": "prettier --write ./src/**/*",
18
18
  "format:check": "prettier --check ./src/**/*",
19
19
  "build": "tsc",
@@ -42,26 +42,27 @@
42
42
  "devDependencies": {
43
43
  "@changesets/changelog-github": "^1.0.1",
44
44
  "@changesets/cli": "^3.0.3",
45
- "@robot-inventor/oxlint-config": "^0.4.0",
45
+ "@robot-inventor/oxlint-config": "^0.5.2",
46
46
  "@robot-inventor/tsconfig-base": "^7.2.0",
47
- "@types/node": "^26.6.2",
47
+ "@types/node": "^26.6.5",
48
48
  "hast-util-to-html": "^9.0.5",
49
- "oxlint": "^1.85.0",
50
- "prettier": "^3.9.8",
49
+ "oxlint": "^1.89.0",
50
+ "prettier": "^3.9.10",
51
51
  "rehype-stringify": "^10.0.1",
52
52
  "remark-parse": "^11.0.0",
53
53
  "remark-rehype": "^11.1.2",
54
54
  "typescript": "^7.0.2",
55
55
  "unified": "^11.0.5",
56
- "vitest": "^5.0.1"
56
+ "vitest": "^5.0.5"
57
57
  },
58
58
  "dependencies": {
59
59
  "@types/hast": "^3.0.5",
60
- "arktype": "^2.2.3",
60
+ "arktype": "^2.2.9",
61
61
  "hast-util-is-element": "^3.0.0",
62
62
  "hastscript": "^9.0.1",
63
63
  "node-fetch": "^3.3.2",
64
64
  "open-graph-scraper": "^6.12.0",
65
+ "sharp": "^0.35.5",
65
66
  "unist-util-visit-parents": "^6.0.2"
66
67
  }
67
68
  }