home menu

Revisiting Obsidian as a CMS, again

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.

collection
posts
rkey
1710177704945-revisiting-obsidian-as-a-cms-again
record cid
bafkreib2seo67l7fbyupqai6gpyncwmx6fk7lqybqljlr5thifyno3ezue
record
https://content.farfield.systems/api/entries/1710177704945-revisiting-obsidian-as-a-cms-again
created
updated