Revisiting Obsidian as a CMS, again

Matthias March 11, 2024
Source

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

    Loading comments...