blume 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +13784 -10579
  3. package/dist/cli/index.js.map +93 -61
  4. package/dist/types/core/config-input.d.ts +87 -8
  5. package/dist/types/core/data.d.ts +21 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +140 -140
  9. package/dist/types/core/schema.d.ts +549 -370
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +23 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/dist/types/openapi/references.d.ts +12 -7
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +22 -3
  19. package/docs/advanced/changelog.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +1 -1
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +40 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +15 -2
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +11 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +116 -4
  34. package/docs/reference/cli.mdx +79 -1
  35. package/docs/reference/frontmatter.mdx +29 -1
  36. package/package.json +3 -3
  37. package/skills/blume-migrate/SKILL.md +170 -0
  38. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  39. package/skills/blume-migrate/references/docusaurus.md +95 -0
  40. package/skills/blume-migrate/references/fumadocs.md +95 -0
  41. package/skills/blume-migrate/references/mintlify.md +156 -0
  42. package/skills/blume-migrate/references/monorepo.md +224 -0
  43. package/skills/blume-migrate/references/nextra.md +76 -0
  44. package/skills/blume-migrate/references/starlight.md +116 -0
  45. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
  46. package/src/ai/llms.ts +15 -0
  47. package/src/astro/adapter-root.ts +70 -0
  48. package/src/astro/component-slots.ts +3 -2
  49. package/src/astro/generate.ts +132 -42
  50. package/src/astro/index.ts +1 -0
  51. package/src/astro/pages.ts +18 -3
  52. package/src/astro/templates.ts +158 -56
  53. package/src/audit/agent.ts +114 -0
  54. package/src/audit/catalog.ts +826 -0
  55. package/src/audit/checks/assets.ts +177 -0
  56. package/src/audit/checks/content.ts +231 -0
  57. package/src/audit/checks/duplicates.ts +131 -0
  58. package/src/audit/checks/i18n.ts +246 -0
  59. package/src/audit/checks/indexability.ts +213 -0
  60. package/src/audit/checks/links.ts +223 -0
  61. package/src/audit/checks/llms.ts +135 -0
  62. package/src/audit/checks/network.ts +272 -0
  63. package/src/audit/checks/og-image.ts +113 -0
  64. package/src/audit/checks/redirects.ts +87 -0
  65. package/src/audit/checks/robots.ts +114 -0
  66. package/src/audit/checks/sitemap.ts +229 -0
  67. package/src/audit/checks/social.ts +238 -0
  68. package/src/audit/crawl.ts +259 -0
  69. package/src/audit/graph.ts +74 -0
  70. package/src/audit/html.ts +54 -0
  71. package/src/audit/image-size.ts +63 -0
  72. package/src/audit/locate.ts +33 -0
  73. package/src/audit/redirects.ts +74 -0
  74. package/src/audit/report.ts +278 -0
  75. package/src/audit/run.ts +198 -0
  76. package/src/audit/snapshot.ts +189 -0
  77. package/src/audit/types.ts +214 -0
  78. package/src/audit/url.ts +103 -0
  79. package/src/cli/commands/audit.ts +205 -0
  80. package/src/cli/commands/build.ts +51 -12
  81. package/src/cli/index.ts +2 -0
  82. package/src/components/content/Callout.astro +8 -2
  83. package/src/components/content/Prompt.astro +25 -13
  84. package/src/components/content/Tabs.astro +98 -15
  85. package/src/components/layout/Breadcrumbs.astro +1 -1
  86. package/src/components/layout/Header.astro +5 -8
  87. package/src/components/layout/Logo.astro +13 -1
  88. package/src/components/layout/PageFeedback.astro +2 -2
  89. package/src/components/layout/PageLayout.astro +9 -9
  90. package/src/components/layout/Pagination.astro +7 -7
  91. package/src/components/layout/RootLayout.astro +9 -11
  92. package/src/components/layout/Search.astro +36 -7
  93. package/src/components/layout/TableOfContents.astro +1 -1
  94. package/src/components/layout/nav-utils.ts +9 -7
  95. package/src/components/openapi/Authorization.astro +80 -0
  96. package/src/components/openapi/Operation.astro +19 -1
  97. package/src/components/openapi/ParametersTable.astro +1 -1
  98. package/src/components/openapi/security.ts +201 -0
  99. package/src/components/openapi/snippets.ts +42 -13
  100. package/src/core/config-input.ts +94 -8
  101. package/src/core/data.ts +18 -2
  102. package/src/core/deployment-env.ts +9 -0
  103. package/src/core/diagnostics.ts +59 -12
  104. package/src/core/links.ts +2 -91
  105. package/src/core/nav-diagnostics.ts +48 -4
  106. package/src/core/navigation.ts +55 -13
  107. package/src/core/probe.ts +136 -0
  108. package/src/core/project-graph.ts +8 -0
  109. package/src/core/schema.ts +100 -1
  110. package/src/core/sources/normalize.ts +198 -25
  111. package/src/core/sources/types.ts +3 -1
  112. package/src/core/sources/watch.ts +5 -0
  113. package/src/core/standard-schema.ts +54 -0
  114. package/src/core/types.ts +23 -0
  115. package/src/deploy/adapter-output.ts +27 -15
  116. package/src/deploy/headers.ts +66 -0
  117. package/src/deploy/redirects.ts +49 -9
  118. package/src/markdown/index.ts +2 -0
  119. package/src/markdown/language-icon.ts +2 -1
  120. package/src/markdown/table-wrap.ts +43 -0
  121. package/src/og/card.ts +128 -36
  122. package/src/og/index.ts +1 -1
  123. package/src/og/logo.ts +21 -0
  124. package/src/openapi/references.ts +19 -16
  125. package/src/search/popular.ts +33 -0
  126. package/src/theme/entry.ts +56 -6
