blume 0.2.0 → 0.4.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 (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  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 +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The MDX component tags Blume ships (the keys of the catch-all's component map,
3
+ * plus composed sub-parts like `Color.Item`/`Tree.File`). Used by the
4
+ * missing-component diagnostic to tell an unknown `<Tag>` from a built-in. Keep
5
+ * in sync with the component map in `astro/templates.ts`.
6
+ */
7
+ export const BUILTIN_MDX_TAGS = new Set<string>([
8
+ "Accordion",
9
+ "AccordionItem",
10
+ "AutoTypeTable",
11
+ "Badge",
12
+ "Callout",
13
+ "Card",
14
+ "CardGroup",
15
+ "CodeBlock",
16
+ "CodeGroup",
17
+ "Color",
18
+ "Column",
19
+ "Columns",
20
+ "Component",
21
+ "Diff",
22
+ "Expandable",
23
+ "FileTree",
24
+ "Frame",
25
+ "GithubInfo",
26
+ "Icon",
27
+ "Math",
28
+ "Panel",
29
+ "Prompt",
30
+ "Step",
31
+ "Steps",
32
+ "Tab",
33
+ "Tabs",
34
+ "Tile",
35
+ "Tooltip",
36
+ "Tree",
37
+ "TypeTable",
38
+ "Visibility",
39
+ "YouTube",
40
+ ]);
@@ -0,0 +1,44 @@
1
+ import { BUILTIN_MDX_TAGS } from "./builtin-tags.ts";
2
+ import type { Diagnostic, PageRecord } from "./types.ts";
3
+
4
+ /** `CardGroup` → `card-group`, matching registry item names. */
5
+ const toKebab = (tag: string): string =>
6
+ tag
7
+ .replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>")
8
+ .toLowerCase();
9
+
10
+ /**
11
+ * Warn when an `.mdx` page uses a `<Component>` tag that resolves to nothing —
12
+ * a built-in, an island, or a `components.ts` override — so a typo surfaces as a
13
+ * friendly diagnostic (with a `blume add` hint where one exists) instead of a raw
14
+ * MDX "X is not defined" build error. `extraTags` are the project's own known
15
+ * components (islands + overrides); `registryNames` gates the install hint.
16
+ */
17
+ export const validateUsedComponents = (
18
+ pages: PageRecord[],
19
+ extraTags: Set<string>,
20
+ registryNames: Set<string>
21
+ ): Diagnostic[] => {
22
+ const diagnostics: Diagnostic[] = [];
23
+ const seen = new Set<string>();
24
+ for (const page of pages) {
25
+ for (const tag of page.componentsUsed ?? []) {
26
+ if (BUILTIN_MDX_TAGS.has(tag) || extraTags.has(tag) || seen.has(tag)) {
27
+ continue;
28
+ }
29
+ seen.add(tag);
30
+ const name = toKebab(tag);
31
+ const suggestion = registryNames.has(name)
32
+ ? `Run \`blume add ${name}\` to install it, or register <${tag}> in components.ts (mdx).`
33
+ : `Register <${tag}> in components.ts (mdx), or add an islands/${tag}.tsx component.`;
34
+ diagnostics.push({
35
+ code: "BLUME_UNKNOWN_COMPONENT",
36
+ file: page.sourcePath ?? page.id,
37
+ message: `<${tag}> is used in ${page.route} but isn't a known component.`,
38
+ severity: "warning",
39
+ suggestion,
40
+ });
41
+ }
42
+ }
43
+ return diagnostics;
44
+ };
@@ -0,0 +1,478 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { dirname, extname, isAbsolute, resolve } from "pathe";
4
+ import ts from "typescript";
5
+
6
+ import type { HydrationMode } from "./schema.ts";
7
+
8
+ /**
9
+ * Static analysis of a user `components.ts`/`.tsx`.
10
+ *
11
+ * Astro can only hydrate a component it imports *statically by path*, so to honor
12
+ * hydration on overrides (the `islands` group and `client:*` layout/mdx
13
+ * descriptors) Blume needs each override's source path and client mode at
14
+ * generate time — before Vite compiles anything. We read that here by parsing the
15
+ * file with the TypeScript compiler API (never executing it, so `.astro`/React
16
+ * imports don't need a Node loader).
17
+ *
18
+ * Only statically-analyzable authoring is understood: a default export that is an
19
+ * object literal or a `defineComponents({ ... })` call, with entries that are
20
+ * imported identifiers, path strings, or `{ component, client, media }` object
21
+ * literals. Anything else falls back to the runtime overrides object (which can
22
+ * still render a static component, just not hydrate it).
23
+ */
24
+
25
+ export type OverrideFramework = "react" | "svelte" | "vue";
26
+
27
+ /** How a wrapper should import an override's component. */
28
+ export interface OverrideImport {
29
+ /** Framework inferred from the file extension, or null (e.g. `.astro`). */
30
+ framework: OverrideFramework | null;
31
+ /** Exported name to import: `"default"` or a named export. */
32
+ name: string;
33
+ /** Absolute path (for relative/absolute specifiers) or a bare specifier. */
34
+ path: string;
35
+ }
36
+
37
+ /** A normalized override entry, keyed by its MDX tag / layout-slot name. */
38
+ export interface NormalizedOverride {
39
+ /** Present when the override should hydrate; drives the `client:*` directive. */
40
+ client?: HydrationMode;
41
+ /**
42
+ * True when the value is a bare imported identifier, so the runtime overrides
43
+ * object already holds a usable component (no generated import needed for a
44
+ * non-hydrated entry). False for path strings and `{ component }` descriptors.
45
+ */
46
+ identifier: boolean;
47
+ key: string;
48
+ /** Media query for `client: "media"`. */
49
+ media?: string;
50
+ /**
51
+ * How to obtain the component. `null` means it couldn't be resolved to a file,
52
+ * so the runtime overrides object is used (static render only).
53
+ */
54
+ source: OverrideImport | null;
55
+ }
56
+
57
+ export interface ComponentOverrideAnalysis {
58
+ islands: NormalizedOverride[];
59
+ layout: NormalizedOverride[];
60
+ mdx: NormalizedOverride[];
61
+ warnings: string[];
62
+ }
63
+
64
+ const GROUPS = ["mdx", "layout", "islands"] as const;
65
+ type Group = (typeof GROUPS)[number];
66
+
67
+ const FRAMEWORK_BY_EXT: Record<string, OverrideFramework> = {
68
+ jsx: "react",
69
+ svelte: "svelte",
70
+ tsx: "react",
71
+ vue: "vue",
72
+ };
73
+
74
+ const FRAMEWORK_LABEL: Record<OverrideFramework, string> = {
75
+ react: "React",
76
+ svelte: "Svelte",
77
+ vue: "Vue",
78
+ };
79
+
80
+ /** Extensions probed (in order) when a specifier omits one. */
81
+ const COMPONENT_EXTS = [
82
+ "astro",
83
+ "tsx",
84
+ "ts",
85
+ "jsx",
86
+ "js",
87
+ "mjs",
88
+ "vue",
89
+ "svelte",
90
+ ];
91
+
92
+ const HYDRATION_MODES = new Set<HydrationMode>([
93
+ "idle",
94
+ "load",
95
+ "media",
96
+ "only",
97
+ "visible",
98
+ ]);
99
+
100
+ interface ImportBinding {
101
+ /** Exported name: `"default"` or a named export. */
102
+ imported: string;
103
+ specifier: string;
104
+ }
105
+
106
+ /** An override's declared component before framework/path resolution. */
107
+ interface RawDescriptor {
108
+ client?: HydrationMode;
109
+ hadComponent: boolean;
110
+ media?: string;
111
+ source: OverrideImport | null;
112
+ }
113
+
114
+ const emptyAnalysis = (): ComponentOverrideAnalysis => ({
115
+ islands: [],
116
+ layout: [],
117
+ mdx: [],
118
+ warnings: [],
119
+ });
120
+
121
+ const propName = (name: ts.PropertyName): string | undefined =>
122
+ ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
123
+
124
+ /** Map each local binding name to the module + exported name it came from. */
125
+ const collectImports = (
126
+ sourceFile: ts.SourceFile
127
+ ): Map<string, ImportBinding> => {
128
+ const map = new Map<string, ImportBinding>();
129
+ for (const statement of sourceFile.statements) {
130
+ if (
131
+ !ts.isImportDeclaration(statement) ||
132
+ !ts.isStringLiteral(statement.moduleSpecifier)
133
+ ) {
134
+ continue;
135
+ }
136
+ const specifier = statement.moduleSpecifier.text;
137
+ const clause = statement.importClause;
138
+ if (!clause) {
139
+ continue;
140
+ }
141
+ if (clause.name) {
142
+ map.set(clause.name.text, { imported: "default", specifier });
143
+ }
144
+ const named = clause.namedBindings;
145
+ if (named && ts.isNamedImports(named)) {
146
+ for (const element of named.elements) {
147
+ map.set(element.name.text, {
148
+ imported: (element.propertyName ?? element.name).text,
149
+ specifier,
150
+ });
151
+ }
152
+ }
153
+ }
154
+ return map;
155
+ };
156
+
157
+ /** Unwrap `defineComponents({...})`, `({...})`, or `{...} as T` to the object. */
158
+ const unwrapObject = (
159
+ expression: ts.Expression
160
+ ): ts.ObjectLiteralExpression | undefined => {
161
+ if (ts.isObjectLiteralExpression(expression)) {
162
+ return expression;
163
+ }
164
+ if (ts.isCallExpression(expression)) {
165
+ const [arg] = expression.arguments;
166
+ return arg && ts.isObjectLiteralExpression(arg) ? arg : undefined;
167
+ }
168
+ if (
169
+ ts.isAsExpression(expression) ||
170
+ ts.isParenthesizedExpression(expression)
171
+ ) {
172
+ return unwrapObject(expression.expression);
173
+ }
174
+ return undefined;
175
+ };
176
+
177
+ const findDefaultExportObject = (
178
+ sourceFile: ts.SourceFile
179
+ ): ts.ObjectLiteralExpression | undefined => {
180
+ for (const statement of sourceFile.statements) {
181
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
182
+ return unwrapObject(statement.expression);
183
+ }
184
+ }
185
+ return undefined;
186
+ };
187
+
188
+ const probeExtension = (base: string): string | null => {
189
+ for (const extension of COMPONENT_EXTS) {
190
+ const candidate = `${base}.${extension}`;
191
+ if (existsSync(candidate)) {
192
+ return candidate;
193
+ }
194
+ }
195
+ return null;
196
+ };
197
+
198
+ /** Resolve a module specifier to a wrapper-importable path + framework. */
199
+ const toImport = (
200
+ specifier: string,
201
+ imported: string,
202
+ dir: string
203
+ ): OverrideImport => {
204
+ const relative = specifier.startsWith(".") || isAbsolute(specifier);
205
+ let path = specifier;
206
+ let extension = extname(specifier).slice(1).toLowerCase();
207
+ if (relative) {
208
+ const absolute = isAbsolute(specifier)
209
+ ? specifier
210
+ : resolve(dir, specifier);
211
+ if (extension) {
212
+ path = absolute;
213
+ } else {
214
+ const probed = probeExtension(absolute);
215
+ path = probed ?? absolute;
216
+ extension = probed ? extname(probed).slice(1).toLowerCase() : "";
217
+ }
218
+ }
219
+ return {
220
+ framework: FRAMEWORK_BY_EXT[extension] ?? null,
221
+ name: imported,
222
+ path,
223
+ };
224
+ };
225
+
226
+ const resolveIdentifier = (
227
+ name: string,
228
+ imports: Map<string, ImportBinding>,
229
+ dir: string
230
+ ): OverrideImport | null => {
231
+ const binding = imports.get(name);
232
+ return binding ? toImport(binding.specifier, binding.imported, dir) : null;
233
+ };
234
+
235
+ const readDescriptor = (
236
+ object: ts.ObjectLiteralExpression,
237
+ imports: Map<string, ImportBinding>,
238
+ dir: string
239
+ ): RawDescriptor => {
240
+ const descriptor: RawDescriptor = { hadComponent: false, source: null };
241
+ for (const property of object.properties) {
242
+ if (ts.isShorthandPropertyAssignment(property)) {
243
+ if (property.name.text === "component") {
244
+ descriptor.hadComponent = true;
245
+ descriptor.source = resolveIdentifier(property.name.text, imports, dir);
246
+ }
247
+ continue;
248
+ }
249
+ if (!ts.isPropertyAssignment(property)) {
250
+ continue;
251
+ }
252
+ const name = propName(property.name);
253
+ const init = property.initializer;
254
+ if (name === "component") {
255
+ descriptor.hadComponent = true;
256
+ if (ts.isStringLiteral(init)) {
257
+ descriptor.source = toImport(init.text, "default", dir);
258
+ } else if (ts.isIdentifier(init)) {
259
+ descriptor.source = resolveIdentifier(init.text, imports, dir);
260
+ }
261
+ } else if (
262
+ name === "client" &&
263
+ ts.isStringLiteral(init) &&
264
+ HYDRATION_MODES.has(init.text as HydrationMode)
265
+ ) {
266
+ descriptor.client = init.text as HydrationMode;
267
+ } else if (name === "media" && ts.isStringLiteral(init)) {
268
+ descriptor.media = init.text;
269
+ }
270
+ }
271
+ return descriptor;
272
+ };
273
+
274
+ /** Apply cross-cutting validation and produce the final normalized override. */
275
+ const finalize = (
276
+ key: string,
277
+ group: Group,
278
+ descriptor: RawDescriptor,
279
+ label: string,
280
+ identifier: boolean,
281
+ warnings: string[]
282
+ ): NormalizedOverride | null => {
283
+ const { client, media, source } = descriptor;
284
+
285
+ if (group === "islands") {
286
+ if (!source) {
287
+ warnings.push(
288
+ `Island override "${key}" couldn't be resolved to a file. Reference it by an imported component or a path string with an extension.`
289
+ );
290
+ return null;
291
+ }
292
+ if (!source.framework) {
293
+ warnings.push(
294
+ `Island override "${key}" (${label}) is not a React, Vue, or Svelte component; only framework components can be islands.`
295
+ );
296
+ return null;
297
+ }
298
+ }
299
+
300
+ if (client === "media" && !media) {
301
+ warnings.push(
302
+ `Override "${key}" uses client: "media" but no \`media\` query was given; it will hydrate as if \`client: "load"\`.`
303
+ );
304
+ }
305
+
306
+ if (client === "only" && source && !source.framework) {
307
+ warnings.push(
308
+ `Override "${key}" uses client: "only" but its framework couldn't be inferred; reference a .tsx/.jsx/.vue/.svelte file.`
309
+ );
310
+ }
311
+
312
+ if (client && !source) {
313
+ warnings.push(
314
+ `Override "${key}" declares client: "${client}" but its component couldn't be resolved to a file, so it can't hydrate. Reference it by an imported component or a path string.`
315
+ );
316
+ return { identifier, key, source: null };
317
+ }
318
+
319
+ if (!client && source?.framework) {
320
+ warnings.push(
321
+ `Override "${key}" points to a ${FRAMEWORK_LABEL[source.framework]} component (${label}) but has no hydration mode, so it renders as static HTML with no interactivity. Add one, e.g. \`${key}: { component: ${JSON.stringify(label)}, client: "load" }\`.`
322
+ );
323
+ }
324
+
325
+ return {
326
+ identifier,
327
+ key,
328
+ ...(client ? { client } : {}),
329
+ ...(media ? { media } : {}),
330
+ source,
331
+ };
332
+ };
333
+
334
+ const normalizeEntry = (
335
+ entry: ts.ObjectLiteralElementLike,
336
+ group: Group,
337
+ imports: Map<string, ImportBinding>,
338
+ dir: string,
339
+ warnings: string[]
340
+ ): NormalizedOverride | null => {
341
+ const defaultClient: HydrationMode | undefined =
342
+ group === "islands" ? "visible" : undefined;
343
+
344
+ if (ts.isShorthandPropertyAssignment(entry)) {
345
+ const name = entry.name.text;
346
+ return finalize(
347
+ name,
348
+ group,
349
+ {
350
+ client: defaultClient,
351
+ hadComponent: true,
352
+ source: resolveIdentifier(name, imports, dir),
353
+ },
354
+ name,
355
+ true,
356
+ warnings
357
+ );
358
+ }
359
+
360
+ if (!ts.isPropertyAssignment(entry)) {
361
+ return null;
362
+ }
363
+ const key = propName(entry.name);
364
+ if (!key) {
365
+ return null;
366
+ }
367
+ const value = entry.initializer;
368
+
369
+ if (ts.isIdentifier(value)) {
370
+ return finalize(
371
+ key,
372
+ group,
373
+ {
374
+ client: defaultClient,
375
+ hadComponent: true,
376
+ source: resolveIdentifier(value.text, imports, dir),
377
+ },
378
+ value.text,
379
+ true,
380
+ warnings
381
+ );
382
+ }
383
+ if (ts.isStringLiteral(value)) {
384
+ return finalize(
385
+ key,
386
+ group,
387
+ {
388
+ client: defaultClient,
389
+ hadComponent: true,
390
+ source: toImport(value.text, "default", dir),
391
+ },
392
+ value.text,
393
+ false,
394
+ warnings
395
+ );
396
+ }
397
+ if (ts.isObjectLiteralExpression(value)) {
398
+ const descriptor = readDescriptor(value, imports, dir);
399
+ if (!descriptor.hadComponent) {
400
+ warnings.push(
401
+ `Override "${key}" is an object without a \`component\` field; expected \`{ component, client }\`.`
402
+ );
403
+ return null;
404
+ }
405
+ if (!descriptor.source) {
406
+ warnings.push(
407
+ `Override "${key}"'s \`component\` couldn't be resolved to a file. Reference an imported component or a path string with an extension.`
408
+ );
409
+ return null;
410
+ }
411
+ return finalize(
412
+ key,
413
+ group,
414
+ { ...descriptor, client: descriptor.client ?? defaultClient },
415
+ key,
416
+ false,
417
+ warnings
418
+ );
419
+ }
420
+
421
+ // An inline function/expression: keep it on the runtime object (static only).
422
+ return { identifier: false, key, source: null };
423
+ };
424
+
425
+ /**
426
+ * Parse a user `components.ts`/`.tsx` and return its normalized overrides. Never
427
+ * executes the file. On a parse failure or unrecognized shape, returns empty
428
+ * groups so generation falls back to the plain runtime overrides object.
429
+ */
430
+ export const analyzeComponentOverrides = (
431
+ source: string,
432
+ filePath: string
433
+ ): ComponentOverrideAnalysis => {
434
+ const result = emptyAnalysis();
435
+ const sourceFile = ts.createSourceFile(
436
+ filePath,
437
+ source,
438
+ ts.ScriptTarget.Latest,
439
+ true,
440
+ filePath.endsWith("tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
441
+ );
442
+
443
+ const object = findDefaultExportObject(sourceFile);
444
+ if (!object) {
445
+ return result;
446
+ }
447
+
448
+ const imports = collectImports(sourceFile);
449
+ const dir = dirname(filePath);
450
+
451
+ for (const property of object.properties) {
452
+ if (!ts.isPropertyAssignment(property)) {
453
+ continue;
454
+ }
455
+ const name = propName(property.name);
456
+ if (
457
+ !(name && (GROUPS as readonly string[]).includes(name)) ||
458
+ !ts.isObjectLiteralExpression(property.initializer)
459
+ ) {
460
+ continue;
461
+ }
462
+ const group = name as Group;
463
+ for (const entry of property.initializer.properties) {
464
+ const normalized = normalizeEntry(
465
+ entry,
466
+ group,
467
+ imports,
468
+ dir,
469
+ result.warnings
470
+ );
471
+ if (normalized) {
472
+ result[group].push(normalized);
473
+ }
474
+ }
475
+ }
476
+
477
+ return result;
478
+ };
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
1
3
  import { detectMintlifyBridge } from "./bridge.ts";
2
4
  import type { BridgeDetection } from "./bridge.ts";
3
5
  import { applyDeploymentEnv } from "./deployment-env.ts";
@@ -73,9 +75,15 @@ export const loadConfig = async (
73
75
  const sourceFile = bridge?.configFile ?? configFile;
74
76
  const parsed = blumeConfigSchema.safeParse(raw ?? {});
75
77
  if (!parsed.success) {
78
+ // Read the raw config text (when on disk) so errors carry a line/column.
79
+ const source =
80
+ sourceFile && existsSync(sourceFile)
81
+ ? readFileSync(sourceFile, "utf-8")
82
+ : undefined;
76
83
  const diagnostics = diagnosticsFromZod(parsed.error, {
77
84
  code: "BLUME_CONFIG_INVALID",
78
85
  file: sourceFile ?? undefined,
86
+ source,
79
87
  });
80
88
  throw new BlumeError(
81
89
  diagnostics[0] ?? {
package/src/core/data.ts CHANGED
@@ -108,6 +108,20 @@ export interface BlumeDataConfig {
108
108
  structuredData: boolean;
109
109
  theme: ResolvedConfig["theme"];
110
110
  title: string;
111
+ /** Table-of-contents settings: whether to show it and the heading range. */
112
+ toc: ResolvedConfig["toc"];
113
+ }
114
+
115
+ /**
116
+ * The compact snapshot the layout serializes into the page for React island
117
+ * hooks (`blume/hooks`). Islands hydrate independently, so this is read from a
118
+ * `<script type="application/json" id="blume-client-data">` tag rather than
119
+ * React context.
120
+ */
121
+ export interface BlumeClientData {
122
+ config: BlumeDataConfig;
123
+ navigation: Navigation;
124
+ page: { route: string; title: string };
111
125
  }
112
126
 
113
127
  /** The `blume:data` module a Blume site's custom pages import. */
@@ -19,10 +19,17 @@ export type ComponentOverride = ComponentReference | IslandDescriptor;
19
19
 
20
20
  /** User-authored component overrides, grouped by surface. */
21
21
  export interface ComponentOverrides {
22
+ /**
23
+ * Interactive framework components made available in every `.mdx` page. Like
24
+ * `mdx`, but hydrated: entries default to `client: "visible"`. Shorthand for
25
+ * an `mdx` descriptor with a client mode, and the config-file equivalent of
26
+ * dropping a component in the `islands/` folder.
27
+ */
28
+ islands?: Record<string, ComponentOverride>;
29
+ /** Layout slot overrides (`Header`, `Sidebar`, `Footer`, ...). */
30
+ layout?: Record<string, ComponentOverride>;
22
31
  /** MDX component map overrides (`Callout`, `Card`, ...). */
23
32
  mdx?: Record<string, ComponentOverride>;
24
- /** Layout slot overrides (`Header`, `Sidebar`, `Search`, ...). */
25
- layout?: Record<string, ComponentOverride>;
26
33
  }
27
34
 
28
35
  /**