npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@coniferous/docs-core

v0.4.1

Published

Framework-agnostic markdown parsing, TOC extraction, search indexing, and source-map utilities powering Spruce doc sites.

Readme

@coniferous/docs-core

Framework-agnostic building blocks for doc sites: markdown parsing, Table of Contents extraction, search indexing, callout detection, and the source-map protocol that makes Spruce's overlay editor work on any product site.

This package has zero React dependency. The companion package @coniferous/docs-react provides the hooks, renderer, and opinionated Mantine layout that sits on top of this core.

Looking for the directive reference? See DIRECTIVES.md — the canonical spec for callouts, layouts, media, and inline directives. Suitable for human authors and coding agents alike.

Install

npm install @coniferous/docs-core

What's in the box

| Export | What it does | | ---------------------- | ----------------------------------------------------------------------------------------------------- | | parseDoc(raw) | Parse a markdown file (frontmatter + body) → { frontmatter, ast, body, plain }. | | parseBody(md) | Parse just the body (no frontmatter expected) → mdast Root. | | extractFrontmatter | Pull the YAML block off the top of a file. Zod-free hand-rolled validator. | | buildToc(ast) | Collect heading entries for a Table of Contents. | | slugify(text) | Convert heading text to a URL-safe anchor slug. Matches the renderer's id convention. | | BUILT_IN_DIRECTIVES / findBuiltInDirective / buildDirectiveRegistry | Registry of :::name directive definitions (callouts, layouts, media, inline icon). | | sourceAttrs(node) | Compute the data-source-start / -end attributes for a node. Overlay-protocol compatible. | | buildSearchIndex | Fuse.js index over a set of SearchEntrys (title / section / body). | | stripMarkdown(raw) | Turn raw markdown into plain text for search indexing. | | resolveConfig | Walk a user config + loader, lift titles out of frontmatter, return nav + flat page list. | | buildSearchEntries | Produce search entries for a resolved config. | | getPrevNext(resolved, slug) | Compute nav-order neighbours. | | buildSeoMetadata | Compute per-page { title, description, canonical, og, twitter, jsonLd }. Pure — render however you like. | | buildSitemap | Emit a conformant sitemap.xml from a resolved config + optional extra URLs. | | docsToLlmsSections / buildLlmsTxt | Two-step composable llms.txt generator. Docs + anything else you want to splice in (blog, changelog…). | | docsToLlmsFullPages / buildLlmsFullTxt | Same shape for the full-dump llms-full.txt. | | getRawMarkdown | Named helper over loader.getRaw — returns clean body for .md endpoints. |

Two Node-side build sub-paths sit on top of the core. @coniferous/docs-core/prerender is framework-agnostic (it never imports a bundler): prerenderSite (the orchestrator over one or more content sets), prerenderBundle (the bundler-agnostic static-build engine — given an already-built SSR bundle, it owns the DOM shim + render loop + RSS), plus renderSeoToHtml and injectPlaceholders. @coniferous/docs-core/vite is the Vite integration: runStaticBuild does the vite build, then delegates to prerenderBundle; the docsPlugin() Vite plugin runs the same pipeline from vite build (no script). Only the build step is bundler-specific, so a future /webpack (etc.) integration reuses prerenderBundle and just sits alongside /vite. vite is an optional peer, dynamically imported only when these run — client-only consumers never install it. See Framework compatibility — which exports do I need? for which you want in each stack.

Loaders

