@coniferous/docs-react
v0.5.4
Published
React hooks, renderer, and opinionated Mantine layout for building doc sites on top of @coniferous/docs-core.
Readme
@coniferous/docs-react
React hooks, a renderer, and an opinionated Mantine layout for shipping doc sites. Built on top of @coniferous/docs-core.
@coniferous/docs-react — hooks + <DocRenderer> + unstyled defaults
@coniferous/docs-react/layout — <DocsLayout> + Mantine-styled element defaultsImport whichever piece you want — the layout subpath is only needed if you want the pre-styled 3-pane shell.
Install
npm install @coniferous/docs-react @coniferous/docs-core
# If you plan to use the layout:
npm install @mantine/core @tabler/icons-reactQuick start — zero styling
import { createStaticLoader } from "@coniferous/docs-core/loaders/static";
import {
DocRenderer,
DocsProvider,
useDocContent,
} from "@coniferous/docs-react";
import getStarted from "./docs/get-started.md?raw";
import trackWork from "./docs/track-work.md?raw";
const loader = createStaticLoader({
"get-started": getStarted,
"track-work": trackWork,
});
const config = {
sections: [
{ title: "Start", pages: ["get-started", "track-work"] },
],
};
function App({ slug }: { slug: string }) {
return (
<DocsProvider loader={loader} config={config} activeSlug={slug}>
<Page slug={slug} />
</DocsProvider>
);
}
function Page({ slug }: { slug: string }) {
const { parsed } = useDocContent(slug);
if (!parsed) return <p>Not found</p>;
return <DocRenderer ast={parsed.ast} />;
}"It just works" — the opinionated layout
import { DocsProvider, DocRenderer } from "@coniferous/docs-react";
import {
DocsLayout,
DocsPrevNext,
mantineComponents,
} from "@coniferous/docs-react/layout";
import { Link, useParams } from "react-router-dom";
export default function DocsPage() {
const { slug = "get-started" } = useParams();
return (
<DocsProvider loader={loader} config={config} activeSlug={slug}>
<Inner slug={slug} />
</DocsProvider>
);
}
function Inner({ slug }: { slug: string }) {
const { parsed, frontmatter } = useDocContent(slug);
if (!parsed || !frontmatter) return null;
return (
<DocsLayout
ast={parsed.ast}
pageTitle={frontmatter.title}
activeSlug={slug}
LinkComponent={Link}
linkHrefProp="to"
>
<DocRenderer ast={parsed.ast} components={mantineComponents} />
<DocsPrevNext slug={slug} />
</DocsLayout>
);
}For a full reference integration — including Shiki syntax highlighting, HMR-aware Vite loader, and the Spruce overlay editor — see website/src/DocsPage.tsx.
Customization model
Two complementary knobs:
- Component map — per-element overrides (
<DocRenderer components={{ h2, callout, a, ... }} />). Same pattern asreact-markdown. Unset entries fall back to our defaults. - Headless hooks — if you want full control of markup, skip
<DocsLayout>and build your own withuseDocContent,useToc,useDocsSearch,useDocsConfig,useDocsPrevNext, anduseActiveHeading.
We deliberately don't expose slot overrides — they always leak constraints into the layout. Either restyle one element (component map) or build your own shell (hooks).
SEO + agent discoverability
Two drop-in components layer on top of @coniferous/docs-core's pure helpers.
<DocSeo> — per-page head tags
import { buildSeoMetadata } from "@coniferous/docs-core";
import { DocSeo } from "@coniferous/docs-react";
function Page({ slug }: { slug: string }) {
const { parsed, raw } = useDocContent(slug);
const page = useDocsConfig().getPage(slug);
if (!parsed || !page) return null;
const metadata = buildSeoMetadata(page, {
siteConfig: SITE_CONFIG, // your own { baseUrl, siteName, titleTemplate, … }
body: raw ?? undefined, // enables body-paragraph description fallback
});
return (
<>
<DocSeo metadata={metadata} />
<DocRenderer ast={parsed.ast} />
</>
);
}<DocSeo> is imperative — it renders nothing in the DOM tree. An effect mutates document.head to match the provided SeoMetadata, updating the existing <title> / <meta> / <link> / JSON-LD nodes in place. This is the right shape for a site that prerenders per-page head on disk and then hydrates: the prerendered tags stay authoritative, <DocSeo> just keeps them in sync on client-side route changes. Zero duplicates post-hydration.
If you need the imperative sync without the component wrapper (e.g. in a hook), import syncDocumentHead directly:
import { syncDocumentHead } from "@coniferous/docs-react";
useEffect(() => syncDocumentHead(metadata), [metadata]);Framework-native integration
<DocSeo> targets client-side-navigation-aware React apps (React Router, TanStack Router, wouter, etc.). If you're on a framework that owns head rendering end-to-end, skip <DocSeo> and feed buildSeoMetadata's output into the framework's metadata API instead:
| Framework | Pattern |
| --- | --- |
| Vite + SSG | Use prerenderSite from @coniferous/docs-core/prerender for build + <DocSeo> for client-side nav. Reference: Spruce marketing site — website/scripts/prerender.mjs, website/src/DocsPage.tsx. |
| Next.js (App Router) | Feed buildSeoMetadata into generateMetadata(). Skip <DocSeo> — Next's metadata API handles client-side nav natively. See the Next recipe in @coniferous/docs-core/README.md. |
| Astro | Spread buildSeoMetadata into the <head> slot, or use renderSeoToHtml + set:html for the complete head. See the Astro recipe in @coniferous/docs-core/README.md. |
| Remix / TanStack Start | Feed buildSeoMetadata into the route loader's meta export. |
<CopyMarkdownButton> — "Copy as markdown"
import { CopyMarkdownButton } from "@coniferous/docs-react";
// In a page
<CopyMarkdownButton />
// Or with an explicit slug / label
<CopyMarkdownButton slug="get-started" label="Copy source" />The button pulls the current slug from <DocsProvider> (via activeSlug), reads the raw markdown with getRawMarkdown, strips the frontmatter by default, and writes it to the clipboard. It's a plain <button> with inline styles — no UI-library dependency — so it drops into any layout.
DocsLayout renders one by default in the content toolbar. Override via the toolbar prop:
<DocsLayout /* … */ toolbar={false}> {/* hide */}
<DocsLayout /* … */ toolbar={<MyOwnToolbar />}> {/* replace */}Replace with a custom ReactNode when you want to add "Open in Claude" / "Open in ChatGPT" buttons or anything else alongside the copy action.
Syntax highlighting
<DocRenderer ast={parsed.ast} shiki />Or customize the theme / languages:
<DocRenderer
ast={parsed.ast}
shiki={{ theme: "github-light", langs: ["rust", "typescript"] }}
/>Shiki is lazy-loaded on first render, so the grammars don't pay the cost until you actually encounter a code block.
Blog UI — @coniferous/docs-react/blog
Mantine components for a blog built on the framework-agnostic helpers in @coniferous/docs-core (getBlogPosts, resolveBlogConfig, …). They're brand-neutral: chrome, the router link component, and the no-cover placeholder are all props, so you wrap them in your own header/footer.
<BlogCardGrid posts hrefFor LinkComponent coverPlaceholder />— the index. A responsive grid of fixed-height cards: cover band (image or your placeholder), date + reading time, a 2-line-clamped title, a 3-line-clamped excerpt, and an author byline (photo when present, initials otherwise).<BlogCard>is exported too if you want a single card.<BlogArticle post ast sourceFile dev shiki />— a turnkey post body:<BlogPostHeader>(title, byline, date, cover hero) + a<DocRenderer>for the markdown, wrapped in a<LayoutProvider>so the Mantine component map works without the 3-pane docs chrome.sourceFilemakes the Spruce overlay work on blog files for free. Passshikito enable (slim, fine-grained) syntax highlighting.<BlogPostHeader>is exported separately if you compose the body yourself.useBlogPost(slug, opts)+<BlogArticleBySlug slug>— the turnkey path.useBlogPostresolves{ post, page, ast, seo }from the loader in context (wrapsuseDocContent+getBlogPosts().find+resolveBlogConfig().pages.find+blogPostSeo), returningnullfor a 404.<BlogArticleBySlug>renders straight from a slug, hiding theastnull-juggling.siteConfigis an explicit option (the context doesn't carry it) — supply it to getseo.
import { createAuthorResolver, getBlogPosts } from "@coniferous/docs-core";
import { DocsProvider, DocSeo } from "@coniferous/docs-react";
import { BlogCardGrid, BlogArticleBySlug, useBlogPost } from "@coniferous/docs-react/blog";
const getAuthor = createAuthorResolver(AUTHORS, "team");
// Index page
function BlogIndex() {
const posts = getBlogPosts(loader, { excludeDrafts: import.meta.env.PROD, getAuthor });
return <BlogCardGrid posts={posts} LinkComponent={Link} linkHrefProp="to" />;
}
// Post page — turnkey: slug in, article out. `useBlogPost` reads the loader
// from context, so it must run INSIDE <DocsProvider> — hence the split.
function BlogPost({ slug }) {
return (
<DocsProvider loader={loader} config={{ sections: [] }} activeSlug={slug}>
<PostBody slug={slug} />
</DocsProvider>
);
}
function PostBody({ slug }) {
const result = useBlogPost(slug, { siteConfig, getAuthor });
if (!result) return <NotFound />;
return (
<>
<DocSeo metadata={result.seo!} />
<BlogArticleBySlug slug={slug} options={{ siteConfig, getAuthor }} shiki />
</>
);
}See BLOG-INTEGRATION.md for the full end-to-end wiring — content store / HMR, prerender (posts and index from one prerenderSite call via the index field), the render(url, ctx) contract, and the dedupe + ssr.noExternal build config.
Theming
The blog components read the same --spruce-docs-* CSS variables as the docs layout — surface (--spruce-docs-bg-subtle), border (--spruce-docs-border / --spruce-docs-border-hover), and foreground (--spruce-docs-fg, -fg-muted, -fg-faint). Import @coniferous/docs-react/styles.css and the cards/header automatically match your docs; override accent/accentSoft (and any other variable) once and both surfaces follow. Each value has an inline fallback, so a card never renders Mantine's default gray if the stylesheet is missing — it just falls back to a transparent surface. The no-cover band is yours to style via coverPlaceholder.
Prose typography is driven by three variables — --spruce-docs-prose-font-size (1rem), --spruce-docs-prose-line-height (1.7, unitless), and --spruce-docs-prose-paragraph-gap (1em, em-relative) — so redefining the font size reflows the whole vertical rhythm. <BlogArticle> adds a .spruce-docs-blog scope that bumps the size to 1.0625rem and sets a --spruce-docs-prose-measure (68ch) reading column; pass maxWidth to override the measure per article. Override any token at :root, the blog scope, or your own container — never !important.
Steps marker (:::steps number chip) uses a dedicated --spruce-docs-step-marker-bg / --spruce-docs-step-marker-fg pair (default: glyph on a solid --spruce-docs-accent disk, contrast-safe in both themes) so it stays legible regardless of how you tune --spruce-docs-accent-fg.
Build the static HTML + RSS + merged sitemap/llms with prerenderSite({ sets }) + buildRss from @coniferous/docs-core — see that package's "Blog support" section.
Overlay editing
useDocContent automatically runs stampSourceAttrs from @coniferous/docs-core on every parsed tree, so the source-map attributes (data-source-file, data-source-start, data-source-end) are baked into the mdast's data.hProperties. Any renderer that honors those — including <DocRenderer> — ships overlay-compatible markup with no extra wiring.
To set the source-file prefix, pass sourceFilePrefix to the provider:
<DocsProvider
loader={loader}
config={config}
sourceFilePrefix="website/src/docs/"
dev={import.meta.env.DEV}
>The Spruce overlay editor (under /overlay in this monorepo) attaches to any product site that emits those attributes — clicking a paragraph in the rendered doc opens the source markdown in Spruce, and edits hot-reload back into the page.
License
MIT © Coniferous — see LICENSE.
