blume 1.1.0 → 1.1.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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli/index.js +319 -91
  3. package/dist/cli/index.js.map +18 -18
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +18 -8
  6. package/dist/types/core/schema.d.ts +38 -24
  7. package/dist/types/core/types.d.ts +14 -0
  8. package/dist/types/markdown/themes.d.ts +21 -0
  9. package/docs/advanced/changelog.mdx +1 -1
  10. package/docs/configuration/ai.mdx +2 -2
  11. package/docs/configuration/index.mdx +1 -1
  12. package/docs/configuration/seo.mdx +16 -0
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/navigation.mdx +4 -0
  15. package/docs/content/syntax.mdx +14 -0
  16. package/docs/reference/cli.mdx +1 -1
  17. package/docs/reference/frontmatter.mdx +2 -0
  18. package/package.json +2 -1
  19. package/src/ai/component-markdown.ts +39 -11
  20. package/src/ai/llms.ts +4 -2
  21. package/src/ai/markdown.ts +5 -1
  22. package/src/astro/generate.ts +121 -32
  23. package/src/astro/pages.ts +21 -5
  24. package/src/astro/templates.ts +70 -7
  25. package/src/audit/checks/llms.ts +4 -1
  26. package/src/cli/commands/build.ts +13 -1
  27. package/src/cli/prepare.ts +10 -2
  28. package/src/components/content/Component.astro +99 -6
  29. package/src/components/content/diff.ts +53 -4
  30. package/src/components/layout/RootLayout.astro +9 -1
  31. package/src/components/layout/nav-utils.ts +18 -7
  32. package/src/core/config-input.ts +18 -8
  33. package/src/core/diagnostics.ts +2 -0
  34. package/src/core/graph.ts +23 -4
  35. package/src/core/navigation.ts +180 -21
  36. package/src/core/project-graph.ts +54 -28
  37. package/src/core/schema.ts +39 -2
  38. package/src/core/sources/github-releases.ts +65 -2
  39. package/src/core/types.ts +14 -0
  40. package/src/markdown/index.ts +3 -0
  41. package/src/markdown/inline-code.ts +1 -1
  42. package/src/markdown/themes.ts +7 -2
  43. package/src/markdown/twoslash.ts +60 -0
  44. package/src/registry/eject.ts +3 -1
  45. /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
@@ -8,13 +8,15 @@
8
8
  * a unified patch (string or `.patch`/`.diff` file), a pair of file paths, or a
9
9
  * pair of inline strings.
10
10
  */
11
+ import { createHash } from "node:crypto";
11
12
  import { readFile } from "node:fs/promises";
12
13
 
14
+ import { registerCustomTheme } from "@pierre/diffs";
13
15
  import { preloadDiffHTML, preloadPatchDiff } from "@pierre/diffs/ssr";
14
16
  import { isAbsolute, join } from "pathe";
15
17
 
16
18
  import { DEFAULT_CODE_THEMES } from "../../markdown/themes.ts";
17
- import type { CodeThemes } from "../../markdown/themes.ts";
19
+ import type { CodeTheme, CodeThemes } from "../../markdown/themes.ts";
18
20
 
