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 +36 -15
- package/dist/index.js +20 -15
- package/dist/types.d.ts +12 -0
- package/dist/util/cache.js +34 -39
- package/dist/util/file.d.ts +1 -7
- package/dist/util/file.js +10 -26
- package/dist/util/network.d.ts +6 -1
- package/dist/util/network.js +44 -28
- package/dist/util/svg.d.ts +7 -0
- package/dist/util/svg.js +20 -0
- package/package.json +9 -8
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 |
|
|
6
|
-
| :--------------------------------------------- |
|
|
7
|
-
| Plugin type |
|
|
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: `"
|
|
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
|
-
|
|
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 =
|
|
43
|
-
mergedOptions.buildCachePath =
|
|
44
|
-
const buildCacheExists =
|
|
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
|
-
|
|
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 =
|
|
123
|
+
OGData.OGImageURL = posix.join("/rehype-og-card", filename);
|
|
120
124
|
if (mergedOptions.buildCache) {
|
|
121
|
-
const downloadedFilePath =
|
|
122
|
-
const buildCachePath =
|
|
123
|
-
|
|
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 =
|
|
143
|
+
OGData.faviconURL = posix.join("/rehype-og-card", filename);
|
|
139
144
|
if (mergedOptions.buildCache) {
|
|
140
|
-
const downloadedFilePath =
|
|
141
|
-
const buildCachePath =
|
|
142
|
-
|
|
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.
|
package/dist/util/cache.js
CHANGED
|
@@ -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
|
|
5
|
-
import
|
|
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
|
|
63
|
+
const handle = await open(lockPath, "wx");
|
|
64
64
|
await handle.close();
|
|
65
65
|
return async () => {
|
|
66
|
-
await
|
|
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 =
|
|
100
|
-
|
|
99
|
+
const fd = openSync(lockPath, "wx");
|
|
100
|
+
closeSync(fd);
|
|
101
101
|
return () => {
|
|
102
|
-
|
|
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 =
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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
|
|
184
|
-
await
|
|
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
|
-
|
|
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 (!
|
|
208
|
+
if (!existsSync(directory))
|
|
209
209
|
return;
|
|
210
210
|
const index = readCacheIndexSync(directory);
|
|
211
211
|
let indexUpdated = false;
|
|
212
|
-
const entries =
|
|
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 =
|
|
225
|
+
const raw = readFileSync(entryPath, "utf-8");
|
|
226
226
|
const data = JSON.parse(raw);
|
|
227
227
|
if (typeof data.cachedAt !== "number") {
|
|
228
|
-
|
|
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
|
-
|
|
233
|
+
rmSync(entryPath, { force: true });
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
catch {
|
|
237
|
-
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
279
|
+
if (!existsSync(buildCachePath))
|
|
285
280
|
return;
|
|
286
281
|
pruneExpiredCacheFilesSync(buildCachePath, maxAgeMs);
|
|
287
|
-
if (!
|
|
288
|
-
|
|
282
|
+
if (!existsSync(serverCachePath)) {
|
|
283
|
+
mkdirSync(serverCachePath, { recursive: true });
|
|
289
284
|
}
|
|
290
|
-
|
|
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
|
|
298
|
+
await mkdir(buildCachePath, { recursive: true });
|
|
304
299
|
}
|
|
305
300
|
const payload = { ...OGData, cachedAt: Date.now() };
|
|
306
|
-
await
|
|
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
|
|
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
|
|
321
|
+
await rm(savePath, { force: true });
|
|
327
322
|
return null;
|
|
328
323
|
}
|
|
329
324
|
if (isCacheExpired(parsed.cachedAt, maxAgeMs)) {
|
|
330
|
-
await
|
|
325
|
+
await rm(savePath, { force: true });
|
|
331
326
|
return null;
|
|
332
327
|
}
|
|
333
328
|
const { cachedAt: __, ...rest } = parsed;
|
package/dist/util/file.d.ts
CHANGED
|
@@ -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,
|
|
25
|
+
export { checkFileExists, createDirectorySync, copyDirectory, generateFilename };
|
package/dist/util/file.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
|
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
|
-
|
|
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 =
|
|
32
|
+
const sourceExists = existsSync(source);
|
|
49
33
|
if (!sourceExists)
|
|
50
34
|
return;
|
|
51
|
-
const destinationExists =
|
|
35
|
+
const destinationExists = existsSync(destination);
|
|
52
36
|
if (!destinationExists) {
|
|
53
37
|
createDirectorySync(destination);
|
|
54
38
|
}
|
|
55
|
-
|
|
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,
|
|
62
|
+
export { checkFileExists, createDirectorySync, copyDirectory, generateFilename };
|
package/dist/util/network.d.ts
CHANGED
|
@@ -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.
|
package/dist/util/network.js
CHANGED
|
@@ -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
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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?.
|
|
114
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
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 };
|
package/dist/util/svg.js
ADDED
|
@@ -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
|
+
"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
|
|
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.
|
|
45
|
+
"@robot-inventor/oxlint-config": "^0.5.2",
|
|
46
46
|
"@robot-inventor/tsconfig-base": "^7.2.0",
|
|
47
|
-
"@types/node": "^26.6.
|
|
47
|
+
"@types/node": "^26.6.5",
|
|
48
48
|
"hast-util-to-html": "^9.0.5",
|
|
49
|
-
"oxlint": "^1.
|
|
50
|
-
"prettier": "^3.9.
|
|
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.
|
|
56
|
+
"vitest": "^5.0.5"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@types/hast": "^3.0.5",
|
|
60
|
-
"arktype": "^2.2.
|
|
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
|
}
|