← Home

Resolving Obsidian Wiki-Links in Astro with a Custom Remark Plugin

Obsidian uses [[filename]] syntax for internal links. Astro doesn’t understand that — it expects standard Markdown links like [text](/path/to/page/). Since my content lives in Obsidian and gets deployed to an Astro site, I needed a way to bridge the two without giving up native Obsidian linking.

The solution: a Remark plugin that runs at build time, scans the content directory to build a filename → URL map, and replaces every [[wiki-link]] in the AST with a proper link node.

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. The key node type here is text — raw text content inside paragraphs, list items, etc. Wiki-links appear inside text nodes and need to be split out into link nodes.

Building the File Index

The plugin scans the content directories once at module load time and builds a Map<filename, url>:

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();

The urlPrefix must match what Astro’s router actually generates — not necessarily the folder structure. In my case the blog loader uses base: "./src/content/blog/posts", so slugs start directly with the year (2026/03/26/...) and the URL is /blog/2026/03/26/.... There’s 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 if it contains [[...]], and if so splits it into a mix of text and link nodes:

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:

ObsidianRenders 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 found in the index, the link falls back to #filename so the post still builds — just with a broken anchor instead of a 404.

Registering the Plugin

In astro.config.mjs, the plugin goes into markdown.remarkPlugins. Astro applies this to both .md and .mdx files:

import { remarkObsidianLinks } from "./src/lib/remark-obsidian-links.mjs";

export default defineConfig({
  markdown: {
    remarkPlugins: [remarkObsidianLinks],
  },
  // ...
});

One Gotcha: Astro’s Content Cache

Astro caches parsed content in .astro/. If you change the remark plugin after content has already been parsed, the cached (old) output is served even after a dev server restart. The fix:

rm -rf .astro

Then restart the dev server. The file index is rebuilt and all wiki-links are re-resolved from scratch.

← Home