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

@cmsweb-dev/cms

v1.6.0

Published

Show posts from the cmsweb.dev CMS on your website: the API types, the rule checker, React and Astro renderers that draw each post with your own components, a Markdown copy of each post, the sitemap, llms.txt, hreflang and refresh helpers, and the command

Readme

@cmsweb-dev/cms

Show posts from the cmsweb.dev CMS on your website, drawn with your own React or Astro components.

Posts live in the CMS. Your site reads them from its API and draws them with this package. The CMS checks every post against your site's rules when it is saved, so a post can never use a component your site does not have. No MDX is compiled on your site and nothing from a post ever runs as code.

What is inside

| Import | What it gives you | | --- | --- | | @cmsweb-dev/cms | createCms, the client your site reads the CMS with. The types of everything it sends (Post, PostSummary, Redirect...), the Rules type, the API's routes, treeToMarkdown, the sitemap, llms.txt, hreflang and refresh helpers | | @cmsweb-dev/cms/render | renderTree: draws a post with your React components | | @cmsweb-dev/cms/astro | Tree: draws a post in Astro with your components, .astro or React | | @cmsweb-dev/cms/parse | mdxToTree, readExports and checkRules: the same checks the CMS runs on every save | | @cmsweb-dev/cms/guide | This guide as text: this README and the CMS's install prompt are both made from it |

1. Install

npm install --save-exact @cmsweb-dev/cms

Pin the exact version, so your site moves to a new one only when you choose. React 19 is needed only for @cmsweb-dev/cms/render, and Astro 7 only for @cmsweb-dev/cms/astro.

2. Settings

Put the site's key in your site's .env.local, never in a committed file, and name it with a comment in .env.example:

CMS_KEY=  # the site's key, from its page in the CMS
CMS_AGENT_KEY=  # only for cms-import, on this machine: the agent key from the CMS's home page

The key names your site, so it is the only setting your site reads. CMS_URL is only for reading another CMS than https://cmsweb.dev, such as in tests. Make the client once:

// lib/cms.ts
import { createCms } from "@cmsweb-dev/cms";

// Import this file from server code only: the key sends your rules and reads drafts.
export const cms = createCms(process.env.CMS_KEY!, { url: process.env.CMS_URL });
  • The key sends your rules and reads drafts, so it must stay on your server: never import this file from a component that runs in the browser. In Next.js, import "server-only"; at its top makes that an error.
  • Set CMS_KEY on your host too, for production and for preview deploys: every build and every page reads posts with it, and a build without it fails.
  • CMS_AGENT_KEY, from the CMS's home page, is only for moving your posts in (step 12). It can write every site's posts, so it stays in .env.local on your machine, never on your host.

3. Send your rules

Your rules list each component a post may use, the props it takes, and what goes between its tags. Write them next to your components, with a strict object for each component's props so a prop it does not know is refused. Then send them all at once, from one script such as cms:rules in your package.json:

// scripts/cms-rules.ts
import type { Rules } from "@cmsweb-dev/cms";
import { z } from "zod";
import { cms } from "../lib/cms";

const rules: Rules = {
  Callout: {
    props: z.toJSONSchema(z.strictObject({ tone: z.enum(["info", "warning"]) })),
    children: "markdown",
  },
  YouTube: {
    props: z.toJSONSchema(z.strictObject({ id: z.string(), title: z.string() })),
  },
};

console.log("The CMS now holds", await cms.sendRules(rules));

What a component takes between its tags:

| children | Means | | --- | --- | | "none" | Nothing, the default: <YouTube id="..." title="..." /> | | "text" | One line of plain text, which your component gets as a string (in Astro, in its slot) | | "markdown" | Any Markdown, drawn with the same components as the rest of the post |

A wrong key throws a CmsError with status 401. Broken rules throw one that lists every problem. Send the rules again whenever a component's props change.

A prop holds plain values only: text, numbers, true, false, null, lists and objects. A component that took JSX in a prop, such as a link inside a table cell or an icon beside a value, takes an object instead and draws it itself: a cell { text, link, href }, with link the words of text that become the link, or { text, icon }. Give each field of such an object a .describe() in its rule, so whoever writes a post knows what goes in it.

