├── 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 | 2 | 3 | 9 | 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 | [![CI](https://github.com/okaryo/remark-link-card-plus/actions/workflows/ci.yml/badge.svg)](https://github.com/okaryo/remark-link-card-plus/actions/workflows/ci.yml) 4 | [![npm version](https://img.shields.io/npm/v/remark-link-card-plus)](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 | `example.com`, 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 | ` 577 |