blume 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli/index.js +13404 -10228
  3. package/dist/cli/index.js.map +94 -63
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +73 -4
  6. package/dist/types/core/data.d.ts +9 -0
  7. package/dist/types/core/deployment-env.d.ts +6 -0
  8. package/dist/types/core/diagnostics.d.ts +23 -0
  9. package/dist/types/core/i18n-ui.d.ts +8 -8
  10. package/dist/types/core/schema.d.ts +144 -22
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +41 -0
  13. package/dist/types/core/types.d.ts +20 -0
  14. package/dist/types/og/card.d.ts +63 -0
  15. package/dist/types/og/dimensions.d.ts +12 -0
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +11 -0
  19. package/docs/advanced/changelog.mdx +2 -2
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +3 -3
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +37 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +14 -0
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +5 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/reference/cli.mdx +80 -2
  34. package/docs/reference/frontmatter.mdx +31 -1
  35. package/package.json +4 -3
  36. package/skills/blume-migrate/SKILL.md +1 -1
  37. package/skills/blume-migrate/references/mintlify.md +3 -2
  38. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  39. package/src/ai/component-markdown.ts +39 -11
  40. package/src/ai/llms.ts +19 -2
  41. package/src/ai/markdown.ts +5 -1
  42. package/src/astro/adapter-root.ts +70 -0
  43. package/src/astro/generate.ts +124 -50
  44. package/src/astro/index.ts +1 -0
  45. package/src/astro/pages.ts +39 -8
  46. package/src/astro/templates.ts +93 -28
  47. package/src/audit/agent.ts +114 -0
  48. package/src/audit/catalog.ts +826 -0
  49. package/src/audit/checks/assets.ts +177 -0
  50. package/src/audit/checks/content.ts +231 -0
  51. package/src/audit/checks/duplicates.ts +131 -0
  52. package/src/audit/checks/i18n.ts +246 -0
  53. package/src/audit/checks/indexability.ts +213 -0
  54. package/src/audit/checks/links.ts +223 -0
  55. package/src/audit/checks/llms.ts +138 -0
  56. package/src/audit/checks/network.ts +272 -0
  57. package/src/audit/checks/og-image.ts +113 -0
  58. package/src/audit/checks/redirects.ts +87 -0
  59. package/src/audit/checks/robots.ts +114 -0
  60. package/src/audit/checks/sitemap.ts +229 -0
  61. package/src/audit/checks/social.ts +238 -0
  62. package/src/audit/crawl.ts +259 -0
  63. package/src/audit/graph.ts +74 -0
  64. package/src/audit/html.ts +54 -0
  65. package/src/audit/image-size.ts +63 -0
  66. package/src/audit/locate.ts +33 -0
  67. package/src/audit/redirects.ts +74 -0
  68. package/src/audit/report.ts +278 -0
  69. package/src/audit/run.ts +198 -0
  70. package/src/audit/snapshot.ts +189 -0
  71. package/src/audit/types.ts +214 -0
  72. package/src/audit/url.ts +103 -0
  73. package/src/cli/commands/audit.ts +205 -0
  74. package/src/cli/commands/build.ts +64 -13
  75. package/src/cli/index.ts +2 -0
  76. package/src/cli/prepare.ts +10 -2
  77. package/src/components/content/Tabs.astro +98 -15
  78. package/src/components/layout/Breadcrumbs.astro +1 -1
  79. package/src/components/layout/Header.astro +1 -0
  80. package/src/components/layout/PageFeedback.astro +1 -1
  81. package/src/components/layout/PageLayout.astro +5 -1
  82. package/src/components/layout/Pagination.astro +1 -1
  83. package/src/components/layout/RootLayout.astro +5 -3
  84. package/src/components/layout/Search.astro +35 -6
  85. package/src/components/layout/TableOfContents.astro +1 -1
  86. package/src/components/openapi/Authorization.astro +80 -0
  87. package/src/components/openapi/Operation.astro +19 -1
  88. package/src/components/openapi/ParametersTable.astro +1 -1
  89. package/src/components/openapi/security.ts +201 -0
  90. package/src/components/openapi/snippets.ts +42 -13
  91. package/src/core/config-input.ts +78 -4
  92. package/src/core/data.ts +9 -1
  93. package/src/core/deployment-env.ts +9 -0
  94. package/src/core/diagnostics.ts +61 -12
  95. package/src/core/graph.ts +23 -4
  96. package/src/core/links.ts +2 -91
  97. package/src/core/nav-diagnostics.ts +48 -4
  98. package/src/core/navigation.ts +169 -14
  99. package/src/core/probe.ts +136 -0
  100. package/src/core/project-graph.ts +54 -20
  101. package/src/core/schema.ts +93 -3
  102. package/src/core/sources/github-releases.ts +65 -2
  103. package/src/core/sources/normalize.ts +198 -25
  104. package/src/core/sources/types.ts +3 -1
  105. package/src/core/standard-schema.ts +54 -0
  106. package/src/core/types.ts +20 -0
  107. package/src/deploy/adapter-output.ts +27 -15
  108. package/src/deploy/headers.ts +66 -0
  109. package/src/deploy/redirects.ts +49 -9
  110. package/src/markdown/index.ts +1 -0
  111. package/src/markdown/twoslash.ts +60 -0
  112. package/src/og/card.ts +98 -33
  113. package/src/og/index.ts +1 -1
  114. package/src/registry/eject.ts +3 -1
  115. package/src/search/popular.ts +33 -0
  116. package/src/theme/entry.ts +6 -1
  117. /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
