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.
- package/dist/cli/index.js +2429 -792
- package/dist/cli/index.js.map +63 -44
- 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 +313 -778
- package/dist/types/core/types.d.ts +2 -2
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +26 -40
- package/docs/advanced/api-reference.mdx +10 -37
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +66 -61
- package/docs/configuration/ai.mdx +47 -91
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +92 -27
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +78 -85
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +18 -44
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +42 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +79 -33
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +42 -56
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +13 -51
- package/docs/reference/cli.mdx +49 -18
- package/docs/reference/frontmatter.mdx +2 -5
- 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 +85 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +11 -13
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +120 -50
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +209 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +32 -3
- package/src/cli/commands/doctor.ts +32 -6
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +184 -16
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +27 -2
- package/src/cli/dev-lock.ts +84 -0
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +41 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +109 -0
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +29 -15
- 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 +71 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +40 -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 +95 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/graph.ts +7 -0
- package/src/core/links.ts +60 -19
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +35 -96
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/normalize.ts +57 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/deploy/rss.ts +1 -8
- package/src/deploy/sitemap.ts +20 -1
- package/src/deploy/xml.ts +8 -0
- package/src/markdown/directives.ts +15 -7
- package/src/markdown/package-commands.ts +26 -4
- package/src/migrate/fumadocs/content.ts +14 -1
- package/src/migrate/fumadocs/groups.ts +7 -0
- package/src/migrate/fumadocs/index.ts +5 -2
- package/src/migrate/mintlify/assets.ts +46 -0
- package/src/migrate/mintlify/config.ts +1 -176
- package/src/migrate/mintlify/index.ts +53 -45
- package/src/migrate/shared.ts +12 -27
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +175 -38
- package/src/registry/eject.ts +52 -12
- package/src/registry/registry.ts +172 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/runtime/index.ts +61 -0
- package/src/search/documents.ts +23 -5
- package/src/search/sync/algolia.ts +5 -1
- package/src/search/sync/typesense.ts +24 -16
- package/src/theme/palette.ts +26 -7
- 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
|
+
};
|
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
|
/**
|