blume 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,139 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
+ import type { BlumeConfig } from "./config-input.ts";
3
4
  import { applyDeploymentEnv } from "./deployment-env.ts";
4
5
  import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
5
6
  import { createModuleLoader } from "./load-module.ts";
6
7
  import { findConfigFile } from "./project.ts";
7
8
  import { blumeConfigSchema } from "./schema.ts";
8
- import type { BlumeConfig, ResolvedConfig } from "./schema.ts";
9
+ import type { ResolvedConfig } from "./schema.ts";
9
10
  import type { Diagnostic } from "./types.ts";
10
11
 
11
12
  /**
12
- * Identity helper for authoring `blume.config.ts`. Exists for type inference
13
- * and a stable future home for plugin hooks; it does not transform input.
13
+ * Define a Blume site's configuration with full type-checking and editor
14
+ * autocomplete. Place the call in `blume.config.ts` at your project root and
15
+ * `export default` the result:
16
+ *
17
+ * ```ts
18
+ * import { defineConfig } from "blume";
19
+ *
20
+ * export default defineConfig({
21
+ * title: "Acme Docs",
22
+ * description: "Everything you need to build with Acme.",
23
+ * });
24
+ * ```
25
+ *
26
+ * Every field is optional — an empty `defineConfig({})` produces a working
27
+ * site from the Markdown/MDX in your `docs/` directory. Configure only what you
28
+ * want to change; sensible defaults fill in the rest.
29
+ *
30
+ * This is an identity helper: it returns its input unchanged and exists purely
31
+ * for type inference (and as a stable home for future plugin hooks). The object
32
+ * is validated against the Blume schema when the CLI loads it.
33
+ *
34
+ * ## Top-level fields
35
+ *
36
+ * **Site identity**
37
+ * - `title` — site title, shown in the header, `<title>`, and OG images.
38
+ * Defaults to `"Documentation"`.
39
+ * - `description` — default meta description, used where a page sets none.
40
+ * - `logo` — brand mark. A string is an image path/URL; the object form splits
41
+ * an `image` mark from wordmark `text` and can override the brand `href`.
42
+ * - `banner` — site-wide announcement bar; a string, or `{ content, link,
43
+ * dismissible }`.
44
+ *
45
+ * **Content & navigation**
46
+ * - `content` — where content lives (`root`, defaults to `docs`) and pluggable
47
+ * `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
48
+ * custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
49
+ * implicit filesystem source.
50
+ * - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
51
+ * switchers), pinned `featured` links, and the `repo` link toggle. Omit
52
+ * `sidebar` to generate it from the content tree.
53
+ * - `redirects` — `{ from, to, status }` rules (301 by default).
54
+ * - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
55
+ * and the header repo link.
56
+ *
57
+ * **Appearance**
58
+ * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
59
+ * `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
60
+ * - `markdown` — `code` (language icons, inline highlighting, line wrap),
61
+ * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
62
+ * - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
63
+ * - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
64
+ * - `feedback` — the per-page "Was this helpful?" widget (on by default).
65
+ * - `export` — reader-facing PDF/EPUB export actions (off by default).
66
+ *
67
+ * **Reference docs**
68
+ * - `openapi` — native OpenAPI reference: one real page per operation, woven
69
+ * into the sidebar and search. Point `sources`/`spec` at your spec.
70
+ * - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
71
+ *
72
+ * **Search & AI**
73
+ * - `search` — search backend `provider` (`orama` by default; `pagefind`,
74
+ * `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
75
+ * credential block.
76
+ * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
77
+ * (emit `llms.txt`).
78
+ * - `mcp` — expose the docs as an MCP server for connecting agents.
79
+ *
80
+ * **SEO, feeds & analytics**
81
+ * - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
82
+ * JSON-LD, `agentReadability`, and robots `contentSignals`.
83
+ * - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
84
+ * GA, …).
85
+ *
86
+ * **Deployment & i18n**
87
+ * - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
88
+ * `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
89
+ * (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
90
+ * Cloudflare from the platform env.
91
+ * - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
92
+ * (`dir` vs filename `dot` suffix), and per-locale UI overrides.
93
+ *
94
+ * - `examples` — where `<Component path>` previews resolve their source from
95
+ * (defaults to `examples/`; supports a glob for colocated registries).
96
+ *
97
+ * @example Zero-config — just render the Markdown under `docs/`.
98
+ * ```ts
99
+ * export default defineConfig({});
100
+ * ```
101
+ *
102
+ * @example A production docs site with theming, search, and deployment.
103
+ * ```ts
104
+ * export default defineConfig({
105
+ * title: "Acme Docs",
106
+ * description: "Build faster with Acme.",
107
+ * logo: { image: "/logo.svg", text: "Acme" },
108
+ * github: { owner: "acme", repo: "acme" },
109
+ * theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
110
+ * navigation: {
111
+ * tabs: [
112
+ * { label: "Guides", path: "/guides" },
113
+ * { label: "API", path: "/api" },
114
+ * ],
115
+ * },
116
+ * search: { provider: "orama" },
117
+ * deployment: { site: "https://docs.acme.com", adapter: "vercel" },
118
+ * });
119
+ * ```
120
+ *
121
+ * @example An OpenAPI reference with the Ask AI assistant enabled.
122
+ * ```ts
123
+ * export default defineConfig({
124
+ * title: "Acme API",
125
+ * openapi: {
126
+ * enabled: true,
127
+ * route: "/reference",
128
+ * sources: [{ label: "Core", spec: "./openapi.json" }],
129
+ * },
130
+ * ai: { ask: { enabled: true }, llmsTxt: true },
131
+ * });
132
+ * ```
133
+ *
134
+ * @param config - The site configuration. All fields are optional.
135
+ * @returns The same config object, typed for inference.
136
+ * @see https://useblume.dev/docs for the full configuration reference.
14
137
  */
