---
title: Resolving Obsidian Wiki-Links in Astro with a Custom Remark Plugin
description: 'How I wrote a small Remark plugin that converts [[wiki-links]] from Obsidian into proper HTML links at build time — without changing how I write in the editor.'
pubDate: '2026-03-26T17:00:00+01:00'
category: en/development
tags:
- astro
- obsidian
- workflow
- remark
seriesParent: obsidian-to-vps-pipeline-with-sync-pull-and-redeploy
seriesOrder: 4
---
Obsidian uses `[[filename]]` syntax for internal links. Astro doesn't understand that — it expects standard Markdown links like `[text](/path/to/page/)`. My content lives in Obsidian and gets deployed to an Astro site, and I wanted to keep writing native Obsidian links in the editor without manually rewriting them before every commit. The answer is a small Remark plugin that runs at build time.
## The setup
- **Editor**: Obsidian vault with a `content/` subtree that's mirrored to the Astro repo.
- **Site**: Astro 6 static build with `.md` and `.mdx` posts.
- **Goal**: `[[my-post]]` in Markdown becomes `my-post` in HTML, with no pre-processing step in the pipeline.
## How Remark plugins work
Remark operates on a Markdown Abstract Syntax Tree — MDAST. A plugin is a function that receives the tree and transforms it in place. The key node type here is `text` — raw text content inside paragraphs, list items, headings. Wiki-links appear inside text nodes and need to be split out into `link` nodes.
## Building the file index
**Problem:** the plugin needs to know which filename maps to which URL. Scanning the filesystem on every node visit would be absurd.
**Implementation:** scan the content directories once at module load time and cache a `Map`:
```js
const SOURCES = [
{ base: resolve(__dirname, "../content/blog/posts"), urlPrefix: "/blog" },
{ base: resolve(__dirname, "../content/notes"), urlPrefix: "/notes" },
];
function buildFileIndex() {
const map = new Map();
for (const { base, urlPrefix } of SOURCES) {
let files;
try {
files = readdirSync(base, { recursive: true });
} catch {
continue;
}
for (const file of files) {
if (!/\.(md|mdx)$/.test(file)) continue;
const slug = file.replace(/\.(md|mdx)$/, "").replace(/\\/g, "/");
const filename = slug.split("/").pop();
if (!map.has(filename)) {
map.set(filename, `${urlPrefix}/${slug}/`);
}
}
}
return map;
}
const fileIndex = buildFileIndex();
```
**Solution:** one filesystem scan per build, O(1) lookups for the rest.
One non-obvious detail: `urlPrefix` must match what Astro's router actually generates — not the folder structure. My blog loader uses `base: "./src/content/blog/posts"`, so slugs start with the year (`2026/03/26/...`) and the URL is `/blog/2026/03/26/...`. No `posts/` segment in the URL even though the files live in a `posts/` folder.
## Replacing text nodes
The plugin visits every `text` node, checks for `[[...]]`, and if present splits the node into a mix of text and link nodes:
```js
visit(tree, "text", (node, index, parent) => {
if (!WIKI_LINK_RE.test(node.value)) return;
WIKI_LINK_RE.lastIndex = 0;
const nodes = [];
let last = 0;
let match;
while ((match = WIKI_LINK_RE.exec(node.value)) !== null) {
if (match.index > last) {
nodes.push({ type: "text", value: node.value.slice(last, match.index) });
}
const inner = match[1];
const pipeIdx = inner.indexOf("|");
const ref = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);
const label = pipeIdx === -1 ? ref.split("#")[0].trim() : inner.slice(pipeIdx + 1).trim();
const [filename, heading] = ref.trim().split("#");
const base = fileIndex.get(filename.trim());
const url = base
? (heading ? `${base}#${heading.trim()}` : base)
: `#${filename.trim()}`;
nodes.push({ type: "link", url, title: null,
children: [{ type: "text", value: label }] });
last = match.index + match[0].length;
}
if (last < node.value.length) {
nodes.push({ type: "text", value: node.value.slice(last) });
}
parent.children.splice(index, 1, ...nodes);
return [SKIP, index + nodes.length];
});
```
Supported syntax:
| Obsidian | Renders as |
|---|---|
| `[[my-post]]` | link with filename as label |
| `[[my-post\|custom label]]` | link with custom label |
| `[[my-post#heading]]` | link with `#heading` fragment |
If a filename isn't in the index, the link falls back to `#filename` — the post still builds, the link just goes nowhere useful. Better a broken anchor than a 404 on the whole page.
## Registering the plugin
In `astro.config.mjs`, the plugin goes into `markdown.remarkPlugins`. Astro applies this to both `.md` and `.mdx`:
```js
import { remarkObsidianLinks } from "./src/lib/remark-obsidian-links.mjs";
export default defineConfig({
markdown: {
remarkPlugins: [remarkObsidianLinks],
},
// ...
});
```
## The one gotcha: Astro's content cache
**Problem:** Astro caches parsed content under `.astro/`. If you change the remark plugin after content has already been parsed, the cached output is still served — even after a dev-server restart. You debug the plugin for fifteen minutes before you realise nothing you change has any effect.
**Implementation:** blow the cache away:
```bash
rm -rf .astro
```
**Solution:** restart the dev server. The file index is rebuilt, every wiki-link is re-resolved from scratch.
## What to take away
- **Plugins at the MDAST level are cheaper than preprocessing.** One build-time pass, no extra files in the repo, no shell step in the pipeline.
- **Build a lookup index once at module load.** Scanning per node visit is the kind of quadratic fate you only notice when the vault grows.
- **`urlPrefix` mirrors the router, not the folder tree.** Astro's URL generation doesn't always include every folder segment.
- **Fall back to `#filename` for unknown targets.** A broken anchor is easier to spot than a silently-missing link, and it keeps the build green.
- **Remember to `rm -rf .astro` when the plugin changes.** The content cache will happily hand you yesterday's output.