4. Read posts

Read every post through the client, never from files:

// lib/posts.ts
import { cms } from "./cms";

export const posts = await cms.posts({ type: "blog", locale: "en" });
export const post = await cms.post({ type: "blog", locale: "en", slug: "my-first-post" });
export const redirects = await cms.redirects();

| Call | Request | Gives | | --- | --- | --- | | cms.posts({ type?, locale? }) | GET https://cmsweb.dev/v1/sites/your-site/posts | Every published post, without its body, newest first | | cms.post({ type, locale, slug }) | GET https://cmsweb.dev/v1/sites/your-site/posts/:type/:locale/:slug | One post with its tree, or undefined when there is none | | cms.redirects() | GET https://cmsweb.dev/v1/sites/your-site/redirects | The 301s your site should serve, each { from, to } | | cms.sendRules(rules) | PUT https://cmsweb.dev/v1/sites/your-site/rules | The components the CMS now holds (step 3) |

Post types are blog, guide and tool. Every post comes with its path on your site, such as /blog/my-first-post: use it for links and for your sitemap. A request the CMS refuses throws a CmsError with its status and the reason. A read that cannot reach the CMS at all is tried 3 times, then throws the network error, so one dropped connection does not fail your build.

Ask the CMS which posts exist every time you need to know, never a list of file names or one written at build time: a post published since the build must count, in every locale.

5. Draw a post

// components/article.tsx
import type { Post } from "@cmsweb-dev/cms";
import { renderTree, type Components } from "@cmsweb-dev/cms/render";
import { Callout, SiteLink, YouTube } from "./mdx";

const components: Components = {
  Callout,
  YouTube,
  // Optional: your own look for any Markdown element.
  a: SiteLink,
};

export function Article({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      {renderTree(post.tree, components)}
    </article>
  );
}
  • Pass every component in your rules. A post that uses one you did not pass throws an error, so a missing piece never goes unnoticed.
  • The Markdown elements are p, h1, h2, h3, h4, h5, h6, blockquote, ul, ol, li, pre, code, em, strong, a, img, hr and br. Any you do not pass is drawn as the plain HTML tag.
  • h1, h2 and h3 come with an id made from their text, for your table of contents and # links: "Créer un sprite ?" is creer-un-sprite, and a repeated heading gets -1, -2. headingId(text) makes the same id, for a component that draws its own heading. An override that wants no id leaves it out. A post saved before version 1.6.0 has them once it is saved again.
  • renderTree(post.tree, components, { unwrapImages: true }) draws a paragraph that holds only an image as the image alone, for an img that draws a block such as a <figure>, which HTML does not allow inside a <p>.
  • renderTree uses no hooks and no context, so it works in React Server Components and with react-dom/server. In Astro, draw with Tree instead (step 10), so .astro components draw and islands hydrate.
  • Never compile or run MDX on your site: the tree was checked when the post was saved.

6. A post's page

The page reads the post by its type, locale and slug, and builds its head from the post's fields:

| In the head | From | | --- | --- | | <title> and the meta description | title, description | | The canonical URL | Your main URL, then path | | og:image | image, with imageAlt | | hreflang | hreflang(post, await cms.posts({ type }), siteUrl, { codes, xDefault }) | | Article JSON-LD | datePublished from date, dateModified from dateModified (or date), author |

  • translationKey pairs a post with its translations: the posts of its type with the same key. hreflang gives the head's links, its own included, and {} when the post has no translation. codes turns a locale into its hreflang code, such as { fr: "fr-CA" }, and xDefault names the locale x-default points to. translationsOf(post, posts) gives the post's path in each locale, for your language switcher.
  • Link to a post by its path. Never put a locale in front of a slug yourself: the site's settings in the CMS decide whether the first locale has a prefix, and path already holds it.
  • A slug the CMS does not have may be a post that was renamed or deleted: redirectFor(await cms.redirects(), path) gives where it went, with path the URL the page answers on. Answer it with a 301, and only then with your 404.
  • A post published after your build must render on its first visit, then stay cached until the CMS refreshes it. Your framework's recipe (step 10) says how.

