blume 0.6.0 → 0.6.2

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.
Files changed (65) hide show
  1. package/dist/cli/index.js +6070 -5718
  2. package/dist/cli/index.js.map +41 -40
  3. package/dist/types/core/config-input.d.ts +749 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/schema.d.ts +10 -27
  6. package/dist/types/core/sources/types.d.ts +6 -0
  7. package/dist/types/index.d.ts +2 -1
  8. package/docs/advanced/changelog.mdx +10 -2
  9. package/docs/configuration/index.mdx +0 -2
  10. package/docs/content/syntax.mdx +4 -8
  11. package/package.json +1 -1
  12. package/src/astro/generate.ts +59 -24
  13. package/src/astro/markdown-negotiation.ts +12 -3
  14. package/src/astro/templates.ts +94 -12
  15. package/src/cli/commands/build.ts +26 -1
  16. package/src/cli/commands/dev.ts +30 -14
  17. package/src/cli/commands/doctor.ts +35 -7
  18. package/src/cli/commands/sync.ts +14 -2
  19. package/src/cli/dev-lock.ts +40 -10
  20. package/src/cli/env.ts +5 -1
  21. package/src/components/content/Update.astro +12 -2
  22. package/src/components/content/changelog-element.ts +62 -0
  23. package/src/components/islands/ask-ai.tsx +3 -1
  24. package/src/components/islands/hooks.ts +5 -1
  25. package/src/components/layout/Header.astro +10 -2
  26. package/src/components/layout/NavSelector.astro +5 -3
  27. package/src/components/layout/PageLayout.astro +2 -1
  28. package/src/components/layout/ReferenceLayout.astro +1 -0
  29. package/src/components/layout/RootLayout.astro +47 -10
  30. package/src/components/layout/Search.astro +8 -3
  31. package/src/components/layout/nav-utils.ts +7 -3
  32. package/src/core/config-input.ts +923 -0
  33. package/src/core/config.ts +126 -3
  34. package/src/core/i18n.ts +6 -5
  35. package/src/core/links.ts +16 -1
  36. package/src/core/meta.ts +112 -52
  37. package/src/core/navigation.ts +15 -5
  38. package/src/core/project-graph.ts +68 -2
  39. package/src/core/schema.ts +8 -14
  40. package/src/core/sources/assets.ts +21 -5
  41. package/src/core/sources/cache.ts +19 -1
  42. package/src/core/sources/github-releases.ts +9 -3
  43. package/src/core/sources/mdx-remote.ts +14 -4
  44. package/src/core/sources/normalize.ts +13 -1
  45. package/src/core/sources/notion.ts +43 -7
  46. package/src/core/sources/resolve.ts +44 -1
  47. package/src/core/sources/sanity.ts +9 -3
  48. package/src/core/sources/types.ts +6 -0
  49. package/src/deploy/adapter-output.ts +82 -0
  50. package/src/deploy/rss.ts +3 -1
  51. package/src/index.ts +1 -1
  52. package/src/markdown/code-title.ts +11 -4
  53. package/src/markdown/index.ts +28 -30
  54. package/src/markdown/math.ts +3 -2
  55. package/src/markdown/package-commands.ts +13 -0
  56. package/src/og/card.ts +3 -1
  57. package/src/openapi/model.ts +2 -1
  58. package/src/openapi/parse.ts +9 -1
  59. package/src/openapi/references.ts +11 -1
  60. package/src/openapi/render-mdx.ts +30 -3
  61. package/src/openapi/source.ts +3 -1
  62. package/src/registry/eject.ts +21 -14
  63. package/src/search/documents.ts +4 -1
  64. package/src/theme/entry.ts +10 -3
  65. package/src/theme/icons.ts +7 -11
@@ -1,8 +1,131 @@
1
- import type { BlumeConfig, ResolvedConfig } from "./schema.ts";
1
+ import type { BlumeConfig } from "./config-input.ts";
2
+ import type { ResolvedConfig } from "./schema.ts";
2
3
  import type { Diagnostic } from "./types.ts";
