{"collection":"posts","slug":"1710177704945-revisiting-obsidian-as-a-cms-again","cid":"bafkreif3jxsichpyzzidn4j6xlkzhyak7o2rrxkefp77wtzztmvmim7cky","title":"Revisiting Obsidian as a CMS, again","excerpt":"Obsidian, GitHub's GraphQL API, and Astro form a free, flexible publishing system for Markdown content.","body":"The technical details of this setup are in [Obsidian as a CMS](1670659200001-obsidian-as-a-cms.md) and [Revisiting Obsidian as a CMS](1699332127006-revisiting-obsidian-as-a-cms.md). The thought process that got me here is its own thing.\n\nI 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).\n\nObsidian 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.\n\n## Inner workings\n\nThe GitHub GraphQL API can fetch a single file or a whole directory in one query. YAML gets parsed with [gray-matter](https://github.com/jonschlinkert/gray-matter), then the markdown renders. In Next.js I used [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote). I'm on Astro now to ship less JS, with [astro-remote](https://github.com/natemoo-re/astro-remote), which uses [marked](https://marked.js.org/) and sanitizes output with [ultrahtml](https://github.com/natemoo-re/ultrahtml). Component overrides work the same as MDX.\n\n### Fetching posts\n\n```typescript\nasync function fetchFromGitHubGraphQL(query: string, variables: any) {\n  const response = await fetch(\"https://api.github.com/graphql\", {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: `Bearer ${github}`,\n    },\n    body: JSON.stringify({ query, variables }),\n  });\n\n  if (!response.ok) {\n    console.error(\"HTTP Error:\", response.status);\n    return response;\n  }\n\n  return response.json();\n}\n```\n\n`fetchFromGitHubGraphQL` used to be load-bearing across several callers. Now it sits behind one function, `getObsidianEntries`.\n\n```typescript\nexport async function getObsidianEntries(path: string, slug?: string) {\n  const expression = slug ? `HEAD:content/${path}/${slug}.md` : `HEAD:content/${path}`;\n\n  const {\n    data: {\n      repository: { object },\n    },\n  } = await fetchFromGitHubGraphQL(\n    `\n      query fetchEntries($owner: String!, $name: String!, $expression: String!) {\n        repository(owner: $owner, name: $name) {\n          object(expression: $expression) {\n            ... on Tree {\n              entries {\n                name\n                object {\n                  ... on Blob {\n                    text\n                  }\n                }\n              }\n            }\n            ... on Blob {\n              text\n            }\n          }\n        }\n      }\n    `,\n    {\n      owner: `GITHUB_USERNAME`,\n      name: `REPO_NAME`,\n      expression,\n    }\n  );\n\n  if (slug) {\n    if (!object || !object.text) {\n      console.error(\"No data returned from the GraphQL query for the single entry.\");\n      return null;\n    }\n    return parseMarkdownContent(object.text, path);\n  }\n\n  if (!object || !object.entries) {\n    console.error(\"No data returned from the GraphQL query for multiple entries.\");\n    return [];\n  }\n\n  const parsedEntries = await Promise.all(\n    object.entries.map((entry: { object: { text: any } }) =\u003e {\n      const content = entry.object.text;\n      return parseMarkdownContent(content, path);\n    })\n  );\n\n  parseAndMergeTags(parsedEntries);\n\n  return parsedEntries;\n}\n```\n\n### File structure\n\n```\n.\n├── README.md\n├── content\n│   ├── art\n│   │   └── txt.md\n│   ├── notes\n│   │   └── txt.md\n│   ├── posts\n│   │   └── txt.md\n│   └── recipes\n│       └── txt.md\n└── templates\n    └── base_template.md\n```\n\nThe 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.\n\n```typescript\n---\nimport { Markdown } from \"astro-remote\";\n\nconst { path, slug } = Astro.params;\nconst entry = await getObsidianEntries(path, slug);\nconst { body, frontmatter } = entry;\n---\n\n\u003carticle\u003e\n  \u003cMarkdown\n    components={{ img: Image, p: Paragraph }}\n    sanitize={{\n    dropElements: [\"head\", \"style\"],\n    allowCustomElements: true,\n    }}\n  \u003e\n    {body}\n  \u003c/Markdown\u003e\n\u003c/article\u003e\n```\n\n```typescript\n---\nimport { getObsidianEntries } from \"@lib/github\";\n\nconst { path } = Astro.params;\nentries = entries.sort((a, b) =\u003e new Date(b.frontmatter.created).getTime() - new Date(a.frontmatter.created).getTime());\n---\n\n\u003c\u003e\n  {\n    entries.map((entry) =\u003e (\n      \u003cli\u003e\n        \u003cp\u003e\n          \u003ca href={`/${path}/${entry.frontmatter.slug}`}\u003e{entry.frontmatter.title}\u003c/a\u003e\n        \u003c/p\u003e\n      \u003c/li\u003e\n    ))\n  }\n\u003c/\u003e\n```\n\n### Frontmatter\n\nA `base_template` populates each new file. It prompts for a title, formats a URL-safe slug, and stamps creation and modified dates.\n\n```yaml\n---\n\u003c%*\nlet title = await tp.system.prompt(\"Please enter a value\");\nlet slug = tp.file.creation_date(\"x\") + \" \" + title;\nlet formatted_slug = slug.trim().replace(/\\W+/g, '-').toLowerCase();\nawait tp.file.rename(`${formatted_slug}`);\n%\u003e\ntitle: \u003c%* tR += title; %\u003e\nslug: \u003c%* tR += formatted_slug; %\u003e\npublished: false\ncreated: \u003c% tp.file.creation_date(\"YYYY-MM-DD HH:mm\") %\u003e\nupdated: \u003c% tp.file.last_modified_date(\"YYYY-MM-DD HH:mm\") %\u003e\ntags:\n  -\n---\n```\n\nTags 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.\n\n### Images\n\nThe 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](https://github.com/jvsteiner/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`](https://github.com/jvsteiner/s3-image-uploader/releases/tag/0.2.10).\n\nMarkdown wraps `\u003cimg\u003e` in `\u003cp\u003e`, which I don't want. Astro-Remote makes it easy to unwrap by checking the rendered slot:\n\n```\n---\nlet slots = await Astro.slots.render(\"default\");\nlet slotsString = slots.toString();\n---\n\n{\n  slotsString.includes(\"img src\") ? (\n    \u003cslot /\u003e\n  ) : (\n    \u003cp\u003e\n      \u003cslot /\u003e\n    \u003c/p\u003e\n  )\n}\n```\n\nThe whole thing is cheap, fast to write in, and mine.","tags":["obsidian","cms","markdown","github","astro","nextjs","content-management","web-development"],"published":true,"createdAt":"2024-03-11T10:21:00Z","updatedAt":"2026-07-16T14:56:28Z"}
