TLDR#
I built a remark plugin that transforms [[slug]] syntax into internal links with hover preview cards. It supports multiple content collections, custom display text, and shows broken link warnings at build time.
Live Examples#
Hover over these links to see the preview cards in action:
Blog post: Are LLMs Creative?Are LLMs Creative?Exploring the fundamental nature of creativity in Large Language Models compared to human creativity, sparked by reflections on OpenAI's latest image model.ai
Blog post with alias: my thoughts on LLM creativityAre LLMs Creative?Exploring the fundamental nature of creativity in Large Language Models compared to human creativity, sparked by reflections on OpenAI's latest image model.ai
TIL: Dynamic Pinia Stores in Vue 3Dynamic Pinia Stores in Vue 3Create dynamic Pinia stores with unique IDs for separate component instancesvuepiniatypescript
Notes: Testing with AISoftware Testing with Generative AIA beginner friendly guide for leveraging AI in software testing practicestestingaibook-summary
Broken link: this-post-does-not-exist
All of these are written as simple [[slug]] syntax in the markdown source.
Why I Built This#
I use Obsidian for note-taking and love the [[wiki link]] syntax. It’s fast to type and creates connections between notes naturally. I wanted the same experience when writing blog posts.
Before this, I had an InternalLink component that required MDX imports:
import InternalLink from "@features/mdx-components/components/InternalLink.astro";
Check out <InternalLink slug="some-post">this post</InternalLink>.
Too verbose. I wanted to just type [[some-post]] and have it work.
How It Works#
The solution uses a custom remark plugin that:
- Finds
[[...]]patterns in markdown text - Looks up the post metadata from the file system
- Generates the full preview card HTML at build time
Supported Syntax#
[[slug]] # Links to blog post
[[slug|custom text]] # With display text
[[til:slug]] # Links to TIL collection
[[notes:slug|my notes]] # Other collections with alias
The Preview Card#
Hover over any wiki link to see a preview card with:
- Post title
- Description (3 lines max)
- Tags (first 3)
- Publication date
The card uses fixed positioning to escape overflow containers and flips below the link when too close to the viewport top.
Building the Remark Plugin#
The plugin runs during markdown processing. It reads all content collection files at initialization and caches the metadata for fast lookups.
// src/lib/remarkWikiLinks.ts
import { visit } from "unist-util-visit";
import matter from "gray-matter";
import fs from "node:fs";
const WIKI_LINK_REGEX = /\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g;
export function remarkWikiLinks() {
// Load all posts at plugin init
const cache = loadAllPosts();
return (tree) => {
visit(tree, "text", (node, index, parent) => {
const matches = [...node.value.matchAll(WIKI_LINK_REGEX)];
if (matches.length === 0) return;
// Replace matches with HTML nodes containing preview cards
// ...
});
};
}
The key insight: remark plugins can output raw HTML nodes. The plugin generates the complete preview card markup, so no separate rehype processing is needed.
Parsing the Syntax#
The regex captures two groups:
- The target (either
slugorcollection:slug) - The optional alias after the pipe
function parseWikiLink(target: string, alias?: string) {
let collection = "blog";
let slug = target;
if (target.includes(":")) {
const [col, sl] = target.split(":", 2);
if (["blog", "til", "notes", "prompts"].includes(col)) {
collection = col;
slug = sl;
}
}
return { collection, slug, alias };
}
Loading Post Metadata#
The plugin reads frontmatter directly from content files using gray-matter:
function loadCollectionPosts(collection: string) {
const posts = new Map();
const dir = `src/content/${collection}`;
for (const file of fs.readdirSync(dir, { recursive: true })) {
if (!file.endsWith(".md") && !file.endsWith(".mdx")) continue;
const content = fs.readFileSync(`${dir}/${file}`, "utf-8");
const { data } = matter(content);
if (data.draft) continue;
const slug = file.replace(/\.(md|mdx)$/, "");
posts.set(slug, {
title: data.title,
description: data.description,
tags: data.tags,
pubDatetime: data.pubDatetime,
});
}
return posts;
}
Generating Preview Card HTML#
The plugin outputs the same HTML structure as my existing InternalLink component:
function createPreviewCardHtml(post, href, displayText) {
return `
<span class="internal-link-wrapper">
<a href="${href}" class="internal-link">${displayText}</a>
<span class="preview-card" role="tooltip">
<span class="preview-content">
<span class="preview-title">${post.title}</span>
<span class="preview-description">${post.description}</span>
<!-- tags and date -->
</span>
</span>
</span>
`;
}
Broken Link Detection#
When a wiki link references a non-existent post, the plugin:
- Logs a warning during build:
[wiki-links] Post not found: blog:missing-slug - Renders the link with error styling (red wavy underline)
if (!postData) {
console.warn(`[wiki-links] Post not found: ${collection}:${slug}`);
return `<span class="wiki-link-broken" title="Post not found: ${slug}">${displayText}</span>`;
}
This catches typos and stale references before they hit production.
Adding the Plugin to Astro#
Register the plugin in astro.config.ts:
import { remarkWikiLinks } from "./src/lib/remarkWikiLinks";
export default defineConfig({
markdown: {
remarkPlugins: [
remarkWikiLinks,
// other plugins...
],
},
});
The plugin runs first so wiki links are processed before other transformations.
The CSS#
The styles match my existing InternalLink component:
.internal-link-wrapper {
position: relative;
display: inline-block;
}
.internal-link {
@apply text-skin-accent underline decoration-dashed;
}
.preview-card {
position: absolute;
bottom: calc(100% + 8px);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s;
}
.wiki-link-broken {
@apply text-red-400 underline decoration-wavy;
}
The Hover Script#
A small inline script handles the preview card positioning:
document.addEventListener("astro:page-load", () => {
document.querySelectorAll(".internal-link-wrapper").forEach((wrapper) => {
wrapper.addEventListener("mouseenter", () => {
const card = wrapper.querySelector(".preview-card");
// Calculate position, flip if needed, show card
});
});
});
The script runs on astro:page-load to work with Astro’s view transitions.
Result#
Now I can write posts with natural wiki link syntax:
I wrote about [[are-llms-creative|LLM creativity]] last month.
See also my [[til:dynamic-pinia-stores|TIL on Pinia stores]].
The links render with hover previews, and broken references get caught at build time. Much better than importing components everywhere.
What’s Next#
A few improvements I’m considering:
- Fuzzy matching for slug typos
- Backlinks section showing which posts link to the current one
- Support for heading anchors:
[[post#section]]