3
4
  /**
4
- * Identity helper for authoring `blume.config.ts`. Exists for type inference
5
- * and a stable future home for plugin hooks; it does not transform input.
5
+ * Define a Blume site's configuration with full type-checking and editor
6
+ * autocomplete. Place the call in `blume.config.ts` at your project root and
7
+ * `export default` the result:
8
+ *
9
+ * ```ts
10
+ * import { defineConfig } from "blume";
11
+ *
12
+ * export default defineConfig({
13
+ * title: "Acme Docs",
14
+ * description: "Everything you need to build with Acme.",
15
+ * });
16
+ * ```
17
+ *
18
+ * Every field is optional — an empty `defineConfig({})` produces a working
19
+ * site from the Markdown/MDX in your `docs/` directory. Configure only what you
20
+ * want to change; sensible defaults fill in the rest.
21
+ *
22
+ * This is an identity helper: it returns its input unchanged and exists purely
23
+ * for type inference (and as a stable home for future plugin hooks). The object
24
+ * is validated against the Blume schema when the CLI loads it.
25
+ *
26
+ * ## Top-level fields
27
+ *
28
+ * **Site identity**
29
+ * - `title` — site title, shown in the header, `<title>`, and OG images.
30
+ * Defaults to `"Documentation"`.
31
+ * - `description` — default meta description, used where a page sets none.
32
+ * - `logo` — brand mark. A string is an image path/URL; the object form splits
33
+ * an `image` mark from wordmark `text` and can override the brand `href`.
34
+ * - `banner` — site-wide announcement bar; a string, or `{ content, link,
35
+ * dismissible }`.
36
+ *
37
+ * **Content & navigation**
38
+ * - `content` — where content lives (`root`, defaults to `docs`) and pluggable
39
+ * `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
40
+ * custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
41
+ * implicit filesystem source.
42
+ * - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
43
+ * switchers), pinned `featured` links, and the `repo` link toggle. Omit
44
+ * `sidebar` to generate it from the content tree.
45
+ * - `redirects` — `{ from, to, status }` rules (301 by default).
46
+ * - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
47
+ * and the header repo link.
48
+ *
49
+ * **Appearance**
50
+ * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
51
+ * `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
52
+ * - `markdown` — `code` (language icons, inline highlighting, line wrap),
53
+ * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
54
+ * - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
55
+ * - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
56
+ * - `feedback` — the per-page "Was this helpful?" widget (on by default).
57
+ * - `export` — reader-facing PDF/EPUB export actions (off by default).
58
+ *
59
+ * **Reference docs**
60
+ * - `openapi` — native OpenAPI reference: one real page per operation, woven
61
+ * into the sidebar and search. Point `sources`/`spec` at your spec.
62
+ * - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
63
+ *
64
+ * **Search & AI**
65
+ * - `search` — search backend `provider` (`orama` by default; `pagefind`,
66
+ * `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
67
+ * credential block.
68
+ * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
69
+ * (emit `llms.txt`).
70
+ * - `mcp` — expose the docs as an MCP server for connecting agents.
71
+ *
72
+ * **SEO, feeds & analytics**
73
+ * - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
74
+ * JSON-LD, `agentReadability`, and robots `contentSignals`.
75
+ * - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
76
+ * GA, …).
77
+ *
78
+ * **Deployment & i18n**
79
+ * - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
80
+ * `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
81
+ * (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
82
+ * Cloudflare from the platform env.
83
+ * - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
84
+ * (`dir` vs filename `dot` suffix), and per-locale UI overrides.
85
+ *
86
+ * - `examples` — where `<Component path>` previews resolve their source from
87
+ * (defaults to `examples/`; supports a glob for colocated registries).
88
+ *
89
+ * @example Zero-config — just render the Markdown under `docs/`.
90
+ * ```ts
91
+ * export default defineConfig({});
92
+ * ```
93
+ *
94
+ * @example A production docs site with theming, search, and deployment.
95
+ * ```ts
96
+ * export default defineConfig({
97
+ * title: "Acme Docs",
98
+ * description: "Build faster with Acme.",
99
+ * logo: { image: "/logo.svg", text: "Acme" },
100
+ * github: { owner: "acme", repo: "acme" },
101
+ * theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
102
+ * navigation: {
103
+ * tabs: [
104
+ * { label: "Guides", path: "/guides" },
105
+ * { label: "API", path: "/api" },
106
+ * ],
107
+ * },
108
+ * search: { provider: "orama" },
109
+ * deployment: { site: "https://docs.acme.com", adapter: "vercel" },
110
+ * });
111
+ * ```
112
+ *
113
+ * @example An OpenAPI reference with the Ask AI assistant enabled.
114
+ * ```ts
115
+ * export default defineConfig({
116
+ * title: "Acme API",
117
+ * openapi: {
118
+ * enabled: true,
119
+ * route: "/reference",
120
+ * sources: [{ label: "Core", spec: "./openapi.json" }],
121
+ * },
122
+ * ai: { ask: { enabled: true }, llmsTxt: true },
123
+ * });
124
+ * ```
125
+ *
126
+ * @param config - The site configuration. All fields are optional.
127
+ * @returns The same config object, typed for inference.
128
+ * @see https://useblume.dev/docs for the full configuration reference.
6
129
  */