@coniferous/docs-core/loaders/* exposes three adapters for getting markdown into the library. Each one returns a DocLoader.

Vite (import.meta.glob)

import { createViteLoader } from "@coniferous/docs-core/loaders/vite";

const files = import.meta.glob("./docs/*.md", {
  query: "?raw",
  import: "default",
  eager: true,
});

const loader = createViteLoader(files);

For HMR (doc pages that live-update when you edit a markdown file), use createMutableViteLoader and wire it into Vite's import.meta.hot.accept. See website/src/lib/doc-content-store.ts for a full example.

Static imports (Next.js / webpack)

import { createStaticLoader } from "@coniferous/docs-core/loaders/static";

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,
});

Runtime fetch

import { createFetchLoader } from "@coniferous/docs-core/loaders/fetch";

const loader = createFetchLoader({
  slugs: ["get-started", "track-work"],
  baseUrl: "/docs/",
});

await loader.prefetchAll();

Ordering & grouping

Two separate dimensions of "order" in a doc site:

Across pages — sidebar nav. Owned by your DocsConfig. The order of sections, and the order of pages within each section, is the order they render in. Frontmatter doesn't control placement, only the displayed title.

const config: DocsConfig = {
  sections: [
    { title: "Getting Started", pages: ["get-started", "explore-code"] },
    { title: "Managing Work",   pages: ["track-work", "organize"] },
  ],
};

Within a page — Table of Contents. Owned by the markdown file itself. buildToc(ast) walks the parsed tree in source order and collects ## / ### headings by default (configurable via { minDepth, maxDepth }). You don't order the TOC manually — it's the order you wrote the headings in.

Frontmatter

Every doc page requires a title in its frontmatter. description and draft are optional:

---
title: Get Started
description: Install Spruce and open your first project.
draft: false
---

# Get Started with Spruce
...

parseDoc<T>() accepts a custom validator for any additional fields you want to require:

const doc = parseDoc<{ title: string; tags: string[] }>(raw, {
  frontmatter: (data) => ({
    title: String(data.title),
    tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
  }),
});

SEO metadata

Per-page SEO is a pure data transform: buildSeoMetadata(page, { siteConfig, body?, ogImageUrl? }) returns a fully resolved { title, description, canonical, og, twitter, jsonLd } payload. Consumers render the payload however they want — React head tags, SSR string injection, Next.js metadata API, Astro <head> slot. The package ships a <DocSeo> React component in @coniferous/docs-react for that rendering step, but it's optional.

import { buildSeoMetadata, type SiteConfig } from "@coniferous/docs-core";

const siteConfig: SiteConfig = {
  baseUrl: "https://example.com",
  siteName: "Acme",
  titleTemplate: (t) => `${t} — Acme Docs`,
  defaultDescription: "Acme — build weird internet things fast.",
  defaultOgImage: "/og-image.png",
  locale: "en_US",
  twitterHandle: "@acme",
};

const meta = buildSeoMetadata(page, {
  siteConfig,
  body: loader.getRaw(page.slug) ?? undefined,
  ogImageUrl: (slug) => `/og/${slug}.png`,    // optional per-slug OG images
  onWarning: (msg) => console.warn(msg),       // surfaced at build time
});

For pages not backed by a DocPage (homepage, pricing), buildPageSeo(siteConfig, { title, description, path, type?, image?, jsonLd? }) builds the same payload from a plain page descriptor. Add an optional organization to your SiteConfig ({ name?, url?, logo?, sameAs? }) and buildPageSeo auto-appends an Organization + WebSite JSON-LD node to every page — so site-level schema is single-sourced from config instead of hand-written. It's purely additive: a jsonLd override still gets the site nodes grafted on, and configs without organization emit byte-identical output.

Description fallback chain

When a page's frontmatter.description is missing, buildSeoMetadata walks a deterministic fallback chain and calls onWarning each time a fallback kicks in — so npm run build surfaces the pages that need explicit descriptions:

  1. Explicit frontmatter.description
  2. First real paragraph of the body (via the exported extractFirstParagraph helper — skips headings, lists, code blocks, blockquotes, and trims to 160 chars)
  3. siteConfig.defaultDescription
  4. Empty string (with a warning)

OG images

buildSeoMetadata ships the hook, not the generator. Pass an ogImageUrl(slug) callback that returns a URL — pre-generated static PNG, Cloudflare Images, Vercel OG, your own edge function, whatever. The callback can return an absolute URL or an origin-relative path (the helper absolutizes against siteConfig.baseUrl). The built-in OG image generator lands in a later release as a separate sub-export (@coniferous/docs-core/og) so its heavy deps (satori + resvg, fonts) don't bloat the core package.

Sitemap

import { buildSitemap, resolveConfig } from "@coniferous/docs-core";

const resolved = resolveConfig(config, loader, { excludeDrafts: true });
const xml = buildSitemap(resolved, {
  baseUrl: "https://example.com",
  extraUrls: [
    { loc: "/", changefreq: "weekly", priority: 1.0 },
    { loc: "/pricing", changefreq: "monthly", priority: 0.5 },
  ],
  // Optional per-slug overrides
  lastmod:    (slug) => gitLastEdited(slug),
  priority:   (slug) => (slug === "get-started" ? 0.9 : 0.7),
  changefreq: () => "weekly",
});

Drafts are auto-excluded. Extra URLs get appended verbatim after the generated doc entries so you don't maintain two sources of truth.

Agent surface — llms.txt + llms-full.txt

Per the emerging convention these live at the site root, not under /docs. The API is deliberately split into two steps so consumers with non-docs structured content (blog, changelog, API reference) can compose them in as peer sections alongside the auto-generated docs ones.

llms.txt — top-level index

import {
  buildLlmsTxt,
  docsToLlmsSections,
  type LlmsEntry,
  type LlmsSection,
} from "@coniferous/docs-core";

// Step 1: project docs → generic LlmsSection[]
const docsSections = docsToLlmsSections(resolved, { baseUrl: "https://acme.com" });

// Step 2: splice in anything else the site exposes
const blog: LlmsSection = { title: "Blog", entries: await loadBlogPosts() };
const changelog: LlmsSection = { title: "Changelog", entries: loadReleases() };

const sections = [...docsSections, blog, changelog];

// Step 3: render
const llms = buildLlmsTxt(sections, {
  siteName: "Acme",
  description: "Acme is the delivery workspace for…",
  optionalLinks: [
    { title: "Homepage", url: "https://acme.com/" },
    { title: "Pricing",  url: "https://acme.com/pricing" },
  ],
});

Output:

# Acme

> Acme is the delivery workspace for…

## Getting Started
- [Get Started](https://acme.com/docs/get-started): …

## Blog
- [How we shipped the thing](…)

## Changelog
- [v1.0 — launch](…)

## Optional
- [Homepage](https://acme.com/)
- [Pricing](https://acme.com/pricing)

llms-full.txt — concatenated bodies

import {
  buildLlmsFullTxt,
  docsToLlmsFullPages,
  type LlmsFullPage,
} from "@coniferous/docs-core";

const docsPages = docsToLlmsFullPages(resolved, loader, { baseUrl });
const blogPages: LlmsFullPage[] = await loadBlogPostBodies(); // same shape

const llmsFull = buildLlmsFullTxt([...docsPages, ...blogPages], {
  siteName: "Acme — Docs",
  description: "…",
});

Each page is emitted after a --- delimiter with Section: + Source: URL headers so LLMs can cite.

Raw markdown endpoints

getRawMarkdown(slug, loader, { includeFrontmatter: false }) returns the clean body for a slug. Typically emitted as dist/docs/<slug>.md during the build so curl https://example.com/docs/get-started.md works without going through a Worker route.

Blog support

A blog is just docs ordered by publish date instead of a hand-maintained config. The blog helpers derive the post list from frontmatter (newest-first, drafts/future-dated dropped in prod) and reuse the same SEO / sitemap / llms / prerender machinery as docs. Everything site-specific — the author registry, baseUrl, siteConfig — is injected, so the helpers are framework-agnostic.

Frontmatter per post: title, description (optional, drives the excerpt + meta), author (optional registry id — omit it and the post renders with no byline; there's no "Unknown" fallback), published (ISO date), optional updated, draft, and cover/image.

import {
  createAuthorResolver,
  getBlogPosts,
  resolveBlogConfig,
  blogCoverFor,
  blogAuthorFor,
  blogRssItems,
  buildBlogIndexSeo,
  buildRss,
  type BlogAuthor,
} from "@coniferous/docs-core";

// 1. Author registry → resolver (unknown ids fall back to "team").
const AUTHORS: Record<string, BlogAuthor> = {
  jake: { name: "Jake Arntson", avatar: "/blog/authors/jake.jpg", url: "https://example.com" },
  team: { name: "The Team" },
};
const getAuthor = createAuthorResolver(AUTHORS, "team");

// 2. A loader over your posts (any DocLoader — e.g. the Vite glob loader).
//    Then derive view-models for the index, and a ResolvedConfig for the
//    sitemap/llms/prerender pipeline.
const posts = getBlogPosts(loader, { excludeDrafts: import.meta.env.PROD, getAuthor });
const resolved = resolveBlogConfig(loader, { excludeDrafts: true });

// 3. RSS — pipe the items straight into buildRss.
const rss = buildRss(blogRssItems(loader, { baseUrl, getAuthor, excludeDrafts: true }), {
  title: "Acme Blog",
  link: `${baseUrl}/blog`,
  description: "News from Acme.",
  feedUrl: `${baseUrl}/blog/rss.xml`,
});

getBlogPosts returns BlogPost[]{ slug, title, excerpt, author, published, updated, cover, readingMinutes, draft } — ready to render in a card or post header. formatPublishDate(iso) formats the date; buildBlogIndexSeo(siteConfig, { baseUrl }) builds the /blog listing-page SEO.

For a single post, blogPostSeo(slug, { loader, siteConfig, getAuthor }) is the one builder for its SeoMetadata (BlogPosting JSON-LD + author Person + cover og:image) — the same logic the React useBlogPost hook uses, so the client and prerender heads agree. For arbitrary marketing pages (homepage, pricing) that aren't backed by a DocPage, buildPageSeo(siteConfig, { title, description, path, type?, image?, jsonLd? }) replaces a hand-authored SeoMetadata literal.

Wiring blog posts into the build

prerenderSite renders a list of content sets — docs, blog, changelog, … — as peers: each emits static HTML + .md under its pathPrefix and merges into the single sitemap.xml / llms.txt / llms-full.txt (in sets order). Blog is just another set. Per-set you control the SEO: jsonLdType: "BlogPosting", a per-slug author (a JSON-LD Person) and ogImageUrl (the post cover), plus an optional siteConfig override (e.g. a blog-specific titleTemplate).

By default a set with a non-root pathPrefix emits a meta-refresh redirect at <pathPrefix>/index.html (good for docs, which have no listing page). A blog wants a real listing instead — set the index field and the orchestrator renders it via your render hook (with context.kind === "index"), so posts and the listing come from one call. Setting index skips the redirect; declaring both index and an explicit indexRedirectSlug surfaces a warning (the listing wins).

One call: blogSet(loader, { siteConfig, getAuthor }) returns a fully-wired blog ContentSetresolved (date-sorted, draft-filtered), jsonLdType: "BlogPosting", author, ogImageUrl, and the index card-grid listing all assembled — replacing the hand-rolled object below. And runStaticBuild({ entry, outDir }) (or the docsPlugin() Vite plugin) drives the whole build, reading siteConfig/pages/sets/rss/llms/sitemap out of your entry's exported buildConfig. See the blog integration guide for the minimal end-to-end path.

await prerenderSite({
  siteConfig, outDir, template, render,
  sets: [
    {
      // docs is just a set — you resolve it yourself
      resolved: resolveConfig(docsConfig, docsLoader, { excludeDrafts: true }),
      loader: docsLoader,
      pathPrefix: "/docs",
    },
    {
      resolved: resolveBlogConfig(blogLoader, { excludeDrafts: true }),
      loader: blogLoader,
      pathPrefix: "/blog",
      siteConfig: blogSiteConfig,           // optional title-template override
      jsonLdType: "BlogPosting",
      ogImageUrl: blogCoverFor(blogLoader),
      author: blogAuthorFor(blogLoader, getAuthor),
      changefreq: () => "weekly",
      priority: () => 0.6,
      // Render /blog/index.html as a real listing (no redirect):
      index: { url: "/blog", seo: buildBlogIndexSeo(siteConfig, { baseUrl }) },
    },
  ],
});
// Only rss.xml is left to emit yourself (a single file).

For ready-made React UI (post cards, post header/body), see @coniferous/docs-react/blog.

Framework compatibility — which exports do I need?

Most of the package is pure functions with no runtime assumption, so they work in any JS environment. The @coniferous/docs-core/prerender sub-path is Node-only and specifically for consumers that own their own build script.

| Export | Runtime | Works with | | --- | --- | --- | | parseDoc, buildToc, slugify, findBuiltInDirective, buildSearchIndex | Browser / Node / edge | Anything | | buildSeoMetadata, buildSitemap, buildLlmsTxt, buildLlmsFullTxt, getRawMarkdown, resolveConfig | Browser / Node / edge | Anything — Next, Astro, Remix, SvelteKit, vanilla Node build scripts, Cloudflare Workers | | @coniferous/docs-core/prerender.renderSeoToHtml, injectPlaceholders | Any JS | Template-based SSG stacks (Vite / Webpack / Rollup / vanilla) | | @coniferous/docs-core/prerender.prerenderSite, prerenderBundle | Node only (uses fs, no bundler import) | Vite / Webpack / Rollup / esbuild / vanilla Node build scripts | | @coniferous/docs-core/vite.runStaticBuild, docsPlugin() | Node only (uses fs + an optional vite peer) | Vite sites — one-call build / plugin |

Nothing in this package imports from Vite. The prerender sub-path is often described as "the Vite SSR pattern" because VitePress popularized it, but the actual code is readFileSync / writeFileSync / two String.replace calls. Any bundler that can produce a Node-consumable SSR bundle plus an HTML template with placeholder tokens can call prerenderSite.

Recipe 1 — Vite / Webpack / vanilla SSG

One call to prerenderSite from your build script, one consumer-owned render(url) hook. The build tool (Vite, Webpack, esbuild…) sits outside this call — it produces the SSR bundle, nothing more. Docs is just a content set; pass more (blog, changelog) in the same sets array.

// scripts/prerender.mjs
import { readFileSync, rmSync } from "node:fs";
import { resolveConfig } from "@coniferous/docs-core";
import {
  prerenderSite,
  injectPlaceholders,
  renderSeoToHtml,
} from "@coniferous/docs-core/prerender";
import { build } from "vite";            // or webpack/esbuild/rollup — doesn't matter

await build({ /* your SSR bundle config */ });