15
138
  export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
16
139
 
@@ -925,12 +925,6 @@ const codeConfigSchema = z
925
925
  * …). On by default; recognized languages only.
926
926
  */
927
927
  icons: z.boolean().default(true),
928
- /**
929
- * Syntax-highlight inline `` `code{:lang}` `` snippets. Off by default — most
930
- * inline code (flags, file names) reads better plain; opt a snippet in with
931
- * a trailing `{:lang}` marker.
932
- */
933
- inline: z.boolean().default(false),
934
928
  /**
935
929
  * Wrap long lines instead of scrolling horizontally. Off by default, so
936
930
  * code keeps its original line breaks and overflows into a scroll area.
@@ -955,11 +949,6 @@ const markdownConfigSchema = z
955
949
  * opt a single image out with `data-no-zoom`.
956
950
  */
957
951
  imageZoom: z.boolean().default(true),
958
- /**
959
- * Enable LaTeX math (`$…$` inline, `$$…$$` block) rendered with KaTeX.
960
- * Off by default since `$` is common in prose, shell, and code. MDX only.
961
- */
962
- math: z.boolean().default(false),
963
952
  })
964
953
  .strict();
965
954
 
@@ -1097,8 +1086,12 @@ export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
1097
1086
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
1098
1087
  /** A configured locale with display metadata. */
1099
1088
  export type LocaleConfig = z.infer<typeof localeSchema>;
1100
- /** User-authored config: the shape accepted by `defineConfig`. */
1101
- export type BlumeConfig = z.input<typeof blumeConfigSchema>;
1089
+ /**
1090
+ * User-authored config, straight off the schema. The public, hand-documented
1091
+ * authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
1092
+ * guard keeps structurally identical to this.
1093
+ */
1094
+ export type BlumeConfigInput = z.input<typeof blumeConfigSchema>;
1102
1095
  /** A configured search backend. */
1103
1096
  export type SearchProvider = (typeof searchProviders)[number];