7. Images

  • post.image and every image in a post's body are full URLs on media.cmsweb.dev. Print them as they are: never put your site's URL in front of them, in a page or in a sitemap.
  • Allow that host in your image optimizer (step 10), so post images are drawn like your own.
  • Moving your posts in (step 12) uploads their images and keeps cms-import-images.json: each old path with its new URL. Serve a 301 from each old image URL to its new one, so image search follows.

8. Sitemap and llms.txt

// lib/listings.ts
import { llmsLines, sitemapEntries, sitemapXml } from "@cmsweb-dev/cms";
import { cms } from "./cms";

const SITE_URL = "https://www.example.com";

// One sitemap per locale: your own pages (here the locale's home page, on a
// site where every locale has its prefix), then every post in that locale.
export async function sitemap(locale: string): Promise<string> {
  const own = [{ url: SITE_URL + "/" + locale }];
  return sitemapXml([...own, ...sitemapEntries(await cms.posts(), SITE_URL, { locale })]);
}

export async function llmsTxt(): Promise<string> {
  return ["# Example", "", ...llmsLines(await cms.posts(), SITE_URL), ""].join("\n");
}
  • sitemapEntries gives each post its full URL, its dateModified (or date) as lastmod, and its image. { locale } makes a sitemap per locale, and { hreflang } lists each post's translations in it too. Its entries are what Next's MetadataRoute.Sitemap takes, and sitemapXml writes them as XML, your own pages' entries with them.
  • llmsLines gives one line of llms.txt per post: a link to it and what it is about.
  • treeToMarkdown(post.tree, { components }) gives a post as Markdown, for a Markdown copy of each page and an llms-full.txt. A hook per component writes it, (props, children) => string | null, with children the Markdown between its tags, and null leaves the component out. A component without a hook gives its children's Markdown, so one that holds nothing (an image, a diagram) is left out.
  • Both are built from every post, so a refresh must be able to build them again: when a post's entry changes, the refresh clears each path in its sitemap option, /sitemap.xml and /llms.txt unless you name your own (step 9). A sitemap written as a file at build time never shows a new post.

9. Cache and refresh your pages

The API never caches its answers: cache the pages your site builds from them. After a write that changes what your site shows (a publish, a save of a published post, a delete, a restore, a new slug), the CMS sends a POST to /api/cms/refresh on your site's main URL. Answer it with cms.handleRefresh(request, clear, { sitemap }), in the route your framework's recipe (step 10) shows:

  • clear(paths) is yours: it clears each page from your cache. paths holds the post's path (its old one too, after a new slug), the page that lists it and, when the post's entry in your sitemap and llms.txt changed, every path in sitemap.
  • It answers 401 unless the CMS signed the call with your key in the last 5 minutes, and 500 when clear throws, so the CMS shows that the refresh failed.
  • The main URL is https on the domain set on your site's page in the CMS, and that host must answer the POST itself: a redirect (the bare domain to www, http to https) turns the POST into a GET, and the refresh fails. Set the domain to the host your site serves, such as www.example.com.
  • A locale middleware must let /api/cms/refresh through: its matcher skips /api.
  • The CMS sends each refresh once and never waits for your site. Build no page again on a timer for it: a refresh your site missed (it was down) is sent again by Refresh the site's pages in the post's menu in the CMS, or by the refresh tool of its MCP.
  • A content page must never read a login cookie on the server, or it can no longer be cached.

10. Your framework

Your site needs a server for its post pages: they render on demand, and the refresh clears them. A static site can stay static everywhere else and render only its post pages on demand. A site with no server at all, such as a single-page app, cannot show a post published after its build.

Next.js

| Part | How | | --- | --- | | The refresh route | app/api/cms/refresh/route.ts | | Clear a page | revalidatePath(path) | | A post published after the build | The post page lists the posts in generateStaticParams and keeps dynamicParams = true: each page is built once, again only on a refresh, and a new slug renders on its first visit. Under app/[locale], every copy of the page keeps it too | | Draw a post | renderTree in a server component (step 5) | | The image host | images: { remotePatterns: [{ protocol: "https", hostname: "media.cmsweb.dev" }] } in next.config | | Sitemap and llms.txt | A route handler with dynamic = "force-static" at a path that does not end in sitemap.xml, such as /sitemaps/en, which next.config rewrites /sitemap.xml to. Next ships app/sitemap.ts as a file a refresh cannot rebuild, and on Vercel a prerendered path ending in /sitemap.xml fails the build. /llms.txt can be a force-static route handler as it is |

