Revisiting Obsidian as a CMS, again
The technical details of this setup are in Obsidian as a CMS and Revisiting Obsidian as a CMS. The thought process that got me here is its own thing.
I wanted a backend for my personal site that was as close to free as possible. Airtable burned through the free tier fast, Google Sheets felt like dragging a desk through a doorway. What I actually needed was a flat-file way to publish markdown with metadata, plus the ability to write on the go (Git clients on iOS make that painful).
Obsidian was already my knowledge base. Folder-based templates, private Git backup. With the right YAML frontmatter, it covered all the meta and feature-flag fields I cared about. The only gap was getting the markdown out of the repo and into a front-end framework.
Inner workings
The GitHub GraphQL API can fetch a single file or a whole directory in one query. YAML gets parsed with gray-matter, then the markdown renders. In Next.js I used next-mdx-remote. I'm on Astro now to ship less JS, with astro-remote, which uses marked and sanitizes output with ultrahtml. Component overrides work the same as MDX.
Fetching posts
async function fetchFromGitHubGraphQL(query: string, variables: any) { const response = await fetch("https://api.github.com/graphql", { method: "POST", headers: { "Content-Type": "application/json", Authorization: Bearer ${github}, }, body: JSON.stringify({ query, variables }), });
if (!response.ok) { console.error("HTTP Error:", response.status); return response; }
return response.json(); }
fetchFromGitHubGraphQL used to be load-bearing across several callers. Now it sits behind one function, getObsidianEntries.
export async function getObsidianEntries(path: string, slug?: string) { const expression = slug ? HEAD:content/${path}/${slug}.md : HEAD:content/${path};
const { data: { repository: { object }, }, } = await fetchFromGitHubGraphQL( query fetchEntries($owner: String!, $name: String!, $expression: String!) { repository(owner: $owner, name: $name) { object(expression: $expression) { ... on Tree { entries { name object { ... on Blob { text } } } } ... on Blob { text } } } }, { owner: GITHUB_USERNAME, name: REPO_NAME, expression, } );
if (slug) { if (!object || !object.text) { console.error("No data returned from the GraphQL query for the single entry."); return null; } return parseMarkdownContent(object.text, path); }
if (!object || !object.entries) { console.error("No data returned from the GraphQL query for multiple entries."); return []; }
const parsedEntries = await Promise.all( object.entries.map((entry: { object: { text: any } }) => { const content = entry.object.text; return parseMarkdownContent(content, path); }) );
parseAndMergeTags(parsedEntries);
return parsedEntries; }
File structure
. ├── README.md ├── content │ ├── art │ │ └── txt.md │ ├── notes │ │ └── txt.md │ ├── posts │ │ └── txt.md │ └── recipes │ └── txt.md └── templates └── base_template.md
The folder layout doubles as routing on the front end. getObsidianEntries takes a path, with an optional slug. Slug matches the filename, so path + slug.md returns one entry, and path alone returns the whole directory. That one trick made it easy to spin up new content types.
--- import { Markdown } from "astro-remote";
const { path, slug } = Astro.params; const entry = await getObsidianEntries(path, slug); const { body, frontmatter } = entry; ---
{body}--- import { getObsidianEntries } from "@lib/github";
const { path } = Astro.params; entries = entries.sort((a, b) => new Date(b.frontmatter.created).getTime() - new Date(a.frontmatter.created).getTime()); ---
<> { entries.map((entry) => (
<a href={/${path}/${entry.frontmatter.slug}}>{entry.frontmatter.title}
Frontmatter
A base_template populates each new file. It prompts for a title, formats a URL-safe slug, and stamps creation and modified dates.
--- <%* let title = await tp.system.prompt("Please enter a value"); let slug = tp.file.creation_date("x") + " " + title; let formatted_slug = slug.trim().replace(/\W+/g, '-').toLowerCase(); await tp.file.rename(${formatted_slug}); %> title: <%* tR += title; %> slug: <%* tR += formatted_slug; %> published: false created: <% tp.file.creation_date("YYYY-MM-DD HH:mm") %> updated: <% tp.file.last_modified_date("YYYY-MM-DD HH:mm") %> tags: - ---
Tags are the rough edge. Right now I aggregate them into a flat file on Cloudflare R2, which is hacky and unreliable. A hashing scheme to keep tags in sync with published content is on the list.
Images
The old setup hashed each image with md5, dumped it to assets, and a GitHub Action pushed it to R2 on push. The S3 Image Uploader plugin replaced that: it hashes the filename and uploads from the Obsidian editor directly. My PR adding concurrent uploads landed in 0.2.10.
Markdown wraps in
, which I don't want. Astro-Remote makes it easy to unwrap by checking the rendered slot:
--- let slots = await Astro.slots.render("default"); let slotsString = slots.toString(); ---
{ slotsString.includes("img src") ? ( ) : (
) }
The whole thing is cheap, fast to write in, and mine.
Discussion in the ATmosphere