1104
1097
  /** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
@@ -0,0 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, rm } from "node:fs/promises";
3
+
4
+ import { dirname, join } from "pathe";
5
+
6
+ import type { ResolvedConfig } from "../core/schema.ts";
7
+ import type { ProjectContext } from "../core/types.ts";
8
+
9
+ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
10
+
11
+ /**
12
+ * Server adapters whose deploy bundle lands *outside* Astro's `outDir`, at a
13
+ * path relative to the Astro project root. Blume points the Astro root at the
14
+ * hidden `<root>/.blume` runtime, so these adapters write their bundle to
15
+ * `<root>/.blume/<path>` — where the deploy platform never looks. Each value is
16
+ * the sub-path to surface up to the real project root.
17
+ *
18
+ * `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
19
+ * subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
20
+ * project root survives the relocation. `netlify` owns its whole `.netlify`
21
+ * dir. `node` and `cloudflare` emit into `dist/` (already at the project root),
22
+ * so they are absent here and need no relocation.
23
+ */
24
+ export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
25
+ netlify: ".netlify",
26
+ vercel: ".vercel/output",
27
+ };
28
+
29
+ /**
30
+ * Directory whose contents the deploy platform serves as static files. Build
31
+ * artifacts (robots.txt, sitemap.xml, llms.txt, …) must be written here to be
32
+ * served. For a Vercel server build that is the adapter's
33
+ * `.vercel/output/static`; every other build serves `dist/`.
34
+ */
35
+ export const deployStaticDir = (
36
+ config: ResolvedConfig,
37
+ context: ProjectContext
38
+ ): string => {
39
+ const { adapter, output } = config.deployment;
40
+ if (output === "server" && adapter === "vercel") {
41
+ return join(context.root, ".vercel", "output", "static");
42
+ }
43
+ return context.distDir ?? join(context.root, "dist");
44
+ };
45
+
46
+ /** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
47
+ export type SurfaceResult =
48
+ | { moved: false }
49
+ | { from: string; ignore: string; moved: true; to: string };
50
+
51
+ /**
52
+ * Move a server adapter's deploy bundle out of the hidden `.blume` runtime and
53
+ * up to the project root, where the deploy platform (and `vercel deploy
54
+ * --prebuilt`) expects it. A no-op for static builds, for adapters that emit
55
+ * into `dist/`, and when the expected output is absent.
56
+ */
57
+ export const surfaceAdapterOutput = async (
58
+ config: ResolvedConfig,
59
+ context: ProjectContext
60
+ ): Promise<SurfaceResult> => {
61
+ const { adapter, output } = config.deployment;
62
+ if (output !== "server" || !adapter) {
63
+ return { moved: false };
64
+ }
65
+ const rel = ADAPTER_OUTPUT_PATHS[adapter];
66
+ if (!rel) {
67
+ return { moved: false };
68
+ }
69
+ const from = join(context.outDir, rel);
70
+ const to = join(context.root, rel);
71
+ if (!existsSync(from)) {
72
+ return { moved: false };
73
+ }
74
+ await mkdir(dirname(to), { recursive: true });
75
+ await rm(to, { force: true, recursive: true });
76
+ await cp(from, to, { recursive: true });
77
+ await rm(from, { force: true, recursive: true });
78
+ // The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
79
+ // never the moved sub-path — Vercel's own `.vercel/project.json` lives there
80
+ // too and must also be ignored.
81
+ return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
82
+ };
package/src/index.ts CHANGED
@@ -22,8 +22,8 @@ export type {
22
22
  FolderMetaFactory,
23
23
  } from "./core/define-meta.ts";
24
24
  export type { UIStrings } from "./core/i18n-ui.ts";