@@ -7,6 +7,8 @@ import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
7
7
  import { normalizeBasePath } from "./base-path.ts";
8
8
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
9
9
  import type { ContentSource } from "./sources/types.ts";
10
+ import { isStandardSchema } from "./standard-schema.ts";
11
+ import type { StandardSchema } from "./standard-schema.ts";
10
12
 
11
13
  /**
12
14
  * Public Blume schemas.
@@ -514,6 +516,13 @@ const PROVIDER_CONFIG_KEY = {
514
516
  typesense: "typesense",
515
517
  } as const;
516
518
 
519
+ /** Curated link for the search dialog empty state (internal route or external URL). */
520
+ const searchPopularLinkSchema = z.strictObject({
521
+ href: z.string(),
522
+ icon: iconName.optional(),
523
+ label: z.string(),
524
+ });
525
+
517
526
  const searchConfigSchema = z
518
527
  .strictObject({
519
528
  algolia: algoliaSearchSchema.optional(),
@@ -524,6 +533,8 @@ const searchConfigSchema = z
524
533
  .default({}),
525
534
  mixedbread: mixedbreadSearchSchema.optional(),
526
535
  oramaCloud: oramaCloudSearchSchema.optional(),
536
+ /** Curated links for the Cmd+K empty state; defaults to the first sidebar pages. */
537
+ popular: z.array(searchPopularLinkSchema).default([]),
527
538
  provider: z.enum(searchProviders).default("orama"),
528
539
  typesense: typesenseSearchSchema.optional(),
529
540
  })
@@ -672,7 +683,7 @@ const navigationConfigSchema = z.strictObject({
672
683
  .transform((value) =>
673
684
  Array.isArray(value) ? { display: "flat" as const, items: value } : value
674
685
  ),
675
- tabs: z.array(navTabSchema).optional(),
686
+ tabs: z.array(navTabSchema).default([]),
676
687
  });
677
688
 
678
689
  export type AskAiProvider = (typeof askAiProviders)[number];
@@ -802,6 +813,45 @@ const xConfigSchema = z.strictObject({
802
813
  handle: xHandleSchema,
803
814
  });
804
815
 
816
+ /**
817
+ * Any CSS color. Takumi parses the full grammar, so this stays unvalidated
818
+ * here and a bad value fails the OG prerender with a parse error naming it —
819
+ * the same fail-fast the card's accent relies on. Validating hex-only here
820
+ * would reject `oklch(…)`, which `theme.accent` (the card's default accent)
821
+ * already accepts.
822
+ */
823
+ const ogColorSchema = z.string();
824
+
825
+ const ogPaletteSchema = z.strictObject({
826
+ accent: ogColorSchema.optional(),
827
+ background: ogColorSchema.optional(),
828
+ border: ogColorSchema.optional(),
829
+ foreground: ogColorSchema.optional(),
830
+ muted: ogColorSchema.optional(),
831
+ });
832
+
833
+ /**
834
+ * A Google Font family to load into the OG card renderer. A bare string is the
835
+ * family name; the object form pins the weight (a number, a list, or a variable
836
+ * range like `"100..900"`) and style. Fetched from Google Fonts at build and
837
+ * handed to Takumi, which does per-glyph fallback so a family covering a script
838
+ * (e.g. Noto Sans JP for CJK) fixes tofu without touching how Latin renders.
839
+ */
840
+ const ogFontWeightSchema = z.union([
841
+ z.number().int().positive(),
842
+ z.array(z.number().int().positive()),
843
+ z.string().regex(/^\d+\.\.\d+$/u),
844
+ ]);
845
+ const ogFontStyleSchema = z.enum(["normal", "italic"]);
846
+ const ogFontSchema = z.union([
847
+ z.string(),
848
+ z.strictObject({
849
+ name: z.string(),
850
+ style: z.union([ogFontStyleSchema, z.array(ogFontStyleSchema)]).optional(),
851
+ weight: ogFontWeightSchema.optional(),
852
+ }),
853
+ ]);
854
+
805
855
  const ogConfigSchema = z.strictObject({
806
856
  /**
807
857
  * Generate a per-page Open Graph image. Defaults to on once a deployment
@@ -810,6 +860,16 @@ const ogConfigSchema = z.strictObject({
810
860
  * `loadConfig`. An explicit value here always wins.
811
861
  */
812
862
  enabled: z.boolean().optional(),
863
+ /**
864
+ * Google Font families for the generated card, extending Takumi's Latin-only
865
+ * default so non-Latin titles (CJK, and so on) render instead of tofu.
866
+ * Fetched from Google Fonts at build.
867
+ */
868
+ fonts: z.array(ogFontSchema).optional(),
869
+ /** Local SVG used in the generated card instead of the site logo. */
870
+ logo: z.string().optional(),
871
+ /** Optional generated-card colors. */
872
+ palette: ogPaletteSchema.optional(),
813
873
  });
