blume 1.0.4 → 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.
Files changed (117) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli/index.js +13404 -10228
  3. package/dist/cli/index.js.map +94 -63
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +73 -4
  6. package/dist/types/core/data.d.ts +9 -0
  7. package/dist/types/core/deployment-env.d.ts +6 -0
  8. package/dist/types/core/diagnostics.d.ts +23 -0
  9. package/dist/types/core/i18n-ui.d.ts +8 -8
  10. package/dist/types/core/schema.d.ts +144 -22
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +41 -0
  13. package/dist/types/core/types.d.ts +20 -0
  14. package/dist/types/og/card.d.ts +63 -0
  15. package/dist/types/og/dimensions.d.ts +12 -0
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +11 -0
  19. package/docs/advanced/changelog.mdx +2 -2
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +3 -3
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +37 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +14 -0
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +5 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/reference/cli.mdx +80 -2
  34. package/docs/reference/frontmatter.mdx +31 -1
  35. package/package.json +4 -3
  36. package/skills/blume-migrate/SKILL.md +1 -1
  37. package/skills/blume-migrate/references/mintlify.md +3 -2
  38. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  39. package/src/ai/component-markdown.ts +39 -11
  40. package/src/ai/llms.ts +19 -2
  41. package/src/ai/markdown.ts +5 -1
  42. package/src/astro/adapter-root.ts +70 -0
  43. package/src/astro/generate.ts +124 -50
  44. package/src/astro/index.ts +1 -0
  45. package/src/astro/pages.ts +39 -8
  46. package/src/astro/templates.ts +93 -28
  47. package/src/audit/agent.ts +114 -0
  48. package/src/audit/catalog.ts +826 -0
  49. package/src/audit/checks/assets.ts +177 -0
  50. package/src/audit/checks/content.ts +231 -0
  51. package/src/audit/checks/duplicates.ts +131 -0
  52. package/src/audit/checks/i18n.ts +246 -0
  53. package/src/audit/checks/indexability.ts +213 -0
  54. package/src/audit/checks/links.ts +223 -0
  55. package/src/audit/checks/llms.ts +138 -0
  56. package/src/audit/checks/network.ts +272 -0
  57. package/src/audit/checks/og-image.ts +113 -0
  58. package/src/audit/checks/redirects.ts +87 -0
  59. package/src/audit/checks/robots.ts +114 -0
  60. package/src/audit/checks/sitemap.ts +229 -0
  61. package/src/audit/checks/social.ts +238 -0
  62. package/src/audit/crawl.ts +259 -0
  63. package/src/audit/graph.ts +74 -0
  64. package/src/audit/html.ts +54 -0
  65. package/src/audit/image-size.ts +63 -0
  66. package/src/audit/locate.ts +33 -0
  67. package/src/audit/redirects.ts +74 -0
  68. package/src/audit/report.ts +278 -0
  69. package/src/audit/run.ts +198 -0
  70. package/src/audit/snapshot.ts +189 -0
  71. package/src/audit/types.ts +214 -0
  72. package/src/audit/url.ts +103 -0
  73. package/src/cli/commands/audit.ts +205 -0
  74. package/src/cli/commands/build.ts +64 -13
  75. package/src/cli/index.ts +2 -0
  76. package/src/cli/prepare.ts +10 -2
  77. package/src/components/content/Tabs.astro +98 -15
  78. package/src/components/layout/Breadcrumbs.astro +1 -1
  79. package/src/components/layout/Header.astro +1 -0
  80. package/src/components/layout/PageFeedback.astro +1 -1
  81. package/src/components/layout/PageLayout.astro +5 -1
  82. package/src/components/layout/Pagination.astro +1 -1
  83. package/src/components/layout/RootLayout.astro +5 -3
  84. package/src/components/layout/Search.astro +35 -6
  85. package/src/components/layout/TableOfContents.astro +1 -1
  86. package/src/components/openapi/Authorization.astro +80 -0
  87. package/src/components/openapi/Operation.astro +19 -1
  88. package/src/components/openapi/ParametersTable.astro +1 -1
  89. package/src/components/openapi/security.ts +201 -0
  90. package/src/components/openapi/snippets.ts +42 -13
  91. package/src/core/config-input.ts +78 -4
  92. package/src/core/data.ts +9 -1
  93. package/src/core/deployment-env.ts +9 -0
  94. package/src/core/diagnostics.ts +61 -12
  95. package/src/core/graph.ts +23 -4
  96. package/src/core/links.ts +2 -91
  97. package/src/core/nav-diagnostics.ts +48 -4
  98. package/src/core/navigation.ts +169 -14
  99. package/src/core/probe.ts +136 -0
  100. package/src/core/project-graph.ts +54 -20
  101. package/src/core/schema.ts +93 -3
  102. package/src/core/sources/github-releases.ts +65 -2
  103. package/src/core/sources/normalize.ts +198 -25
  104. package/src/core/sources/types.ts +3 -1
  105. package/src/core/standard-schema.ts +54 -0
  106. package/src/core/types.ts +20 -0
  107. package/src/deploy/adapter-output.ts +27 -15
  108. package/src/deploy/headers.ts +66 -0
  109. package/src/deploy/redirects.ts +49 -9
  110. package/src/markdown/index.ts +1 -0
  111. package/src/markdown/twoslash.ts +60 -0
  112. package/src/og/card.ts +98 -33
  113. package/src/og/index.ts +1 -1
  114. package/src/registry/eject.ts +3 -1
  115. package/src/search/popular.ts +33 -0
  116. package/src/theme/entry.ts +6 -1
  117. /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
