blume 0.1.5 → 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 (85) hide show
  1. package/dist/cli/index.js +2123 -555
  2. package/dist/cli/index.js.map +39 -25
  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 +136 -508
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/docs/02-deployment.mdx +21 -2
  9. package/docs/advanced/changelog.mdx +28 -1
  10. package/docs/advanced/custom-pages.mdx +63 -2
  11. package/docs/configuration/ai.mdx +20 -3
  12. package/docs/configuration/customization.mdx +103 -5
  13. package/docs/configuration/index.mdx +25 -11
  14. package/docs/configuration/search.mdx +13 -1
  15. package/docs/configuration/seo.mdx +5 -0
  16. package/docs/configuration/theming.mdx +51 -0
  17. package/docs/content/components.mdx +18 -0
  18. package/docs/content/islands.mdx +73 -0
  19. package/docs/content/navigation.mdx +25 -0
  20. package/docs/content/sources.mdx +43 -0
  21. package/docs/content/syntax.mdx +1 -1
  22. package/docs/index.mdx +3 -12
  23. package/docs/reference/cli.mdx +49 -1
  24. package/docs/reference/frontmatter.mdx +9 -1
  25. package/package.json +3 -1
  26. package/src/ai/ask-context.ts +131 -0
  27. package/src/ai/ask-data.ts +25 -0
  28. package/src/astro/component-slots.ts +165 -0
  29. package/src/astro/generate.ts +162 -26
  30. package/src/astro/integration.ts +59 -0
  31. package/src/astro/pages.ts +5 -12
  32. package/src/astro/templates.ts +102 -45
  33. package/src/blume-modules.d.ts +25 -0
  34. package/src/cli/commands/build.ts +186 -1
  35. package/src/cli/commands/check.ts +62 -0
  36. package/src/cli/commands/dev.ts +21 -1
  37. package/src/cli/commands/doctor.ts +23 -6
  38. package/src/cli/commands/init.ts +163 -15
  39. package/src/cli/commands/validate.ts +16 -2
  40. package/src/cli/env.ts +84 -0
  41. package/src/cli/index.ts +20 -0
  42. package/src/cli/internal-error.ts +63 -0
  43. package/src/cli/log.ts +30 -1
  44. package/src/cli/prepare.ts +22 -3
  45. package/src/cli/required-secrets.ts +44 -0
  46. package/src/components/BlumePage.astro +107 -0
  47. package/src/components/content/CodeBlock.astro +7 -2
  48. package/src/components/index.ts +3 -3
  49. package/src/components/islands/ask-ai.tsx +15 -1
  50. package/src/components/islands/hooks.ts +188 -0
  51. package/src/components/layout/Empty.astro +6 -0
  52. package/src/components/layout/Header.astro +24 -39
  53. package/src/components/layout/Logo.astro +50 -0
  54. package/src/components/layout/NavSelector.astro +75 -0
  55. package/src/components/layout/PageLayout.astro +38 -2
  56. package/src/components/layout/RootLayout.astro +70 -4
  57. package/src/components/layout/hydration-hint.ts +30 -0
  58. package/src/components/layout/overrides.ts +6 -4
  59. package/src/components/props.ts +68 -0
  60. package/src/core/builtin-tags.ts +39 -0
  61. package/src/core/component-diagnostics.ts +44 -0
  62. package/src/core/component-overrides.ts +478 -0
  63. package/src/core/config.ts +8 -0
  64. package/src/core/data.ts +14 -0
  65. package/src/core/define-components.ts +9 -2
  66. package/src/core/diagnostics.ts +90 -1
  67. package/src/core/graph.ts +7 -0
  68. package/src/core/nav-diagnostics.ts +205 -0
  69. package/src/core/project-graph.ts +40 -1
  70. package/src/core/schema.ts +54 -96
  71. package/src/core/sources/github-releases.ts +200 -0
  72. package/src/core/sources/normalize.ts +51 -0
  73. package/src/core/sources/resolve.ts +16 -0
  74. package/src/core/types.ts +2 -2
  75. package/src/deploy/redirects.ts +43 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/migrate/mintlify/config.ts +1 -176
  78. package/src/migrate/starlight/config.ts +0 -4
  79. package/src/og/card.ts +163 -38
  80. package/src/registry/eject.ts +39 -9
  81. package/src/registry/registry.ts +166 -0
  82. package/src/runtime/index.ts +61 -0
  83. package/src/vite-env.d.ts +14 -0
  84. package/docs/changelog/v0-1-0.mdx +0 -12
  85. package/docs/changelog/v0-2-0.mdx +0 -16
