Skip to content

Obsidian vault as content

An Obsidian vault is a folder of Markdown files, which is exactly what a content collection reads. This guide publishes one as a website directly from the folder: no copy step, no export script, and the vault stays the place you write.

It uses the conventions of a developer knowledge base as the example, where each note marked for publication becomes a page of the public site and the rest stay private. The same setup fits any vault that has some pages to publish and some to keep private.

The vault

The layout that makes this easy is one folder per topic, each with a README.md that indexes it:

README.md
STYLE.md
.obsidian/
Frappe/
  README.md
  Bench Operations.md
  Custom Pages.md
Git & Dev Tools/
  README.md
  Git Cheatsheet.md
WordPress/
  README.md
  Gravity Forms/
    README.md
    Submission Overlay Spinner.md
internal/
  Company/
  Drafts/
Sites/
  website/
    project.json
    pages/

File names are Title Case with spaces, because that is how a person names a note. The website project lives inside the same repository ( Sites/website ), which is why its content source will be ../.. .

Every document starts with the same frontmatter:

---
title: Bench Operations
description: Backup and restore, switching branches, and schema repair for a Frappe bench.
slug: bench-operations
category: Frappe
tags: [frappe, bench]
created: 2024-08-26
updated: 2026-10-01
status: review
publish: true
---

The body has no H1, because the title comes from frontmatter. Callouts use GitHub's alert notation, links between notes are relative with %20 for spaces, and every code fence names its language:

Run these from the bench directory. Swap setup is in [Swap Configuration](../Linux/Swap%20Configuration.md).

> [!WARNING]
> Take a backup first. Restoring replaces the site.

```bash
bench --site <SITE_NAME> backup
```

Wire up the content type

Enable the parser and search extensions, then describe the collection in Sites/website/project.json :

{
  "extensions": ["@jxsuite/parser", "@jxsuite/search"],
  "content": {
    "kb": {
      "source": "../..",
      "format": "Markdown",
      "exclude": [
        "internal/**",
        "Sites/**",
        "STYLE.md",
        "README.md",
        ".*/**",
        "**/node_modules/**"
      ],
      "where": { "publish": true, "status": { "$ne": "draft" } },
      "route": "/kb/{category:slug}/{slug}/",
      "indexRoute": "/kb/{dir:slug}/",
      "schema": {
        "type": "object",
        "properties": {
          "title": { "type": "string" },
          "description": { "type": "string" },
          "slug": { "type": "string" },
          "category": { "type": "string" },
          "tags": { "type": "array", "items": { "type": "string" } },
          "created": { "type": "string", "format": "date" },
          "updated": { "type": "string", "format": "date" },
          "publish": { "type": "boolean" },
          "status": { "type": "string" }
        },
        "required": ["title", "description", "slug", "category"]
      }
    }
  },
  "search": { "collections": { "kb": { "basePath": "/kb/" } } }
}

These options do the work that would otherwise need a script:

  • exclude keeps whole folders and single files out. The private internal/ notes, the website project itself, the style guide, the vault's root README and Obsidian's own .obsidian folder are never read, so none of them can be validated, routed, linked or searched, and a published note cannot pull one into the site by referring to it either. Add Obsidian's templates folder here too: a template with placeholder frontmatter is a note that fails to load.

  • where publishes only documents with publish: true that are not drafts. A note without those keys is out, which is the safe default for a vault.

  • route gives each document its URL from its own frontmatter. {category:slug} turns Git & Dev Tools into git-and-dev-tools , and indexRoute sends each folder's README.md to the folder's own page.

  • schema still validates what is published, and only that.

One page serves the whole collection

Add pages/kb/[...path].json . It names no parameter, because the route decides every URL:

{
  "$layout": "./layouts/docs.json",
  "$paths": { "contentType": "kb" },
  "title": "${state.page.data.title}",
  "state": {
    "page": { "$prototype": "ContentEntry", "contentType": "kb" }
  },
  "children": [
    {
      "tagName": "article",
      "style": {
        "& .jx-alert": {
          "margin": "1.5rem 0",
          "padding": "0.75rem 1rem",
          "borderLeft": "3px solid currentColor"
        },
        "& .jx-alert-title": { "margin": "0 0 0.25rem", "fontWeight": "600" }
      },
      "children": "${state.page.$children ?? []}"
    }
  ]
}

The compiler hands the page's URL pattern ( /kb/* ) to the collection, which returns one set of parameters per routed entry. The same $paths would serve pages/kb/[category]/[slug].json and pages/kb/[category].json as separate article and section pages. The ContentEntry with no id binds to the entry whose route is this page's own URL.

A listing page, such as a section's index, links to entries with the URL the loader worked out:

{
  "state": {
    "docs": { "$prototype": "ContentCollection", "contentType": "kb", "sort": { "field": "title" } }
  },
  "tagName": "ul",
  "children": {
    "$prototype": "Array",
    "of": { "$ref": "#/state/docs" },
    "map": {
      "tagName": "li",
      "children": [
        {
          "tagName": "a",
          "attributes": { "href": "${item._meta.url}" },
          "textContent": "${item.data.title}"
        }
      ]
    }
  }
}

What happens to each file

File Result
Frappe/Bench Operations.md , published page at /kb/frappe/bench-operations/
Frappe/README.md the section page at /kb/frappe/
WordPress/Gravity Forms/README.md /kb/wordpress/gravity-forms/
a note with status: draft left out by where , with no route and no warning
a note with publish: false left out by where
anything under internal/ or Sites/ never read, even if it says publish: true
STYLE.md , the root README.md never read
.obsidian/ never read, and never even listed

The folders are not part of the URL. The category comes from frontmatter, so moving a note between folders does not change its address unless you also change category .

Callouts

> [!NOTE] , > [!TIP] , > [!IMPORTANT] , > [!WARNING] and > [!CAUTION] render as accessible callouts: a div with role="note" , a visible title, and jx-alert-* classes for your stylesheet. A title after the marker, as in > [!TIP] Faster restores , replaces the default one, and callouts work inside list items.

If your site already has callout components, map the types onto them with "alerts": { "NOTE": "doc-note", "TIP": "doc-tip", "WARNING": "doc-warning" } in the content type. Obsidian's other callout types ( [!info] , [!example] , [!faq] ) are not enabled until you say so with "alerts": { "INFO": true } ; until then they stay blockquotes and the build names each one. See Callouts for the details.

The links you write for Obsidian work as written. [Swap](../Linux/Swap%20Configuration.md#Create%20a%20Swap%20File) becomes /kb/linux/swap-configuration/#create-a-swap-file , and [the folder](../Frappe/) becomes /kb/frappe/ .

A link to a note that is not published is shown as plain text and the build says so:

Content links: "kb": "Frappe/Bench Operations.md" links to "Draft%20Recipe.md", which is not published (left out by where.status); it renders as plain text.
Content links: "kb": "Frappe/Bench Operations.md" links to "../internal/Plan.md", which is not published (excluded by "internal/**"); it renders as plain text.

That is the right default while you write, because an unpublished note should not break the build. For a deploy, add "links": "error" (or set it from CI) and every broken link fails the build, listed together.

Code

Fences are highlighted at build time with light and dark colors. Besides the web languages, the set covers what a notes vault tends to contain: sql , php , python , ruby , nix , nginx , caddyfile , toml , ini , diff , xml and dockerfile . A fence with no language, or text , stays plain. Braces in code are left alone: {{ doc.name }} , ${first} and ${{ secrets.TOKEN }} come out exactly as written, because the text of a note is never evaluated as a template.

Check the build

Run jx build and read three things:

  • The warnings. Every Content links , Content routes and Content ids line names the file involved. Two notes with the same category and slug are reported with both file names, and only the first is routed.

  • dist/kb/ . The folders there are exactly the published set.

  • dist/search-index.json . It lists the same pages, at the same URLs.

Note

A vault of Markdown can contain text that looks like something else. Jx never evaluates the text of a note: a code fence, a heading or a paragraph that contains ${ stays exactly as written, and so do image descriptions, link titles and callout titles. A link's own href is the one place a template is still read, because a link target may legitimately name state. where and exclude take no code, only data.

What is not supported

  • A link to an attachment ( [report](files/report.pdf) ) is published with the note that links it: the file is copied to /content/kb/files/report.pdf and the link points there, the same way an image does. An attachment the type excludes, or a folder with no README.md or index.md , is reported like any other broken link and shown as text. A hidden file (a name starting with a dot) and a Markdown document are never served as attachments.

  • Obsidian wikilinks ( [[Note]] ) and embeds ( ![[Note]] ) are plain text to the parser. Turn off Use [[Wikilinks]] in the vault's Files and Links settings so links are written as Markdown links.

  • A foldable callout ( > [!NOTE]- ) is shown open. Plugin syntax such as Dataview blocks is not interpreted.

  • A symlink inside the vault is followed wherever it points, so a folder linked in from elsewhere publishes with the rest. That is trusted input, like every other file in the source: only commit links you would commit files for. A link back into a folder the walk is already inside is skipped.

  • Studio's canvas shows callouts and relative links as written, because it opens a file through the component parser. The built site and build previews show them rendered.

  • A link whose file name differs from the real one only by case resolves when exactly one file matches, so a vault written on a case-insensitive filesystem still publishes from a Linux CI runner. Two files that differ only by case make such a link ambiguous and it is reported.