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.
- package/dist/cli/index.js +1921 -560
- package/dist/cli/index.js.map +36 -24
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/define-components.d.ts +9 -2
- package/dist/types/core/diagnostics.d.ts +5 -0
- package/dist/types/core/schema.d.ts +26 -502
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/custom-pages.mdx +63 -1
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +13 -0
- package/docs/configuration/seo.mdx +5 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +42 -0
- package/package.json +3 -1
- package/src/ai/ask-context.ts +131 -0
- package/src/ai/ask-data.ts +25 -0
- package/src/astro/component-slots.ts +165 -0
- package/src/astro/generate.ts +132 -13
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +92 -44
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/commands/build.ts +186 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +21 -1
- package/src/cli/commands/doctor.ts +23 -6
- package/src/cli/commands/init.ts +163 -15
- package/src/cli/commands/validate.ts +16 -2
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +15 -1
- package/src/components/islands/hooks.ts +188 -0
- package/src/components/layout/Empty.astro +6 -0
- package/src/components/layout/Header.astro +24 -39
- package/src/components/layout/Logo.astro +50 -0
- package/src/components/layout/NavSelector.astro +75 -0
- package/src/components/layout/PageLayout.astro +38 -2
- package/src/components/layout/RootLayout.astro +70 -4
- package/src/components/layout/hydration-hint.ts +30 -0
- package/src/components/layout/overrides.ts +6 -4
- package/src/components/props.ts +68 -0
- package/src/core/builtin-tags.ts +39 -0
- package/src/core/component-diagnostics.ts +44 -0
- package/src/core/component-overrides.ts +478 -0
- package/src/core/config.ts +8 -0
- package/src/core/data.ts +14 -0
- package/src/core/define-components.ts +9 -2
- package/src/core/diagnostics.ts +90 -1
- package/src/core/graph.ts +7 -0
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +28 -96
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/migrate/mintlify/config.ts +1 -176
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +163 -38
- package/src/registry/eject.ts +39 -9
- package/src/registry/registry.ts +166 -0
- package/src/runtime/index.ts +61 -0
- 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
|
+
};
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
/**
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -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,
|