import "server-only"; import { type JsonLd } from "../shared/seo.js"; import type { BlogConfig, LocaleConfig, PageMetadata, Post, PostMeta, SiteConfig, SitemapEntry, TocEntry } from "./types.js"; /** * Reads and normalises MDX blog posts from a content directory, and builds their SEO * metadata and JSON-LD. Instantiate once with your content directory and site config, then * import that instance into your Next.js `/blog` and `/blog/[slug]` route files. * * Multi-language: leave `locales` unset for a single-language blog (behaviour is unchanged). * When `locales` is set, a post in a non-default language lives in the post's own `/` * folder as `` (e.g. `fr.mdx`), beside the default-language * `` (e.g. `en.mdx`; the language-neutral `post` also * works); it is served under `//` with hreflang/`og:locale` linking the * translations - or, on a domain-per-locale site (`localeOrigins`), under `/` on * that locale's own origin. * * The class is Node-only (it reads the filesystem) and is marked `server-only`; keep it in * server components / route files, and pass the resulting `Post`/`PostMeta` data to the * `BlogOverview` / `BlogPage` components as props. */ export declare class Blog { /** * The filesystem layer: the directory walk, the path-traversal guard, and the memoized read + * front-matter parse. Per-instance (never shared between `Blog`s), because what the walk * discovers depends on this instance's `locales`/`extension`. */ private readonly store; /** * BCP 47 locale used by {@link Blog.formatDate} for the default language. Public so a client * child (e.g. `BlogOverviewGrid`) can format dates with the same locale via the pure * `formatDate`. */ readonly locale: string; /** * The languages this blog is published in, with `label` defaulted to `code`. Empty for a * single-language blog. Public so the components can render a language switcher. */ readonly locales: LocaleConfig[]; /** * The default locale code: the language served without a URL prefix, and the `x-default` * hreflang target. Derived from `defaultLocale`, else the first `locales` entry, else the * base subtag of {@link Blog.locale}. Public so route files can build `generateStaticParams`. */ readonly defaultLocale: string; /** * Whether the default locale is URL-prefixed too (`/en/blog/` rather than * `/blog/`), so every locale routes through one `[lang]` segment. Public so the * components build links that match the SEO metadata. */ readonly prefixDefaultLocale: boolean; /** * Whether every built URL ends in a slash. Public so the components build links that match * the SEO metadata. Must match the host app's `next.config` `trailingSlash`; see * {@link SiteConfig.trailingSlash}. */ readonly trailingSlash: boolean; /** * Each locale's origin on a domain-per-locale site (trailing slashes stripped), or `undefined` * for a single-origin site. When set, no locale is URL-prefixed. Public so the components build * links that match the SEO metadata. See {@link SiteConfig.localeOrigins}. */ readonly localeOrigins: Readonly> | undefined; /** * Site config assembled from the flat site attributes passed to the constructor, or * `undefined` when `brandName` or a `siteUrl` (which defaults to the default locale's * `localeOrigins` entry) was not provided. Exposed as a public read-only * property; the components read it to build their SEO JSON-LD. */ readonly site: SiteConfig | undefined; /** * @param config - directory paths and options; see {@link BlogConfig}. * @throws Error when `localeOrigins` is set together with `prefixDefaultLocale`, lacks an * origin for a configured locale, names an unconfigured locale, or holds a value that is not * an absolute `http(s)` URL. */ constructor(config: BlogConfig); /** * Every post file discovered on disk. Delegates to the shared content store, which owns the * folder-layout rules; see `ContentStore.entries`. * * @returns every discovered post entry; empty when the content directory does not exist. * @throws {@link DuplicatePostError} when two files resolve to the same `(slug, lang)` * (e.g. both `post` and `` in one folder). */ private entries; /** * Reads an entry from the walk and normalises it into a {@link Post}. * * @param entry - the entry to read, from {@link Blog.entries}. * @returns the post's normalised front-matter and MDX body. * @throws {@link PostNotFoundError} when the entry's file has vanished or its path escapes the * content directory. */ private readEntry; /** * Normalises one parsed post file into a {@link Post}: the domain mapping from raw front-matter * to {@link PostMeta}. The store hands back uninterpreted `gray-matter` output; every default, * coercion, and fallback below is the blog's own concern. * * @param content - the MDX body, front-matter stripped. * @param data - the raw parsed front-matter. * @param folder - the post's directory name. * @param lang - the post's language code. * @returns the post's normalised front-matter and MDX body. * @throws Error when the front-matter declares a `slug:` that is not a kebab-case URL slug. */ private toPost; /** * Every post page: each discovered file paired with the URL slug it is served under (its * front-matter `slug:`, else its folder). The one place a localized slug is checked for * uniqueness, so every listing method fails loudly on a clash instead of one page shadowing * another. * * A name - a folder or a URL slug - belongs to ONE post across every language. That is * stricter than per-language uniqueness on purpose: it makes any slug identify its post * without knowing which language it came from, so {@link Blog.slugFor} and * {@link Blog.getTranslations} are exact lookups, never a guess. Without any `slug:` every * name is its own folder's, so this never fires. * * @returns one route per discovered file, in discovery order. * @throws {@link DuplicatePostError} when two files in one folder claim one language, or when a * declared `slug:` is another post's folder name or URL slug (in any language). * @throws Error when a file declares an invalid `slug:`. */ private routes; /** * Resolves a URL slug or folder name to the post folder it belongs to. Exact, because * {@link Blog.routes} guarantees every name belongs to one post. * * @param slug - a folder name, or any language's URL slug. * @param routes - the routes to search (from {@link Blog.routes}). * @returns the folder name, or `undefined` when nothing matches. */ private folderOf; /** * Each language a post folder is published in, mapped to that language's URL slug, the default * locale first and then in configured order. * * @param folder - the post's folder name. * @param routes - the routes to search (from {@link Blog.routes}). * @returns a `lang -> slug` map in hreflang order; empty for an unknown folder. */ private translationSlugs; /** * Lists the distinct folder names of every post in the content directory, across all * languages. A folder name is the post's translation key (and its hero/asset key); it is also * every language's URL slug unless a locale file declares its own `slug:` - for the exhaustive * list of URL slugs per language use {@link Blog.getPostRefs}. * * @returns the folder names (each appearing once even when translated); empty when the * directory does not exist yet. * @throws {@link DuplicatePostError} when two files in one folder resolve to the same language. */ getPostSlugs(): string[]; /** * Lists every `(slug, lang)` pair in the content directory - the exhaustive set of pages, * suitable for a route file's `generateStaticParams`. `slug` is that language's URL slug (its * front-matter `slug:`, else the folder name). * * @returns every post's slug and language; empty when the directory does not exist yet. * @throws {@link DuplicatePostError} when two files resolve to the same `(slug, lang)`, or a * declared `slug:` is another post's folder name or URL slug. * @throws Error when a file declares an invalid `slug:`. */ getPostRefs(): { slug: string; lang: string; }[]; /** * Reads and parses a single post by slug and (optional) language. * * @param slug - the post's URL slug in that language: its front-matter `slug:` when the file * declares one, else the post's directory name. A folder name whose file in that language * declares a different `slug:` does NOT resolve, so a page is never served at two URLs. * @param lang - the language code. Defaults to the blog's default locale. The default locale * resolves to `/` (recommended), falling back to `/post`; * any other locale resolves to `/`. * @returns the post's normalised front-matter and MDX body. * @throws {@link PostNotFoundError} when no file exists for the slug in that language. * A miss is always this error, even when some OTHER post in the directory is broken (a * duplicate, an invalid `slug:`) - that is the listing methods' error to raise, so a 404 stays * a 404. * @throws Error when the requested file itself declares an invalid `slug:`. */ getPost(slug: string, lang?: string): Post; /** * Reads every post's front-matter for one language, newest first. * * @param lang - the language code. Defaults to the blog's default locale. * @returns the language's posts' metadata sorted by `date` descending. * @throws {@link DuplicatePostError} when two files resolve to the same `(slug, lang)`. * @throws Error when a file declares an invalid `slug:`. */ getAllPosts(lang?: string): PostMeta[]; /** * Lists the language codes a given post is available in, the default locale first and then in * configured order. Drives the hreflang alternates and the `BlogPage` language switcher. * * @param slug - the post's folder name, or any language's URL slug (every such name belongs * to exactly one post, so the match is exact). * @returns the available language codes (a single entry when the post is not translated); * empty when nothing matches. */ getTranslations(slug: string): string[]; /** * Translates a post's slug into another language's URL slug - what a language switcher links * to. Without per-locale `slug:` front-matter this is the folder name for every language. * * @param slug - the post's folder name, or any language's URL slug (exact: every such name * belongs to one post). * @param lang - the language whose URL slug to return. * @returns the post's URL slug in `lang`, or `undefined` when the post is not published in * that language (or nothing matches `slug`). * @throws {@link DuplicatePostError} / Error as {@link Blog.getPostRefs} does. */ slugFor(slug: string, lang: string): string | undefined; /** * Lists every distinct category across a language's posts, sorted alphabetically. Useful for * building a category nav; the overview derives its own filter buttons from the posts it * renders. * * @param lang - the language code. Defaults to the blog's default locale. * @returns the distinct category labels, sorted; empty when no post declares one. */ getAllCategories(lang?: string): string[]; /** * The BCP 47 locale used to format a given language's dates: the locale's own `dateLocale` * when set, else the blog's `locale` for the default language, else the language code itself. * * @param lang - the language code. Defaults to the blog's default locale. * @returns the BCP 47 locale to pass to `formatDate` / `toLocaleDateString`. */ dateLocale(lang?: string): string; /** * The `og:locale` tag for a language - the same value {@link Blog.postMetadata} and * {@link Blog.overviewMetadata} put on their own OpenGraph blocks, exposed because a host app * that hand-builds a metadata block (to guarantee some other property, e.g. that `og:url` is * byte-for-byte its `alternates.canonical`) must be able to declare the SAME `og:locale` its * posts do. Deriving it there instead means re-deriving a territory from a bare subtag, which is * how an `en-GB` site ends up announcing `en_US` on one surface and `en_GB` on the next. * * @param lang - the language code. Defaults to the blog's default locale. * @returns the `language_TERRITORY` string for `og:locale`. */ ogLocale(lang?: string): string; /** * Formats an ISO `YYYY-MM-DD` date as a long date in the given language's date locale. * * @param iso - the ISO date string; empty or invalid input is returned as-is. * @param lang - the language code. Defaults to the blog's default locale. * @returns the formatted date, or the original string when empty/unparseable. */ formatDate(iso: string, lang?: string): string; /** * Estimates a post's reading time in whole minutes (floored at 1). * * @param post - the post (typically from {@link Blog.getPost}). * @returns the estimated reading time in minutes. */ readingMinutes(post: Post): number; /** * Extracts a post's table-of-contents minimap from its `##`/`###` headings, in order. * * @param post - the post (typically from {@link Blog.getPost}). * @returns the headings as {@link TocEntry} objects; empty when the body has none. */ tableOfContents(post: Post): TocEntry[]; /** * Finds the posts most similar to the given one by weighted-term cosine similarity over * their metadata, newest-first as a tie-break. The post itself is excluded, and only posts in * the same language are considered. * * @param post - the post to find neighbours for. * @param limit - the maximum number of similar posts to return. Defaults to `3`. * @returns the closest posts' metadata; empty when nothing meaningfully overlaps. */ similarPosts(post: Post, limit?: number): PostMeta[]; /** * Builds Next.js page metadata for a single post, including hreflang alternates for its * translations and `og:locale`. * * @param post - the post (typically from {@link Blog.getPost}). * @returns metadata assignable to Next's `Metadata`. * @throws when the instance was created without a `site` config. */ postMetadata(post: Post): PageMetadata; /** * Builds Next.js page metadata for the blog index of one language. * * @param lang - the language code. Defaults to the blog's default locale. * @returns metadata assignable to Next's `Metadata`. * @throws when the instance was created without a `site` config. */ overviewMetadata(lang?: string): PageMetadata; /** * Builds schema.org JSON-LD for a single post, including `translationOfWork`/`workTranslation` * cross-links to its translations. * * @param post - the post (typically from {@link Blog.getPost}). * @returns the JSON-LD document to serialise into a `