@@ -14,10 +14,13 @@ import { pathToFileURL } from "node:url";
14
14
  import { basename, dirname, join, normalize, relative } from "pathe";
15
15
  import { glob } from "tinyglobby";
16
16
 
17
+ import { buildAskData } from "../ai/ask-data.ts";
17
18
  import { resolveAskBackend } from "../ai/ask.ts";
18
19
  import { buildRawMarkdown } from "../ai/markdown.ts";
19
20
  import { buildMcpData } from "../ai/mcp/data.ts";
20
21
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
22
+ import { validateUsedComponents } from "../core/component-diagnostics.ts";
23
+ import { analyzeComponentOverrides } from "../core/component-overrides.ts";
21
24
  import type {
22
25
  BlumeBanner,
23
26
  BlumeData,
@@ -26,6 +29,7 @@ import type {
26
29
  } from "../core/data.ts";
27
30
  import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
28
31
  import { resolveFallbackLocale } from "../core/i18n.ts";
32
+ import { validateNavTargets } from "../core/nav-diagnostics.ts";
29
33
  import { packageRoot } from "../core/package-root.ts";
30
34
  import type { BlumeProject } from "../core/project-graph.ts";
31
35
  import type { ResolvedConfig } from "../core/schema.ts";
@@ -37,12 +41,15 @@ import {
37
41
  hasReferences,
38
42
  referenceTabs,
39
43
  } from "../openapi/scalar.ts";
44
+ import { registry } from "../registry/registry.ts";
40
45
  import { buildSearchDocuments } from "../search/documents.ts";
41
46
  import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
42
47
  import { tailwindEntryTemplate } from "../theme/entry.ts";
43
48
  import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
44
49
  import { buildThemeCss } from "../theme/palette.ts";
45
50
  import { twoslashCss } from "../theme/twoslash.ts";
51
+ import { planComponentSlots } from "./component-slots.ts";
52
+ import type { ComponentSlotPlan } from "./component-slots.ts";
46
53
  import { discoverExamples } from "./examples.ts";
47
54
  import { discoverIslands } from "./islands.ts";
48
55
  import { customOgRoutes, discoverPages, routeIsTaken } from "./pages.ts";
@@ -72,7 +79,6 @@ import {
72
79
  searchClientTemplate,
73
80
  searchEndpointTemplate,
74
81
  stagedContentDir,
75
- userComponentsTemplate,
76
82
  } from "./templates.ts";
77
83
 
78
84
  /** Absolute path to the Blume package `src` directory. */
@@ -661,6 +667,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
661
667
  structuredData: config.seo.structuredData,
662
668
  theme: config.theme,
663
669
  title: config.title,
670
+ toc: config.toc,
664
671
  },