package/src/core/links.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { basename, join } from "pathe";
4
4
 
5
5
  import { stripBasePath, withBasePath } from "./base-path.ts";
6
+ import { gradeExternal, probeAll } from "./probe.ts";
6
7
  import type {
7
8
  ContentGraph,
8
9
  Diagnostic,
@@ -25,13 +26,6 @@ const decodePercent = (value: string): string => {
25
26
  const DOC_EXT = /\.(?:md|mdx)$/iu;
26
27
  const FILE_EXT = /\.[a-z0-9]+$/iu;
27
28
 
28
- const EXTERNAL_CONCURRENCY = 8;
29
- const EXTERNAL_TIMEOUT_MS = 10_000;
30
- const STATUS_NOT_FOUND = 404;
31
- const STATUS_GONE = 410;
32
- const STATUS_METHOD_NOT_ALLOWED = 405;
33
- const STATUS_NOT_IMPLEMENTED = 501;
34
-
35
29
  /** Source position shared by every diagnostic raised for a link. */
36
30
  interface LinkSite {
37
31
  column: number;
@@ -214,94 +208,11 @@ const checkPathLink = (
214
208
  };
215
209
  };
216
210
 
217
- /** Probe a URL with the given method, normalizing failures to a result. */
218
- const request = async (
219
- url: string,
220
- method: "GET" | "HEAD"
221
- ): Promise<{
222
- ok: boolean;
223
- status?: number;
224
- timedOut?: boolean;
225
- error?: string;
226
- }> => {
227
- const controller = new AbortController();
228
- const timer = setTimeout(() => controller.abort(), EXTERNAL_TIMEOUT_MS);
229
- try {
230
- const response = await fetch(url, {
231
- method,
232
- redirect: "follow",
233
- signal: controller.signal,
234
- });
235
- return { ok: response.ok, status: response.status };
236
- } catch (error) {
237
- if (error instanceof Error && error.name === "AbortError") {
238
- return { ok: false, timedOut: true };
239
- }
240
- return {
241
- error: error instanceof Error ? error.message : String(error),
242
- ok: false,
243
- };
244
- } finally {
245
- clearTimeout(timer);
246
- }
247
- };
248
-
249
- /** Probe a single URL: HEAD first, falling back to GET when needed. */
250
- const probe = async (
251
- url: string
252
- ): Promise<Awaited<ReturnType<typeof request>>> => {
253
- const head = await request(url, "HEAD");
254
- const unreachable = !head.ok && head.status === undefined && !head.timedOut;
255
- const retry =
256
- head.status === STATUS_METHOD_NOT_ALLOWED ||
257
- head.status === STATUS_NOT_IMPLEMENTED ||
258
- unreachable;
259
- return retry ? await request(url, "GET") : head;
260
- };
261
-
262
- /** Grade a probe result into a diagnostic severity + detail, or null if OK. */
263
- const gradeExternal = (
264
- result: Awaited<ReturnType<typeof request>>
265
- ): { severity: Diagnostic["severity"]; detail: string } | null => {
266
- if (result.ok) {
267
- return null;
268
- }
269
- if (result.timedOut) {
270
- return { detail: "request timed out", severity: "warning" };
271
- }
272
- if (result.status === undefined) {
273
- return { detail: result.error ?? "unreachable", severity: "error" };
274
- }
275
- if (result.status === STATUS_NOT_FOUND || result.status === STATUS_GONE) {
276
- return { detail: `HTTP ${result.status}`, severity: "error" };
277
- }
278
- return { detail: `HTTP ${result.status}`, severity: "warning" };
279
- };
280
-
281
211
  /** Probe queued external links with bounded concurrency. */
282
212
  const checkExternalLinks = async (
283
213
  refs: ExternalRef[]
284
214
  ): Promise<Diagnostic[]> => {
285
- const unique = [...new Set(refs.map((ref) => ref.url))];
286
- const results = new Map<string, Awaited<ReturnType<typeof probe>>>();
287
-
288
- let cursor = 0;
289
- const worker = async (): Promise<void> => {
290
- while (cursor < unique.length) {
291
- const url = unique[cursor];
292
- cursor += 1;
293
- if (url !== undefined) {
294
- // oxlint-disable-next-line no-await-in-loop -- bounded-concurrency pool
295
- results.set(url, await probe(url));
296
- }
297
- }
298
- };
299
- await Promise.all(
300
- Array.from(
301
- { length: Math.min(EXTERNAL_CONCURRENCY, unique.length) },
302
- worker
303
- )
304
- );
215
+ const results = await probeAll(refs.map((ref) => ref.url));
305
216
 
306
217
  const diagnostics: Diagnostic[] = [];
307
218
  for (const ref of refs) {
@@ -55,10 +55,13 @@ const collectIcons = (
55
55
  };
56
56
 
57
57
  /** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
58
- export const validateNavIcons = (navigation: Navigation): Diagnostic[] => {
58
+ const unknownIconDiagnostics = (
59
+ icons: { icon: string; where: string }[],
60
+ suggestion: string
61
+ ): Diagnostic[] => {
59
62
  const seen = new Set<string>();
60
63
  const diagnostics: Diagnostic[] = [];
61
- for (const { icon, where } of collectIcons(navigation)) {
64
+ for (const { icon, where } of icons) {
62
65
  if (isAssetIcon(icon) || hasIcon(icon) || seen.has(icon)) {
63
66
  continue;
64
67
  }
@@ -67,13 +70,54 @@ export const validateNavIcons = (navigation: Navigation): Diagnostic[] => {
67
70
  code: "BLUME_UNKNOWN_ICON",
68
71
  message: `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`,
69
72
  severity: "warning",
70
- suggestion:
71
- "Use a built-in icon name, an image path/URL, or inline SVG markup.",
73
+ suggestion,
72
74
  });
73
75
  }
74
76
  return diagnostics;
75
77
  };
76
78
 
79
+ /** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
80
+ export const validateNavIcons = (navigation: Navigation): Diagnostic[] =>
81
+ unknownIconDiagnostics(
82
+ collectIcons(navigation),
83
+ "Use a built-in icon name, an image path/URL, or inline SVG markup."
84
+ );
85
+
86
+ /**
87
+ * Warn about unknown icons on curated `search.popular` links. Separate from
88
+ * {@link validateNavIcons} because these live under `search`, not the built
89
+ * navigation — and unlike nav icons they resolve in a *client* island, so only
90
+ * set names work (an image/SVG icon quietly falls back to the file glyph).
91
+ */
92
+ export const validateSearchPopularIcons = (
93
+ popular: { icon?: string; label: string }[]
94
+ ): Diagnostic[] => {
95
+ const icons = popular.flatMap((link) =>
96
+ link.icon
97
+ ? [{ icon: link.icon, where: `popular link "${link.label}"` }]
98
+ : []
99
+ );
100
+ // Asset icons are valid in the nav, so the shared helper skips them — but
101
+ // here they are exactly the silent failure this validator exists to catch.
102
+ const diagnostics: Diagnostic[] = [];
103
+ const seen = new Set<string>();
104
+ for (const { icon, where } of icons) {
105
+ if (isAssetIcon(icon) && !seen.has(icon)) {
106
+ seen.add(icon);
107
+ diagnostics.push({
108
+ code: "BLUME_UNKNOWN_ICON",
109
+ message: `Icon "${icon}" (${where}) is an image or inline SVG — popular links render in the client search island, where only built-in icon names resolve, so it falls back to the file glyph.`,
110
+ severity: "warning",
111
+ suggestion: "Use a built-in icon name.",
112
+ });
113
+ }
114
+ }
115
+ return [
116
+ ...diagnostics,
117
+ ...unknownIconDiagnostics(icons, "Use a built-in icon name."),
118
+ ];
119
+ };
120
+
77
121
  /** Whether an internal path resolves to a page or a section that has pages. */
78
122
  const resolvesToPages = (routes: Set<string>, path: string): boolean =>
79
123
  routes.has(path) || [...routes].some((route) => route.startsWith(`${path}/`));
@@ -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,
@@ -0,0 +1,136 @@
1
+ import type { DiagnosticSeverity } from "./types.ts";
2
+
3
+ export const PROBE_CONCURRENCY = 8;
4
+ export const PROBE_TIMEOUT_MS = 10_000;
5
+
6
+ const STATUS_NOT_FOUND = 404;
7
+ const STATUS_GONE = 410;
8
+ const STATUS_METHOD_NOT_ALLOWED = 405;
9
+ const STATUS_NOT_IMPLEMENTED = 501;
10
+
11
+ /** The outcome of a single HTTP probe, with failures normalized rather than thrown. */
12
+ export interface ProbeResult {
13
+ ok: boolean;
14
+ status?: number;
15
+ timedOut?: boolean;
16
+ error?: string;
17
+ /** Whether the request was redirected before landing on `status`. */
18
+ redirected?: boolean;
19
+ /** The URL finally landed on, after following redirects. */
20
+ finalUrl?: string;
21
+ /** `Content-Encoding` of the response, when the server set one. */
22
+ encoding?: string | null;
23
+ /** Wall-clock milliseconds for the request. */
24
+ ms?: number;
25
+ /** `X-Robots-Tag` of the response, when the server set one. */
26
+ robotsTag?: string | null;
27
+ }
28
+
29
+ /** Probe a URL with the given method, normalizing failures to a result. */
30
+ const request = async (
31
+ url: string,
32
+ method: "GET" | "HEAD",
33
+ timeoutMs: number
34
+ ): Promise<ProbeResult> => {
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
37
+ const started = performance.now();
38
+ try {
39
+ const response = await fetch(url, {
40
+ method,
41
+ redirect: "follow",
42
+ signal: controller.signal,
43
+ });
44
+ return {
45
+ encoding: response.headers.get("content-encoding"),
46
+ finalUrl: response.url,
47
+ ms: Math.round(performance.now() - started),
48
+ ok: response.ok,
49
+ redirected: response.redirected,
50
+ robotsTag: response.headers.get("x-robots-tag"),
51
+ status: response.status,
52
+ };
53
+ } catch (error) {
54
+ if (error instanceof Error && error.name === "AbortError") {
55
+ return { ok: false, timedOut: true };
56
+ }
57
+ return {
58
+ error: error instanceof Error ? error.message : String(error),
59
+ ok: false,
60
+ };
61
+ } finally {
62
+ clearTimeout(timer);
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Probe a single URL: HEAD first, falling back to GET.
68
+ *
69
+ * Plenty of servers reject HEAD outright (405/501) or drop the connection, so a
70
+ * HEAD-only probe would report healthy pages as dead.
71
+ */
72
+ export const probe = async (
73
+ url: string,
74
+ options: { timeoutMs?: number } = {}
75
+ ): Promise<ProbeResult> => {
76
+ const timeoutMs = options.timeoutMs ?? PROBE_TIMEOUT_MS;
77
+ const head = await request(url, "HEAD", timeoutMs);
78
+ const unreachable = !head.ok && head.status === undefined && !head.timedOut;
79
+ const retry =
80
+ head.status === STATUS_METHOD_NOT_ALLOWED ||
81
+ head.status === STATUS_NOT_IMPLEMENTED ||
82
+ unreachable;
83
+ return retry ? await request(url, "GET", timeoutMs) : head;
84
+ };
85
+
86
+ /** Grade a probe result into a severity + detail, or null when the URL is fine. */
87
+ export const gradeExternal = (
88
+ result: ProbeResult
89
+ ): { severity: DiagnosticSeverity; detail: string } | null => {
90
+ if (result.ok) {
91
+ return null;
92
+ }
93
+ if (result.timedOut) {
94
+ return { detail: "request timed out", severity: "warning" };
95
+ }
96
+ if (result.status === undefined) {
97
+ return { detail: result.error ?? "unreachable", severity: "error" };
98
+ }
99
+ // A 404/410 is definitively dead. A 403/429/5xx may well be rate limiting or a
100
+ // transient blip, which is not the author's bug to fix.
101
+ if (result.status === STATUS_NOT_FOUND || result.status === STATUS_GONE) {
102
+ return { detail: `HTTP ${result.status}`, severity: "error" };
103
+ }
104
+ return { detail: `HTTP ${result.status}`, severity: "warning" };
105
+ };
106
+
107
+ /**
108
+ * Probe many URLs with bounded concurrency, deduplicating first. Returns a map
109
+ * from URL to its result — the caller decides what each one means.
110
+ */
111
+ export const probeAll = async (
112
+ urls: readonly string[],
113
+ options: { concurrency?: number; timeoutMs?: number } = {}
114
+ ): Promise<Map<string, ProbeResult>> => {
115
+ const unique = [...new Set(urls)];
116
+ const results = new Map<string, ProbeResult>();
117
+ const limit = Math.min(
118
+ options.concurrency ?? PROBE_CONCURRENCY,
119
+ unique.length
120
+ );
121
+
122
+ let cursor = 0;
123
+ const worker = async (): Promise<void> => {
124
+ while (cursor < unique.length) {
125
+ const url = unique[cursor];
126
+ cursor += 1;
127
+ if (url !== undefined) {
128
+ // oxlint-disable-next-line no-await-in-loop -- bounded-concurrency pool
129
+ results.set(url, await probe(url, options));
130
+ }
131
+ }
132
+ };
133
+ await Promise.all(Array.from({ length: limit }, worker));
134
+
135
+ return results;
136
+ };