/** * Shared, framework-free type definitions used by every content module (blog, docs, ...). * This file is fs-free and free of any `next`/`react` import so it can be shared by the Node * backends, the pure SEO builders, and the React components without coupling any of them to * `node:fs`. */ /** * One language a content section is published in. Passed as `locales` on a module config; the * `code` is both the URL/hreflang subtag and the content file's stem inside a page folder - a * page in this language lives at `//` (e.g. `en.mdx`, * `fr.mdx`). The default locale's file may also be the language-neutral `/post`. */ export interface LocaleConfig { /** URL/hreflang subtag; also the content file's stem, e.g. `"fr"` for `/fr.mdx`. */ code: string; /** Human-readable name for the language switcher (e.g. `"Français"`). Defaults to `code`. */ label?: string | undefined; /** * This language's full BCP 47 tag (`en-GB` for the `en` code). Defaults to `code`, then the * instance `locale`. It formats this language's dates AND it is the tag `og:locale` is derived * from - a locale that declares `en-GB` publishes `og:locale` `en_GB`, not the `en_US` that * maximizing the bare `en` subtag through CLDR would otherwise produce. */ dateLocale?: string | undefined; } /** * A single entry in a page's table of contents (heading minimap), derived from the MDX * body's ATX headings. The `id` is the anchor slug: the same `slugify` is used both here * and when the matching heading is rendered, so `` always resolves. */ export interface TocEntry { /** Heading level: `2` for `##` (title), `3` for `###` (subtitle). */ depth: 2 | 3; /** The heading's visible text (closing `#`s and surrounding whitespace stripped). */ text: string; /** Anchor id for the heading, produced by `slugify(text)`. */ id: string; } /** * A piece of site-level copy that may differ per locale: either ONE string used in every locale, * or a map keyed by locale code (`{ en: "...", nl: "..." }`). * * The map form exists because the index copy a multi-locale site renders is per-locale, while the * SEO surfaces built from it here are not rendered by the consumer at all - the RSS channel header * and the `CollectionPage` JSON-LD are emitted by this package. With only the string form, a Dutch * feed announced its channel in English while every one of its items was Dutch, and the JSON-LD * description contradicted the page's own ``. * * A missing key falls back to the site's default locale, then to the builder's own generic text - * never to another locale's copy. */ export type LocalizedText = string | Record; /** * Site-level configuration used to build SEO metadata and JSON-LD. Passed once to a `Blog` or * `Docs` instance (or directly to the pure builders) so titles, canonical URLs, and structured * data resolve to the consumer's real domain and brand. */ export interface SiteConfig { /** Absolute site origin, e.g. `https://example.com`. Used for `metadataBase` and absolute JSON-LD URLs. */ siteUrl: string; /** Brand/site name, used in title suffixes, `openGraph.siteName`, and the JSON-LD publisher. */ brandName: string; /** Author used when a page omits its own. Defaults to `brandName`. */ defaultAuthor?: string | undefined; /** * The configured locales, so the SEO builders can read a locale's own BCP 47 tag rather than * re-deriving a territory from its bare code - see {@link LocaleConfig.dateLocale}. The backend * classes set it from their `locales`; a caller using the pure builders directly may omit it, * and every locale then falls back to the CLDR-maximized bare code as before. */ locales?: LocaleConfig[] | undefined; /** Route the section is mounted at, used to build page URLs. Defaults to `/blog` for a blog, `/docs` for docs. */ basePath?: string | undefined; /** * Description for the index page's metadata, its CollectionPage JSON-LD and the RSS channel. * A plain string is used in every locale; a {@link LocalizedText} map gives each locale its own. */ description?: LocalizedText | undefined; /** * Human-readable name of the section's index, used for the RSS channel ``, the * `CollectionPage` `name` and the index page's OpenGraph title. Defaults to `<brandName> Blog` * / `<brandName> Docs` (and to the bare `brandName` for the RSS channel, which is what it was * before this field existed). A {@link LocalizedText} map gives each locale its own name. */ indexName?: LocalizedText | undefined; /** * Template for every page `<title>` this package builds, with `%s` where the page's own title * goes (`"%s - Example"`), so the section matches the host site's separator. Defaults to * `"%s | <brandName>"`, the form every title had before this field existed. */ titleTemplate?: string | undefined; /** * The index page's own title, put through {@link titleTemplate} for its `<title>` - e.g. a * keyword title per locale (`{ en: "Birth announcement tips", nl: "Tips voor je geboortekaartje" }`). * Defaults to `"Blog"` / `"Docs"`. Unlike {@link indexName} it changes only the `<title>`. */ indexTitle?: LocalizedText | undefined; /** * Name of the first BreadcrumbList crumb (the locale's home page). Defaults to `"Home"`; a * {@link LocalizedText} map gives each locale its own (`{ de: "Startseite", fr: "Accueil" }`). */ homeLabel?: LocalizedText | undefined; /** * Default locale code: the locale whose pages are served without a URL prefix (and the * `x-default` hreflang target). The pure SEO builders need it to decide which locale omits * the prefix; the backend class sets it from `defaultLocale`/`locales`/`locale`. */ defaultLocale?: string | undefined; /** * When `true`, the default locale is also URL-prefixed with its code (`/en/blog/<slug>` * instead of `/blog/<slug>`), so every locale routes through a single `[lang]` segment. * Defaults to `false` (the default locale is served unprefixed). */ prefixDefaultLocale?: boolean | undefined; /** * One origin per locale, for a site that serves each language on its own domain * (`{ nl: "https://example.nl", de: "https://example.de" }` - e.g. an i18nkit * `strategy: "domain"` instance's `i18n.domains`). When set, no locale is URL-prefixed (every * locale's pages live at `<basePath>/<slug>` on its own origin) and every absolute URL built for * a page - canonical, hreflang alternates, `og:url`, `metadataBase`, JSON-LD, sitemap, RSS - uses * the origin of that page's locale, so hreflang links cross domains. A locale missing from the * map falls back to {@link siteUrl}. Leave unset for a single-origin site (the default). * Blog only: a `Docs` instance rejects it. */ localeOrigins?: Readonly<Record<string, string>> | undefined; /** * Whether every built URL ends in a slash (`/docs/getting-started/`). **Defaults to `true`**, * matching Next's `trailingSlash: true`. * * THIS MUST MATCH THE HOST APP'S `next.config` `trailingSlash`, and a mismatch is silent. Under * `output: "export"` the flag decides the emitted filename: `trailingSlash: true` writes * `<slug>/index.html` (a static host then serves `/slug/`, and redirects `/slug` to it), while * `false` writes `<slug>.html` (so `/slug` is served and `/slug/` 404s). Every URL this package * builds - nav links, prev/next, breadcrumbs, canonical, hreflang, the sitemap, RSS - comes from * one builder, so a mismatch does not break one link: it points the whole surface at the form * the host does not serve. A canonical and a sitemap full of 404s is the failure mode, and * nothing throws to tell you. * * Set `false` only for a host that serves bare paths (Next's own default). An index URL (`/`, or * `/<lang>` / `<basePath>`) always keeps its slash either way - it addresses a directory. */ trailingSlash?: boolean | undefined; /** * `@id` of an `Organization` the host site already defines in its own site-wide JSON-LD * (e.g. `https://example.com/#organization`). When set, the content article's `publisher` * and the index `CollectionPage.publisher` reference it by `@id` (`{ "@id": organizationId }`) * instead of inlining a standalone `Organization` node - so search engines merge the section * into the host's single knowledge-graph entity rather than seeing a duplicate. Leave unset * to keep the self-contained inlined publisher. */ organizationId?: string | undefined; /** * `@id` of a `Person` (or `Organization`) the host site already defines as the section's * author (e.g. `https://example.com/#person`). When set, the content article's `author` * references it by `@id` (`{ "@id": authorId }`) instead of inlining a name-only * `Organization` - letting a named human author entity carry authorship. Leave unset to keep * the inlined author. */ authorId?: string | undefined; /** * `@id` of a `WebSite` the host site already defines (e.g. `https://example.com/#website`). * When set, the content article and the index `CollectionPage` gain an `isPartOf` reference * to it (`{ "@id": websiteId }`), tying the section into the site's WebSite entity. Leave * unset to omit `isPartOf`. */ websiteId?: string | undefined; /** * The site's X (Twitter) handle, `@` included (e.g. `@example`), emitted as `twitter:site` on * every page's card. Leave unset to emit no attribution. */ twitterSite?: string | undefined; } /** * A structural subset of Next.js's `Metadata` type, covering only the fields the SEO * builders set. Declared locally so this package has no type dependency on `next`; the * returned objects remain assignable to `Metadata` in a consumer's `generateMetadata`. */ export interface PageMetadata { /** Base URL that relative `canonical`/OG image paths resolve against. */ metadataBase?: URL | undefined; /** * SEO/browser title. The builders return `{ absolute }` (already brand-suffixed), so a * consumer's root `title.template` never appends the brand a second time. */ title?: string | { absolute: string; } | undefined; /** Meta description. */ description?: string | undefined; /** SEO keywords. */ keywords?: string[] | undefined; /** * Robots indexing directives. Set `{ index: false }` to keep a page out of search-engine * indexes (e.g. a `hidden` docs page): Next renders it as * `<meta name="robots" content="noindex">`. Omitted for a normally-indexed page. */ robots?: { index?: boolean | undefined; follow?: boolean | undefined; } | undefined; /** Author list. */ authors?: { name: string; }[] | undefined; /** * Canonical URL plus per-language alternates. `canonical` and every `languages` value are * root-relative (resolved against `metadataBase`); `languages` is a `hreflang -> path` map * (including `"x-default"`) that Next renders as `<link rel="alternate" hreflang>` tags. */ alternates?: { canonical?: string | undefined; languages?: Record<string, string> | undefined; } | undefined; /** OpenGraph fields. */ openGraph?: { type?: string | undefined; url?: string | undefined; siteName?: string | undefined; title?: string | undefined; description?: string | undefined; publishedTime?: string | undefined; modifiedTime?: string | undefined; authors?: string[] | undefined; /** * Share-card images. `alt` is emitted as `og:image:alt`, which is what a screen reader * announces in place of the preview image when the link is shared - the picture itself * is unreachable to that reader, so without it the card is announced as nothing. * `width`/`height` become `og:image:width`/`og:image:height`, which let Facebook's * crawler render the image on the first share instead of after its own async fetch. */ images?: { url: string; alt?: string | undefined; width?: number | undefined; height?: number | undefined; }[] | undefined; /** Language of this page's content (e.g. `"fr"`), emitted as `og:locale`. */ locale?: string | undefined; /** Languages this page is also available in, emitted as `og:locale:alternate`. */ alternateLocale?: string[] | undefined; } | undefined; /** Twitter card fields. */ twitter?: { card?: string | undefined; /** The site's X handle (`twitter:site`), from {@link SiteConfig.twitterSite}. */ site?: string | undefined; title?: string | undefined; description?: string | undefined; /** * Share-card images, in the same object form as {@link PageMetadata.openGraph}'s, so * `alt` reaches `twitter:image:alt` too. It was a bare `string[]`, which has nowhere to * put the alt text - the two must stay the same shape or the next person adds the alt * to one card and not the other. */ images?: { url: string; alt?: string | undefined; width?: number | undefined; height?: number | undefined; }[] | undefined; } | undefined; } /** * A structural subset of one entry in Next.js's `MetadataRoute.Sitemap`, covering only the * fields the sitemap builder sets. Declared locally so this package has no type dependency on * `next`; the returned array stays assignable to a `sitemap.ts`'s return type. */ export interface SitemapEntry { /** Absolute URL of the page (sitemap URLs must be absolute; `metadataBase` does not apply). */ url: string; /** * Per-language alternates for this page, a `hreflang -> absolute-url` map (including * `"x-default"`) that Next renders as `<xhtml:link rel="alternate" hreflang>`. Omitted for an * untranslated page. */ alternates?: { languages?: Record<string, string> | undefined; } | undefined; /** Last-modified date (ISO `YYYY-MM-DD`), rendered as `<lastmod>`. Omitted when unknown. */ lastModified?: string | undefined; }