// app/api/cms/refresh/route.ts
import { revalidatePath } from "next/cache";
import { cms } from "@/lib/cms";

// Built from every post: cleared when a post's entry in them changed.
const LISTINGS = ["/sitemaps/en", "/sitemaps/fr", "/llms.txt"];

export function POST(request: Request) {
  return cms.handleRefresh(request, (paths) => paths.forEach((path) => revalidatePath(path)), { sitemap: LISTINGS });
}
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { hreflang, redirectFor } from "@cmsweb-dev/cms";
import { Article } from "@/components/article";
import { cms } from "@/lib/cms";

const SITE_URL = "https://www.example.com";
type Props = { params: Promise<{ locale: string; slug: string }> };

// Each post is built once, then again only when the CMS refreshes it, and a
// slug published after the build renders on its first visit.
export const dynamicParams = true;

export async function generateStaticParams() {
  return (await cms.posts({ type: "blog" })).map(({ locale, slug }) => ({ locale, slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale, slug } = await params;
  const post = await cms.post({ type: "blog", locale, slug });
  if (!post) return {};
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: SITE_URL + post.path, languages: hreflang(post, await cms.posts({ type: "blog" }), SITE_URL) },
    openGraph: { images: post.image ? [{ url: post.image, alt: post.imageAlt }] : [] },
  };
}

export default async function Page({ params }: Props) {
  const { locale, slug } = await params;
  const post = await cms.post({ type: "blog", locale, slug });
  if (!post) {
    // The path this page answers on, as the site writes its URLs: here every
    // locale has its prefix. A renamed or deleted post moved from it.
    const movedTo = redirectFor(await cms.redirects(), "/" + locale + "/blog/" + slug);
    if (movedTo) permanentRedirect(movedTo);
    notFound();
  }
  return <Article post={post} />;
}
// app/sitemaps/[locale]/route.ts
import { sitemap } from "@/lib/listings";

// Built once, then again only when the CMS refreshes its path. next.config
// rewrites /sitemap.xml and /fr/sitemap.xml here.
export const dynamic = "force-static";

export function generateStaticParams() {
  return [{ locale: "en" }, { locale: "fr" }];
}

export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  return new Response(await sitemap(locale), { headers: { "content-type": "application/xml" } });
}

Astro

| Part | How | | --- | --- | | The refresh route | src/pages/api/cms/refresh.ts, with export const prerender = false | | Clear a page | context.cache.invalidate({ path }): Astro's route cache (Astro 7), with a provider in astro.config: memoryCache() on Node, or your adapter's cacheVercel(), cacheNetlify() or cacheCloudflare() | | A post published after the build | Post pages render on demand (export const prerender = false) and stay cached with Astro.cache.set({ maxAge: 31536000 }) until a refresh clears them. A slug redirectFor finds answers Astro.redirect(to, 301). The rest of the site can stay prerendered | | Draw a post | <Tree tree={post.tree} components={components} /> from @cmsweb-dev/cms/astro, with .astro components or React ones. A component that hydrates goes in as an .astro wrapper that sets its client:* directive, since Astro hydrates only what an .astro file imports. unwrapImages and the heading ids work as in step 5 | | The image host | image: { domains: ["media.cmsweb.dev"] } in astro.config. On Cloudflare, also the adapter's imageService: "cloudflare": a Worker has no sharp, so without it images are served as they are, never resized | | Sitemap and llms.txt | src/pages/sitemap.xml.ts, on demand, answering sitemapXml(...), and src/pages/llms.txt.ts the same way |

  • On Cloudflare, @astrojs/cloudflare 14 builds a Worker with static assets, not a Pages site, and needs astro ^7.4.0 and wrangler ^4.125.0. A site on Cloudflare Pages moves to a Worker. cacheCloudflare() from @astrojs/cloudflare/cache tags each page with its path, so context.cache.invalidate({ path }) purges it at the edge.
  • A component gets what is between its tags in its slot, never as a string: a React component gets it as the children it draws. Props keep the names the tree gives them, such as className, so an override written for renderTree works as it is.
  • Astro keeps a post's fields in a YAML frontmatter, at src/content/{type}/{locale}/{slug}.mdx: cms-import reads it as metadata (step 12).
