import { existsSync, readFileSync } from "node:fs";
import { z } from "zod";
import type { BlumeConfig } from "./config-input.ts";
import { applyDeploymentEnv } from "./deployment-env.ts";
import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
import { createModuleLoader } from "./load-module.ts";
import { findConfigFile } from "./project.ts";
import { blumeConfigSchema } from "./schema.ts";
import type { ResolvedConfig } from "./schema.ts";
import type { Diagnostic } from "./types.ts";
/**
* Define a Blume site's configuration with full type-checking and editor
* autocomplete. Place the call in `blume.config.ts` at your project root and
* `export default` the result:
*
* ```ts
* import { defineConfig } from "blume";
*
* export default defineConfig({
* title: "Acme Docs",
* description: "Everything you need to build with Acme.",
* });
* ```
*
* Every field is optional — an empty `defineConfig({})` produces a working
* site from the Markdown/MDX in your `docs/` directory. Configure only what you
* want to change; sensible defaults fill in the rest.
*
* This is an identity helper: it returns its input unchanged and exists purely
* for type inference (and as a stable home for future plugin hooks). The object
* is validated against the Blume schema when the CLI loads it.
*
* ## Top-level fields
*
* **Site identity**
* - `title` — site title, shown in the header, `
`, and OG images.
* Defaults to `"Documentation"`.
* - `description` — default meta description, used where a page sets none.
* - `logo` — brand mark. A string is an image path/URL; the object form splits
* an `image` mark from wordmark `text` and can override the brand `href`.
* - `banner` — site-wide announcement bar; a string, or `{ content, link,
* dismissible }`.
*
* **Content & navigation**
* - `content` — where content lives (`root`, defaults to `docs`) and pluggable
* `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
* custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
* implicit filesystem source.
* - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
* switchers), pinned `featured` links, and the `repo` link toggle. Omit
* `sidebar` to generate it from the content tree.
* - `redirects` — `{ from, to, status }` rules (301 by default).
* - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
* and the header repo link.
*
* **Appearance**
* - `theme` — `accent` color, `fonts` (curated slugs, any provider family, or
* local font files), `radius`,
* `mode` (`system`/`light`/`dark`), and `background`.
* - `markdown` — `code` (language icons, inline highlighting, line wrap),
* `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
* - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
* - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
* - `feedback` — the per-page "Was this helpful?" widget (on by default).
* - `export` — reader-facing PDF/EPUB export actions (off by default).
*
* **Reference docs**
* - `openapi` — native OpenAPI reference: one real page per operation, woven
* into the sidebar and search. Point `sources`/`spec` at your spec.
* - `asyncapi` — native AsyncAPI reference with the same treatment; 2.x specs
* are normalized to 3.x automatically.
*
* **Search & AI**
* - `search` — search backend `provider` (`orama` by default; `pagefind`,
* `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
* credential block.
* - `ai` — `ask` (the Ask AI chat endpoint and its provider/model), `llmsTxt`
* (emit `llms.txt`), `mcp` (expose the docs as an MCP server for connecting
* agents), and `markdownComponents` (Markdown serializers for custom
* components in agent-facing output).
*
* **SEO, feeds & analytics**
* - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
* JSON-LD, `agentReadability`, and robots `contentSignals`.
* - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
* GA, …).
*
* **Astro**
* - `integrations` — Astro integrations appended after Blume's built-ins, in
* declaration order. Install and maintain each integration in the site.
*
* **Deployment & i18n**
* - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
* `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
* (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
* Cloudflare from the platform env.
* - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
* (`dir` vs filename `dot` suffix), and per-locale UI overrides.
*
* - `examples` — `` previews: `source` (default `examples/`;
* supports a glob for colocated registries) and `css`, a stylesheet injected
* into the isolated preview frames (e.g. shadcn variables).
*
* @example Zero-config — just render the Markdown under `docs/`.
* ```ts
* export default defineConfig({});
* ```
*
* @example A production docs site with theming, search, and deployment.
* ```ts
* export default defineConfig({
* title: "Acme Docs",
* description: "Build faster with Acme.",
* logo: { image: "/logo.svg", text: "Acme" },
* github: { owner: "acme", repo: "acme" },
* theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
* navigation: {
* tabs: [
* { label: "Guides", path: "/guides" },
* { label: "API", path: "/api" },
* ],
* },
* search: { provider: "orama" },
* deployment: { site: "https://docs.acme.com", adapter: "vercel" },
* });
* ```
*
* @example An OpenAPI reference with the Ask AI assistant enabled.
* ```ts
* export default defineConfig({
* title: "Acme API",
* openapi: {
* enabled: true,
* route: "/reference",
* sources: [{ label: "Core", spec: "./openapi.json" }],
* },
* ai: { ask: { enabled: true }, llmsTxt: true },
* });
* ```
*
* @param config - The site configuration. All fields are optional.
* @returns The same config object, typed for inference.
* @see https://useblume.dev/docs for the full configuration reference.
*/
export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
/** Result of loading + validating a project config. */
export interface ConfigLoadResult {
config: ResolvedConfig;
/** Absolute path of the config file used, or null when defaults were used. */
configFile: string | null;
diagnostics: Diagnostic[];
/**
* Whether the config file set `theme.fonts` itself. The schema always fills
* the roles with defaults, so the resolved config can't tell an intentional
* font choice from the fallback — and only intentional choices should flow
* into derived surfaces like OG card fonts.
*/
themeFontsConfigured: boolean;
}
const importConfigModule = createModuleLoader();
/**
* The slice of a user config module probed before schema defaults apply:
* whether `theme.fonts` was actually set. `looseObject` keeps every other key
* out of scope; a non-object at either level simply fails the probe.
*/
const themeFontsProbeSchema = z.looseObject({
theme: z.looseObject({ fonts: z.unknown() }).optional(),
});
/**
* Load and validate the project config. When no config file exists, schema
* defaults produce a fully resolved config so the zero-boilerplate path works.
*/
export const loadConfig = async (
root: string,
/**
* Supplied only by `blume dev`: the local dev server URL, used as the
* `deployment.site` fallback when none is configured or detected. Builds
* never pass it, so production output can't end up pointing at localhost.
*/
options: { devServerUrl?: string } = {}
): Promise => {
const configFile = findConfigFile(root);
let raw: unknown;
if (configFile) {
try {
raw = await importConfigModule(configFile);
} catch (error) {
// SAFETY: the module loader rejects with the thrown load/parse failure,
// which Node surfaces as an Error; a non-Error rejection only degrades
// the interpolated message.
throw new BlumeError({
code: "BLUME_CONFIG_LOAD_FAILED",
file: configFile,
message: `Failed to load config: ${(error as Error).message}`,
severity: "error",
});
}
}
// Read before parsing: schema defaults erase the set-vs-defaulted distinction.
const probe = themeFontsProbeSchema.safeParse(raw);
const themeFontsConfigured =
probe.success && probe.data.theme?.fonts !== undefined;
const parsed = blumeConfigSchema.safeParse(raw ?? {});
if (!parsed.success) {
// Read the raw config text (when on disk) so errors carry a line/column.
const source =
configFile && existsSync(configFile)
? readFileSync(configFile, "utf-8")
: undefined;
const diagnostics = diagnosticsFromZod(parsed.error, {
code: "BLUME_CONFIG_INVALID",
file: configFile ?? undefined,
source,
});
const [first, ...rest] = diagnostics;
const primary = first ?? {
code: "BLUME_CONFIG_INVALID",
file: configFile ?? undefined,
message: "Invalid Blume config.",
severity: "error" as const,
};
// Surface every issue in one failing run — reporting only the first turns
// a three-mistake config into three fix-rerun-fail loops.
const moreIssues = rest.map((d) => ` - ${d.message}`).join("\n");
const detail =
rest.length > 0
? {
...primary,
message: `${primary.message}\n${rest.length} more config issue(s):\n${moreIssues}`,
}
: primary;
throw new BlumeError(detail);
}
// Resolve the canonical site URL, then SEO defaults that depend on it.
// Precedence: explicit config > platform env (Vercel/Netlify/Cloudflare, via
// applyDeploymentEnv) > the local dev server URL (dev only).
const config = applyDeploymentEnv(parsed.data);
const site = config.deployment.site ?? options.devServerUrl;
// OG images need an absolute `og:image`, so they default on once a site URL
// is known and off otherwise. An explicit `seo.og.enabled` always wins.
const ogEnabled = config.seo.og.enabled ?? Boolean(site);
return {
config: {
...config,
deployment: { ...config.deployment, site },
seo: { ...config.seo, og: { ...config.seo.og, enabled: ogEnabled } },
},
configFile,
diagnostics: [],
themeFontsConfigured,
};
};