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
@@ -88,6 +88,49 @@ a remote source for changes instead, set `pollInterval` (seconds) on it — the
88
88
  dev server re-fetches on that interval and reloads only when the content
89
89
  actually changes. Leave it unset to avoid hitting the API while you work.
90
90
 
91
+ ## GitHub Releases
92
+
93
+ The built-in `github-releases` source turns a repo's releases into a changelog:
94
+ each release becomes a `type: changelog` entry, so your release notes _are_ your
95
+ changelog — nothing to write twice. Combined with the generated
96
+ [changelog timeline](/docs/advanced/changelog), publishing a GitHub release
97
+ ships a changelog entry.
98
+
99
+ ```ts blume.config.ts
100
+ import { defineConfig } from "blume";
101
+
102
+ export default defineConfig({
103
+ content: {
104
+ sources: [
105
+ { type: "filesystem", root: "content" },
106
+ {
107
+ type: "github-releases",
108
+ prefix: "changelog",
109
+ owner: "acme",
110
+ repo: "sdk",
111
+ // prereleases: false, // include prereleases (default off)
112
+ // drafts: false, // include drafts (needs a write token)
113
+ // limit: 100, // cap releases, newest-first
114
+ },
115
+ ],
116
+ },
117
+ });
118
+ ```
119
+
120
+ Each release maps to the changelog fields automatically: its name (or tag)
121
+ becomes the title, its published date drives the timeline order, the tag becomes
122
+ `changelog.version`, and prereleases are tagged `Prerelease` (others `Release`).
123
+ The notes render as the entry body. Give the source a `prefix` so its release
124
+ pages nest under a route like `/changelog/v1-2-0`.
125
+
126
+ A private repo authenticates with the `GITHUB_TOKEN` environment variable — the
127
+ same token the other GitHub features use, never inlined into your config. Like
128
+ every remote source it's cached under `.blume/cache/<source>/` and served
129
+ offline if the API is unreachable. Because a changelog is supplementary, a fetch
130
+ failure with no cache (say a CI build without a token) degrades to an empty
131
+ changelog with a warning rather than failing the build — set `GITHUB_TOKEN` in
132
+ your CI and deploy environments to populate it.
133
+
91
134
  ## Sanity
92
135
 
93
136
  The built-in `sanity` source runs a GROQ query and maps each document's fields
@@ -11,7 +11,7 @@ each.
11
11
  ## Headings
12
12
 
13
13
  Structure a page with headings. Blume renders your frontmatter `title` as the
14
- page heading, so start your content at `##` — `##` through `####` become entries
14
+ page heading, so start your content at `##` — `##` and `###` become entries
15
15
  in the table of contents. Every `##`–`######` heading is also wrapped in a link
16
16
  to its own anchor, so readers can click a heading to copy, bookmark, or share a
17
17
  permalink straight to that section (hover to reveal the `#`). Turn this off with
package/docs/index.mdx CHANGED
@@ -21,18 +21,9 @@ library — with no app boilerplate to write or maintain.
21
21
 
22
22
  ## Why Blume exists
23
23
 
24
- Blume is the answer to a problem I kept trying to solve at Vercel: documentation
25
- should be _fast_, _AI-ready_, and require _zero configuration_ — down to not
26
- needing a starter template at all.
24
+ Docs should be fast, AI-ready and require zero configuration — down to not needing a starter template at all. Some docs tools hand you an entire codebase to maintain before you've written a word. Others build the template around your content, but lock you in to their managed service.
27
25
 
28
- Most docs tools hand you a project to own before you've written a word: an app to
29
- scaffold, a framework to learn, a template to keep in sync with upstream. Blume
30
- flips that around. The framework _is_ the template, so the only thing you ever
31
- touch is your content. When you outgrow the defaults, you add configuration one
32
- file at a time — and you can `blume eject` to a plain Astro project the day you
33
- want full control.
34
-
35
- — Hayden Bleasel
26
+ Blume takes the best of both worlds. The framework is the template, so the only thing you ever touch is your content. When you want to customize, you can start replacing the built-in components, modifying the single configuration file or even ejecting if you want the Astro site directly.
36
27
 
