rehype-og-card 2.0.0 → 2.0.2
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 +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +160 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +7 -0
- package/dist/util/cache.d.ts +55 -0
- package/dist/util/cache.js +346 -0
- package/dist/util/file.d.ts +31 -0
- package/dist/util/file.js +78 -0
- package/dist/util/hast.d.ts +38 -0
- package/dist/util/hast.js +73 -0
- package/dist/util/network.d.ts +40 -0
- package/dist/util/network.js +129 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ Expiration is calculated using the cached timestamp stored in the cache itself.
|
|
|
128
128
|
User agent to use for fetching the OG metadata and images.
|
|
129
129
|
|
|
130
130
|
- Type: `string`
|
|
131
|
-
- Default: `"Mozilla/5.0 (
|
|
131
|
+
- Default: `"Mozilla/5.0 (compatible; rehype-og-card/2.0; +https://github.com/Robot-Inventor/rehype-og-card/; purpose=link-preview; twitterbot-compatible)"`
|
|
132
132
|
|
|
133
133
|
### `decoding`
|
|
134
134
|
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Plugin } from "unified";
|
|
2
|
+
import type { RehypeOGCardOptions } from "./types.js";
|
|
3
|
+
import type { Root } from "hast";
|
|
4
|
+
/**
|
|
5
|
+
* Rehype plugin to create OG card from bare links.
|
|
6
|
+
* @param options Plugin options.
|
|
7
|
+
* @returns Transformer function.
|
|
8
|
+
*/
|
|
9
|
+
declare const rehypeOGCard: Plugin<[RehypeOGCardOptions | undefined], Root>;
|
|
10
|
+
export default rehypeOGCard;
|
|
11
|
+
export type { RehypeOGCardOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { convertTextToAnchorElement, createOGCard, isAnchorElement, isTextNode } from "./util/hast.js";
|
|
2
|
+
import { checkFileExistsSync, createDirectorySync } from "./util/file.js";
|
|
3
|
+
import { downloadImage, getOGData, isValidURL } from "./util/network.js";
|
|
4
|
+
import { restoreBuildCache, restoreOGDataBuildCache, saveBuildCacheFile, saveOGDataBuildCache } from "./util/cache.js";
|
|
5
|
+
import { isElement } from "hast-util-is-element";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { visitParents } from "unist-util-visit-parents";
|
|
8
|
+
// eslint-disable-next-line no-magic-numbers
|
|
9
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
// eslint-disable-next-line no-magic-numbers
|
|
11
|
+
const DEFAULT_CACHE_MAX_AGE_MS = 30 * ONE_DAY_MS;
|
|
12
|
+
const DEFAULT_OPTIONS = {
|
|
13
|
+
buildCache: false,
|
|
14
|
+
buildCacheMaxAge: DEFAULT_CACHE_MAX_AGE_MS,
|
|
15
|
+
buildCachePath: "./node_modules/.cache",
|
|
16
|
+
crawlerUserAgent: "Mozilla/5.0 (compatible; rehype-og-card/2.0; +https://github.com/Robot-Inventor/rehype-og-card/; purpose=link-preview; twitterbot-compatible)",
|
|
17
|
+
decoding: "async",
|
|
18
|
+
enableSameTextURLConversion: false,
|
|
19
|
+
excludeDomains: [],
|
|
20
|
+
loading: "lazy",
|
|
21
|
+
openInNewTab: false,
|
|
22
|
+
serverCache: true,
|
|
23
|
+
serverCacheMaxAge: DEFAULT_CACHE_MAX_AGE_MS,
|
|
24
|
+
serverCachePath: "./public",
|
|
25
|
+
shortenURL: true
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Rehype plugin to create OG card from bare links.
|
|
29
|
+
* @param options Plugin options.
|
|
30
|
+
* @returns Transformer function.
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line max-lines-per-function, max-statements
|
|
33
|
+
const rehypeOGCard = (options) => {
|
|
34
|
+
const mergedOptions = {
|
|
35
|
+
...DEFAULT_OPTIONS,
|
|
36
|
+
...options
|
|
37
|
+
};
|
|
38
|
+
if (mergedOptions.buildCache && !mergedOptions.serverCache) {
|
|
39
|
+
throw new Error("[rehype-og-card] `buildCache` option requires `serverCache` option to be enabled.");
|
|
40
|
+
}
|
|
41
|
+
mergedOptions.serverCachePath = path.posix.join(mergedOptions.serverCachePath, "./rehype-og-card/");
|
|
42
|
+
mergedOptions.buildCachePath = path.posix.join(mergedOptions.buildCachePath, "./rehype-og-card/");
|
|
43
|
+
const buildCacheExists = checkFileExistsSync(mergedOptions.buildCachePath);
|
|
44
|
+
if (mergedOptions.buildCache) {
|
|
45
|
+
if (buildCacheExists) {
|
|
46
|
+
restoreBuildCache(mergedOptions.buildCachePath, mergedOptions.serverCachePath, mergedOptions.buildCacheMaxAge);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
createDirectorySync(mergedOptions.buildCachePath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Transform function to create OG card from bare links.
|
|
54
|
+
* @param tree Root node of the HAST tree.
|
|
55
|
+
*/
|
|
56
|
+
// eslint-disable-next-line max-lines-per-function
|
|
57
|
+
const transform = async (tree) => {
|
|
58
|
+
const linkCardPromises = [];
|
|
59
|
+
// eslint-disable-next-line max-statements, max-lines-per-function
|
|
60
|
+
visitParents(tree, ["element", "text"], (node, ancestors) => {
|
|
61
|
+
let anchorNode = null;
|
|
62
|
+
const isBareLink = node.type === "text" && isValidURL(node.value.trim());
|
|
63
|
+
const isValidAnchor = isAnchorElement(node) && isValidURL(node.properties.href);
|
|
64
|
+
if (isBareLink) {
|
|
65
|
+
anchorNode = convertTextToAnchorElement(node);
|
|
66
|
+
}
|
|
67
|
+
else if (mergedOptions.enableSameTextURLConversion && isValidAnchor) {
|
|
68
|
+
const isSameTextURL =
|
|
69
|
+
// eslint-disable-next-line no-magic-numbers
|
|
70
|
+
node.children.length === 1 &&
|
|
71
|
+
node.children[0] &&
|
|
72
|
+
isTextNode(node.children[0]) &&
|
|
73
|
+
node.children[0].value.trim() === node.properties.href;
|
|
74
|
+
if (!isSameTextURL)
|
|
75
|
+
return;
|
|
76
|
+
anchorNode = node;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// eslint-disable-next-line no-magic-numbers
|
|
82
|
+
const parent = ancestors[ancestors.length - 1];
|
|
83
|
+
if (!parent)
|
|
84
|
+
return;
|
|
85
|
+
if (!isElement(parent, "p"))
|
|
86
|
+
return;
|
|
87
|
+
const shouldSkip = ancestors.some((ancestor) => isElement(ancestor) && ["ul", "ol"].includes(ancestor.tagName));
|
|
88
|
+
if (shouldSkip)
|
|
89
|
+
return;
|
|
90
|
+
// eslint-disable-next-line no-magic-numbers
|
|
91
|
+
const isTheOnlyChild = parent.children.length === 1;
|
|
92
|
+
if (!isTheOnlyChild)
|
|
93
|
+
return;
|
|
94
|
+
const targetURL = new URL(anchorNode.properties.href);
|
|
95
|
+
if (mergedOptions.excludeDomains.includes(targetURL.hostname))
|
|
96
|
+
return;
|
|
97
|
+
// eslint-disable-next-line jsdoc/require-jsdoc, max-statements, max-lines-per-function
|
|
98
|
+
const linkCardPromise = async () => {
|
|
99
|
+
let OGData = mergedOptions.buildCache
|
|
100
|
+
? await restoreOGDataBuildCache(anchorNode.properties.href, mergedOptions.buildCachePath, mergedOptions.buildCacheMaxAge)
|
|
101
|
+
: null;
|
|
102
|
+
if (!OGData) {
|
|
103
|
+
OGData = await getOGData(anchorNode.properties.href, mergedOptions.crawlerUserAgent);
|
|
104
|
+
if (!OGData)
|
|
105
|
+
return;
|
|
106
|
+
if (mergedOptions.buildCache) {
|
|
107
|
+
void saveOGDataBuildCache(anchorNode.properties.href, OGData, mergedOptions.buildCachePath);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (OGData.OGImageURL && mergedOptions.serverCache) {
|
|
111
|
+
const filename = await downloadImage({
|
|
112
|
+
cacheMaxAge: mergedOptions.serverCacheMaxAge,
|
|
113
|
+
directory: mergedOptions.serverCachePath,
|
|
114
|
+
url: OGData.OGImageURL,
|
|
115
|
+
userAgent: mergedOptions.crawlerUserAgent
|
|
116
|
+
});
|
|
117
|
+
if (filename) {
|
|
118
|
+
OGData.OGImageURL = path.posix.join("/rehype-og-card", filename);
|
|
119
|
+
}
|
|
120
|
+
if (filename && mergedOptions.buildCache) {
|
|
121
|
+
const downloadedFilePath = path.join(mergedOptions.serverCachePath, filename);
|
|
122
|
+
const buildCachePath = path.join(mergedOptions.buildCachePath, filename);
|
|
123
|
+
void saveBuildCacheFile(downloadedFilePath, buildCachePath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (OGData.faviconURL && mergedOptions.serverCache) {
|
|
127
|
+
const filename = await downloadImage({
|
|
128
|
+
cacheMaxAge: mergedOptions.serverCacheMaxAge,
|
|
129
|
+
directory: mergedOptions.serverCachePath,
|
|
130
|
+
url: OGData.faviconURL,
|
|
131
|
+
userAgent: mergedOptions.crawlerUserAgent
|
|
132
|
+
});
|
|
133
|
+
if (filename) {
|
|
134
|
+
OGData.faviconURL = path.posix.join("/rehype-og-card", filename);
|
|
135
|
+
}
|
|
136
|
+
if (filename && mergedOptions.buildCache) {
|
|
137
|
+
const downloadedFilePath = path.join(mergedOptions.serverCachePath, filename);
|
|
138
|
+
const buildCachePath = path.join(mergedOptions.buildCachePath, filename);
|
|
139
|
+
void saveBuildCacheFile(downloadedFilePath, buildCachePath);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (mergedOptions.shortenURL) {
|
|
143
|
+
const shortenedURL = new URL(anchorNode.properties.href);
|
|
144
|
+
OGData.displayURL = shortenedURL.hostname;
|
|
145
|
+
}
|
|
146
|
+
const OGCard = createOGCard(OGData, mergedOptions);
|
|
147
|
+
const index = parent.children.indexOf(node);
|
|
148
|
+
// eslint-disable-next-line no-magic-numbers
|
|
149
|
+
if (index === -1)
|
|
150
|
+
return;
|
|
151
|
+
// eslint-disable-next-line no-magic-numbers
|
|
152
|
+
parent.children.splice(index, 1, OGCard);
|
|
153
|
+
};
|
|
154
|
+
linkCardPromises.push(linkCardPromise());
|
|
155
|
+
});
|
|
156
|
+
await Promise.all(linkCardPromises);
|
|
157
|
+
};
|
|
158
|
+
return transform;
|
|
159
|
+
};
|
|
160
|
+
export default rehypeOGCard;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
interface RehypeOGCardOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Domains to exclude from conversion.
|
|
4
|
+
*/
|
|
5
|
+
excludeDomains?: string[];
|
|
6
|
+
/**
|
|
7
|
+
* `decoding` attribute of the image element.
|
|
8
|
+
* @default "async"
|
|
9
|
+
*/
|
|
10
|
+
decoding?: HTMLImageElement["decoding"];
|
|
11
|
+
/**
|
|
12
|
+
* `loading` attribute of the image element.
|
|
13
|
+
* @default "lazy"
|
|
14
|
+
*/
|
|
15
|
+
loading?: HTMLImageElement["loading"];
|
|
16
|
+
/**
|
|
17
|
+
* Whether to shorten the displayed URL.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
shortenURL?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether to open the link in a new tab.
|
|
23
|
+
* @default false
|
|
24
|
+
*/
|
|
25
|
+
openInNewTab?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether to enable converting the links
|
|
28
|
+
* that have the same URL and text (e.g., `[https://example.com](https://example.com)`).
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
enableSameTextURLConversion?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Whether to cache the OG data on the server.
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
serverCache?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Path to save the server cache.
|
|
39
|
+
* Specify the directory whose contents will be copied to the root.
|
|
40
|
+
* For example, for an Astro project, specify the `public` directory.
|
|
41
|
+
* @default "./public"
|
|
42
|
+
*/
|
|
43
|
+
serverCachePath?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Cache expiration time for server cache in milliseconds.
|
|
46
|
+
* Set to `false` to disable expiration.
|
|
47
|
+
* Expiration is calculated from the cached timestamp stored in the cache itself.
|
|
48
|
+
* @default 2592000000
|
|
49
|
+
*/
|
|
50
|
+
serverCacheMaxAge?: number | false;
|
|
51
|
+
/**
|
|
52
|
+
* Whether to cache the OG metadata and images for the next build.
|
|
53
|
+
* `buildCache` option requires `serverCache` option to be enabled.
|
|
54
|
+
* @default false
|
|
55
|
+
*/
|
|
56
|
+
buildCache?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Path to save the build cache.
|
|
59
|
+
* For Astro projects, specify the `./node_modules/.astro` directory.
|
|
60
|
+
* @default "./node_modules/.cache"
|
|
61
|
+
*/
|
|
62
|
+
buildCachePath?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Cache expiration time for build cache in milliseconds.
|
|
65
|
+
* Set to `false` to disable expiration.
|
|
66
|
+
* Expiration is calculated from the cached timestamp stored in the cache itself.
|
|
67
|
+
* @default 2592000000
|
|
68
|
+
*/
|
|
69
|
+
buildCacheMaxAge?: number | false;
|
|
70
|
+
/**
|
|
71
|
+
* User agent to use for fetching the OG metadata and images.
|
|
72
|
+
* @default "Mozilla/5.0 (compatible; rehype-og-card/2.0; +https://github.com/Robot-Inventor/rehype-og-card/; purpose=link-preview; twitterbot-compatible)"
|
|
73
|
+
*/
|
|
74
|
+
crawlerUserAgent?: string;
|
|
75
|
+
}
|
|
76
|
+
interface OGCardData {
|
|
77
|
+
url: string;
|
|
78
|
+
title: string;
|
|
79
|
+
description?: string | undefined;
|
|
80
|
+
displayURL: string;
|
|
81
|
+
faviconURL?: string | undefined;
|
|
82
|
+
OGImageURL?: string | undefined;
|
|
83
|
+
OGImageAlt?: string | undefined;
|
|
84
|
+
OGImageWidth?: number | undefined;
|
|
85
|
+
OGImageHeight?: number | undefined;
|
|
86
|
+
}
|
|
87
|
+
declare const cacheIndexSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
88
|
+
[x: string]: {
|
|
89
|
+
createdAt: number;
|
|
90
|
+
};
|
|
91
|
+
}, {}>;
|
|
92
|
+
type CacheIndex = typeof cacheIndexSchema.infer;
|
|
93
|
+
export { cacheIndexSchema, type RehypeOGCardOptions, type OGCardData, type CacheIndex };
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type CacheIndex, type OGCardData } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Check whether a cache entry is expired.
|
|
4
|
+
* @param cachedAtMs Cached timestamp in milliseconds.
|
|
5
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
6
|
+
* @returns `true` if expired, otherwise `false`.
|
|
7
|
+
*/
|
|
8
|
+
declare const isCacheExpired: (cachedAtMs: number, maxAgeMs: number | false) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Get the cache index path for a directory.
|
|
11
|
+
* @param directory Cache directory path.
|
|
12
|
+
* @returns Cache index path.
|
|
13
|
+
*/
|
|
14
|
+
declare const getCacheIndexPath: (directory: string) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Read cache index.
|
|
17
|
+
* @param directory Cache directory path.
|
|
18
|
+
* @returns Cache index data.
|
|
19
|
+
*/
|
|
20
|
+
declare const readCacheIndex: (directory: string) => Promise<CacheIndex>;
|
|
21
|
+
/**
|
|
22
|
+
* Write cache index.
|
|
23
|
+
* @param directory Cache directory path.
|
|
24
|
+
* @param index Cache index data.
|
|
25
|
+
*/
|
|
26
|
+
declare const writeCacheIndex: (directory: string, index: CacheIndex) => Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Save build cache if server cache exists.
|
|
29
|
+
* @param serverCachePath Server cache path.
|
|
30
|
+
* @param buildCachePath Build cache path.
|
|
31
|
+
*/
|
|
32
|
+
declare const saveBuildCacheFile: (serverCachePath: string, buildCachePath: string) => Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Restore build cache if it exists.
|
|
35
|
+
* @param buildCachePath Build cache path.
|
|
36
|
+
* @param serverCachePath Server cache path.
|
|
37
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
38
|
+
*/
|
|
39
|
+
declare const restoreBuildCache: (buildCachePath: string, serverCachePath: string, maxAgeMs: number | false) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Save OG data to build cache.
|
|
42
|
+
* @param url URL of the OG data.
|
|
43
|
+
* @param OGData OG data to save.
|
|
44
|
+
* @param buildCachePath Build cache path.
|
|
45
|
+
*/
|
|
46
|
+
declare const saveOGDataBuildCache: (url: string, OGData: OGCardData, buildCachePath: string) => Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Restore OG data from build cache.
|
|
49
|
+
* @param url URL of the OG data.
|
|
50
|
+
* @param buildCachePath Build cache path.
|
|
51
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
52
|
+
* @returns Restored OG data. If not found, returns `null`.
|
|
53
|
+
*/
|
|
54
|
+
declare const restoreOGDataBuildCache: (url: string, buildCachePath: string, maxAgeMs: number | false) => Promise<OGCardData | null>;
|
|
55
|
+
export { getCacheIndexPath, isCacheExpired, readCacheIndex, restoreBuildCache, restoreOGDataBuildCache, saveBuildCacheFile, saveOGDataBuildCache, writeCacheIndex };
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
2
|
+
import { cacheIndexSchema } from "../types.js";
|
|
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";
|
|
8
|
+
import { type } from "arktype";
|
|
9
|
+
/**
|
|
10
|
+
* Check whether a cache entry is expired.
|
|
11
|
+
* @param cachedAtMs Cached timestamp in milliseconds.
|
|
12
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
13
|
+
* @returns `true` if expired, otherwise `false`.
|
|
14
|
+
*/
|
|
15
|
+
const isCacheExpired = (cachedAtMs, maxAgeMs) => {
|
|
16
|
+
if (maxAgeMs === false)
|
|
17
|
+
return false;
|
|
18
|
+
return Date.now() - cachedAtMs > maxAgeMs;
|
|
19
|
+
};
|
|
20
|
+
const CACHE_INDEX_FILENAME = "cache.json";
|
|
21
|
+
const CACHE_INDEX_LOCK_FILENAME = "cache.json.lock";
|
|
22
|
+
/**
|
|
23
|
+
* Get the cache index path for a directory.
|
|
24
|
+
* @param directory Cache directory path.
|
|
25
|
+
* @returns Cache index path.
|
|
26
|
+
*/
|
|
27
|
+
const getCacheIndexPath = (directory) => path.join(directory, CACHE_INDEX_FILENAME);
|
|
28
|
+
/**
|
|
29
|
+
* Get the cache index lock path for a directory.
|
|
30
|
+
* @param directory Cache directory path.
|
|
31
|
+
* @returns Cache index lock path.
|
|
32
|
+
*/
|
|
33
|
+
const getCacheIndexLockPath = (directory) => path.join(directory, CACHE_INDEX_LOCK_FILENAME);
|
|
34
|
+
/**
|
|
35
|
+
* Sleep synchronously for a given duration.
|
|
36
|
+
* @param ms Duration in milliseconds.
|
|
37
|
+
*/
|
|
38
|
+
const sleepSync = (ms) => {
|
|
39
|
+
// eslint-disable-next-line no-magic-numbers
|
|
40
|
+
const buffer = new SharedArrayBuffer(4);
|
|
41
|
+
const view = new Int32Array(buffer);
|
|
42
|
+
// eslint-disable-next-line no-magic-numbers
|
|
43
|
+
Atomics.wait(view, 0, 0, ms);
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Acquire the cache index lock.
|
|
47
|
+
* @param directory Cache directory path.
|
|
48
|
+
* @returns Release function or `null` if locking failed.
|
|
49
|
+
*/
|
|
50
|
+
const acquireCacheIndexLock = async (directory) => {
|
|
51
|
+
const lockPath = getCacheIndexLockPath(directory);
|
|
52
|
+
const maxRetries = 50;
|
|
53
|
+
const delayMs = 20;
|
|
54
|
+
const firstAttempt = 0;
|
|
55
|
+
const nextAttemptIncrement = 1;
|
|
56
|
+
/**
|
|
57
|
+
* Try acquiring the cache index lock.
|
|
58
|
+
* @param attempt Current attempt count.
|
|
59
|
+
* @returns Release function or `null` if locking failed.
|
|
60
|
+
*/
|
|
61
|
+
const tryAcquire = async (attempt) => {
|
|
62
|
+
try {
|
|
63
|
+
const handle = await fs.open(lockPath, "wx");
|
|
64
|
+
await handle.close();
|
|
65
|
+
return async () => {
|
|
66
|
+
await fs.rm(lockPath, { force: true });
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const err = error;
|
|
71
|
+
if (err.code !== "EEXIST")
|
|
72
|
+
throw error;
|
|
73
|
+
if (attempt >= maxRetries)
|
|
74
|
+
return null;
|
|
75
|
+
await setTimeout(delayMs);
|
|
76
|
+
return tryAcquire(attempt + nextAttemptIncrement);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return tryAcquire(firstAttempt);
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Acquire the cache index lock synchronously.
|
|
83
|
+
* @param directory Cache directory path.
|
|
84
|
+
* @returns Release function or `null` if locking failed.
|
|
85
|
+
*/
|
|
86
|
+
const acquireCacheIndexLockSync = (directory) => {
|
|
87
|
+
const lockPath = getCacheIndexLockPath(directory);
|
|
88
|
+
const maxRetries = 50;
|
|
89
|
+
const delayMs = 20;
|
|
90
|
+
const firstAttempt = 0;
|
|
91
|
+
const nextAttemptIncrement = 1;
|
|
92
|
+
/**
|
|
93
|
+
* Try acquiring the cache index lock synchronously.
|
|
94
|
+
* @param attempt Current attempt count.
|
|
95
|
+
* @returns Release function or `null` if locking failed.
|
|
96
|
+
*/
|
|
97
|
+
const tryAcquire = (attempt) => {
|
|
98
|
+
try {
|
|
99
|
+
const fd = fsSync.openSync(lockPath, "wx");
|
|
100
|
+
fsSync.closeSync(fd);
|
|
101
|
+
return () => {
|
|
102
|
+
fsSync.rmSync(lockPath, { force: true });
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const err = error;
|
|
107
|
+
if (err.code !== "EEXIST")
|
|
108
|
+
throw error;
|
|
109
|
+
if (attempt >= maxRetries)
|
|
110
|
+
return null;
|
|
111
|
+
sleepSync(delayMs);
|
|
112
|
+
return tryAcquire(attempt + nextAttemptIncrement);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return tryAcquire(firstAttempt);
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Read cache index synchronously.
|
|
119
|
+
* @param directory Cache directory path.
|
|
120
|
+
* @returns Cache index data.
|
|
121
|
+
*/
|
|
122
|
+
const readCacheIndexSync = (directory) => {
|
|
123
|
+
const indexPath = getCacheIndexPath(directory);
|
|
124
|
+
try {
|
|
125
|
+
const raw = fsSync.readFileSync(indexPath, "utf-8");
|
|
126
|
+
const parsed = JSON.parse(raw);
|
|
127
|
+
const validated = cacheIndexSchema(parsed);
|
|
128
|
+
if (validated instanceof type.errors)
|
|
129
|
+
return {};
|
|
130
|
+
return validated;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Write cache index synchronously.
|
|
138
|
+
* @param directory Cache directory path.
|
|
139
|
+
* @param index Cache index data.
|
|
140
|
+
*/
|
|
141
|
+
const writeCacheIndexSync = (directory, index) => {
|
|
142
|
+
const indexPath = getCacheIndexPath(directory);
|
|
143
|
+
const tempPath = `${indexPath}.tmp`;
|
|
144
|
+
const releaseLock = acquireCacheIndexLockSync(directory);
|
|
145
|
+
try {
|
|
146
|
+
fsSync.writeFileSync(tempPath, JSON.stringify(index));
|
|
147
|
+
fsSync.renameSync(tempPath, indexPath);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
releaseLock?.();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Read cache index.
|
|
155
|
+
* @param directory Cache directory path.
|
|
156
|
+
* @returns Cache index data.
|
|
157
|
+
*/
|
|
158
|
+
const readCacheIndex = async (directory) => {
|
|
159
|
+
const indexPath = getCacheIndexPath(directory);
|
|
160
|
+
const exists = await checkFileExists(indexPath);
|
|
161
|
+
if (!exists)
|
|
162
|
+
return {};
|
|
163
|
+
try {
|
|
164
|
+
const raw = await fs.readFile(indexPath, "utf-8");
|
|
165
|
+
const parsed = JSON.parse(raw);
|
|
166
|
+
const validated = cacheIndexSchema(parsed);
|
|
167
|
+
if (validated instanceof type.errors)
|
|
168
|
+
return {};
|
|
169
|
+
return validated;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Write cache index.
|
|
177
|
+
* @param directory Cache directory path.
|
|
178
|
+
* @param index Cache index data.
|
|
179
|
+
*/
|
|
180
|
+
const writeCacheIndex = async (directory, index) => {
|
|
181
|
+
const indexPath = getCacheIndexPath(directory);
|
|
182
|
+
const tempPath = `${indexPath}.tmp`;
|
|
183
|
+
const releaseLock = await acquireCacheIndexLock(directory);
|
|
184
|
+
try {
|
|
185
|
+
await fs.writeFile(tempPath, JSON.stringify(index));
|
|
186
|
+
await fs.rename(tempPath, indexPath);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
await releaseLock?.();
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Remove a cache file synchronously.
|
|
194
|
+
* @param directory Cache directory path.
|
|
195
|
+
* @param filename Cache filename.
|
|
196
|
+
*/
|
|
197
|
+
const removeCacheEntrySync = (directory, filename) => {
|
|
198
|
+
const filePath = path.join(directory, filename);
|
|
199
|
+
fsSync.rmSync(filePath, { force: true });
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Remove expired cache files synchronously.
|
|
203
|
+
* @param directory Cache directory path.
|
|
204
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
205
|
+
*/
|
|
206
|
+
// eslint-disable-next-line max-statements, max-lines-per-function
|
|
207
|
+
const pruneExpiredCacheFilesSync = (directory, maxAgeMs) => {
|
|
208
|
+
if (maxAgeMs === false)
|
|
209
|
+
return;
|
|
210
|
+
if (!fsSync.existsSync(directory))
|
|
211
|
+
return;
|
|
212
|
+
const index = readCacheIndexSync(directory);
|
|
213
|
+
let indexUpdated = false;
|
|
214
|
+
const entries = fsSync.readdirSync(directory, { withFileTypes: true });
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const entryPath = path.join(directory, entry.name);
|
|
217
|
+
if (entry.isDirectory()) {
|
|
218
|
+
pruneExpiredCacheFilesSync(entryPath, maxAgeMs);
|
|
219
|
+
// eslint-disable-next-line no-continue
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// eslint-disable-next-line no-continue
|
|
223
|
+
if (entry.name === CACHE_INDEX_FILENAME || entry.name === CACHE_INDEX_LOCK_FILENAME)
|
|
224
|
+
continue;
|
|
225
|
+
if (entry.name.endsWith(".json")) {
|
|
226
|
+
try {
|
|
227
|
+
const raw = fsSync.readFileSync(entryPath, "utf-8");
|
|
228
|
+
const data = JSON.parse(raw);
|
|
229
|
+
if (typeof data.cachedAt !== "number") {
|
|
230
|
+
fsSync.rmSync(entryPath, { force: true });
|
|
231
|
+
// eslint-disable-next-line no-continue
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (isCacheExpired(data.cachedAt, maxAgeMs)) {
|
|
235
|
+
fsSync.rmSync(entryPath, { force: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
fsSync.rmSync(entryPath, { force: true });
|
|
240
|
+
}
|
|
241
|
+
// eslint-disable-next-line no-continue
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const cachedAtMs = index[entry.name]?.createdAt;
|
|
245
|
+
if (typeof cachedAtMs !== "number" || isCacheExpired(cachedAtMs, maxAgeMs)) {
|
|
246
|
+
removeCacheEntrySync(directory, entry.name);
|
|
247
|
+
indexUpdated = true;
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
249
|
+
delete index[entry.name];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (indexUpdated) {
|
|
253
|
+
writeCacheIndexSync(directory, index);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Save build cache if server cache exists.
|
|
258
|
+
* @param serverCachePath Server cache path.
|
|
259
|
+
* @param buildCachePath Build cache path.
|
|
260
|
+
*/
|
|
261
|
+
// eslint-disable-next-line max-statements
|
|
262
|
+
const saveBuildCacheFile = async (serverCachePath, buildCachePath) => {
|
|
263
|
+
const serverCacheExists = await checkFileExists(serverCachePath);
|
|
264
|
+
const buildCacheExists = await checkFileExists(buildCachePath);
|
|
265
|
+
if (serverCacheExists && !buildCacheExists) {
|
|
266
|
+
await fs.copyFile(serverCachePath, buildCachePath);
|
|
267
|
+
}
|
|
268
|
+
const buildCacheExistsAfter = await checkFileExists(buildCachePath);
|
|
269
|
+
if (!buildCacheExistsAfter)
|
|
270
|
+
return;
|
|
271
|
+
const serverDirectory = path.dirname(serverCachePath);
|
|
272
|
+
const buildDirectory = path.dirname(buildCachePath);
|
|
273
|
+
const filename = path.basename(buildCachePath);
|
|
274
|
+
const serverIndex = readCacheIndexSync(serverDirectory);
|
|
275
|
+
const cachedAt = typeof serverIndex[filename]?.createdAt === "number" ? serverIndex[filename].createdAt : Date.now();
|
|
276
|
+
const buildIndex = readCacheIndexSync(buildDirectory);
|
|
277
|
+
buildIndex[filename] = { createdAt: cachedAt };
|
|
278
|
+
writeCacheIndexSync(buildDirectory, buildIndex);
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Restore build cache if it exists.
|
|
282
|
+
* @param buildCachePath Build cache path.
|
|
283
|
+
* @param serverCachePath Server cache path.
|
|
284
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
285
|
+
*/
|
|
286
|
+
const restoreBuildCache = (buildCachePath, serverCachePath, maxAgeMs) => {
|
|
287
|
+
if (!fsSync.existsSync(buildCachePath))
|
|
288
|
+
return;
|
|
289
|
+
pruneExpiredCacheFilesSync(buildCachePath, maxAgeMs);
|
|
290
|
+
if (!fsSync.existsSync(serverCachePath)) {
|
|
291
|
+
fsSync.mkdirSync(serverCachePath, { recursive: true });
|
|
292
|
+
}
|
|
293
|
+
fsSync.cpSync(buildCachePath, serverCachePath, { recursive: true });
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Save OG data to build cache.
|
|
297
|
+
* @param url URL of the OG data.
|
|
298
|
+
* @param OGData OG data to save.
|
|
299
|
+
* @param buildCachePath Build cache path.
|
|
300
|
+
*/
|
|
301
|
+
const saveOGDataBuildCache = async (url, OGData, buildCachePath) => {
|
|
302
|
+
const filename = generateFilename(url, false);
|
|
303
|
+
const savePath = `${path.join(buildCachePath, filename)}.json`;
|
|
304
|
+
const cacheDirectoryExists = await checkFileExists(buildCachePath);
|
|
305
|
+
if (!cacheDirectoryExists) {
|
|
306
|
+
await fs.mkdir(buildCachePath, { recursive: true });
|
|
307
|
+
}
|
|
308
|
+
const payload = { ...OGData, cachedAt: Date.now() };
|
|
309
|
+
await fs.writeFile(savePath, JSON.stringify(payload));
|
|
310
|
+
};
|
|
311
|
+
/**
|
|
312
|
+
* Restore OG data from build cache.
|
|
313
|
+
* @param url URL of the OG data.
|
|
314
|
+
* @param buildCachePath Build cache path.
|
|
315
|
+
* @param maxAgeMs Cache expiration time in milliseconds.
|
|
316
|
+
* @returns Restored OG data. If not found, returns `null`.
|
|
317
|
+
*/
|
|
318
|
+
// eslint-disable-next-line max-statements
|
|
319
|
+
const restoreOGDataBuildCache = async (url, buildCachePath, maxAgeMs) => {
|
|
320
|
+
const filename = generateFilename(url, false);
|
|
321
|
+
const savePath = `${path.join(buildCachePath, filename)}.json`;
|
|
322
|
+
try {
|
|
323
|
+
const data = await fs.readFile(savePath, "utf-8");
|
|
324
|
+
const parsed = JSON.parse(data);
|
|
325
|
+
if (typeof parsed.cachedAt !== "number") {
|
|
326
|
+
if (maxAgeMs === false) {
|
|
327
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
328
|
+
const { cachedAt: __, ...rest } = parsed;
|
|
329
|
+
return rest;
|
|
330
|
+
}
|
|
331
|
+
await fs.rm(savePath, { force: true });
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (isCacheExpired(parsed.cachedAt, maxAgeMs)) {
|
|
335
|
+
await fs.rm(savePath, { force: true });
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
339
|
+
const { cachedAt: __, ...rest } = parsed;
|
|
340
|
+
return rest;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
export { getCacheIndexPath, isCacheExpired, readCacheIndex, restoreBuildCache, restoreOGDataBuildCache, saveBuildCacheFile, saveOGDataBuildCache, writeCacheIndex };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if the file exists.
|
|
3
|
+
* @param filePath Path to check.
|
|
4
|
+
* @returns `true` if the file exists, `false` otherwise.
|
|
5
|
+
*/
|
|
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
|
+
/**
|
|
14
|
+
* Create directory.
|
|
15
|
+
* @param dir Directory path.
|
|
16
|
+
*/
|
|
17
|
+
declare const createDirectorySync: (dir: string) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Copy directory from source to destination.
|
|
20
|
+
* @param source Source directory.
|
|
21
|
+
* @param destination Destination directory.
|
|
22
|
+
*/
|
|
23
|
+
declare const copyDirectory: (source: string, destination: string) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Generate filename from URL.
|
|
26
|
+
* @param url URL to generate filename.
|
|
27
|
+
* @param extension Include extension in the filename.
|
|
28
|
+
* @returns Generated filename.
|
|
29
|
+
*/
|
|
30
|
+
declare const generateFilename: (url: string, extension?: boolean) => string;
|
|
31
|
+
export { checkFileExists, checkFileExistsSync, createDirectorySync, copyDirectory, generateFilename };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
/**
|
|
5
|
+
* Check if the file exists.
|
|
6
|
+
* @param filePath Path to check.
|
|
7
|
+
* @returns `true` if the file exists, `false` otherwise.
|
|
8
|
+
*/
|
|
9
|
+
const checkFileExists = async (filePath) => {
|
|
10
|
+
try {
|
|
11
|
+
await fs.promises.access(filePath);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
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
|
+
/**
|
|
31
|
+
* Create directory.
|
|
32
|
+
* @param dir Directory path.
|
|
33
|
+
*/
|
|
34
|
+
const createDirectorySync = (dir) => {
|
|
35
|
+
fs.mkdir(dir, { recursive: true }, (error) => {
|
|
36
|
+
if (error) {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.error("[rehype-og-card] Failed to copy files: ", error);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Copy directory from source to destination.
|
|
44
|
+
* @param source Source directory.
|
|
45
|
+
* @param destination Destination directory.
|
|
46
|
+
*/
|
|
47
|
+
const copyDirectory = (source, destination) => {
|
|
48
|
+
const sourceExists = checkFileExistsSync(source);
|
|
49
|
+
if (!sourceExists)
|
|
50
|
+
return;
|
|
51
|
+
const destinationExists = checkFileExistsSync(destination);
|
|
52
|
+
if (!destinationExists) {
|
|
53
|
+
createDirectorySync(destination);
|
|
54
|
+
}
|
|
55
|
+
fs.cp(source, destination, { recursive: true }, (error) => {
|
|
56
|
+
if (error) {
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.error("[rehype-og-card] Failed to copy files: ", error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Generate filename from URL.
|
|
64
|
+
* @param url URL to generate filename.
|
|
65
|
+
* @param extension Include extension in the filename.
|
|
66
|
+
* @returns Generated filename.
|
|
67
|
+
*/
|
|
68
|
+
const generateFilename = (url, extension = true) => {
|
|
69
|
+
const hash = createHash("sha256").update(url).digest("hex");
|
|
70
|
+
const possibleExtension = path.extname(new URL(url).pathname).toLowerCase();
|
|
71
|
+
const allowedExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".svg"];
|
|
72
|
+
const extensionString = allowedExtensions.includes(possibleExtension)
|
|
73
|
+
? possibleExtension
|
|
74
|
+
: "";
|
|
75
|
+
const filename = extension ? hash + extensionString : hash;
|
|
76
|
+
return filename;
|
|
77
|
+
};
|
|
78
|
+
export { checkFileExists, checkFileExistsSync, createDirectorySync, copyDirectory, generateFilename };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Element, ElementContent, Text } from "hast";
|
|
2
|
+
import type { OGCardData, RehypeOGCardOptions } from "../types.js";
|
|
3
|
+
interface AnchorElement extends Element {
|
|
4
|
+
tagName: "a";
|
|
5
|
+
properties: {
|
|
6
|
+
href: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Check if the node is a text node.
|
|
11
|
+
* @param node Node to check.
|
|
12
|
+
* @returns `true` if the node is a text node, `false` otherwise.
|
|
13
|
+
*/
|
|
14
|
+
declare const isTextNode: (node: ElementContent) => node is Text;
|
|
15
|
+
/**
|
|
16
|
+
* Check if the node is an anchor element.
|
|
17
|
+
* @param node Node to check.
|
|
18
|
+
* @returns `true` if the node is an anchor element, `false` otherwise.
|
|
19
|
+
*/
|
|
20
|
+
declare const isAnchorElement: (node: unknown) => node is AnchorElement;
|
|
21
|
+
/**
|
|
22
|
+
* Convert text node to anchor element. **This function does not check if the text is a valid URL.**
|
|
23
|
+
* @param text Text node to convert.
|
|
24
|
+
* @returns Anchor element.
|
|
25
|
+
*/
|
|
26
|
+
declare const convertTextToAnchorElement: (text: Text) => AnchorElement;
|
|
27
|
+
/**
|
|
28
|
+
* Create OG card element from given data.
|
|
29
|
+
* @param data Data to create OG card.
|
|
30
|
+
* @param options Options to create OG card.
|
|
31
|
+
* @returns OG card element.
|
|
32
|
+
*/
|
|
33
|
+
declare const createOGCard: (data: OGCardData, options: Required<{
|
|
34
|
+
loading: RehypeOGCardOptions["loading"];
|
|
35
|
+
decoding: RehypeOGCardOptions["decoding"];
|
|
36
|
+
openInNewTab: RehypeOGCardOptions["openInNewTab"];
|
|
37
|
+
}>) => Element;
|
|
38
|
+
export { type AnchorElement, isTextNode, isAnchorElement, convertTextToAnchorElement, createOGCard };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { h } from "hastscript";
|
|
2
|
+
import { isElement } from "hast-util-is-element";
|
|
3
|
+
/**
|
|
4
|
+
* Check if the node is a text node.
|
|
5
|
+
* @param node Node to check.
|
|
6
|
+
* @returns `true` if the node is a text node, `false` otherwise.
|
|
7
|
+
*/
|
|
8
|
+
const isTextNode = (node) => Boolean(node) && typeof node === "object" && "type" in node && node.type === "text";
|
|
9
|
+
/**
|
|
10
|
+
* Check if the node is an anchor element.
|
|
11
|
+
* @param node Node to check.
|
|
12
|
+
* @returns `true` if the node is an anchor element, `false` otherwise.
|
|
13
|
+
*/
|
|
14
|
+
const isAnchorElement = (node) => Boolean(node) && isElement(node, "a") && "href" in node.properties && typeof node.properties["href"] === "string";
|
|
15
|
+
/**
|
|
16
|
+
* Convert text node to anchor element. **This function does not check if the text is a valid URL.**
|
|
17
|
+
* @param text Text node to convert.
|
|
18
|
+
* @returns Anchor element.
|
|
19
|
+
*/
|
|
20
|
+
const convertTextToAnchorElement = (text) => ({
|
|
21
|
+
children: [text],
|
|
22
|
+
properties: {
|
|
23
|
+
href: text.value.trim()
|
|
24
|
+
},
|
|
25
|
+
tagName: "a",
|
|
26
|
+
type: "element"
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Create OG card element from given data.
|
|
30
|
+
* @param data Data to create OG card.
|
|
31
|
+
* @param options Options to create OG card.
|
|
32
|
+
* @returns OG card element.
|
|
33
|
+
*/
|
|
34
|
+
const createOGCard = (data, options) => {
|
|
35
|
+
const openInNewTab = {
|
|
36
|
+
rel: "noopener noreferrer",
|
|
37
|
+
target: "_blank"
|
|
38
|
+
};
|
|
39
|
+
const card = h("div.og-card-container", [
|
|
40
|
+
h("a", { href: data.url, ...(options.openInNewTab ? openInNewTab : {}) }, [
|
|
41
|
+
h("div.og-card-info", [
|
|
42
|
+
h("div.og-card-title", data.title),
|
|
43
|
+
data.description ? h("div.og-card-description", data.description) : null,
|
|
44
|
+
h("div.og-card-url-container", [
|
|
45
|
+
h("img.og-card-favicon", {
|
|
46
|
+
alt: "favicon",
|
|
47
|
+
decoding: options.decoding,
|
|
48
|
+
height: 16,
|
|
49
|
+
loading: options.loading,
|
|
50
|
+
src: data.faviconURL,
|
|
51
|
+
width: 16
|
|
52
|
+
}),
|
|
53
|
+
h("span.og-card-url", data.displayURL)
|
|
54
|
+
])
|
|
55
|
+
]),
|
|
56
|
+
data.OGImageURL
|
|
57
|
+
? h("div.og-card-image-container", [
|
|
58
|
+
h("img.og-card-image", {
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
60
|
+
alt: data.OGImageAlt || data.OGImageURL,
|
|
61
|
+
decoding: options.decoding,
|
|
62
|
+
height: data.OGImageHeight,
|
|
63
|
+
loading: options.loading,
|
|
64
|
+
src: data.OGImageURL,
|
|
65
|
+
width: data.OGImageWidth
|
|
66
|
+
})
|
|
67
|
+
])
|
|
68
|
+
: null
|
|
69
|
+
])
|
|
70
|
+
]);
|
|
71
|
+
return card;
|
|
72
|
+
};
|
|
73
|
+
export { isTextNode, isAnchorElement, convertTextToAnchorElement, createOGCard };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { OGCardData } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Check if the URL is valid.
|
|
4
|
+
* @param url URL to check.
|
|
5
|
+
* @returns `true` if the URL is valid, `false` otherwise.
|
|
6
|
+
*/
|
|
7
|
+
declare const isValidURL: (url: string) => boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Get OG card data from given URL.
|
|
10
|
+
* @param url URL to get OG card data.
|
|
11
|
+
* @param userAgent User agent to use for fetching.
|
|
12
|
+
* @returns OG card data.
|
|
13
|
+
*/
|
|
14
|
+
declare const getOGData: (url: string, userAgent: string) => Promise<OGCardData | null>;
|
|
15
|
+
interface DownloadImageOptions {
|
|
16
|
+
/**
|
|
17
|
+
* URL to download image.
|
|
18
|
+
*/
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* Directory to save the downloaded image.
|
|
22
|
+
*/
|
|
23
|
+
directory: string;
|
|
24
|
+
/**
|
|
25
|
+
* User agent to use for fetching the image.
|
|
26
|
+
*/
|
|
27
|
+
userAgent: string;
|
|
28
|
+
/**
|
|
29
|
+
* Cache expiration time in milliseconds.
|
|
30
|
+
* Set to `false` to disable expiration.
|
|
31
|
+
*/
|
|
32
|
+
cacheMaxAge: number | false;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Download image from given URL. If the image already exists, return the filename.
|
|
36
|
+
* @param options Options to download image.
|
|
37
|
+
* @returns Filename of the downloaded image.
|
|
38
|
+
*/
|
|
39
|
+
declare const downloadImage: (options: DownloadImageOptions) => Promise<string | null>;
|
|
40
|
+
export { isValidURL, getOGData, downloadImage };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { checkFileExists, generateFilename } from "./file.js";
|
|
2
|
+
import { isCacheExpired, readCacheIndex, writeCacheIndex } from "./cache.js";
|
|
3
|
+
import fetch from "node-fetch";
|
|
4
|
+
import fs from "fs/promises";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import scraper from "open-graph-scraper";
|
|
7
|
+
/**
|
|
8
|
+
* Check if the URL is valid.
|
|
9
|
+
* @param url URL to check.
|
|
10
|
+
* @returns `true` if the URL is valid, `false` otherwise.
|
|
11
|
+
*/
|
|
12
|
+
const isValidURL = (url) => {
|
|
13
|
+
const ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
14
|
+
const parsedUrl = URL.parse(url);
|
|
15
|
+
return Boolean(parsedUrl && ALLOWED_PROTOCOLS.includes(parsedUrl.protocol));
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Resolve an absolute URL from a raw URL and a base URL.
|
|
19
|
+
* @param rawURL Raw URL to resolve.
|
|
20
|
+
* @param baseURL Base URL to resolve against.
|
|
21
|
+
* @returns Resolved absolute URL or `undefined` if the raw URL is invalid.
|
|
22
|
+
*/
|
|
23
|
+
const resolveAbsoluteURL = (rawURL, baseURL) => {
|
|
24
|
+
// eslint-disable-next-line no-undefined
|
|
25
|
+
if (!rawURL)
|
|
26
|
+
return undefined;
|
|
27
|
+
const parsedUrl = URL.parse(rawURL, baseURL);
|
|
28
|
+
// eslint-disable-next-line no-undefined
|
|
29
|
+
return parsedUrl ? parsedUrl.href : undefined;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Get OG card data from given URL.
|
|
33
|
+
* @param url URL to get OG card data.
|
|
34
|
+
* @param userAgent User agent to use for fetching.
|
|
35
|
+
* @returns OG card data.
|
|
36
|
+
*/
|
|
37
|
+
const getOGData = async (url, userAgent) => {
|
|
38
|
+
try {
|
|
39
|
+
const { result } = await scraper({
|
|
40
|
+
fetchOptions: {
|
|
41
|
+
headers: {
|
|
42
|
+
"user-agent": userAgent
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
url
|
|
46
|
+
});
|
|
47
|
+
const OGImage = result.ogImage ? result.ogImage[0] : null;
|
|
48
|
+
const OGImageURL = resolveAbsoluteURL(OGImage?.url, url);
|
|
49
|
+
const resolvedFaviconURL = result.favicon ? resolveAbsoluteURL(result.favicon, url) : null;
|
|
50
|
+
const faviconURL = resolvedFaviconURL ?? `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`;
|
|
51
|
+
return {
|
|
52
|
+
OGImageAlt: OGImage?.alt,
|
|
53
|
+
OGImageHeight: OGImage?.height,
|
|
54
|
+
OGImageURL,
|
|
55
|
+
OGImageWidth: OGImage?.width,
|
|
56
|
+
description: result.ogDescription,
|
|
57
|
+
displayURL: url,
|
|
58
|
+
faviconURL,
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
60
|
+
title: result.ogTitle || url,
|
|
61
|
+
url
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.error("[rehype-og-card] Error fetching OG data:", error);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Download image from given URL. If the image already exists, return the filename.
|
|
72
|
+
* @param options Options to download image.
|
|
73
|
+
* @returns Filename of the downloaded image.
|
|
74
|
+
*/
|
|
75
|
+
// eslint-disable-next-line max-statements, max-lines-per-function
|
|
76
|
+
const downloadImage = async (options) => {
|
|
77
|
+
if (!isValidURL(options.url)) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.error("[rehype-og-card] Invalid thumbnail URL:", options.url);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
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
|
+
// eslint-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
|
+
}
|
|
103
|
+
const response = await fetch(options.url, {
|
|
104
|
+
headers: {
|
|
105
|
+
"user-agent": options.userAgent
|
|
106
|
+
},
|
|
107
|
+
// eslint-disable-next-line no-magic-numbers
|
|
108
|
+
signal: AbortSignal.timeout(10 * 1000)
|
|
109
|
+
});
|
|
110
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
111
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
112
|
+
const saveDirectoryExists = await checkFileExists(options.directory);
|
|
113
|
+
if (!saveDirectoryExists) {
|
|
114
|
+
await fs.mkdir(options.directory, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
await fs.writeFile(savePath, buffer);
|
|
117
|
+
const cachedAt = Date.now();
|
|
118
|
+
const index = await readCacheIndex(options.directory);
|
|
119
|
+
index[filename] = { createdAt: cachedAt };
|
|
120
|
+
await writeCacheIndex(options.directory, index);
|
|
121
|
+
return filename;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// eslint-disable-next-line no-console
|
|
125
|
+
console.error("[rehype-og-card] Error downloading image:", error);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
export { isValidURL, getOGData, downloadImage };
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rehype-og-card",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
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",
|
|
7
7
|
"files": [
|
|
8
|
-
"
|
|
8
|
+
"dist"
|
|
9
9
|
],
|
|
10
10
|
"type": "module",
|
|
11
11
|
"publishConfig": {
|