rehype-og-card 0.1.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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +111 -0
- package/dist/util.d.ts +110 -0
- package/dist/util.js +189 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 roboin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# rehype-og-card
|
|
2
|
+
|
|
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
|
+
|
|
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 | ❌ | Planned to be implemented |
|
|
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
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install rehype-og-card
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import rehypeOGCard, { RehypeOGCardOptions } from "rehype-og-card";
|
|
30
|
+
import remarkParse from "remark-parse";
|
|
31
|
+
import remarkRehype from "remark-rehype";
|
|
32
|
+
import rehypeStringify from "rehype-stringify";
|
|
33
|
+
import { unified } from "unified";
|
|
34
|
+
|
|
35
|
+
const processorFactory = (options?: RehypeOGCardOptions) => {
|
|
36
|
+
return unified().use(remarkParse).use(remarkRehype).use(rehypeOGCard, options).use(rehypeStringify);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const main = async () => {
|
|
40
|
+
const processor = processorFactory({ serverCache: false });
|
|
41
|
+
|
|
42
|
+
const input = `
|
|
43
|
+
this is a link: https://blog.google/products/android/world-emoji-day-2024/
|
|
44
|
+
|
|
45
|
+
\`https://blog.google/products/android/world-emoji-day-2024/\`
|
|
46
|
+
|
|
47
|
+
https://blog.google/products/android/world-emoji-day-2024/
|
|
48
|
+
|
|
49
|
+
[https://blog.google/products/android/world-emoji-day-2024/](https://blog.google/products/android/world-emoji-day-2024/)
|
|
50
|
+
|
|
51
|
+
[Google blog](https://blog.google/products/android/world-emoji-day-2024/)
|
|
52
|
+
`.trim();
|
|
53
|
+
|
|
54
|
+
const result = await processor.process(input);
|
|
55
|
+
console.log(result.toString());
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
main();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Output:
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<p>this is a link: https://blog.google/products/android/world-emoji-day-2024/</p>
|
|
65
|
+
<p><code>https://blog.google/products/android/world-emoji-day-2024/</code></p>
|
|
66
|
+
<p><a class="rlc-container" href="https://blog.google/products/android/world-emoji-day-2024/">
|
|
67
|
+
<div class="rlc-info">
|
|
68
|
+
<div class="rlc-title">10 fun facts about emoji for World Emoji Day</div>
|
|
69
|
+
<div class="rlc-description">Celebrate World Emoji Day with Google, and check out what’s new for Emoji Kitchen.</div>
|
|
70
|
+
<div class="rlc-url-container">
|
|
71
|
+
<img class="rlc-favicon" loading="lazy" decoding="async" src="https://www.google.com/s2/favicons?domain=blog.google" alt="favicon" width="16" height="16">
|
|
72
|
+
<span class="rlc-url">blog.google</span>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="rlc-image-container">
|
|
76
|
+
<img class="rlc-image" loading="lazy" decoding="async" src="https://storage.googleapis.com/gweb-uniblog-publish-prod/images/world_emoji_day_v2_1.width-1300.png" alt="https://storage.googleapis.com/gweb-uniblog-publish-prod/images/world_emoji_day_v2_1.width-1300.png">
|
|
77
|
+
</div>
|
|
78
|
+
</a></p>
|
|
79
|
+
<p><a href="https://blog.google/products/android/world-emoji-day-2024/">https://blog.google/products/android/world-emoji-day-2024/</a></p>
|
|
80
|
+
<p><a href="https://blog.google/products/android/world-emoji-day-2024/">Google blog</a></p>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## What will be converted to link cards and what won't?
|
|
84
|
+
|
|
85
|
+
The following links will be converted to link cards:
|
|
86
|
+
|
|
87
|
+
- Bare links
|
|
88
|
+
- Links that have the same URL and text (if you enable the `enableSameTextURLConversion` option)
|
|
89
|
+
|
|
90
|
+
The following links will NOT be converted to link cards:
|
|
91
|
+
|
|
92
|
+
- Links in lists
|
|
93
|
+
- Links in code blocks
|
|
94
|
+
- Links in sentences
|
|
95
|
+
- Non-bare links
|
|
96
|
+
- Links that have the same URL and text (if you disable the `enableSameTextURLConversion` option)
|
|
97
|
+
|
|
98
|
+
## Options
|
|
99
|
+
|
|
100
|
+
All options are optional.
|
|
101
|
+
|
|
102
|
+
### `crawlerUserAgent`
|
|
103
|
+
|
|
104
|
+
User agent to use for fetching the OG metadata and images.
|
|
105
|
+
|
|
106
|
+
- Type: `string`
|
|
107
|
+
- Default: `"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"`
|
|
108
|
+
|
|
109
|
+
### `decoding`
|
|
110
|
+
|
|
111
|
+
`decoding` attribute of the image element.
|
|
112
|
+
|
|
113
|
+
- Type: `"sync" | "async" | "auto"`
|
|
114
|
+
- Default: `"async"`
|
|
115
|
+
|
|
116
|
+
### `enableSameTextURLConversion`
|
|
117
|
+
|
|
118
|
+
Whether to enable converting the links that have the same URL and text (e.g., `[https://example.com](https://example.com)`).
|
|
119
|
+
|
|
120
|
+
- Type: `boolean`
|
|
121
|
+
- Default: `false`
|
|
122
|
+
|
|
123
|
+
### `excludeDomains`
|
|
124
|
+
|
|
125
|
+
Domains to exclude from conversion.
|
|
126
|
+
|
|
127
|
+
- Type: `string[]`
|
|
128
|
+
- Default: `[]`
|
|
129
|
+
|
|
130
|
+
### `loading`
|
|
131
|
+
|
|
132
|
+
`loading` attribute of the image element.
|
|
133
|
+
|
|
134
|
+
- Type: `"eager" | "lazy"`
|
|
135
|
+
- Default: `"lazy"`
|
|
136
|
+
|
|
137
|
+
### `serverCache`
|
|
138
|
+
|
|
139
|
+
Whether to cache the OG data on the server.
|
|
140
|
+
|
|
141
|
+
- Type: `boolean`
|
|
142
|
+
- Default: `true`
|
|
143
|
+
|
|
144
|
+
### `serverCachePath`
|
|
145
|
+
|
|
146
|
+
Path to save the server cache.
|
|
147
|
+
|
|
148
|
+
- Type: `string`
|
|
149
|
+
- Default: `"./public/rehype-og-card-cache/"`
|
|
150
|
+
|
|
151
|
+
### `shortenURL`
|
|
152
|
+
|
|
153
|
+
Whether to shorten the displayed URL.
|
|
154
|
+
|
|
155
|
+
- Type: `boolean`
|
|
156
|
+
- Default: `true`
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RehypeOGCardOptions } from "./util.js";
|
|
2
|
+
import type { Plugin } from "unified";
|
|
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 { RehypeOGCardOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { convertTextToAnchorElement, createOGCard, downloadImage, getOGData, isAnchorElement, isTextNode, isValidURL } from "./util.js";
|
|
11
|
+
import { isElement } from "hast-util-is-element";
|
|
12
|
+
import { visitParents } from "unist-util-visit-parents";
|
|
13
|
+
const DEFAULT_OPTIONS = {
|
|
14
|
+
crawlerUserAgent:
|
|
15
|
+
// eslint-disable-next-line max-len
|
|
16
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
17
|
+
decoding: "async",
|
|
18
|
+
enableSameTextURLConversion: false,
|
|
19
|
+
excludeDomains: [],
|
|
20
|
+
loading: "lazy",
|
|
21
|
+
serverCache: true,
|
|
22
|
+
serverCachePath: "./public/rehype-og-card-cache/",
|
|
23
|
+
shortenURL: true
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Rehype plugin to create OG card from bare links.
|
|
27
|
+
* @param options Plugin options.
|
|
28
|
+
* @returns Transformer function.
|
|
29
|
+
*/
|
|
30
|
+
// eslint-disable-next-line max-lines-per-function
|
|
31
|
+
const rehypeOGCard = (options) => {
|
|
32
|
+
const mergedOptions = Object.assign(Object.assign({}, DEFAULT_OPTIONS), options);
|
|
33
|
+
/**
|
|
34
|
+
* Transform function to create OG card from bare links.
|
|
35
|
+
* @param tree Root node of the HAST tree.
|
|
36
|
+
*/
|
|
37
|
+
// eslint-disable-next-line max-lines-per-function
|
|
38
|
+
const transform = (tree) => __awaiter(void 0, void 0, void 0, function* () {
|
|
39
|
+
const linkCardPromises = [];
|
|
40
|
+
// eslint-disable-next-line max-statements, max-lines-per-function
|
|
41
|
+
visitParents(tree, ["element", "text"], (node, ancestors) => {
|
|
42
|
+
var _a;
|
|
43
|
+
let anchorNode = null;
|
|
44
|
+
if (node.type === "text" && isValidURL(node.value.trim())) {
|
|
45
|
+
anchorNode = convertTextToAnchorElement(node);
|
|
46
|
+
}
|
|
47
|
+
else if (mergedOptions.enableSameTextURLConversion &&
|
|
48
|
+
isAnchorElement(node) &&
|
|
49
|
+
isValidURL(node.properties.href)) {
|
|
50
|
+
const isBareLink =
|
|
51
|
+
// eslint-disable-next-line no-magic-numbers
|
|
52
|
+
node.children.length === 1 &&
|
|
53
|
+
isTextNode(node.children[0]) &&
|
|
54
|
+
node.children[0].value === node.properties.href;
|
|
55
|
+
if (!isBareLink)
|
|
56
|
+
return;
|
|
57
|
+
anchorNode = node;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// eslint-disable-next-line no-magic-numbers
|
|
63
|
+
if (!isElement(ancestors[ancestors.length - 1], "p"))
|
|
64
|
+
return;
|
|
65
|
+
const shouldSkip = ancestors.some((ancestor) => isElement(ancestor) && ["ul", "ol"].includes(ancestor.tagName));
|
|
66
|
+
if (shouldSkip)
|
|
67
|
+
return;
|
|
68
|
+
// eslint-disable-next-line no-magic-numbers
|
|
69
|
+
const isTheOnlyChild = ancestors[ancestors.length - 1].children.length === 1;
|
|
70
|
+
if (!isTheOnlyChild)
|
|
71
|
+
return;
|
|
72
|
+
const targetURL = new URL(anchorNode.properties.href);
|
|
73
|
+
if ((_a = mergedOptions.excludeDomains) === null || _a === void 0 ? void 0 : _a.includes(targetURL.hostname))
|
|
74
|
+
return;
|
|
75
|
+
// eslint-disable-next-line jsdoc/require-jsdoc, max-statements
|
|
76
|
+
const linkCardPromise = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
77
|
+
const OGData = yield getOGData(anchorNode.properties.href, mergedOptions.crawlerUserAgent);
|
|
78
|
+
if (!OGData)
|
|
79
|
+
return;
|
|
80
|
+
if (OGData.OGImageURL && mergedOptions.serverCache) {
|
|
81
|
+
const cachePath = yield downloadImage(OGData.OGImageURL, mergedOptions.serverCachePath, mergedOptions.crawlerUserAgent);
|
|
82
|
+
if (cachePath) {
|
|
83
|
+
OGData.OGImageURL = `/${cachePath}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (OGData.faviconURL && mergedOptions.serverCache) {
|
|
87
|
+
const cachePath = yield downloadImage(OGData.faviconURL, mergedOptions.serverCachePath, mergedOptions.crawlerUserAgent);
|
|
88
|
+
if (cachePath) {
|
|
89
|
+
OGData.faviconURL = `/${cachePath}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (mergedOptions.shortenURL) {
|
|
93
|
+
const shortenedURL = new URL(anchorNode.properties.href);
|
|
94
|
+
OGData.displayURL = shortenedURL.hostname;
|
|
95
|
+
}
|
|
96
|
+
const OGCard = createOGCard(OGData, mergedOptions);
|
|
97
|
+
// eslint-disable-next-line no-magic-numbers
|
|
98
|
+
const index = ancestors[ancestors.length - 1].children.indexOf(node);
|
|
99
|
+
// eslint-disable-next-line no-magic-numbers
|
|
100
|
+
if (index === -1)
|
|
101
|
+
return;
|
|
102
|
+
// eslint-disable-next-line no-magic-numbers
|
|
103
|
+
ancestors[ancestors.length - 1].children.splice(index, 1, ...OGCard);
|
|
104
|
+
});
|
|
105
|
+
linkCardPromises.push(linkCardPromise());
|
|
106
|
+
});
|
|
107
|
+
yield Promise.all(linkCardPromises);
|
|
108
|
+
});
|
|
109
|
+
return transform;
|
|
110
|
+
};
|
|
111
|
+
export default rehypeOGCard;
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Element, ElementContent, RootContent, Text } from "hast";
|
|
2
|
+
interface RehypeOGCardOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Domains to exclude from conversion.
|
|
5
|
+
*/
|
|
6
|
+
excludeDomains?: string[];
|
|
7
|
+
/**
|
|
8
|
+
* `decoding` attribute of the image element.
|
|
9
|
+
* @default "async"
|
|
10
|
+
*/
|
|
11
|
+
decoding?: HTMLImageElement["decoding"];
|
|
12
|
+
/**
|
|
13
|
+
* `loading` attribute of the image element.
|
|
14
|
+
* @default "lazy"
|
|
15
|
+
*/
|
|
16
|
+
loading?: HTMLImageElement["loading"];
|
|
17
|
+
/**
|
|
18
|
+
* Whether to shorten the displayed URL.
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
21
|
+
shortenURL?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether to enable converting the links
|
|
24
|
+
* that have the same URL and text (e.g., `[https://example.com](https://example.com)`).
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
enableSameTextURLConversion?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether to cache the OG data on the server.
|
|
30
|
+
* @default true
|
|
31
|
+
*/
|
|
32
|
+
serverCache?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Path to save the server cache.
|
|
35
|
+
* @default "./public/rehype-og-card-cache/"
|
|
36
|
+
*/
|
|
37
|
+
serverCachePath?: string;
|
|
38
|
+
/**
|
|
39
|
+
* User agent to use for fetching the OG metadata and images.
|
|
40
|
+
* @default "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
41
|
+
*/
|
|
42
|
+
crawlerUserAgent?: string;
|
|
43
|
+
}
|
|
44
|
+
interface AnchorElement extends Element {
|
|
45
|
+
tagName: "a";
|
|
46
|
+
properties: {
|
|
47
|
+
href: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Check if the node is a text node.
|
|
52
|
+
* @param node Node to check.
|
|
53
|
+
* @returns `true` if the node is a text node, `false` otherwise.
|
|
54
|
+
*/
|
|
55
|
+
declare const isTextNode: (node: ElementContent) => node is Text;
|
|
56
|
+
/**
|
|
57
|
+
* Check if the node is an anchor element.
|
|
58
|
+
* @param node Node to check.
|
|
59
|
+
* @returns `true` if the node is an anchor element, `false` otherwise.
|
|
60
|
+
*/
|
|
61
|
+
declare const isAnchorElement: (node: unknown) => node is AnchorElement;
|
|
62
|
+
/**
|
|
63
|
+
* Check if the URL is valid.
|
|
64
|
+
* @param url URL to check.
|
|
65
|
+
* @returns `true` if the URL is valid, `false` otherwise.
|
|
66
|
+
*/
|
|
67
|
+
declare const isValidURL: (url: string) => boolean;
|
|
68
|
+
interface OGCardData {
|
|
69
|
+
url: string;
|
|
70
|
+
title: string;
|
|
71
|
+
description?: string;
|
|
72
|
+
displayURL: string;
|
|
73
|
+
faviconURL?: string;
|
|
74
|
+
OGImageURL?: string;
|
|
75
|
+
OGImageAlt?: string;
|
|
76
|
+
OGImageWidth?: number;
|
|
77
|
+
OGImageHeight?: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get OG card data from given URL.
|
|
81
|
+
* @param url URL to get OG card data.
|
|
82
|
+
* @param userAgent User agent to use for fetching.
|
|
83
|
+
* @returns OG card data.
|
|
84
|
+
*/
|
|
85
|
+
declare const getOGData: (url: string, userAgent: string) => Promise<OGCardData | null>;
|
|
86
|
+
/**
|
|
87
|
+
* Create OG card element from given data.
|
|
88
|
+
* @param data Data to create OG card.
|
|
89
|
+
* @param options Options to create OG card.
|
|
90
|
+
* @returns OG card element.
|
|
91
|
+
*/
|
|
92
|
+
declare const createOGCard: (data: OGCardData, options: Required<{
|
|
93
|
+
loading: RehypeOGCardOptions["loading"];
|
|
94
|
+
decoding: RehypeOGCardOptions["decoding"];
|
|
95
|
+
}>) => RootContent[];
|
|
96
|
+
/**
|
|
97
|
+
* Convert text node to anchor element. **This function does not check if the text is a valid URL.**
|
|
98
|
+
* @param text Text node to convert.
|
|
99
|
+
* @returns Anchor element.
|
|
100
|
+
*/
|
|
101
|
+
declare const convertTextToAnchorElement: (text: Text) => AnchorElement;
|
|
102
|
+
/**
|
|
103
|
+
* Download image from given URL.
|
|
104
|
+
* @param url URL to download image.
|
|
105
|
+
* @param directly Path to save the image.
|
|
106
|
+
* @param userAgent User agent to use for fetching.
|
|
107
|
+
* @returns Path to the downloaded image.
|
|
108
|
+
*/
|
|
109
|
+
declare const downloadImage: (url: string, directly: string, userAgent: string) => Promise<string | null>;
|
|
110
|
+
export { AnchorElement, RehypeOGCardOptions, OGCardData, isTextNode, isAnchorElement, isValidURL, getOGData, createOGCard, convertTextToAnchorElement, downloadImage };
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { createHash } from "crypto";
|
|
11
|
+
import { fromHtml } from "hast-util-from-html";
|
|
12
|
+
import fs from "fs/promises";
|
|
13
|
+
import { isElement } from "hast-util-is-element";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import sanitizeHtml from "sanitize-html";
|
|
16
|
+
import scraper from "open-graph-scraper";
|
|
17
|
+
/**
|
|
18
|
+
* Check if the node is a text node.
|
|
19
|
+
* @param node Node to check.
|
|
20
|
+
* @returns `true` if the node is a text node, `false` otherwise.
|
|
21
|
+
*/
|
|
22
|
+
const isTextNode = (node) => Boolean(node) && typeof node === "object" && "type" in node && node.type === "text";
|
|
23
|
+
/**
|
|
24
|
+
* Check if the node is an anchor element.
|
|
25
|
+
* @param node Node to check.
|
|
26
|
+
* @returns `true` if the node is an anchor element, `false` otherwise.
|
|
27
|
+
*/
|
|
28
|
+
const isAnchorElement = (node) => Boolean(node) && isElement(node, "a") && "href" in node.properties && typeof node.properties.href === "string";
|
|
29
|
+
/**
|
|
30
|
+
* Check if the URL is valid.
|
|
31
|
+
* @param url URL to check.
|
|
32
|
+
* @returns `true` if the URL is valid, `false` otherwise.
|
|
33
|
+
*/
|
|
34
|
+
const isValidURL = (url) => {
|
|
35
|
+
try {
|
|
36
|
+
new URL(url);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (_a) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Get OG card data from given URL.
|
|
45
|
+
* @param url URL to get OG card data.
|
|
46
|
+
* @param userAgent User agent to use for fetching.
|
|
47
|
+
* @returns OG card data.
|
|
48
|
+
*/
|
|
49
|
+
const getOGData = (url, userAgent) => __awaiter(void 0, void 0, void 0, function* () {
|
|
50
|
+
try {
|
|
51
|
+
const { result } = yield scraper({
|
|
52
|
+
fetchOptions: {
|
|
53
|
+
headers: {
|
|
54
|
+
"user-agent": userAgent
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
url
|
|
58
|
+
});
|
|
59
|
+
const OGImage = result.ogImage ? result.ogImage[0] : null;
|
|
60
|
+
const faviconURL = result.favicon
|
|
61
|
+
? `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`
|
|
62
|
+
: // eslint-disable-next-line no-undefined
|
|
63
|
+
undefined;
|
|
64
|
+
return {
|
|
65
|
+
OGImageAlt: OGImage === null || OGImage === void 0 ? void 0 : OGImage.alt,
|
|
66
|
+
OGImageHeight: OGImage === null || OGImage === void 0 ? void 0 : OGImage.height,
|
|
67
|
+
OGImageURL: OGImage === null || OGImage === void 0 ? void 0 : OGImage.url,
|
|
68
|
+
OGImageWidth: OGImage === null || OGImage === void 0 ? void 0 : OGImage.width,
|
|
69
|
+
// eslint-disable-next-line no-undefined
|
|
70
|
+
description: result.ogDescription ? sanitizeHtml(result.ogDescription) : undefined,
|
|
71
|
+
displayURL: url,
|
|
72
|
+
faviconURL,
|
|
73
|
+
title: result.ogTitle ? sanitizeHtml(result.ogTitle) : url,
|
|
74
|
+
url
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.error("[rehype-og-card] Error fetching OG data:", error);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* Sanitize HTML.
|
|
85
|
+
* @param html HTML to sanitize.
|
|
86
|
+
* @returns Sanitized HTML.
|
|
87
|
+
*/
|
|
88
|
+
const sanitize = (html) => {
|
|
89
|
+
const options = {
|
|
90
|
+
allowedAttributes: {},
|
|
91
|
+
allowedTags: [],
|
|
92
|
+
disallowedTagsMode: "recursiveEscape"
|
|
93
|
+
// eslint-disable-next-line no-magic-numbers
|
|
94
|
+
};
|
|
95
|
+
return sanitizeHtml(html, options);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Create OG card element from given data.
|
|
99
|
+
* @param data Data to create OG card.
|
|
100
|
+
* @param options Options to create OG card.
|
|
101
|
+
* @returns OG card element.
|
|
102
|
+
*/
|
|
103
|
+
const createOGCard = (data, options) => {
|
|
104
|
+
data.title = sanitize(data.title);
|
|
105
|
+
if (data.description) {
|
|
106
|
+
data.description = sanitize(data.description);
|
|
107
|
+
}
|
|
108
|
+
const imageContainerElement = data.OGImageURL
|
|
109
|
+
? `
|
|
110
|
+
<div class="rlc-image-container">
|
|
111
|
+
<img class="rlc-image"
|
|
112
|
+
loading="${options.loading}"
|
|
113
|
+
decoding="${options.decoding}"
|
|
114
|
+
src="${data.OGImageURL}" alt="${data.OGImageAlt || data.OGImageURL}"
|
|
115
|
+
${data.OGImageWidth ? `width="${data.OGImageWidth}"` : ""}
|
|
116
|
+
${data.OGImageHeight ? `height="${data.OGImageHeight}"` : ""} >
|
|
117
|
+
</div>`
|
|
118
|
+
: "";
|
|
119
|
+
const descriptionElement = data.description
|
|
120
|
+
? `
|
|
121
|
+
<div class="rlc-description">${data.description}</div>`
|
|
122
|
+
: "";
|
|
123
|
+
// The `loading` attribute must be specified before the `src` attribute because of a bug in Firefox.
|
|
124
|
+
// See https://bugzil.la/1647077 for more information.
|
|
125
|
+
const card = `
|
|
126
|
+
<a class="rlc-container" href="${data.url}">
|
|
127
|
+
<div class="rlc-info">
|
|
128
|
+
<div class="rlc-title">${data.title}</div>${descriptionElement}
|
|
129
|
+
<div class="rlc-url-container">
|
|
130
|
+
<img class="rlc-favicon"
|
|
131
|
+
loading="${options.loading}"
|
|
132
|
+
decoding="${options.decoding}"
|
|
133
|
+
src="${data.faviconURL}" alt="favicon" width="16" height="16">
|
|
134
|
+
<span class="rlc-url">${data.displayURL}</span>
|
|
135
|
+
</div>
|
|
136
|
+
</div>${imageContainerElement}
|
|
137
|
+
</a>
|
|
138
|
+
`.trim();
|
|
139
|
+
return fromHtml(card, { fragment: true }).children;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Convert text node to anchor element. **This function does not check if the text is a valid URL.**
|
|
143
|
+
* @param text Text node to convert.
|
|
144
|
+
* @returns Anchor element.
|
|
145
|
+
*/
|
|
146
|
+
const convertTextToAnchorElement = (text) => ({
|
|
147
|
+
children: [text],
|
|
148
|
+
properties: {
|
|
149
|
+
href: text.value.trim()
|
|
150
|
+
},
|
|
151
|
+
tagName: "a",
|
|
152
|
+
type: "element"
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* Download image from given URL.
|
|
156
|
+
* @param url URL to download image.
|
|
157
|
+
* @param directly Path to save the image.
|
|
158
|
+
* @param userAgent User agent to use for fetching.
|
|
159
|
+
* @returns Path to the downloaded image.
|
|
160
|
+
*/
|
|
161
|
+
// eslint-disable-next-line max-statements
|
|
162
|
+
const downloadImage = (url, directly, userAgent) => __awaiter(void 0, void 0, void 0, function* () {
|
|
163
|
+
if (!isValidURL(url)) {
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.error("[rehype-og-card] Invalid thumbnail URL:", url);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const response = yield fetch(url, {
|
|
170
|
+
headers: {
|
|
171
|
+
"user-agent": userAgent
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
const arrayBuffer = yield response.arrayBuffer();
|
|
175
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
176
|
+
const hash = createHash("sha256").update(url).digest("hex");
|
|
177
|
+
const filename = hash + path.extname(new URL(url).pathname);
|
|
178
|
+
const savePath = path.posix.join(directly, filename);
|
|
179
|
+
yield fs.mkdir(directly, { recursive: true });
|
|
180
|
+
yield fs.writeFile(savePath, buffer);
|
|
181
|
+
return savePath;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
// eslint-disable-next-line no-console
|
|
185
|
+
console.error("[rehype-og-card] Error downloading image:", error);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
export { isTextNode, isAnchorElement, isValidURL, getOGData, createOGCard, convertTextToAnchorElement, downloadImage };
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rehype-og-card",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rehype plugin to convert text links to link cards",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"./dist"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"provenance": true
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "npx vitest",
|
|
16
|
+
"lint": "npx eslint ./src/**/*.ts",
|
|
17
|
+
"format": "npx prettier --write ./src/**/*",
|
|
18
|
+
"format:check": "npx prettier --check ./src/**/*",
|
|
19
|
+
"build": "npx tsc",
|
|
20
|
+
"version": "npm run build && git add .",
|
|
21
|
+
"ci:version": "changeset version && npm run version",
|
|
22
|
+
"ci:publish": "npm run build && changeset publish"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Robot-Inventor/rehype-og-card.git"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"markdown",
|
|
30
|
+
"remark",
|
|
31
|
+
"ogp",
|
|
32
|
+
"unified",
|
|
33
|
+
"rehype",
|
|
34
|
+
"rehype-plugin"
|
|
35
|
+
],
|
|
36
|
+
"author": "Robot-Inventor",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/Robot-Inventor/rehype-og-card/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/Robot-Inventor/rehype-og-card#readme",
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@changesets/changelog-github": "^0.5.0",
|
|
44
|
+
"@changesets/cli": "^2.27.7",
|
|
45
|
+
"@robot-inventor/eslint-config": "^0.2.21",
|
|
46
|
+
"@types/node": "^20.14.11",
|
|
47
|
+
"@types/sanitize-html": "^2.11.0",
|
|
48
|
+
"eslint": "^9.7.0",
|
|
49
|
+
"hast-util-to-html": "^9.0.1",
|
|
50
|
+
"prettier": "^3.3.3",
|
|
51
|
+
"rehype-stringify": "^10.0.0",
|
|
52
|
+
"remark-parse": "^11.0.0",
|
|
53
|
+
"remark-rehype": "^11.1.0",
|
|
54
|
+
"typescript": "^5.5.3",
|
|
55
|
+
"unified": "^11.0.5",
|
|
56
|
+
"vitest": "^2.0.3"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@types/hast": "^3.0.4",
|
|
60
|
+
"hast-util-from-html": "^2.0.1",
|
|
61
|
+
"hast-util-is-element": "^3.0.0",
|
|
62
|
+
"open-graph-scraper": "^6.6.3",
|
|
63
|
+
"sanitize-html": "^2.13.0",
|
|
64
|
+
"unist-util-visit-parents": "^6.0.1"
|
|
65
|
+
}
|
|
66
|
+
}
|