19
21
  export interface DiffOptions {
20
22
  /** Path to the "after" file, resolved relative to {@link DiffOptions.root}. */
@@ -46,6 +48,53 @@ const resolvePath = (path: string, root: string): string =>
46
48
  const readText = (path: string, root: string): Promise<string> =>
47
49
  readFile(resolvePath(path, root), "utf-8");
48
50
 
51
+ const registeredDiffThemes = new WeakMap<object, Map<string, string>>();
52
+ const registeredDiffThemeNames = new Set<string>();
53
+
54
+ /**
55
+ * Pierre accepts custom Shiki themes through its registry, while Shiki itself
56
+ * accepts the object directly. Register each configured object under a name
57
+ * derived from its content and resolved type, so registration survives
58
+ * dev-server reloads: the same theme resolves to the same name (already
59
+ * registered, so it's skipped — Pierre logs a console error on a same-name
60
+ * re-register), while an edited theme gets a fresh name instead of a stale
61
+ * entry. The memo is keyed per resolved type as well — a typeless object
62
+ * shared between both modes must not hand light mode the dark-typed
63
+ * registration.
64
+ */
65
+ const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
66
+ if (typeof theme === "string") {
67
+ return theme;
68
+ }
69
+ const type = theme.type ?? mode;
70
+ let byType = registeredDiffThemes.get(theme);
71
+ if (!byType) {
72
+ byType = new Map();
73
+ registeredDiffThemes.set(theme, byType);
74
+ }
75
+ const cached = byType.get(type);
76
+ if (cached) {
77
+ return cached;
78
+ }
79
+ const hash = createHash("sha256")
80
+ .update(JSON.stringify(theme))
81
+ .digest("hex")
82
+ .slice(0, 12);
83
+ const name = `blume-custom-${hash}-${type}`;
84
+ if (!registeredDiffThemeNames.has(name)) {
85
+ const registered = { ...theme, name, type };
86
+ registerCustomTheme(name, () => Promise.resolve(registered));
87
+ registeredDiffThemeNames.add(name);
88
+ }
89
+ byType.set(type, name);
90
+ return name;
91
+ };
92
+
93
+ const diffThemes = (themes: CodeThemes): { dark: string; light: string } => ({
94
+ dark: diffThemeName(themes.dark, "dark"),
95
+ light: diffThemeName(themes.light, "light"),
96
+ });
97
+
49
98
  /**
50
99
  * Resolve `<Diff>` inputs to a prerendered HTML string. Throws when no input
51
100
  * group is supplied or a pair is half-specified, so the component can degrade
@@ -67,7 +116,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
67
116
  if (patch !== undefined || src !== undefined) {
68
117
  const text = patch ?? (await readText(src as string, root));
69
118
  const result = await preloadPatchDiff({
70
- options: { theme },
119
+ options: { theme: diffThemes(theme) },
71
120
  patch: text,
72
121
  });
73
122
  return result.prerenderedHTML;
@@ -80,7 +129,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
80
129
  return await preloadDiffHTML({
81
130
  newFile: { contents: await readText(after, root), name: after },
82
131
  oldFile: { contents: await readText(before, root), name: before },
83
- options: { theme },
132
+ options: { theme: diffThemes(theme) },
84
133
  });
85
134
  }
86
135
 
@@ -91,7 +140,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
91
140
  return await preloadDiffHTML({
92
141
  newFile: { contents: newText, lang, name: "snippet" },
93
142
  oldFile: { contents: old, lang, name: "snippet" },
94
- options: { disableFileHeader: true, theme },
143
+ options: { disableFileHeader: true, theme: diffThemes(theme) },
95
144
  });
96
145
  }
97
146
 
@@ -306,7 +306,15 @@ const mcpUrl =
306
306
  // Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
307
307
  // active tab's section, so a multi-section site drills each tab into its own
308
308
  // pages. Without tabs — or on a route under none — this is the full sidebar.
309
- const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
309
+ // `navigation.root` keeps the root-tab check in the tabs' localized/based
310
+ // path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
311
+ // root tab as a section tab.
312
+ const sidebar = sidebarForRoute(
313
+ navigation.sidebar,
314
+ navigation.tabs,
315
+ page.route,
316
+ navigation.root
317
+ );
310
318
  const activeTab = activeTabForRoute(navigation.tabs, page.route);
311
319
  const crumbs = findBreadcrumbs(sidebar, page.route);
312
320
  const { prev, next } = getPagination(flattenPages(sidebar), page.route);
@@ -135,12 +135,16 @@ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean => {
135
135
  * so a root/un-tabbed route lists only the pages outside every tab's section
136
136
  * instead of duplicating each tab as a sidebar group. A container left empty by
137
137
  * this pruning is dropped too, so no bare heading is stranded. The root tab
138
- * (`/`) spans everything, so it never removes anything.
138
+ * spans everything, so it never removes anything.
139
139
  */
