blume 1.0.4 → 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 +65 -0
- package/dist/cli/index.js +13255 -10232
- package/dist/cli/index.js.map +91 -60
- package/dist/types/core/config-input.d.ts +61 -1
- 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 +131 -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 +13 -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 +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 +21 -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 +1 -1
- package/docs/content/sources.mdx +1 -1
- 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 +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +50 -19
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +65 -22
- 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/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 +66 -1
- package/src/core/data.ts +9 -1
- 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/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +86 -3
- 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 +13 -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/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +6 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security (authorization) resolution for the OpenAPI components. An operation
|
|
3
|
+
* enforces its own `security` when declared — an empty array explicitly makes
|
|
4
|
+
* it public — and inherits the document's root `security` otherwise. Within the
|
|
5
|
+
* resolved list, each requirement object is one way to authorize (OR between
|
|
6
|
+
* entries), and every scheme named inside a single requirement is needed
|
|
7
|
+
* together (AND). Pure and dependency-free like `helpers.ts`, so it runs in the
|
|
8
|
+
* browser build with no server-only imports.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A permissive view of an OpenAPI security scheme — only the fields we render. */
|
|
12
|
+
export interface SecuritySchemeLike {
|
|
13
|
+
type?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** `apiKey`: the parameter name the key is sent as. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** `apiKey`: where the key goes — `header`, `query`, or `cookie`. */
|
|
18
|
+
in?: string;
|
|
19
|
+
/** `http`: the HTTP auth scheme, e.g. `bearer` or `basic`. */
|
|
20
|
+
scheme?: string;
|
|
21
|
+
/** `http` bearer: a hint at the token format, e.g. `JWT`. */
|
|
22
|
+
bearerFormat?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One security requirement: scheme name -> required scopes (empty outside OAuth). */
|
|
27
|
+
export type SecurityRequirementLike = Record<string, string[]>;
|
|
28
|
+
|
|
29
|
+
/** A scheme resolved out of `components.securitySchemes`, with its scopes. */
|
|
30
|
+
export interface ResolvedScheme {
|
|
31
|
+
/** The scheme's component name, e.g. `bearerAuth`. */
|
|
32
|
+
key: string;
|
|
33
|
+
/** The scheme object; undefined when the requirement names an unknown one. */
|
|
34
|
+
scheme?: SecuritySchemeLike;
|
|
35
|
+
scopes: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The security state one operation renders. */
|
|
39
|
+
export interface OperationSecurity {
|
|
40
|
+
/** Ways to authorize (OR); every scheme within one entry is required (AND). */
|
|
41
|
+
alternatives: ResolvedScheme[][];
|
|
42
|
+
/** True when an empty requirement also allows unauthenticated calls. */
|
|
43
|
+
optional: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The requirement list an operation actually enforces: its own `security` when
|
|
48
|
+
* declared — the OpenAPI override rule, where `[]` removes the default and
|
|
49
|
+
* makes the operation public — else the document's root `security`.
|
|
50
|
+
*/
|
|
51
|
+
export const effectiveSecurity = (
|
|
52
|
+
operation?: SecurityRequirementLike[],
|
|
53
|
+
document?: SecurityRequirementLike[]
|
|
54
|
+
): SecurityRequirementLike[] => operation ?? document ?? [];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve requirement names against `components.securitySchemes`. A name with
|
|
58
|
+
* no matching component is kept (with `scheme` undefined) so an inconsistent
|
|
59
|
+
* spec still renders the requirement instead of silently dropping it. An empty
|
|
60
|
+
* requirement object — the spec idiom for "auth optional" — contributes no
|
|
61
|
+
* alternative and flips `optional` instead.
|
|
62
|
+
*/
|
|
63
|
+
export const resolveSecurity = (
|
|
64
|
+
requirements: SecurityRequirementLike[],
|
|
65
|
+
schemes: Record<string, SecuritySchemeLike> | undefined
|
|
66
|
+
): OperationSecurity => {
|
|
67
|
+
const alternatives: ResolvedScheme[][] = [];
|
|
68
|
+
let optional = false;
|
|
69
|
+
for (const requirement of requirements) {
|
|
70
|
+
const entries = Object.entries(requirement ?? {});
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
optional = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
alternatives.push(
|
|
76
|
+
entries.map(([key, scopes]) => ({
|
|
77
|
+
key,
|
|
78
|
+
scheme: schemes?.[key],
|
|
79
|
+
scopes: Array.isArray(scopes)
|
|
80
|
+
? scopes.filter((scope): scope is string => typeof scope === "string")
|
|
81
|
+
: [],
|
|
82
|
+
}))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { alternatives, optional };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const capitalize = (text: string): string =>
|
|
89
|
+
text.charAt(0).toUpperCase() + text.slice(1);
|
|
90
|
+
|
|
91
|
+
/** A short human label for a scheme row, e.g. `Bearer token` or `API key`. */
|
|
92
|
+
export const schemeLabel = (resolved: ResolvedScheme): string => {
|
|
93
|
+
const { scheme } = resolved;
|
|
94
|
+
switch (scheme?.type) {
|
|
95
|
+
case "http": {
|
|
96
|
+
const kind = (scheme.scheme ?? "").toLowerCase();
|
|
97
|
+
if (kind === "bearer") {
|
|
98
|
+
return scheme.bearerFormat
|
|
99
|
+
? `Bearer token (${scheme.bearerFormat})`
|
|
100
|
+
: "Bearer token";
|
|
101
|
+
}
|
|
102
|
+
if (kind === "basic") {
|
|
103
|
+
return "Basic auth";
|
|
104
|
+
}
|
|
105
|
+
return kind ? `HTTP ${kind}` : "HTTP auth";
|
|
106
|
+
}
|
|
107
|
+
case "apiKey": {
|
|
108
|
+
return "API key";
|
|
109
|
+
}
|
|
110
|
+
case "oauth2": {
|
|
111
|
+
return "OAuth2 access token";
|
|
112
|
+
}
|
|
113
|
+
case "openIdConnect": {
|
|
114
|
+
return "OpenID Connect token";
|
|
115
|
+
}
|
|
116
|
+
case "mutualTLS": {
|
|
117
|
+
return "Mutual TLS";
|
|
118
|
+
}
|
|
119
|
+
default: {
|
|
120
|
+
// Unknown scheme ref: the component name is the best label available.
|
|
121
|
+
return resolved.key;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where the credential travels: the header/query/cookie parameter it occupies.
|
|
128
|
+
* Undefined for schemes with no request parameter (mutual TLS) and for unknown
|
|
129
|
+
* refs, where guessing a location would be misleading.
|
|
130
|
+
*/
|
|
131
|
+
export const schemeCarrier = (
|
|
132
|
+
resolved: ResolvedScheme
|
|
133
|
+
): { name: string; in: string } | undefined => {
|
|
134
|
+
const { scheme } = resolved;
|
|
135
|
+
switch (scheme?.type) {
|
|
136
|
+
case "http":
|
|
137
|
+
case "oauth2":
|
|
138
|
+
case "openIdConnect": {
|
|
139
|
+
return { in: "header", name: "Authorization" };
|
|
140
|
+
}
|
|
141
|
+
case "apiKey": {
|
|
142
|
+
return { in: scheme.in ?? "header", name: scheme.name ?? resolved.key };
|
|
143
|
+
}
|
|
144
|
+
default: {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Placeholder credentials the request samples send. */
|
|
151
|
+
export interface SampleAuth {
|
|
152
|
+
headers: Record<string, string>;
|
|
153
|
+
query: Record<string, string>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Placeholder credentials for an operation's request samples, from its first
|
|
158
|
+
* alternative (the spec's preferred way to authorize). Schemes that don't
|
|
159
|
+
* travel in the request (mutual TLS) and unknown refs contribute nothing.
|
|
160
|
+
*/
|
|
161
|
+
export const sampleAuth = (security: OperationSecurity): SampleAuth => {
|
|
162
|
+
const headers: Record<string, string> = {};
|
|
163
|
+
const query: Record<string, string> = {};
|
|
164
|
+
const cookies: string[] = [];
|
|
165
|
+
for (const resolved of security.alternatives[0] ?? []) {
|
|
166
|
+
const { scheme } = resolved;
|
|
167
|
+
switch (scheme?.type) {
|
|
168
|
+
case "http": {
|
|
169
|
+
const kind = (scheme.scheme ?? "bearer").toLowerCase();
|
|
170
|
+
headers.Authorization =
|
|
171
|
+
kind === "bearer"
|
|
172
|
+
? "Bearer YOUR_TOKEN"
|
|
173
|
+
: `${capitalize(kind)} YOUR_CREDENTIALS`;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "oauth2":
|
|
177
|
+
case "openIdConnect": {
|
|
178
|
+
headers.Authorization = "Bearer YOUR_ACCESS_TOKEN";
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "apiKey": {
|
|
182
|
+
const name = scheme.name ?? resolved.key;
|
|
183
|
+
if (scheme.in === "query") {
|
|
184
|
+
query[name] = "YOUR_API_KEY";
|
|
185
|
+
} else if (scheme.in === "cookie") {
|
|
186
|
+
cookies.push(`${name}=YOUR_API_KEY`);
|
|
187
|
+
} else {
|
|
188
|
+
headers[name] = "YOUR_API_KEY";
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
default: {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (cookies.length > 0) {
|
|
198
|
+
headers.Cookie = cookies.join("; ");
|
|
199
|
+
}
|
|
200
|
+
return { headers, query };
|
|
201
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exampleValue, toJson } from "./helpers.ts";
|
|
2
2
|
import type { SchemaLike } from "./helpers.ts";
|
|
3
|
+
import type { SampleAuth } from "./security.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Request example + code-sample generation for an operation. Kept separate from
|
|
@@ -43,16 +44,22 @@ const jsonContentType = (
|
|
|
43
44
|
return entries.find(([type]) => type.includes("json")) ?? entries[0];
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* The `?a=1&b=2` query string from an operation's required query params, plus
|
|
49
|
+
* any extra entries (a query-borne API key from the security requirements).
|
|
50
|
+
*/
|
|
47
51
|
const queryString = (
|
|
48
52
|
params: ParamLike[],
|
|
49
|
-
schemas: Record<string, SchemaLike
|
|
53
|
+
schemas: Record<string, SchemaLike>,
|
|
54
|
+
extra: Record<string, string>
|
|
50
55
|
): string => {
|
|
51
56
|
const query: string[] = [];
|
|
57
|
+
const seen = new Set<string>();
|
|
52
58
|
for (const param of params) {
|
|
53
59
|
if (!(param.in === "query" && param.required && param.name)) {
|
|
54
60
|
continue;
|
|
55
61
|
}
|
|
62
|
+
seen.add(param.name);
|
|
56
63
|
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
57
64
|
query.push(
|
|
58
65
|
`${encodeURIComponent(param.name)}=${encodeURIComponent(
|
|
@@ -60,16 +67,46 @@ const queryString = (
|
|
|
60
67
|
)}`
|
|
61
68
|
);
|
|
62
69
|
}
|
|
70
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
71
|
+
// A spec may declare the credential as an explicit query parameter too;
|
|
72
|
+
// its (better) example wins over the auth placeholder, as in headers.
|
|
73
|
+
if (seen.has(name)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
query.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
|
|
77
|
+
}
|
|
63
78
|
return query.length > 0 ? `?${query.join("&")}` : "";
|
|
64
79
|
};
|
|
65
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The sample's headers: auth placeholders first, so a spec that also declares
|
|
83
|
+
* the credential as an explicit header parameter overrides them with its own
|
|
84
|
+
* (better) example.
|
|
85
|
+
*/
|
|
86
|
+
const headerValues = (
|
|
87
|
+
params: ParamLike[],
|
|
88
|
+
schemas: Record<string, SchemaLike>,
|
|
89
|
+
auth: SampleAuth | undefined
|
|
90
|
+
): Record<string, string> => {
|
|
91
|
+
const headers: Record<string, string> = { ...auth?.headers };
|
|
92
|
+
for (const param of params) {
|
|
93
|
+
if (param.in === "header" && param.required && param.name) {
|
|
94
|
+
headers[param.name] = String(
|
|
95
|
+
param.example ?? exampleValue(param.schema, schemas) ?? ""
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return headers;
|
|
100
|
+
};
|
|
101
|
+
|
|
66
102
|
/** Assemble a representative request from an operation and the spec servers. */
|
|
67
103
|
export const buildRequestSample = (
|
|
68
104
|
operation: OperationLike,
|
|
69
105
|
method: string,
|
|
70
106
|
path: string,
|
|
71
107
|
servers: { url?: string }[],
|
|
72
|
-
schemas: Record<string, SchemaLike
|
|
108
|
+
schemas: Record<string, SchemaLike>,
|
|
109
|
+
auth?: SampleAuth
|
|
73
110
|
): RequestSample => {
|
|
74
111
|
const base = (servers[0]?.url ?? "").replace(TRAILING_SLASH, "");
|
|
75
112
|
const params = operation.parameters ?? [];
|
|
@@ -85,16 +122,8 @@ export const buildRequestSample = (
|
|
|
85
122
|
}
|
|
86
123
|
}
|
|
87
124
|
|
|
88
|
-
const search = queryString(params, schemas);
|
|
89
|
-
|
|
90
|
-
const headers: Record<string, string> = {};
|
|
91
|
-
for (const param of params) {
|
|
92
|
-
if (param.in === "header" && param.required && param.name) {
|
|
93
|
-
headers[param.name] = String(
|
|
94
|
-
param.example ?? exampleValue(param.schema, schemas) ?? ""
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
125
|
+
const search = queryString(params, schemas, auth?.query ?? {});
|
|
126
|
+
const headers = headerValues(params, schemas, auth);
|
|
98
127
|
|
|
99
128
|
const media = jsonContentType(operation.requestBody?.content);
|
|
100
129
|
let body: string | undefined;
|
package/src/core/config-input.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SidebarItemConfig,
|
|
11
11
|
} from "./schema.ts";
|
|
12
12
|
import type { ContentSource } from "./sources/types.ts";
|
|
13
|
+
import type { StandardSchema } from "./standard-schema.ts";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The public, hand-documented authoring type for `blume.config.ts`.
|
|
@@ -452,6 +453,16 @@ export interface MixedbreadSearch {
|
|
|
452
453
|
storeId: string;
|
|
453
454
|
}
|
|
454
455
|
|
|
456
|
+
/** A curated link for the search dialog empty state. */
|
|
457
|
+
export interface SearchPopularLink {
|
|
458
|
+
/** Internal route or external URL. */
|
|
459
|
+
href: string;
|
|
460
|
+
/** Built-in icon name shown beside the label; defaults to the file glyph. */
|
|
461
|
+
icon?: string;
|
|
462
|
+
/** Link label shown in the dialog. */
|
|
463
|
+
label: string;
|
|
464
|
+
}
|
|
465
|
+
|
|
455
466
|
/**
|
|
456
467
|
* Search backend. The default `orama` builds a local index at build time (and
|
|
457
468
|
* runs in dev); hosted providers need their credential block below. `none`
|
|
@@ -469,6 +480,11 @@ export interface SearchConfig {
|
|
|
469
480
|
mixedbread?: MixedbreadSearch;
|
|
470
481
|
/** Orama Cloud credentials (required when `provider` is `orama-cloud`). */
|
|
471
482
|
oramaCloud?: OramaCloudSearch;
|
|
483
|
+
/**
|
|
484
|
+
* Curated links for the Cmd+K empty state. When omitted or empty, the first
|
|
485
|
+
* sidebar pages are shown instead.
|
|
486
|
+
*/
|
|
487
|
+
popular?: SearchPopularLink[];
|
|
472
488
|
/** Which backend powers search. Defaults to `orama`. */
|
|
473
489
|
provider?: SearchProvider;
|
|
474
490
|
/** Typesense credentials (required when `provider` is `typesense`). */
|
|
@@ -711,7 +727,7 @@ export interface RssConfig {
|
|
|
711
727
|
types?: string[];
|
|
712
728
|
}
|
|
713
729
|
|
|
714
|
-
/** Colors used by generated Open Graph cards.
|
|
730
|
+
/** Colors used by generated Open Graph cards. Any CSS color — hex, `oklch(…)`, `rgb(…)`, named. */
|
|
715
731
|
export interface OgPaletteConfig {
|
|
716
732
|
/** Fallback mark color. Defaults to the light theme accent. */
|
|
717
733
|
accent?: string;
|
|
@@ -733,6 +749,21 @@ export interface OgConfig {
|
|
|
733
749
|
* value always wins.
|
|
734
750
|
*/
|
|
735
751
|
enabled?: boolean;
|
|
752
|
+
/**
|
|
753
|
+
* Google Font families for the generated card, extending Takumi's Latin-only
|
|
754
|
+
* default so non-Latin titles (CJK, and so on) render instead of tofu.
|
|
755
|
+
* Fetched from Google Fonts at build. A bare string loads the family's
|
|
756
|
+
* default weights; the object form pins weights (`700`, `[400, 700]`, or a
|
|
757
|
+
* `"100..900"` variable range) and styles.
|
|
758
|
+
*/
|
|
759
|
+
fonts?: (
|
|
760
|
+
| string
|
|
761
|
+
| {
|
|
762
|
+
name: string;
|
|
763
|
+
style?: "normal" | "italic" | ("normal" | "italic")[];
|
|
764
|
+
weight?: number | number[] | string;
|
|
765
|
+
}
|
|
766
|
+
)[];
|
|
736
767
|
/** Local SVG used in the generated card instead of the site logo. */
|
|
737
768
|
logo?: string;
|
|
738
769
|
/** Optional generated-card colors. */
|
|
@@ -918,6 +949,38 @@ export type ExportConfig =
|
|
|
918
949
|
pdf?: boolean;
|
|
919
950
|
};
|
|
920
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Opt-in custom frontmatter keys. Page frontmatter is strictly validated —
|
|
954
|
+
* an unknown key fails the build so typos are caught — and `extend` carves
|
|
955
|
+
* out project-specific keys from that rule, each validated by a schema you
|
|
956
|
+
* supply.
|
|
957
|
+
*/
|
|
958
|
+
export interface FrontmatterConfig {
|
|
959
|
+
/**
|
|
960
|
+
* Extra frontmatter keys pages may carry, mapped to their validation
|
|
961
|
+
* schemas — any library implementing Standard Schema works (Zod — the
|
|
962
|
+
* version your project installs, 3.24+ or 4 — Valibot, ArkType):
|
|
963
|
+
*
|
|
964
|
+
* ```ts
|
|
965
|
+
* import { z } from "zod";
|
|
966
|
+
*
|
|
967
|
+
* frontmatter: {
|
|
968
|
+
* extend: {
|
|
969
|
+
* owner: z.string(),
|
|
970
|
+
* reviewedAt: z.coerce.date().optional(),
|
|
971
|
+
* },
|
|
972
|
+
* },
|
|
973
|
+
* ```
|
|
974
|
+
*
|
|
975
|
+
* Every declared key is validated on every page — absent ones included —
|
|
976
|
+
* so a required schema enforces the key site-wide; mark it `.optional()`
|
|
977
|
+
* to validate only when present. Validated values are preserved on each
|
|
978
|
+
* page record's `custom` field. Built-in frontmatter fields cannot be
|
|
979
|
+
* redeclared.
|
|
980
|
+
*/
|
|
981
|
+
extend?: Record<string, StandardSchema>;
|
|
982
|
+
}
|
|
983
|
+
|
|
921
984
|
/**
|
|
922
985
|
* "Last updated" timestamps. `false` (default) disables them; `true` derives
|
|
923
986
|
* each date from git history; the object form selects the source. A page's
|
|
@@ -991,6 +1054,8 @@ export interface BlumeConfig {
|
|
|
991
1054
|
export?: ExportConfig;
|
|
992
1055
|
/** Show the per-page "Was this helpful?" widget. Defaults to `true`. */
|
|
993
1056
|
feedback?: boolean;
|
|
1057
|
+
/** Opt-in custom frontmatter keys, validated by schemas you supply. */
|
|
1058
|
+
frontmatter?: FrontmatterConfig;
|
|
994
1059
|
/** Source repository (Edit-this-page links and the header repo link). */
|
|
995
1060
|
github?: GithubConfig;
|
|
996
1061
|
/** Internationalization (opt-in multi-locale). */
|
package/src/core/data.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OgFont } from "../og/card.ts";
|
|
1
2
|
import type { UIStrings } from "./i18n-ui.ts";
|
|
2
3
|
import type { ResolvedConfig, SearchProvider } from "./schema.ts";
|
|
3
4
|
import type { Navigation, RouteAlternate } from "./types.ts";
|
|
@@ -115,12 +116,19 @@ export interface BlumeDataConfig {
|
|
|
115
116
|
/** Open Graph image generation. */
|
|
116
117
|
og: {
|
|
117
118
|
enabled: boolean;
|
|
119
|
+
/** Extra Google Font family specs for the card renderer, fetched at build. */
|
|
120
|
+
fonts?: OgFont[];
|
|
118
121
|
logo?: string;
|
|
119
122
|
palette?: ResolvedConfig["seo"]["og"]["palette"];
|
|
120
123
|
};
|
|
121
124
|
/** Repository URL for header/edit links, or `null`. */
|
|
122
125
|
repoUrl: string | null;
|
|
123
|
-
search: {
|
|
126
|
+
search: {
|
|
127
|
+
enabled: boolean;
|
|
128
|
+
/** Resolved empty-state links; empty when unset (Search falls back to sidebar). */
|
|
129
|
+
popular: { icon?: string; label: string; route: string }[];
|
|
130
|
+
provider: SearchProvider;
|
|
131
|
+
};
|
|
124
132
|
/** Deployment site URL, or `null` when none is configured/detected. */
|
|
125
133
|
site: string | null;
|
|
126
134
|
structuredData: boolean;
|
|
@@ -44,6 +44,15 @@ const PLATFORMS: Platform[] = [
|
|
|
44
44
|
},
|
|
45
45
|
];
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Adapters whose `deployment.site` arrives from platform env vars at deploy
|
|
49
|
+
* time. Consumers (e.g. the audit) use this to tell "site is missing" apart
|
|
50
|
+
* from "site is missing *here*, but the platform will set it".
|
|
51
|
+
*/
|
|
52
|
+
export const SITE_INFERRING_ADAPTERS: ReadonlySet<string> = new Set(
|
|
53
|
+
PLATFORMS.map((platform) => platform.adapter)
|
|
54
|
+
);
|
|
55
|
+
|
|
47
56
|
/**
|
|
48
57
|
* Fill in `deployment.adapter` and `deployment.site` from platform env vars
|
|
49
58
|
* (Vercel, Netlify, Cloudflare Pages) when the user hasn't set them. Explicit
|
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) {
|