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.
- package/CHANGELOG.md +94 -0
- package/dist/cli/index.js +13784 -10579
- package/dist/cli/index.js.map +93 -61
- package/dist/types/core/config-input.d.ts +87 -8
- package/dist/types/core/data.d.ts +21 -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 +140 -140
- package/dist/types/core/schema.d.ts +549 -370
- 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 +23 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/dist/types/openapi/references.d.ts +12 -7
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +22 -3
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- 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 +40 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +15 -2
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +11 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +116 -4
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +170 -0
- package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
- package/skills/blume-migrate/references/docusaurus.md +95 -0
- package/skills/blume-migrate/references/fumadocs.md +95 -0
- package/skills/blume-migrate/references/mintlify.md +156 -0
- package/skills/blume-migrate/references/monorepo.md +224 -0
- package/skills/blume-migrate/references/nextra.md +76 -0
- package/skills/blume-migrate/references/starlight.md +116 -0
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/component-slots.ts +3 -2
- package/src/astro/generate.ts +132 -42
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +158 -56
- 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 +135 -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 +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Callout.astro +8 -2
- package/src/components/content/Prompt.astro +25 -13
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +5 -8
- package/src/components/layout/Logo.astro +13 -1
- package/src/components/layout/PageFeedback.astro +2 -2
- package/src/components/layout/PageLayout.astro +9 -9
- package/src/components/layout/Pagination.astro +7 -7
- package/src/components/layout/RootLayout.astro +9 -11
- package/src/components/layout/Search.astro +36 -7
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/layout/nav-utils.ts +9 -7
- 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 +94 -8
- package/src/core/data.ts +18 -2
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/navigation.ts +55 -13
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +100 -1
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/sources/watch.ts +5 -0
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +23 -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 +2 -0
- package/src/markdown/language-icon.ts +2 -1
- package/src/markdown/table-wrap.ts +43 -0
- package/src/og/card.ts +128 -36
- package/src/og/index.ts +1 -1
- package/src/og/logo.ts +21 -0
- package/src/openapi/references.ts +19 -16
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +56 -6
package/src/core/diagnostics.ts
CHANGED
|
@@ -126,17 +126,43 @@ const locatePath = (
|
|
|
126
126
|
return { column: found - lastNewline, line: before.split("\n").length };
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
541
|
-
|
|
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,
|