Skip to content

GitBasedDocs

Making an Obsidian vault render on the web

10 September 2026 Updated 10 September 2026 4 min read
A GitBasedDocs page with the folder sidebar and rendered Obsidian Markdown

GitBasedDocs now renders Obsidian callouts, wikilinks, highlights and safe inline HTML, strips %% comments before they reach the database, and keeps folder names and .trash the way Obsidian does.

The content repo behind GitBasedDocs is an Obsidian vault that also lives on GitHub. Writers see their notes in Obsidian while they type, so the site has to show a page the way Obsidian did. On September 10 I ranked the ten Markdown extensions that matter most for that (docs/research/06-markdown-extensions.md) and shipped them over a few commits. This post covers the Obsidian ones. Code highlighting and diagrams get their own post, and so does math.

Callouts with every type

GitHub knows five callout types. Obsidian adds more types and aliases, plus custom titles and folding:

> [!tip] Before you start
> Ask your admin for access first.

> [!faq]- Why is my page missing?
> Pages marked draft only show to editors.

rehypeCallouts in lib/render/markdown.ts maps every type and alias to one of five looks (note, tip, warning, danger, quote). faq and question land on warning, bug and failure on danger, example on quote. An unknown type becomes a note instead of leaving [!type] on the page.

Folding is a <details> element. - starts closed, + starts open, and neither needs any JavaScript. One limit is marked in the code with a comment: the title is the plain text after the marker, so a title that starts with bold or a link lands in the body.

[[Page]], [[Page#Heading|label]] and ![[diagram.png|300]] all work. They resolve by file name the way Obsidian does: the exact path first, then the bare name anywhere in the project. |300 sets a width and |300x200 sets both sides.

A link to a note that does not exist yet renders dimmed with the tooltip "No page with this name yet", which is what Obsidian shows. A note embed, ![[Other note]], becomes a link. I left transclusion out on purpose, because inlining another page needs cycle handling and its own access check.

Images never point at GitHub. They load from /api/assets/<project>/... behind the same access check as pages. An image that is not in the project's asset cache renders as a box that says "Image not found: diagram.png" instead of a broken request. Vaults are full of names like My Note.md, so every path segment gets URL-encoded.

Highlights

==text== becomes <mark>. The regex wants a non-space character right inside each ==, so a sentence like "if a == b and c == d" stays as it is. Highlights never apply inside code or <kbd>.

Comments that never leave the server

Obsidian writers use %% ... %% for notes to themselves. Before this change the site showed them to readers, and search indexed them, so a reader could find a private note by searching for a word in it.

Now stripComments in lib/render/comments.ts runs during sync, before the page is stored. The title, the search excerpt and the stored body all come from the stripped text, so a comment never reaches the database. It follows Obsidian's rules: fenced code keeps its %%, and an unclosed %% hides the rest of the page. A line that held only a comment is dropped, so it does not split the paragraph around it in two.

Pages synced before the change still had their comments in the database. Migration 0008 forces a full refetch:

UPDATE `doc_pages` SET `blob_sha` = NULL;
UPDATE `repo_connections` SET `last_synced_sha` = NULL;

With no stored sha, the next sync treats every page as changed and stores it again without comments.

Safe inline HTML

The first renderer dropped all raw HTML, so a <details> or <kbd> in a note simply vanished. Now rehype-raw parses the HTML and rehype-sanitize cuts it down to GitHub's allowlist. <details>, <summary>, <kbd>, <sub> and <sup> survive. <script>, <iframe>, style and on* attributes do not.

Sanitize runs before any of my own transforms, so everything the pipeline adds after it is trusted markup and user input never gets to add attributes. markdown.check.ts runs the injection cases on every change: a script tag, an onerror image, a javascript: link in both HTML and Markdown form, an iframe and an onclick div.

There was one snag. rehype-raw rebuilds the tree and drops each node's data, which is where a code fence keeps its meta (title="lib/auth.ts" {2}). The pipeline parks the meta in a data-meta attribute, allows exactly that attribute on <code> in the sanitize schema, and puts it back afterwards.

Folders keep their real names

The sidebar used to rename a folder after the title of its index.md. An untitled index page fell back to "Index", so docs/index.md turned the whole docs folder into "Index". A humanize() helper also rewrote getting-started as "Getting started".

Obsidian shows the folder name as it is on disk, so now the sidebar does too. The index page only makes the folder clickable and can set its position with order. An untitled index.md takes its folder's name as its own title.

Dot folders stay out

Obsidian can move deleted notes into a .trash folder inside the vault, and the sync used to read every Markdown file under the project folder. A note you deleted in Obsidian could come back as a live page. The fix is one function, run on every path in the tree:

export function isHiddenPath(path: string): boolean {
  return path.split("/").some((segment) => segment.startsWith("."))
}

That also keeps .obsidian and .github out, since they hold tooling and not docs.