7
130
  export declare const defineConfig: (config: BlumeConfig) => BlumeConfig;
8
131
  /** Result of loading + validating a project config. */
@@ -112,9 +112,8 @@ declare const pageMetaBaseSchema: z.ZodObject<{
112
112
  }>>;
113
113
  slug: z.ZodOptional<z.ZodString>;
114
114
  title: z.ZodOptional<z.ZodString>;
115
- type: z.ZodDefault<z.ZodString>;
115
+ type: z.ZodOptional<z.ZodString>;
116
116
  }, "strict", z.ZodTypeAny, {
117
- type: string;
118
117
  search: {
119
118
  exclude: boolean;
120
119
  boost?: number | undefined;
@@ -138,6 +137,7 @@ declare const pageMetaBaseSchema: z.ZodObject<{
138
137
  badge?: string | undefined;
139
138
  order?: number | undefined;
140
139
  };
140
+ type?: string | undefined;
141
141
  date?: string | undefined;
142
142
  title?: string | undefined;
143
143
  description?: string | undefined;
@@ -317,9 +317,8 @@ export declare const pageMetaSchema: z.ZodObject<{
317
317
  }>>;
318
318
  slug: z.ZodOptional<z.ZodString>;
319
319
  title: z.ZodOptional<z.ZodString>;
320
- type: z.ZodDefault<z.ZodString>;
320
+ type: z.ZodOptional<z.ZodString>;
321
321
  }, "strict", z.ZodTypeAny, {
322
- type: string;
323
322
  search: {
324
323
  exclude: boolean;
325
324
  boost?: number | undefined;
@@ -343,6 +342,7 @@ export declare const pageMetaSchema: z.ZodObject<{
343
342
  badge?: string | undefined;
344
343
  order?: number | undefined;
345
344
  };
345
+ type?: string | undefined;
346
346
  date?: string | undefined;
347
347
  title?: string | undefined;
348
348
  description?: string | undefined;
@@ -1708,12 +1708,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
1708
1708
  * …). On by default; recognized languages only.
1709
1709
  */
1710
1710
  icons: z.ZodDefault<z.ZodBoolean>;
1711
- /**
1712
- * Syntax-highlight inline `` `code{:lang}` `` snippets. Off by default — most
1713
- * inline code (flags, file names) reads better plain; opt a snippet in with
1714
- * a trailing `{:lang}` marker.
1715
- */
1716
- inline: z.ZodDefault<z.ZodBoolean>;
1717
1711
  /**
1718
1712
  * Wrap long lines instead of scrolling horizontally. Off by default, so
1719
1713
  * code keeps its original line breaks and overflows into a scroll area.
@@ -1721,11 +1715,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
1721
1715
  wrap: z.ZodDefault<z.ZodBoolean>;
1722
1716
  }, "strict", z.ZodTypeAny, {
1723
1717
  icons: boolean;
1724
- inline: boolean;
1725
1718
  wrap: boolean;
1726
1719
  }, {
1727
1720
  icons?: boolean | undefined;
1728
- inline?: boolean | undefined;
1729
1721
  wrap?: boolean | undefined;
1730
1722
  }>>;
1731
1723
  codeBlocks: z.ZodDefault<z.ZodObject<{
@@ -1761,15 +1753,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
1761
1753
  * opt a single image out with `data-no-zoom`.
1762
1754
  */
1763
1755
  imageZoom: z.ZodDefault<z.ZodBoolean>;
1764
- /**
1765
- * Enable LaTeX math (`$…$` inline, `$$…$$` block) rendered with KaTeX.
1766
- * Off by default since `$` is common in prose, shell, and code. MDX only.
1767
- */
1768
- math: z.ZodDefault<z.ZodBoolean>;
1769
1756
  }, "strict", z.ZodTypeAny, {
1770
1757
  code: {
1771
1758
  icons: boolean;
1772
- inline: boolean;
1773
1759
  wrap: boolean;
1774
1760
  };
1775
1761
  codeBlocks: {
@@ -1780,11 +1766,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
1780
1766
  };
1781
1767
  headingAnchors: boolean;
1782
1768
  imageZoom: boolean;
1783
- math: boolean;
1784
1769
  }, {
1785
1770
  code?: {
1786
1771
  icons?: boolean | undefined;
1787
- inline?: boolean | undefined;
1788
1772
  wrap?: boolean | undefined;
1789
1773
  } | undefined;
1790
1774
  codeBlocks?: {
@@ -1795,7 +1779,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
1795
1779
  } | undefined;
1796
1780
  headingAnchors?: boolean | undefined;
1797
1781
  imageZoom?: boolean | undefined;
1798
- math?: boolean | undefined;
1799
1782
  }>>;
1800
1783
  mcp: z.ZodDefault<z.ZodObject<{
1801
1784
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -2664,7 +2647,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2664
2647
  markdown: {
2665
2648
  code: {
2666
2649
  icons: boolean;
2667
- inline: boolean;
2668
2650
  wrap: boolean;
2669
2651
  };
2670
2652
  codeBlocks: {
@@ -2675,7 +2657,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2675
2657
  };
2676
2658
  headingAnchors: boolean;
2677
2659
  imageZoom: boolean;
2678
- math: boolean;
2679
2660
  };
2680
2661
  mcp: {
2681
2662
  enabled: boolean;
@@ -3022,7 +3003,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
3022
3003
  markdown?: {
3023
3004
  code?: {
3024
3005
  icons?: boolean | undefined;
3025
- inline?: boolean | undefined;
3026
3006
  wrap?: boolean | undefined;
3027
3007
  } | undefined;
3028
3008
  codeBlocks?: {
@@ -3033,7 +3013,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
3033
3013
  } | undefined;
3034
3014
  headingAnchors?: boolean | undefined;
3035
3015
  imageZoom?: boolean | undefined;
3036
- math?: boolean | undefined;
3037
3016
  } | undefined;
3038
3017
  mcp?: {
3039
3018
  name?: string | undefined;
@@ -3102,8 +3081,12 @@ export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
3102
3081
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
3103
3082
  /** A configured locale with display metadata. */
3104
3083
  export type LocaleConfig = z.infer<typeof localeSchema>;
3105
- /** User-authored config: the shape accepted by `defineConfig`. */
3106
- export type BlumeConfig = z.input<typeof blumeConfigSchema>;
3084
+ /**
3085
+ * User-authored config, straight off the schema. The public, hand-documented
3086
+ * authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
3087
+ * guard keeps structurally identical to this.
3088
+ */
3089
+ export type BlumeConfigInput = z.input<typeof blumeConfigSchema>;
3107
3090
  /** A configured search backend. */
3108
3091
  export type SearchProvider = (typeof searchProviders)[number];
3109
3092
  /** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
@@ -83,6 +83,12 @@ export interface ContentSource {
83
83
  readonly staged: boolean;
84
84
  /** Optional route prefix; the source's routes namespace under `/<prefix>/`. */
85
85
  readonly prefix?: string;
86
+ /**
87
+ * Resolved on-disk root, set by filesystem-backed sources only. Drives
88
+ * folder-meta discovery (scan under this root) and the docs-collection base;
89
+ * omitted by remote/CMS/staged sources that have no local tree.
90
+ */
91
+ readonly contentRoot?: string;
86
92
  /** Pull every entry. Called once per scan. */
87
93
  load: () => Promise<SourceLoadResult>;
88
94
  /** Validate the source is usable; throws a BlumeError when not. */
@@ -5,6 +5,7 @@ export type { ComponentOverride, ComponentOverrides, IslandDescriptor, } from ".
5
5
  export { defineMeta } from "./core/define-meta.ts";
6
6
  export type { FolderMetaDefinition, FolderMetaFactory, } from "./core/define-meta.ts";
7
7
  export type { UIStrings } from "./core/i18n-ui.ts";
8
- export type { BlumeConfig, FolderMeta, HydrationMode, ResolvedConfig, } from "./core/schema.ts";
8
+ export type { BlumeConfig } from "./core/config-input.ts";
9
+ export type { FolderMeta, HydrationMode, ResolvedConfig, } from "./core/schema.ts";
9
10
  export type { Diagnostic, Heading, NavNode, Navigation, NavTab, PageRecord, } from "./core/types.ts";
10
11
  export { getBlumeVersion } from "./core/version.ts";
@@ -53,14 +53,22 @@ The optional `changelog` object adds richer metadata for the timeline and feed:
53
53
 
54
54
  ## The timeline page
55
55
 
56
- Once you have at least one `type: changelog` entry, Blume generates a **`/changelog`** page automatically. Each entry renders newest-first with its date, label, and `category` tag in a left rail beside its content:
56
+ Once you have at least one `type: changelog` entry, Blume generates a **`/changelog`** page automatically. It renders as a focused, full-width timeline — no sidebar or table of contents — with each entry newest-first, showing its date, label, and `category` tag in a left rail beside its content:
57
57
 
58
- - The entry **title** becomes its label — or `v{version}` when there's no title.
58
+ - The entry **title** becomes its label — or `v{version}` when there's no title. It links to that entry's own page, so a release is both a line in the timeline and a shareable permalink.
59
59
  - The `category` renders as a tag next to the date.
60
60
  - Drafts and `sidebar.hidden` entries are skipped.
61
61
 
62
62
  The page appears only when nothing already occupies the `/changelog` route. To replace it with your own design, add a [custom page](/docs/advanced/custom-pages) at `pages/changelog.astro` — it takes over and Blume stops generating the default timeline.
63
63
 
64
+ ### Grouped by major version
65
+
66
+ When your versions follow [semver](https://semver.org) and span more than one major, Blume paginates the timeline by major version. Only the newest major line is shown, with a **Show N.x releases** button at the bottom that reveals the next-oldest major one click at a time:
67
+
68
+ - Detection is automatic — no configuration. It kicks in only when every listed release parses as `major.minor.patch` and there is more than one major; otherwise the timeline stays flat.
69
+ - It tolerates the scoped tags monorepos publish, so `pkg@2.0.0` groups under `2.x` and `pkg@1.4.0` under `1.x`.
70
+ - It's progressive enhancement: every release is still in the page's HTML (and its RSS feed and search index), so readers without JavaScript — and crawlers — see the complete history. The button only collapses older majors once the page hydrates.
71
+
64
72
  ## From GitHub Releases
65
73
 
66
74
  Rather than authoring entries by hand, point the built-in [`github-releases` source](/docs/content/sources#github-releases) at a repo and every release becomes a `type: changelog` entry — the same timeline and feed, fed straight from the releases you already publish:
@@ -49,11 +49,9 @@ export default defineConfig({
49
49
  // Markdown features
50
50
  markdown: {
51
51
  imageZoom: true,
52
- math: false,
53
52
  code: {
54
53
  icons: true, // language icon in the code-block header
55
54
  wrap: false, // wrap long lines instead of scrolling
56
- inline: false, // highlight inline `code{:lang}` snippets
57
55
  },
58
56
  },
59
57
 
@@ -139,7 +139,7 @@ export default defineConfig({
139
139
  ```
140
140
  ````
141
141
 
142
- Inline code can be highlighted too: add a `{:lang}` marker inside a backtick span and it's colored like a tiny code block — `useState(){:js}` or `T extends object{:ts}`. Turn it on with `markdown: { code: { inline: true } }`.
142
+ Inline code can be highlighted too: add a `{:lang}` marker inside a backtick span and it's colored like a tiny code block — `useState(){:js}` or `T extends object{:ts}`. It only kicks in when you add the marker, so plain inline code stays untouched — nothing to switch on.
143
143
 
144
144
  ### Line numbers
145
145
 
@@ -372,24 +372,20 @@ The names `caution`, `error`, `important`, and `warn` are accepted as aliases fo
372
372
 
373
373
  ## Math
374
374
 
375
- Render LaTeX with KaTeX for formulas in prose or as centered blocks — useful for math-heavy or scientific docs. Inline math goes in `$…$`; block math in `$$…$$`.
376
-
377
- The Pythagorean theorem is $a^2 + b^2 = c^2$.
375
+ Render LaTeX with KaTeX as centered blocks — useful for math-heavy or scientific docs. Wrap a formula in `$$…$$`:
378
376
 
379
377
  $$
380
378
  \int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}
381
379
  $$
382
380
 
383
381
  ```md
384
- The Pythagorean theorem is $a^2 + b^2 = c^2$.
385
-
386
382
  $$
387
- \int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}
383
+ a^2 + b^2 = c^2
388
384
  $$
389
385
  ```
390
386
 
391
387
  :::note
392
- Math is opt-in because `$` is common in prose and code. Enable it with `markdown: { math: true }` in `blume.config.ts`.
388
+ Math is block-only and on automatically write `$$…$$` and it renders; write none and KaTeX's stylesheet never ships. There's no inline `$…$` math: a lone `$` (currency, shell variables, code) is always left as literal text, so there's no delimiter to escape and no setting to toggle. Math is an MDX-only feature.
393
389
  :::
394
390
 
395
391
  ## Smart punctuation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -33,6 +33,7 @@ import { validateNavTargets } from "../core/nav-diagnostics.ts";
33
33
  import { packageRoot } from "../core/package-root.ts";
34
34
  import type { BlumeProject } from "../core/project-graph.ts";
35
35
  import type { ResolvedConfig } from "../core/schema.ts";
36
+ import { resolveDocsCollection } from "../core/sources/resolve.ts";
36
37
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
37
38
  import type { Navigation } from "../core/types.ts";
38
39
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
@@ -330,6 +331,26 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
330
331
  return matches.length > 0;
331
332
  };
332
333
 
334
+ /**
335
+ * Detect whether the project authors block math (`$$…$$`) in any `.mdx`. Drives
336
+ * whether the generated runtime imports the `<Math>` component and KaTeX's
337
+ * stylesheet, so a math-free site ships no KaTeX CSS. Math parsing itself is
338
+ * always on but block-only, so a literal `$$` in source is a necessary
339
+ * condition — no false negatives. A stray `$$` (e.g. inside a code fence) merely
340
+ * over-includes the idempotent import, which is harmless.
341
+ */
342
+ export const detectUsesMath = async (root: string): Promise<boolean> => {
343
+ const files = await glob(["**/*.mdx"], {
344
+ cwd: root,
345
+ ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
346
+ onlyFiles: true,
347
+ });
348
+ const contents = await Promise.all(
349
+ files.map((file) => readOptional(join(root, file)))
350
+ );
351
+ return contents.some((content) => content.includes("$$"));
352
+ };
353
+
333
354
  const writeIfChanged = async (
334
355
  path: string,
335
356
  content: string
@@ -722,10 +743,14 @@ interface McpPlan {
722
743
 
723
744
  /**
724
745
  * Decide whether (and how) to generate the MCP server. Skipped — with a
725
- * warning — when a content page already occupies its route, so the user's page
726
- * keeps working.
746
+ * warning — when a content page or a custom `.astro` page already occupies its
747
+ * route, so the user's page keeps working instead of colliding.
727
748
  */
728
- const planMcp = (project: BlumeProject, srcDir: string): McpPlan => {
749
+ const planMcp = (
750
+ project: BlumeProject,
751
+ srcDir: string,
752
+ userPages: { pattern: string }[]
753
+ ): McpPlan => {
729
754
  const { config } = project;
730
755
  const { route } = config.mcp;
731
756
  const dir = join(srcDir, "blume-mcp");
@@ -740,11 +765,11 @@ const planMcp = (project: BlumeProject, srcDir: string): McpPlan => {
740
765
  if (!config.mcp.enabled) {
741
766
  return base;
742
767
  }
743
- if (project.graph.pages.some((page) => page.route === route)) {
768
+ if (routeIsTaken(userPages, project.graph.pages, route)) {
744
769
  return {
745
770
  ...base,
746
771
  warnings: [
747
- `MCP server route "${route}" is already used by a content page; the MCP server was not generated. Set a different "mcp.route" in blume.config.ts.`,
772
+ `MCP server route "${route}" is already used by a content or custom page; the MCP server was not generated. Set a different "mcp.route" in blume.config.ts.`,
748
773
  ],
749
774
  };
750
775
  }
@@ -858,9 +883,13 @@ export interface GenerateResult {
858
883
  * `type: changelog` entries — or when a release-backed changelog source is
859
884
  * configured, so its route (and any nav tab pointing at it) still resolves to an
860
885
  * empty timeline on a build where the source could not be fetched (e.g. CI
861
- * without a token). Skipped when a user content page already owns `/changelog`.
886
+ * without a token). Skipped when a user content page or a custom `.astro` page
887
+ * already owns `/changelog`.
862
888
  */
863
- const shouldGenerateChangelog = (project: BlumeProject): boolean => {
889
+ const shouldGenerateChangelog = (
890
+ project: BlumeProject,
891
+ userPages: { pattern: string }[]
892
+ ): boolean => {
864
893
  const hasChangelog = project.graph.pages.some(
865
894
  (page) =>
866
895
  page.contentType === "changelog" &&
@@ -869,10 +898,10 @@ const shouldGenerateChangelog = (project: BlumeProject): boolean => {
869
898
  const hasChangelogSource = (project.config.content.sources ?? []).some(
870
899
  (source) => source.type === "github-releases"
871
900
  );
872
- const changelogRouteTaken = project.graph.pages.some(
873
- (page) => page.route === "/changelog"
901
+ return (
902
+ (hasChangelog || hasChangelogSource) &&
903
+ !routeIsTaken(userPages, project.graph.pages, "/changelog")
874
904
  );
875
- return (hasChangelog || hasChangelogSource) && !changelogRouteTaken;
876
905
  };
877
906
 
878
907
  /**
@@ -933,16 +962,21 @@ export const generateRuntime = async (
933
962
  const askEnabled = config.ai.ask?.enabled ?? false;
934
963
  const exportPdf = config.export.pdf;
935
964
  const exportEpub = config.export.epub;
936
- const [pages, detectedReact, userTheme, islandDiscovery, exampleDiscovery] =
937
- await Promise.all([
938
- context.pagesRoot
939
- ? discoverPages(context.pagesRoot)
940
- : Promise.resolve([]),
941
- detectNeedsReact(context.root),
942
- readOptional(context.themeFile),
943
- discoverIslands(context.root),
944
- discoverExamples(context.root, config.examples),
945
- ]);
965
+ const [
966
+ pages,
967
+ detectedReact,
968
+ usesMath,
969
+ userTheme,
970
+ islandDiscovery,
971
+ exampleDiscovery,
972
+ ] = await Promise.all([
973
+ context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
974
+ detectNeedsReact(context.root),
975
+ detectUsesMath(context.root),
976
+ readOptional(context.themeFile),
977
+ discoverIslands(context.root),
978
+ discoverExamples(context.root, config.examples),
979
+ ]);
946
980
  // Statically analyze `components.ts` overrides (never executed): drives the
947
981
  // `islands` group, hydration on layout/mdx overrides, string-path resolution,
948
982
  // and the "framework component with no client mode" diagnostic.
@@ -973,7 +1007,7 @@ export const generateRuntime = async (
973
1007
  // The hosted MCP server. The `.well-known` discovery docs are injected as
974
1008
  // prerendered routes alongside user pages; the server endpoint itself is a
975
1009
  // normal (server-rendered) page written by `writeMcpFiles`.
976
- const mcp = planMcp(project, srcDir);
1010
+ const mcp = planMcp(project, srcDir, pages);
977
1011
  pages.push(...mcp.discoveryPages);
978
1012
 
979
1013
  // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
@@ -1016,6 +1050,7 @@ export const generateRuntime = async (
1016
1050
  write(
1017
1051
  join(srcDir, "content.config.ts"),
1018
1052
  contentConfigTemplate({
1053
+ collection: resolveDocsCollection(config, context),
1019
1054
  config,
1020
1055
  context,
1021
1056
  filesystem: hasFilesystemSource,
@@ -1028,7 +1063,7 @@ export const generateRuntime = async (
1028
1063
  askEnabled,
1029
1064
  exportEpub,
1030
1065
  exportPdf,
1031
- mathEnabled: config.markdown.math,
1066
+ mathEnabled: usesMath,
1032
1067
  needsReact,
1033
1068
  })
1034
1069
  ),
@@ -1107,7 +1142,7 @@ export const generateRuntime = async (
1107
1142
  }
1108
1143
 
1109
1144
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1110
- if (shouldGenerateChangelog(project)) {
1145
+ if (shouldGenerateChangelog(project, pages)) {
1111
1146
  await write(
1112
1147
  join(srcDir, "pages", "changelog.astro"),
1113
1148
  changelogIndexTemplate({
@@ -1201,7 +1236,7 @@ export const generateRuntime = async (
1201
1236
  ...pages.map((page) => page.pattern),
1202
1237
  ...referenceTabs(config).map((tab) => tab.path),
1203
1238
  ]);
1204
- if (shouldGenerateChangelog(project)) {
1239
+ if (shouldGenerateChangelog(project, pages)) {
1205
1240
  navTargetRoutes.add("/changelog");
1206
1241
  }
1207
1242
  warnings.push(
@@ -72,11 +72,20 @@ export const markdownVariantUrl = (
72
72
  }
73
73
  }
74
74
 
75
- const pathname =
76
- path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
75
+ const trimmed = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
76
+ // Requests arrive percent-encoded while routes are stored decoded, so a
77
+ // non-ASCII route (`/ja/はじめに` requested as `/ja/%E3%81%AF…`) must be
78
+ // decoded before the lookup. Malformed sequences stay verbatim.
79
+ let pathname = trimmed;
80
+ try {
81
+ pathname = decodeURIComponent(trimmed);
82
+ } catch {
83
+ // Keep the raw path; it simply won't match a content route.
84
+ }
77
85
  if (!routes.has(pathname)) {
78
86
  return null;
79
87
  }
80
88
  const target = pathname === "/" ? "/index" : pathname;
81
- return `${prefix}${target}.md${query}`;
89
+ // Re-encode: the variant URL goes back into the request pipeline.
90
+ return `${prefix}${encodeURI(target)}.md${query}`;
82
91
  };