const ssr = await import("./dist-ssr/entry-server.js");
const template = readFileSync("./dist/index.html", "utf-8");

// Homepage (one-off — not part of a content set)
{
  const seo = ssr.getHomepageSeo();
  const { html } = ssr.render("/", seo);
  const page = injectPlaceholders(template, {
    html,
    head: renderSeoToHtml(seo),
  }).html;
  writeFileSync("./dist/index.html", page);
}

// All content sets + agent surface in one call
await prerenderSite({
  siteConfig:  ssr.SITE_CONFIG,
  outDir:      "./dist",
  template,
  render:      (url, { seo }) => ssr.render(url, seo),
  llmsDescription: "…",
  optionalLlmsLinks: [{ title: "Homepage", url: "…/" }],
  extraSitemapUrls: [{ loc: "/", priority: 1.0 }],
  sets: [
    {
      resolved: resolveConfig(ssr.docsConfig, ssr.docLoader, { excludeDrafts: true }),
      loader: ssr.docLoader,
      pathPrefix: "/docs",
    },
  ],
});

rmSync("./dist-ssr", { recursive: true });

Your template has two placeholders:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <!--app-head-->
  </head>
  <body>
    <div id="root"><!--app-html--></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

And your SSR entry exports render(url, seo): { html, head }:

