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.
This commit is contained in:
parent
9d22d93361
commit
4bf4eb03b1
69 changed files with 4904 additions and 344 deletions
153
src/content/posts/en/2026/03/remark-obsidian-wiki-links.md
Normal file
153
src/content/posts/en/2026/03/remark-obsidian-wiki-links.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue