blume 0.2.0 → 0.3.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 (71) hide show
  1. package/dist/cli/index.js +1921 -560
  2. package/dist/cli/index.js.map +36 -24
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +26 -502
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/docs/02-deployment.mdx +21 -2
  9. package/docs/advanced/custom-pages.mdx +63 -1
  10. package/docs/configuration/ai.mdx +20 -3
  11. package/docs/configuration/customization.mdx +103 -5
  12. package/docs/configuration/index.mdx +13 -0
  13. package/docs/configuration/seo.mdx +5 -0
  14. package/docs/content/islands.mdx +73 -0
  15. package/docs/content/navigation.mdx +25 -0
  16. package/docs/index.mdx +3 -12
  17. package/docs/reference/cli.mdx +42 -0
  18. package/package.json +3 -1
  19. package/src/ai/ask-context.ts +131 -0
  20. package/src/ai/ask-data.ts +25 -0
  21. package/src/astro/component-slots.ts +165 -0
  22. package/src/astro/generate.ts +132 -13
  23. package/src/astro/integration.ts +59 -0
  24. package/src/astro/pages.ts +5 -12
  25. package/src/astro/templates.ts +92 -44
  26. package/src/blume-modules.d.ts +25 -0
  27. package/src/cli/commands/build.ts +186 -1
  28. package/src/cli/commands/check.ts +62 -0
  29. package/src/cli/commands/dev.ts +21 -1
  30. package/src/cli/commands/doctor.ts +23 -6
  31. package/src/cli/commands/init.ts +163 -15
  32. package/src/cli/commands/validate.ts +16 -2
  33. package/src/cli/index.ts +15 -0
  34. package/src/cli/internal-error.ts +63 -0
  35. package/src/cli/log.ts +30 -1
  36. package/src/cli/prepare.ts +17 -3
  37. package/src/cli/required-secrets.ts +44 -0
  38. package/src/components/BlumePage.astro +107 -0
  39. package/src/components/index.ts +3 -3
  40. package/src/components/islands/ask-ai.tsx +15 -1
  41. package/src/components/islands/hooks.ts +188 -0
  42. package/src/components/layout/Empty.astro +6 -0
  43. package/src/components/layout/Header.astro +24 -39
  44. package/src/components/layout/Logo.astro +50 -0
  45. package/src/components/layout/NavSelector.astro +75 -0
  46. package/src/components/layout/PageLayout.astro +38 -2
  47. package/src/components/layout/RootLayout.astro +70 -4
  48. package/src/components/layout/hydration-hint.ts +30 -0
  49. package/src/components/layout/overrides.ts +6 -4
  50. package/src/components/props.ts +68 -0
  51. package/src/core/builtin-tags.ts +39 -0
  52. package/src/core/component-diagnostics.ts +44 -0
  53. package/src/core/component-overrides.ts +478 -0
  54. package/src/core/config.ts +8 -0
  55. package/src/core/data.ts +14 -0
  56. package/src/core/define-components.ts +9 -2
  57. package/src/core/diagnostics.ts +90 -1
  58. package/src/core/graph.ts +7 -0
  59. package/src/core/nav-diagnostics.ts +205 -0
  60. package/src/core/project-graph.ts +40 -1
  61. package/src/core/schema.ts +28 -96
  62. package/src/core/sources/normalize.ts +51 -0
  63. package/src/core/types.ts +2 -2
  64. package/src/deploy/redirects.ts +43 -0
  65. package/src/migrate/mintlify/config.ts +1 -176
  66. package/src/migrate/starlight/config.ts +0 -4
  67. package/src/og/card.ts +163 -38
  68. package/src/registry/eject.ts +39 -9
  69. package/src/registry/registry.ts +166 -0
  70. package/src/runtime/index.ts +61 -0
  71. package/src/vite-env.d.ts +14 -0
@@ -0,0 +1,205 @@
1
+ import { hasIcon } from "../theme/icons.ts";
2
+ import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
3
+
4
+ /**
5
+ * Navigation diagnostics: catch icon typos and structural mistakes (missing
6
+ * pages, duplicate labels) that otherwise fail silently — a wrong icon just
7
+ * doesn't render, a bad tab path just 404s. Run over the built navigation so it
8
+ * covers every source (config, folder meta, frontmatter) at once.
9
+ */
10
+
11
+ const IMAGE_ICON =
12
+ /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
13
+
14
+ /** Whether an icon string is an asset (image/URL/inline SVG), not a set name. */
15
+ const isAssetIcon = (value: string): boolean =>
16
+ value.startsWith("<") || IMAGE_ICON.test(value);
17
+
18
+ /** Flatten a sidebar tree to every node, descending into groups. */
19
+ const flattenNodes = (nodes: NavNode[]): NavNode[] =>
20
+ nodes.flatMap((node) =>
21
+ node.kind === "group" ? [node, ...flattenNodes(node.children)] : [node]
22
+ );
23
+
24
+ /** Every icon string referenced anywhere in the navigation, with a label. */
25
+ const collectIcons = (
26
+ navigation: Navigation
27
+ ): { icon: string; where: string }[] => {
28
+ const icons: { icon: string; where: string }[] = [];
29
+ const push = (icon: string | undefined, where: string): void => {
30
+ if (icon) {
31
+ icons.push({ icon, where });
32
+ }
33
+ };
34
+ for (const tab of navigation.tabs) {
35
+ push(tab.icon, `tab "${tab.label}"`);
36
+ for (const item of tab.items ?? []) {
37
+ push(item.icon, `tab item "${item.label}"`);
38
+ }
39
+ }
40
+ for (const selector of navigation.selectors) {
41
+ for (const item of selector.items) {
42
+ push(item.icon, `selector "${item.label}"`);
43
+ }
44
+ }
45
+ const sidebars = [
46
+ navigation.sidebar,
47
+ ...navigation.sidebarVariants.map((variant) => variant.sidebar),
48
+ ];
49
+ for (const sidebar of sidebars) {
50
+ for (const node of flattenNodes(sidebar)) {
51
+ push(node.icon, `"${node.label}"`);
52
+ }
53
+ }
54
+ return icons;
55
+ };
56
+
57
+ /** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
58
+ export const validateNavIcons = (navigation: Navigation): Diagnostic[] => {
59
+ const seen = new Set<string>();
60
+ const diagnostics: Diagnostic[] = [];
61
+ for (const { icon, where } of collectIcons(navigation)) {
62
+ if (isAssetIcon(icon) || hasIcon(icon) || seen.has(icon)) {
63
+ continue;
64
+ }
65
+ seen.add(icon);
66
+ diagnostics.push({
67
+ code: "BLUME_UNKNOWN_ICON",
68
+ message: `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`,
69
+ severity: "warning",
70
+ suggestion:
71
+ "Use a built-in icon name, an image path/URL, or inline SVG markup.",
72
+ });
73
+ }
74
+ return diagnostics;
75
+ };
76
+
77
+ /** Whether an internal path resolves to a page or a section that has pages. */
78
+ const resolvesToPages = (routes: Set<string>, path: string): boolean =>
79
+ routes.has(path) || [...routes].some((route) => route.startsWith(`${path}/`));
80
+
81
+ /**
82
+ * Warn when a config-linked tab/selector target has no matching page. `routes`
83
+ * must be the full set of servable routes — content, custom `.astro` pages, and
84
+ * generated routes — so this runs where all three are known (`generateRuntime`),
85
+ * not in the content-only graph build.
86
+ */
87
+ export const validateNavTargets = (
88
+ navigation: Navigation,
89
+ routes: Set<string>
90
+ ): Diagnostic[] => {
91
+ const targets: { label: string; path: string }[] = [
92
+ ...navigation.tabs.map((tab) => ({ label: tab.label, path: tab.path })),
93
+ ...navigation.selectors.flatMap((selector) =>
94
+ selector.items.map((item) => ({ label: item.label, path: item.path }))
95
+ ),
96
+ ];
97
+ const diagnostics: Diagnostic[] = [];
98
+ const seen = new Set<string>();
99
+ for (const { label, path } of targets) {
100
+ // Only internal, non-anchor paths can be checked against routes.
101
+ if (!path.startsWith("/") || path.startsWith("/#") || seen.has(path)) {
102
+ continue;
103
+ }
104
+ if (!resolvesToPages(routes, path.split("#")[0] ?? path)) {
105
+ seen.add(path);
106
+ diagnostics.push({
107
+ code: "BLUME_NAV_MISSING_PAGE",
108
+ message: `Navigation entry "${label}" points to ${path}, but no page matches it.`,
109
+ severity: "warning",
110
+ suggestion: "Fix the path, or add a page at that route.",
111
+ });
112
+ }
113
+ }
114
+ return diagnostics;
115
+ };
116
+
117
+ /** Warn about two nav items sharing a label at the same sidebar level. */
118
+ const duplicateLabelDiagnostics = (navigation: Navigation): Diagnostic[] => {
119
+ const diagnostics: Diagnostic[] = [];
120
+ const checkLevel = (nodes: NavNode[], where: string): void => {
121
+ const counts = new Map<string, number>();
122
+ for (const node of nodes) {
123
+ counts.set(node.label, (counts.get(node.label) ?? 0) + 1);
124
+ }
125
+ for (const [label, count] of counts) {
126
+ if (count > 1) {
127
+ diagnostics.push({
128
+ code: "BLUME_NAV_DUPLICATE_LABEL",
129
+ message: `Duplicate sidebar label "${label}" appears ${count} times ${where}.`,
130
+ severity: "warning",
131
+ suggestion: "Give the entries distinct titles.",
132
+ });
133
+ }
134
+ }
135
+ for (const node of nodes) {
136
+ if (node.kind === "group") {
137
+ checkLevel(node.children, `under "${node.label}"`);
138
+ }
139
+ }
140
+ };
141
+ const sidebars: { nodes: NavNode[]; where: string }[] = [
142
+ { nodes: navigation.sidebar, where: "at the top level" },
143
+ ...navigation.sidebarVariants.map((variant) => ({
144
+ nodes: variant.sidebar,
145
+ where: `in the "${variant.path}" section`,
146
+ })),
147
+ ];
148
+ for (const { nodes, where } of sidebars) {
149
+ checkLevel(nodes, where);
150
+ }
151
+ return diagnostics;
152
+ };
153
+
154
+ /** Warn when a page shown in the sidebar is marked hidden (so pagination hits it). */
155
+ const hiddenInSidebarDiagnostics = (
156
+ navigation: Navigation,
157
+ pages: PageRecord[]
158
+ ): Diagnostic[] => {
159
+ const hidden = new Set(
160
+ pages.filter((page) => page.meta.sidebar.hidden).map((page) => page.id)
161
+ );
162
+ if (hidden.size === 0) {
163
+ return [];
164
+ }
165
+ const sidebars = [
166
+ navigation.sidebar,
167
+ ...navigation.sidebarVariants.map((variant) => variant.sidebar),
168
+ ];
169
+ const diagnostics: Diagnostic[] = [];
170
+ const seen = new Set<string>();
171
+ for (const sidebar of sidebars) {
172
+ for (const node of flattenNodes(sidebar)) {
173
+ if (
174
+ node.kind === "page" &&
175
+ hidden.has(node.pageId) &&
176
+ !seen.has(node.pageId)
177
+ ) {
178
+ seen.add(node.pageId);
179
+ diagnostics.push({
180
+ code: "BLUME_NAV_HIDDEN_IN_SIDEBAR",
181
+ message: `Page "${node.label}" is marked hidden but appears in the sidebar (and its pagination).`,
182
+ severity: "warning",
183
+ suggestion:
184
+ "Remove it from the navigation config, or unset sidebar.hidden.",
185
+ });
186
+ }
187
+ }
188
+ }
189
+ return diagnostics;
190
+ };
191
+
192
+ /**
193
+ * Structural navigation diagnostics that need only the built navigation +
194
+ * content pages: duplicate sidebar labels at a level, and hidden pages that
195
+ * still surface in the sidebar (so pagination lands on them). Target existence
196
+ * is checked separately by {@link validateNavTargets}, which needs the full
197
+ * route set.
198
+ */
199
+ export const validateNavStructure = (
200
+ navigation: Navigation,
201
+ pages: PageRecord[]
202
+ ): Diagnostic[] => [
203
+ ...duplicateLabelDiagnostics(navigation),
204
+ ...hiddenInSidebarDiagnostics(navigation, pages),
205
+ ];
@@ -24,6 +24,41 @@ import type {
24
24
  /** Build mode: drafts are kept in `dev` and dropped in `build`. */
25
25
  export type BuildMode = "dev" | "build";
26
26
 
27
+ /** CLI-supplied overrides applied over the loaded config (see `scanProject`). */
28
+ export interface ConfigOverrides {
29
+ /** Override `content.root` (`blume dev --content-dir`). */
30
+ contentRoot?: string;
31
+ /** Override `deployment.adapter` (`blume build --adapter`). */
32
+ adapter?: ResolvedConfig["deployment"]["adapter"];
33
+ /** Override `deployment.base` (`blume build --base`). */
34
+ base?: string;
35
+ /** Override `deployment.output` (`blume build --output`). */
36
+ output?: ResolvedConfig["deployment"]["output"];
37
+ }
38
+
39
+ /** Apply CLI config overrides onto a resolved config (returns a new object). */
40
+ const applyConfigOverrides = (
41
+ config: ResolvedConfig,
42
+ overrides?: ConfigOverrides
43
+ ): ResolvedConfig => {
44
+ if (!overrides) {
45
+ return config;
46
+ }
47
+ return {
48
+ ...config,
49
+ content: {
50
+ ...config.content,
51
+ root: overrides.contentRoot ?? config.content.root,
52
+ },
53
+ deployment: {
54
+ ...config.deployment,
55
+ adapter: overrides.adapter ?? config.deployment.adapter,
56
+ base: overrides.base ?? config.deployment.base,
57
+ output: overrides.output ?? config.deployment.output,
58
+ },
59
+ };
60
+ };
61
+
27
62
  /** Everything Blume knows about a project after a full scan. */
28
63
  export interface BlumeProject {
29
64
  mode: BuildMode;
@@ -51,13 +86,17 @@ export const scanProject = async (
51
86
  mode?: BuildMode;
52
87
  preview?: boolean;
53
88
  refresh?: boolean;
89
+ /** CLI overrides applied over the loaded config (e.g. `--output`). */
90
+ overrides?: ConfigOverrides;
54
91
  } = {}
55
92
  ): Promise<BlumeProject> => {
56
93
  const mode = options.mode ?? "dev";
57
94
  const preview = options.preview ?? false;
58
- const { bridge, config } = await loadConfig(root, {
95
+ const configResult = await loadConfig(root, {
59
96
  devServerUrl: options.devServerUrl,
60
97
  });
98
+ const { bridge } = configResult;
99
+ const config = applyConfigOverrides(configResult.config, options.overrides);
61
100
  const context = resolveProjectContext(root, config);
62
101
 
63
102
  // Each source validates itself (e.g. the filesystem source checks its root
@@ -462,38 +462,6 @@ const sidebarVariantSchema = z
462
462
  })
463
463
  .strict();
464
464
 
465
- const navbarLinkTypeSchema = z.enum(["github", "discord"]);
466
-
467
- const navbarLinkSchema = z
468
- .object({
469
- href: z.string(),
470
- icon: iconName.optional(),
471
- label: z.string().optional(),
472
- type: navbarLinkTypeSchema.optional(),
473
- })
474
- .strict()
475
- .refine((value) => value.label !== undefined || value.type !== undefined, {
476
- message: "Navbar links require either label or type.",
477
- });
478
-
479
- const navbarPrimarySchema = z
480
- .object({
481
- href: z.string(),
482
- label: z.string().optional(),
483
- type: z.enum(["button", "github", "discord"]).default("button"),
484
- })
485
- .strict()
486
- .refine((value) => value.label !== undefined || value.type !== "button", {
487
- message: "Navbar primary button links require a label.",
488
- });
489
-
490
- const navbarConfigSchema = z
491
- .object({
492
- links: z.array(navbarLinkSchema).default([]),
493
- primary: navbarPrimarySchema.optional(),
494
- })
495
- .strict();
496
-
497
465
  const variablesConfigSchema = z
498
466
  .record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
499
467
  .default({});
@@ -528,12 +496,6 @@ const themeConfigSchema = z
528
496
  })
529
497
  .strict();
530
498
 
531
- const iconsConfigSchema = z
532
- .object({
533
- library: z.enum(["fontawesome", "lucide", "tabler"]).default("lucide"),
534
- })
535
- .strict();
536
-
537
499
  /** Public credentials for the Algolia search backend (sync key is an env var). */
538
500
  const algoliaSearchSchema = z
539
501
  .object({
@@ -661,56 +623,9 @@ const aiConfigSchema = z
661
623
  })
662
624
  .strict();
663
625
 
664
- const contextualOptionSchema = z.union([
665
- z.string(),
666
- z
667
- .object({
668
- description: z.string().optional(),
669
- href: z.string().optional(),
670
- icon: iconName.optional(),
671
- title: z.string(),
672
- })
673
- .passthrough(),
674
- ]);
675
-
676
- const contextualConfigSchema = z
677
- .object({
678
- display: z.enum(["header", "toc"]).default("header"),
679
- options: z.array(contextualOptionSchema).default([]),
680
- })
681
- .strict();
682
-
683
- const footerConfigSchema = z
684
- .object({
685
- links: z
686
- .array(
687
- z
688
- .object({
689
- header: z.string().optional(),
690
- items: z
691
- .array(
692
- z
693
- .object({
694
- href: z.string(),
695
- label: z.string(),
696
- })
697
- .strict()
698
- )
699
- .default([]),
700
- })
701
- .strict()
702
- )
703
- .max(4)
704
- .default([]),
705
- socials: z.record(z.string(), z.string()).default({}),
706
- })
707
- .strict();
708
-
709
626
  const chromeVariantSchema = z
710
627
  .object({
711
628
  banner: bannerConfigSchema.optional(),
712
- footer: footerConfigSchema.optional(),
713
- navbar: navbarConfigSchema.optional(),
714
629
  path: z.string(),
715
630
  })
716
631
  .strict();
@@ -980,12 +895,6 @@ const markdownConfigSchema = z
980
895
  })
981
896
  .strict();
982
897
 
983
- const stylingConfigSchema = z
984
- .object({
985
- eyebrows: z.enum(["breadcrumbs", "section"]).default("section"),
986
- })
987
- .strict();
988
-
989
898
  /**
990
899
  * A single spec rendered by the API reference (Scalar). `spec` is a local path
991
900
  * or an `http(s)` URL; Scalar auto-detects OpenAPI vs AsyncAPI documents.
@@ -1037,6 +946,33 @@ const asyncapiConfigSchema = z
1037
946
  .strict();
1038
947
 
1039
948
  /** Full user-facing config schema. All fields optional with defaults. */
949
+ /**
950
+ * Table-of-contents config. `true`/`false` toggles it; an object narrows the
951
+ * heading range. Normalized to `{ enabled, minLevel, maxLevel }` (default: on,
952
+ * H2–H3, matching the historical hardcoded range).
953
+ */
954
+ const tocConfigSchema = z
955
+ .union([
956
+ z.boolean(),
957
+ z
958
+ .object({
959
+ maxHeadingLevel: z.number().int().min(1).max(6).optional(),
960
+ minHeadingLevel: z.number().int().min(1).max(6).optional(),
961
+ })
962
+ .strict(),
963
+ ])
964
+ .default(true)
965
+ .transform((value) => {
966
+ if (typeof value === "boolean") {
967
+ return { enabled: value, maxLevel: 3, minLevel: 2 };
968
+ }
969
+ return {
970
+ enabled: true,
971
+ maxLevel: value.maxHeadingLevel ?? 3,
972
+ minLevel: value.minHeadingLevel ?? 2,
973
+ };
974
+ });
975
+
1040
976
  export const blumeConfigSchema = z
1041
977
  .object({
1042
978
  ai: aiConfigSchema.default({}),
@@ -1044,7 +980,6 @@ export const blumeConfigSchema = z
1044
980
  asyncapi: asyncapiConfigSchema.default({}),
1045
981
  banner: bannerConfigSchema.optional(),
1046
982
  content: contentConfigSchema.default({}),
1047
- contextual: contextualConfigSchema.default({}),
1048
983
  deployment: deploymentConfigSchema.default({}),
1049
984
  description: z.string().optional(),
1050
985
  /**
@@ -1064,23 +999,20 @@ export const blumeConfigSchema = z
1064
999
  export: exportConfigSchema.default(false),
1065
1000
  favicon: faviconConfigSchema.optional(),
1066
1001
  feedback: z.boolean().default(true),
1067
- footer: footerConfigSchema.default({}),
1068
1002
  github: githubConfigSchema.optional(),
1069
1003
  i18n: i18nConfigSchema.optional(),
1070
- icons: iconsConfigSchema.default({}),
1071
1004
  lastModified: lastModifiedConfigSchema.default(false),
1072
1005
  logo: logoConfigSchema.optional(),
1073
1006
  markdown: markdownConfigSchema.default({}),
1074
1007
  mcp: mcpConfigSchema.default({}),
1075
- navbar: navbarConfigSchema.default({}),
1076
1008
  navigation: navigationConfigSchema.default({}),
1077
1009
  openapi: openapiConfigSchema.default({}),
1078
1010
  redirects: z.array(redirectSchema).default([]),
1079
1011
  search: searchConfigSchema.default({}),
1080
1012
  seo: seoConfigSchema.default({}),
1081
- styling: stylingConfigSchema.default({}),
1082
1013
  theme: themeConfigSchema.default({}),
1083
1014
  title: z.string().default("Documentation"),
1015
+ toc: tocConfigSchema,
1084
1016
  variables: variablesConfigSchema,
1085
1017
  })
1086
1018
  .strict();
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
1
3
  import GithubSlugger from "github-slugger";
2
4
  import { extname } from "pathe";
3
5
 
@@ -146,6 +148,44 @@ export const extractLinks = (body: string): PageLink[] => {
146
148
  return links;
147
149
  };
148
150
 
151
+ const INLINE_CODE = /`[^`]*`/gu;
152
+ // Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
153
+ // `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
154
+ // isn't a real usage. Single quotes are left alone so prose apostrophes don't
155
+ // swallow a real tag between two words.
156
+ const DOUBLE_QUOTED = /"[^"]*"/gu;
157
+ const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
158
+
159
+ /**
160
+ * Capitalized JSX component tags used in an `.mdx` body (`<Callout>`,
161
+ * `<Tree.File>` → `Tree`). Skips fenced code, inline code, and double-quoted
162
+ * strings so code samples and prose don't count. Powers the missing-component
163
+ * diagnostic.
164
+ */
165
+ export const extractComponentTags = (body: string): string[] => {
166
+ const tags = new Set<string>();
167
+ let inFence = false;
168
+ for (const line of body.split("\n")) {
169
+ if (CODE_FENCE.test(line.trimStart())) {
170
+ inFence = !inFence;
171
+ continue;
172
+ }
173
+ if (inFence) {
174
+ continue;
175
+ }
176
+ const clean = line
177
+ .replaceAll(INLINE_CODE, "")
178
+ .replaceAll(DOUBLE_QUOTED, "");
179
+ for (const match of clean.matchAll(JSX_OPEN)) {
180
+ const tag = match.groups?.tag;
181
+ if (tag) {
182
+ tags.add(tag);
183
+ }
184
+ }
185
+ }
186
+ return [...tags];
187
+ };
188
+
149
189
  const deriveTitle = (
150
190
  meta: PageMeta,
151
191
  headings: Heading[],
@@ -179,10 +219,19 @@ export const normalizeEntry = (
179
219
 
180
220
  const result = pageMetaSchema.safeParse(entry.data);
181
221
  if (!result.success) {
222
+ // Source text lets the error carry a line/column into the frontmatter block:
223
+ // `entry.raw` for non-filesystem sources, else the file itself (read only on
224
+ // this rare error path, so filesystem entries stay cheap in the happy path).
225
+ const source =
226
+ entry.raw ??
227
+ (entry.sourcePath && existsSync(entry.sourcePath)
228
+ ? readFileSync(entry.sourcePath, "utf-8")
229
+ : undefined);
182
230
  return {
183
231
  diagnostics: diagnosticsFromZod(result.error, {
184
232
  code: "BLUME_FRONTMATTER_INVALID",
185
233
  file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
234
+ source,
186
235
  }),
187
236
  pages: [],
188
237
  };
@@ -212,6 +261,8 @@ export const normalizeEntry = (
212
261
  const base = {
213
262
  body: staged ? { format, text: entry.raw ?? entry.body.text } : undefined,
214
263
  collection: staged ? "staged" : undefined,
264
+ componentsUsed:
265
+ format === "mdx" ? extractComponentTags(entry.body.text) : undefined,
215
266
  contentType: meta.type ?? ctx.defaultType,
216
267
  description: meta.description,
217
268
  editUrl: entry.editUrl,
package/src/core/types.ts CHANGED
@@ -113,6 +113,8 @@ export interface PageRecord {
113
113
  format: "md" | "mdx";
114
114
  /** Internal/asset links discovered in the page (for validation). */
115
115
  links: PageLink[];
116
+ /** Capitalized JSX component tags used in the body (`.mdx` only). */
117
+ componentsUsed?: string[];
116
118
  /** Resolved "last updated" ISO date, when the feature is enabled. */
117
119
  lastModified?: string;
118
120
  }
@@ -181,8 +183,6 @@ export interface NavSidebarVariant {
181
183
  export interface NavChromeVariant {
182
184
  path: string;
183
185
  banner?: ResolvedConfig["banner"];
184
- footer?: ResolvedConfig["footer"];
185
- navbar?: ResolvedConfig["navbar"];
186
186
  }
187
187
 
188
188
  /** The complete navigation model derived from the content graph. */
@@ -0,0 +1,43 @@
1
+ import type { ResolvedConfig } from "../core/schema.ts";
2
+
3
+ /**
4
+ * Platform redirect files for a static build. Astro already emits redirect HTML
5
+ * (meta-refresh) pages for `deployment.output: "static"`, but that's a soft
6
+ * client redirect. These give the host a real HTTP 3xx: Netlify/Cloudflare read
7
+ * `_redirects`, Vercel reads `vercel.json`, and `blume-redirects.json` is a
8
+ * structured manifest for anything else (Apache/nginx rules, an edge worker).
9
+ */
10
+
11
+ type Redirect = ResolvedConfig["redirects"][number];
12
+
13
+ /** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
14
+ export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
15
+ `${redirects
16
+ .map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
17
+ .join("\n")}\n`;
18
+
19
+ /** `vercel.json` contents with a `redirects` array (permanent = 301/308). */
20
+ export const buildVercelConfig = (redirects: Redirect[]): string =>
21
+ `${JSON.stringify(
22
+ {
23
+ redirects: redirects.map((redirect) => ({
24
+ destination: redirect.to,
25
+ permanent: redirect.status === 301 || redirect.status === 308,
26
+ source: redirect.from,
27
+ })),
28
+ },
29
+ null,
30
+ 2
31
+ )}\n`;
32
+
33
+ /** Structured manifest for hosts that need manual wiring. */
34
+ export const buildRedirectManifest = (redirects: Redirect[]): string =>
35
+ `${JSON.stringify(
36
+ redirects.map((redirect) => ({
37
+ from: redirect.from,
38
+ status: redirect.status,
39
+ to: redirect.to,
40
+ })),
41
+ null,
42
+ 2
43
+ )}\n`;