@pyreon/zero-content — API Reference
Generated from
zero-content'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see zero-content.
Pyreon Zero's content layer. Tight coupling to zero (no standalone use) — that integration is the value: markdown pages route through zero's fs-router, view transitions enabled by default, theme + dark mode flow through zero's theme system. Markdown is parsed via the unified+remark ecosystem (battle-tested by Astro/Next/Nuxt) and walked into Pyreon JSX (not dangerouslySetInnerHTML — full reactivity + tree-shaking preserved). Content collections give Astro-style typed queries via Standard Schema inference (use zod / valibot / arktype — the package itself ships no validator runtime): getCollection('docs') returns fully-typed entries. MDX components resolve via three tiers: built-ins (<Callout>, <CodeGroup>, <CodeBlock>) → convention-scanned src/mdx/**/*.tsx (PascalCase exports, drop a file and use it) → per-.md import statements (one-offs) → escape-hatch defineComponents wrappers for per-collection overrides.
Features
Compile-time .md/.mdx → Pyreon JSX (no dangerouslySetInnerHTML — full reactivity + tree-shaking preserved)
MDX support: JSX-in-markdown + top-of-file
importstatements hoisted to the compiled .tsxsrc/mdx/ convention scan — drop a PascalCase .tsx file, use the component in any .md by name (no wiring)
Virtual module
virtual:zero-content/componentsre-exports the scanned set + the built-insAstro-style typed content collections via Standard Schema inference + emitted .pyreon/content-types.d.ts
getCollection<K>(name) / getEntry<K, S>(...) / getEntries<K>(...) runtime queries with full type inference
Standard Schema-compatible frontmatter validation — BYO validator (zod, valibot, arktype, typia; all work via duck-typing, no validator runtime in this package)
Three-tier MDX component resolution: built-ins → src/mdx/ convention scan → per-.md imports → escape-hatch defineComponents
Built-in components: Callout, CodeGroup, CodeBlock (more candidates — Playground, PackageBadge, Tabs — live in
docs/src/mdx/and are author-side until promoted)Shiki syntax highlighting with shared instance + dual light/dark theme baked into one emit (no runtime cost)
Custom markdown blocks: :::tip / :::warning / :::note / :::danger / :::info / :::code-group via remark-directive
Build-time validation: frontmatter (zod) + component props (TS) + unknown component name (with "did you mean...?")
HMR for src/mdx/ changes + content.config.ts edits — invalidates virtual modules, re-renders dependent .md pages without reload
Built-in search — minisearch-backed
<Search>component with Cmd+K + debounced query + SPA navigation + lazy index loadingBuilt-in layout components —
<Sidebar>with groups + active highlighting,<Toc>with scroll-spy via IntersectionObserverFrontmatter JSON Schema + .vscode-settings emission — autocomplete + validation in any .md file via the YAML extension
Inherits zero's perf stack — image/font auto-wire, script defer default, resource hints, view transitions
Complete example
A full, end-to-end usage of the package:
// vite.config.ts
import pyreon from '@pyreon/vite-plugin'
import zero from '@pyreon/zero'
import content from '@pyreon/zero-content/plugin'
export default { plugins: [pyreon(), zero(), content()] }
// content.config.ts — Standard Schema-compatible validators only; the
// package itself does NOT re-export zod. Bring your own (zod / valibot /
// arktype / typia). See @pyreon/validation for the curated adapters.
import { defineConfig, defineCollection } from '@pyreon/zero-content'
import { z } from 'zod'
export default defineConfig({
collections: {
docs: defineCollection({
type: 'pages',
path: 'src/content/docs',
schema: z.object({
title: z.string(),
description: z.string(),
sidebar: z.object({ order: z.number(), group: z.string() }).optional(),
}),
}),
},
})
// User code anywhere — typed via Standard Schema inference
import { getCollection } from '@pyreon/zero-content'
const docs = await getCollection('docs')
// ^? Array<{ slug: string; data: { title: string; description: string; ... }; render: () => Promise<ComponentFn> }>Exports
| Symbol | Kind | Summary |
|---|---|---|
defineConfig | function | Top-level configuration helper. |
defineCollection | function | Per-collection definition. |
defineComponents | function | Wrap a map of MDX components. |
getCollection | function | Runtime query — returns every entry in a collection. |
Callout | component | Built-in callout box. |
CodeGroup | component | Tabbed code blocks. |
CodeBlock | component | Wrapper around a Shiki-rendered code block. |
Example | component | The Pyreon-native replacement for iframe-sandboxed <Playground>. |
registerExamples | function | Register the consumer's example files for <Example file="./..."> lookups. |
getOrCreateSharedSignal | function | Module-level registry of Signal<T> instances keyed by string. |
API
defineConfig function
defineConfig(config: ContentConfig): ContentConfigTop-level configuration helper. Pass-through factory that preserves the literal type of collections so downstream type inference works. Lives in content.config.ts at the project root; the plugin auto-discovers it.
Example
// content.config.ts — BYO validator (zod / valibot / arktype / typia)
import { defineConfig, defineCollection } from '@pyreon/zero-content'
import { z } from 'zod'
export default defineConfig({
collections: {
docs: defineCollection({
type: 'pages',
schema: z.object({ title: z.string() }),
}),
},
})Common mistakes
Importing
zfrom@pyreon/zero-content— the package does NOT re-export zod. Bring your own validator (zod, valibot, arktype, typia all duck-type onto Standard Schema). See @pyreon/validation for curated adapters.Adding components in
vite.config.tsinstead ofcontent.config.ts. The vite config is build orchestration; content components live in user space.Forgetting the
default export. The plugin readscontent.config.tsvia dynamic import and reads the default export.Putting collections under a path that doesn't exist. Default
pathissrc/content/<collection-name>; either create that directory or override withpath:.
defineCollection function
defineCollection<TSchema>({ type, path?, schema, components?, searchable? }): CollectionDefinition<TSchema>Per-collection definition. type: 'pages' triggers route generation under src/routes/_content/<name>/[...slug].tsx (auto-gitignored); type: 'data' is queryable via getCollection/getEntry but not routed. Schema is a zod schema — frontmatter is validated against it at build with file:line errors on mismatch.
Example
defineCollection({
type: 'pages',
path: 'src/content/docs',
schema: z.object({
title: z.string(),
description: z.string(),
sidebar: z.object({ order: z.number(), group: z.string() }).optional(),
}),
})Common mistakes
Returning raw
z.object(...)schemas without wrapping indefineCollection. The plugin needs the wrapper to know the collection name + type.Setting
type: "pages"for a collection that should not be routed (e.g. blog posts queried viagetCollectionfor an index page). Usetype: "data".Schema mismatches that surface only at build time. Use
pyreon doctor --check-content(PR 9) at edit time, or watch the dev server output duringbun run dev.
defineComponents function
defineComponents<T extends Record<string, ComponentFn>>(components: T): T & ComponentsRegistryWrap a map of MDX components. The brand symbol distinguishes user bundles from accidentally raw objects (which fail the build). Dev-mode validates each value is a function — catches { Playground: undefined } typos. Compose with mergeComponents.
Example
import { defineComponents } from '@pyreon/zero-content'
import { Playground, APIReference } from './components'
export default defineComponents({ Playground, APIReference })Common mistakes
Passing a raw
{...}object to acomponents:field. The plugin refuses raw objects with a build error pointing at the call site.Mixing component imports inside
vite.config.ts. Imports live in user-space files (content.config.ts or _-prefixed files under src/mdx/), never in build orchestration.
getCollection function
getCollection<K extends keyof CollectionSchemas>(name: K): Promise<CollectionEntry<CollectionSchemas[K]>[]>Runtime query — returns every entry in a collection. Data shape inferred from the collection's zod schema via the generated .pyreon/content-types.d.ts. Each entry exposes a render() lazy loader to get the page component.
Example
import { getCollection } from '@pyreon/zero-content'
const posts = await getCollection('blog')
// ^? Array<{ slug: string; data: { title: string; author: string; date: Date; ... }; render(); headings }>
for (const post of posts) {
console.log(post.data.title, post.slug)
}Common mistakes
Calling
getCollectionin a component body withoutawait. It returns a Promise. Wrap in an async setup function, use a loader, or await it during SSG render.Passing a string that isn't a defined collection. TypeScript catches this once
.pyreon/content-types.d.tsis generated; without it, you'd get a runtime error.
Callout component
<Callout type="tip"|"warning"|"note"|"danger"|"info" title? children?>Built-in callout box. Emitted automatically by the :::tip / :::warning / :::note / :::danger / :::info container syntax in markdown. Each type carries a default icon + title; pass title to override. Body content renders through the full markdown pipeline (bold, links, code, lists all work inside).
Example
// In markdown:
:::tip{title="Pro tip"}
Use **signals** for fine-grained reactivity. See [reactivity rules](/docs/reactivity).
:::
// In JSX (when used directly):
<Callout type="warning" title="Breaking change">…</Callout>Common mistakes
Forgetting the closing
:::line — the rest of the markdown file becomes part of the callout silently.Using
:::tipto highlight code — Shiki + dual themes already make code blocks visually distinct; callouts are for prose context (warnings, tips, side-notes).Putting a
:::code-groupinside a:::tip— directives don't nest reliably; refactor to sibling blocks.
CodeGroup component
<CodeGroup labels={["npm","bun","pnpm"]} initial? children>Tabbed code blocks. Emitted by the :::code-group container syntax — each child code fence carries [label] in its meta string. The active tab is a signal; SSR ships tab 0 visible, client-side hydration enables tab switching with zero per-mount cost (tabs are CSS class swaps, not VNode reconciliation).
Example
// In markdown:
:::code-group
\`\`\`bash [npm]
npm install @pyreon/zero
\`\`\`
\`\`\`bash [bun]
bun add @pyreon/zero
\`\`\`
:::Common mistakes
Omitting the
[label]on a code fence inside:::code-group— the unlabelled block is silently dropped from the group (consistent with the prototype, but easy to miss). Always label every fence.Mixing languages without labels —
:::code-groupis for the same task in different syntaxes (npm vs bun vs pnpm), not arbitrary unrelated code.Hand-writing
<CodeGroup>JSX with mismatched labels-to-children count — write markdown instead so the codegroup plugin keeps them in sync.
CodeBlock component
<CodeBlock lang? filename? dangerouslySetInnerHTML={{ __html }}>Wrapper around a Shiki-rendered code block. Emitted automatically when highlighting is enabled — Shiki produces a full <pre><code> with per-token coloring + dual light/dark themes baked into one <span> tree, and CodeBlock wraps it for filename labels + copy buttons (future) without forcing the markdown pipeline to know about them. The dangerouslySetInnerHTML here is safe because Shiki output is build-time HTML, not user input — round-tripping it through the JSX emitter would throw away the precomputed coloring.
Example
// Output from \`\`\`ts\nconst x = 1\n\`\`\` becomes:
<CodeBlock lang="ts" dangerouslySetInnerHTML={{ __html: "<pre class=\"shiki\">…</pre>" }} />
// Hand-using is rare; the pipeline emits it for you.
<CodeBlock lang="ts" filename="signal.ts" dangerouslySetInnerHTML={{ __html: shikiOutput }} />Common mistakes
Hand-emitting CodeBlock without Shiki-shaped HTML in
__html— you lose dual-theme support; just write a code fence in markdown.Trying to read or mutate the rendered HTML at runtime — it's baked at build time. To customize coloring, swap themes via the plugin's
highlighteroption.Building a copy-to-clipboard button by parsing the
__html— use the original code value before highlighting (PR 4 will expose the raw value alongside the rendered HTML).
Example component
<Example file="./path/to/example" share?="key" shareInitial?={value} title?="…" class?="…">The Pyreon-native replacement for iframe-sandboxed <Playground>. Loads a real .tsx file inline (NOT iframe) — no escape passes, no srcdoc string-blob, no SyntaxError when a string contains a backslash. Two <Example> calls with the same share key receive the SAME signal instance via a module-level registry, so a click in one example reactively updates the rendered output of another mounted example on the same page. Build-time-resolved via import.meta.glob registered at startup with registerExamples() — no runtime overhead beyond the dynamic import() of the resolved chunk.
Example
// In markdown:
<Example file="./examples/counter" share="cnt" />
<Example file="./examples/readout" share="cnt" />
// examples/counter.tsx — a real Pyreon component file
import { signal, type Signal } from '@pyreon/reactivity'
export default function Counter(props: { shared?: Signal<number> }) {
const count = props.shared ?? signal(0)
return (
<div>
<button onClick={() => count.update(n => n + 1)}>+</button>
<span>{count()}</span>
</div>
)
}
// entry-client.ts — one-time consumer-side registration
import { registerExamples } from '@pyreon/zero-content'
registerExamples(import.meta.glob('./examples/⁎⁎/⁎.tsx'))Common mistakes
Forgetting
registerExamples(import.meta.glob(...))inentry-client.ts— the registry stays empty and every<Example>renders the "not found" error message.import.meta.globis resolved at COMPILE TIME relative to the file it's called in, so the registration MUST live in the consumer's source tree (this package can't do it for you).Passing children to an example:
<Example file="./x">content</Example>— children are dropped during JSON serialization of props. Render content inside the example file itself.Using
share="key"with a value the receiving component can't consume — the example component must accept{ shared?: Signal<T> }and fall back to a local signal when undefined. Without that fallback, the example breaks when used WITHOUTshare.
registerExamples function
registerExamples(glob: Record<string, () => Promise<unknown>>): voidRegister the consumer's example files for <Example file="./..."> lookups. Call once at app boot from entry-client.ts (or equivalent), passing the result of import.meta.glob('./examples/**/*.tsx'). Idempotent: re-registering replaces the previous registry (useful for hot-reload scenarios).
Example
// entry-client.ts
import { registerExamples } from '@pyreon/zero-content'
registerExamples(
import.meta.glob('./examples/⁎⁎/⁎.tsx') as Record<
string,
() => Promise<unknown>
>,
)Common mistakes
Calling
registerExamplesat module scope of a server-only file — the glob must be evaluated in the client bundle. Put it inentry-client.ts, notentry-server.ts.Passing the wrong glob shape (resolved path strings instead of loaders) —
import.meta.globreturnsRecord<path, lazy loader>. Don't wrap it.Forgetting that the glob is COMPILE-TIME-RESOLVED relative to the file. If you
registerExamples(import.meta.glob('./x/**/*.tsx'))insrc/foo/entry.ts, the glob walkssrc/foo/x/, NOTsrc/x/.
getOrCreateSharedSignal function
getOrCreateSharedSignal<T>(key: string, initial: T): Signal<T>Module-level registry of Signal<T> instances keyed by string. First lookup for a key creates a signal with the supplied initial value; subsequent lookups return the SAME instance (ignoring initial after the first). Powers the share="key" prop on <Example> but can be used directly for cross-component shared state without a context. Companion clearAllSharedSignals() resets the whole registry (test-helper / page-nav use case).
Example
import { getOrCreateSharedSignal } from '@pyreon/zero-content'
// Two components on the same page receive the SAME signal:
const a = getOrCreateSharedSignal<number>('cnt', 0)
const b = getOrCreateSharedSignal<number>('cnt', 99)
console.log(a === b) // true
console.log(a()) // 0 (initial from FIRST lookup; second arg ignored)
b.set(5)
console.log(a()) // 5Common mistakes
Disagreeing on
Tacross two callers with the same key — both get the same runtime signal but mismatched compile-time types (author error, no runtime safeguard).Calling
clearAllSharedSignals()in production (default-page-nav handler etc.) — signals are normally session-scoped; clearing wipes intentional app-wide state (theme/locale/...).Re-implementing the registry per-feature instead of reusing this — the registry is the canonical home for module-level shared signals across mount boundaries.