Content Collections

Content collections provide typed Markdown content with YAML frontmatter. They are ideal for docs, blogs, and any structured content.

# Defining a collection

Collections are defined in src/content/config.ts. Each collection is a directory under src/content/<name>/ containing .md files:

// src/content/config.ts
import { defineCollection } from "@elurjs/kit/content";
import { z } from "zod"; // optional peer dep

export const collections = {
  blog: defineCollection({
    schema: z.object({
      title: z.string(),
      date: z.date(),
      tags: z.array(z.string()).optional(),
    }),
  }),
  docs: defineCollection({
    // no schema — frontmatter is untyped
  }),
};

defineCollection accepts { schema?: unknown }. The collection name comes from the key in the collections object, and the directory is src/content/<name>/.

ℹ Note

Collection names must be alphanumeric (plus - and _) — they are validated against the content root to prevent path traversal.

# Frontmatter

Each Markdown file has YAML frontmatter:

---
title: Getting Started
description: Learn the basics of Elur.
section: Getting Started
order: 5
---

# Getting Started

Content here...

# Querying content

# getCollection<T>(name)

Returns all entries in a collection:

import { getCollection } from "@elurjs/kit/content";
import type { DocMeta } from "../lib/docs-nav";

const entries = await getCollection<DocMeta>("docs");

# getEntry<T>(name, slug)

Returns a single entry by slug:

import { getEntry } from "@elurjs/kit/content";

const entry = await getEntry<DocMeta>("docs", "getting-started/introduction");

# renderMarkdown(source)

Renders a Markdown string to HTML using marked (optional peer dep):

import { renderMarkdown } from "@elurjs/kit/content";

const html = await renderMarkdown(entry.body);
// Returns an HTML string

Throws if marked is not installed.

# renderEntryHTML(entry)

Renders the Markdown body of an entry to HTML (cached on the entry object):

import { renderEntryHTML } from "@elurjs/kit/content";

const html = await renderEntryHTML(entry);
// Renders only the markdown body, not the frontmatter

# raw(html)

Inserts raw HTML into a template without escaping:

import { raw } from "@elurjs/kit/content";
import { html } from "@elurjs/core";

html`<article>${raw(await renderEntryHTML(entry))}</article>`;

# Using in pages

// src/app/docs/[...slug]/page.data.ts
import { getEntry } from "@elurjs/kit/content";

export const load = async ({ params }) => {
  const slug = Array.isArray(params.slug) ? params.slug.join("/") : params.slug;
  const entry = await getEntry("docs", slug);
  if (!entry) throw new Response("Not Found", { status: 404 });
  return { entry, title: entry.data.title };
};

# Slug convention

The slug is derived from the file path relative to the content directory, without the extension:

content/docs/getting-started/introduction.md  →  slug: "getting-started/introduction"
content/docs/core/signals.md                  →  slug: "core/signals"

# getEntries(collection, slugs)

Returns multiple entries by slug:

import { getEntries } from "@elurjs/kit/content";

const posts = await getEntries("blog", ["hello-world", "second-post"]);
// Array of entries matching the slugs

getCollection(collection) returns all entries, sorted by frontmatter date descending (if present), otherwise by slug ascending.

# Frontmatter parsing

# parseFrontmatter(raw)

Parses a raw YAML-subset frontmatter string (without the --- fences) into a plain object — split the document first with splitFrontmatter, or use parseDocument to do both in one call:

import { splitFrontmatter, parseFrontmatter } from "@elurjs/kit/content";

const { raw, body } = splitFrontmatter(text);
const data = parseFrontmatter(raw);
// data: { title: "Hello" }

# splitFrontmatter(source)

Splits frontmatter from body without parsing. Returns { raw, body }:

import { splitFrontmatter } from "@elurjs/kit/content";

const { raw, body } = splitFrontmatter(text);
// raw: frontmatter string without --- fences
// body: markdown body

# parseDocument(source)

Parses frontmatter and returns structured document:

import { parseDocument } from "@elurjs/kit/content";

const { data, body } = parseDocument(text);
// data: parsed frontmatter object
// body: markdown body (trimStart applied)

# Schema validation

# createValidator(schema)

Creates a validator function from a schema (zod schema with .parse() or a plain function). Returns undefined if no valid schema is provided:

import { createValidator } from "@elurjs/kit/content";
import { z } from "zod";

const validator = createValidator(z.object({ title: z.string() }));
if (validator) {
  const parsed = validator({ title: "Hello" }, "/path/to/file.md");
}

Validation is duck-typed — any object with .parse() or a plain function works. When a collection has a schema but zod isn't installed, entries still load; the schema is only enforced when createValidator can build a validator from it (an unrecognized shape returns undefined, skipping validation silently).

# getZod()

Returns a Promise that resolves to the zod instance. Throws if zod is not installed:

import { getZod } from "@elurjs/kit/content";

const z = await getZod();
const schema = z.object({ title: z.string() });

# Per-request scope

The content root defaults to <cwd>/src/content and is set automatically by the Vite plugin / CLI on startup via setContentRoot(). Content collections use AsyncLocalStorage for per-request scope — set the content root per request to prevent global state leaking between concurrent requests:

import { withContentRoot, setContentRoot, clearContentCache } from "@elurjs/kit/content";

// Wrap a request handler
withContentRoot("./src/content", () => {
  // getEntry/getEntries use this root
});

// Or set manually
setContentRoot("./src/content");
// ... queries ...

# clearContentCache()

Clears the in-memory content cache (useful after file changes in dev):

import { clearContentCache } from "@elurjs/kit/content";
clearContentCache();

# Types

# CollectionDefinition

interface CollectionDefinition {
  /** Schema for frontmatter validation (zod schema or plain function). */
  schema?: unknown;
}

# CollectionsConfig

type CollectionsConfig = Record<string, CollectionDefinition>;

# ContentEntry<TData>

Field Type Description
collection string Collection name, e.g. "blog"
slug string Entry slug (filename without .md)
data TData Parsed and validated frontmatter
body string Raw Markdown body
html string? Rendered HTML body (lazily computed)
filePath string Absolute path to the source .md file

# SchemaValidator

interface SchemaValidator {
  (data: unknown, filePath: string): Record<string, unknown>;
}

A function that validates raw frontmatter data and returns the typed result. createValidator(schema) returns one when zod is installed, or undefined when it is not.

# Limitations

  • Markdown only — collections read .md files; JSON, YAML data files, and MDX are not supported.
  • Frontmatter is a YAML subset — flat scalar keys and simple lists/maps only. Nested mappings are rejected deliberately; no anchors or aliases.
  • renderMarkdown does not sanitize — the content model is trusted. Clean untrusted Markdown yourself before rendering.
  • The content cache is in-memory — 5 s TTL per collection (invalidated on dev HMR), does not persist across restarts. Use clearContentCache() after file changes that must be visible immediately.
  • Sorting is fixed — entries come sorted by frontmatter date descending when both entries have ISO dates (parsed to Date), otherwise by slug. There is no query API; filter/sort in your loader.