blume 1.0.3 → 1.1.0

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 (126) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +13784 -10579
  3. package/dist/cli/index.js.map +93 -61
  4. package/dist/types/core/config-input.d.ts +87 -8
  5. package/dist/types/core/data.d.ts +21 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +140 -140
  9. package/dist/types/core/schema.d.ts +549 -370
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +23 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/dist/types/openapi/references.d.ts +12 -7
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +22 -3
  19. package/docs/advanced/changelog.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +1 -1
  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 +40 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +15 -2
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +11 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +116 -4
  34. package/docs/reference/cli.mdx +79 -1
  35. package/docs/reference/frontmatter.mdx +29 -1
  36. package/package.json +3 -3
  37. package/skills/blume-migrate/SKILL.md +170 -0
  38. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  39. package/skills/blume-migrate/references/docusaurus.md +95 -0
  40. package/skills/blume-migrate/references/fumadocs.md +95 -0
  41. package/skills/blume-migrate/references/mintlify.md +156 -0
  42. package/skills/blume-migrate/references/monorepo.md +224 -0
  43. package/skills/blume-migrate/references/nextra.md +76 -0
  44. package/skills/blume-migrate/references/starlight.md +116 -0
  45. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
  46. package/src/ai/llms.ts +15 -0
  47. package/src/astro/adapter-root.ts +70 -0
  48. package/src/astro/component-slots.ts +3 -2
  49. package/src/astro/generate.ts +132 -42
  50. package/src/astro/index.ts +1 -0
  51. package/src/astro/pages.ts +18 -3
  52. package/src/astro/templates.ts +158 -56
  53. package/src/audit/agent.ts +114 -0
  54. package/src/audit/catalog.ts +826 -0
  55. package/src/audit/checks/assets.ts +177 -0
  56. package/src/audit/checks/content.ts +231 -0
  57. package/src/audit/checks/duplicates.ts +131 -0
  58. package/src/audit/checks/i18n.ts +246 -0
  59. package/src/audit/checks/indexability.ts +213 -0
  60. package/src/audit/checks/links.ts +223 -0
  61. package/src/audit/checks/llms.ts +135 -0
  62. package/src/audit/checks/network.ts +272 -0
  63. package/src/audit/checks/og-image.ts +113 -0
  64. package/src/audit/checks/redirects.ts +87 -0
  65. package/src/audit/checks/robots.ts +114 -0
  66. package/src/audit/checks/sitemap.ts +229 -0
  67. package/src/audit/checks/social.ts +238 -0
  68. package/src/audit/crawl.ts +259 -0
  69. package/src/audit/graph.ts +74 -0
  70. package/src/audit/html.ts +54 -0
  71. package/src/audit/image-size.ts +63 -0
  72. package/src/audit/locate.ts +33 -0
  73. package/src/audit/redirects.ts +74 -0
  74. package/src/audit/report.ts +278 -0
  75. package/src/audit/run.ts +198 -0
  76. package/src/audit/snapshot.ts +189 -0
  77. package/src/audit/types.ts +214 -0
  78. package/src/audit/url.ts +103 -0
  79. package/src/cli/commands/audit.ts +205 -0
  80. package/src/cli/commands/build.ts +51 -12
  81. package/src/cli/index.ts +2 -0
  82. package/src/components/content/Callout.astro +8 -2
  83. package/src/components/content/Prompt.astro +25 -13
  84. package/src/components/content/Tabs.astro +98 -15
  85. package/src/components/layout/Breadcrumbs.astro +1 -1
  86. package/src/components/layout/Header.astro +5 -8
  87. package/src/components/layout/Logo.astro +13 -1
  88. package/src/components/layout/PageFeedback.astro +2 -2
  89. package/src/components/layout/PageLayout.astro +9 -9
  90. package/src/components/layout/Pagination.astro +7 -7
  91. package/src/components/layout/RootLayout.astro +9 -11
  92. package/src/components/layout/Search.astro +36 -7
  93. package/src/components/layout/TableOfContents.astro +1 -1
  94. package/src/components/layout/nav-utils.ts +9 -7
  95. package/src/components/openapi/Authorization.astro +80 -0
  96. package/src/components/openapi/Operation.astro +19 -1
  97. package/src/components/openapi/ParametersTable.astro +1 -1
  98. package/src/components/openapi/security.ts +201 -0
  99. package/src/components/openapi/snippets.ts +42 -13
  100. package/src/core/config-input.ts +94 -8
  101. package/src/core/data.ts +18 -2
  102. package/src/core/deployment-env.ts +9 -0
  103. package/src/core/diagnostics.ts +59 -12
  104. package/src/core/links.ts +2 -91
  105. package/src/core/nav-diagnostics.ts +48 -4
  106. package/src/core/navigation.ts +55 -13
  107. package/src/core/probe.ts +136 -0
  108. package/src/core/project-graph.ts +8 -0
  109. package/src/core/schema.ts +100 -1
  110. package/src/core/sources/normalize.ts +198 -25
  111. package/src/core/sources/types.ts +3 -1
  112. package/src/core/sources/watch.ts +5 -0
  113. package/src/core/standard-schema.ts +54 -0
  114. package/src/core/types.ts +23 -0
  115. package/src/deploy/adapter-output.ts +27 -15
  116. package/src/deploy/headers.ts +66 -0
  117. package/src/deploy/redirects.ts +49 -9
  118. package/src/markdown/index.ts +2 -0
  119. package/src/markdown/language-icon.ts +2 -1
  120. package/src/markdown/table-wrap.ts +43 -0
  121. package/src/og/card.ts +128 -36
  122. package/src/og/index.ts +1 -1
  123. package/src/og/logo.ts +21 -0
  124. package/src/openapi/references.ts +19 -16
  125. package/src/search/popular.ts +33 -0
  126. package/src/theme/entry.ts +56 -6
