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:

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: permalinkslugslugify(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:

  1. Fails to resolve (the import() silently catches the error and returns an empty store), or
  2. 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

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.


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

Are you absolutely sure?

This action cannot be undone.