import type { AstroIntegration } from "astro"; import type { z } from "zod"; import type { AskRetrievalOptions } from "../ai/ask-context.ts"; import type { ComponentMarkdown } from "../ai/component-markdown.ts"; import type { CodeTheme } from "../markdown/themes.ts"; import type { FontSlug } from "../theme/fonts.ts"; import type { blumeConfigSchema, OpenApiSource, OpenInChatProvider, SearchProvider, SidebarDisplay, SidebarItemConfig, } from "./schema.ts"; import type { ContentSource } from "./sources/types.ts"; import type { StandardSchema } from "./standard-schema.ts"; /** * The public, hand-documented authoring type for `blume.config.ts`. * * This interface mirrors the input side of {@link blumeConfigSchema} — the Zod * schema is still the single source of validation truth, but the schema's * inferred type carries no doc comments, so this parallel interface exists * purely to give editors rich per-field hover text and autocomplete. A * compile-time guard at the bottom of this file fails `tsc` if the two ever * drift, so keep them in sync. * * @see {@link defineConfig} — the helper you actually call. */ // --------------------------------------------------------------------------- // Small shared helpers // --------------------------------------------------------------------------- /** * A literal union that still accepts any other string, so known values * autocomplete without rejecting custom ones (matches the schema's `string`). */ type LiteralUnion = T | (string & Record); /** * A per-color-mode value: a single string applies to both light and dark; the * object form sets each mode independently (either key may be omitted to * override just one mode). */ export type PerModeValue = string | { dark?: string; light?: string }; // --------------------------------------------------------------------------- // Brand: logo & banner // --------------------------------------------------------------------------- /** The logo mark: a single image path/URL, or per-mode variants with alt text. */ export type LogoImage = | string | { /** Alt text for the mark. */ alt?: string; /** Image shown in dark mode. */ dark?: string; /** Image shown in light mode. */ light?: string; }; /** * Site logo. A bare string is the image shorthand. The object form splits the * brand into an optional `image` mark and an optional wordmark `text`, so a site * can show an image-only logo, a text-only logo, or both. */ export type LogoConfig = | string | { /** Overrides the brand link target. Defaults to `/`. */ href?: string; /** The logo mark. Omit for a text-only brand. */ image?: LogoImage; /** * Wordmark text beside the mark. Omit to fall back to the site `title`; * set to `""` to render the mark alone. */ text?: string; }; /** * Site-wide announcement banner shown above the header. A bare string is the * banner text; the object form adds an optional call-to-action link and * dismiss behavior. */ export type BannerConfig = | string | { /** The banner message. */ content: string; /** Show a dismiss button; the choice is remembered per visitor. */ dismissible?: boolean; /** Stable key for remembering dismissal; defaults to the content. */ id?: string; /** An optional call-to-action link. */ link?: { /** Link target (internal route or external URL). */ href: string; /** Link text. */ text: string; }; }; // --------------------------------------------------------------------------- // Content sources // --------------------------------------------------------------------------- /** Local Markdown/MDX read from the filesystem. */ export interface FilesystemSource { type: "filesystem"; /** Glob patterns to ignore. Defaults to `["**\/_*", "**\/.*"]`. */ exclude?: string[]; /** Glob patterns to include. Defaults to `["**\/*.{md,mdx}"]`. */ include?: string[]; /** Namespaces this source's routes under `//`. */ prefix?: string; /** Directory to read from, relative to the project root. Defaults to `docs`. */ root?: string; } /** * Remote Markdown/MDX fetched over HTTP. Enumerate files explicitly against a * raw `url` base, or from a GitHub repo subtree via `github`. A private repo's * token comes from `GITHUB_TOKEN` — never inline it here. */ export interface MdxRemoteSource { type: "mdx-remote"; /** Explicit list of source-relative file paths to fetch from `url`. */ files?: string[]; /** Enumerate a GitHub repo subtree via the git-trees API. */ github?: { /** Repository owner (user or org). */ owner: string; /** Subpath within the repo. Defaults to the repo root. */ path?: string; /** Git ref (branch, tag, or SHA). Defaults to `main`. */ ref?: string; /** Repository name. */ repo: string; }; /** Glob patterns applied to enumerated refs. Defaults to `["**\/*.{md,mdx}"]`. */ include?: string[]; /** Opt-in dev polling interval (seconds); omit to freeze for the session. */ pollInterval?: number; /** Namespaces this source's routes under `//`. */ prefix?: string; /** Raw base URL, e.g. `https://raw.githubusercontent.com/acme/sdk/main/docs`. */ url?: string; } /** * A repo's GitHub Releases, materialized as `type: changelog` entries — release * notes become the changelog with no files to maintain. A private repo reads a * token from `GITHUB_TOKEN`; never inline it here. */ export interface GithubReleasesSource { type: "github-releases"; /** Include draft releases (needs a token with repo write access). */ drafts?: boolean; /** Cap the number of releases materialized, newest-first. Defaults to 100. */ limit?: number; /** Repository owner (user or org). */ owner: string; /** Opt-in dev polling interval (seconds); omit to freeze for the session. */ pollInterval?: number; /** Namespaces this source's routes under `//`; e.g. `changelog`. */ prefix?: string; /** Include prereleases. */ prereleases?: boolean; /** Repository name. */ repo: string; } /** A Sanity dataset queried with GROQ; Portable Text bodies become Markdown. */ export interface SanitySource { type: "sanity"; /** Sanity API version (a date). Defaults to `2024-01-01`. */ apiVersion?: string; /** Dataset name to query. */ dataset: string; /** Field paths mapping a document onto Blume meta + body. */ fields?: { /** Field holding the renderable body (Portable Text or Markdown). */ body?: string; /** Field holding the page description. */ description?: string; /** Field holding the last-modified date. */ lastModified?: string; /** Field holding the page slug. */ slug?: string; /** Field holding the page title. */ title?: string; }; /** Opt-in dev polling interval (seconds); omit to freeze for the session. */ pollInterval?: number; /** Namespaces this source's routes under `//`. */ prefix?: string; /** Sanity project id. */ projectId: string; /** GROQ query selecting the documents to import. */ query: string; } /** A Notion database; pages become entries, blocks become MDX. */ export interface NotionSource { type: "notion"; /** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */ concurrency?: number; /** Notion database id. */ database: string; /** Opt-in dev polling interval (seconds); omit to freeze for the session. */ pollInterval?: number; /** Namespaces this source's routes under `//`. */ prefix?: string; /** Notion property names mapped onto Blume meta. */ properties?: { /** Property holding the page description. */ description?: string; /** Property holding the sort order. */ order?: string; /** Property holding the page slug. */ slug?: string; /** Property holding the publish status. */ status?: string; /** Property holding the page title. */ title?: string; }; /** Status value treated as published; others map to `draft`. Defaults to `Published`. */ publishedValue?: string; } /** * A user-provided {@link ContentSource} instance, passed straight through. This * is the extension point for adapters with custom serializers or any other * backend, without their SDKs touching core. */ export interface CustomSource { type: "custom"; /** A `ContentSource` implementation (an object with `name` + `load`). */ source: ContentSource; } /** A single configured content source, discriminated by `type`. */ export type ContentSourceInput = | FilesystemSource | MdxRemoteSource | GithubReleasesSource | SanitySource | NotionSource | CustomSource; /** * Where content lives and how it's discovered. When `sources` is omitted, the * top-level `root`/`include`/`exclude` desugar to one implicit filesystem * source, so simple sites need nothing here. */ export interface ContentConfig { /** Default page `type` for content that sets none. Defaults to `doc`. */ defaultType?: string; /** Glob patterns to ignore. Defaults to `["**\/_*", "**\/.*"]`. */ exclude?: string[]; /** Glob patterns to include. Defaults to `["**\/*.{md,mdx}"]`. */ include?: string[]; /** Directory of standalone `pages` (outside the docs tree). Defaults to `pages`. */ pages?: string; /** Content root directory, relative to the project root. Defaults to `docs`. */ root?: string; /** * Pluggable content sources. Mix local files with remote MDX, GitHub * Releases, Sanity, Notion, or a custom `ContentSource`. */ sources?: ContentSourceInput[]; /** * Per-type content definitions, keyed by the frontmatter `type` they apply * to (including `defaultType`, for pages that set none): * * ```ts * import { z } from "zod"; * * content: { * types: { * rfc: { * frontmatter: { * domain: z.string(), * status: z.enum(["draft", "enforced"]), * }, * }, * }, * }, * ``` */ types?: Record; } /** * A per-type content definition: configuration that applies only to pages * whose resolved frontmatter `type` matches the map key. */ export interface ContentTypeConfig { /** * Custom frontmatter keys whose values become filterable facets for pages * of this type. Faceted values ride along on search documents * (`blume-search.json` and the MCP index), and the MCP `search_docs` and * `list_pages` tools accept a `filters` input matching against them: * * ```ts * content: { * types: { * rfc: { * facets: ["domain", "status"], * frontmatter: { domain: z.string(), status: z.string() }, * }, * }, * }, * ``` * * Each name must be a custom key declared for the type — in its * `frontmatter` map or the site-wide `frontmatter.extend`. String values * facet as-is; numbers and booleans are stringified; anything else * (objects, arrays, transformed dates) does not facet. */ facets?: string[]; /** * Custom frontmatter keys for pages of this type, layered on top of the * site-wide `frontmatter.extend` (a key can be declared in one or the * other, not both). Schemas follow the same rules as `extend`: any * Standard Schema library works, every declared key is validated on every * page of the type — absent ones included — so a required schema enforces * the key type-wide (mark it `.optional()` to validate only when present), * and validated values land on the page record's `custom` field. Built-in * frontmatter fields cannot be redeclared. */ frontmatter?: Record; } // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- /** * A header label, optionally per locale: a plain string, or a map of locale * code to label (`{ en: "Docs", ja: "ドキュメント" }`). The active locale's * entry wins, then the default locale's, then the map's first entry. */ export type LocalizableLabel = string | Record; /** A single item inside a header tab's dropdown. */ export interface NavTabItem { /** Secondary line under the label. */ description?: string; /** Lucide icon name shown beside the label. */ icon?: string; /** Item label, optionally per locale. */ label: LocalizableLabel; /** Route the item links to. */ path: string; /** Short tag/pill (e.g. `New`, `Beta`). */ tag?: string; } /** A top-level tab in the header, optionally opening a dropdown of items. */ export interface NavTab { /** * Where the tab links to, when that differs from `path`. `path` scopes the * sidebar section and matches the active tab; without `href`, a section whose * `path` isn't itself a page falls back to the section's first page, or keeps * `path` when the section has no linkable page at all. Set this to send * readers somewhere else — e.g. a generated `/changelog` index, or a custom * `.astro` landing page, neither of which is part of the content tree. */ href?: string; /** Lucide icon name shown beside the label. */ icon?: string; /** Dropdown items; omit for a plain link tab. */ items?: NavTabItem[]; /** Tab label, optionally per locale. */ label: LocalizableLabel; /** Route the tab links to. */ path: string; } /** A single option in a header selector (version, language, product, …). */ export interface NavSelectorItem { /** Secondary line under the label. */ description?: string; /** Lucide icon name shown beside the label. */ icon?: string; /** Option label. */ label: string; /** Route the option links to. */ path: string; /** Short tag/pill. */ tag?: string; } /** * A header dropdown for switching context — versions, languages, products, or a * generic dropdown. `kind` drives the icon and a11y labeling. */ /** Context-partition selector kinds (a versioned/localized/multi-product site). */ type NavSelectorContextKind = "product" | "version"; /** What a header selector switches between. */ type NavSelectorKind = "dropdown" | "language" | NavSelectorContextKind; export interface NavSelector { /** The options shown in the dropdown. */ items?: NavSelectorItem[]; /** What the selector switches between. */ kind: NavSelectorKind; /** Selector label / current value. */ label: string; } /** * A pinned link rendered above the sidebar sections — a blog, changelog, or * contact page that stays reachable regardless of the active tab. `href` may be * an internal route or an external URL. */ export interface FeaturedLink { /** Link target. */ href: string; /** Lucide icon name shown beside the label. */ icon?: string; /** Link label. */ label: string; } /** * The sidebar. Omit `items` to generate the sidebar from the content tree; * provide `items` for a fully explicit sidebar. `display` sets how every group * renders by default (an individual group may override it). A bare array is * shorthand for `{ items }`. */ export type SidebarConfig = | SidebarItemConfig[] | { /** * Default group rendering: `flat` (header + list), `group` (collapsible * disclosure), or `page` (drill-in sub-panel). Defaults to `flat`. */ display?: SidebarDisplay; /** Explicit sidebar nodes; omit to auto-generate from content. */ items?: SidebarItemConfig[]; }; /** Header, sidebar, tabs, and switcher configuration. */ export interface NavigationConfig { /** Pinned links shown above the generated sidebar sections. */ featured?: FeaturedLink[]; /** Show a GitHub repo link in the header (requires `github` configured). */ repo?: boolean; /** Context switchers shown in the header (versions, languages, …). */ selectors?: NavSelector[]; /** Sidebar behavior and (optionally) an explicit sidebar tree. */ sidebar?: SidebarConfig; /** Top-level tabs shown in the header. */ tabs?: NavTab[]; } // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- /** Fallback stack category for a custom font. */ export type FontFallback = "sans" | "serif" | "mono"; /** * Any family from a zero-config Astro font provider, by name. Self-hosted and * optimized like the curated slugs. */ export interface RemoteFontInput { /** Fallback stack. Defaults to `mono` for the mono role, `sans` otherwise. */ fallback?: FontFallback; /** Family name as the provider lists it, e.g. `"Noto Sans JP"`. */ name: string; /** Which provider serves the family. Defaults to `google`. */ provider?: "google" | "fontsource" | "bunny" | "fontshare"; /** Weights (or variable ranges like `"100..900"`) to load. Defaults to `[400, 500, 600, 700]`. */ weights?: (number | string)[]; } /** One local `@font-face`: a file plus optional weight/style (else inferred). */ export interface LocalFontVariantInput { /** Font file path, relative to the project root. */ src: string; /** Face style; inferred from the file when omitted. */ style?: "normal" | "italic" | "oblique"; /** Face weight (a number or `"100..900"` range); inferred when omitted. */ weight?: number | string; } /** A self-hosted family loaded from font files in the project. */ export interface LocalFontInput { /** Fallback stack. Defaults to `mono` for the mono role, `sans` otherwise. */ fallback?: FontFallback; /** Family name used in CSS and the OG card. */ name: string; /** The faces to declare (at least one). */ variants: LocalFontVariantInput[]; } /** A role's font: curated slug, remote-provider family, or local files. */ export type FontInput = | LiteralUnion | RemoteFontInput | LocalFontInput; /** The three type roles: a curated slug, any provider family, or local files. */ export interface FontsConfig { /** Body / prose font. Defaults to `inter`. */ body?: FontInput; /** Display / heading font. Defaults to `inter` (shared with the body). */ display?: FontInput; /** Monospace / code font. Defaults to `ibm-plex-mono`. */ mono?: FontInput; } /** Colors, fonts, radius, and color-mode behavior. */ /** Corner radius scale (`none`/`sm` tighter, `md`/`lg` rounder). */ type RadiusScaleTight = "none" | "sm"; type RadiusScaleRound = "md" | "lg"; type RadiusScale = RadiusScaleTight | RadiusScaleRound; export interface ThemeConfig { /** * Accent color. A palette name (`blue`, `violet`, `green`, …) or any CSS * color applies to both modes; the object form sets each mode. Defaults to * `blue`. */ accent?: string | { dark: string; light: string }; /** Optional distinct color for call-to-action surfaces. */ action?: string; /** Page background color, per mode. */ background?: PerModeValue; /** Page background image (CSS `background-image` value), per mode. */ backgroundImage?: PerModeValue; /** Font selection for body, display, and mono roles. */ fonts?: FontsConfig; /** Overall page layout. Currently only `sidebar`. */ layout?: "sidebar"; /** Initial color mode. Defaults to `system`. */ mode?: "system" | "light" | "dark"; /** Corner radius scale. Defaults to `md`. */ radius?: RadiusScale; } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- /** Public credentials for the Algolia backend (the sync key stays an env var). */ export interface AlgoliaSearch { appId: string; indexName: string; searchApiKey: string; } /** Public credentials for the Orama Cloud backend. */ export interface OramaCloudSearch { apiKey: string; endpoint: string; /** Index id used by the build-time sync (with `ORAMA_PRIVATE_API_KEY`). */ indexId?: string; } /** Connection details for a self-hosted or cloud Typesense backend. */ export interface TypesenseSearch { collection: string; host: string; port?: number; protocol?: "http" | "https"; searchApiKey: string; } /** Mixedbread semantic search: the store the server endpoint queries. */ export interface MixedbreadSearch { storeId: string; } /** A curated link for the search dialog empty state. */ export interface SearchPopularLink { /** Internal route or external URL. */ href: string; /** * Icon shown beside the label — a built-in name, image path/URL, or inline * SVG (same as nav icons). Defaults to the file glyph. */ icon?: string; /** Link label shown in the dialog. */ label: string; } /** * Search backend. The default `orama` builds a local index at build time (and * runs in dev); hosted providers need their credential block below. `none` * disables search. */ export interface SearchConfig { /** Algolia credentials (required when `provider` is `algolia`). */ algolia?: AlgoliaSearch; /** Indexing behavior. */ indexing?: { /** Include pages marked `hidden` in the search index. Defaults to `false`. */ includeHiddenPages?: boolean; }; /** Mixedbread store (required when `provider` is `mixedbread`). */ mixedbread?: MixedbreadSearch; /** Orama Cloud credentials (required when `provider` is `orama-cloud`). */ oramaCloud?: OramaCloudSearch; /** * Curated links for the Cmd+K empty state. When omitted or empty, the first * sidebar pages are shown instead. */ popular?: SearchPopularLink[]; /** Which backend powers search. Defaults to `orama`. */ provider?: SearchProvider; /** Typesense credentials (required when `provider` is `typesense`). */ typesense?: TypesenseSearch; } // --------------------------------------------------------------------------- // AI // --------------------------------------------------------------------------- /** An empty-state prompt shown before the first Ask AI question. */ export interface AskSuggestion { /** Lucide icon name shown beside the suggestion. */ icon?: string; /** The clickable suggestion text. */ label: string; } /** The Ask AI chat assistant. */ /** Backends that can route an Ask AI request. */ type AskProviderGateway = "gateway" | "openrouter" | "llmgateway"; type AskProvider = AskProviderGateway | "inkeep" | "openai-compatible"; /** How much retrieved documentation each Ask AI question carries. */ export interface AskRetrievalConfig { /** * Total injected documentation characters, across all excerpts. Defaults to * `10000`. The single biggest lever on time-to-first-token — the model reads * every injected character before it emits a token. */ contextBudget?: number; /** * Characters kept per excerpt. Defaults to `2000`. Raise it when one long * page holds the whole answer (a table the excerpt cuts in half); the * `contextBudget` still caps the total. */ excerptChars?: number; /** * Documents retrieved per question. Defaults to `6`. The page the reader is * viewing is injected on top of the retrieved ones, so an answer can cite up * to one page more than this. */ maxResults?: number; } export interface AskConfig { /** * Name of the env var holding the provider API key. Each provider has a * sensible default; set this only to override it. */ apiKeyEnv?: string; /** * Backend base URL. Required for `openai-compatible`; for named providers it * overrides the built-in preset. */ baseUrl?: string; /** Turn Ask AI on. Defaults to `false`. */ enabled?: boolean; /** * Existing Ask AI endpoint to call instead of generating one. This keeps a * Blume site static while an API backend owns retrieval, model access, rate * limiting, and streaming. Accepts an absolute URL or root-relative path. */ endpoint?: string; /** * Extra system-prompt text appended to the built-in instructions — use it * for identity, language, or tone. The built-in grounding behavior (answer * from the retrieved excerpts, cite pages as Markdown links) is preserved. */ instructions?: string; /** Model id to use. Defaults to `openai/gpt-5.5`. */ model?: string; /** Which backend routes the request. Defaults to `gateway`. */ provider?: AskProvider; /** * How much documentation each question carries into the model's prompt. * Lower values cut time-to-first-token — which dominates on a self-hosted * backend — at the cost of recall. Defaults keep the built-in behavior. */ retrieval?: AskRetrievalConfig; /** Starter prompts shown before the first question. */ suggestions?: AskSuggestion[]; } /** What the `llms.txt`/`llms-full.txt` files include. */ export interface LlmsTxtConfig { /** Emit `llms.txt` and `llms-full.txt`. Defaults to `true`. */ enabled?: boolean; /** * Include the generated API reference pages (OpenAPI/AsyncAPI). Defaults to * `true`; set `false` to keep a placeholder or example spec's pages out of * the LLM-facing files. */ openapi?: boolean; } /** Expose the docs as an MCP server for connecting agents. */ export interface McpConfig { /** Turn the MCP server on. Defaults to `false`. */ enabled?: boolean; /** Optional system hint passed to connecting agents. */ instructions?: string; /** Server name shown to clients; defaults to the site title. */ name?: string; /** Route the server mounts at. Defaults to `/mcp`. */ route?: string; } /** * AI-facing features: the Ask AI assistant, an `llms.txt` manifest, and the * hosted MCP server. */ export interface AiConfig { /** The Ask AI chat assistant. */ ask?: AskConfig; /** * Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`. * The object form adds knobs for what the files include. */ llmsTxt?: boolean | LlmsTxtConfig; /** * Markdown serializers for custom components in agent-facing output (the * `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each * receives the component's statically-evaluated `props` (with the page's * `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its * downleveled `children`, and the page's `frontmatter` data, and returns * replacement Markdown — or `null` to leave the JSX verbatim. A same-name * entry replaces a built-in serializer. * * These live in `blume.config.ts` (which is executed at build time), not in * `components.tsx` (which is only statically analyzed, never run). * * ```ts * ai: { * markdownComponents: { * Chart: ({ props }) => `![${props.title}](/charts/${props.slug}.png)`, * }, * } * ``` */ markdownComponents?: Record; /** Expose the docs as an MCP server for agents. */ mcp?: McpConfig; /** * The "Open in chat" page action, which opens the current page in an AI * assistant pre-filled with a prompt pointing at its raw Markdown. * Defaults to `true` (every provider). Set `false` to hide the action, or * list a subset of providers to show, in order. * * ```ts * ai: { * openInChat: ["claude", "chatgpt", "cursor"], * } * ``` */ openInChat?: boolean | OpenInChatProvider[]; /** * Publish Agent Skills for discovery: a directory (resolved against the * project root) whose subdirectories each hold a `SKILL.md`. Skills are * copied under `/.well-known/agent-skills/` — single-file skills verbatim, * skills with supporting resources as `.tar.gz` archives — and enumerated * in a discovery index with SHA-256 digests (Agent Skills Discovery RFC). * * ```ts * ai: { * skills: "./skills", * } * ``` */ skills?: string; /** * Web Bot Auth: publish the org's HTTP Message Signature public keys at * `/.well-known/http-message-signatures-directory`, so sites receiving * requests from your agents can verify them. Public keys only — a key * containing private material (`d`, `p`, `q`, …) is rejected. */ webBotAuth?: WebBotAuthConfig; /** * WebMCP: register in-page tools (search, page Markdown, the docs index) * on the browser's model context, so agentic browsers can drive the docs * without a separate MCP connection. The script is tiny and no-ops in * browsers without the API. Defaults to `true`; set `false` to opt out. */ webmcp?: boolean; } /** Web Bot Auth signature directory. Off until at least one key is listed. */ export interface WebBotAuthConfig { /** Public JWKs to publish (e.g. an Ed25519 key: `kty: "OKP"`, `crv: "Ed25519"`, `x: …`). */ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- mirrors the schema's `z.record(z.unknown())` (the drift guard requires it); JWK parameters are validated at parse time, not typed. keys?: Record[]; } // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- /** An arbitrary analytics `