--- 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.