export function render(url, seo) {
  const html = renderToString(<StaticRouter location={url}>…</StaticRouter>);
  const head = seo ? renderSeoToHtml(seo) : "";
  return { html, head };
}

Recipe 2 — Next.js (App Router)

Next owns routing and head rendering via generateMetadata(). Skip prerenderSite entirely; reach directly for the pure helpers.

// app/docs/[slug]/page.tsx
import { buildSeoMetadata, resolveConfig } from "@coniferous/docs-core";
import { docsConfig, docLoader, SITE_CONFIG } from "@/lib/docs";
import type { Metadata } from "next";

export async function generateStaticParams() {
  const resolved = resolveConfig(docsConfig, docLoader, { excludeDrafts: true });
  return resolved.pages.map((p) => ({ slug: p.slug }));
}

export async function generateMetadata(
  { params }: { params: { slug: string } },
): Promise<Metadata> {
  const resolved = resolveConfig(docsConfig, docLoader, { excludeDrafts: true });
  const page = resolved.pages.find((p) => p.slug === params.slug);
  if (!page) return {};
  const body = docLoader.getRaw(params.slug) ?? undefined;
  const seo = buildSeoMetadata(page, { siteConfig: SITE_CONFIG, body });

  return {
    title: seo.title,
    description: seo.description,
    alternates: { canonical: seo.canonical },
    openGraph: {
      type: "article",
      title: seo.og.title,
      description: seo.og.description,
      url: seo.og.url,
      siteName: seo.og.siteName,
      locale: seo.og.locale,
      images: seo.og.image ? [seo.og.image] : undefined,
      publishedTime: seo.article?.publishedTime,
      modifiedTime: seo.article?.modifiedTime,
      section: seo.article?.section,
    },
    twitter: { card: "summary_large_image", site: seo.twitter.site },
  };
}

