adrian-altner.de/src/content/posts/en/2026/03/remark-obsidian-wiki-links.md
Adrian Altner 4bf4eb03b1 Add new posts for Image Voice Memos, Initial VPS Setup on Debian, Local Webmention Avatars, Security Headers for Astro with Caddy, and Setting up Forgejo Actions Runner
- Created a new post on Image Voice Memos detailing a macOS app for browsing photos and recording voice memos with automatic transcription.
- Added a guide for Initial VPS Setup on Debian covering system updates, user creation, and SSH hardening.
- Introduced a post on caching webmention avatars locally at build time to enhance privacy and comply with CSP.
- Documented the implementation of security headers for an Astro site behind Caddy, focusing on GDPR compliance and CSP.
- Set up a Forgejo Actions runner for self-hosted CI/CD, detailing the installation and configuration process for automated deployments.
2026-04-22 23:00:10 +02:00

6.1 KiB

title description pubDate category tags seriesParent seriesOrder
Resolving Obsidian Wiki-Links in Astro with a Custom Remark Plugin 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. 2026-03-26T17:00:00+01:00 en/development
astro
obsidian
workflow
remark
obsidian-to-vps-pipeline-with-sync-pull-and-redeploy 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>:

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:

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:

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:

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.