Revisiting Obsidian as a CMS, again
4 min read
Obsidian as CMS: Using YAML frontmatter and GitHub's GraphQL API to create a free, flexible publishing system for markdown content that.
On this page
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;
---
<article>
<Markdown
components={{ img: Image, p: Paragraph }}
sanitize={{
dropElements: ["head", "style"],
allowCustomElements: true,
}}
>
{body}
</Markdown>
</article>
---
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) => (
<li>
<p>
<a href={`/${path}/${entry.frontmatter.slug}`}>{entry.frontmatter.title}</a>
</p>
</li>
))
}
</>
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 <img> in <p>, 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") ? (
<slot />
) : (
<p>
<slot />
</p>
)
}
The whole thing is cheap, fast to write in, and mine.