API reference
Every export, option, method, default, type, prop, icon name, and CSS token in @daanvandenbergh/scribekit.

@daanvandenbergh/scribekit publishes three entry points: the root (the Blog and Docs classes plus the framework-free helpers), /react (the components), and /styles.css (the stylesheet). This page lists every public export, its signature, and its default. To learn the wiring, read Getting started; for task steps, read the blog guide or the docs guide.
The entry points
| Subpath | Resolves to | Exports |
|---|---|---|
@daanvandenbergh/scribekit | dist/index.js | Blog, Docs, their errors, SEO builders, nav builders, shared helpers, shared types |
@daanvandenbergh/scribekit/react | dist/react/index.js | Blog components, docs components, the i18n core |
@daanvandenbergh/scribekit/styles.css | dist/react/styles.css | The stylesheet. Import once in your app |
The root entry re-exports ./shared, ./blog, and ./docs in that order. Both the Blog and Docs class modules carry import "server-only" and read the filesystem, so they resolve only in a server component, a route handler, or a build script. Importing either from a client component is a build error.
Both read it through one shared, package-internal module that owns every node:fs call, the path-traversal guard, and the read cache. It is not exported and you never construct it, but it is why a page renders the same content whether it is the first read or the fiftieth: each file is read and normalised once, then re-used until its mtime or size changes on disk. Editing a post in next dev shows up immediately, and there is no cache to warm, clear, or configure.
import { Blog, Docs, slugify, formatDate, localePath } from "@daanvandenbergh/scribekit";
import { BlogPage, DocsPage, DocsSidebar } from "@daanvandenbergh/scribekit/react";
import "@daanvandenbergh/scribekit/styles.css";
Peer dependencies: react (>=18) is the only hard one. next (>=14), react-dom (>=18), and next-mdx-remote (^5 || ^6) are all marked optional in peerDependenciesMeta, so npm stays quiet for a consumer who only imports the Blog or Docs class - though BlogPage and DocsPage need next, react-dom, and next-mdx-remote at render time.
Real dependencies, installed for you: @daanvandenbergh/i18nkit, fuse.js, gray-matter, remark-gfm, and server-only.
Docs
new Docs(config: DocsConfig)
DocsConfig extends Partial<SiteConfig>, so the site attributes are flat on the config object. The constructor does not throw.
DocsConfig options
| Option | Type | Default | Purpose |
|---|---|---|---|
contentDir | string | required | Directory holding one <slug>/ folder per page. A relative path resolves against process.cwd() |
extension | string | ".mdx" | Page file extension, leading dot included |
basePath | string | "/docs" | Route the docs are mounted at. "" mounts at the site root (/<slug>) |
locale | string | "en-GB" | BCP 47 locale used by formatDate |
locales | LocaleConfig[] | [] | Languages published. Each label defaults to its code. Unset means single-language |
defaultLocale | string | config.defaultLocale, else locales[0].code, else locale.split("-")[0], else "en" | Locale served without a URL prefix, and the x-default target |
prefixDefaultLocale | boolean | false | When true, the default locale is URL-prefixed too |
trailingSlash | boolean | true | When true, every built URL ends in a slash (/docs/quickstart/). Must match the host app's next.config trailingSlash |
tabs | NavConfigEntry[] | [] | Display order and labels for top-level tabs. Purely presentational |
groups | NavConfigEntry[] | [] | Display order and labels for sidebar groups |
redirects | Record<string, string> | {} | Old slug to new slug, for renamed pages |
siteUrl | string | undefined | Absolute origin. Sets metadataBase and absolute JSON-LD URLs |
brandName | string | undefined | Title suffix, og:siteName, JSON-LD publisher |
defaultAuthor | string | undefined, falls back to brandName | Author used when a page omits one |
description | string | undefined, falls back to `The ${brandName} documentation.` | Index page description |
organizationId | string | undefined, which inlines a standalone Organization publisher | @id of an existing Organization |
authorId | string | undefined, which inlines a name-only author | @id of a Person or Organization author |
websiteId | string | undefined, which omits isPartOf | @id of the host WebSite entity |
this.site is assembled only when both siteUrl and brandName are defined; otherwise it is undefined and every SEO method throws. site.basePath is always the resolved basePath.
Public fields
| Field | Type |
|---|---|
readonly locale | string |
readonly locales | LocaleConfig[] |
readonly defaultLocale | string |
readonly prefixDefaultLocale | boolean |
readonly trailingSlash | boolean |
readonly tabs | NavConfigEntry[] |
readonly groups | NavConfigEntry[] |
readonly site | SiteConfig | undefined |
Nothing else is exposed. basePath and redirects are private fields; contentDir and extension are not fields at all - they are passed straight into the internal content store at construction time and never held on the instance.
Methods
Every lang? parameter defaults to this.defaultLocale.
| Signature | Returns | Throws |
|---|---|---|
getDocSlugs() | string[] of distinct slugs across all languages | DuplicateDocError |
getDocRefs() | { slug: string; lang: string }[], every pair, including hidden pages | DuplicateDocError |
getRedirect(slug, lang?) | string | undefined. A root-relative, locale-prefixed destination; undefined means 404 | DuplicateDocError |
getRedirectRefs() | { slug: string; lang: string }[], one per live redirect per language; inert entries filtered | DuplicateDocError |
getDoc(slug, lang?) | Doc | DocNotFoundError |
getAllDocs(lang?) | DocMeta[] for one language in on-disk order, including hidden | DuplicateDocError |
getTranslations(slug) | string[] of language codes, default first. Does not filter hidden | DuplicateDocError |
getNavTree(lang?) | NavTree. hidden pages excluded | DuplicateDocError |
getBreadcrumb(slug, lang?) | Breadcrumb | undefined; undefined for hidden or unknown | DuplicateDocError |
getAdjacent(slug, lang?) | Adjacent; {} for hidden or unknown | DuplicateDocError |
dateLocale(lang?) | string, the resolved BCP 47 date locale | no |
formatDate(iso, lang?) | string. An unparseable value is returned as-is | no |
readingMinutes(doc) | number, whole minutes, floored at 1 | no |
tableOfContents(doc) | TocEntry[] from ## and ### headings | no |
docMetadata(doc) | PageMetadata | Error when site is unset |
indexMetadata(lang?) | PageMetadata | Error when site is unset |
docJsonLd(doc) | JsonLd: TechArticle + BreadcrumbList | Error when site is unset |
indexJsonLd(lang?) | JsonLd: CollectionPage + BreadcrumbList + ItemList | Error when site is unset |
sitemapEntries() | SitemapEntry[], one per non-hidden (slug, lang), with hreflang alternates | Error when site is unset |
The five SEO methods route through a private requireSite(), which throws this message verbatim:
This Docs was created without a `site` config; pass `siteUrl` and `brandName` to use the SEO helpers.
dateLocale(lang?) resolves in this order: the matched locale's own dateLocale; then, if the code equals defaultLocale, the instance locale; then the matched code; then the instance locale. An unconfigured language returns the instance locale, not the code.
Page file resolution
entries() walks the immediate subdirectories of contentDir only. There is no recursion and no nested slugs, and a missing contentDir yields []. Per <slug>/ folder it checks post<ext> first, then <defaultLocale><ext>; both present is a DuplicateDocError. It then checks each configured non-default locale as <code><ext>. A <slug>/fr.mdx whose fr is not in config.locales is never discovered.
Reading one page uses the opposite priority. getDoc() delegates to the internal content store, whose candidate list is:
const candidates =
lang === this.defaultLocale
? [`${slug}/${lang}${this.extension}`, `${slug}/post${this.extension}`]
: [`${slug}/${lang}${this.extension}`];
For the default locale, <defaultLocale><ext> wins and post<ext> is the fallback. A non-default locale has no post fallback. A resolved path that escapes contentDir throws DocNotFoundError.
hidden
A hidden: true page stays in entries(), getDocRefs(), and getAllDocs(), so generateStaticParams still renders it and a direct link works. It is excluded from the nav tree, and therefore from getAdjacent (returns {}), getBreadcrumb (returns undefined), and indexJsonLd's ItemList. It receives robots: { index: false }, and is dropped from the sitemap, from siblings' hreflang, and from JSON-LD translation cross-links.
redirects
getRedirect(slug, lang?) resolves against three rules, in order:
- A real page always wins. An entry whose source slug still exists on disk is inert.
- Chains resolve in one hop.
{ a: "b", b: "c" }sendsastraight toc. - Cycles yield
undefined, so the URL 404s rather than looping.
The returned string is already locale-prefixed and base-rooted, ready for Next's permanentRedirect(). getRedirectRefs() exists because a route with dynamicParams = false 404s an unrendered slug at the router, before the page component can redirect.
export function generateStaticParams() {
return [...docs.getDocRefs(), ...docs.getRedirectRefs()];
}
DocMeta
The normalized front matter of one docs page. slug and lang are injected by the reader and are never read from YAML.
| Field | Type | Required | Normalization |
|---|---|---|---|
slug | string | yes | The directory name |
lang | string | yes | From filename resolution |
title | string | yes | Text coercion, else the slug |
description | string | yes | Text coercion, else "" |
tab | string? | no | Text coercion |
group | string? | no | Text coercion |
order | number? | no | Kept only if typeof === "number" and Number.isFinite |
icon | string? | no | typeof data.icon === "string", strict |
label | string? | no | Text coercion |
keywords | string[]? | no | Array.isArray then map/filter; a non-array becomes undefined |
image | string? | no | typeof data.image === "string", strict |
date | string? | no | isoDateString(data.date) |
updated | string? | no | isoDateString(data.updated) |
readingTime | number? | set in practice | readingMinutes(content), computed from the body. A front-matter value is ignored |
hidden | boolean? | no | data.hidden === true ? true : undefined, strict identity |
Which YAML types are silently dropped
Text coercion is the rule applied to title, description, tab, group, label, and each keywords element. It accepts exactly three shapes: a string as-is; a finite number, stringified; and a Date, converted to YYYY-MM-DD. Everything else becomes undefined. The coercion step itself is internal and not exported; only its effect is observable. Nothing errors, so a mistyped field is invisible until the page misbehaves.
| YAML | Result |
|---|---|
order: "3" | Dropped. The page sorts into the unordered bucket, after every ordered sibling |
hidden: "true" or hidden: 1 | Not hidden. Only the bare boolean true hides a page |
keywords: "a, b" | Dropped. keywords must be a YAML list |
keywords: [foo, true, 3, null] | ["foo", "3"]. Booleans, nulls, and objects are filtered out |
keywords: [] | Stays [], not undefined |
title: 2024 | Survives as "2024" |
icon: 2024 or image: 2024 | Dropped. Those two use a strict typeof === "string" check |
Booleans, arrays, objects, null, ~, NaN, .inf in a text field | Dropped |
A dropped title | Falls back to the slug |
A dropped description | Becomes "", shipping a blank meta and OG description |
date: "not-a-date" | Survives verbatim. isoDateString passes any string through unvalidated, with no YYYY-MM-DD shape check |
Blog
new Blog(config: BlogConfig)
BlogConfig extends Partial<SiteConfig>. The site attributes are flattened onto the config; there is no nested site key.
BlogConfig options
| Option | Type | Default | Purpose |
|---|---|---|---|
contentDir | string | required | Directory holding one <slug>/ folder per post. A relative path resolves against process.cwd() |
extension | string | ".mdx" | Post file extension, leading dot included |
locale | string | "en-GB" | BCP 47 locale for formatDate |
locales | LocaleConfig[] | [] | Languages published. Unset means single-language |
defaultLocale | string | config.defaultLocale, else locales[0].code, else locale.split("-")[0], else "en" | Unprefixed locale and x-default target |
prefixDefaultLocale | boolean | false | Prefix the default locale too |
trailingSlash | boolean | true | End every built URL in a slash (/blog/post/). Must match the host app's next.config trailingSlash |
basePath | string | "/blog" | Mount route, run through normalizeBasePath |
siteUrl | string | undefined | Origin for metadataBase and absolute JSON-LD. Gates site |
brandName | string | undefined | Title suffix, og:siteName, publisher. Gates site |
defaultAuthor | string | undefined | Author fallback |
description | string | falls back to `The ${brandName} blog.` | Index description |
organizationId | string | undefined | @id reference for the publisher |
authorId | string | undefined | @id reference for the author |
websiteId | string | undefined | @id reference for isPartOf |
site is built only when siteUrl !== undefined && brandName !== undefined.
Public fields
| Field | Type |
|---|---|
readonly locale | string |
readonly locales | LocaleConfig[] |
readonly defaultLocale | string |
readonly prefixDefaultLocale | boolean |
readonly trailingSlash | boolean |
readonly site | SiteConfig | undefined |
Nothing else is exposed; contentDir and extension are not fields at all, only constructor arguments handed to the internal content store.
Methods
| Signature | Returns | Throws |
|---|---|---|
getPostSlugs() | string[] of distinct slugs across all languages | DuplicatePostError |
getPostRefs() | { slug: string; lang: string }[], every pair | DuplicatePostError |
getPost(slug, lang?) | Post | PostNotFoundError |
getAllPosts(lang?) | PostMeta[] for one language, sorted by date descending | DuplicatePostError |
getTranslations(slug) | string[], default first | DuplicatePostError |
getAllCategories(lang?) | string[], distinct and sorted | DuplicatePostError |
dateLocale(lang?) | string | no |
formatDate(iso, lang?) | string | no |
readingMinutes(post) | number, whole minutes, floored at 1 | no |
tableOfContents(post) | TocEntry[] | no |
similarPosts(post, limit?) | PostMeta[], same-language neighbours. limit defaults to 3 | DuplicatePostError |
postMetadata(post) | PageMetadata | Error when site is unset |
overviewMetadata(lang?) | PageMetadata | Error when site is unset |
postJsonLd(post) | JsonLd | Error when site is unset |
overviewJsonLd(posts, lang?) | JsonLd | Error when site is unset |
sitemapEntries() | SitemapEntry[] | DuplicatePostError, Error when site is unset |
rssFeed(lang?) | string, one locale's complete RSS 2.0 document, newest post first | DuplicatePostError, Error when site is unset |
requireSite() throws this message verbatim:
This Blog was created without a `site` config; pass `site` to use the SEO helpers.
That message is misleading. BlogConfig has no site key. The config is flat: pass siteUrl and brandName. Do not look for a site option; there is none.
Blog.dateLocale's JSDoc claims the fallback is the language code itself. The code returns the instance locale for an unconfigured language.
Post file resolution
entries() walks the immediate subdirectories of contentDir only; non-directories are skipped, so a stray notes.txt is ignored. Both <slug>/post<ext> and <slug>/<defaultLocale><ext> are checked, and having both is a DuplicatePostError. Each configured non-default locale is <slug>/<code><ext>. Any other file stem is ignored. In getPost, the locale-named file wins and post<ext> is the fallback.
There is an asymmetry: post.mdx and en.mdx together throw from entries(), and therefore from getPostSlugs, getAllPosts, and sitemapEntries, but getPost does not throw and silently returns en.mdx.
Drafts and hidden posts do not exist in the blog module. Every discovered post is published. PageMetadata.robots exists in the type, but no blog builder ever sets it.
similarPosts
similarPosts(current: PostMeta, all: PostMeta[], limit: number = 3): PostMeta[]
- Build a term vector for
current; if empty, return[]. - Exclude
currentby slug. - Score every candidate by cosine similarity, which returns a value in
[0, 1]. - Drop every candidate whose score is
0. - Sort by score descending, tie-broken by
datedescending. - Slice to
Math.max(0, limit).
Term weights: each keyword counts 3, the title 2, the description 1. Scoring is term-frequency only, with no idf. categories are not used in the ranking. Tokenization lowercases, splits on /[^\p{L}\p{N}]+/u, and keeps tokens of length 3 or more that are not among the 22 English-only stopwords: the, and, for, with, that, this, from, into, are, was, were, you, your, how, why, what, when, who, our, its, their, about.
Blog.similarPosts calls this with this.getAllPosts(post.meta.lang), so candidates are same-language only.
collectCategories
collectCategories(posts: PostMeta[]): string[]
Returns [...new Set(posts.flatMap((p) => p.categories ?? []))].sort(): distinct labels in default lexicographic order, not localeCompare.
PostMeta
| Field | Type | Required | Normalization |
|---|---|---|---|
slug | string | yes | The directory name |
lang | string | yes | From the filename stem |
title | string | yes | Text coercion, else the slug |
date | string | yes | isoDateString(data.date) ?? "" |
description | string | yes | Text coercion, else "" |
keywords | string[]? | no | Array.isArray then map/filter, else undefined |
categories | string[]? | no | Same shape as keywords |
readingTime | number? | set in practice | readingMinutes(content) |
author | string? | no | typeof === "string", strict |
authorImage | string? | no | typeof === "string", strict. The YAML key is author-image, kebab-case |
image | string? | no | typeof === "string", strict |
updated | string? | no | isoDateString(data.updated) |
The camelCase authorImage field is populated from the kebab-case author-image YAML key:
---
title: "A post"
date: "2026-07-16"
author: "Ada Lovelace"
author-image: "/assets/authors/ada.jpg"
---
Normalization notes:
| YAML | Result |
|---|---|
categories: "news" | Dropped. Only an array survives |
author: 404 | undefined. author, authorImage, and image are string-only, unlike title: 404, which becomes "404" |
date: "yesterday" | Survives into meta.date, datePublished, and the sort. date is not validated |
Post is { meta: PostMeta; content: string }, where content is the MDX body with the front matter stripped.
Errors
All four are declared in fs-free modules, so client code can instanceof them.
| Error | Fields | Message |
|---|---|---|
DocNotFoundError | readonly slug: string | `No docs page found for slug "${slug}".` |
DuplicateDocError | slug, lang, files: [string, string] | `Two files resolve to the same docs page (slug "${slug}", lang "${lang}"): "${a}" and "${b}".` |
PostNotFoundError | readonly slug: string | `No blog post found for slug "${slug}".` |
DuplicatePostError | slug, lang, files: [string, string] | `Two files resolve to the same post (slug "${slug}", lang "${lang}"): "${a}" and "${b}".` |
| Error | Thrown when |
|---|---|
DocNotFoundError | No candidate file exists for the slug, or the resolved path escapes contentDir |
DuplicateDocError | Two files map to one ${slug}/${lang}; in practice <slug>/post.mdx and <slug>/<defaultLocale>.mdx both exist |
PostNotFoundError | No candidate file exists, or the path escapes contentDir |
DuplicatePostError | Two files map to one ${slug}/${lang}; in practice post.mdx and en.mdx both exist |
import { Docs, DocNotFoundError } from "@daanvandenbergh/scribekit";
try {
docs.getDoc(slug);
} catch (err) {
if (err instanceof DocNotFoundError) {
console.error(err.slug);
}
}
Navigation builders
Pure and fs-free. They import no next and no react, and none of them throw.
| Signature |
|---|
buildNavTree(metas: DocMeta[], opts: NavBuildOptions): NavTree |
flattenNav(tree: NavTree): NavItem[] |
adjacentFor(slug: string, flat: NavItem[]): Adjacent |
breadcrumbFor(slug: string, tree: NavTree): Breadcrumb | undefined |
NavBuildOptions:
| Field | Type | Required |
|---|---|---|
basePath | string? | no |
defaultLocale | string | yes |
lang | string? | no |
prefixDefaultLocale | boolean? | no |
trailingSlash | boolean? | no |
tabs | NavConfigEntry[]? | no |
groups | NavConfigEntry[]? | no |
An omitted basePath falls back to "/blog", because normalizeBasePath supplies that default. Docs always passes its own basePath, so this only affects direct callers of buildNavTree. trailingSlash is forwarded to localePath for every item href and defaults to true the same way, so a direct caller that omits it gets slash-terminated nav links.
lang is the language the tree is built for, and it selects which entry of a per-locale tab or group label / description map is used. It defaults to defaultLocale. Docs.getNavTree always passes it.
Sort rules
Bucketing: hidden pages are skipped. tabId = meta.tab ?? "" and groupId = meta.group ?? "".
Pages within a group compare on two keys:
order, ascending, where an unsetorderbecomesNumber.POSITIVE_INFINITY, so unordered pages sort after every ordered one.- On a tie,
a.title.localeCompare(b.title), by title, not label and not slug.
Tabs and groups compare on three keys:
configIndex, the position in thetabsorgroupsconfig array. An unlisted entry is-1, mapped toInfinity, so configured entries come first in config order and unconfigured entries follow.minOrder, the minimumorderamong descendant pages.firstSeen, discovery order fromfs.readdirSync, as the final tie-break.
A tab or group label is the label given for that id in the tabs or groups config, falling back to the id itself. The implicit "" tab and "" group therefore get an empty-string label, which is falsy, so they contribute no breadcrumb segment.
adjacentFor walks the flattened tree, so prev/next crosses group and tab boundaries: the last page of one tab links straight into the first page of the next.
Known behaviour: when two pages in a group both lack order, the comparator computes Infinity - Infinity, which is NaN; Array#sort coerces that to 0. Those two pages keep their on-disk order rather than sorting alphabetically by title. The alphabetical tie-break applies only when both pages carry the same finite order. This is tracked in the repository's TODO.md.
SEO builders
Pure and fs-free. Each takes a SiteConfig whose siteUrl and brandName are required at the type level. At runtime, new URL(site.siteUrl) throws TypeError [ERR_INVALID_URL] on a missing or invalid siteUrl; the readable Error comes only from the Blog and Docs wrapper methods via requireSite().
Docs
| Signature | Emits |
|---|---|
buildDocMetadata(meta, site, translations = [meta.lang]): PageMetadata | metadataBase, title: `${meta.title} | ${site.brandName}`, description, keywords, authors, alternates.canonical and languages, OpenGraph (type: "article", publishedTime, modifiedTime: meta.updated ?? meta.date, images, locale, alternateLocale), Twitter (card: "summary_large_image"). Sets robots: { index: false } iff meta.hidden. hreflang is undefined when translations.length <= 1 |
buildIndexMetadata(site, lang?, langs = []): PageMetadata | title: `Docs | ${site.brandName}`, OG type: "website" with title: `${brandName} Docs`. No robots key; the index is never noindexed |
docJsonLd(meta, site, translations = [meta.lang]): JsonLd | @graph of a TechArticle and a BreadcrumbList (Home, Docs, the group name only, the page title). The tab never appears in JSON-LD breadcrumbs. When the page is translated, the TechArticle also carries workTranslation (an array, on the original-language version) or translationOfWork (on each translation) |
indexJsonLd(items, site, lang?): JsonLd | CollectionPage, BreadcrumbList, and an ItemList only when items.length > 0 |
The docs section name in breadcrumbs is the literal "Docs".
Blog
| Signature | Emits |
|---|---|
buildPostMetadata(meta, site, translations = [meta.lang]): PageMetadata | title: `${meta.title} | ${site.brandName}`, alternates.canonical, OG type: "article", publishedTime: meta.date || undefined, modifiedTime: meta.updated ?? (meta.date || undefined), locale, twitter.card: "summary_large_image". Never sets robots |
buildOverviewMetadata(site, lang?, langs = []): PageMetadata | title: `Blog | ${site.brandName}`, OG type: "website" with title: `${brandName} Blog`. hreflang only when langs.length > 1 |
postJsonLd(meta, site, translations = [meta.lang]): JsonLd | @graph of a BlogPosting and a BreadcrumbList of 3 items (Home, Blog, the title). A translated post also carries workTranslation / translationOfWork, as the docs builder does |
overviewJsonLd(posts, site, lang?): JsonLd | CollectionPage, a BreadcrumbList of 2 items, and an ItemList only when posts.length > 0 |
buildRssFeed(posts: PostMeta[], site: SiteConfig, lang?): string | One locale's complete RSS 2.0 document, newest post first. This is what Blog.rssFeed() wraps |
rssFeedPath(site: SiteConfig, lang: string): string | The conventional mount path for that locale's feed: the locale's index path plus /rss.xml, so /blog/rss.xml by default, or /en/rss.xml under prefixDefaultLocale. site.trailingSlash never applies - the feed is a file, so its path stays bare |
Shared helpers
| Signature | Behaviour |
|---|---|
readingMinutes(content: string, wpm = 200): number | Math.max(1, Math.round(words / wpm)). Floored at 1, rounded rather than ceiled |
slugify(text: string): string | Lowercases, strips everything that is not a letter, number, space, or hyphen, and collapses runs to -. Unicode letters are preserved. May return "" |
tableOfContents(content: string): TocEntry[] | One entry per ## or ### ATX heading in document order. Skips headings inside code fences. id = slugify(text), so duplicate heading text yields duplicate ids |
formatDate(iso: string, locale = "en-GB"): string | "" returns "". Parses at UTC midnight, so there is no timezone day-shift. An unparseable value is returned unchanged. Otherwise renders a long date, for example "28 June 2026" |
isoDateString(value: unknown): string | undefined | A string is passed through unvalidated. A valid Date becomes YYYY-MM-DD. Anything else, including an invalid Date, becomes undefined |
normalizeBasePath(basePath: string | undefined): string | basePath ?? "/blog", then adds a leading / and strips a trailing /. "/" returns "", a root mount |
buildSitemap(refs, site, translationsOf): SitemapEntry[] | One entry per ref in the order given. alternates are emitted only when a slug has more than one translation. A siteUrl carrying a sub-path (https://user.github.io/repo) has that sub-path prepended, not discarded, so a project-site sitemap matches its canonicals |
localePath
localePath(opts: {
basePath?: string | undefined;
defaultLocale: string;
lang: string;
slug?: string | undefined;
prefixDefaultLocale?: boolean | undefined;
trailingSlash?: boolean | undefined;
}): string
Builds the root-relative URL path for a page, or for a locale's index page when slug is omitted. It is the single source of truth shared by the SEO metadata and the rendered links.
| Parameter | Type | Required | Notes |
|---|---|---|---|
basePath | string? | no | The section base path. Defaults to /blog |
defaultLocale | string | yes | The locale code served without a prefix |
lang | string | yes | The target locale code |
slug | string? | no | Omit for the locale's index URL |
prefixDefaultLocale | boolean? | no | When true, the default locale is prefixed too |
trailingSlash | boolean? | no | Defaults to true. When false, the path is emitted bare |
The locale prefix comes first, then the base path. The URL shape is /<lang><basePath>/<slug>/.
| Case | Result |
|---|---|
Default locale, prefixDefaultLocale false | /blog/post/ |
| Non-default locale | /fr/blog/post/ |
| Default locale, no slug | /blog/ |
| Non-default locale, no slug | /fr/blog/ |
prefixDefaultLocale: true, default locale | /en/blog/post/ and /en/blog/ |
prefixDefaultLocale: true, non-default locale | /fr/blog/post/, prefixed either way |
basePath: "/docs" | /docs/quickstart/ and /fr/docs/quickstart/ |
basePath: "" (or "/") | /quickstart/, /fr/quickstart/, and / for the index |
trailingSlash: false | The same paths without the final slash: /blog/post, /fr/blog, /quickstart. The site root stays / |
Mounting a section at the site root - for a site that is only docs, so /getting-started/ beats a redundant /docs/getting-started/ - is what basePath: "" is for. The default locale's index is "/", never the empty string: an empty href resolves to the current page, so the index link would be dead. Scribekit's own docs site is mounted this way.
prefixDefaultLocale affects the default locale only. The slug is never escaped or slugified; it is the directory name verbatim. localePath takes plain serializable inputs rather than an instance, because it is called from a client component.
trailingSlash defaults to true here and must match the host app's next.config trailingSlash, whose own default is false - so an app that never set it needs either trailingSlash: true in next.config or trailingSlash: false on the instance. Every canonical, hreflang, sitemap entry, and nav link comes from this one function, so a mismatch is not one broken link: it points the whole surface at the URL form the host does not serve, and nothing throws.
The JSDoc on blog.ts, blog/types.ts, docs/types.ts, and docs/docs.ts claims a translation is served under <basePath>/<code>/, that is /blog/fr/post. That comment is wrong. The code and its tests produce /fr/blog/post.
Types
| Type | Shape |
|---|---|
LocaleConfig | { code: string; label?: string; dateLocale?: string } |
TocEntry | { depth: 2 | 3; text: string; id: string } |
SiteConfig | siteUrl and brandName are the only two required fields; plus defaultAuthor?, basePath?, description?, defaultLocale?, prefixDefaultLocale?, trailingSlash?, organizationId?, authorId?, websiteId? |
SitemapEntry | { url: string; alternates?: { languages?: Record<string, string> } } |
PageMetadata | A structural subset of Next's Metadata with no next type dependency: metadataBase?: URL, title?, description?, keywords?: string[], robots?: { index?: boolean; follow?: boolean }, authors?: { name: string }[], alternates?: { canonical?; languages? }, openGraph?, twitter? |
JsonLd | Record<string, unknown> |
Docs navigation types:
| Type | Shape |
|---|---|
Doc | { meta: DocMeta; content: string }, where content is the MDX body with front matter stripped |
NavLabel | string | Record<string, string>. A bare string is used for every language; a map is resolved at lang, then defaultLocale, then falls through to the id |
NavConfigEntry | string | { id: string; label?: NavLabel; description?: NavLabel }. description is the one-sentence blurb the docs index topic card prints under its heading |
NavItem | { slug, title, label, description?, icon?, href, lang, tab?, group?, order?, updated?, readingTime? }. label is meta.label ?? meta.title; href comes from localePath. DocsIndex reads description, updated, and readingTime |
NavGroup | { id, label, description?, items }. id is "" for the ungrouped bucket |
NavTab | { id, label, description?, groups }. id is "" for the implicit single tab |
NavTree | { tabs, multiTab }, where multiTab === tabs.length > 1 |
BreadcrumbSegment | { label: string; href?: string }. breadcrumbFor never sets href, so the field exists but is always absent |
Breadcrumb | { tab?, group?, title, segments } |
Adjacent | { prev?: NavItem; next?: NavItem } |
React components
Import from @daanvandenbergh/scribekit/react.
Blog
| Component | Kind | Required props |
|---|---|---|
BlogOverview | server | blog |
BlogOverviewGrid | client | posts, categories, basePath, defaultLocale, prefixDefaultLocale, locale, pageSize, imgComponent, linkComponent |
BlogPage | server | blog, slug |
BlogSidebar | client | toc |
BlogOverview
| Prop | Type | Default |
|---|---|---|
blog | Blog | required |
lang | string | blog.defaultLocale |
posts | PostMeta[] | blog.getAllPosts(resolvedLang) |
basePath | string | blog.site?.basePath ?? "/blog" |
header | ReactNode | none |
imgComponent | ElementType | "img" |
linkComponent | ElementType | "a" |
emptyLabel | string | translated |
readMoreLabel | string | translated |
pageSize | number | 9 |
searchPlaceholder | string | translated |
loadMoreLabel | string | translated |
allCategoriesLabel | string | translated |
BlogOverviewGrid
The interactive body rendered by BlogOverview: fuzzy search, category filters, and infinite scroll via IntersectionObserver. Category filters render only when categories.length > 1. Optional props are trailingSlash (defaults to true), lang, and the five label props.
BlogPage
| Prop | Type | Default |
|---|---|---|
blog | Blog | required |
slug | string | required |
lang | string | blog.defaultLocale |
basePath | string | blog.site?.basePath ?? "/blog" |
components | MDXRemoteProps["components"] | merged over { h2, h3 }; the caller wins |
mdxOptions | MDXRemoteProps["options"] | merged with GFM |
showBackLink | boolean | true |
backLabel | string | "← Blog" |
imgComponent | ElementType | "img" |
linkComponent | ElementType | "a" |
showSidebar | boolean | true |
tocTitle | string | translated (e.g. "On this page") |
similarTitle | string | translated (e.g. "Similar pages") |
similarCount | number | passes undefined through, so the backend default 3 applies |
readingLabel | (minutes: number) => string | translated |
BlogSidebar
| Prop | Type | Default |
|---|---|---|
toc | TocEntry[] | required |
title | string | "On this page", hardcoded English |
similarTitle | string | "Similar pages", hardcoded English |
similar | ReactNode | none |
Docs
| Component | Kind | Required props |
|---|---|---|
DocsPage | server | docs, slug |
DocsIndex | server | docs |
DocsHero | server | title |
DocsTopicGrid | client | topics |
DocsRecentlyUpdated | server | items |
DocsNavbar | client | none |
DocsNavbarButton | server-safe, no hooks | children |
DocsTabs | client | nav |
DocsSidebar | client | nav |
DocsLanguagePicker | client | locales, currentLang, defaultLocale |
DocsToc | client | toc |
DocsSearchProvider | client | nav, children |
DocsSearchButton | client | none |
DocsFeedback | client | question |
DocsIcon | server-safe | none |
DocsPage
| Prop | Type | Default |
|---|---|---|
docs | Docs | required |
slug | string | required |
lang | string | docs.defaultLocale |
components | MDXRemoteProps["components"] | merged over { h2, h3 }; the caller wins |
mdxOptions | MDXRemoteProps["options"] | merged with GFM |
linkComponent | ElementType | "a" |
imgComponent | ElementType | "img" |
showBreadcrumb | boolean | true |
showToc | boolean | true |
showFeedback | boolean | true |
tocTitle | string | translated (e.g. "On this page") |
previousLabel | string | translated (e.g. "Previous") |
nextLabel | string | translated (e.g. "Next") |
feedbackQuestion | string | translated (e.g. "Was this page helpful?") |
feedbackYesLabel | string | translated (e.g. "Yes") |
feedbackNoLabel | string | translated (e.g. "No") |
feedbackThanksLabel | string | translated (e.g. "Thanks for the feedback!") |
onFeedback | (vote: "yes" | "no") => void | none |
readingLabel | (minutes: number) => string | translated |
updatedLabel | (date: string) => string | translated |
DocsPage renders a hero <img> at width={1200} height={630} when meta.image is set.
DocsIndex
| Prop | Type | Default |
|---|---|---|
docs | Docs | required |
lang | string | docs.defaultLocale |
linkComponent | ElementType | "a" |
title | string | the localized word "Documentation" (docsLabels(lang).title); the brand is not used |
description | string | site?.description |
header | ReactNode | the built-in hero |
actions | DocsHeroAction[] | none. The index does not invent a destination |
heroVariant | DocsHeroVariant | "plain" |
children | ReactNode | none. Rendered between the hero and the topic grid |
showStats | boolean | true. The hero's article-count / topic-count / newest-update row |
filter | boolean | true. The topic grid's filter box |
pagesPerTopic | number | 3 |
recentCount | number | 5. 0 drops the "Recently updated" section |
accents | string[] | the built-in blue-to-coral cycle |
renderIcon | (name: string | undefined) => ReactNode | <DocsIcon name={name} size={19} /> |
DocsIndex is a wrapper, not a monolith: it derives its data from the Docs instance and then renders DocsHero, your children, DocsTopicGrid, and DocsRecentlyUpdated. All three are exported separately, so an index that needs sections the kit does not ship can compose them by hand and still emit docs.indexJsonLd(lang) through the exported JsonLd component.
DocsHero
| Prop | Type | Default |
|---|---|---|
title | string | required. The <h1> |
description | string | omitted when unset |
eyebrow | string | omitted when unset |
actions | DocsHeroAction[] | omitted entirely when empty |
stats | DocsHeroStat[] | omitted entirely when empty |
variant | DocsHeroVariant | "plain"; "card" wraps it in the tinted gradient panel |
linkComponent | ElementType | "a" |
children | ReactNode | none. Rendered inside the panel, after the stat row |
className | string | none |
DocsTopicGrid
The "Browse by topic" section. A client component, because its filter is local state - which is why every prop is plain data and icons arrive as already-rendered nodes rather than through a renderIcon callback. The filter searches each page's title and description plus its topic's title, across every page, including the ones the pagesPerTopic cap hides from the card.
| Prop | Type | Default |
|---|---|---|
topics | DocsTopic[] | required |
labels | DocsTopicGridLabels | required |
pagesPerTopic | number | 3 |
filter | boolean | true |
accents | string[] | ["#2563eb", "#5b57f2", "#7c4fe0", "#9a55d0", "#b0487e", "#c25b4a", "#c4699e"] |
linkComponent | ElementType | "a" |
headingId | string | "scribekit-docs-topics" |
className | string | none |
DocsRecentlyUpdated
A pure presentational server component. It does not sort or slice - it renders exactly the rows it is given, in the order given, and returns null when items is empty, so a corpus with no updated: dates simply has no section.
| Prop | Type | Default |
|---|---|---|
items | DocsRecentItem[] | required |
heading | string | translated |
linkComponent | ElementType | "a" |
headingId | string | "scribekit-docs-recent" |
className | string | none |
DocsNavbar
Must be rendered inside DocsSearchProvider.
| Prop | Type | Default |
|---|---|---|
logo | ReactNode | none |
logoSize | number | 22 |
brandName | string | none |
docsText | string | null | "Docs". Pass null or "" to hide |
homeHref | string | "/" |
linkComponent | ElementType | "a" |
lang | string | none |
showSearch | boolean | true |
searchPlaceholder | string | translated |
actions | ReactNode[] | none. Auto-hidden when the bar is too narrow - see below |
languagePicker | ReactNode | none |
showNavToggle | boolean | true. The drawer hamburger; pass false for a navbar with no sidebar |
brandName and docsText are ReactNode, not string, so either can be a lockup rather than a word.
The navbar measures itself and drops the whole actions group when the bar is too narrow, marking itself data-cramped. Pass the same nodes to DocsSidebar's footer prop and the stylesheet reveals them there exactly when the bar gives up, so nothing becomes unreachable on a phone.
DocsNavbarButton
| Prop | Type | Default |
|---|---|---|
children | ReactNode | required |
href | string | none. When set, renders a link; otherwise a <button> |
onClick | () => void | none. Ignored when href is set |
variant | DocsNavbarButtonVariant | "link". The type is "link" | "primary" | "secondary" |
linkComponent | ElementType | "a" |
icon | ReactNode | none |
target | string | none |
rel | string | none |
ariaLabel | string | none |
className | string | none |
DocsTabs
Returns null when !nav.multiTab, so a single-tab corpus renders no tab bar.
| Prop | Type | Default |
|---|---|---|
nav | NavTree | required |
activePath | string | none |
lang | string | "en" |
linkComponent | ElementType | "a" |
label | string | translated |
DocsSidebar
| Prop | Type | Default |
|---|---|---|
nav | NavTree | required |
activePath | string | none |
lang | string | "en" |
linkComponent | ElementType | "a" |
label | string | translated |
renderIcon | (name: string | undefined) => ReactNode | <DocsIcon name={name} /> at size 16 |
footer | ReactNode | none. Drawer-mode only: shown when DocsNavbar sets data-cramped |
brand | ReactNode | none. Drawer-mode only: the drawer's brand lockup |
showSearch | boolean | true. Drawer-mode only |
searchPlaceholder | string | translated |
DocsLanguagePicker
Returns null when locales.length <= 1.
| Prop | Type | Default |
|---|---|---|
locales | LocaleConfig[] | required |
currentLang | string | required |
defaultLocale | string | required |
activePath | string | "" |
basePath | string | "/blog" via normalizeBasePath. Docs consumers must pass basePath="/docs" |
prefixDefaultLocale | boolean | false |
trailingSlash | boolean | true |
linkComponent | ElementType | "a" |
onSelect | (code: string) => void | none |
renderFlag | (code: string) => ReactNode | i18nkit's localeFlag |
lang | string | currentLang |
changeLanguageLabel | string | translated |
headingLabel | string | translated |
className | string | none |
DocsToc
Returns null when toc.length === 0.
| Prop | Type | Default |
|---|---|---|
toc | TocEntry[] | required |
title | string | "On this page", hardcoded English |
DocsSearchProvider
Owns the ⌘K palette.
| Prop | Type | Default |
|---|---|---|
nav | NavTree | required |
children | ReactNode | required |
lang | string | "en" |
linkComponent | ElementType | "a" |
searchPlaceholder | string | translated (e.g. "Search docs…") |
searchEmptyLabel | string | translated (e.g. "No results") |
renderIcon | (name: string | undefined) => ReactNode | <DocsIcon name={name} />. Pass your own set here too, or the palette's result icons revert to the built-ins |
DocsSearchButton
| Prop | Type | Default |
|---|---|---|
placeholder | string | translated (e.g. "Search docs…") |
lang | string | "en" |
className | string | none |
DocsFeedback
| Prop | Type | Default |
|---|---|---|
question | string | required |
yesLabel | string | "Yes", hardcoded English |
noLabel | string | "No", hardcoded English |
thanksLabel | string | "Thanks for the feedback!", hardcoded English |
onVote | (vote: "yes" | "no") => void | none. Fires once, on the first answer |
DocsIcon
| Prop | Type | Default |
|---|---|---|
name | string? | none. An unknown or missing name renders the document glyph |
size | number? | 16 |
className | string? | none |
useDocsSearch
useDocsSearch(): { open: () => void }
Opens the palette owned by DocsSearchProvider. Call it from any client component beneath that provider.
Exported prop types
BlogOverviewProps, BlogOverviewGridProps, BlogPageProps, BlogSidebarProps, DocsPageProps, DocsIndexProps, DocsHeroProps, DocsHeroAction, DocsHeroStat, DocsHeroVariant, DocsTopicGridProps, DocsTopicGridLabels, DocsTopic, DocsTopicPage, DocsRecentlyUpdatedProps, DocsRecentItem, DocsNavbarProps, DocsNavbarButtonProps, DocsNavbarButtonVariant, DocsTabsProps, DocsSidebarProps, DocsLanguagePickerProps, DocsTocProps, DocsSearchProviderProps, DocsSearchButtonProps, DocsFeedbackProps, Language, BlogLabels, DocsLabels.
The /react subpath also exports the JsonLd component and the i18n core (ui, CATALOG, blogLabels, docsLabels, resolveLanguage).
The icon set
DocMeta.icon and DocsIcon's name accept these names:
book, rocket, workflow, phone, voice, calendar, globe, clock, plug, link, mail, grid, list, check, gear, code, sparkles, shield, document
Any other name silently renders the neutral document glyph. It does not error. A missing or undefined name also falls back to document. Lookup uses Object.prototype.hasOwnProperty.call(...), so icon: "toString" cannot resolve to an inherited member. Every glyph uses a 0 0 24 24 viewBox, fill="none", stroke="currentColor", strokeWidth={1.7}, round caps and joins, and aria-hidden="true". The path table itself is module-private and not exported.
Pass a renderIcon prop to DocsSidebar or DocsIndex to substitute your own icon set, in which case that set's names apply instead.
CSS custom properties
There is no :root block in the stylesheet. Every --scribekit-* token exists only as a var(--name, fallback) reference at each call site, so each "default" below is that inline fallback, not a declared value. You override a token by declaring the property yourself:
:root {
--scribekit-primary: #6d5df6;
--scribekit-ink: #101828;
}
62 distinct tokens are referenced. No dark mode ships. The stylesheet contains no prefers-color-scheme block and no .dark class; its nine media queries are responsive breakpoints at 1080px, 1081px, 640px, and 560px, plus two prefers-reduced-motion: reduce blocks. Dark theming is entirely your job, via token overrides.
One caveat before you try it: a handful of colours are written as literal rgba() with no token in front of them - the drawer backdrop and some DocsHero surfaces among them - so a token override alone will not reach every pixel.
Tokens with no single default
Six tokens are referenced with different fallbacks at different call sites, so they have no single default value. Set them explicitly if you want them consistent.
| Token | Value to set |
|---|---|
--scribekit-primary-subtle | #eef2ff |
--scribekit-ink-subtle | #94a3b8 |
--scribekit-bg-subtle | #f7f9fc |
--scribekit-border | #e5eaf0 |
--scribekit-border-strong | #eaeef4 |
--scribekit-docs-tabbar-top | 0 |
Brand and accent
| Token | Default |
|---|---|
--scribekit-primary | #2563eb |
--scribekit-primary-soft | #c7d2fe |
--scribekit-primary-subtle | no single default; see above |
--scribekit-primary-line | #dbe4fe |
--scribekit-violet | #6d5df6 |
--scribekit-violet-subtle | #f0eefe |
--scribekit-violet-line | #e4e0fc |
--scribekit-success | #15a34a |
--scribekit-on-primary | #ffffff |
--scribekit-on-ink | #ffffff |
Text
| Token | Default |
|---|---|
--scribekit-ink | #0b1b36, the main text colour |
--scribekit-ink-body | #41506a |
--scribekit-ink-muted | #4a5568 |
--scribekit-ink-soft | #5a6b82 |
--scribekit-ink-subtle | no single default; see above |
--scribekit-ink-hover | #15264a |
--scribekit-ink-faint | #8189a8 |
--scribekit-nav-ink | #475569 |
--scribekit-toc-ink | #64748b |
Surfaces and borders
| Token | Default |
|---|---|
--scribekit-surface | #ffffff |
--scribekit-bg-subtle | no single default; see above |
--scribekit-border | no single default; see above |
--scribekit-border-strong | no single default; see above |
--scribekit-navbar-bg | rgba(255, 255, 255, 0.9) |
--scribekit-docs-nav-bg | linear-gradient(180deg, #fcfdfe, #fbfcfe) |
--scribekit-docs-nav-hover | #f1f4f9 |
--scribekit-scrim | rgba(11, 27, 54, 0.32) |
Shadows and focus
| Token | Default |
|---|---|
--scribekit-card-shadow | 0 14px 34px -18px rgba(11, 27, 54, 0.35) |
--scribekit-menu-shadow | 0 14px 40px -12px rgba(11, 27, 54, 0.28) |
--scribekit-palette-shadow | 0 24px 70px rgba(11, 27, 54, 0.28) |
--scribekit-focus-ring | rgba(37, 99, 235, 0.18) |
Typography
| Token | Default |
|---|---|
--scribekit-font-sans | "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif |
--scribekit-font-mono | ui-monospace, SFMono-Regular, Menlo, Consolas, monospace |
--scribekit-font-display | inherit. Set it to give headings their own face |
The docs index hero and topic cards
| Token | Default |
|---|---|
--scribekit-topic-accent | #5b57f2. Per-card, overridden by DocsTopicGrid's accents |
--scribekit-hero-bg | linear-gradient(160deg, #f7f8fe 0%, #f4f1fd 46%, #fbf3f8 100%) |
--scribekit-hero-cta | linear-gradient(120deg, #2563eb, #6d5df6 62%, #8a54d8) |
--scribekit-hero-rule | linear-gradient(90deg, #2563eb, #6d5df6 38%, #9a55d0 68%, #c4699e 100%) |
--scribekit-hero-glow | two radial-gradient washes; set none to blank them |
--scribekit-hero-shadow | 0 20px 48px rgba(40, 30, 120, 0.06) |
Code blocks
| Token | Default |
|---|---|
--scribekit-code-bg | #0b1b36 |
--scribekit-code-border | #10203f |
--scribekit-code-ink | #c7d2fe |
Layout widths
| Token | Default |
|---|---|
--scribekit-overview-width | 1040px |
--scribekit-post-width | 760px |
--scribekit-doc-width | 760px |
--scribekit-sidebar-width | 240px |
--scribekit-docs-max-width | none |
--scribekit-docs-min-height | auto |
--scribekit-docs-body-min-height | 60vh |
--scribekit-docs-nav-width | 300px |
--scribekit-docs-toc-width | 250px |
--scribekit-docs-index-width | 1080px |
--scribekit-docs-navbar-search-width | 440px |
Sticky-chrome offsets
| Token | Default | Notes |
|---|---|---|
--scribekit-docs-navbar-height | 64px | |
--scribekit-docs-tabbar-height | 47px | |
--scribekit-docs-navbar-top | 0 | |
--scribekit-docs-tabbar-top | no single default | Set 0; see above |
--scribekit-docs-chrome-top | computed | Internal. Do not set. Sibling-selector rules derive it from which chrome actually rendered, because DocsTabs renders nothing for a single tab. This is the only token the stylesheet itself assigns |
--scribekit-docs-content-top | none | The consumer override, and it always wins. Set it when the docs shell sits under an app-level header |
i18n
Exported from @daanvandenbergh/scribekit/react.
| Export | Type |
|---|---|
ui | An @daanvandenbergh/i18nkit I18n instance over 24 locales, with default: "en" |
CATALOG | The copy catalog, 36 entries, each covering all 24 locales |
blogLabels | (lang: string) => BlogLabels |
docsLabels | (lang: string) => DocsLabels |
resolveLanguage | (raw: string | undefined) => Language |
The i18n module imports no react, next, or server-only code, so both server and client components can use it.
ui covers the 24 official EU languages, keyed by primary BCP 47 subtag:
en, fr, de, es, it, pt, nl, pl, ro, el, sv, da, fi, cs, sk, hu, bg, hr, sl, et, lv, lt, ga, mt
resolveLanguage(raw) matches on the primary subtag, so "pt-BR" resolves to pt. An unknown or missing code resolves to "en".
Overriding copy
You do not mutate the catalog. Every label is a component prop that falls back to the resolved translation. Parameterized copy is overridden with a function.
<BlogPage
blog={blog}
slug={slug}
tocTitle="Contents"
readingLabel={(minutes) => `${minutes} min`}
/>
Default English strings
CATALOG key | Default English |
|---|---|
empty | "No posts yet - check back soon." |
readMore | "Read more →" |
searchPlaceholder | "Search posts…" |
loadMore | "Load more" |
allCategories | "All" |
back | "← Blog" |
onThisPage | "On this page" |
similarPages | "Similar pages" |
filterByCategory | "Filter by category" |
writtenBy | "Written by" |
publishedOn | (d) => `Published ${d}` |
readingTime | (m) => `${m} min read` |
docsPrevious | "Previous" |
docsNext | "Next" |
docsFeedbackQuestion | "Was this page helpful?" |
docsFeedbackYes | "Yes" |
docsFeedbackNo | "No" |
docsFeedbackThanks | "Thanks for the feedback!" |
docsSearchPlaceholder | "Search docs…" |
docsSearchEmpty | "No results" |
docsUpdatedOn | (d) => `Updated ${d}` |
docsTitle | "Documentation" |
docsLanguage | "Language" |
docsChangeLanguage | "Change language" |
docsOpenNav | "Open navigation" |
docsCloseNav | "Close navigation" |
docsBrowseByTopic | "Browse by topic" |
docsRecentlyUpdated | "Recently updated" |
docsFilterPages | "Filter pages" |
docsClearFilter | "Clear filter" |
docsPageCount | (n) => `${n} page(s)` |
docsArticleCount | (n) => `${n} article(s)` |
docsTopicCount | (n) => `${n} topic(s)` |
docsResultCount | "{count} pages match “{query}”" |
docsResultCountOne | "1 page matches “{query}”" |
docsNoMatches | "Nothing matches “{query}”" |
The last three are templates with {count} / {query} placeholders, not functions, because DocsTopicGrid is a client component and a function cannot cross the server/client boundary.
back is authored with ui.uniform("← Blog"), so it is one string for all 24 languages.
blogLabels(lang) returns BlogLabels: empty, readMore, searchPlaceholder, loadMore, allCategories, back, onThisPage, similarPages, filterByCategory, writtenBy, publishedLabel(date), readingLabel(minutes).
docsLabels(lang) returns DocsLabels, 26 members: title, onThisPage, previous, next, feedbackQuestion, feedbackYes, feedbackNo, feedbackThanks, searchPlaceholder, searchEmpty, language, changeLanguage, openNav, closeNav, browseByTopic, recentlyUpdated, filterPages, clearFilter, updatedLabel(date), readingLabel(minutes), pageCountLabel(count), articleCountLabel(count), topicCountLabel(count), resultCountTemplate, resultCountOneTemplate, noMatchesTemplate.
BlogSidebar's title and similarTitle, DocsToc's title, and DocsFeedback's yesLabel, noLabel, and thanksLabel hardcode English defaults instead of reading the catalog. Their parent components always pass the translated values, so this surfaces only when you render those components directly.
MDX
BlogPage and DocsPage both render the body through MdxContent, a package-internal wrapper that takes the same props MDXRemote does but calls serialize from next-mdx-remote/serialize and memoizes the compile in a 256-entry cache keyed on the options signature and the source text - so a repeated render of the same page skips the compile entirely. Only the MDXRemoteProps type comes from next-mdx-remote/rsc. The merge steps are internal; the observable contract is below.
remark-gfm is always on and cannot be disabled. It is prepended to options.mdxOptions.remarkPlugins on every render, so tables, strikethrough, task lists, and autolinks are always available. There is no prop, config option, or plugin ordering that turns it off. Your own plugins are preserved, never replaced; rehypePlugins, parseFrontmatter, and the rest pass through untouched.
Only h2 and h3 receive id attributes. Each id is the shared slugify applied to the heading's text content, and tableOfContents derives its ids the same way, so anchor ids always match ToC entry ids. Your components are spread after the injected h2 and h3, so the caller wins: overriding h2 or h3 drops the injected ids and breaks minimap jump links unless you set your own.
<DocsPage
docs={docs}
slug={slug}
components={{ h2: MyHeading }}
/>
Levels other than h2 and h3 never receive an id, whether or not you override them.
imgComponent is not an MDX component. It styles the chrome images only, meaning the hero, card thumbnails, and the author avatar, and not <img> elements inside the MDX body. It defaults to "img"; pass next/image to use it. To restyle images inside the MDX body, use components={{ img: ... }}.