// src/pages/api/cms/refresh.ts
import type { APIRoute } from "astro";
import { cms } from "@/lib/cms";

export const prerender = false;

export const POST: APIRoute = (context) =>
  cms.handleRefresh(context.request, (paths) => Promise.all(paths.map((path) => context.cache.invalidate({ path }))));
---
// src/pages/blog/[slug].astro
import { redirectFor } from "@cmsweb-dev/cms";
import Tree from "@cmsweb-dev/cms/astro";
import Callout from "@/components/Callout.astro";
import FaqIsland from "@/components/FaqIsland.astro";
import { cms } from "@/lib/cms";

export const prerender = false;

const slug = Astro.params.slug!;
const post = await cms.post({ type: "blog", locale: "en", slug });
if (!post) {
  const movedTo = redirectFor(await cms.redirects(), "/blog/" + slug);
  return movedTo ? Astro.redirect(movedTo, 301) : new Response(null, { status: 404 });
}
Astro.cache.set({ maxAge: 31536000 });
---

<article>
  <h1>{post.title}</h1>
  <Tree tree={post.tree} components={{ Callout, Faq: FaqIsland }} unwrapImages />
</article>
---
// src/components/FaqIsland.astro
import Faq from "./Faq";
---

<Faq client:visible {...Astro.props} />

Node (Hono, Express)

| Part | How | | --- | --- | | The refresh route | POST /api/cms/refresh. Hono passes c.req.raw. Express and node:http pass await nodeRequest(req), with no body parser before the route | | Clear a page | Delete the page from your own cache, or purge it at your CDN | | A post published after the build | Render each page on request, and keep its HTML until a refresh clears it | | Draw a post | renderTree, turned into HTML with renderToString from react-dom/server | | The image host | Nothing to set, unless your Content-Security-Policy lists image hosts: add https://media.cmsweb.dev to img-src | | Sitemap and llms.txt | A route that answers sitemapXml(...), and one for /llms.txt |

// server.ts
import { createServer } from "node:http";
import { nodeRequest } from "@cmsweb-dev/cms";
import { cms } from "./lib/cms";

// Your page cache: each page's HTML by its path, kept until a refresh.
const pages = new Map<string, string>();

createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/api/cms/refresh") {
    const answer = await cms.handleRefresh(await nodeRequest(req), (paths) => paths.forEach((path) => pages.delete(path)));
    res.writeHead(answer.status, { "content-type": "application/json" }).end(await answer.text());
    return;
  }
  // Your pages: from the cache, or rendered on request and kept there.
}).listen(3000);

11. Preview drafts

Nothing to add. Beside each post, the CMS shows your own page, copied as your site serves it and without its scripts, with the post's body in it as it is written. It works the same whatever your site is built with.

  • Your components show as labelled boxes while the post is being written. Once a published post is saved, your site refreshes it and the copy is your own page, exact.
  • A post not published yet borrows the page of a published post of its type.
  • The CMS copies the page from your site's server, so it previews on the URLs the internet reaches: your main URL, or a preview deploy among the site's other URLs. It reaches http://localhost:3000 only when the CMS runs on that same machine.
  • drafts: true still reads drafts with your key, on the server only, should your site want a preview of its own. A draft comes with status: "draft" and today's date until it is published.

12. Move your posts in

If your posts are MDX files today, cms-import moves them in. It names the site from CMS_KEY and writes with CMS_AGENT_KEY, both from .env.local. Without --write it is a dry run: it reads every file, checks it against the rules the CMS holds, and lists what it would upload and write.

