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.
- package/CHANGELOG.md +80 -0
- package/dist/cli/index.js +13404 -10228
- package/dist/cli/index.js.map +94 -63
- package/dist/types/ai/component-markdown.d.ts +12 -1
- package/dist/types/core/config-input.d.ts +73 -4
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +144 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +20 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +3 -3
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +37 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +5 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +80 -2
- package/docs/reference/frontmatter.mdx +31 -1
- package/package.json +4 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/component-markdown.ts +39 -11
- package/src/ai/llms.ts +19 -2
- package/src/ai/markdown.ts +5 -1
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +124 -50
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +39 -8
- package/src/astro/templates.ts +93 -28
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +138 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +64 -13
- package/src/cli/index.ts +2 -0
- package/src/cli/prepare.ts +10 -2
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +78 -4
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +61 -12
- package/src/core/graph.ts +23 -4
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/navigation.ts +169 -14
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +54 -20
- package/src/core/schema.ts +93 -3
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +20 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/markdown/index.ts +1 -0
- package/src/markdown/twoslash.ts +60 -0
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/registry/eject.ts +3 -1
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +6 -1
- /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
|
|
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
|
-
|
|
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
|
|
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}/`));
|
package/src/core/navigation.ts
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
+
};
|