Sitemap goes in app/sitemap.ts:

import { buildSitemap, resolveConfig } from "@coniferous/docs-core";

export default function sitemap() {
  const resolved = resolveConfig(docsConfig, docLoader, { excludeDrafts: true });
  // Return Next's MetadataRoute.Sitemap shape, or call buildSitemap and
  // serve the XML from a route handler — both work.
  return resolved.pages.map((p) => ({
    url: `${SITE_CONFIG.baseUrl}/docs/${p.slug}`,
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));
}

Agent-surface endpoints are route handlers:

// app/llms.txt/route.ts
import { buildLlmsTxt, docsToLlmsSections, resolveConfig } from "@coniferous/docs-core";

export function GET() {
  const resolved = resolveConfig(docsConfig, docLoader, { excludeDrafts: true });
  const body = buildLlmsTxt(
    docsToLlmsSections(resolved, { baseUrl: SITE_CONFIG.baseUrl }),
    { siteName: SITE_CONFIG.siteName, description: "…" },
  );
  return new Response(body, {
    headers: { "content-type": "text/plain; charset=utf-8" },
  });
}

JSON-LD blocks from buildSeoMetadata can be rendered in the page body with Next 13+'s <Script> component or emitted directly into the <head> via <head>.tsx.

Recipe 3 — Astro

Astro owns head rendering via component slots. Same pure-helper pattern.

---
// src/pages/docs/[slug].astro
import { buildSeoMetadata, resolveConfig } from "@coniferous/docs-core";
import { renderSeoToHtml } from "@coniferous/docs-core/prerender";
import { docsConfig, docLoader, SITE_CONFIG } from "../../lib/docs";

export async function getStaticPaths() {
  const resolved = resolveConfig(docsConfig, docLoader, { excludeDrafts: true });
  return resolved.pages.map((page) => ({ params: { slug: page.slug } }));
}

const { slug } = Astro.params;
const resolved = resolveConfig(docsConfig, docLoader, { excludeDrafts: true });
const page = resolved.pages.find((p) => p.slug === slug)!;
const body = docLoader.getRaw(slug) ?? undefined;
const seo = buildSeoMetadata(page, { siteConfig: SITE_CONFIG, body });
---
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <Fragment set:html={renderSeoToHtml(seo)} />
  </head>
  <body>
    <!-- your Astro content -->
  </body>
</html>

renderSeoToHtml gives you the complete head-tag string in one call — perfect for Astro's set:html. Alternatively, destructure seo into individual <meta> tags for fully typed Astro rendering.

Canonical reference — Spruce marketing site

The Spruce website uses Recipe 1 end-to-end. ~100 lines of build-script code, all of it either Vite-specific (the SSR build step) or SSR-entry glue:

Source-map protocol — overlay-compatible by default

Doc pages rendered in dev mode stamp three attributes on every element so the Spruce overlay editor can map DOM nodes back to source lines:

  • data-source-file="website/src/docs/get-started.md" (on the container)
  • data-source-start="12" (line where the node begins)
  • data-source-end="18" (inclusive end line)

stampSourceAttrs walks an mdast tree and writes those values into each node's data.hProperties — the standard unified/remark hook that flows custom attributes into HTML output:

import { parseDoc, stampSourceAttrs } from "@coniferous/docs-core";

const doc = parseDoc(raw);
stampSourceAttrs(doc.ast, {
  sourceFile: "docs/get-started.md",
  dev: process.env.NODE_ENV !== "production",
});

Or do it at parse time:

const doc = parseDoc(raw, {
  stampSource: { sourceFile: "docs/get-started.md" },
});

Because the attrs live in data.hProperties, any renderer that honors mdast hProperties emits overlay-compatible output automatically. That includes:

  • remark-rehyperehype-stringify (plain HTML pipeline)
  • @coniferous/docs-react's <DocRenderer> (which also reads position directly for symmetry)
  • A hand-written Vue / Svelte renderer, as long as it forwards hProperties to rendered elements

The overlay lives in /overlay and attaches to any site that emits the three attributes — you get live editing of your doc pages from Spruce without integrating anything.

License

MIT © Coniferous — see LICENSE.