Content Collections

Use one server-only registry for route/source mapping, locales, source and compiled representations, build iteration, and generated static assets.

Install

@pracht/content is an opt-in companion package. It owns content data; it does not choose your route structure or public agent surface. Add @pracht/markdown when you want Pracht's official Markdown route compiler.

pnpm add @pracht/content @pracht/markdown @pracht/image
pnpm add -D sharp

Define one collection

For Markdown documentation, start with defineMarkdownCollection(). It wraps the lower-level collection primitive, preserves raw Markdown for content negotiation, and compiles normal relative Markdown images into intrinsic, responsive markup through prachtImage().

content.ts
import { defineMarkdownCollection } from "@pracht/markdown";

export const docs = defineMarkdownCollection({
  name: "docs",
  root: new URL("./src/routes/docs", import.meta.url),
  routeBase: "/docs",
  images: { sizes: "(max-width: 960px) 100vw, 960px" },
});

Use the lower-level defineCollection() API when the compiled representation is not Markdown HTML or when the application needs a completely custom module shape:

Define the collection next to the Vite config so every server/build consumer imports the same registry. Sources can be listed explicitly, or discovered recursively from root.

The root can be a symbolic link; Vite's canonical module IDs still map back to the collection registry. Symbolic links inside it cannot escape the root.

content.ts
import { defineCollection, llmsTxtArtifacts } from "@pracht/content";

export const docs = defineCollection({
  name: "docs",
  root: new URL("./src/routes/docs", import.meta.url),
  routeBase: "/docs",
  locales: {
    default: "en",
    supported: ["en", "fr"],
  },
  compile({ body }) {
    return renderMarkdown(body);
  },
  module(document) {
    return `
      import { h } from "preact";
      export const markdown = ${JSON.stringify(document.raw)};
      export function Component() {
        return h("article", { dangerouslySetInnerHTML: { __html: ${JSON.stringify(document.compiled)} } });
      }
    `;
  },
  artifacts: [
    llmsTxtArtifacts({
      title: "My docs",
      sections: [{ heading: "Docs", match: "/docs" }],
    }),
  ],
});

Every document has one stable shape:

  • id, path, locale, source, and relativeSource identify it;
  • raw preserves the exact source, while body removes YAML frontmatter;
  • frontmatter is the parsed YAML mapping;
  • compiled is whatever your compiler returns.

The compiler is memoized per source. Filesystem reads reuse the compiled value until the file's mtime or size changes; Vite transforms invalidate the matching entry on add, change, or unlink.

Emit the sources themselves

rawContentArtifacts() publishes selected documents as ordinary static assets — useful for serving the Markdown behind a page so an agent (or a curl) can read the source instead of scraping the rendered HTML:

content.ts
import { defineCollection, rawContentArtifacts } from "@pracht/content";

artifacts: [
  rawContentArtifacts({
    // Return the artifact path, or `false` to skip the document.
    path: (document) => `${document.path}.md`,
    // "raw" (default) emits the full source; "body" strips YAML frontmatter.
    representation: "body",
    contentType: "text/markdown; charset=utf-8",
  }),
];

Like llmsTxtArtifacts(), the generator runs in development against the live files and is emitted to dist/client/ at build time.

Parsing frontmatter yourself

compile() already receives body with frontmatter removed and frontmatter parsed. parseFrontmatter() is the same parser exported on its own, for code outside a collection — a script, a test, a custom loader:

import { parseFrontmatter } from "@pracht/content";

const { frontmatter, body } = parseFrontmatter<{ title: string }>(raw);

It throws a TypeError when the frontmatter block is not a YAML mapping, and returns { frontmatter: {}, body: raw } when there is no block at all.

Add the Vite integration

Place prachtContent() and prachtImage() before pracht(). They transform registered source modules in both client and server graphs, serve generated artifacts and image variants live in development, and emit the same files for production.

vite.config.ts
import { prachtContent } from "@pracht/content/vite";
import { prachtImage } from "@pracht/image/vite";
import { pracht } from "@pracht/vite-plugin";
import { defineConfig } from "vite";
import { docs } from "./content";

export default defineConfig({
  plugins: [prachtContent({ collections: [docs] }), prachtImage(), pracht()],
});

Resolve content on the server

The package is server-only. Loaders and other deployed server code consume a filesystem-free snapshot generated from the same registry. Import it by collection name so Cloudflare, Vercel, and dist-only Node deployments do not need the source tree at request time.

src/server/docs-loader.ts
import { contentLoader } from "@pracht/content/runtime";
import docs from "virtual:pracht/content/docs";

export const loader = contentLoader(docs, {
  select(document) {
    return {
      html: document.compiled,
      title: document.frontmatter.title,
    };
  },
});

Snapshot frontmatter and compiled values must be JSON-serializable. Add @pracht/content/virtual to compilerOptions.types for the generic virtual module declaration; applications can augment it when they want exact compiled and frontmatter types.

Locale lookup falls back to the default locale unless fallback: false is requested. resolveById() and resolveByRoute() additionally report whether the returned document is a fallback, so applications can make that visible or redirect to the canonical locale URL. Every configured fallback target must be included in supported; invalid fallback configuration is rejected when the collection is defined. routePrefix: "never" deliberately shares one route between translations; pass locale during lookup to select one.

Agent-facing surfaces stay opt-in

llmsTxtArtifacts() generates curated /llms.txt and /llms-full.txt files from collection metadata and source. It is separate from Pracht's app-graph llmsTxt option: use the framework option for a route/API/capability index, and the collection helper when titles, descriptions, sections, and full source are the desired policy. Enabling both at /llms.txt fails the build instead of silently overwriting the collection output. Generated artifacts also cannot share a path with a file in public/; the build rejects the collision before a later copy can replace generated bytes while retaining their generated headers. The same preflight rejects prerendered-page overlaps, exact request-time page or API paths, clean-URL index.html aliases, concrete ISG paths served by an adapter function, Pracht's internal content-header path, and portable case-folded or file/directory collisions, including Netlify control-file paths. Custom artifact content types are also applied to non-HTML static assets by the Node, Cloudflare, Netlify, and Vercel adapters. Artifacts inside an /assets/ path use revalidation caching because their filenames are not required to contain a content hash.

@pracht/content/capabilities also exports page and basic full-text-search field factories. Wrap their input, output, and run fields in an app-owned literal defineCapability({ ... }) call. That keeps the effect, middleware, exposure, and agentPolicy visible to pracht verify; omitting expose keeps the capability private. The page helper returns a missing result for malformed routes and unsupported locales instead of turning agent input into an execution failure. Both helpers advertise supported locales for localized collections, while the search helper leaves unlocalized results intact when a locale hint is supplied.

The complete API and extension points live in packages/content/README.md.