665
672
  feeds: buildRssFeeds(project).map((feed) => ({
666
673
  href: feed.path,
@@ -787,6 +794,33 @@ const writeMcpFiles = async (
787
794
  ]);
788
795
  };
789
796
 
797
+ /**
798
+ * Write the Ask AI endpoint and, unless the backend runs its own retrieval
799
+ * (Inkeep), the grounding snapshot the endpoint queries at request time. A no-op
800
+ * when Ask AI is disabled.
801
+ */
802
+ const writeAskFiles = async (
803
+ project: BlumeProject,
804
+ srcDir: string,
805
+ write: (path: string, content: string) => Promise<boolean>
806
+ ): Promise<void> => {
807
+ const { ask } = project.config.ai;
808
+ if (!ask?.enabled) {
809
+ return;
810
+ }
811
+ const grounded = ask.provider !== "inkeep";
812
+ if (grounded) {
813
+ await write(
814
+ join(srcDir, "generated", "ask-data.json"),
815
+ `${JSON.stringify(await buildAskData(project))}\n`
816
+ );
817
+ }
818
+ await write(
819
+ join(srcDir, "pages", "api", "ask.ts"),
820
+ askEndpointTemplate(resolveAskBackend(ask), grounded)
821
+ );
822
+ };
823
+
790
824
  /**
791
825
  * Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
792
826
  * static builds emit `dist/404.html`. Skipped when the project already owns
@@ -813,6 +847,57 @@ export interface GenerateResult {
813
847
  warnings: string[];
814
848
  }
815
849
 
850
+ /**
851
+ * Whether to generate the default `/changelog` index. Written when there are
852
+ * `type: changelog` entries — or when a release-backed changelog source is
853
+ * configured, so its route (and any nav tab pointing at it) still resolves to an
854
+ * empty timeline on a build where the source could not be fetched (e.g. CI
855
+ * without a token). Skipped when a user content page already owns `/changelog`.
856
+ */
857
+ const shouldGenerateChangelog = (project: BlumeProject): boolean => {
858
+ const hasChangelog = project.graph.pages.some(
859
+ (page) =>
860
+ page.contentType === "changelog" &&
861
+ !(page.meta.draft || page.meta.sidebar.hidden)
862
+ );
863
+ const hasChangelogSource = (project.config.content.sources ?? []).some(
864
+ (source) => source.type === "github-releases"
865
+ );
866
+ const changelogRouteTaken = project.graph.pages.some(
867
+ (page) => page.route === "/changelog"
868
+ );
869
+ return (hasChangelog || hasChangelogSource) && !changelogRouteTaken;
870
+ };
871
+
872
+ /**
873
+ * Statically analyze the user's `components.ts` (never executing it) and plan the
874
+ * generated `components.ts` module plus any hydration wrappers. Returns the plan
875
+ * and the analyzer's warnings; a project with no components file gets an empty
876
+ * plan and no warnings.
877
+ */
878
+ const buildComponentSlots = async (
879
+ componentsFile: string | null
880
+ ): Promise<{
881
+ plan: ComponentSlotPlan;
882
+ /** MDX tags the overrides define (for the unknown-component check). */
883
+ tags: string[];
884
+ warnings: string[];
885
+ }> => {
886
+ const analysis = componentsFile
887
+ ? analyzeComponentOverrides(
888
+ await readFile(componentsFile, "utf-8"),
889
+ componentsFile
890
+ )
891
+ : null;
892
+ return {
893
+ plan: planComponentSlots(componentsFile, analysis),
894
+ tags: analysis
895
+ ? [...analysis.mdx, ...analysis.islands].map((entry) => entry.key)
896
+ : [],
897
+ warnings: analysis ? analysis.warnings : [],
898
+ };
899
+ };
900
+
816
901
  /**
817
902
  * Write (or update) the generated `.blume/` Astro runtime for a project.
818
903
  * Only files whose content changed are rewritten so Vite HMR stays fast.
@@ -851,12 +936,23 @@ export const generateRuntime = async (
851
936
  discoverIslands(context.root),
852
937
  discoverExamples(context.root, config.examples),
853
938
  ]);
939
+ // Statically analyze `components.ts` overrides (never executed): drives the
940
+ // `islands` group, hydration on layout/mdx overrides, string-path resolution,
941
+ // and the "framework component with no client mode" diagnostic.
942
+ const {
943
+ plan: slotPlan,
944
+ tags: overrideTags,
945
+ warnings: overrideWarnings,
946
+ } = await buildComponentSlots(context.componentsFile);
947
+
854
948
  // Each island/example framework enables its Astro renderer. React also
855
949
  // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
856
- // island/example-driven. `.astro` examples need no renderer.
950
+ // island/example-driven. `.astro` examples need no renderer. Component
951
+ // overrides referencing a framework component enable its renderer too.
857
952
  const frameworks = new Set<string>([
858
953
  ...islandDiscovery.islands.map((island) => island.framework),
859
954
  ...exampleDiscovery.examples.map((example) => example.framework),
955
+ ...slotPlan.frameworks,
860
956
  ]);
861
957
  const needsReact = detectedReact || askEnabled || frameworks.has("react");
862
958
  const needsVue = frameworks.has("vue");
@@ -865,7 +961,7 @@ export const generateRuntime = async (
865
961
  // Custom pages that should get a generated OG card (the home most of all).
866
962
  // Computed before the MCP `.well-known` routes are appended below — those are
867
963
  // private and filtered out anyway, but the intent is the user's pages.
868
- const ogRoutes = customOgRoutes(pages, config.title, config.description);
964
+ const ogRoutes = customOgRoutes(pages, config.title);
869
965
 
870
966
  // The hosted MCP server. The `.well-known` discovery docs are injected as
871
967
  // prerendered routes alongside user pages; the server endpoint itself is a
@@ -915,12 +1011,10 @@ export const generateRuntime = async (
915
1011
  exportEpub,
916
1012
  exportPdf,
917
1013
  mathEnabled: config.markdown.math,
1014
+ needsReact,
918
1015
  })
919
1016
  ),
920
- write(
921
- join(srcDir, "generated", "components.ts"),
922
- userComponentsTemplate(context.componentsFile)
923
- ),
1017
+ write(join(srcDir, "generated", "components.ts"), slotPlan.module),
924
1018
  write(
925
1019
  join(srcDir, "generated", "islands.ts"),
926
1020
  islandMapTemplate(islandDiscovery.islands)
@@ -955,6 +1049,18 @@ export const generateRuntime = async (
955
1049
  )
956
1050
  );
957
1051
 
1052
+ // Per-override hydration wrappers for `defineComponents` islands and `client:*`
1053
+ // layout/mdx overrides. The generated `components.ts` (written above) imports
1054
+ // these; orphans from removed overrides are pruned at the end of the pass.
1055
+ await Promise.all(
1056
+ slotPlan.wrappers.map((wrapper) =>
1057
+ write(
1058
+ join(srcDir, "generated", "component-slots", `${wrapper.name}.astro`),
1059
+ wrapper.content
1060
+ )
1061
+ )
1062
+ );
1063
+
958
1064
  // Per-example live wrappers for the `examples/` convention, resolved by
959
1065
  // `<Component path>` through the `examples.ts` map (written above, always).
960
1066
  await Promise.all(
@@ -971,12 +1077,7 @@ export const generateRuntime = async (
971
1077
  )
972
1078
  );
973
1079
 
974
- if (askEnabled) {
975
- await write(
976
- join(srcDir, "pages", "api", "ask.ts"),
977
- askEndpointTemplate(resolveAskBackend(config.ai.ask))
978
- );
979
- }
1080
+ await writeAskFiles(project, srcDir, write);
980
1081
 
981
1082
  await writeMcpFiles(project, mcp, write);
982
1083
 
@@ -987,21 +1088,17 @@ export const generateRuntime = async (
987
1088
  );
988
1089
  }
989
1090
 
990
- // Changelog index (`/changelog`): a timeline of every `type: changelog` entry,
991
- // rendered through the Update layout. Skipped when there are no entries, or
992
- // when a user content page already occupies the `/changelog` route.
993
- const hasChangelog = project.graph.pages.some(
994
- (page) =>
995
- page.contentType === "changelog" &&
996
- !(page.meta.draft || page.meta.sidebar.hidden)
997
- );
998
- const changelogRouteTaken = project.graph.pages.some(
999
- (page) => page.route === "/changelog"
1000
- );
1001
- if (hasChangelog && !changelogRouteTaken) {
1091
+ // Changelog index (`/changelog`), rendered through the Update timeline layout.
1092
+ if (shouldGenerateChangelog(project)) {
1002
1093
  await write(
1003
1094
  join(srcDir, "pages", "changelog.astro"),
1004
- changelogIndexTemplate({ askEnabled, exportEpub, exportPdf })
1095
+ changelogIndexTemplate({
1096
+ askEnabled,
1097
+ exportEpub,
1098
+ exportPdf,
1099
+ needsReact,
1100
+ staged: hasStaged,
1101
+ })
1005
1102
  );
1006
1103
  }
1007
1104
 
@@ -1075,8 +1172,47 @@ export const generateRuntime = async (
1075
1172
  ...mcp.warnings,
1076
1173
  ...islandDiscovery.warnings,
1077
1174
  ...exampleDiscovery.warnings,
1175
+ ...overrideWarnings,
1078
1176
  ];
1079
1177
 
1178
+ // Missing-navigation-target check, now that every servable route is known:
1179
+ // content routes, custom `.astro` pages, the generated changelog, and any
1180
+ // OpenAPI reference tabs.
1181
+ const navTargetRoutes = new Set<string>([
1182
+ ...project.graph.routes.keys(),
1183
+ ...pages.map((page) => page.pattern),
1184
+ ...referenceTabs(config).map((tab) => tab.path),
1185
+ ]);
1186
+ if (shouldGenerateChangelog(project)) {
1187
+ navTargetRoutes.add("/changelog");
1188
+ }
1189
+ warnings.push(
1190
+ ...validateNavTargets(project.graph.navigation, navTargetRoutes).map(
1191
+ (diagnostic) =>
1192
+ diagnostic.suggestion
1193
+ ? `${diagnostic.message} ${diagnostic.suggestion}`
1194
+ : diagnostic.message
1195
+ )
1196
+ );
1197
+
1198
+ // Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
1199
+ // or a `components.ts` override. Needs the project's own components, known here.
1200
+ const knownComponentTags = new Set<string>([
1201
+ ...islandDiscovery.islands.map((island) => island.name),
1202
+ ...overrideTags,
1203
+ ]);
1204
+ warnings.push(
1205
+ ...validateUsedComponents(
1206
+ project.graph.pages,
1207
+ knownComponentTags,
1208
+ new Set(registry.map((item) => item.name))
1209
+ ).map((diagnostic) =>
1210
+ diagnostic.suggestion
1211
+ ? `${diagnostic.message} ${diagnostic.suggestion}`
1212
+ : diagnostic.message
1213
+ )
1214
+ );
1215
+
1080
1216
  // Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
1081
1217
  // when the configured provider's package isn't installed. A dep is available
1082
1218
  // if the project installed it (resolves from the root) OR Blume ships it
@@ -2,8 +2,64 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
 
3
3
  import type { AstroIntegration } from "astro";
4
4
 
5
+ import { enrichDiagnostic } from "../core/diagnostics.ts";
6
+ import type { Diagnostic } from "../core/types.ts";
5
7
  import { markdownVariantUrl, prefersMarkdown } from "./markdown-negotiation.ts";
6
8
 
9
+ /** The dev server's HMR channel — either `.ws` (Vite ≤5) or `.hot` (Vite 6+). */
10
+ interface OverlayChannel {
11
+ send: (payload: unknown) => void;
12
+ }
13
+ interface OverlayServer {
14
+ hot?: OverlayChannel;
15
+ ws?: OverlayChannel;
16
+ }
17
+
18
+ // Set on `astro:server:setup`; read by `showBlumeErrorOverlay` so the CLI's
19
+ // regeneration can push Blume diagnostics into Vite's browser error overlay.
20
+ // Same-process module singleton (dev and the integration share the instance).
21
+ let overlayServer: OverlayServer | null = null;
22
+
23
+ const overlayChannel = (): OverlayChannel | undefined =>
24
+ overlayServer?.ws ?? overlayServer?.hot;
25
+
26
+ /**
27
+ * Surface Blume's own diagnostics (config/frontmatter/content errors) in the
28
+ * Vite/Astro browser error overlay during `blume dev`, so they don't hide in the
29
+ * terminal. A no-op when there are no errors or the dev server isn't up. The
30
+ * overlay clears itself on the next successful HMR update.
31
+ */
32
+ export const showBlumeErrorOverlay = (diagnostics: Diagnostic[]): void => {
33
+ const errors = diagnostics
34
+ .filter((diagnostic) => diagnostic.severity === "error")
35
+ .map(enrichDiagnostic);
36
+ const channel = overlayChannel();
37
+ if (errors.length === 0 || !channel) {
38
+ return;
39
+ }
40
+ const body = errors
41
+ .map((diagnostic) => {
42
+ const where = diagnostic.file
43
+ ? `\n at ${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}`
44
+ : "";
45
+ const fix = diagnostic.suggestion
46
+ ? `\n fix: ${diagnostic.suggestion}`
47
+ : "";
48
+ const docs = diagnostic.docsUrl ? `\n docs: ${diagnostic.docsUrl}` : "";
49
+ return `[${diagnostic.code}] ${diagnostic.message}${where}${fix}${docs}`;
50
+ })
51
+ .join("\n\n");
52
+ channel.send({
53
+ err: {
54
+ id: errors[0]?.file,
55
+ message: `Blume found ${errors.length} error(s):\n\n${body}`,
56
+ plugin: "blume",
57
+ stack: "",
58
+ },
59
+ type: "error",
60
+ });
61
+ };
62
+
7
63
  /** A user page mounted into the generated runtime. */
8
64
  export interface BlumePageRoute {
9
65
  /** Route pattern, e.g. `/changelog` or `/examples/[slug]`. */
@@ -62,6 +118,9 @@ export const blumeIntegration = (
62
118
  }
63
119
  },
64
120
  "astro:server:setup": ({ server }) => {
121
+ // Keep a handle on the dev server so Blume diagnostics can be pushed to
122
+ // its browser error overlay (see `showBlumeErrorOverlay`).
123
+ overlayServer = server as unknown as OverlayServer;
65
124
  // Prepend so the rewrite happens before Astro's own request handler,
66
125
  // letting the rewritten URL resolve to the `.md` endpoint.
67
126
  server.middlewares.stack.unshift({
@@ -45,10 +45,8 @@ export const routeIsTaken = (
45
45
  export interface OgCustomRoute {
46
46
  /** `og/<slug>.png` path segment; `index` for the site root. */
47
47
  slug: string;
48
- /** Card title. */
48
+ /** Card headline. */
49
49
  title: string;
50
- /** Small eyebrow line above the title. */
51
- eyebrow?: string;
52
50
  }
53
51
 
54
52
  /** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
@@ -68,13 +66,12 @@ const humanizeSegment = (segment: string): string =>
68
66
  *
69
67
  * Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
70
68
  * are skipped: they aren't shareable pages. The home is titled with the site
71
- * title (eyebrow: the site description); a deeper page is titled from its last
72
- * path segment.
69
+ * title; a deeper page is titled from its last path segment. The card's brand
70
+ * lockup, description, and footer come from the resolved config at render time.
73
71
  */
74
72
  export const customOgRoutes = (
75
73
  pages: BlumePageRoute[],
76
- siteTitle: string,
77
- siteDescription?: string
74
+ siteTitle: string
78
75
  ): OgCustomRoute[] => {
79
76
  const seen = new Set<string>();
80
77
  const routes: OgCustomRoute[] = [];
@@ -91,11 +88,7 @@ export const customOgRoutes = (
91
88
  }
92
89
  seen.add(slug);
93
90
  const last = segments.at(-1);
94
- routes.push(
95
- last
96
- ? { eyebrow: siteTitle, slug, title: humanizeSegment(last) }
97
- : { eyebrow: siteDescription, slug, title: siteTitle }
98
- );
91
+ routes.push({ slug, title: last ? humanizeSegment(last) : siteTitle });
99
92
  }
100
93
  return routes;
101
94
  };