37
28
  ## What makes Blume different
38
29
 
@@ -83,7 +74,7 @@ loosely-typed YAML.
83
74
  - **SEO** — metadata, Open Graph images, RSS feeds, and JSON-LD, [built in](/docs/configuration/seo).
84
75
  - **Customization** — component overrides, React islands, custom pages, theme
85
76
  tokens, and a source-component registry via `blume add`.
86
- - **Migration** — `blume migrate mintlify | starlight | fumadocs`.
77
+ - **Migration** — `blume migrate mintlify | starlight | nextra | fumadocs`.
87
78
  - **Eject** — `blume eject` produces a standalone Astro project that still uses
88
79
  the `blume` package.
89
80
 
@@ -17,17 +17,65 @@ blume <command> [options]
17
17
  | `blume preview` | Preview the last build. |
18
18
  | `blume add <item>` | Install a source component from the registry. |
19
19
  | `blume migrate <tool>` | Migrate from Mintlify, Starlight, Nextra, or Fumadocs. |
20
+ | `blume sync` | Re-fetch remote content sources and regenerate. |
20
21
  | `blume eject` | Promote the runtime into a standalone Astro app. |
22
+ | `blume check` | Type-check the site with `astro check`. |
21
23
  | `blume doctor` | Diagnose config and content problems. |
22
24
  | `blume validate` | Validate links across your content. |
23
25
 
24
26
  ## Common flags
25
27
 
28
+ - `blume init --content-dir <dir> --yes` — set the content folder (default `docs`) and skip prompts.
29
+ - `blume init --template docs|api|sdk|changelog` — scaffold from a starter (API reference, SDK, or changelog instead of the plain docs seed).
30
+ - `blume init --package-manager npm|pnpm|yarn|bun` — tailor the printed next-steps to your package manager.
31
+ - `blume init --eject` — scaffold, then eject to a standalone Astro project (falls back to guiding you through `blume eject` when dependencies aren't installed yet).
26
32
  - `blume dev --host --port <n> --open`
27
- - `blume build --strict`fail on diagnostics.
33
+ - `blume dev --content-dir <dir>` scan a different content folder without editing `blume.config.ts`.
34
+ - `blume dev --debug` — verbose Astro/Vite logging for troubleshooting.
35
+ - `blume dev --preview` / `blume build --preview` — include drafts and unpublished CMS content.
36
+ - `blume build --strict` — fail the build on diagnostic errors (also works on `blume dev`).
37
+ - `blume build --output static|server --adapter vercel|node|netlify|cloudflare --base /docs` — override the deployment output, adapter, and base path from `blume.config.ts`.
38
+ - `blume build --analyze` — print the client JavaScript bundle sizes (largest first) after the build.
39
+ - `blume build --budget-js <kb> --budget-css <kb>` — fail the build when total client JavaScript/CSS exceeds the budget, turning a performance target into a CI gate.
40
+ - `blume preview --host --port <n>` — bind the preview server.
41
+ - `blume sync --force` — re-fetch remote sources, dropping the cached snapshot first.
42
+ - `blume add <item> --force` — overwrite files that already exist.
43
+ - `blume check --preview` — include drafts and unpublished CMS content when checking.
44
+ - `blume check --strict` — fail on content diagnostics as well as type errors.
28
45
  - `blume eject --yes` — skip the confirmation prompt.
29
46
  - `blume validate --external` — also check external links over the network.
30
47
  - `blume validate --strict` — exit non-zero on warnings too.
48
+ - `blume validate --json` / `blume doctor --json` — emit diagnostics as JSON on
49
+ stdout (with `code`, `severity`, `file`, `line`/`column`, and `docsUrl`) for CI
50
+ and editor integrations.
51
+
52
+ ## Type-checking
53
+
54
+ `blume check` runs [`astro check`](https://docs.astro.build/en/reference/cli-reference/#astro-check)
55
+ over your project. It regenerates the `.blume` runtime, syncs Astro's content
56
+ types, then reports any TypeScript errors — in your `blume.config.ts`, in custom
57
+ `.astro` pages, and in the components they import. It exits non-zero when there
58
+ are errors, so it works as a `typecheck` step in CI:
59
+
60
+ ```json title="package.json"
61
+ {
62
+ "scripts": {
63
+ "typecheck": "blume check"
64
+ }
65
+ }
66
+ ```
67
+
68
+ Add a `tsconfig.json` extending Astro's config to your project root so authored
69
+ pages resolve `blume/*` imports and virtual modules like `blume:data`:
70
+
71
+ ```json title="tsconfig.json"
72
+ {
73
+ "extends": "astro/tsconfigs/strict",
74
+ "include": [".blume/.astro/types.d.ts", ".blume/src/env.d.ts", "**/*"]
75
+ }
76
+ ```
77
+
78
+ Without a project `tsconfig.json`, only the generated runtime is checked.
31
79
 
32
80
  ## Validating links
33
81
 
@@ -24,6 +24,11 @@ Every page accepts the following frontmatter. All fields are optional.
24
24
  default: "false",
25
25
  description: "Exclude from production builds.",
26
26
  },
27
+ lastModified: {
28
+ type: "string",
29
+ description:
30
+ 'Pin the page\'s "last updated" date (ISO or YAML date); overrides the git-derived date.',
31
+ },
27
32
  }}