@@ -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
- /** The `?a=1&b=2` query string from an operation's required query params. */
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;
@@ -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`). */
@@ -552,9 +568,11 @@ export interface AiConfig {
552
568
  /**
553
569
  * Markdown serializers for custom components in agent-facing output (the
554
570
  * `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each
555
- * receives the component's statically-evaluated `props` and downleveled
556
- * `children` and returns replacement Markdown — or `null` to leave the JSX
557
- * verbatim. A same-name entry replaces a built-in serializer.
571
+ * receives the component's statically-evaluated `props` (with the page's
572
+ * `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
573
+ * downleveled `children`, and the page's `frontmatter` data, and returns
574
+ * replacement Markdown — or `null` to leave the JSX verbatim. A same-name
575
+ * entry replaces a built-in serializer.
558
576
  *
559
577
  * These live in `blume.config.ts` (which is executed at build time), not in
560
578
  * `components.tsx` (which is only statically analyzed, never run).
@@ -711,7 +729,7 @@ export interface RssConfig {
711
729
  types?: string[];
712
730
  }
713
731
 
714
- /** Colors used by generated Open Graph cards. Values must be hex colors. */
732
+ /** Colors used by generated Open Graph cards. Any CSS color — hex, `oklch(…)`, `rgb(…)`, named. */
715
733
  export interface OgPaletteConfig {
716
734
  /** Fallback mark color. Defaults to the light theme accent. */
717
735
  accent?: string;
@@ -733,10 +751,32 @@ export interface OgConfig {
733
751
  * value always wins.
734
752
  */
735
753
  enabled?: boolean;
754
+ /**
755
+ * Google Font families for the generated card, extending Takumi's Latin-only
756
+ * default so non-Latin titles (CJK, and so on) render instead of tofu.
757
+ * Fetched from Google Fonts at build. A bare string loads the family's
758
+ * default weights; the object form pins weights (`700`, `[400, 700]`, or a
759
+ * `"100..900"` variable range) and styles.
760
+ */
761
+ fonts?: (
762
+ | string
763
+ | {
764
+ name: string;
765
+ style?: "normal" | "italic" | ("normal" | "italic")[];
766
+ weight?: number | number[] | string;
767
+ }
768
+ )[];
736
769
  /** Local SVG used in the generated card instead of the site logo. */
737
770
  logo?: string;
738
771
  /** Optional generated-card colors. */
739
772
  palette?: OgPaletteConfig;
773
+ /**
774
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
775
+ * A custom page has no frontmatter to read, so its card is otherwise titled
776
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
777
+ * Content pages always take their card headline from the page title.
778
+ */
779
+ titles?: Record<string, string>;
740
780
  }
741
781
 
742
782
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
@@ -918,6 +958,38 @@ export type ExportConfig =
918
958
  pdf?: boolean;
919
959
  };
920
960
 
961
+ /**
962
+ * Opt-in custom frontmatter keys. Page frontmatter is strictly validated —
963
+ * an unknown key fails the build so typos are caught — and `extend` carves
964
+ * out project-specific keys from that rule, each validated by a schema you
965
+ * supply.
966
+ */
967
+ export interface FrontmatterConfig {
968
+ /**
969
+ * Extra frontmatter keys pages may carry, mapped to their validation
970
+ * schemas — any library implementing Standard Schema works (Zod — the
971
+ * version your project installs, 3.24+ or 4 — Valibot, ArkType):
972
+ *
973
+ * ```ts
974
+ * import { z } from "zod";
975
+ *
976
+ * frontmatter: {
977
+ * extend: {
978
+ * owner: z.string(),
979
+ * reviewedAt: z.coerce.date().optional(),
980
+ * },
981
+ * },
982
+ * ```
983
+ *
984
+ * Every declared key is validated on every page — absent ones included —
985
+ * so a required schema enforces the key site-wide; mark it `.optional()`
986
+ * to validate only when present. Validated values are preserved on each
987
+ * page record's `custom` field. Built-in frontmatter fields cannot be
988
+ * redeclared.
989
+ */
990
+ extend?: Record<string, StandardSchema>;
991
+ }
992
+
921
993
  /**
922
994
  * "Last updated" timestamps. `false` (default) disables them; `true` derives
923
995
  * each date from git history; the object form selects the source. A page's
@@ -991,6 +1063,8 @@ export interface BlumeConfig {
991
1063
  export?: ExportConfig;
992
1064
  /** Show the per-page "Was this helpful?" widget. Defaults to `true`. */
993
1065
  feedback?: boolean;
1066
+ /** Opt-in custom frontmatter keys, validated by schemas you supply. */
1067
+ frontmatter?: FrontmatterConfig;
994
1068
  /** Source repository (Edit-this-page links and the header repo link). */
995
1069
  github?: GithubConfig;
996
1070
  /** 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: { enabled: boolean; provider: SearchProvider };
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
@@ -38,12 +38,14 @@ const DOCS_PATHS: Record<string, string> = {
38
38
  BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
39
39
  BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
40
40
  BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
41
+ BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
41
42
  BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
42
43
  BLUME_META_INVALID: "/docs/content/meta",
43
44
  BLUME_META_LOAD_FAILED: "/docs/content/meta",
44
45
  BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
45
46
  BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
46
47
  BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
48
+ BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
47
49
  BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
48
50
  BLUME_NODE_VERSION: "/docs/quickstart",
49
51
  BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
@@ -126,17 +128,43 @@ const locatePath = (
126
128
  return { column: found - lastNewline, line: before.split("\n").length };
127
129
  };
128
130
 
129
- /** Convert a ZodError into Blume diagnostics, anchored to a file. */
130
- export const diagnosticsFromZod = (
131
- error: ZodError,
131
+ /** The YAML front matter block of a `.md`/`.mdx` source, if it has one. */
132
+ const FRONTMATTER = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/u;
133
+
134
+ /**
135
+ * Locate a front matter key in a content file, e.g. `["seo", "description"]` in
136
+ * `docs/api.mdx`. Scoped to the front matter block so a `title:` written in the
137
+ * page body can't be mistaken for the front matter key of the same name; returns
138
+ * undefined when the file has no front matter or the key isn't set (a missing
139
+ * key has no line to point at — callers anchor to the file instead).
140
+ */
141
+ export const locateFrontmatterKey = (
142
+ source: string,
143
+ path: readonly (string | number)[]
144
+ ): { column: number; line: number } | undefined => {
145
+ const block = FRONTMATTER.exec(source);
146
+ if (!block) {
147
+ return;
148
+ }
149
+ // `locatePath` reports lines 1-based within the text it was given, and the
150
+ // front matter body starts one line below the opening `---`.
151
+ const position = locatePath(block.groups?.body ?? "", path);
152
+ return position && { column: position.column, line: position.line + 1 };
153
+ };
154
+
155
+ /**
156
+ * Convert generic validation issues (message + path, the shape shared by Zod
157
+ * and Standard Schema issues) into Blume diagnostics, anchored to a file.
158
+ */
159
+ export const diagnosticsFromIssues = (
160
+ issues: readonly {
161
+ message: string;
162
+ path: readonly (string | number)[];
163
+ }[],
132
164
  options: { code: string; file?: string; source?: string }
133
165
  ): Diagnostic[] =>
134
- error.issues.map((issue) => {
166
+ issues.map((issue) => {
135
167
  const schemaPath = issue.path.join(".");
136
- const received =
137
- "received" in issue
138
- ? ` (received: ${JSON.stringify(issue.received)})`
139
- : "";
140
168
  const position = options.source
141
169
  ? locatePath(options.source, issue.path)
142
170
  : undefined;
@@ -145,14 +173,28 @@ export const diagnosticsFromZod = (
145
173
  column: position?.column,
146
174
  file: options.file,
147
175
  line: position?.line,
148
- message: schemaPath
149
- ? `${schemaPath}: ${issue.message}${received}`
150
- : `${issue.message}${received}`,
176
+ message: schemaPath ? `${schemaPath}: ${issue.message}` : issue.message,
151
177
  schemaPath: schemaPath || undefined,
152
178
  severity: "error",
153
179
  } satisfies Diagnostic;
154
180
  });
155
181
 
182
+ /** Convert a ZodError into Blume diagnostics, anchored to a file. */
183
+ export const diagnosticsFromZod = (
184
+ error: ZodError,
185
+ options: { code: string; file?: string; source?: string }
186
+ ): Diagnostic[] =>
187
+ diagnosticsFromIssues(
188
+ error.issues.map((issue) => ({
189
+ message:
190
+ "received" in issue
191
+ ? `${issue.message} (received: ${JSON.stringify(issue.received)})`
192
+ : issue.message,
193
+ path: issue.path,
194
+ })),
195
+ options
196
+ );
197
+
156
198
  const ESC = String.fromCodePoint(27);
157
199
  const COLORS = {
158
200
  blue: `${ESC}[34m`,
@@ -184,13 +226,20 @@ export const formatDiagnostic = (
184
226
  `${color}${COLORS.bold}${diagnostic.code}${COLORS.reset} ${diagnostic.message}`,
185
227
  ];
186
228
 
229
+ // An audit finding is about a built URL, and names the source file that fixes
230
+ // it as a second line ("at /docs/api" / "in docs/api.mdx:3:2"). Everything
231
+ // else is about a file alone, and keeps the original single `at file` line.
232
+ if (diagnostic.url) {
233
+ lines.push(` ${COLORS.dim}at ${diagnostic.url}${COLORS.reset}`);
234
+ }
187
235
  if (diagnostic.file) {
188
236
  const location = root ? relative(root, diagnostic.file) : diagnostic.file;
189
237
  const column =
190
238
  diagnostic.column === undefined ? "" : `:${diagnostic.column}`;
191
239
  const position =
192
240
  diagnostic.line === undefined ? "" : `:${diagnostic.line}${column}`;
193
- lines.push(` ${COLORS.dim}at ${location}${position}${COLORS.reset}`);
241
+ const label = diagnostic.url ? "in" : "at";
242
+ lines.push(` ${COLORS.dim}${label} ${location}${position}${COLORS.reset}`);
194
243
  }
195
244
 
196
245
  if (diagnostic.suggestion) {
package/src/core/graph.ts CHANGED
@@ -70,6 +70,7 @@ const localePagesFor = (
70
70
  if (!present.has(key)) {
71
71
  filled.push({
72
72
  ...source,
73
+ fallback: true,
73
74
  locale: code,
74
75
  route: withBasePath(basePath, localizeRoute(key, code, i18n)),
75
76
  });
@@ -85,7 +86,8 @@ const buildLocaleNavigation = (
85
86
  fallback: FallbackLocale,
86
87
  fallbackByKey: Map<string, PageRecord>,
87
88
  options: BuildContentGraphOptions,
88
- i18n: ResolvedI18nConfig
89
+ i18n: ResolvedI18nConfig,
90
+ diagnostics: Diagnostic[]
89
91
  ): Navigation => {
90
92
  // Localize internal tab paths — the tab's own and its dropdown items' — so a
91
93
  // header tab points to its in-locale route (e.g. `/docs` -> `/fr/docs`);
@@ -112,6 +114,7 @@ const buildLocaleNavigation = (
112
114
  );
113
115
  return buildNavigation(localePages, {
114
116
  basePath: options.basePath ?? "",
117
+ diagnostics,
115
118
  display: options.navigation.sidebar.display,
116
119
  featured: options.navigation.featured,
117
120
  folderMeta: options.folderMeta,
@@ -137,7 +140,8 @@ const buildLocaleNavigation = (
137
140
  const buildI18nNavigation = (
138
141
  pages: PageRecord[],
139
142
  options: BuildContentGraphOptions,
140
- i18n: ResolvedI18nConfig
143
+ i18n: ResolvedI18nConfig,
144
+ diagnostics: Diagnostic[]
141
145
  ): {
142
146
  navigation: Navigation;
143
147
  navigationByLocale: Record<string, Navigation>;
@@ -155,16 +159,30 @@ const buildI18nNavigation = (
155
159
  }
156
160
 
157
161
  // Each locale gets an independent tree, so navigation may diverge per language.
162
+ // Untranslated pages are padded into every locale from the fallback, so a tie
163
+ // in shared content would otherwise be re-reported once per locale — dedupe on
164
+ // code + file + message, which are all locale-stable for padded pages. A
165
+ // locale-specific tie names its own translated files/labels and survives.
158
166
  const navigationByLocale: Record<string, Navigation> = {};
167
+ const seen = new Set<string>();
159
168
  for (const { code } of i18n.locales) {
169
+ const localeDiagnostics: Diagnostic[] = [];
160
170
  navigationByLocale[code] = buildLocaleNavigation(
161
171
  code,
162
172
  pages,
163
173
  fallback,
164
174
  fallbackByKey,
165
175
  options,
166
- i18n
176
+ i18n,
177
+ localeDiagnostics
167
178
  );
179
+ for (const diagnostic of localeDiagnostics) {
180
+ const key = `${diagnostic.code}\n${diagnostic.file ?? ""}\n${diagnostic.message}`;
181
+ if (!seen.has(key)) {
182
+ seen.add(key);
183
+ diagnostics.push(diagnostic);
184
+ }
185
+ }
168
186
  }
169
187
  const navigation = navigationByLocale[i18n.defaultLocale] ?? {
170
188
  featured: [],
@@ -184,10 +202,11 @@ export const buildContentGraph = (
184
202
  const { i18n } = options;
185
203
 
186
204
  const { navigation, navigationByLocale } = i18n
187
- ? buildI18nNavigation(pages, options, i18n)
205
+ ? buildI18nNavigation(pages, options, i18n, diagnostics)
188
206
  : {
189
207
  navigation: buildNavigation(pages, {
190
208
  basePath: options.basePath ?? "",
209
+ diagnostics,
191
210
  display: options.navigation.sidebar.display,
192
211
  featured: options.navigation.featured,
193
212
  folderMeta: options.folderMeta,