140
- const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
140
+ const withoutTabSections = (
141
+ nodes: NavNode[],
142
+ tabs: NavTab[],
143
+ root: string
144
+ ): NavNode[] => {
141
145
  const tabPaths = new Set<string>();
142
146
  for (const tab of tabs) {
143
- if (tab.path !== "/") {
147
+ if (tab.path !== root) {
144
148
  tabPaths.add(tab.path);
145
149
  }
146
150
  }
@@ -174,9 +178,15 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
174
178
  * under one tab shows only that tab's group — so a multi-section site (e.g.
175
179
  * Adapters / API / AI tabs) drills each tab into its own pages instead of one
176
180
  * global tree, the way Fumadocs' root folders do. On a route under no tab (or
177
- * the root `/` tab), the tab-owned groups are hidden so the root sidebar shows
181
+ * the root tab), the tab-owned groups are hidden so the root sidebar shows
178
182
  * only pages that don't belong to a tab.
179
183
  *
184
+ * `root` is the tree root in the tabs' own path space (`Navigation.root`) —
185
+ * tab paths arrive localized and based, so under i18n or a `basePath` the root
186
+ * tab is `/en` or `/docs`, not `/`. Comparing against `/` would misread it as
187
+ * a section tab: a root-level `(group)` folder's path is exactly that prefix,
188
+ * so the sidebar collapsed to that one group (or blanked entirely).
189
+ *
180
190
  * When a matched tab owns no sidebar group — a standalone page like the
181
191
  * generated changelog timeline (`/changelog`), or a tab whose source produced
182
192
  * no pages — the sidebar is empty. It must not fall back to the full tree: that
