- 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.
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 |
|
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
.mdand.mdxposts. - 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.
urlPrefixmirrors the router, not the folder tree. Astro's URL generation doesn't always include every folder segment.- Fall back to
#filenamefor unknown targets. A broken anchor is easier to spot than a silently-missing link, and it keeps the build green. - Remember to
rm -rf .astrowhen the plugin changes. The content cache will happily hand you yesterday's output.