blume 1.1.0 → 1.1.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.
@@ -410,9 +410,10 @@ export const astroConfigTemplate = (options: {
410
410
  // Twoslash runs first, before the always-on transformers, but only on fences
411
411
  // with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
412
412
  // no config flag; the TypeScript compiler only spins up when a block uses it.
413
- const twoslashImport = `import { transformerTwoslash } from "@shikijs/twoslash";\n`;
414
- const twoslashTransformer =
415
- "transformerTwoslash({ explicitTrigger: true }), ";
413
+ // Blume's preconfigured transformer compiles with the package's own pinned
414
+ // classic TypeScript, so the user's project can be on any version (see
415
+ // markdown/twoslash.ts).
416
+ const twoslashTransformer = "blumeTwoslashTransformer(), ";
416
417
 
417
418
  // Content links are rewritten to their real served URL: the `deployment.base`
418
419
  // subdirectory (Astro doesn't rewrite `<a href>`) layered over the site-wide
@@ -449,8 +450,8 @@ export const astroConfigTemplate = (options: {
449
450
  ${defineConfigImport}
450
451
  import mdx from "@astrojs/mdx";
451
452
  import tailwindcss from "@tailwindcss/vite";
452
- import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers } from "blume/markdown";
453
- ${twoslashImport}${reactImport}${vueImport}${svelteImport}${blumeImport}${adapterImport}
453
+ import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
454
+ ${reactImport}${vueImport}${svelteImport}${blumeImport}${adapterImport}
454
455
  export default defineConfig({
455
456
  root: ${JSON.stringify(context.outDir)},
456
457
  srcDir: ${JSON.stringify(`${context.outDir}/src`)},
@@ -1077,7 +1078,9 @@ import data from "blume:data";
1077
1078
  export const prerender = true;
1078
1079
 
1079
1080
  // Custom (non-content) pages opted into a generated card, baked in at build.
1080
- const customRoutes = ${JSON.stringify(customRoutes)};
1081
+ // The annotation keeps the empty-array case from being an implicit any[]
1082
+ // (ts(7034)) under a strict tsconfig.
1083
+ const customRoutes: { slug: string; title: string }[] = ${JSON.stringify(customRoutes)};
1081
1084
 
1082
1085
  export function getStaticPaths() {
1083
1086
  const seen = new Set<string>();
@@ -1803,6 +1806,23 @@ const islandDirective = (spec: IslandSpec): string =>
1803
1806
  ? `client:only="${spec.framework}"`
1804
1807
  : `client:${spec.client}`;
1805
1808
 
1809
+ /**
1810
+ * Frontmatter `Props` alias mirroring the wrapped component's own props, so
1811
+ * `{...Astro.props}` satisfies required props under `astro check` (the spread
1812
+ * of an untyped `Astro.props` contributes nothing to the JSX props type).
1813
+ * `infer P extends object` rather than `Record<string, unknown>` because
1814
+ * interfaces have no implicit index signature and would miss the narrower
1815
+ * constraint. Non-function component types (Vue/Svelte ambient modules) fall
1816
+ * back to an open record, keeping the untyped permissiveness they had.
1817
+ */
1818
+ const wrapperPropsType = (name: string): string =>
1819
+ `type Props = typeof ${name} extends (
1820
+ props: infer P extends object,
1821
+ ...rest: never[]
1822
+ ) => unknown
1823
+ ? P
1824
+ : Record<string, unknown>;`;
1825
+
1806
1826
  /**
1807
1827
  * Generate `.blume/src/generated/islands/<Name>.astro` — a wrapper that renders
1808
1828
  * a convention island with its hydration directive applied. Astro client
@@ -1813,6 +1833,7 @@ export const islandWrapperTemplate = (spec: IslandSpec): string =>
1813
1833
  `---
1814
1834
  // Generated by Blume. Do not edit.
1815
1835
  import Island from ${JSON.stringify(spec.file)};
1836
+ ${wrapperPropsType("Island")}
1816
1837
  ---
1817
1838
  <Island ${islandDirective(spec)} {...Astro.props}><slot /></Island>
1818
1839
  `;
@@ -1875,6 +1896,7 @@ export const exampleWrapperTemplate = (spec: ExampleSpec): string =>
1875
1896
  `---
1876
1897
  // Generated by Blume. Do not edit.
1877
1898
  import Example from ${JSON.stringify(spec.file)};
1899
+ ${wrapperPropsType("Example")}
1878
1900
  ---
1879
1901
  <Example ${exampleDirective(spec)}{...Astro.props}><slot /></Example>
1880
1902
  `;
@@ -91,7 +91,10 @@ export const llmsChecks: CheckModule = {
91
91
  continue;
92
92
  }
93
93
  listed.add(path);
94
- if (!context.byUrl.has(path)) {
94
+ // A listed target may be a served asset rather than a page — Blume's own
95
+ // llms.txt links the changelog RSS feed — so the file index vouches for
96
+ // it too, the same way redirect targets may land on a served asset.
97
+ if (!context.byUrl.has(path) && !context.files.has(path)) {
95
98
  found.push(
96
99
  finding(
97
100
  "BLUME_AUDIT_LLMS_TXT_STALE_ENTRY",
@@ -402,6 +402,13 @@ const publishBuildArtifacts = async (
402
402
 
403
403
  await runClientAssetChecks(distDir, args);
404
404
 
405
+ // Only reachable with --no-strict (strict aborts earlier): repeat the missing
406
+ // count next to the success banner so it can't scroll away unseen.
407
+ if (project.droppedPages > 0) {
408
+ logger.warn(
409
+ `${project.droppedPages} page(s) failed frontmatter validation and are missing from this build.`
410
+ );
411
+ }
405
412
  logger.success(`Built to ${distDir}`);
406
413
  };
407
414
 
@@ -440,7 +447,12 @@ export const buildCommand = defineCommand({
440
447
  description: "Include drafts and unpublished CMS content.",
441
448
  type: "boolean",
442
449
  },
443
- strict: { description: "Fail on diagnostics.", type: "boolean" },
450
+ strict: {
451
+ default: true,
452
+ description:
453
+ "Fail on error diagnostics (default; pass --no-strict to build anyway, dropping pages that fail validation).",
454
+ type: "boolean",
455
+ },
444
456
  },
445
457
  meta: {
446
458
  description: "Build the docs site for production.",
@@ -83,12 +83,20 @@ export const prepareProject = async (
83
83
  }
84
84
 
85
85
  const hadErrors = reportDiagnostics(project.diagnostics, options.root);
86
+ const dropped =
87
+ project.droppedPages > 0
88
+ ? `${project.droppedPages} page(s) failed frontmatter validation and were dropped from the site. `
89
+ : "";
86
90
  if (hadErrors && options.strict) {
87
- logger.error("Aborting due to errors (strict mode).");
91
+ logger.error(
92
+ `Aborting due to errors. ${dropped}Fix the diagnostics above, or pass --no-strict to continue despite them.`
93
+ );
88
94
  process.exit(1);
89
95
  }
90
96
  if (hasErrors(project.diagnostics) && !options.strict) {
91
- logger.warn("Continuing despite errors. Use --strict to fail the build.");
97
+ logger.warn(
98
+ `Continuing despite errors. ${dropped}Use --strict to fail instead.`
99
+ );
92
100
  }
93
101
 
94
102
  const { warnings } = await generateRuntime(project);
@@ -568,9 +568,11 @@ export interface AiConfig {
568
568
  /**
569
569
  * Markdown serializers for custom components in agent-facing output (the
570
570
  * `.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.
571
+ * receives the component's statically-evaluated `props` (with the page's
572
+ * `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
573
+ * downleveled `children`, and the page's `frontmatter` data, and returns
574
+ * replacement Markdown — or `null` to leave the JSX verbatim. A same-name
575
+ * entry replaces a built-in serializer.
574
576
  *
575
577
  * These live in `blume.config.ts` (which is executed at build time), not in
576
578
  * `components.tsx` (which is only statically analyzed, never run).
@@ -768,6 +770,13 @@ export interface OgConfig {
768
770
  logo?: string;
769
771
  /** Optional generated-card colors. */
770
772
  palette?: OgPaletteConfig;
773
+ /**
774
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
775
+ * A custom page has no frontmatter to read, so its card is otherwise titled
776
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
777
+ * Content pages always take their card headline from the page title.
778
+ */
779
+ titles?: Record<string, string>;
771
780
  }
772
781
 
773
782
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
@@ -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 ?? "";
@@ -583,7 +735,10 @@ export const buildNavigation = (
583
735
  sharedFolderMeta,
584
736
  metaPrefix,
585
737
  display,
586
- new Set(tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path])))
738
+ new Set(
739
+ tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
740
+ ),
741
+ options.diagnostics
587
742
  );
588
743
  return {
589
744
  featured,
@@ -15,7 +15,7 @@ import { resolveProjectContext } from "./project.ts";
15
15
  import type { ResolvedConfig } from "./schema.ts";
16
16
  import { normalizeEntry } from "./sources/normalize.ts";
17
17
  import { resolveDocsCollection, resolveSources } from "./sources/resolve.ts";
18
- import type { ContentSource } from "./sources/types.ts";
18
+ import type { ContentSource, SourceLoadResult } from "./sources/types.ts";
19
19
  import type {
20
20
  BlumeManifest,
21
21
  ContentGraph,
@@ -70,6 +70,8 @@ export interface BlumeProject {
70
70
  graph: ContentGraph;
71
71
  manifest: BlumeManifest;
72
72
  diagnostics: Diagnostic[];
73
+ /** Entries excluded from the graph because their frontmatter failed validation. */
74
+ droppedPages: number;
73
75
  /** The instantiated content sources, for lazy entry reads (search/AI/raw). */
74
76
  sources: ContentSource[];
75
77
  }
@@ -116,6 +118,51 @@ const entryIdDiagnostics = (
116
118
  return diagnostics;
117
119
  };
118
120
 
121
+ /**
122
+ * Funnel every loaded source's entries through the shared `normalizeEntry`,
123
+ * collecting pages, diagnostics, and the count of entries dropped outright —
124
+ * an entry that yields no pages but did yield diagnostics was rejected for
125
+ * invalid frontmatter, and callers surface that count so a build with missing
126
+ * pages can't read as clean.
127
+ */
128
+ const normalizeLoadedEntries = (
129
+ loaded: ({ source: ContentSource } & SourceLoadResult)[],
130
+ config: ResolvedConfig
131
+ ): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
132
+ // Only thread `frontmatter.extend` through when a project opts in, so the
133
+ // known-key split in `normalizeEntry` stays off the default path.
134
+ const frontmatterExtend =
135
+ Object.keys(config.frontmatter.extend).length > 0
136
+ ? config.frontmatter.extend
137
+ : undefined;
138
+
139
+ const pages: PageRecord[] = [];
140
+ const allDiagnostics: Diagnostic[] = [];
141
+ let droppedPages = 0;
142
+ for (const { source, entries, diagnostics } of loaded) {
143
+ allDiagnostics.push(...diagnostics);
144
+ for (const entry of entries) {
145
+ const normalized = normalizeEntry(entry, {
146
+ basePath: config.basePath,
147
+ defaultType: config.content.defaultType,
148
+ frontmatterExtend,
149
+ i18n: config.i18n,
150
+ source: {
151
+ name: source.name,
152
+ prefix: source.prefix,
153
+ staged: source.staged,
154
+ },
155
+ });
156
+ if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
157
+ droppedPages += 1;
158
+ }
159
+ pages.push(...normalized.pages);
160
+ allDiagnostics.push(...normalized.diagnostics);
161
+ }
162
+ }
163
+ return { diagnostics: allDiagnostics, droppedPages, pages };
164
+ };
165
+
119
166
  /**
120
167
  * Run the full core pipeline for a project root: load config, resolve paths,
121
168
  * discover content and folder meta, build the graph, and assemble the manifest.
@@ -190,33 +237,11 @@ export const scanProject = async (
190
237
  discoverFolderMeta(metaSources, { localeDirs }),
191
238
  ]);
192
239
 
193
- // Only thread `frontmatter.extend` through when a project opts in, so the
194
- // known-key split in `normalizeEntry` stays off the default path.
195
- const frontmatterExtend =
196
- Object.keys(config.frontmatter.extend).length > 0
197
- ? config.frontmatter.extend
198
- : undefined;
199
-
200
- const allPages: PageRecord[] = [];
201
- const contentDiagnostics: Diagnostic[] = [];
202
- for (const { source, entries, diagnostics } of loaded) {
203
- contentDiagnostics.push(...diagnostics);
204
- for (const entry of entries) {
205
- const normalized = normalizeEntry(entry, {
206
- basePath: config.basePath,
207
- defaultType: config.content.defaultType,
208
- frontmatterExtend,
209
- i18n: config.i18n,
210
- source: {
211
- name: source.name,
212
- prefix: source.prefix,
213
- staged: source.staged,
214
- },
215
- });
216
- allPages.push(...normalized.pages);
217
- contentDiagnostics.push(...normalized.diagnostics);
218
- }
219
- }
240
+ const {
241
+ diagnostics: contentDiagnostics,
242
+ droppedPages,
243
+ pages: allPages,
244
+ } = normalizeLoadedEntries(loaded, config);
220
245
 
221
246
  // Drafts render in dev and in preview, but are excluded from production builds.
222
247
  const pages =
@@ -267,6 +292,7 @@ export const scanProject = async (
267
292
  ...graph.diagnostics,
268
293
  ...i18nWarnings,
269
294
  ],
295
+ droppedPages,
270
296
  graph,
271
297
  manifest,
272
298
  mode,