@@ -187,13 +197,14 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
187
197
  export const sidebarForRoute = (
188
198
  sidebar: NavNode[],
189
199
  tabs: NavTab[],
190
- route: string
200
+ route: string,
201
+ root = "/"
191
202
  ): NavNode[] => {
192
203
  const tab = activeTabForRoute(tabs, route);
193
- if (tab && tab.path !== "/") {
204
+ if (tab && tab.path !== root) {
194
205
  return sectionChildren(sidebar, tab.path) ?? [];
195
206
  }
196
- const scoped = withoutTabSections(sidebar, tabs);
207
+ const scoped = withoutTabSections(sidebar, tabs, root);
197
208
  return scoped.length > 0 ? scoped : sidebar;
198
209
  };
199
210
 
@@ -1,6 +1,7 @@
1
1
  import type { z } from "zod";
2
2
 
3
3
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
4
+ import type { CodeTheme } from "../markdown/themes.ts";
4
5
  import type { FontSlug } from "../theme/fonts.ts";
5
6
  import type {
6
7
  blumeConfigSchema,
@@ -568,9 +569,11 @@ export interface AiConfig {
568
569
  /**
569
570
  * Markdown serializers for custom components in agent-facing output (the
570
571
  * `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each
571
- * receives the component's statically-evaluated `props` and downleveled
572
- * `children` and returns replacement Markdown — or `null` to leave the JSX
573
- * verbatim. A same-name entry replaces a built-in serializer.
572
+ * receives the component's statically-evaluated `props` (with the page's
573
+ * `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
574
+ * downleveled `children`, and the page's `frontmatter` data, and returns
575
+ * replacement Markdown — or `null` to leave the JSX verbatim. A same-name
576
+ * entry replaces a built-in serializer.
574
577
  *
575
578
  * These live in `blume.config.ts` (which is executed at build time), not in
576
579
  * `components.tsx` (which is only statically analyzed, never run).
@@ -768,6 +771,13 @@ export interface OgConfig {
768
771
  logo?: string;
769
772
  /** Optional generated-card colors. */
770
773
  palette?: OgPaletteConfig;
774
+ /**
775
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
776
+ * A custom page has no frontmatter to read, so its card is otherwise titled
777
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
778
+ * Content pages always take their card headline from the page title.
779
+ */
780
+ titles?: Record<string, string>;
771
781
  }
772
782
 
773
783
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
@@ -839,12 +849,12 @@ export interface MarkdownConfig {
839
849
  * `` `code`{:lang} ``, `<CodeBlock>`, and `<Diff>`.
840
850
  */
841
851
  codeBlocks?: {
842
- /** Shiki theme names per color mode. */
852
+ /** Bundled Shiki theme names or inline custom Shiki themes per color mode. */
843
853
  theme?: {
844
- /** Dark-mode theme. Defaults to `github-dark`. */
845
- dark?: string;
846
- /** Light-mode theme. Defaults to `github-light`. */
847
- light?: string;
854
+ /** Dark-mode theme name or custom theme. Defaults to `github-dark`. */
855
+ dark?: CodeTheme;
856
+ /** Light-mode theme name or custom theme. Defaults to `github-light`. */
857
+ light?: CodeTheme;
848
858
  };
849
859
  };
850
860
  /**
@@ -38,12 +38,14 @@ const DOCS_PATHS: Record<string, string> = {
38
38
  BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
39
39
  BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
40
40
  BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
41
+ BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
41
42
  BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
42
43
  BLUME_META_INVALID: "/docs/content/meta",
43
44
  BLUME_META_LOAD_FAILED: "/docs/content/meta",
44
45
  BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
45
46
  BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
46
47
  BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
48
+ BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
47
49
  BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
48
50
  BLUME_NODE_VERSION: "/docs/quickstart",
49
51
  BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
package/src/core/graph.ts CHANGED
@@ -70,6 +70,7 @@ const localePagesFor = (
70
70
  if (!present.has(key)) {
71
71
  filled.push({
72
72
  ...source,
73
+ fallback: true,
73
74
  locale: code,
74
75
  route: withBasePath(basePath, localizeRoute(key, code, i18n)),
75
76
  });
@@ -85,7 +86,8 @@ const buildLocaleNavigation = (
85
86
  fallback: FallbackLocale,
86
87
  fallbackByKey: Map<string, PageRecord>,
87
88
  options: BuildContentGraphOptions,
88
- i18n: ResolvedI18nConfig
89
+ i18n: ResolvedI18nConfig,
90
+ diagnostics: Diagnostic[]
89
91
  ): Navigation => {
90
92
  // Localize internal tab paths — the tab's own and its dropdown items' — so a
91
93
  // header tab points to its in-locale route (e.g. `/docs` -> `/fr/docs`);
@@ -112,6 +114,7 @@ const buildLocaleNavigation = (
112
114
  );
113
115
  return buildNavigation(localePages, {
114
116
  basePath: options.basePath ?? "",
117
+ diagnostics,
115
118
  display: options.navigation.sidebar.display,
116
119
  featured: options.navigation.featured,
117
120
  folderMeta: options.folderMeta,
@@ -137,7 +140,8 @@ const buildLocaleNavigation = (
137
140
  const buildI18nNavigation = (
138
141
  pages: PageRecord[],
139
142
  options: BuildContentGraphOptions,
140
- i18n: ResolvedI18nConfig
143
+ i18n: ResolvedI18nConfig,
144
+ diagnostics: Diagnostic[]
141
145
  ): {
142
146
  navigation: Navigation;
143
147
  navigationByLocale: Record<string, Navigation>;
@@ -155,16 +159,30 @@ const buildI18nNavigation = (
155
159
  }
156
160
 
157
161
  // Each locale gets an independent tree, so navigation may diverge per language.
162
+ // Untranslated pages are padded into every locale from the fallback, so a tie
163
+ // in shared content would otherwise be re-reported once per locale — dedupe on
164
+ // code + file + message, which are all locale-stable for padded pages. A
165
+ // locale-specific tie names its own translated files/labels and survives.
158
166
  const navigationByLocale: Record<string, Navigation> = {};
167
+ const seen = new Set<string>();
159
168
  for (const { code } of i18n.locales) {
169
+ const localeDiagnostics: Diagnostic[] = [];
160
170
  navigationByLocale[code] = buildLocaleNavigation(
161
171
  code,
162
172
  pages,
163
173
  fallback,
164
174
  fallbackByKey,
165
175
  options,
166
- i18n
176
+ i18n,
177
+ localeDiagnostics
167
178
  );
179
+ for (const diagnostic of localeDiagnostics) {
180
+ const key = `${diagnostic.code}\n${diagnostic.file ?? ""}\n${diagnostic.message}`;
181
+ if (!seen.has(key)) {
182
+ seen.add(key);
183
+ diagnostics.push(diagnostic);
184
+ }
185
+ }
168
186
  }
169
187
  const navigation = navigationByLocale[i18n.defaultLocale] ?? {
170
188
  featured: [],
@@ -184,10 +202,11 @@ export const buildContentGraph = (
184
202
  const { i18n } = options;
185
203
 
186
204
  const { navigation, navigationByLocale } = i18n
187
- ? buildI18nNavigation(pages, options, i18n)
205
+ ? buildI18nNavigation(pages, options, i18n, diagnostics)
188
206
  : {
189
207
  navigation: buildNavigation(pages, {
190
208
  basePath: options.basePath ?? "",
209
+ diagnostics,
191
210
  display: options.navigation.sidebar.display,
192
211
  featured: options.navigation.featured,
193
212
  folderMeta: options.folderMeta,
@@ -7,6 +7,7 @@ import type {
7
7
  SidebarItemConfig,
8
8
  } from "./schema.ts";
9
9
  import type {
10
+ Diagnostic,
10
11
  FeaturedLink,
11
12
  NavNode,
12
13
  Navigation,
@@ -56,7 +57,17 @@ interface MutablePage {
56
57
  badge?: string;
57
58
  deprecated?: boolean;
58
59
  pageId: string;
60
+ /** Absolute source path (filesystem adapter only), to anchor diagnostics. */
61
+ file?: string;
59
62
  order: number;
63
+ /**
64
+ * Whether `order` reflects a deliberate authoring choice (explicit
65
+ * `sidebar.order`, a numeric filename prefix, or a folder-meta `pages` rank)
66
+ * rather than a derived value like a changelog entry's publish date — two
67
+ * changelog entries published on the same day aren't an authoring mistake,
68
+ * so they're excluded from the duplicate-order diagnostic.
69
+ */
70
+ orderIsAuthored: boolean;
60
71
  }
61
72
 
62
73
  interface MutableGroup {
@@ -110,24 +121,35 @@ const ensureGroup = (
110
121
  return group;
111
122
  };
112
123
 
113
- const pageOrder = (page: PageRecord, filename: string): number => {
124
+ const pageOrder = (
125
+ page: PageRecord,
126
+ filename: string
127
+ ): { order: number; orderIsAuthored: boolean } => {
114
128
  if (page.meta.sidebar.order !== undefined) {
115
- return page.meta.sidebar.order;
129
+ return { order: page.meta.sidebar.order, orderIsAuthored: true };
116
130
  }
117
131
  if (isIndexStem(filename.replace(extname(filename), ""))) {
118
- return Number.NEGATIVE_INFINITY;
132
+ return { order: Number.NEGATIVE_INFINITY, orderIsAuthored: false };
119
133
  }
120
134
  // Changelog entries read newest-first, matching the generated timeline. Sort
121
135
  // on the negated publish timestamp so a later date yields a smaller order
122
136
  // under the ascending comparator; undated entries fall back to filename order.
137
+ // The date is derived, not an authoring choice, so same-day entries aren't a
138
+ // duplicate-order mistake.
123
139
  if (page.contentType === "changelog") {
124
140
  const iso = page.meta.date ?? page.meta.changelog?.date;
125
141
  const time = iso ? Date.parse(iso) : Number.NaN;
126
142
  if (!Number.isNaN(time)) {
127
- return -time;
143
+ return { order: -time, orderIsAuthored: false };
128
144
  }
129
145
  }
130
- return numericOrder(filename);
146
+ // An undated changelog entry's numeric filename prefix is usually a date
147
+ // (`2024-01-05-release.md`) rather than a rank, so it is derived too.
148
+ const order = numericOrder(filename);
149
+ return {
150
+ order,
151
+ orderIsAuthored: page.contentType !== "changelog" && Number.isFinite(order),
152
+ };
131
153
  };
132
154
 
133
155
  /**
@@ -166,6 +188,9 @@ const applyFolderMeta = (
166
188
  const position = rank.get(child.key);
167
189
  if (position !== undefined) {
168
190
  child.order = position;
191
+ if (child.kind === "page") {
192
+ child.orderIsAuthored = true;
193
+ }
169
194
  }
170
195
  }
171
196
  }
@@ -178,7 +203,108 @@ const applyFolderMeta = (
178
203
  }
179
204
  };
180
205
 
181
- const sortNodes = (nodes: MutableNode[]): void => {
206
+ /**
207
+ * Warn when an index page's own frontmatter title diverges from its folder's
208
+ * explicit `meta.title`. The sidebar label and the page's own `<title>`/heading
209
+ * are resolved from two independent sources — under i18n, a translator can
210
+ * update the folder's `meta.ts` and forget the index page's own frontmatter
211
+ * (or vice versa), and a correct-looking sidebar hides the mismatch.
212
+ *
213
+ * Only fires when the page has an explicit frontmatter `title` of its own:
214
+ * when it's absent, `page.title` is derived from the first heading or the
215
+ * filename, so it almost never coincidentally matches a custom folder title —
216
+ * flagging that would be noise on exactly the plain-landing-page case this is
217
+ * least worth warning about. The root group's `meta.title` (an empty
218
+ * `folderPath`) is also skipped: nothing ever renders it as a sidebar label,
219
+ * so a mismatch there wouldn't correspond to anything visible.
220
+ *
221
+ * Fallback-filled pages are exempt: their title belongs to the fallback
222
+ * locale, so comparing it against this locale's `meta.title` would flag every
223
+ * not-yet-translated index page (once per locale) and point the suggestion at
224
+ * the fallback locale's source file, where "fixing" it would break that
225
+ * locale. The default locale's own build still checks the real page.
226
+ */
227
+ const indexTitleMismatchDiagnostic = (
228
+ page: PageRecord,
229
+ folderPath: string,
230
+ folderMeta: Map<string, FolderMeta>,
231
+ sharedMeta: Map<string, FolderMeta>,
232
+ metaPrefix: string
233
+ ): Diagnostic | undefined => {
234
+ if (!page.meta.title || page.fallback || folderPath === "") {
235
+ return undefined;
236
+ }
237
+ const meta =
238
+ folderMeta.get(metaKey(folderPath, metaPrefix)) ??
239
+ sharedMeta.get(folderPath);
240
+ if (!meta?.title || meta.title === page.title) {
241
+ return undefined;
242
+ }
243
+ return {
244
+ code: "BLUME_NAV_INDEX_TITLE_MISMATCH",
245
+ file: page.sourcePath ?? page.id,
246
+ message: `Index page "${page.navPath}" has title "${page.title}", but its folder's meta.title is "${meta.title}" — the sidebar shows the folder title while the page's own <title>/heading still say "${page.title}".`,
247
+ severity: "warning",
248
+ suggestion: `Update the page's frontmatter title to match ("${meta.title}"), or leave it if the divergence is intentional.`,
249
+ };
250
+ };
251
+
252
+ /** Whether a node's `order` reflects a deliberate authoring choice. */
253
+ const isAuthoredOrder = (node: MutableNode): boolean =>
254
+ node.kind === "group" || node.orderIsAuthored;
255
+
256
+ /**
257
+ * Warn when two sibling nodes share an explicit/numeric order (frontmatter
258
+ * `sidebar.order`, a numeric filename prefix, or folder-meta `order`) — they'd
259
+ * otherwise fall back to a silent, arbitrary alphabetical tiebreak. Nodes at
260
+ * the default fallback order (no numeric prefix, no explicit order) are
261
+ * excluded: that's the common, intentional case of "just sort alphabetically."
262
+ * So is a derived, non-authored order (e.g. two changelog entries published
263
+ * on the same day) — not an authoring mistake.
264
+ */
265
+ const duplicateOrderDiagnostics = (nodes: MutableNode[]): Diagnostic[] => {
266
+ const byOrder = new Map<number, MutableNode[]>();
267
+ for (const node of nodes) {
268
+ if (!Number.isFinite(node.order) || !isAuthoredOrder(node)) {
269
+ continue;
270
+ }
271
+ const tied = byOrder.get(node.order);
272
+ if (tied) {
273
+ tied.push(node);
274
+ } else {
275
+ byOrder.set(node.order, [node]);
276
+ }
277
+ }
278
+ const diagnostics: Diagnostic[] = [];
279
+ for (const [order, tied] of byOrder) {
280
+ if (tied.length > 1) {
281
+ const names = tied.map((node) => `"${node.label}"`);
282
+ const list =
283
+ names.length > 2
284
+ ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`
285
+ : names.join(" and ");
286
+ const verb = tied.length > 2 ? "all have" : "both have";
287
+ // Anchor the diagnostic to one tied source file so tooling can point
288
+ // somewhere concrete; the message names the rest. Folder-only ties
289
+ // (folder-meta `order`) have no single file, so `file` stays unset.
290
+ const file = tied.find(
291
+ (node): node is MutablePage => node.kind === "page"
292
+ )?.file;
293
+ diagnostics.push({
294
+ code: "BLUME_DUPLICATE_SIDEBAR_ORDER",
295
+ file,
296
+ message: `${list} ${verb} sidebar order ${order}; falling back to alphabetical order.`,
297
+ severity: "warning",
298
+ suggestion:
299
+ "Give each item a distinct sidebar.order (or folder meta order).",
300
+ });
301
+ }
302
+ }
303
+ return diagnostics;
304
+ };
305
+
306
+ const sortNodes = (nodes: MutableNode[], diagnostics: Diagnostic[]): void => {
307
+ diagnostics.push(...duplicateOrderDiagnostics(nodes));
182
308
  nodes.sort((a, b) => {
183
309
  if (a.order !== b.order) {
184
310
  return a.order - b.order;
@@ -187,7 +313,7 @@ const sortNodes = (nodes: MutableNode[]): void => {
187
313
  });
188
314
  for (const node of nodes) {
189
315
  if (node.kind === "group") {
190
- sortNodes(node.children);
316
+ sortNodes(node.children, diagnostics);
191
317
  }
192
318
  }
193
319
  };
@@ -263,20 +389,37 @@ const buildFileSystemSidebar = (
263
389
  sharedMeta: Map<string, FolderMeta>,
264
390
  metaPrefix: string,
265
391
  display: SidebarDisplay,
266
- tabPaths: Set<string>
392
+ tabPaths: Set<string>,
393
+ diagnostics: Diagnostic[] = []
267
394
  ): NavNode[] => {
268
395
  const root = createGroup("", "", "", 0);
269
396
 
270
397
  for (const page of pages) {
271
- if (page.meta.sidebar.hidden) {
272
- continue;
273
- }
274
398
  // Group by the locale-stripped path so the locale dir is not a nav group.
275
399
  const parts = page.navPath.split("/");
276
400
  const filename = parts.at(-1) ?? page.navPath;
277
401
  const stem = filename.replace(extname(filename), "");
278
402
  const dirs = parts.slice(0, -1);
279
403
 
404
+ // Checked before the hidden filter: a sidebar-hidden index page still
405
+ // renders with its own <title>, so title drift matters there just the same.
406
+ if (isIndexStem(stem)) {
407
+ const diagnostic = indexTitleMismatchDiagnostic(
408
+ page,
409
+ dirs.join("/"),
410
+ folderMeta,
411
+ sharedMeta,
412
+ metaPrefix
413
+ );
414
+ if (diagnostic) {
415
+ diagnostics.push(diagnostic);
416
+ }
417
+ }
418
+
419
+ if (page.meta.sidebar.hidden) {
420
+ continue;
421
+ }
422
+
280
423
  // Each group's URL path is the matching prefix of the page's route. navPath
281
424
  // is locale-stripped while the route may carry a locale/base prefix, so
282
425
  // align the folder segments from the right (the extra leading segments are
@@ -301,22 +444,25 @@ const buildFileSystemSidebar = (
301
444
  parent.routePath ??= `/${folderParts.slice(0, consumed).join("/")}`;
302
445
  }
303
446
 
447
+ const { order, orderIsAuthored } = pageOrder(page, filename);
304
448
  parent.children.push({
305
449
  badge: page.meta.sidebar.badge,
306
450
  deprecated: page.meta.deprecated || undefined,
307
451
  description: page.description,
452
+ file: page.sourcePath,
308
453
  icon: page.meta.sidebar.icon,
309
454
  key: segmentKey(stem),
310
455
  kind: "page",
311
456
  label: page.meta.sidebar.label ?? page.title,
312
- order: pageOrder(page, filename),
457
+ order,
458
+ orderIsAuthored,
313
459
  pageId: page.id,
314
460
  route: page.route,
315
461
  });
316
462
  }
317
463
 
318
464
  applyFolderMeta(root, folderMeta, sharedMeta, metaPrefix);
319
- sortNodes(root.children);
465
+ sortNodes(root.children, diagnostics);
320
466
  hoistPages(root.children, display === "flat");
321
467
  hoistTabSections(root.children, tabPaths, display === "flat");
322
468
  return root.children.map((child) => toNavNode(child, display));
@@ -500,6 +646,12 @@ export const buildNavigation = (
500
646
  * scoping.
501
647
  */
502
648
  localizedRoot?: string;
649
+ /**
650
+ * Sink for diagnostics produced while building the tree (duplicate sidebar
651
+ * `order` values, index-page title/folder-meta-title mismatches). Pushed
652
+ * into in place; omit to discard.
653
+ */
654
+ diagnostics?: Diagnostic[];
503
655
  }
504
656
  ): Navigation => {
505
657
  const basePath = options.basePath ?? "";
@@ -555,6 +707,15 @@ export const buildNavigation = (
555
707
  }
556
708
  }
557
709
 
710
+ // A tab pointing at the tree root spans the whole sidebar rather than one
711
+ // section, so it must not feed tab-section hoisting. `tabs` carries final
712
+ // paths (localized, then based), so the root is compared in the same space —
713
+ // a root-level `(group)` folder's routePath is exactly the based/localized
714
+ // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
715
+ // under a base, falsely scope a group named like the prefix). Carried on the
716
+ // returned navigation so render-time scoping compares in the same space too.
717
+ const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
718
+
558
719
  if (options.sidebar) {
559
720
  const sidebar = buildConfigSidebar(
560
721
  options.sidebar,
@@ -564,29 +725,27 @@ export const buildNavigation = (
564
725
  );
565
726
  return {
566
727
  featured,
728
+ root: rootTabPath,
567
729
  selectors,
568
730
  sidebar,
569
731
  tabs: withTabHrefs(tabs, sidebar),
570
732
  };
571
733
  }
572
734
 
573
- // A tab pointing at the tree root spans the whole sidebar rather than one
574
- // section, so it must not feed tab-section hoisting. `tabs` carries final
575
- // paths (localized, then based), so the root is compared in the same space —
576
- // a root-level `(group)` folder's routePath is exactly the based/localized
577
- // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
578
- // under a base, falsely scope a group named like the prefix).
579
- const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
580
735
  const sidebar = buildFileSystemSidebar(
581
736
  pages,
582
737
  options.folderMeta,
583
738
  sharedFolderMeta,
584
739
  metaPrefix,
585
740
  display,
586
- new Set(tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path])))
741
+ new Set(
742
+ tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
743
+ ),
744
+ options.diagnostics
587
745
  );
588
746
  return {
589
747
  featured,
748
+ root: rootTabPath,
590
749
  selectors,
591
750
  sidebar,
592
751
  tabs: withTabHrefs(tabs, sidebar),