814
874
 
815
875
  const rssConfigSchema = z.strictObject({
@@ -1025,6 +1085,41 @@ const asyncapiConfigSchema = z.strictObject({
1025
1085
  theme: z.string().optional(),
1026
1086
  });
1027
1087
 
1088
+ /**
1089
+ * Opt-in custom frontmatter keys. `extend` maps each extra key a project's
1090
+ * pages may carry (e.g. `owner`, `reviewedAt`) to a validation schema; the
1091
+ * page schema stays strict for everything else, so typo-catching is preserved.
1092
+ * Schemas are consumed through the Standard Schema `~standard` contract —
1093
+ * never Zod's own API — so the consumer's zod (any version), Valibot, or
1094
+ * ArkType all work (see `standard-schema.ts`). Every declared key is validated
1095
+ * on every page, absent ones included, so a required schema enforces the key
1096
+ * site-wide; mark it `.optional()` to validate only when present. Built-in
1097
+ * frontmatter fields can't be redeclared — they're load-bearing (routing,
1098
+ * sidebar, SEO), and shadowing one would silently change its semantics.
1099
+ */
1100
+ const frontmatterConfigSchema = z.strictObject({
1101
+ extend: z
1102
+ .record(
1103
+ z.string(),
1104
+ z.custom<StandardSchema>(isStandardSchema, {
1105
+ message:
1106
+ "Expected a Standard Schema (e.g. a Zod schema — any Zod version works).",
1107
+ })
1108
+ )
1109
+ .default({})
1110
+ .superRefine((value, ctx) => {
1111
+ for (const key of Object.keys(value)) {
1112
+ if (Object.hasOwn(pageMetaBaseSchema.shape, key)) {
1113
+ ctx.addIssue({
1114
+ code: z.ZodIssueCode.custom,
1115
+ message: `"${key}" is a built-in frontmatter field and cannot be redeclared via frontmatter.extend.`,
1116
+ path: [key],
1117
+ });
1118
+ }
1119
+ }
1120
+ }),
1121
+ });
1122
+
1028
1123
  /** Full user-facing config schema. All fields optional with defaults. */
1029
1124
  /**
1030
1125
  * Table-of-contents config. `true`/`false` toggles it; an object narrows the
@@ -1085,6 +1180,8 @@ export const blumeConfigSchema = z.strictObject({
1085
1180
  examples: examplesConfigSchema.default("examples"),
1086
1181
  export: exportConfigSchema.default(false),
1087
1182
  feedback: z.boolean().default(true),
1183
+ /** Opt-in custom frontmatter keys, validated by user-supplied schemas. */
1184
+ frontmatter: frontmatterConfigSchema.default({}),
1088
1185
  github: githubConfigSchema.optional(),
1089
1186
  i18n: i18nConfigSchema.optional(),
1090
1187
  lastModified: lastModifiedConfigSchema.default(false),
@@ -1103,6 +1200,8 @@ export const blumeConfigSchema = z.strictObject({
1103
1200
 
1104
1201
  /** Resolved config: every field present after defaults are applied. */
1105
1202
  export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
1203
+ /** Resolved `frontmatter.extend`: custom key → user-supplied schema. */
1204
+ export type FrontmatterExtend = Record<string, StandardSchema>;
1106
1205
  /** Resolved i18n block (present only when the project opts into i18n). */
1107
1206
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
1108
1207
  /** A configured locale with display metadata. */
@@ -4,10 +4,10 @@ import GithubSlugger from "github-slugger";
4
4
  import { extname } from "pathe";
5
5
 
6
6
  import { withBasePath } from "../base-path.ts";
7
- import { diagnosticsFromZod } from "../diagnostics.ts";
7
+ import { diagnosticsFromIssues, diagnosticsFromZod } from "../diagnostics.ts";
8
8
  import { localePlacement, localizeRoute } from "../i18n.ts";
9
9
  import { pageMetaSchema } from "../schema.ts";
10
- import type { PageMeta } from "../schema.ts";
10
+ import type { FrontmatterExtend, PageMeta } from "../schema.ts";
11
11
  import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
12
12
  import type { NormalizeContext, SourceEntry } from "./types.ts";
13
13
 
@@ -131,6 +131,21 @@ const PARAGRAPH_INTERRUPT = /^ {0,3}(?:[-+*][ \t]|\d{1,9}[.)][ \t]|>)/u;
131
131
  const THEMATIC_BREAK =
132
132
  /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/u;
133
133
  const FRONT_MATTER_CLOSE = /^(?:-{3}|\.{3})\s*$/u;
134
+ // `<Prompt>` renders its children into a permanently `hidden` DOM node (see
135
+ // `Prompt.astro`) — the agent-facing prompt text is never visible page
136
+ // content, only read by client JS for the copy button. Any `##` inside it
137
+ // must not surface in the page's heading-derived table of contents. Tracked
138
+ // as an open/close depth, the same way fenced code blocks are tracked above.
139
+ // The opening tag is matched only at the start of a trimmed line: block-level
140
+ // JSX in MDX starts its own line, so a mention mid-prose or mid-heading —
141
+ // "the `<Prompt>` component", `## Using <Prompt>` — never opens a hidden
142
+ // region (an unanchored match here silently ate every heading after the
143
+ // mention). The lookahead rejects longer tag names that share the prefix,
144
+ // like `<PromptCard>` or `<Prompt-Custom>`.
145
+ const PROMPT_OPEN = /^<Prompt(?![\w-])/u;
146
+ // Unanchored: while inside a prompt the close tag may trail the hidden
147
+ // children text (`...copy this.</Prompt>`), not just sit on its own line.
148
+ const PROMPT_CLOSE = /<\/Prompt>/u;
134
149
 
135
150
  /**
136
151
  * The body lines, minus a leading front matter block. Bodies from the
@@ -155,13 +170,46 @@ interface HeadingScanState {
155
170
  fence: FenceState;
156
171
  /** Consecutive paragraph lines — the candidate text for a setext underline. */
157
172
  paragraph: string[];
173
+ /** Nesting depth inside `<Prompt>...</Prompt>` — 0 when outside one. */
174
+ promptDepth: number;
175
+ /** True inside a multi-line `<Prompt` opening tag, awaiting its `>`. */
176
+ promptTag: boolean;
158
177
  }
159
178
 
179
+ /**
180
+ * Consume the rest of a `<Prompt` opening tag, scanning a trimmed line from
181
+ * `start`. The tag's attributes may spread over several lines
182
+ * (`state.promptTag` carries the search onto the next one), and until the
183
+ * terminating `>` arrives it isn't known whether the tag even has children —
184
+ * so the depth only rises once that `>` is found, and not when it turns out
185
+ * to be `/>` or when the element also closes on the same line
186
+ * (`<Prompt ...>copy this</Prompt>`). Attribute values containing `>` are not
187
+ * parsed: the first `>` ends the tag, which errs toward opening a region a
188
+ * real close tag will still exit.
189
+ */
190
+ const finishPromptTag = (
191
+ line: string,
192
+ start: number,
193
+ state: HeadingScanState
194
+ ): void => {
195
+ const end = line.indexOf(">", start);
196
+ if (end === -1) {
197
+ state.promptTag = true;
198
+ return;
199
+ }
200
+ state.promptTag = false;
201
+ if (line[end - 1] === "/" || line.includes("</Prompt>", end)) {
202
+ return;
203
+ }
204
+ state.promptDepth += 1;
205
+ };
206
+
160
207
  /**
161
208
  * Extract ATX and setext headings from a markdown body, skipping fenced code
162
- * blocks, exactly as the renderer sees them: ATX headings may be indented up
163
- * to 3 spaces, and a paragraph underlined with `=`/`-` is a level 1/2 setext
164
- * heading. Each heading's anchor slug comes from a per-document
209
+ * blocks and `<Prompt>` children, exactly as the renderer sees them: ATX
210
+ * headings may be indented up to 3 spaces, and a paragraph underlined with
211
+ * `=`/`-` is a level 1/2 setext heading. Each heading's anchor slug comes
212
+ * from a per-document
165
213
  * `github-slugger` — the exact slugger the renderer uses
166
214
  * (`markdown/heading-anchors`) — advanced over every heading in document
167
215
  * order. Matching it (rather than a hand-rolled slugify) keeps the manifest's
@@ -185,6 +233,28 @@ const scanHeadingLine = (
185
233
  state.paragraph = [];
186
234
  return;
187
235
  }
236
+ // Prompt tags may be indented arbitrarily (MDX has no indented code
237
+ // blocks), so they match against the trimmed line. A tag line can't also
238
+ // be a heading, so each just updates the state and moves on, same as a
239
+ // fence delimiter line. Outside a prompt, a `</Prompt>` line is plain text.
240
+ const trimmed = line.trimStart();
241
+ if (state.promptTag) {
242
+ finishPromptTag(trimmed, 0, state);
243
+ state.paragraph = [];
244
+ return;
245
+ }
246
+ if (PROMPT_OPEN.test(trimmed)) {
247
+ finishPromptTag(trimmed, "<Prompt".length, state);
248
+ state.paragraph = [];
249
+ return;
250
+ }
251
+ if (state.promptDepth > 0) {
252
+ if (PROMPT_CLOSE.test(line)) {
253
+ state.promptDepth -= 1;
254
+ }
255
+ state.paragraph = [];
256
+ return;
257
+ }
188
258
  const atx = line.match(ATX_HEADING);
189
259
  if (atx?.groups) {
190
260
  const depth = atx.groups.hashes?.length ?? 1;
@@ -220,7 +290,12 @@ const scanHeadingLine = (
220
290
  export const extractHeadings = (body: string): Heading[] => {
221
291
  const headings: Heading[] = [];
222
292
  const slugger = new GithubSlugger();
223
- const state: HeadingScanState = { fence: null, paragraph: [] };
293
+ const state: HeadingScanState = {
294
+ fence: null,
295
+ paragraph: [],
296
+ promptDepth: 0,
297
+ promptTag: false,
298
+ };
224
299
 
225
300
  for (const line of linesWithoutFrontMatter(body)) {
226
301
  scanHeadingLine(line, state, slugger, headings);
@@ -364,6 +439,118 @@ const withPrefix = (prefix: string | undefined, path: string): string => {
364
439
  return clean ? `${clean}/${path}` : path;
365
440
  };
366
441
 
442
+ /** A custom-key validation failure, lowered to a joinable diagnostic path. */
443
+ interface CustomKeyIssue {
444
+ message: string;
445
+ path: (string | number)[];
446
+ }
447
+
448
+ /** Lower a Standard Schema path segment (`key` or `{ key }`) for joining. */
449
+ const segmentKey = (
450
+ segment: PropertyKey | { readonly key: PropertyKey }
451
+ ): string | number => {
452
+ const key =
453
+ typeof segment === "object" && segment !== null ? segment.key : segment;
454
+ return typeof key === "symbol" ? String(key) : key;
455
+ };
456
+
457
+ /**
458
+ * Validate the opt-in custom frontmatter keys (`frontmatter.extend`) through
459
+ * the Standard Schema contract — the consumer's own Zod (any version),
460
+ * Valibot, or ArkType, never Blume's bundled zod (see `standard-schema.ts`).
461
+ * Every declared key is checked, absent ones included, so a required schema
462
+ * enforces its key on every page. Async schemas are rejected with a
463
+ * diagnostic: this funnel is synchronous, and frontmatter validation has no
464
+ * business awaiting I/O.
465
+ */
466
+ const validateCustomKeys = (
467
+ data: Record<string, unknown>,
468
+ extend: FrontmatterExtend
469
+ ): { custom?: Record<string, unknown>; issues: CustomKeyIssue[] } => {
470
+ const custom: Record<string, unknown> = {};
471
+ const issues: CustomKeyIssue[] = [];
472
+ for (const [key, schema] of Object.entries(extend)) {
473
+ const outcome = schema["~standard"].validate(data[key]);
474
+ if (outcome instanceof Promise) {
475
+ issues.push({
476
+ message: "Async schemas are not supported in frontmatter.extend.",
477
+ path: [key],
478
+ });
479
+ continue;
480
+ }
481
+ if (outcome.issues !== undefined) {
482
+ issues.push(
483
+ ...outcome.issues.map((issue) => ({
484
+ message: issue.message,
485
+ path: [key, ...(issue.path ?? []).map(segmentKey)],
486
+ }))
487
+ );
488
+ continue;
489
+ }
490
+ // Preserve the validated (schema-output) value; skip keys that are absent
491
+ // and stay absent, so `.optional()` extras don't materialize as undefined.
492
+ if (outcome.value !== undefined || Object.hasOwn(data, key)) {
493
+ custom[key] = outcome.value;
494
+ }
495
+ }
496
+ return {
497
+ custom: Object.keys(custom).length > 0 ? custom : undefined,
498
+ issues,
499
+ };
500
+ };
501
+
502
+ /**
503
+ * Parse an entry's frontmatter: built-in keys through the strict page schema,
504
+ * custom keys (`frontmatter.extend`) through their user-supplied schemas. The
505
+ * custom keys are carved out before the strict parse, so the page schema stays
506
+ * strict for everything else and unknown-key typo catching is unchanged.
507
+ * Returns diagnostics instead of meta when either side rejects.
508
+ */
509
+ const parseEntryMeta = (
510
+ entry: SourceEntry,
511
+ ctx: NormalizeContext
512
+ ):
513
+ | { meta: PageMeta; custom?: Record<string, unknown>; diagnostics?: never }
514
+ | { meta?: never; diagnostics: Diagnostic[] } => {
515
+ const extend = ctx.frontmatterExtend;
516
+ const known = extend
517
+ ? Object.fromEntries(
518
+ Object.entries(entry.data).filter(
519
+ ([key]) => !Object.hasOwn(extend, key)
520
+ )
521
+ )
522
+ : entry.data;
523
+
524
+ const result = pageMetaSchema.safeParse(known);
525
+ const customResult = extend ? validateCustomKeys(entry.data, extend) : null;
526
+
527
+ if (result.success && (customResult?.issues.length ?? 0) === 0) {
528
+ return { custom: customResult?.custom, meta: result.data };
529
+ }
530
+
531
+ // Source text lets the error carry a line/column into the frontmatter block:
532
+ // `entry.raw` for non-filesystem sources, else the file itself (read only on
533
+ // this rare error path, so filesystem entries stay cheap in the happy path).
534
+ const source =
535
+ entry.raw ??
536
+ (entry.sourcePath && existsSync(entry.sourcePath)
537
+ ? readFileSync(entry.sourcePath, "utf-8")
538
+ : undefined);
539
+ const location = {
540
+ code: "BLUME_FRONTMATTER_INVALID",
541
+ file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
542
+ source,
543
+ };
544
+ return {
545
+ diagnostics: [
546
+ ...(result.success ? [] : diagnosticsFromZod(result.error, location)),
547
+ ...(customResult
548
+ ? diagnosticsFromIssues(customResult.issues, location)
549
+ : []),
550
+ ],
551
+ };
552
+ };
553
+
367
554
  /**
368
555
  * Normalize one source entry into per-locale `PageRecord`s. This is the single
369
556
  * funnel every adapter's entries pass through, so route mapping, heading/link
@@ -376,27 +563,12 @@ export const normalizeEntry = (
376
563
  const { format } = entry.body;
377
564
  const ext = format === "mdx" ? ".mdx" : ".md";
378
565
 
379
- const result = pageMetaSchema.safeParse(entry.data);
380
- if (!result.success) {
381
- // Source text lets the error carry a line/column into the frontmatter block:
382
- // `entry.raw` for non-filesystem sources, else the file itself (read only on
383
- // this rare error path, so filesystem entries stay cheap in the happy path).
384
- const source =
385
- entry.raw ??
386
- (entry.sourcePath && existsSync(entry.sourcePath)
387
- ? readFileSync(entry.sourcePath, "utf-8")
388
- : undefined);
389
- return {
390
- diagnostics: diagnosticsFromZod(result.error, {
391
- code: "BLUME_FRONTMATTER_INVALID",
392
- file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
393
- source,
394
- }),
395
- pages: [],
396
- };
566
+ const parsed = parseEntryMeta(entry, ctx);
567
+ if (parsed.diagnostics) {
568
+ return { diagnostics: parsed.diagnostics, pages: [] };
397
569
  }
398
570
 
399
- const meta = result.data;
571
+ const { meta } = parsed;
400
572
 
401
573
  // Top-level `hidden`/`noindex` are accepted as shorthands for their nested
402
574
  // equivalents — the schema declares them, so silently ignoring them would
@@ -439,6 +611,7 @@ export const normalizeEntry = (
439
611
  componentsUsed:
440
612
  format === "mdx" ? extractComponentTags(entry.body.text) : undefined,
441
613
  contentType: meta.type ?? ctx.defaultType,
614
+ custom: parsed.custom,
442
615
  description: meta.description,
443
616
  editUrl: entry.editUrl,
444
617
  entryId: staged ? `${ctx.source.name}/${entry.ref}` : undefined,
@@ -1,4 +1,4 @@
1
- import type { ResolvedI18nConfig } from "../schema.ts";
1
+ import type { FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
2
2
  import type { Diagnostic } from "../types.ts";
3
3
 
4
4
  /**
@@ -109,5 +109,7 @@ export interface NormalizeContext {
109
109
  /** Site-wide route mount point (`""` or `/seg`), prepended to every route. */
110
110
  basePath?: string;
111
111
  defaultType: string;
112
+ /** Opt-in custom frontmatter keys (`frontmatter.extend`), schema per key. */
113
+ frontmatterExtend?: FrontmatterExtend;
112
114
  i18n?: ResolvedI18nConfig;
113
115
  }
@@ -23,6 +23,11 @@ import type { WatchListener } from "node:fs";
23
23
  */
24
24
  export const BLUME_IGNORE_DIRS = [
25
25
  ".blume",
26
+ // The isolated `blume check --isolated` runtime. A sibling of `.blume`, it is
27
+ // written while a dev server runs; without this the content-layer watcher (or
28
+ // a `.`-rooted fs.watch) would treat its generation as a content change and
29
+ // reload — the very thing `--isolated` promises not to do.
30
+ ".blume-verify",
26
31
  ".cache",
27
32
  ".git",
28
33
  ".next",
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The Standard Schema interface (https://standardschema.dev), the minimal
3
+ * `~standard` surface shared by Zod 3.24+, Zod 4, Valibot, ArkType, and
4
+ * friends. Blume accepts user-supplied validation schemas (e.g.
5
+ * `frontmatter.extend`) through this interface instead of Zod's own types:
6
+ * `blume.config.ts` imports zod from the *consumer's* node_modules, which may
7
+ * be a different major version than the zod Blume bundles, and calling Zod
8
+ * methods (`.extend()`, `.safeParse()`) across instances is unsupported. The
9
+ * `~standard.validate` contract is version- and library-agnostic.
10
+ */
11
+
12
+ /** One validation failure, with an optional path into the checked value. */
13
+ export interface StandardSchemaIssue {
14
+ readonly message: string;
15
+ readonly path?:
16
+ | readonly (PropertyKey | { readonly key: PropertyKey })[]
17
+ | undefined;
18
+ }
19
+
20
+ /** A passing validation: the (possibly transformed) output value. */
21
+ export interface StandardSchemaSuccess<Output> {
22
+ readonly value: Output;
23
+ readonly issues?: undefined;
24
+ }
25
+
26
+ /** A failing validation: one or more issues. */
27
+ export interface StandardSchemaFailure {
28
+ readonly issues: readonly StandardSchemaIssue[];
29
+ }
30
+
31
+ export type StandardSchemaResult<Output> =
32
+ | StandardSchemaSuccess<Output>
33
+ | StandardSchemaFailure;
34
+
35
+ /** A validation schema exposing the Standard Schema `~standard` contract. */
36
+ export interface StandardSchema<Input = unknown, Output = Input> {
37
+ readonly "~standard": {
38
+ readonly version: 1;
39
+ readonly vendor: string;
40
+ readonly validate: (
41
+ value: unknown
42
+ ) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
43
+ readonly types?:
44
+ | { readonly input: Input; readonly output: Output }
45
+ | undefined;
46
+ };
47
+ }
48
+
49
+ /** Whether a config-supplied value implements the `~standard` contract. */
50
+ export const isStandardSchema = (value: unknown): value is StandardSchema =>
51
+ typeof value === "object" &&
52
+ value !== null &&
53
+ typeof (value as { "~standard"?: { validate?: unknown } })["~standard"]
54
+ ?.validate === "function";
package/src/core/types.ts CHANGED
@@ -19,6 +19,13 @@ export interface Diagnostic {
19
19
  file?: string;
20
20
  line?: number;
21
21
  column?: number;
22
+ /**
23
+ * The built URL this diagnostic is about, for findings that are a property of
24
+ * the output rather than of a source file (`blume audit`). Set alongside
25
+ * `file`/`line` where the page maps back to authored content, so a finding can
26
+ * name both the URL that's wrong and the frontmatter line that fixes it.
27
+ */
28
+ url?: string;
22
29
  schemaPath?: string;
23
30
  suggestion?: string;
24
31
  docsUrl?: string;
@@ -115,6 +122,12 @@ export interface PageRecord {
115
122
  description?: string;
116
123
  contentType: string;
117
124
  meta: PageMeta;
125
+ /**
126
+ * Custom frontmatter values declared via `frontmatter.extend`, validated by
127
+ * the user-supplied schemas (schema output, so transforms apply). Present
128
+ * only when the project opts in and the page carries at least one value.
129
+ */
130
+ custom?: Record<string, unknown>;
118
131
  headings: Heading[];
119
132
  /** Whether the file is `.md`/`.mdx`. */
120
133
  format: "md" | "mdx";
@@ -159,7 +172,17 @@ export type NavNode =
159
172
  /** Top-level tab/section. */
160
173
  export interface NavTab {
161
174
  label: string;
175
+ /**
176
+ * The tab's section prefix, used to scope the sidebar and match the active
177
+ * tab. Not necessarily a linkable route — a section may have no index page.
178
+ */
162
179
  path: string;
180
+ /**
181
+ * The clickable target. Equals `path` when the section has an index page;
182
+ * otherwise it's resolved to the section's first page so the tab never links
183
+ * to a 404. Absent when it matches `path`.
184
+ */
185
+ href?: string;
163
186
  icon?: string;
164
187
  items?: NavSelectorItem[];
165
188
  }
@@ -8,6 +8,18 @@ import type { ProjectContext } from "../core/types.ts";
8
8
 
9
9
  type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
10
10
 
11
+ /**
12
+ * Top-level directory each server adapter writes its deploy bundle into, for
13
+ * `.gitignore` — the bundle is a build artifact, and the platform's own state
14
+ * lives alongside it (`.vercel/project.json`, `.netlify/state.json`), so the
15
+ * whole directory is ignored. `node` and `cloudflare` emit into `dist/`, which
16
+ * `blume init` already ignores.
17
+ */
18
+ export const ADAPTER_IGNORE_DIRS: Partial<Record<Adapter, string>> = {
19
+ netlify: ".netlify/",
20
+ vercel: ".vercel/",
21
+ };
22
+
11
23
  /**
12
24
  * Server adapters whose deploy bundle lands *outside* Astro's `outDir`, at a
13
25
  * path relative to the Astro project root. Blume points the Astro root at the
@@ -15,18 +27,21 @@ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
15
27
  * `<root>/.blume/<path>` — where the deploy platform never looks. Each value is
16
28
  * the sub-path to surface up to the real project root.
17
29
  *
18
- * `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
19
- * subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
20
- * project root survives the relocation. `netlify` writes a Frameworks API tree
21
- * at `.netlify/v1` (its `.netlify/build` sibling is only the intermediate SSR
22
- * bundle, already traced into `v1/functions`); only `v1` is moved, so the
23
- * `.netlify/state.json` written by `netlify link` survives too. `node` and
24
- * `cloudflare` emit into `dist/` (already at the project root), so they are
25
- * absent here and need no relocation.
30
+ * `netlify` writes a Frameworks API tree at `.netlify/v1` (its `.netlify/build`
31
+ * sibling is only the intermediate SSR bundle, already traced into
32
+ * `v1/functions`); only `v1` is moved, so the `.netlify/state.json` written by
33
+ * `netlify link` survives too. `node` and `cloudflare` emit into `dist/`
34
+ * (already at the project root), so they are absent here and need no
35
+ * relocation.
36
+ *
37
+ * `vercel` is absent for a different reason: it is shown the real project root
38
+ * up front (see `withAdapterRoot`), because its `@vercel/nft` dependency trace
39
+ * is rooted there too and tracing from `.blume` silently drops the function's
40
+ * chunks and `node_modules`. Given the right root it writes its Build Output
41
+ * tree straight to `<root>/.vercel/output`, so there is nothing left to move.
26
42
  */
27
43
  export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
28
44
  netlify: ".netlify/v1",
29
- vercel: ".vercel/output",
30
45
  };
31
46
 
32
47
  /**
@@ -53,10 +68,10 @@ export const deployStaticDir = (
53
68
  return dist;
54
69
  };
55
70
 
56
- /** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
71
+ /** Outcome of {@link surfaceAdapterOutput}, for logging. */
57
72
  export type SurfaceResult =
58
73
  | { moved: false }
59
- | { from: string; ignore: string; moved: true; to: string };
74
+ | { from: string; moved: true; to: string };
60
75
 
61
76
  /**
62
77
  * Move a server adapter's deploy bundle out of the hidden `.blume` runtime and
@@ -95,8 +110,5 @@ export const surfaceAdapterOutput = async (
95
110
  // bundle at `/var/task`).
96
111
  await cp(from, to, { recursive: true, verbatimSymlinks: true });
97
112
  await rm(from, { force: true, recursive: true });
98
- // The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
99
- // never the moved sub-path — the platform's own state (`.vercel/project.json`,
100
- // `.netlify/state.json`) lives there too and must also be ignored.
101
- return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
113
+ return { from, moved: true, to };
102
114
  };