@@ -126,17 +126,43 @@ const locatePath = (
126
126
  return { column: found - lastNewline, line: before.split("\n").length };
127
127
  };
128
128
 
129
- /** Convert a ZodError into Blume diagnostics, anchored to a file. */
130
- export const diagnosticsFromZod = (
131
- error: ZodError,
129
+ /** The YAML front matter block of a `.md`/`.mdx` source, if it has one. */
130
+ const FRONTMATTER = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/u;
131
+
132
+ /**
133
+ * Locate a front matter key in a content file, e.g. `["seo", "description"]` in
134
+ * `docs/api.mdx`. Scoped to the front matter block so a `title:` written in the
135
+ * page body can't be mistaken for the front matter key of the same name; returns
136
+ * undefined when the file has no front matter or the key isn't set (a missing
137
+ * key has no line to point at — callers anchor to the file instead).
138
+ */
139
+ export const locateFrontmatterKey = (
140
+ source: string,
141
+ path: readonly (string | number)[]
142
+ ): { column: number; line: number } | undefined => {
143
+ const block = FRONTMATTER.exec(source);
144
+ if (!block) {
145
+ return;
146
+ }
147
+ // `locatePath` reports lines 1-based within the text it was given, and the
148
+ // front matter body starts one line below the opening `---`.
149
+ const position = locatePath(block.groups?.body ?? "", path);
150
+ return position && { column: position.column, line: position.line + 1 };
151
+ };
152
+
153
+ /**
154
+ * Convert generic validation issues (message + path, the shape shared by Zod
155
+ * and Standard Schema issues) into Blume diagnostics, anchored to a file.
156
+ */
157
+ export const diagnosticsFromIssues = (
158
+ issues: readonly {
159
+ message: string;
160
+ path: readonly (string | number)[];
161
+ }[],
132
162
  options: { code: string; file?: string; source?: string }
133
163
  ): Diagnostic[] =>
134
- error.issues.map((issue) => {
164
+ issues.map((issue) => {
135
165
  const schemaPath = issue.path.join(".");
136
- const received =
137
- "received" in issue
138
- ? ` (received: ${JSON.stringify(issue.received)})`
139
- : "";
140
166
  const position = options.source
141
167
  ? locatePath(options.source, issue.path)
142
168
  : undefined;
@@ -145,14 +171,28 @@ export const diagnosticsFromZod = (
145
171
  column: position?.column,
146
172
  file: options.file,
147
173
  line: position?.line,
148
- message: schemaPath
149
- ? `${schemaPath}: ${issue.message}${received}`
150
- : `${issue.message}${received}`,
174
+ message: schemaPath ? `${schemaPath}: ${issue.message}` : issue.message,
151
175
  schemaPath: schemaPath || undefined,
152
176
  severity: "error",
153
177
  } satisfies Diagnostic;
154
178
  });
155
179
 
180
+ /** Convert a ZodError into Blume diagnostics, anchored to a file. */
181
+ export const diagnosticsFromZod = (
182
+ error: ZodError,
183
+ options: { code: string; file?: string; source?: string }
184
+ ): Diagnostic[] =>
185
+ diagnosticsFromIssues(
186
+ error.issues.map((issue) => ({
187
+ message:
188
+ "received" in issue
189
+ ? `${issue.message} (received: ${JSON.stringify(issue.received)})`
190
+ : issue.message,
191
+ path: issue.path,
192
+ })),
193
+ options
194
+ );
195
+
156
196
  const ESC = String.fromCodePoint(27);
157
197
  const COLORS = {
158
198
  blue: `${ESC}[34m`,
@@ -184,13 +224,20 @@ export const formatDiagnostic = (
184
224
  `${color}${COLORS.bold}${diagnostic.code}${COLORS.reset} ${diagnostic.message}`,
185
225
  ];
186
226
 
227
+ // An audit finding is about a built URL, and names the source file that fixes
228
+ // it as a second line ("at /docs/api" / "in docs/api.mdx:3:2"). Everything
229
+ // else is about a file alone, and keeps the original single `at file` line.
230
+ if (diagnostic.url) {
231
+ lines.push(` ${COLORS.dim}at ${diagnostic.url}${COLORS.reset}`);
232
+ }
187
233
  if (diagnostic.file) {
188
234
  const location = root ? relative(root, diagnostic.file) : diagnostic.file;
189
235
  const column =
190
236
  diagnostic.column === undefined ? "" : `:${diagnostic.column}`;
191
237
  const position =
192
238
  diagnostic.line === undefined ? "" : `:${diagnostic.line}${column}`;
193
- lines.push(` ${COLORS.dim}at ${location}${position}${COLORS.reset}`);
239
+ const label = diagnostic.url ? "in" : "at";
240
+ lines.push(` ${COLORS.dim}${label} ${location}${position}${COLORS.reset}`);
194
241
  }
195
242
 
196
243
  if (diagnostic.suggestion) {
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}/`));
@@ -433,6 +433,43 @@ const buildConfigSidebar = (
433
433
  return nodes;
434
434
  };
435
435
 
436
+ /**
437
+ * Resolve a tab's clickable target. A tab's `path` scopes its sidebar section
438
+ * but need not be a real route — a section with no index page would 404 if the
439
+ * tab linked straight to it. Prefer an exact page/group at the path; otherwise
440
+ * fall back to the first linkable route in the section (sidebar order).
441
+ */
442
+ const resolveTabHref = (sidebar: NavNode[], path: string): string => {
443
+ let first: string | undefined;
444
+ const walk = (nodes: NavNode[]): boolean => {
445
+ for (const node of nodes) {
446
+ const { route } = node;
447
+ if (route === path) {
448
+ return true;
449
+ }
450
+ if (
451
+ first === undefined &&
452
+ route !== undefined &&
453
+ route.startsWith(`${path}/`)
454
+ ) {
455
+ first = route;
456
+ }
457
+ if (node.kind === "group" && walk(node.children)) {
458
+ return true;
459
+ }
460
+ }
461
+ return false;
462
+ };
463
+ return walk(sidebar) ? path : (first ?? path);
464
+ };
465
+
466
+ /** Attach a resolved `href` to each tab whose section has no index page. */
467
+ const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
468
+ tabs.map((tab) => {
469
+ const href = resolveTabHref(sidebar, tab.path);
470
+ return href === tab.path ? tab : { ...tab, href };
471
+ });
472
+
436
473
  /** Build the complete navigation model from pages, meta, and config. */
437
474
  export const buildNavigation = (
438
475
  pages: PageRecord[],
@@ -519,11 +556,17 @@ export const buildNavigation = (
519
556
  }
520
557
 
521
558
  if (options.sidebar) {
559
+ const sidebar = buildConfigSidebar(
560
+ options.sidebar,
561
+ byRoute,
562
+ display,
563
+ basePath
564
+ );
522
565
  return {
523
566
  featured,
524
567
  selectors,
525
- sidebar: buildConfigSidebar(options.sidebar, byRoute, display, basePath),
526
- tabs,
568
+ sidebar,
569
+ tabs: withTabHrefs(tabs, sidebar),
527
570
  };
528
571
  }
529
572
 
@@ -534,19 +577,18 @@ export const buildNavigation = (
534
577
  // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
535
578
  // under a base, falsely scope a group named like the prefix).
536
579
  const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
580
+ const sidebar = buildFileSystemSidebar(
581
+ pages,
582
+ options.folderMeta,
583
+ sharedFolderMeta,
584
+ metaPrefix,
585
+ display,
586
+ new Set(tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path])))
587
+ );
537
588
  return {
538
589
  featured,
539
590
  selectors,
540
- sidebar: buildFileSystemSidebar(
541
- pages,
542
- options.folderMeta,
543
- sharedFolderMeta,
544
- metaPrefix,
545
- display,
546
- new Set(
547
- tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
548
- )
549
- ),
550
- tabs,
591
+ sidebar,
592
+ tabs: withTabHrefs(tabs, sidebar),
551
593
  };
552
594
  };
@@ -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
+ };
@@ -190,6 +190,13 @@ export const scanProject = async (
190
190
  discoverFolderMeta(metaSources, { localeDirs }),
191
191
  ]);
192
192
 
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
+
193
200
  const allPages: PageRecord[] = [];
194
201
  const contentDiagnostics: Diagnostic[] = [];
195
202
  for (const { source, entries, diagnostics } of loaded) {
@@ -198,6 +205,7 @@ export const scanProject = async (
198
205
  const normalized = normalizeEntry(entry, {
199
206
  basePath: config.basePath,
200
207
  defaultType: config.content.defaultType,
208
+ frontmatterExtend,
201
209
  i18n: config.i18n,
202
210
  source: {
203
211
  name: source.name,