├── example
├── src
│ ├── components
│ │ └── .keep
│ ├── styles
│ │ ├── index.css
│ │ └── remark-link-card.css
│ ├── content
│ │ ├── config.ts
│ │ └── demo
│ │ │ └── example.md
│ ├── pages
│ │ └── index.astro
│ ├── layouts
│ │ └── Layout.astro
│ └── assets
│ │ ├── background.svg
│ │ └── astro.svg
├── tsconfig.json
├── .gitignore
├── package.json
├── astro.config.mjs
├── README.md
└── public
│ └── favicon.svg
├── .gitignore
├── tsconfig.build.json
├── .npmignore
├── .github
├── release.yml
├── renovate.json
└── workflows
│ ├── release.yml
│ └── ci.yml
├── tsconfig.json
├── biome.json
├── LICENSE
├── package.json
├── README.md
└── src
├── index.ts
└── index.test.ts
/example/src/components/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
3 | public/
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/example/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @plugin "@tailwindcss/typography";
3 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strict",
3 | "include": [".astro/types.d.ts", "**/*"],
4 | "exclude": ["dist"]
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "src/**/*.spec.ts",
5 | "src/**/*.test.ts",
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .github/
2 | example/
3 | node_modules/
4 | public/
5 | src/
6 | .gitignore
7 | biome.json
8 | tsconfig.json
9 | tsconfig.build.json
10 |
--------------------------------------------------------------------------------
/example/src/content/config.ts:
--------------------------------------------------------------------------------
1 | import { defineCollection, z } from 'astro:content';
2 |
3 | const entry = defineCollection({
4 | type: 'content',
5 | schema: z.object({
6 | title: z.string(),
7 | description: z.string(),
8 | }),
9 | });
10 |
11 | export const collections = { 'demo': entry };
12 |
--------------------------------------------------------------------------------
/example/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getCollection } from 'astro:content';
3 | import Layout from '../layouts/Layout.astro';
4 | import '../styles/remark-link-card.css';
5 |
6 | const blog = await getCollection("demo");
7 | const example = blog.find(post => post.slug === "example")!;
8 | const { Content } = await example.render();
9 | ---
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | categories:
3 | - title: 🎉 Features
4 | labels:
5 | - feature
6 | - title: ✨ Refinements
7 | labels:
8 | - refinement
9 | - title: 🐛 Bug Fixes
10 | labels:
11 | - bug
12 | - title: 🏠 Maintenances
13 | labels:
14 | - maintenance
15 | - title: 📝 Documentation
16 | labels:
17 | - documentation
18 | - title: Others
19 | labels:
20 | - "*"
21 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 |
4 | # generated types
5 | .astro/
6 |
7 | # dependencies
8 | node_modules/
9 |
10 | # logs
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
23 | # jetbrains setting folder
24 | .idea/
25 |
26 | # VSCode setting folder
27 | .vscode/
28 |
29 | # cache
30 | public/remark-link-card-plus/
31 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remark-link-card-plus-example",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "astro dev",
7 | "build": "astro build",
8 | "preview": "astro preview",
9 | "astro": "astro"
10 | },
11 | "dependencies": {
12 | "@tailwindcss/typography": "^0.5.19",
13 | "@tailwindcss/vite": "^4.1.17",
14 | "astro": "^5.16.2",
15 | "remark-link-card-plus": "file:../",
16 | "tailwindcss": "^4.1.17"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "declaration": true,
5 | "esModuleInterop": true,
6 | "experimentalDecorators": true,
7 | "jsx": "react-jsx",
8 | "lib": ["es2015", "dom"],
9 | "module": "es2020",
10 | "moduleResolution": "node",
11 | "outDir": "./build",
12 | "sourceMap": true,
13 | "strict": true,
14 | "target": "es5",
15 | },
16 | "include": ["src/**/*.ts"],
17 | "exclude": ["node_modules"]
18 | }
19 |
--------------------------------------------------------------------------------
/example/src/layouts/Layout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import "../styles/index.css";
3 | ---
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | remark-link-card-plus Demo
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/example/astro.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { defineConfig } from 'astro/config';
3 | import tailwindcss from "@tailwindcss/vite";
4 | import remarkLinkCard from 'remark-link-card-plus';
5 |
6 | // https://astro.build/config
7 | export default defineConfig({
8 | vite: {
9 | plugins: [
10 | tailwindcss(),
11 | ],
12 | },
13 | markdown: {
14 | remarkPlugins: [
15 | [
16 | remarkLinkCard, {
17 | cache: true,
18 | shortenUrl: true,
19 | ignoreExtensions: [".mp4"],
20 | },
21 | ],
22 | ],
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:base"],
4 | "labels": ["maintenance"],
5 | "timezone": "Asia/Tokyo",
6 | "automerge": false,
7 | "rangeStrategy": "bump",
8 | "dependencyDashboard": false,
9 | "branchConcurrentLimit": 0,
10 | "prHourlyLimit": 0,
11 | "packageRules": [
12 | {
13 | "matchDepTypes": ["devDependencies"],
14 | "groupName": "devDependencies"
15 | },
16 | {
17 | "matchFileNames": ["example/**"],
18 | "groupName": "example"
19 | },
20 | {
21 | "matchManagers": ["github-actions"],
22 | "groupName": "GitHub Actions"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # remark-link-card-plus Example
2 |
3 | This directory contains an example demo page showcasing the functionality of `remark-link-card-plus`.
4 |
5 | ## Demo Page
6 |
7 | You can view the live demo page [here](https://remark-link-card-plus.pages.dev/).
8 |
9 | ## Running Locally
10 |
11 | The example page is built using [Astro](https://astro.build/). To run it locally:
12 |
13 | 1. Install dependencies:
14 |
15 | ```bash
16 | npm install
17 | ```
18 |
19 | 2. Start the development server:
20 |
21 | ```bash
22 | npm run dev
23 | ```
24 |
25 | 3. Open your browser and navigate to the URL provided in the terminal.
26 |
27 | Feel free to modify the example files to test or customize `remark-link-card-plus`!
28 |
--------------------------------------------------------------------------------
/example/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
3 | "vcs": {
4 | "enabled": false,
5 | "clientKind": "git",
6 | "useIgnoreFile": false
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "includes": ["**"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space",
15 | "indentWidth": 2,
16 | "lineWidth": 80
17 | },
18 | "assist": { "actions": { "source": { "organizeImports": "on" } } },
19 | "linter": {
20 | "enabled": true,
21 | "rules": {
22 | "recommended": true,
23 | "correctness": {
24 | "noUnusedImports": "error",
25 | "noUnusedVariables": "error",
26 | "useHookAtTopLevel": "error"
27 | },
28 | "suspicious": {
29 | "noConsole": { "level": "error", "options": { "allow": ["error"] } }
30 | }
31 | }
32 | },
33 | "javascript": {
34 | "formatter": {
35 | "quoteStyle": "double"
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | release:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: checkout
13 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
14 | - name: Setup node
15 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
16 | with:
17 | node-version: '24.11.1'
18 | cache: npm
19 | registry-url: 'https://registry.npmjs.org'
20 | - name: Install npm packages
21 | run: npm ci
22 | - name: build
23 | run: npm run build
24 | - name: Publish to npm
25 | run: npm publish --access public
26 | env:
27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
28 | - name: Generate release note
29 | uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
30 | with:
31 | generate_release_notes: true
32 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - develop
8 | pull_request:
9 |
10 | jobs:
11 | ci:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
15 | - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
16 | with:
17 | node-version: 24.11.1
18 | cache: npm
19 | - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
20 | id: node_modules_cache_id
21 | env:
22 | cache-name: cache-node-modules
23 | with:
24 | path: "**/node_modules"
25 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
26 | - if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
27 | name: Install npm packages
28 | run: npm install
29 | - name: Run Test
30 | run: npm run test
31 | - name: Run Lint and Formatter
32 | run: npm run check:ci
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 okaryo
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.
22 |
--------------------------------------------------------------------------------
/example/src/styles/remark-link-card.css:
--------------------------------------------------------------------------------
1 | @reference "./index.css";
2 |
3 | .remark-link-card-plus__container {
4 | @apply mb-4;
5 | }
6 |
7 | .remark-link-card-plus__card {
8 | @apply h-32 flex bg-white overflow-hidden rounded-xl border border-slate-300 hover:bg-slate-100 hover:border-slate-500 transition-colors !no-underline;
9 | }
10 |
11 | .remark-link-card-plus__main {
12 | @apply flex flex-col flex-1 p-4;
13 | }
14 |
15 | .remark-link-card-plus__content {
16 | }
17 |
18 | .remark-link-card-plus__title {
19 | @apply text-lg font-semibold leading-6 line-clamp-2 text-gray-900 hover:!text-gray-900;
20 | }
21 |
22 | .remark-link-card-plus__description {
23 | @apply mt-1 text-sm text-gray-500 line-clamp-1;
24 | }
25 |
26 | .remark-link-card-plus__meta {
27 | @apply flex items-center mt-auto;
28 | }
29 |
30 | .remark-link-card-plus__favicon {
31 | @apply !my-0 mr-1 h-4 w-4;
32 | }
33 |
34 | .remark-link-card-plus__url {
35 | @apply text-xs text-gray-600;
36 | }
37 |
38 | .remark-link-card-plus__thumbnail {
39 | @apply h-32 w-1/3 md:max-w-64;
40 | }
41 |
42 | .remark-link-card-plus__image {
43 | @apply h-full w-full !my-0 object-cover;
44 | }
45 |
--------------------------------------------------------------------------------
/example/src/assets/background.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remark-link-card-plus",
3 | "version": "0.7.2",
4 | "description": "Remark plugin to convert text links to link cards",
5 | "main": "./build/index.js",
6 | "types": "./build/index.d.ts",
7 | "type": "module",
8 | "scripts": {
9 | "dev": "tsc -w",
10 | "build": "tsc -p tsconfig.build.json",
11 | "test": "vitest run",
12 | "check": "biome check ./src",
13 | "check:fix": "biome check --write --unsafe ./src",
14 | "check:ci": "biome ci ./src",
15 | "typecheck": "tsc --noEmit",
16 | "prepublishOnly": "npm run build"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/okaryo/remark-link-card-plus.git"
21 | },
22 | "engines": {
23 | "node": ">=20"
24 | },
25 | "keywords": [
26 | "unified",
27 | "remark",
28 | "remark-plugin",
29 | "markdown",
30 | "link",
31 | "card"
32 | ],
33 | "author": "okaryo",
34 | "license": "MIT",
35 | "bugs": {
36 | "url": "https://github.com/okaryo/remark-link-card-plus/issues"
37 | },
38 | "homepage": "https://github.com/okaryo/remark-link-card-plus#readme",
39 | "devDependencies": {
40 | "@biomejs/biome": "2.3.8",
41 | "@remark-embedder/core": "^3.0.3",
42 | "@types/node": "^24.10.1",
43 | "@types/sanitize-html": "^2.16.0",
44 | "msw": "^2.12.3",
45 | "remark": "^15.0.1",
46 | "typescript": "^5.9.3",
47 | "vitest": "^4.0.14"
48 | },
49 | "dependencies": {
50 | "file-type": "^21.1.1",
51 | "open-graph-scraper": "^6.11.0",
52 | "sanitize-html": "^2.17.0",
53 | "unified": "^11.0.5",
54 | "unist-util-visit": "^5.0.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/example/src/assets/astro.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/example/src/content/demo/example.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Remark Link Card Example"
3 | description: "Demonstration of how the remark-link-card-plus plugin handles various types of links."
4 | ---
5 |
6 | # remark-link-card-plus Demo
7 |
8 | This page demonstrates how `remark-link-card-plus` converts links into cards or leaves them as plain text based on context.
9 |
10 | ## Styling the Link Cards
11 |
12 | By default, `remark-link-card-plus` does not apply any styles to the generated link cards. You can fully customize the appearance by defining your own CSS classes. Below is the TailwindCSS applied to the link cards on this demo page as an example:
13 |
14 |
15 | Click to view the CSS styles used for link cards
16 |
17 | ```css
18 | @reference "./index.css";
19 |
20 | .remark-link-card-plus__container {
21 | @apply mb-4;
22 | }
23 |
24 | .remark-link-card-plus__card {
25 | @apply h-32 flex bg-white overflow-hidden rounded-xl border border-slate-300 hover:bg-slate-100 hover:border-slate-500 transition-colors !no-underline;
26 | }
27 |
28 | .remark-link-card-plus__main {
29 | @apply flex flex-col flex-1 p-4;
30 | }
31 |
32 | .remark-link-card-plus__content {
33 | }
34 |
35 | .remark-link-card-plus__title {
36 | @apply text-lg font-semibold leading-6 line-clamp-2 text-gray-900 hover:!text-gray-900;
37 | }
38 |
39 | .remark-link-card-plus__description {
40 | @apply mt-1 text-sm text-gray-500 line-clamp-1;
41 | }
42 |
43 | .remark-link-card-plus__meta {
44 | @apply flex items-center mt-auto;
45 | }
46 |
47 | .remark-link-card-plus__favicon {
48 | @apply !my-0 mr-1 h-4 w-4;
49 | }
50 |
51 | .remark-link-card-plus__url {
52 | @apply text-xs text-gray-600;
53 | }
54 |
55 | .remark-link-card-plus__thumbnail {
56 | @apply h-32 w-1/3 md:max-w-64;
57 | }
58 |
59 | .remark-link-card-plus__image {
60 | @apply h-full w-full !my-0 object-cover;
61 | }
62 | ```
63 |
64 |
65 |
66 | Feel free to adjust or replace this CSS with your own styles to suit your needs.
67 |
68 | ---
69 |
70 | ## ✅ Links Converted to Cards
71 |
72 | ### 1. Bare Link on Its Own Line
73 |
74 | Bare links written on their own line will be converted to a link card.
75 |
76 | Example:
77 |
78 | ```markdown
79 | https://github.com
80 | ```
81 |
82 | Output:
83 |
84 | https://github.com
85 |
86 | ---
87 |
88 | ### 2. `[text](url)` Format with Matching Text and URL
89 |
90 | Links where the text and URL are identical will be converted to a link card.
91 |
92 | Example:
93 |
94 | ```markdown
95 | [https://github.com](https://github.com)
96 | ```
97 |
98 | Output:
99 |
100 | [https://github.com](https://github.com)
101 |
102 | ---
103 |
104 | ### 3. Links Ignored by Extension
105 |
106 | If you configure `ignoreExtensions: [".mp4"]`, links to files with these extensions will not be converted to cards.
107 |
108 | Example:
109 |
110 | ```markdown
111 | https://example.com/video.mp4
112 | ```
113 |
114 | Output:
115 |
116 | https://example.com/video.mp4
117 |
118 | ---
119 |
120 | ## ❌ Links Not Converted to Cards
121 |
122 | ### 1. Link with Additional Text on the Same Line
123 |
124 | If a link is on the same line as additional text, it will not be converted into a card.
125 |
126 | Example:
127 |
128 | ```markdown
129 | Visit the GitHub page here: https://github.com.
130 | ```
131 |
132 | Output:
133 |
134 | Visit the GitHub page here: https://github.com.
135 |
136 | ---
137 |
138 | ### 2. Multiple Links on a Single Line
139 |
140 | If there are multiple links on the same line, none will be converted into cards.
141 |
142 | Example:
143 |
144 | ```markdown
145 | https://github.com https://example.com
146 | ```
147 |
148 | Output:
149 |
150 | https://github.com https://example.com
151 |
152 | ---
153 |
154 | ### 3. Links Inside a List
155 |
156 | Links inside list items will not be converted into cards.
157 |
158 | Example:
159 |
160 | ```markdown
161 | - [GitHub](https://github.com)
162 | - [Example](https://example.com)
163 | ```
164 |
165 | Output:
166 |
167 | - [GitHub](https://github.com)
168 | - [Example](https://example.com)
169 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # remark-link-card-plus
2 |
3 | [](https://github.com/okaryo/remark-link-card-plus/actions/workflows/ci.yml)
4 | [](https://www.npmjs.com/package/remark-link-card-plus)
5 |
6 | [remark](https://github.com/remarkjs/remark) plugin to convert text links to link cards, building upon and improving [remark-link-card](https://github.com/gladevise/remark-link-card).
7 |
8 | You can see it in action on the [demo page](https://remark-link-card-plus.pages.dev/).
9 |
10 | ## Features
11 |
12 | `remark-link-card-plus` is a fork of the original `remark-link-card` with the following changes:
13 |
14 | ### Differences from the original:
15 | * **TypeScript support**: Fully rewritten in TypeScript for improved type safety and developer experience.
16 | * **Target blank**: Links in link cards now open in a new tab using `target="_blank"`.
17 | * **No link cards in lists**: Links inside list items (`listItem`) are not converted into link cards.
18 | * **Thumbnail position customization**: Select whether the thumbnail is displayed on the left or right of the card.
19 | * **Optional image and favicon display**: Added `noThumbnail` and `noFavicon` options to hide thumbnails and favicons from link cards.
20 | * **OG data transformer**: The `ogTransformer` option allows customization of Open Graph data such as the title, description, favicon, and image before rendering the link card.
21 | * **Ignore by extension**: The `ignoreExtensions` option allows you to skip link card conversion for URLs with specific file extensions (e.g., `.mp4`, `.pdf`).
22 |
23 | ### Retained features:
24 | * **Options support**:
25 | * `cache`: Cache images for faster loading and local storage.
26 | * `shortenUrl`: Display only the hostname of URLs in link cards.
27 | * **Customizable styling**: Cards can be styled freely using provided class names (note that class names have been slightly updated).
28 |
29 | ## Install
30 |
31 | ```sh
32 | npm i remark-link-card-plus
33 | ```
34 |
35 | ## Usage
36 |
37 | ### Basic Example
38 |
39 | ```js
40 | import { remark } from "remark";
41 | import remarkLinkCard from "remark-link-card-plus";
42 |
43 | const exampleMarkdown = `
44 | # Example Markdown
45 |
46 | ## Link Card Demo
47 |
48 | Bare links like this:
49 |
50 | https://github.com
51 |
52 | will be converted into a link card.
53 |
54 | Inline links like [GitHub](https://github.com) will **not** be converted.
55 |
56 | Links to files like https://example.com/video.mp4 can be ignored using the `ignoreExtensions` option.
57 | `;
58 |
59 | (async () => {
60 | const result = await remark()
61 | .use(remarkLinkCard, { cache: true, shortenUrl: true, ignoreExtensions: ['.mp4', '.pdf'] })
62 | .process(exampleMarkdown);
63 |
64 | console.log(result.value);
65 | })();
66 | ```
67 |
68 | You can get converted result like this.
69 |
70 | ```md
71 | # Example Markdown
72 |
73 | ## Link Card Demo
74 |
75 | Bare links like this:
76 |
77 |
94 |
95 | will be converted into a link card.
96 |
97 | Inline links like [GitHub](https://github.com) will **not** be converted.
98 |
99 | Links to files like https://example.com/video.mp4 can be ignored using the `ignoreExtensions` option.
100 | ```
101 |
102 | ### Astro Example
103 |
104 | You can also use `remark-link-card-plus` in an [Astro](https://astro.build) project. Below is an example `astro.config.mjs` configuration:
105 |
106 | ```javascript
107 | // astro.config.mjs
108 | import { defineConfig } from 'astro/config';
109 | import remarkLinkCard from 'remark-link-card-plus';
110 |
111 | export default defineConfig({
112 | markdown: {
113 | remarkPlugins: [
114 | [
115 | remarkLinkCard, {
116 | cache: true,
117 | shortenUrl: true,
118 | thumbnailPosition: "right",
119 | noThumbnail: false,
120 | noFavicon: false,
121 | ignoreExtensions: ['.mp4', '.pdf'],
122 | ogTransformer: (og, url) => {
123 | if (url.hostname === 'github.com') {
124 | return { ...og, title: `GitHub: ${og.title}` };
125 | }
126 | if (og.title === og.description) {
127 | return { ...og, description: 'custom description' };
128 | }
129 | return og;
130 | }
131 | },
132 | ],
133 | ],
134 | },
135 | });
136 |
137 | // Here is minimal setup.
138 | export default defineConfig({
139 | markdown: {
140 | remarkPlugins: [remarkLinkCard],
141 | },
142 | });
143 | ```
144 |
145 | ## Options
146 |
147 | | Option | Type | Default | Description |
148 | |--------------|---------|---------|-----------------------------------------------------------------------------|
149 | | `cache` | boolean | `false` | Caches Open Graph images and favicons locally. Images are saved to `process.cwd()/public/remark-link-card-plus/` and paths start with `/remark-link-card-plus/`. This reduces server load on the linked site and improves build performance by avoiding redundant network requests. |
150 | | `shortenUrl` | boolean | `true` | Displays only the hostname of the URL in the link card instead of the full URL. |
151 | | `thumbnailPosition` | string | `right` | Specifies the position of the thumbnail in the card. Accepts `"left"` or `"right"`. |
152 | | `noThumbnail` | boolean | `false` | If `true`, does not display the Open Graph thumbnail image. The generated link card HTML will not contain an `
` tag for the thumbnail. |
153 | | `noFavicon` | boolean | `false` | If `true`, does not display the favicon in the link card. The generated link card HTML will not contain an `
` tag for the favicon. |
154 | | `ogTransformer` | `(og: OgData, url: URL) => OgData` | `undefined` | A callback to transform the Open Graph data before rendering. The function receives the original OG data and the URL being processed. `OgData` has the structure `{ title: string; description: string; faviconUrl?: string; imageUrl?: string }`. |
155 | | `ignoreExtensions` | string[] | `[]` | Skips link card conversion for URLs with the specified file extensions (e.g., `['.mp4', '.pdf']`). The original Markdown is left unchanged for these links. Matching is case-insensitive and only exact extension matches are ignored. |
156 |
157 | ## Styling
158 |
159 | Link cards can be styled using the following class names:
160 |
161 | ```css
162 | .remark-link-card-plus__container {}
163 |
164 | .remark-link-card-plus__card {}
165 |
166 | .remark-link-card-plus__main {}
167 |
168 | .remark-link-card-plus__content {}
169 |
170 | .remark-link-card-plus__title {}
171 |
172 | .remark-link-card-plus__description {}
173 |
174 | .remark-link-card-plus__meta {}
175 |
176 | .remark-link-card-plus__favicon {}
177 |
178 | .remark-link-card-plus__url {}
179 |
180 | .remark-link-card-plus__thumbnail {}
181 |
182 | .remark-link-card-plus__image {}
183 | ```
184 |
185 | Feel free to customize these styles as needed.
186 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createHash } from "node:crypto";
2 | import { access, mkdir, readdir, writeFile } from "node:fs/promises";
3 | import path from "node:path";
4 | import { fileTypeFromBuffer } from "file-type";
5 | import type { Html, Link, Root, Text } from "mdast";
6 | import client from "open-graph-scraper";
7 | import type { ErrorResult, OgObject } from "open-graph-scraper/types/lib/types";
8 | import sanitizeHtml from "sanitize-html";
9 | import type { Plugin } from "unified";
10 | import { visit } from "unist-util-visit";
11 |
12 | const defaultSaveDirectory = "public";
13 | const defaultOutputDirectory = "/remark-link-card-plus/";
14 |
15 | export type OgData = {
16 | title: string;
17 | description: string;
18 | faviconUrl?: string;
19 | imageUrl?: string;
20 | };
21 |
22 | type Options = {
23 | cache?: boolean;
24 | shortenUrl?: boolean;
25 | thumbnailPosition?: "right" | "left";
26 | noThumbnail?: boolean;
27 | noFavicon?: boolean;
28 | ogTransformer?: (og: OgData, url: URL) => OgData;
29 | ignoreExtensions?: string[];
30 | };
31 |
32 | type LinkCardData = {
33 | title: string;
34 | description: string;
35 | faviconUrl: string;
36 | ogImageUrl?: string;
37 | displayUrl: string;
38 | url: URL;
39 | };
40 |
41 | const defaultOptions: Options = {
42 | cache: false,
43 | shortenUrl: true,
44 | thumbnailPosition: "right",
45 | noThumbnail: false,
46 | noFavicon: false,
47 | ignoreExtensions: [],
48 | };
49 |
50 | const remarkLinkCard: Plugin<[Options], Root> =
51 | (userOptions: Options) => async (tree) => {
52 | const options = { ...defaultOptions, ...userOptions };
53 | const transformers: (() => Promise)[] = [];
54 |
55 | const shouldIgnoreUrl = (url: string): boolean => {
56 | if (!options.ignoreExtensions?.length) return false;
57 | try {
58 | const urlObj = new URL(url);
59 | const pathname = urlObj.pathname.toLowerCase();
60 | return options.ignoreExtensions.some((ext) =>
61 | pathname.endsWith(ext.toLowerCase()),
62 | );
63 | } catch (_) {
64 | return false;
65 | }
66 | };
67 |
68 | const addTransformer = (url: string, index: number) => {
69 | transformers.push(async () => {
70 | const data = await getLinkCardData(new URL(url), options);
71 | const linkCardNode = createLinkCardNode(data, options);
72 | if (index !== undefined) {
73 | tree.children.splice(index, 1, linkCardNode);
74 | }
75 | });
76 | };
77 |
78 | const isValidUrl = (value: string): boolean => {
79 | if (!URL.canParse(value)) return false;
80 |
81 | const basicUrlPattern = /^(https?:\/\/[^\s/$.?#].[^\s]*)$/i;
82 | if (!basicUrlPattern.test(value)) return false;
83 |
84 | return true;
85 | };
86 |
87 | visit(tree, "paragraph", (paragraph, index, parent) => {
88 | if (parent?.type !== "root" || paragraph.children.length !== 1) return;
89 |
90 | let unmatchedLink: Link;
91 | let processedUrl: string;
92 |
93 | visit(paragraph, "link", (linkNode) => {
94 | const hasOneChildText =
95 | linkNode.children.length === 1 &&
96 | linkNode.children[0].type === "text";
97 | if (!hasOneChildText) return;
98 |
99 | const childText = linkNode.children[0] as Text;
100 | if (!isSameUrlValue(linkNode.url, childText.value)) {
101 | unmatchedLink = linkNode;
102 | return;
103 | }
104 |
105 | if (index !== undefined) {
106 | processedUrl = linkNode.url;
107 | if (!shouldIgnoreUrl(linkNode.url)) {
108 | addTransformer(linkNode.url, index);
109 | }
110 | }
111 | });
112 |
113 | visit(paragraph, "text", (textNode) => {
114 | if (!isValidUrl(textNode.value)) return;
115 | if (processedUrl === textNode.value) return;
116 |
117 | // NOTE: Skip card conversion if the link text and URL are different, e.g., [https://example.com](https://example.org)
118 | if (
119 | unmatchedLink &&
120 | textNode.value === (unmatchedLink.children[0] as Text).value &&
121 | textNode.position?.start.line === unmatchedLink.position?.start.line
122 | ) {
123 | return;
124 | }
125 |
126 | if (index !== undefined) {
127 | if (!shouldIgnoreUrl(textNode.value)) {
128 | addTransformer(textNode.value, index);
129 | }
130 | }
131 | });
132 | });
133 |
134 | try {
135 | await Promise.all(transformers.map((t) => t()));
136 | } catch (error) {
137 | console.error(`[remark-link-card-plus] Error: ${error}`);
138 | }
139 |
140 | return tree;
141 | };
142 |
143 | const isSameUrlValue = (a: string, b: string) => {
144 | try {
145 | return new URL(a).toString() === new URL(b).toString();
146 | } catch (_) {
147 | return false;
148 | }
149 | };
150 |
151 | const getOpenGraph = async (targetUrl: URL) => {
152 | try {
153 | const { result } = await client({
154 | url: targetUrl.toString(),
155 | timeout: 10000,
156 | });
157 | return result;
158 | } catch (error) {
159 | const ogError = error as ErrorResult | undefined;
160 | console.error(
161 | `[remark-link-card-plus] Error: Failed to get the Open Graph data of ${ogError?.result?.requestUrl} due to ${ogError?.result?.error}.`,
162 | );
163 | return undefined;
164 | }
165 | };
166 |
167 | const getFaviconImageSrc = async (url: URL) => {
168 | const faviconUrl = `https://www.google.com/s2/favicons?domain=${url.hostname}`;
169 |
170 | const res = await fetch(faviconUrl, {
171 | method: "HEAD",
172 | signal: AbortSignal.timeout(10000),
173 | });
174 | if (!res.ok) return "";
175 |
176 | return faviconUrl;
177 | };
178 |
179 | const getLinkCardData = async (url: URL, options: Options) => {
180 | const ogRawResult = await getOpenGraph(url);
181 | let ogData: OgData = {
182 | title: ogRawResult?.ogTitle || "",
183 | description: ogRawResult?.ogDescription || "",
184 | faviconUrl: ogRawResult?.favicon,
185 | imageUrl: extractOgImageUrl(ogRawResult),
186 | };
187 |
188 | if (options.ogTransformer) {
189 | ogData = options.ogTransformer(ogData, url);
190 | }
191 |
192 | const title = ogData?.title || url.hostname;
193 | const description = ogData?.description || "";
194 | const faviconUrl = await getFaviconUrl(url, ogData?.faviconUrl, options);
195 | const ogImageUrl = await getOgImageUrl(ogData.imageUrl, options);
196 |
197 | let displayUrl = options.shortenUrl ? url.hostname : url.toString();
198 | try {
199 | displayUrl = decodeURI(displayUrl);
200 | } catch (error) {
201 | console.error(
202 | `[remark-link-card-plus] Error: Cannot decode url: "${url}"\n ${error}`,
203 | );
204 | }
205 |
206 | return {
207 | title,
208 | description,
209 | faviconUrl,
210 | ogImageUrl,
211 | displayUrl,
212 | url,
213 | };
214 | };
215 |
216 | const getFaviconUrl = async (
217 | url: URL,
218 | ogFavicon: string | undefined,
219 | options: Options,
220 | ) => {
221 | if (options.noFavicon) return "";
222 |
223 | let faviconUrl = ogFavicon;
224 | if (faviconUrl && !URL.canParse(faviconUrl)) {
225 | try {
226 | faviconUrl = new URL(faviconUrl, url.origin).toString();
227 | } catch (error) {
228 | console.error(
229 | `[remark-link-card-plus] Error: Failed to resolve favicon URL ${faviconUrl} relative to ${url}\n${error}`,
230 | );
231 | faviconUrl = undefined;
232 | }
233 | }
234 |
235 | if (!faviconUrl) {
236 | faviconUrl = await getFaviconImageSrc(url);
237 | }
238 |
239 | if (faviconUrl && options.cache) {
240 | try {
241 | const faviconFilename = await getCachedImageFilename(
242 | new URL(faviconUrl),
243 | path.join(process.cwd(), defaultSaveDirectory, defaultOutputDirectory),
244 | );
245 | faviconUrl = faviconFilename
246 | ? path.join(defaultOutputDirectory, faviconFilename)
247 | : faviconUrl;
248 | } catch (error) {
249 | console.error(
250 | `[remark-link-card-plus] Error: Failed to download favicon from ${faviconUrl}\n ${error}`,
251 | );
252 | }
253 | }
254 |
255 | return faviconUrl;
256 | };
257 |
258 | const getOgImageUrl = async (
259 | imageUrl: string | undefined,
260 | options: Options,
261 | ) => {
262 | if (options.noThumbnail) return "";
263 |
264 | const isValidUrl = imageUrl && imageUrl.length > 0 && URL.canParse(imageUrl);
265 | if (!isValidUrl) return "";
266 |
267 | let ogImageUrl = imageUrl;
268 |
269 | if (ogImageUrl && options.cache) {
270 | const imageFilename = await getCachedImageFilename(
271 | new URL(ogImageUrl),
272 | path.join(process.cwd(), defaultSaveDirectory, defaultOutputDirectory),
273 | );
274 | ogImageUrl = imageFilename
275 | ? path.join(defaultOutputDirectory, imageFilename)
276 | : ogImageUrl;
277 | }
278 |
279 | return ogImageUrl;
280 | };
281 |
282 | const extractOgImageUrl = (ogResult: OgObject | undefined) => {
283 | return ogResult?.ogImage && ogResult.ogImage.length > 0
284 | ? ogResult.ogImage[0].url
285 | : undefined;
286 | };
287 |
288 | const getCachedImageFilename = async (
289 | url: URL,
290 | saveDirectory: string,
291 | ): Promise => {
292 | const hash = createHash("sha256").update(decodeURI(url.href)).digest("hex");
293 |
294 | try {
295 | const files = await readdir(saveDirectory);
296 | const cachedFile = files.find((file) => file.startsWith(`${hash}.`));
297 | if (cachedFile) {
298 | return cachedFile;
299 | }
300 | } catch (_) {}
301 |
302 | try {
303 | const response = await fetch(url.href, {
304 | signal: AbortSignal.timeout(10000),
305 | });
306 | const arrayBuffer = await response.arrayBuffer();
307 | const buffer = Buffer.from(arrayBuffer);
308 | const contentType = response.headers.get("Content-Type");
309 | let extension = "";
310 |
311 | // NOTE: file-type cannot detect text-based formats like SVG, so we handle image/svg+xml manually
312 | if (contentType?.startsWith("image/svg+xml")) {
313 | extension = ".svg";
314 | } else if (contentType?.startsWith("image/")) {
315 | const fileType = await fileTypeFromBuffer(buffer);
316 | extension = fileType ? `.${fileType.ext}` : ".png";
317 | }
318 |
319 | const filename = `${hash}${extension}`;
320 | const saveFilePath = path.join(saveDirectory, filename);
321 |
322 | try {
323 | await access(saveDirectory);
324 | } catch (_) {
325 | await mkdir(saveDirectory, { recursive: true });
326 | }
327 |
328 | await writeFile(saveFilePath, buffer);
329 | return filename;
330 | } catch (error) {
331 | console.error(
332 | `[remark-link-card-plus] Error: Failed to download image from ${url.href}\n ${error}`,
333 | );
334 | return undefined;
335 | }
336 | };
337 |
338 | const className = (value: string) => {
339 | const prefix = "remark-link-card-plus";
340 | return `${prefix}__${value}`;
341 | };
342 |
343 | const createLinkCardNode = (data: LinkCardData, options: Options): Html => {
344 | const { title, description, faviconUrl, ogImageUrl, displayUrl, url } = data;
345 | const isThumbnailLeft = options.thumbnailPosition === "left";
346 |
347 | const thumbnail = ogImageUrl
348 | ? `
349 |
350 |

351 |
`.trim()
352 | : "";
353 |
354 | const mainContent = `
355 |
356 |
357 |
${sanitizeHtml(title)}
358 |
${sanitizeHtml(description)}
359 |
360 |
361 | ${faviconUrl ? `

` : ""}
362 |
${sanitizeHtml(displayUrl)}
363 |
364 |
365 | `
366 | .replace(/\n\s*\n/g, "\n")
367 | .trim();
368 |
369 | const content = isThumbnailLeft
370 | ? `
371 | ${thumbnail}
372 | ${mainContent}`
373 | : `
374 | ${mainContent}
375 | ${thumbnail}`;
376 |
377 | return {
378 | type: "html",
379 | value: `
380 |
385 | `.trim(),
386 | };
387 | };
388 |
389 | export default remarkLinkCard;
390 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs/promises";
2 | import path from "node:path";
3 | import { http } from "msw";
4 | import { setupServer } from "msw/node";
5 | import client from "open-graph-scraper";
6 | import { remark } from "remark";
7 | import {
8 | afterAll,
9 | afterEach,
10 | beforeAll,
11 | beforeEach,
12 | describe,
13 | expect,
14 | test,
15 | vi,
16 | } from "vitest";
17 | import remarkLinkCard from "./index";
18 |
19 | vi.mock("open-graph-scraper", () => {
20 | return {
21 | default: vi.fn().mockResolvedValue({
22 | error: false,
23 | result: {
24 | ogTitle: "Test Site Title",
25 | ogDescription: "Test Description",
26 | ogImage: [{ url: "http://example.com" }],
27 | },
28 | }),
29 | };
30 | });
31 |
32 | vi.mock("file-type", () => {
33 | return {
34 | fileTypeFromBuffer: vi.fn().mockResolvedValue({
35 | ext: "png",
36 | }),
37 | };
38 | });
39 |
40 | const server = setupServer(
41 | http.head("https://www.google.com/s2/favicons", () => {
42 | return new Response(null, { status: 200 });
43 | }),
44 | );
45 |
46 | beforeAll(() => server.listen());
47 | beforeEach(() => server.resetHandlers());
48 | afterAll(() => server.close());
49 |
50 | const processor = remark().use(remarkLinkCard, {});
51 |
52 | const removeLineLeadingSpaces = (input: string) => {
53 | return input
54 | .split("\n")
55 | .map((line) => line.trimStart())
56 | .join("\n");
57 | };
58 |
59 | describe("remark-link-card-plus", () => {
60 | describe("Basic usage", () => {
61 | test("Convert a line with only a link into a card", async () => {
62 | const input = `## test
63 |
64 | https://example.com
65 |
66 | https://example.com/path
67 |
68 |
69 |
70 |
71 |
72 | [https://example.com](https://example.com)
73 |
74 | [https://example.com/path](https://example.com/path)
75 | `;
76 | const { value } = await processor.process(input);
77 | const expected = `## test
78 |
79 |
96 |
97 |
114 |
115 |
132 |
133 |
150 |
151 |
168 |
169 |
186 | `;
187 | expect(removeLineLeadingSpaces(value.toString())).toBe(
188 | removeLineLeadingSpaces(expected),
189 | );
190 | });
191 |
192 | test("Does not convert if a link and text exist on the same line", async () => {
193 | const input = `## test
194 |
195 | [example link](https://example.com/path) test
196 | `;
197 | const { value } = await processor().process(input);
198 | const expected = `## test
199 |
200 | [example link](https://example.com/path) test
201 | `;
202 | expect(value.toString()).toBe(expected);
203 | });
204 |
205 | test("Does not convert if link text and URL are different", async () => {
206 | const input = `## test
207 |
208 | [example](https://example.com)
209 | `;
210 | const { value } = await processor().process(input);
211 | const expected = `## test
212 |
213 | [example](https://example.com)
214 | `;
215 | expect(value.toString()).toBe(expected);
216 | });
217 |
218 | test("Does not convert links inside list items to link cards", async () => {
219 | const input = `## test
220 |
221 | * list
222 | * https://example.com
223 | * [https://example.com](https://example.com)
224 | *
225 | * https://example.com/path
226 | * [https://example.com/path](https://example.com/path)
227 | *
228 |
229 |
230 | * https://example.com
231 | * [https://example.com](https://example.com)
232 | *
233 | * https://example.com/path
234 | * [https://example.com/path](https://example.com/path)
235 | *
236 | `;
237 | const { value } = await processor().process(input);
238 | const expected = `## test
239 |
240 | * list
241 | * https://example.com
242 | *
243 | *
244 | * https://example.com/path
245 | *
246 | *
247 |
248 | * https://example.com
249 |
250 | *
251 |
252 | *
253 |
254 | * https://example.com/path
255 |
256 | *
257 |
258 | *
259 | `;
260 | expect(value.toString()).toBe(expected);
261 | });
262 |
263 | test("Does not convert if link text is a URL but different from the link URL", async () => {
264 | const input = `## test
265 |
266 | [https://example.com](https://example.org)
267 | `;
268 |
269 | const { value } = await processor.process(input);
270 | const expected = `## test
271 |
272 | [https://example.com](https://example.org)
273 | `;
274 |
275 | expect(removeLineLeadingSpaces(value.toString())).toBe(
276 | removeLineLeadingSpaces(expected),
277 | );
278 | });
279 |
280 | test("Does not show a favicon if the favicon request fails", async () => {
281 | server.use(
282 | http.head("https://www.google.com/s2/favicons", () => {
283 | return new Response(null, { status: 404 });
284 | }),
285 | );
286 | const input = `## test
287 |
288 | [https://example.com](https://example.com)
289 | `;
290 | const { value } = await processor().process(input);
291 | const expected = `## test
292 |
293 |
309 | `;
310 | expect(removeLineLeadingSpaces(value.toString())).toBe(
311 | removeLineLeadingSpaces(expected),
312 | );
313 | });
314 |
315 | test("Does not show an og image if the URL format is invalid", async () => {
316 | // biome-ignore lint/suspicious/noExplicitAny: for open-graph-scraper mock
317 | const mockedClient = vi.mocked(client as any);
318 | mockedClient.mockResolvedValueOnce({
319 | error: false,
320 | result: {
321 | ogTitle: "Test Site Title",
322 | ogDescription: "Test Description",
323 | ogImage: [{ url: "example.com" }],
324 | },
325 | });
326 |
327 | const input = `## test
328 |
329 | [https://example.com](https://example.com)
330 | `;
331 | const { value } = await processor.process(input);
332 | const expected = `## test
333 |
334 |
348 | `;
349 | expect(removeLineLeadingSpaces(value.toString())).toBe(
350 | removeLineLeadingSpaces(expected),
351 | );
352 | });
353 |
354 | test("title and description are sanitized", async () => {
355 | // biome-ignore lint/suspicious/noExplicitAny: for open-graph-scraper mock
356 | const mockedClient = vi.mocked(client as any);
357 | mockedClient.mockResolvedValueOnce({
358 | error: false,
359 | result: {
360 | ogTitle: "evil title",
361 | ogDescription: "evil description",
362 | },
363 | });
364 |
365 | const input = `## test
366 |
367 | [https://example.com](https://example.com)
368 | `;
369 | const { value } = await processor.process(input);
370 | const expected = `## test
371 |
372 |
386 | `;
387 | expect(removeLineLeadingSpaces(value.toString())).toBe(
388 | removeLineLeadingSpaces(expected),
389 | );
390 | });
391 |
392 | test("Does not convert invalid URLs like 'Example:' into cards (URLs that pass `URL.canParse` but are not valid links)", async () => {
393 | const input = `## test
394 |
395 | Example:
396 | `;
397 | const { value } = await processor.process(input);
398 | const expected = `## test
399 |
400 | Example:
401 | `;
402 | expect(value.toString()).toBe(expected);
403 | });
404 | });
405 |
406 | describe("Options", () => {
407 | describe("cache", () => {
408 | test("Caches ogImage if cache option is enabled", async () => {
409 | // biome-ignore lint/suspicious/noExplicitAny: for open-graph-scraper mock
410 | const mockedClient = vi.mocked(client as any);
411 | mockedClient.mockResolvedValueOnce({
412 | error: false,
413 | result: {
414 | ogTitle: "Cached Title",
415 | ogDescription: "Cached Description",
416 | ogImage: { url: "http://example.com/cached-image.jpg" },
417 | },
418 | });
419 |
420 | const input = `## test
421 |
422 | [https://example.com](https://example.com)
423 | `;
424 | const processorWithCache = remark().use(remarkLinkCard, {
425 | cache: true,
426 | });
427 | const { value } = await processorWithCache.process(input);
428 | expect(value.toString()).toContain(`src="/remark-link-card-plus/`);
429 | });
430 |
431 | test("Caches image with appropriate extension when URL has no extension", async () => {
432 | // biome-ignore lint/suspicious/noExplicitAny: for open-graph-scraper mock
433 | const mockedClient = vi.mocked(client as any);
434 | mockedClient.mockResolvedValueOnce({
435 | error: false,
436 | result: {
437 | ogTitle: "Cached Title",
438 | ogDescription: "Cached Description",
439 | ogImage: { url: "http://example.com/no-extension-image" },
440 | },
441 | });
442 | const saveDirectory = path.join(process.cwd(), "public/cache");
443 | await fs.mkdir(saveDirectory, { recursive: true });
444 |
445 | const processorWithCache = remark().use(remarkLinkCard, {
446 | cache: true,
447 | });
448 | const input = `## test
449 |
450 | [https://example.com](https://example.com)
451 | `;
452 | const { value } = await processorWithCache.process(input);
453 |
454 | expect(value.toString()).toContain(`src="/remark-link-card-plus/`);
455 | expect(value.toString()).toMatch(
456 | /src="\/remark-link-card-plus\/.*\.png"/,
457 | );
458 | });
459 |
460 | test("Caches favicon with .svg extension when Content-Type is image/svg+xml", async () => {
461 | // biome-ignore lint/suspicious/noExplicitAny: for open-graph-scraper mock
462 | const mockedClient = vi.mocked(client as any);
463 | mockedClient.mockResolvedValueOnce({
464 | error: false,
465 | result: {
466 | ogTitle: "SVG Favicon Title",
467 | ogDescription: "SVG Favicon Description",
468 | favicon: "https://example.com/favicon.svg",
469 | },
470 | });
471 |
472 | vi.spyOn(global, "fetch").mockResolvedValueOnce(
473 | new Response("", {
474 | status: 200,
475 | headers: {
476 | "Content-Type": "image/svg+xml",
477 | },
478 | }),
479 | );
480 |
481 | const input = `## test
482 |
483 | [https://example.com](https://example.com)
484 | `;
485 |
486 | const processorWithCache = remark().use(remarkLinkCard, {
487 | cache: true,
488 | });
489 | const { value } = await processorWithCache.process(input);
490 |
491 | expect(value.toString()).toContain(`src="/remark-link-card-plus/`);
492 | expect(value.toString()).toMatch(
493 | /src="\/remark-link-card-plus\/.*\.svg"/,
494 | );
495 | });
496 |
497 | test("Caches .svg favicon when Content-Type includes parameters like charset", async () => {
498 | // biome-ignore lint/suspicious/noExplicitAny: for open-graph-scraper mock
499 | const mockedClient = vi.mocked(client as any);
500 | mockedClient.mockResolvedValueOnce({
501 | error: false,
502 | result: {
503 | ogTitle: "SVG Favicon With Charset",
504 | ogDescription: "Should still be .svg",
505 | favicon: "https://example.com/favicon.svg",
506 | },
507 | });
508 |
509 | vi.spyOn(global, "fetch").mockResolvedValueOnce(
510 | new Response("", {
511 | status: 200,
512 | headers: {
513 | "Content-Type": "image/svg+xml; charset=utf-8",
514 | },
515 | }),
516 | );
517 |
518 | const input = `## test
519 |
520 | https://example.com
521 | `;
522 |
523 | const processorWithCache = remark().use(remarkLinkCard, {
524 | cache: true,
525 | });
526 | const { value } = await processorWithCache.process(input);
527 |
528 | expect(value.toString()).toMatch(
529 | /src="\/remark-link-card-plus\/.*\.svg"/,
530 | );
531 | });
532 | });
533 |
534 | describe("shortenUrl", () => {
535 | test("Shortens URL if shortenUrl option is enabled", async () => {
536 | const input = `## test
537 |
538 | [https://example.com/long/path/to/resource](https://example.com/long/path/to/resource)
539 | `;
540 | const processorWithShorten = remark().use(remarkLinkCard, {
541 | shortenUrl: true,
542 | });
543 | const { value } = await processorWithShorten.process(input);
544 | expect(value.toString()).toContain(
545 | ``,
546 | );
547 | });
548 | });
549 |
550 | describe("thumbnailPosition", () => {
551 | test("Places thumbnail on the right by default", async () => {
552 | const input = `## test
553 |
554 | [https://example.com](https://example.com)
555 | `;
556 | const processorWithDefaultThumbnail = remark().use(remarkLinkCard, {});
557 | const { value } = await processorWithDefaultThumbnail.process(input);
558 | expect(removeLineLeadingSpaces(value.toString())).toContain(
559 | `
560 |
561 |
562 | `,
563 | );
564 | });
565 |
566 | test("Places thumbnail on the left when specified", async () => {
567 | const input = `## test
568 |
569 | [https://example.com](https://example.com)
570 | `;
571 | const processorWithLeftThumbnail = remark().use(remarkLinkCard, {
572 | thumbnailPosition: "left",
573 | });
574 | const { value } = await processorWithLeftThumbnail.process(input);
575 | expect(removeLineLeadingSpaces(value.toString())).toContain(
576 | `