Vault Loader
@stnd/loader/vault is a custom Astro content loader that turns an Obsidian vault into a genuine Astro content collection. It pre-filters published notes at load time so only the notes you’ve explicitly marked with publish: true enter the build pipeline.
Why a Custom Loader?
Astro’s built-in glob() loader works well for most content directories, but Obsidian vaults are a different beast:
-
Scale — A typical vault has thousands of markdown files. Most are private drafts, clippings, or templates. Only a fraction are meant to be published. The
glob()loader would parse all of them through the full markdown pipeline (including Shiki syntax highlighting), causing slow syncs and noisy logs. -
Malformed YAML — Obsidian files accumulate frontmatter from plugins, templates, and manual edits. Duplicate keys, null values, unquoted colons —
glob()usesjs-yamlwhich throws on these. The vault loader uses theyamlpackage with{ uniqueKeys: false }wrapped in try/catch, so malformed frontmatter is silently skipped instead of crashing the build. -
Edge cases — Some vault paths contain characters that confuse file readers (e.g.
#in filenames). The vault loader handles these gracefully.
Installation
The loader lives in packages/loader/vault/ and is referenced as @stnd/loader/vault via the monorepo workspace.
pnpm add @stnd/loader/vault
Usage
Content Config
// src/content.config.ts
import { defineCollection, z } from “astro:content”;
import { vaultLoader } from “@stnd/loader/vault”;
const vault = defineCollection({
loader: vaultLoader({ path: “./vault” }),
schema: z.object({
title: z.string().optional(),
publish: z.boolean().default(false),
visibility: z.enum([“public”, “unlisted”, “private”]).default(“public”),
// … your other fields
}).passthrough(),
});
export const collections = { vault };
Options
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
(required) | Path to vault directory, relative to project root |
ignore |
string[] |
[“.obsidian”, “.trash”, “.agent”] |
Directories to skip inside the vault |
pattern |
string |
“**/*.md” |
Glob pattern for markdown files |
Frontmatter Contract
The loader enforces a simple publishing contract via frontmatter:
—
publish: true # Required — only true enters the collection
visibility: public # public (default), unlisted, or private
permalink: /now/ # Canonical URL (highest priority for slug)
slug: my-custom-slug # Fallback URL
title: My Page Title # Used to generate slug if no permalink/slug
—
Slug resolution priority: permalink → slug → slugify(title) → slugify(filename)
The vault’s folder structure is completely ignored for routing. Only frontmatter decides the public URL.
What the Loader Produces
Entry Data
Each published entry gets three computed fields injected into data:
| Field | Example | Description |
|---|---|---|
_slug |
“explorer” |
Canonical URL segment |
_url |
“/explorer” |
Absolute path |
_filepath |
“/Users/…/vault/Section/Explorer.md” |
Absolute path to source file |
These fields are prefixed with _ to avoid collisions with user frontmatter.
Shared Indexes
The loader builds two indexes during content sync, accessible via getVaultMeta():
import { getVaultMeta } from “@stnd/loader/vault”;
const { validLinks, imageIndex, vaultPath } = getVaultMeta();
| Index | Type | Purpose |
|---|---|---|
validLinks |
Map<string, string> |
Maps slugs, titles, and filenames to URLs — used by Press for [[wikilink]] resolution |
imageIndex |
Map<string, string> |
Maps image filenames to absolute paths — used for ![[image.png]] resolution |
vaultPath |
string |
Absolute path to the vault directory |
Published Entries
All published entries are also available via getVaultEntries():
import { getVaultEntries } from “@stnd/loader/vault”;
const entries = getVaultEntries();
// Each entry: { id, slug, url, body, data, filepath }
Route Patterns
Static Paths (Dynamic Route)
—
import { getVaultEntries } from “@stnd/loader/vault”;
export async function getStaticPaths() {
const entries = getVaultEntries();
return entries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
—
Regular Page (Non-Dynamic)
—
import { getVaultEntries, getVaultMeta } from “@stnd/loader/vault”;
const entries = getVaultEntries();
const { validLinks, imageIndex } = getVaultMeta();
—
⚠️ Known Limitation: getCollection() Is Empty in Dev Mode
Status: Active workaround in place. Tracking for upstream fix.
The Problem
When using @stnd/loader/vault (or any custom Astro content loader), calling getCollection(“vault”) returns an empty array in dev mode, even though the loader successfully stores entries in the content store.
The build works perfectly — 143+ pages generated. But in dev mode, every dynamic route returns a 404 because getStaticPaths() sees zero entries.
Root Cause
In dev mode, Astro’s getCollection() reads from an ImmutableDataStore that is hydrated via a Vite virtual module (astro:data-layer-content). This virtual module serializes the data-store.json file into an ES module using dataToEsm().
The problem occurs when routes are injected via the standard() integration (using Astro’s injectRoute() API) rather than living in src/pages/. The injected routes run in Vite’s SSR pipeline, where the astro:data-layer-content virtual module either:
- Fails to resolve (the
import()silently catches the error and returns an empty store), or - Resolves to a stale/empty version due to module graph timing
The ImmutableDataStore.fromModule() method has an empty catch {} block that swallows the error:
// From astro/dist/content/data-store.js
static async fromModule() {
try {
const data = await import(“astro:data-layer-content”);
// … hydrate store
} catch {
// ← Error is silently swallowed
}
return new ImmutableDataStore(); // ← Empty store returned
}
This means getCollection() works in build (where the store is read synchronously from the filesystem) but fails in dev (where it depends on Vite’s virtual module resolution).
The Workaround
Instead of depending on getCollection(), the vault loader stores all published entries on globalThis:
// In the loader (at content sync time):
globalThis.__vaultMeta = { validLinks, imageIndex, vaultPath };
globalThis.__vaultEntries = entries;
// In route files (at request time):
import { getVaultEntries, getVaultMeta } from “@stnd/loader/vault”;
const entries = getVaultEntries(); // reads globalThis.__vaultEntries
const meta = getVaultMeta(); // reads globalThis.__vaultMeta
globalThis survives across Vite’s code-splitting and module graph — it’s the same object in the loader context and the SSR route context. This is the same pattern Astro’s own globalThis.astroAsset uses internally.
The content collection config (content.config.ts) still uses the vault loader with store.set() — this ensures Astro’s type generation and build pipeline work correctly. But route files never call getCollection(). They read from globalThis via the exported helper functions.
What This Means for You
- Always use
getVaultEntries()andgetVaultMeta()instead ofgetCollection(“vault”)in route files. getCollection(“vault”)will return[]in dev mode. This is expected. Do not rely on it.- The build is unaffected. Production builds work correctly regardless.
- The
content.config.tsschema is still validated and types are still generated — the content collection machinery works, only the runtime query API (getCollection) is broken in dev for injected routes.
Affected Astro Version
Observed on Astro 6.0.2 with Vite 7.x. May be resolved in future Astro releases. If you’re reading this and want to check, try replacing getVaultEntries() with getCollection(“vault”) in a dynamic route and run astro dev — if the collection is no longer empty, the upstream fix has landed.
Related Context
- Astro source:
astro/dist/content/data-store.js→ImmutableDataStore.fromModule() - Astro source:
astro/dist/content/vite-plugin-content-virtual-mod.js→RESOLVED_DATA_STORE_VIRTUAL_ID - The
glob()built-in loader does not have this problem because it’s wired directly into Astro’s content pipeline with special handling for the virtual module.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Content Sync │
│ │
│ vault/*.md │
│ │ │
│ ▼ │
│ vaultLoader() │
│ │ │
│ ├── Parse frontmatter (yaml, try/catch) │
│ ├── Gate: publish === true && visibility !== “private” │
│ ├── Compute _slug, _url, _filepath │
│ ├── store.set({ id, data, body, digest }) │
│ ├── Build validLinks map │
│ ├── Build imageIndex map │
│ └── Store on globalThis │
│ ├── __vaultMeta { validLinks, imageIndex, path } │
│ └── __vaultEntries [ { id, slug, url, … } ] │
│ │
├─────────────────────────────────────────────────────────────────┤
│ Route Rendering │
│ │
│ […slug].astro │
│ │ │
│ ├── getVaultEntries() → entry list from globalThis │
│ ├── getVaultMeta() → validLinks + imageIndex │
│ ├── Press(body, { validLinks }) → HTML │
│ └── press.transformImages(imageIndex) → copy to public/ │
│ │
└─────────────────────────────────────────────────────────────────┘
Performance
With a vault of ~2,600 markdown files and ~4,300 images:
| Metric | Value |
|---|---|
| Content sync | ~2 seconds |
| Files scanned | ~2,600 |
| Published entries | ~142 |
| Skipped entries | ~2,450 |
| Images indexed | ~4,300 |
| Build time (total) | ~22 seconds |
| Pages generated | 143 |
The custom loader is approximately 5–10x faster than glob() on the same vault because it never invokes the markdown pipeline (Shiki, remark, rehype) on unpublished files.
See Also
- CLI Documentation — Command-line interface reference
- API Documentation — REST API reference
- The Obsidian Protocol — Publishing from Obsidian