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,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
  /**
@@ -17,10 +17,94 @@ export class BlumeError extends Error {
17
17
  export const createDiagnostic = (diagnostic: Diagnostic): Diagnostic =>
18
18
  diagnostic;
19
19
 
20
+ /** Docs site base; diagnostic help links resolve against it. */
21
+ const DOCS_BASE = "https://useblume.dev";
22
+
23
+ /** Diagnostic code → the docs page that explains it. */
24
+ const DOCS_PATHS: Record<string, string> = {
25
+ BLUME_ADAPTER_REQUIRED: "/docs/deployment",
26
+ BLUME_ASSETS_UNCHECKED: "/docs/reference/cli",
27
+ BLUME_ASSET_FETCH_FAILED: "/docs/content/sources",
28
+ BLUME_BROKEN_ANCHOR: "/docs/reference/cli",
29
+ BLUME_BROKEN_ASSET: "/docs/reference/cli",
30
+ BLUME_BROKEN_LINK: "/docs/reference/cli",
31
+ BLUME_CONFIG_INVALID: "/docs/configuration",
32
+ BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
33
+ BLUME_CONTENT_ROOT_MISSING: "/docs/content/sources",
34
+ BLUME_DEAD_LINK: "/docs/reference/cli",
35
+ BLUME_DUPLICATE_ROUTE: "/docs/content/navigation",
36
+ BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
37
+ BLUME_META_INVALID: "/docs/content/meta",
38
+ BLUME_META_LOAD_FAILED: "/docs/content/meta",
39
+ BLUME_MISSING_SECRET: "/docs/deployment",
40
+ BLUME_NAV_DUPLICATE_LABEL: "/docs/content/navigation",
41
+ BLUME_NAV_HIDDEN_IN_SIDEBAR: "/docs/content/navigation",
42
+ BLUME_NAV_MISSING_PAGE: "/docs/content/navigation",
43
+ BLUME_NODE_VERSION: "/docs/quickstart",
44
+ BLUME_SERVER_FEATURE_REQUIRED: "/docs/deployment",
45
+ BLUME_SOURCE_FETCH_FAILED: "/docs/content/sources",
46
+ BLUME_SOURCE_MISCONFIGURED: "/docs/content/sources",
47
+ BLUME_SOURCE_OFFLINE: "/docs/content/sources",
48
+ BLUME_SOURCE_SDK_MISSING: "/docs/content/sources",
49
+ BLUME_SOURCE_UNAVAILABLE: "/docs/content/sources",
50
+ BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
51
+ BLUME_UNKNOWN_ICON: "/docs/content/navigation",
52
+ };
53
+
54
+ /** The docs URL that explains a diagnostic code, if one is mapped. */
55
+ export const resolveDocsUrl = (code: string): string | undefined => {
56
+ const path = DOCS_PATHS[code];
57
+ return path ? `${DOCS_BASE}${path}` : undefined;
58
+ };
59
+
60
+ /** Fill in `docsUrl` from the code map where a diagnostic doesn't set its own. */
61
+ export const enrichDiagnostic = (diagnostic: Diagnostic): Diagnostic =>
62
+ diagnostic.docsUrl
63
+ ? diagnostic
64
+ : { ...diagnostic, docsUrl: resolveDocsUrl(diagnostic.code) };
65
+
66
+ const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
67
+ const escapeRegExp = (value: string): string =>
68
+ value.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
69
+
70
+ /**
71
+ * Best-effort source position for a Zod issue path (e.g. `["seo", "title"]`) in
72
+ * the raw config / frontmatter text. Narrows key-by-key — finding each string
73
+ * segment as a `key:`/`key =` at or after the previous match — so a nested key
74
+ * lands under its parent. Array indices are skipped. Returns 1-based line/column,
75
+ * or undefined when nothing matches.
76
+ */
77
+ const locatePath = (
78
+ source: string,
79
+ path: readonly (string | number)[]
80
+ ): { column: number; line: number } | undefined => {
81
+ let cursor = 0;
82
+ let found = -1;
83
+ for (const segment of path) {
84
+ if (typeof segment !== "string") {
85
+ continue;
86
+ }
87
+ const matcher = new RegExp(`${escapeRegExp(segment)}\\s*[:=]`, "gu");
88
+ matcher.lastIndex = cursor;
89
+ const match = matcher.exec(source);
90
+ if (!match) {
91
+ break;
92
+ }
93
+ found = match.index;
94
+ cursor = matcher.lastIndex;
95
+ }
96
+ if (found < 0) {
97
+ return;
98
+ }
99
+ const before = source.slice(0, found);
100
+ const lastNewline = before.lastIndexOf("\n");
101
+ return { column: found - lastNewline, line: before.split("\n").length };
102
+ };
103
+
20
104
  /** Convert a ZodError into Blume diagnostics, anchored to a file. */
21
105
  export const diagnosticsFromZod = (
22
106
  error: ZodError,
23
- options: { code: string; file?: string }
107
+ options: { code: string; file?: string; source?: string }
24
108
  ): Diagnostic[] =>
25
109
  error.issues.map((issue) => {
26
110
  const schemaPath = issue.path.join(".");
@@ -28,9 +112,14 @@ export const diagnosticsFromZod = (
28
112
  "received" in issue
29
113
  ? ` (received: ${JSON.stringify(issue.received)})`
30
114
  : "";
115
+ const position = options.source
116
+ ? locatePath(options.source, issue.path)
117
+ : undefined;
31
118
  return {
32
119
  code: options.code,
120
+ column: position?.column,
33
121
  file: options.file,
122
+ line: position?.line,
34
123
  message: schemaPath
35
124
  ? `${schemaPath}: ${issue.message}${received}`
36
125
  : `${issue.message}${received}`,
package/src/core/graph.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
2
+ import { validateNavIcons, validateNavStructure } from "./nav-diagnostics.ts";
2
3
  import { buildNavigation } from "./navigation.ts";
3
4
  import type {
4
5
  FolderMeta,
@@ -118,6 +119,12 @@ export const buildContentGraph = (
118
119
  });
119
120
  }
120
121
 
122
+ // Icon typos, duplicate labels, and hidden-page-in-sidebar are validated on
123
+ // the built navigation. Missing-target detection needs the full route set
124
+ // (incl. custom + generated pages), so it runs later in generateRuntime.
125
+ diagnostics.push(...validateNavIcons(navigation));
126
+ diagnostics.push(...validateNavStructure(navigation, pages));
127
+
121
128
  return {
122
129
  diagnostics,
123
130
  navigation,