# Content Collections

Pattern for grouping related markdown content files (guides, posts, docs) under a single directory,
can be copied into a `CLAUDE.md`/`AGENTS.md` file for agents to follow.

## Structure

- A `ContentCollection` class wraps a single root directory of markdown files, with content types as
  subdirectories (e.g. `content/guides/`, `content/posts/`). Prefer one instance for the whole root
  over one instance per type, so the type names don't get hardcoded across the app.
- A slug is a file's path relative to the root, without the `.md` extension, e.g. `guides/drizzle`.
  Because the slug carries its own type prefix, it doubles as the item's URL and callers don't need
  to pass the type separately.
- `get(slug)` reads and parses a single item. Throws if no matching file exists.
- `list(filter?)` returns lightweight `{ slug, title }` summaries, for building index/listing pages
  without parsing full content. Skips files prefixed with `_` and any non-`.md` files. The optional
  `filter` is a partial path (e.g. `guides/`) that narrows results to one subtree.

## Implementation

```ts
// src/lib/content-collection.types.ts
export interface ItemSummary {
  slug: string;
  title: string;
}
```

````ts
// src/lib/content-collection.server.ts
import { readdir, readFile } from "fs/promises";

import { parseMarkdown } from "@tanstack/markdown";

import type { ItemSummary } from "./content-collection.types";

const TITLE_REGEX = /^#\s+(.+)$/m;
const MARKDOWN_EXTENSION_REGEX = /\.md$/;

/**
 * A content collection is a group of related content files,
 * stored in the same directory.
 *
 * @example
 * ```ts
 * const content = new ContentCollection("./content/");
 * const guides = await content.list("guides/");
 * const guide = await content.get(guides[0].slug);
 * ```
 */
export class ContentCollection {
  private path: string;

  /**
   * Creates a new content collection.
   *
   * @param path - The path to the directory containing the content, relative to the project root, e.g. `./content/posts/`
   */
  constructor(path: string) {
    this.path = path;
  }

  /**
   * Reads and parses a single content item by its slug.
   *
   * @param slug - The item's filename without the `.md` extension
   *
   * @remarks
   * Throws if no matching markdown file exists in the collection's directory.
   */
  public async get(slug: string) {
    const text = await readFile(`${this.path}/${slug}.md`, "utf-8");
    return parseMarkdown(text);
  }

  /**
   * Lists all content items in the collection.
   *
   * @param filter - Optional partial path; only items whose slug starts with it are returned
   *
   * @remarks
   * Skips files whose names start with `_`, and files that don't end in `.md`.
   */
  public async list(filter?: string): Promise<ItemSummary[]> {
    const files = await readdir(this.path, { recursive: true });
    // Prefix check runs first so non-matching paths skip the more expensive extension checks.
    const markdownFiles = files.filter(
      (file) =>
        (!filter || file.startsWith(filter)) && file.endsWith(".md") && !file.startsWith("_"),
    );
    return Promise.all(
      markdownFiles.map(async (file) => {
        const slug = file.replace(MARKDOWN_EXTENSION_REGEX, "");
        const title = await this.getMarkdownTitle(`${this.path}/${slug}.md`);
        return { slug, title };
      }),
    );
  }

  /**
   * Extracts the title from a markdown file's first level-1 heading.
   *
   * @param filePath - Absolute or relative path to the markdown file
   *
   * @remarks
   * Throws if the file has no `# ` heading.
   */
  private async getMarkdownTitle(filePath: string) {
    const text = await readFile(filePath, "utf-8");
    const match = text.match(TITLE_REGEX);
    if (!match?.[1]) {
      throw new Error(`No title found in markdown file: ${filePath}`);
    }
    return match[1].trim();
  }
}
````

## Usage

```ts
// src/lib/content-collection.server.ts
export const CONTENT = new ContentCollection("./content/");
```

```ts
// route loader, for a listing page scoped to one type
const guides = await CONTENT.list("guides/");
```

```ts
// route loader for a catch-all splat route, e.g. src/routes/$.tsx
// params._splat is already the slug, e.g. "guides/drizzle"
const item = await CONTENT.get(params._splat);
```