npx cms-import "content/{type}/{locale}/{slug}.mdx"
npx cms-import "content/{type}/{locale}/{slug}.mdx" --write
  • The pattern names where each part of a post's key sits. A folder that is not named after a type of post takes --type, such as npx cms-import "content/guides/{slug}.mdx" --type=guide. Without {locale}, the posts are in the site's first locale, or in --locale. --only=<slug> moves one post.
  • Each export const is read as data with readExports, never run. metadata's title, description, date, dateModified, author, tags, order, translationKey, image and imageAlt become the post's fields, and faq and featureList its extra. Any other key is left out.
  • A YAML frontmatter, as Astro writes one, is read as metadata, with a date kept as written. A file with both a frontmatter and export const metadata is refused: keep the fields in one place.
  • {/* */} comments are taken out of the body, each line kept as a blank line, so a refused line has the number it has in the file.
  • The dry run lists every prop that holds code, JSX in a table cell most often. Change the component and its rule to take an object (step 3), send the rules again, write the object in the file, and run the dry run again.
  • Every image path in a post (/content/a.jpg, in Markdown or in a component's props) whose file is under public/ (or --public) is uploaded, keeping its file's name, and the post points at its new URL.
  • It is safe to run again: a post that exists is updated, and cms-import-images.json keeps each image sent. Each post is published, and its note in the CMS names the file it came from.

13. After the move

  • Search the repo for everything that still reads the post files or their names: hreflang lists, a language switcher, a sitemap or llms.txt built from the folders, a prebuild step. Each must read cms.posts() instead.
  • Then delete the post files and their MDX pipeline (the loader, the imports, the generated lists), so no page can still read them.
  • Write in the site's CLAUDE.md (or AGENTS.md) and in its skills that posts live in the CMS and are written through its MCP, never as files. Otherwise the next agent edits a file, and nothing on the site changes.

Other helpers

| Export | Does | | --- | --- | | apiUrl(base, route, params, query?) | The full URL of an API route, every value encoded | | postPath(site, post) | A post's path, from a site's locales and URL patterns | | listPath(site, post) | The page that lists a post's type, such as /blog | | redirectFor(redirects, path) | Where a renamed or deleted post's URL went, for your 301, or undefined | | hreflang(post, posts, siteUrl, { codes?, xDefault? }) | A post's hreflang links, its own included, by translationKey: {} when it has no translation | | translationsOf(post, posts) | A post's path in each locale it is published in, for a language switcher | | sitemapEntries(posts, siteUrl, { locale?, hreflang? }) | A sitemap entry per post: its URL, lastmod and image, in the shape Next's MetadataRoute.Sitemap takes | | sitemapXml(entries) | The sitemap as XML, your own pages' entries included | | llmsLines(posts, siteUrl) | One llms.txt line per post: a link and what it is about | | handleRefresh(request, key, clear, { sitemap? }) | The whole refresh route: cms.handleRefresh(request, clear, options?) is the same with your key | | nodeRequest(req) | A Node or Express request as the Request the refresh reads | | pathInSite(href, hosts) | The path a link points to on your site (any of its hosts), or undefined if it leaves it | | isSitePath(path) | Whether a path stays on your site: //host and /\host do not | | checkSitePaths(site) | Checks a site's locales and URL patterns, and lists what is wrong | | linksIn(nodes) | Everything in a post's tree that can be a link: each Markdown link, and every text in a component's props | | treeToMarkdown(tree, { components? }) | A post as Markdown, for a Markdown copy of a page and llms-full.txt: each component as its hook writes it (step 8) | | headingId(text) | The id the CMS gives a heading, made from its text, for a component that draws a heading of its own | | mdxToTree(source, rules) | Turns MDX into a tree, or lists every rule it breaks, with its line | | mdxToTreeWithLines(source, rules) | mdxToTree, plus the lines each block at the top of the tree is written on, for an editor that shows a post beside its source | | readExports(source) | Splits an MDX file into its export const values or its YAML frontmatter, read as data and never run, and its body | | checkRules(value) | Checks that a value is valid rules, such as before you send them |

Versions

The major version follows the API: 1.x reads /v1. A breaking change comes as a new major with a new API next to this one, so your site moves only when you update it.

If renderTree or Tree throws "this post's tree is version 2", the CMS has moved ahead of your site: update this package.