A rehype plugin for Astro that adds different styles to internal and external links:
- Internal links (pointing to existing pages): Default style with customizable class
- Internal links (pointing to non-existent pages): Red style with customizable class (similar to Wikipedia's broken links)
- External links: Adds "β" icon (or custom content) and sets target="_blank", with customizable class
# npm
npm install rehype-smart-links
# yarn
yarn add rehype-smart-links
# pnpm
pnpm add rehype-smart-links
Add the plugin to your Astro configuration:
// astro.config.mjs
import { defineConfig } from "astro/config";
import rehypeSmartLinks from "rehype-smart-links";
export default defineConfig({
markdown: {
rehypePlugins: [
// Basic usage (default settings)
rehypeSmartLinks,
// Or with custom options
[
rehypeSmartLinks,
{
content: { type: "text", value: "β" },
internalLinkClass: "internal-link",
externalLinkClass: "external-link",
brokenLinkClass: "broken-link",
contentClass: "external-icon",
target: "_blank",
rel: "noopener noreferrer",
publicDir: "./dist",
routesFile: "./.smart-links-routes.json",
includeFileExtensions: ["html", "pdf", "zip"], // Only include specific file types
includeAllFiles: false // Set to true to include all file types
}
]
]
}
});
For accurate detection of valid internal links, a two-phase build process is recommended:
rehype-smart-links provides a built-in CLI command to simplify the routes file generation process:
- Add a build script to your
package.json
:
{
"scripts": {
"build:with-routes": "astro build && rehype-smart-links build && astro build"
}
}
- Run the script to execute the two-phase build:
npm run build:with-routes
This command will:
- First build your site
- Use the
rehype-smart-links build
command to scan the build output and generate a routes file - Build the site again, this time using the generated routes information
The CLI command supports the following options:
Options:
-d, --dir <path> Build directory path (default: "./dist")
-o, --output <path> Output path for the routes file (default: "./.smart-links-routes.json")
-a, --all Include all file types (default: false)
-e, --extensions <ext> File extensions to include (default: ["html"])
-h, --help Show help information
You can also write a custom build script:
- First build the site and create a routes mapping file:
// In your build script
import { generateRoutesFile } from "rehype-smart-links";
// First perform a preliminary build
await build();
// Then generate a routes file from the build output directory
generateRoutesFile("./dist", "./.smart-links-routes.json", {
includeAllFiles: true, // Include all file types
// Or only include specific file types
includeFileExtensions: ["html", "pdf", "zip"]
});
// Finally perform the final build
await build();
- Add a build script to your
package.json
:
{
"scripts": {
"build": "node ./scripts/build-with-routes.js"
}
}
- Create a build script (e.g.,
scripts/build-with-routes.js
):
import { execSync } from "node:child_process";
import { generateRoutesFile } from "rehype-smart-links";
// Phase 1: Initial build
console.log("[PHASE 1] Initial build...");
execSync("astro build", { stdio: "inherit" });
// Generate routes mapping file
console.log("[PHASE 2] Generating routes map...");
generateRoutesFile("./dist", "./.smart-links-routes.json", {
includeAllFiles: true // Include all file types
});
// Phase 2: Build again with routes information
console.log("[PHASE 3] Final build with routes...");
execSync("astro build", { stdio: "inherit" });
console.log("[SUCCESS] Build complete!");
In addition to adding classes, you can fully customize the HTML structure of the links:
import rehypeSmartLinks from "rehype-smart-links";
export default defineConfig({
markdown: {
rehypePlugins: [
[
rehypeSmartLinks,
{
wrapperTemplate: (node, type, className) => {
// Create tooltip wrapper
if (type === "external") {
// Example structure for external links
const tooltip = {
type: "element",
tagName: "div",
properties: {
className: ["tooltip"],
dataTooltip: "This is an external link"
},
children: [node]
};
// You can also modify the original node
if (className) {
node.properties.className
= [...(node.properties.className || []), className];
}
return tooltip;
}
else if (type === "broken") {
// Example structure for broken links
const wrapper = {
type: "element",
tagName: "span",
properties: {
className: ["broken-link-wrapper"],
dataError: "Page doesn't exist"
},
children: [node]
};
// Add a warning icon
node.children.push({
type: "element",
tagName: "span",
properties: { className: ["warning-icon"] },
children: [{ type: "text", value: "β " }]
});
return wrapper;
}
// Only add class for internal links
if (className) {
node.properties.className
= [...(node.properties.className || []), className];
}
return node;
}
}
]
]
}
});
This approach allows you to create completely different HTML structures for different types of links, not just add class names, making it ideal for use with component libraries like DaisyUI and TailwindCSS.
Add CSS styles for different link types:
/* Default style for internal links */
.internal-link {
/* Custom styles */
}
/* External links with icons */
.external-link {
/* Custom styles */
}
.external-link .external-icon {
margin-left: 0.25em;
font-size: 0.75em;
}
/* Style for broken links (similar to Wikipedia) */
.broken-link {
color: red;
}
Option | Type | Default | Description |
---|---|---|---|
content |
{ type: string, value: string } |
{ type: 'text', value: 'β' } |
Content to add after external links |
internalLinkClass |
string |
'internal-link' |
Class for internal links to existing pages |
externalLinkClass |
string |
'external-link' |
Class for external links |
brokenLinkClass |
string |
'broken-link' |
Class for internal links to non-existent pages |
contentClass |
string |
'external-icon' |
Class for the content element added to external links |
target |
string |
'_blank' |
Target attribute for external links |
rel |
string |
'noopener noreferrer' |
Rel attribute for external links |
publicDir |
string |
'./dist' |
Path to the build output directory |
routesFile |
string |
'./.smart-links-routes.json' |
Path to the routes mapping file |
includeFileExtensions |
string[] |
['html'] |
List of file extensions to include |
includeAllFiles |
boolean |
false |
Set to true to include all file types |
wrapperTemplate |
(node, type, className) => Element |
undefined |
Template function for custom link structure |
customInternalLinkTransform |
(node) => void |
undefined |
Custom transform function for internal links |
customExternalLinkTransform |
(node) => void |
undefined |
Custom transform function for external links |
customBrokenLinkTransform |
(node) => void |
undefined |
Custom transform function for broken links |
In addition to wrapperTemplate
, you can use separate transform functions for finer control:
import rehypeSmartLinks from "rehype-smart-links";
// Example custom transform function for external links
function customExternalLinkTransform(node) {
// Add custom icon or structure
node.properties.class = [...(node.properties.class || []), "my-external-link"];
node.properties.target = "_blank";
node.properties.rel = "noopener";
// Add custom SVG icon
const svgIcon = {
type: "element",
tagName: "span",
properties: { class: "custom-icon" },
children: [{ type: "text", value: "π" }]
};
node.children.push(svgIcon);
}
export default {
markdown: {
rehypePlugins: [
[
rehypeSmartLinks,
{
customExternalLinkTransform
}
]
]
}
};
// Example using TailwindCSS class names
const tailwindWrapper = (node, type, className) => {
// Save original link content
const linkChildren = [...node.children];
// Clear original link content
node.children = [];
if (type === "external") {
// Add Tailwind class names for external links
node.properties.className = ["text-blue-500", "hover:text-blue-700", "inline-flex", "items-center", "gap-1"];
// Add original content
node.children = [
...linkChildren,
{
type: "element",
tagName: "svg",
properties: {
className: ["w-4", "h-4"],
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor"
},
children: [{
type: "element",
tagName: "path",
properties: {
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "2",
d: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
},
children: []
}]
}
];
return node;
}
else if (type === "broken") {
// Create broken link wrapper
const wrapper = {
type: "element",
tagName: "span",
properties: {
className: ["group", "relative", "inline-block"]
},
children: [
{
...node,
properties: {
...node.properties,
className: ["text-red-500", "underline", "underline-offset-2", "decoration-wavy", "decoration-red-500"]
},
children: linkChildren
},
{
type: "element",
tagName: "span",
properties: {
className: ["invisible", "group-hover:visible", "absolute", "bottom-full", "left-1/2", "-translate-x-1/2", "bg-red-100", "text-red-800", "text-xs", "px-2", "py-1", "rounded", "whitespace-nowrap"]
},
children: [{ type: "text", value: "Page doesn't exist" }]
}
]
};
return wrapper;
}
else {
// Add Tailwind class names for internal links
node.properties.className = ["text-green-600", "hover:text-green-800", "transition-colors"];
node.children = linkChildren;
return node;
}
};
This plugin includes a comprehensive test suite to ensure functionality works as expected.
# Install dependencies first
npm install
# Run the tests
npm test
If you're experiencing an issue or want to add a new test case:
-
Add a new test case to
tests/cases/testCases.ts
following the existing pattern. -
Run the tests to verify your test case:
npm test
- The test report will be generated at
tests/results/report.html
with visual comparison between expected and actual outputs.
If you find a bug or have a feature request, please open an issue with:
- A clear description of the problem
- Steps to reproduce (or ideally, a test case that fails)
- Expected vs. actual behavior
- Version information for rehype-smart-links and your environment
Pull requests are always welcome!