Abdulkader Safi

Making an Obsidian vault render on the web

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.

5 min read

Share
A GitBasedDocs page with the folder sidebar and rendered Obsidian Markdown

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.

Last updated 10 Sep 2026 · filed under Typescript, nextjs, frontend, obsidian

Every entry on this project

19 build notes, in order

Including the ones where nothing worked. You are on part 10.

Read the series →

FAQ

Frequently asked questions

Does GitBasedDocs support Obsidian note transclusion?

No. An embed like `![[Other note]]` renders as a link to that note. Inlining another page would need cycle handling and its own per-page access check, so it was left out. Image embeds like `![[diagram.png|300]]` do render, with the size applied.

Can a reader see Obsidian %% comments?

No. Comments are stripped during sync, before the page is stored, so they never reach the database, the search index or the rendered page. Fenced code keeps its `%%`, and an unclosed `%%` hides the rest of the page, the same as in Obsidian.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from GitBasedDocs

All 19 entries →
  • The GitBasedDocs search palette showing results for a partial word
    GitBasedDocs

    · part 19 of 19

    Search that quotes whatever you type

    GitBasedDocs search moved from LIKE over every page body to SQLite FTS5 kept in step by triggers. Word prefixes and accent folding came with the tokenizer. The real work was quoting input, choosing a stable order, and a backup detail about rowids.

  • A GitBasedDocs doc page with a Linked from list under the article
    GitBasedDocs

    · part 18 of 19

    Backlinks that use the renderer's own link rules

    GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.

  • A docker-compose.yml with BACKUP_ENABLED set to true and a folder of dated backup archives
    GitBasedDocs

    · part 17 of 19

    Backups behind one flag in docker compose

    GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.