25
+ export type { BlumeConfig } from "./core/config-input.ts";
25
26
  export type {
26
- BlumeConfig,
27
27
  FolderMeta,
28
28
  HydrationMode,
29
29
  ResolvedConfig,
@@ -46,15 +46,16 @@ type HastPlugin = NonNullable<
46
46
 
47
47
  /**
48
48
  * Hast plugins enabled by config. Inline `` `code`{:lang} `` highlighting is
49
- * opt-in; self-linking heading anchors (`<h2>`–`<h6>` wrapped in an `<a>` to
50
- * their own id) are on unless `markdown.headingAnchors` is `false`. Inline code
51
- * runs first so the anchor wrap re-refs already-highlighted code.
49
+ * always on: it only fires on an explicit trailing `{:lang}` marker, so plain
50
+ * inline code is untouched and there's nothing to opt out of. Self-linking
51
+ * heading anchors (`<h2>`–`<h6>` wrapped in an `<a>` to their own id) are on
52
+ * unless `markdown.headingAnchors` is `false`. Inline code runs first so the
53
+ * anchor wrap re-refs already-highlighted code.
52
54
  */
53
55
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
54
- const plugins: HastPlugin[] = [];
55
- if (options.inline) {
56
- plugins.push(inlineCodeHighlightPlugin() as unknown as HastPlugin);
57
- }
56
+ const plugins: HastPlugin[] = [
57
+ inlineCodeHighlightPlugin() as unknown as HastPlugin,
58
+ ];
58
59
  if (options.headingAnchors !== false) {
59
60
  plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
60
61
  }
@@ -204,8 +205,6 @@ export interface BlumeMarkdownOptions {
204
205
  * On unless explicitly `false`.
205
206
  */
206
207
  headingAnchors?: boolean;
207
- /** Highlight inline `` `code`{:lang} `` snippets (`markdown.code.inline`). */
208
- inline?: boolean;
209
208
  }
210
209
 
211
210
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
@@ -215,38 +214,37 @@ export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
215
214
  hastPlugins: blumeHastPlugins(options),
216
215
  });
217
216
 
218
- export interface BlumeMdxOptions extends BlumeMarkdownOptions {
219
- /** Enable KaTeX math parsing and rendering. */
220
- math?: boolean;
221
- }
217
+ export type BlumeMdxOptions = BlumeMarkdownOptions;
222
218
 
223
219
  /**
224
220
  * Sätteri MDX processor: Blume's feature set plus the MDAST plugins that target
225
221
  * components — `package-install` → package-manager tabs, `:::note` →
226
- * `<Callout>`, and ` ```mermaid ` → a `<blume-mermaid>` element. Used as the
227
- * `processor` for `@astrojs/mdx` so these apply to
228
- * `.mdx` only (plain `.md` uses {@link blumeMarkdownProcessor}). Math is opt-in
229
- * via config since `$` is common in prose and code.
222
+ * `<Callout>`, ` ```mermaid ` → a `<blume-mermaid>` element, and block math
223
+ * (`$$…$$`) the `<Math>` component. Used as the `processor` for
224
+ * `@astrojs/mdx` so these apply to `.mdx` only (plain `.md` uses
225
+ * {@link blumeMarkdownProcessor}).
226
+ *
227
+ * Math is always on but block-only: `singleDollarTextMath: false` keeps a bare
228
+ * `$` (currency, shell, code) as literal text and only parses `$$…$$`. The
229
+ * generated runtime imports the `<Math>` component (and KaTeX's stylesheet) only
230
+ * when content actually uses `$$`, so a math-free site ships no KaTeX CSS.
230
231
  *
231
232
  * The plugins are modeled with minimal structural types; bridge them to
232
233
  * Satteri's full `MdastPlugin` type at this single boundary.
233
234
  */