28
33
  />
29
34
 
@@ -33,7 +38,8 @@ Every page accepts the following frontmatter. All fields are optional.
33
38
  sidebar:
34
39
  label: Install
35
40
  order: 2
36
- icon: Download
41
+ icon: download
42
+ badge: New
37
43
  hidden: false
38
44
  ```
39
45
 
@@ -42,7 +48,9 @@ sidebar:
42
48
  ```yaml lineNumbers
43
49
  seo:
44
50
  title: Install Blume
51
+ description: Install Blume and scaffold your first project.
45
52
  image: /og/install.png
53
+ canonical: https://acme.com/install
46
54
  noindex: false
47
55
  ```
48
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -43,6 +43,7 @@
43
43
  "default": "./src/core/schema.ts"
44
44
  },
45
45
  "./runtime": "./src/runtime/index.ts",
46
+ "./hooks": "./src/components/islands/hooks.ts",
46
47
  "./astro": "./src/astro/index.ts",
47
48
  "./og": "./src/og/index.ts",
48
49
  "./markdown": "./src/markdown/index.ts",
@@ -62,6 +63,7 @@
62
63
  "typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit"
63
64
  },
64
65
  "dependencies": {
66
+ "@astrojs/check": "^0.9.0",
65
67
  "@astrojs/markdown-satteri": "^0.3.2",
66
68
  "@astrojs/mdx": "^7.0.0",
67
69
  "@astrojs/node": "^11.0.0",
@@ -0,0 +1,131 @@
1
+ import { buildOramaIndex, queryOramaIndex } from "../search/orama-index.ts";
2
+ import type { OramaDoc } from "../search/orama-index.ts";
3
+
4
+ /** A chat message as posted by the Ask AI island (`{ role, content }`). */
5
+ export interface AskMessage {
6
+ content: string;
7
+ role: string;
8
+ }
9
+
10
+ /** The current-page hint the island forwards so the endpoint can prioritize it. */
11
+ export interface AskPage {
12
+ path?: string;
13
+ }
14
+
15
+ /**
16
+ * The self-contained snapshot the grounded Ask AI endpoint imports. Bundles the
17
+ * search documents so retrieval works regardless of the configured search
18
+ * provider and needs no filesystem access at request time. Serialized to
19
+ * `generated/ask-data.json` and built by {@link buildAskData}.
20
+ */
21
+ export interface AskData {
22
+ documents: OramaDoc[];
23
+ site: string | null;
24
+ }
25
+
26
+ /** Documents retrieved per question and injected into the system prompt. */
27
+ const MAX_RESULTS = 6;
28
+ /** Characters kept per injected excerpt. */
29
+ const EXCERPT_CHARS = 1500;
30
+ /** Overall cap on injected documentation characters. */
31
+ const CONTEXT_BUDGET = 8000;
32
+
33
+ /**
34
+ * The grounding preamble. The model is told to answer strictly from the injected
35
+ * excerpts and to cite the pages it used, so answers stay tied to the docs.
36
+ */
37
+ const BASE_INSTRUCTION =
38
+ "You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. If the answer is not covered by them, say you don't know and suggest where in the docs to look — do not invent details. Cite the page titles you drew from.";
39
+
40
+ /** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
41
+ const normalizeRoute = (input: string): string => {
42
+ const noTrailing = input.trim().replace(/\/+$/u, "");
43
+ const withSlash = noTrailing.startsWith("/") ? noTrailing : `/${noTrailing}`;
44
+ return withSlash === "" ? "/" : withSlash;
45
+ };
46
+
47
+ /** The most recent non-empty user message, used as the retrieval query. */
48
+ const lastUserMessage = (messages: AskMessage[]): string => {
49
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
50
+ const message = messages[i];
51
+ if (message?.role === "user" && message.content?.trim()) {
52
+ return message.content.trim();
53
+ }
54
+ }
55
+ return "";
56
+ };
57
+
58
+ /** Trim a document body to `max` characters, marking truncation with an ellipsis. */
59
+ const excerpt = (content: string, max: number): string => {
60
+ const trimmed = content.trim();
61
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
62
+ };
63
+
64
+ /**
65
+ * Build the request-time grounding function for the Ask AI endpoint.
66
+ *
67
+ * Lexical retrieval over Orama (the same index/ranking the search dialog and MCP
68
+ * server use). The index is built once and memoized across requests. Returns a
69
+ * grounded system prompt — the retrieved excerpts plus the page the user is
70
+ * viewing — or `undefined` when there is nothing to ground on, so the endpoint
71
+ * can fall back to its plain prompt.
72
+ */
73
+ export const createAskContext = (
74
+ data: AskData
75
+ ): ((
76
+ messages: AskMessage[],
77
+ page?: AskPage
78
+ ) => Promise<string | undefined>) => {
79
+ let dbPromise: Promise<Awaited<ReturnType<typeof buildOramaIndex>>> | null =
80
+ null;
81
+ const index = () => {
82
+ dbPromise ??= buildOramaIndex(data.documents);
83
+ return dbPromise;
84
+ };
85
+ const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
86
+
87
+ return async (messages, page) => {
88
+ const list = Array.isArray(messages) ? messages : [];
89
+ const query = lastUserMessage(list);
90
+ if (!query) {
91
+ return;
92
+ }
93
+
94
+ // The current page anchors retrieval to its locale and is injected first.
95
+ const current = page?.path
96
+ ? byRoute.get(normalizeRoute(page.path))
97
+ : undefined;
98
+ const db = await index();
99
+ const hits = await queryOramaIndex(
100
+ db,
101
+ query,
102
+ MAX_RESULTS,
103
+ current?.locale || undefined
104
+ );
105
+
106
+ const seen = new Set<string>();
107
+ const sections: string[] = [];
108
+ let budget = CONTEXT_BUDGET;
109
+ const push = (doc: OramaDoc, label: string) => {
110
+ if (seen.has(doc.route) || budget <= 0) {
111
+ return;
112
+ }
113
+ seen.add(doc.route);
114
+ const body = excerpt(doc.content, Math.min(EXCERPT_CHARS, budget));
115
+ budget -= body.length;
116
+ sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
117
+ };
118
+
119
+ if (current) {
120
+ push(current, " — the page the user is currently viewing");
121
+ }
122
+ for (const hit of hits) {
123
+ push(hit, "");
124
+ }
125
+
126
+ if (sections.length === 0) {
127
+ return;
128
+ }
129
+ return `${BASE_INSTRUCTION}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
130
+ };
131
+ };
@@ -0,0 +1,25 @@
1
+ import type { BlumeProject } from "../core/project-graph.ts";
2
+ import { buildSearchDocuments } from "../search/documents.ts";
3
+ import type { AskData } from "./ask-context.ts";
4
+
5
+ /**
6
+ * Build the grounding snapshot the Ask AI endpoint serves. Like the MCP server,
7
+ * Ask AI is independent of on-page search, so documents are indexed even when the
8
+ * search provider is `none` (`includeWhenDisabled`). `locale` is kept (unlike the
9
+ * MCP snapshot) so retrieval can be filtered to the current page's language.
10
+ */
11
+ export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
12
+ const documents = await buildSearchDocuments(project, {
13
+ includeWhenDisabled: true,
14
+ });
15
+ return {
16
+ documents: documents.map((doc) => ({
17
+ content: doc.content,
18
+ description: doc.description,
19
+ locale: doc.locale,
20
+ route: doc.route,
21
+ title: doc.title,
22
+ })),
23
+ site: project.config.deployment.site ?? null,
24
+ };
25
+ };
@@ -0,0 +1,165 @@
1
+ import type {
2
+ ComponentOverrideAnalysis,
3
+ NormalizedOverride,
4
+ OverrideFramework,
5
+ } from "../core/component-overrides.ts";
6
+
7
+ /**
8
+ * Turn analyzed `components.ts` overrides into the generated `components.ts`
9
+ * module plus per-override hydration wrappers.
10
+ *
11
+ * The runtime overrides object already holds bare imported identifiers, so those
12
+ * (when not hydrated) ride through a plain spread. Everything else needs a static
13
+ * import Vite can see: path-string overrides import the file directly, while
14
+ * hydrated overrides get a wrapper `.astro` that applies the `client:*` directive
15
+ * (Astro directives must be written statically). The `islands` group is folded
16
+ * into the MDX component map — it is just `mdx` with a default `client: "visible"`.
17
+ */
18
+
19
+ export interface ComponentSlotWrapper {
20
+ content: string;
21
+ /** File name (no extension) under `.blume/src/generated/component-slots/`. */
22
+ name: string;
23
+ }
24
+
25
+ export interface ComponentSlotPlan {
26
+ /** Frameworks used by resolved overrides; enable the matching Astro renderer. */
27
+ frameworks: Set<OverrideFramework>;
28
+ /** Contents of `.blume/src/generated/components.ts`. */
29
+ module: string;
30
+ wrappers: ComponentSlotWrapper[];
31
+ }
32
+
33
+ const EMPTY_MODULE = `// Generated by Blume. Do not edit.
34
+ export const mdxComponents = {};
35
+ export const layoutOverrides = {};
36
+ `;
37
+
38
+ /** Astro client directive for a hydrated override. */
39
+ const directiveFor = (override: NormalizedOverride): string => {
40
+ const framework = override.source?.framework;
41
+ switch (override.client) {
42
+ case "idle": {
43
+ return "client:idle";
44
+ }
45
+ case "visible": {
46
+ return "client:visible";
47
+ }
48
+ case "media": {
49
+ return override.media
50
+ ? `client:media="${override.media}"`
51
+ : "client:load";
52
+ }
53
+ case "only": {
54
+ return framework ? `client:only="${framework}"` : "client:load";
55
+ }
56
+ default: {
57
+ return "client:load";
58
+ }
59
+ }
60
+ };
61
+
62
+ const importClause = (variable: string, name: string, path: string): string =>
63
+ name === "default"
64
+ ? `import ${variable} from ${JSON.stringify(path)};`
65
+ : `import { ${name} as ${variable} } from ${JSON.stringify(path)};`;
66
+
67
+ /** A wrapper `.astro` that statically imports a component and hydrates it. */
68
+ const wrapperContent = (override: NormalizedOverride): string => {
69
+ const { name, path } = override.source as NonNullable<
70
+ NormalizedOverride["source"]
71
+ >;
72
+ const clause =
73
+ name === "default"
74
+ ? `import Component from ${JSON.stringify(path)};`
75
+ : `import { ${name} as Component } from ${JSON.stringify(path)};`;
76
+ return `---
77
+ // Generated by Blume. Do not edit.
78
+ ${clause}
79
+ ---
80
+ <Component ${directiveFor(override)} {...Astro.props}><slot /></Component>
81
+ `;
82
+ };
83
+
84
+ const sanitize = (value: string): string =>
85
+ value.replaceAll(/[^A-Za-z0-9]/gu, "_");
86
+
87
+ export const planComponentSlots = (
88
+ componentsFile: string | null,
89
+ analysis: ComponentOverrideAnalysis | null
90
+ ): ComponentSlotPlan => {
91
+ const frameworks = new Set<OverrideFramework>();
92
+ if (!componentsFile) {
93
+ return { frameworks, module: EMPTY_MODULE, wrappers: [] };
94
+ }
95
+ if (!analysis) {
96
+ return {
97
+ frameworks,
98
+ module: `// Generated by Blume. Do not edit.
99
+ import overrides from ${JSON.stringify(componentsFile)};
100
+ export const mdxComponents = overrides.mdx ?? {};
101
+ export const layoutOverrides = overrides.layout ?? {};
102
+ `,
103
+ wrappers: [],
104
+ };
105
+ }
106
+
107
+ const wrappers: ComponentSlotWrapper[] = [];
108
+ const importLines: string[] = [];
109
+ // Explicit `key: Variable` map entries, per surface.
110
+ const mdxEntries: string[] = [];
111
+ const layoutEntries: string[] = [];
112
+ let counter = 0;
113
+
114
+ // Islands are MDX components hydrated by default, so plan them alongside `mdx`.
115
+ const mdxOverrides = [...analysis.mdx, ...analysis.islands];
116
+
117
+ const plan = (
118
+ override: NormalizedOverride,
119
+ group: "mdx" | "layout",
120
+ entries: string[]
121
+ ): void => {
122
+ const { source } = override;
123
+ if (source?.framework) {
124
+ frameworks.add(source.framework);
125
+ }
126
+ // Bare identifier, not hydrated: the runtime object already has it.
127
+ if (override.identifier && !override.client) {
128
+ return;
129
+ }
130
+ if (!source) {
131
+ // Unresolved (already warned): leave it on the runtime object.
132
+ return;
133
+ }
134
+ const variable = `__blumeSlot${counter}`;
135
+ counter += 1;
136
+ if (override.client) {
137
+ const name = `${group}-${sanitize(override.key)}`;
138
+ wrappers.push({ content: wrapperContent(override), name });
139
+ importLines.push(
140
+ importClause(variable, "default", `./component-slots/${name}.astro`)
141
+ );
142
+ } else {
143
+ importLines.push(importClause(variable, source.name, source.path));
144
+ }
145
+ entries.push(`${JSON.stringify(override.key)}: ${variable}`);
146
+ };
147
+
148
+ for (const override of mdxOverrides) {
149
+ plan(override, "mdx", mdxEntries);
150
+ }
151
+ for (const override of analysis.layout) {
152
+ plan(override, "layout", layoutEntries);
153
+ }
154
+
155
+ // `overrides.islands` is not spread: every valid island is hydrated, so it is
156
+ // emitted as an explicit wrapper entry above (raw spreads only carry bare,
157
+ // non-hydrated identifiers from `mdx`/`layout`).
158
+ const moduleSource = `// Generated by Blume. Do not edit.
159
+ import overrides from ${JSON.stringify(componentsFile)};
160
+ ${importLines.join("\n")}${importLines.length ? "\n" : ""}export const mdxComponents = { ...(overrides.mdx ?? {})${mdxEntries.length ? `, ${mdxEntries.join(", ")}` : ""} };
161
+ export const layoutOverrides = { ...(overrides.layout ?? {})${layoutEntries.length ? `, ${layoutEntries.join(", ")}` : ""} };
162
+ `;
163
+
164
+ return { frameworks, module: moduleSource, wrappers };
165
+ };