---
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 `<a href="/blog/.../my-post/">my-post</a>` 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<filename, url>`:

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