234
- export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) => {
235
- const plugins: unknown[] = [
236
- packageInstallPlugin(),
237
- directiveToCalloutPlugin(),
238
- mermaidPlugin(),
239
- ];
240
- if (options.math) {
241
- plugins.push(mathPlugin());
242
- }
243
- return satteri({
235
+ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
236
+ satteri({
244
237
  features: {
245
238
  ...FEATURES,
246
239
  directive: true,
247
- ...(options.math ? { math: true } : {}),
240
+ // Block-only: `$$…$$` parses, a bare `$` stays literal text.
241
+ math: { singleDollarTextMath: false },
248
242
  },
249
243
  hastPlugins: blumeHastPlugins(options),
250
- mdastPlugins: plugins as unknown as MdastPlugin[],
244
+ mdastPlugins: [
245
+ packageInstallPlugin(),
246
+ directiveToCalloutPlugin(),
247
+ mermaidPlugin(),
248
+ mathPlugin(),
249
+ ] as unknown as MdastPlugin[],
251
250
  });
252
- };
@@ -8,8 +8,9 @@ interface MathNode extends MdastNode {
8
8
  /**
9
9
  * Satteri MDAST plugin that turns math nodes into Blume's `<Math>` component,
10
10
  * which renders them with KaTeX at build time. Block math (`$$…$$`) becomes a
11
- * block element; inline math (`$…$`) stays inline. Only active when math is
12
- * enabled in config, so `$` is otherwise left as literal text.
11
+ * block element. Blume runs the parser block-only (`singleDollarTextMath:
12
+ * false`), so a bare `$` stays literal and no `inlineMath` nodes are produced;
13
+ * the `inlineMath` visitor remains as a harmless safety net.
13
14
  */
14
15
  export const mathPlugin = () => ({
15
16
  inlineMath(node: MathNode, ctx: MdastVisitorContext) {
@@ -12,6 +12,7 @@ import {
12
12
  buildRuntimeData,
13
13
  collectStaged,
14
14
  detectNeedsReact,
15
+ detectUsesMath,
15
16
  } from "../astro/generate.ts";
16
17
  import { discoverIslands } from "../astro/islands.ts";
17
18
  import { customOgRoutes, discoverPages, routeIsTaken } from "../astro/pages.ts";
@@ -102,19 +103,25 @@ export const eject = async (root: string): Promise<string[]> => {
102
103
  const exportPdf = config.export.pdf;
103
104
  const exportEpub = config.export.epub;
104
105
 
105
- const [pages, needsReactRaw, userTheme, rawMarkdown, islands, examples] =
106
- await Promise.all([
107
- context.pagesRoot
108
- ? discoverPages(context.pagesRoot)
109
- : Promise.resolve([]),
110
- detectNeedsReact(root),
111
- context.themeFile
112
- ? readFile(context.themeFile, "utf-8")
113
- : Promise.resolve(""),
114
- buildRawMarkdown(project),
115
- discoverIslands(root),
116
- discoverExamples(root, config.examples),
117
- ]);
106
+ const [
107
+ pages,
108
+ needsReactRaw,
109
+ usesMath,
110
+ userTheme,
111
+ rawMarkdown,
112
+ islands,
113
+ examples,
114
+ ] = await Promise.all([
115
+ context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
116
+ detectNeedsReact(root),
117
+ detectUsesMath(root),
118
+ context.themeFile
119
+ ? readFile(context.themeFile, "utf-8")
120
+ : Promise.resolve(""),
121
+ buildRawMarkdown(project),
122
+ discoverIslands(root),
123
+ discoverExamples(root, config.examples),
124
+ ]);
118
125
  // Island/example frameworks drive which Astro renderers the ejected config
119
126
  // wires in; React also switches on for project `.tsx`/`.jsx` and Ask AI.
120
127
  const frameworks = new Set<string>([
@@ -192,7 +199,7 @@ export const eject = async (root: string): Promise<string[]> => {
192
199
  askEnabled,
193
200
  exportEpub,
194
201
  exportPdf,
195
- mathEnabled: config.markdown.math,
202
+ mathEnabled: usesMath,
196
203
  needsReact,
197
204
  }),
198
205
  path: join(srcDir, "pages", "[...slug].astro"),
@@ -435,6 +435,10 @@ blume-diff {
435
435
  font-size: 0.8125rem;
436
436
  }
437
437
 
438
+ .prose :where(td, th) :not(pre) > code {
439
+ white-space: nowrap;
440
+ }
441
+
438
442
  blume-tabs pre,
439
443
  .not-prose > div > pre {
440
444
  background: var(--blume-code-background);
@@ -610,9 +614,9 @@ pre:has(.line.focused):hover .line:not(.focused) {
610
614
  content: none;
611
615
  }
612
616
 
613
- /* Inline code highlighting (markdown.code.inline): Shiki colors the tokens of a
614
- \`code\`{:lang} snippet via the same dual-theme CSS variables as fenced blocks,
615
- keeping the inline pill background. */
617
+ /* Inline code highlighting: Shiki colors the tokens of a \`code\`{:lang} snippet
618
+ via the same dual-theme CSS variables as fenced blocks, keeping the inline
619
+ pill background. Always on — it only fires on the trailing {:lang} marker. */
616
620
  .prose code.blume-inline-code span {
617
621
  color: var(--shiki-light);
618
622
  }