rehype-og-card 3.1.15 → 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 +23 -18
- package/dist/types.d.ts +12 -0
- package/dist/util/cache.js +35 -42
- 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 +45 -29
- 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,14 +25,15 @@ 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.
|
|
30
33
|
* @param options Plugin options.
|
|
31
34
|
* @returns Transformer function.
|
|
32
35
|
*/
|
|
33
|
-
// oxlint-disable-next-line max-
|
|
36
|
+
// oxlint-disable-next-line max-lines-per-function
|
|
34
37
|
const rehypeOGCard = (options) => {
|
|
35
38
|
const mergedOptions = {
|
|
36
39
|
...DEFAULT_OPTIONS,
|
|
@@ -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);
|
|
@@ -57,7 +60,7 @@ const rehypeOGCard = (options) => {
|
|
|
57
60
|
// oxlint-disable-next-line max-lines-per-function
|
|
58
61
|
const transform = async (tree) => {
|
|
59
62
|
const linkCardPromises = [];
|
|
60
|
-
// oxlint-disable-next-line max-
|
|
63
|
+
// oxlint-disable-next-line max-lines-per-function
|
|
61
64
|
visitParents(tree, ["element", "text"], (node, ancestors) => {
|
|
62
65
|
let anchorNode = null;
|
|
63
66
|
const isBareLink = node.type === "text" && isValidURL(node.value.trim());
|
|
@@ -95,7 +98,7 @@ const rehypeOGCard = (options) => {
|
|
|
95
98
|
const targetURL = new URL(anchorNode.properties.href);
|
|
96
99
|
if (mergedOptions.excludeDomains.includes(targetURL.hostname))
|
|
97
100
|
return;
|
|
98
|
-
// oxlint-disable-next-line max-statements
|
|
101
|
+
// oxlint-disable-next-line max-statements jsdoc-js/require-jsdoc
|
|
99
102
|
const linkCardPromise = async () => {
|
|
100
103
|
let OGData = mergedOptions.buildCache
|
|
101
104
|
? await restoreOGDataBuildCache(anchorNode.properties.href, mergedOptions.buildCachePath, 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,22 +194,22 @@ 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.
|
|
201
201
|
* @param directory Cache directory path.
|
|
202
202
|
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
203
203
|
*/
|
|
204
|
-
// oxlint-disable-next-line max-statements
|
|
204
|
+
// oxlint-disable-next-line max-statements
|
|
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;
|
|
@@ -256,23 +256,17 @@ const pruneExpiredCacheFilesSync = (directory, maxAgeMs) => {
|
|
|
256
256
|
* @param serverCachePath Server cache path.
|
|
257
257
|
* @param buildCachePath Build cache path.
|
|
258
258
|
*/
|
|
259
|
-
// oxlint-disable-next-line max-statements
|
|
260
259
|
const saveBuildCacheFile = async (serverCachePath, buildCachePath) => {
|
|
261
260
|
const serverCacheExists = await checkFileExists(serverCachePath);
|
|
262
|
-
|
|
263
|
-
if (serverCacheExists && !buildCacheExists) {
|
|
264
|
-
await fs.copyFile(serverCachePath, buildCachePath);
|
|
265
|
-
}
|
|
266
|
-
const buildCacheExistsAfter = await checkFileExists(buildCachePath);
|
|
267
|
-
if (!buildCacheExistsAfter)
|
|
261
|
+
if (!serverCacheExists)
|
|
268
262
|
return;
|
|
263
|
+
await copyFile(serverCachePath, buildCachePath);
|
|
269
264
|
const serverDirectory = path.dirname(serverCachePath);
|
|
270
265
|
const buildDirectory = path.dirname(buildCachePath);
|
|
271
266
|
const filename = path.basename(buildCachePath);
|
|
272
267
|
const serverIndex = readCacheIndexSync(serverDirectory);
|
|
273
|
-
const cachedAt = typeof serverIndex[filename]?.createdAt === "number" ? serverIndex[filename].createdAt : Date.now();
|
|
274
268
|
const buildIndex = readCacheIndexSync(buildDirectory);
|
|
275
|
-
buildIndex[filename] = { createdAt:
|
|
269
|
+
buildIndex[filename] = { createdAt: serverIndex[filename]?.createdAt ?? Date.now() };
|
|
276
270
|
writeCacheIndexSync(buildDirectory, buildIndex);
|
|
277
271
|
};
|
|
278
272
|
/**
|
|
@@ -282,13 +276,13 @@ const saveBuildCacheFile = async (serverCachePath, buildCachePath) => {
|
|
|
282
276
|
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
283
277
|
*/
|
|
284
278
|
const restoreBuildCache = (buildCachePath, serverCachePath, maxAgeMs) => {
|
|
285
|
-
if (!
|
|
279
|
+
if (!existsSync(buildCachePath))
|
|
286
280
|
return;
|
|
287
281
|
pruneExpiredCacheFilesSync(buildCachePath, maxAgeMs);
|
|
288
|
-
if (!
|
|
289
|
-
|
|
282
|
+
if (!existsSync(serverCachePath)) {
|
|
283
|
+
mkdirSync(serverCachePath, { recursive: true });
|
|
290
284
|
}
|
|
291
|
-
|
|
285
|
+
cpSync(buildCachePath, serverCachePath, { recursive: true });
|
|
292
286
|
};
|
|
293
287
|
/**
|
|
294
288
|
* Save OG data to build cache.
|
|
@@ -301,10 +295,10 @@ const saveOGDataBuildCache = async (url, OGData, buildCachePath) => {
|
|
|
301
295
|
const savePath = `${path.join(buildCachePath, filename)}.json`;
|
|
302
296
|
const cacheDirectoryExists = await checkFileExists(buildCachePath);
|
|
303
297
|
if (!cacheDirectoryExists) {
|
|
304
|
-
await
|
|
298
|
+
await mkdir(buildCachePath, { recursive: true });
|
|
305
299
|
}
|
|
306
300
|
const payload = { ...OGData, cachedAt: Date.now() };
|
|
307
|
-
await
|
|
301
|
+
await writeFile(savePath, JSON.stringify(payload));
|
|
308
302
|
};
|
|
309
303
|
/**
|
|
310
304
|
* Restore OG data from build cache.
|
|
@@ -313,23 +307,22 @@ const saveOGDataBuildCache = async (url, OGData, buildCachePath) => {
|
|
|
313
307
|
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
314
308
|
* @returns Restored OG data. If not found, returns `null`.
|
|
315
309
|
*/
|
|
316
|
-
// oxlint-disable-next-line max-statements
|
|
317
310
|
const restoreOGDataBuildCache = async (url, buildCachePath, maxAgeMs) => {
|
|
318
311
|
const filename = generateFilename(url, false);
|
|
319
312
|
const savePath = `${path.join(buildCachePath, filename)}.json`;
|
|
320
313
|
try {
|
|
321
|
-
const data = await
|
|
314
|
+
const data = await readFile(savePath, "utf-8");
|
|
322
315
|
const parsed = JSON.parse(data);
|
|
323
316
|
if (typeof parsed.cachedAt !== "number") {
|
|
324
317
|
if (maxAgeMs === false) {
|
|
325
318
|
const { cachedAt: __, ...rest } = parsed;
|
|
326
319
|
return rest;
|
|
327
320
|
}
|
|
328
|
-
await
|
|
321
|
+
await rm(savePath, { force: true });
|
|
329
322
|
return null;
|
|
330
323
|
}
|
|
331
324
|
if (isCacheExpired(parsed.cachedAt, maxAgeMs)) {
|
|
332
|
-
await
|
|
325
|
+
await rm(savePath, { force: true });
|
|
333
326
|
return null;
|
|
334
327
|
}
|
|
335
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,12 +68,34 @@ 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.
|
|
73
96
|
* @returns Filename of the downloaded image.
|
|
74
97
|
*/
|
|
75
|
-
// oxlint-disable-next-line max-statements
|
|
98
|
+
// oxlint-disable-next-line max-statements
|
|
76
99
|
const downloadImage = async (options) => {
|
|
77
100
|
if (!isValidURL(options.url)) {
|
|
78
101
|
// oxlint-disable-next-line no-console
|
|
@@ -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.2
|
|
45
|
+
"@robot-inventor/oxlint-config": "^0.5.2",
|
|
46
46
|
"@robot-inventor/tsconfig-base": "^7.2.0",
|
|
47
|
-
"@types/node": "^
|
|
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
|
}
|