blume 1.0.4 → 1.1.1
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/CHANGELOG.md +80 -0
- package/dist/cli/index.js +13404 -10228
- package/dist/cli/index.js.map +94 -63
- package/dist/types/ai/component-markdown.d.ts +12 -1
- package/dist/types/core/config-input.d.ts +73 -4
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +144 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +20 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +3 -3
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +37 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +5 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +80 -2
- package/docs/reference/frontmatter.mdx +31 -1
- package/package.json +4 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/component-markdown.ts +39 -11
- package/src/ai/llms.ts +19 -2
- package/src/ai/markdown.ts +5 -1
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +124 -50
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +39 -8
- package/src/astro/templates.ts +93 -28
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +138 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +64 -13
- package/src/cli/index.ts +2 -0
- package/src/cli/prepare.ts +10 -2
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +78 -4
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +61 -12
- package/src/core/graph.ts +23 -4
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/navigation.ts +169 -14
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +54 -20
- package/src/core/schema.ts +93 -3
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +20 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/markdown/index.ts +1 -0
- package/src/markdown/twoslash.ts +60 -0
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/registry/eject.ts +3 -1
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +6 -1
- /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
|
@@ -63,6 +63,12 @@ export interface ComponentMarkdownContext extends EvaluatedProps {
|
|
|
63
63
|
childComponents: (name: string) => ComponentMarkdownChild[];
|
|
64
64
|
/** The element's body, downleveled and dedented (empty if self-closing). */
|
|
65
65
|
children: string;
|
|
66
|
+
/**
|
|
67
|
+
* The page's parsed front-matter (empty when the caller has none). Lets a
|
|
68
|
+
* serializer read page metadata directly, even when a prop expression is
|
|
69
|
+
* not statically evaluable.
|
|
70
|
+
*/
|
|
71
|
+
frontmatter: Record<string, unknown>;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/**
|
|
@@ -78,15 +84,22 @@ export type ComponentMarkdown = (
|
|
|
78
84
|
* Statically evaluate an MDX attribute expression (`prop={...}`). Component
|
|
79
85
|
* data props are object/array/number literals in practice; evaluation runs at
|
|
80
86
|
* build time over the author's own content — the same trust level as the MDX
|
|
81
|
-
* itself, which Astro compiles and executes.
|
|
82
|
-
*
|
|
87
|
+
* itself, which Astro compiles and executes. The page's `frontmatter` is in
|
|
88
|
+
* scope, mirroring what Astro provides an MDX body at render time, so
|
|
89
|
+
* `prop={frontmatter.status}` resolves; expressions that reference imports or
|
|
90
|
+
* other scope throw and report as not evaluable.
|
|
83
91
|
*/
|
|
84
|
-
const evaluateExpression = (
|
|
92
|
+
const evaluateExpression = (
|
|
93
|
+
raw: string,
|
|
94
|
+
frontmatter: Record<string, unknown> | undefined
|
|
95
|
+
): { ok: boolean; value: unknown } => {
|
|
85
96
|
try {
|
|
86
97
|
// Build-time eval of the author's own attribute literals; a throw falls
|
|
87
98
|
// back to leaving the JSX verbatim.
|
|
88
99
|
// oxlint-disable-next-line no-new-func
|
|
89
|
-
const value = new Function(`"use strict"; return (${raw});`)(
|
|
100
|
+
const value = new Function("frontmatter", `"use strict"; return (${raw});`)(
|
|
101
|
+
frontmatter
|
|
102
|
+
);
|
|
90
103
|
return { ok: true, value };
|
|
91
104
|
} catch {
|
|
92
105
|
return { ok: false, value: undefined };
|
|
@@ -94,7 +107,10 @@ const evaluateExpression = (raw: string): { ok: boolean; value: unknown } => {
|
|
|
94
107
|
};
|
|
95
108
|
|
|
96
109
|
/** Evaluate an element's attributes into a plain props object. */
|
|
97
|
-
const readProps = (
|
|
110
|
+
const readProps = (
|
|
111
|
+
node: MdastNode,
|
|
112
|
+
frontmatter: Record<string, unknown> | undefined
|
|
113
|
+
): EvaluatedProps => {
|
|
98
114
|
const props: Record<string, unknown> = {};
|
|
99
115
|
let lossy = false;
|
|
100
116
|
for (const attribute of node.attributes ?? []) {
|
|
@@ -109,7 +125,7 @@ const readProps = (node: MdastNode): EvaluatedProps => {
|
|
|
109
125
|
} else if (typeof attribute.value === "string") {
|
|
110
126
|
props[attribute.name] = attribute.value;
|
|
111
127
|
} else {
|
|
112
|
-
const result = evaluateExpression(attribute.value.value);
|
|
128
|
+
const result = evaluateExpression(attribute.value.value, frontmatter);
|
|
113
129
|
if (result.ok) {
|
|
114
130
|
props[attribute.name] = result.value;
|
|
115
131
|
} else {
|
|
@@ -347,8 +363,9 @@ const componentHint = (registry: Record<string, ComponentMarkdown>): RegExp =>
|
|
|
347
363
|
|
|
348
364
|
const BUILT_IN_HINT = componentHint(SERIALIZERS);
|
|
349
365
|
|
|
350
|
-
/** One downlevel pass's inputs: the
|
|
366
|
+
/** One downlevel pass's inputs: the source, registry, and page metadata. */
|
|
351
367
|
interface Walk {
|
|
368
|
+
frontmatter: Record<string, unknown> | undefined;
|
|
352
369
|
registry: Record<string, ComponentMarkdown>;
|
|
353
370
|
source: string;
|
|
354
371
|
}
|
|
@@ -388,15 +405,16 @@ const serializeElement = (
|
|
|
388
405
|
node: MdastNode
|
|
389
406
|
): string | null =>
|
|
390
407
|
serializer({
|
|
391
|
-
...readProps(node),
|
|
408
|
+
...readProps(node, walk.frontmatter),
|
|
392
409
|
childComponents: (name) =>
|
|
393
410
|
(node.children ?? [])
|
|
394
411
|
.filter((child) => isJsxElement(child) && child.name === name)
|
|
395
412
|
.map((child) => ({
|
|
396
|
-
...readProps(child),
|
|
413
|
+
...readProps(child, walk.frontmatter),
|
|
397
414
|
children: renderChildren(walk, child),
|
|
398
415
|
})),
|
|
399
416
|
children: renderChildren(walk, node),
|
|
417
|
+
frontmatter: walk.frontmatter ?? {},
|
|
400
418
|
});
|
|
401
419
|
|
|
402
420
|
/**
|
|
@@ -438,10 +456,16 @@ const collectSplices = (
|
|
|
438
456
|
* `components` adds user serializers from `ai.markdownComponents`, layered
|
|
439
457
|
* over the built-ins: a same-name entry replaces the built-in serializer, and
|
|
440
458
|
* one that always returns `null` effectively opts that component out.
|
|
459
|
+
*
|
|
460
|
+
* `frontmatter` is the page's parsed front-matter data. It is put in scope
|
|
461
|
+
* when evaluating attribute expressions — so `prop={frontmatter.status}`
|
|
462
|
+
* resolves the way it does when Astro renders the page — and handed to
|
|
463
|
+
* serializers on their context.
|
|
441
464
|
*/
|
|
442
465
|
export const downlevelComponents = (
|
|
443
466
|
source: string,
|
|
444
|
-
components?: Record<string, ComponentMarkdown
|
|
467
|
+
components?: Record<string, ComponentMarkdown>,
|
|
468
|
+
frontmatter?: Record<string, unknown>
|
|
445
469
|
): string => {
|
|
446
470
|
const custom = components && Object.keys(components).length > 0;
|
|
447
471
|
const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
|
|
@@ -456,6 +480,10 @@ export const downlevelComponents = (
|
|
|
456
480
|
return source;
|
|
457
481
|
}
|
|
458
482
|
const splices: Splice[] = [];
|
|
459
|
-
collectSplices(
|
|
483
|
+
collectSplices(
|
|
484
|
+
{ frontmatter, registry, source },
|
|
485
|
+
tree.children ?? [],
|
|
486
|
+
splices
|
|
487
|
+
);
|
|
460
488
|
return splices.length > 0 ? applySplices(source, splices) : source;
|
|
461
489
|
};
|
package/src/ai/llms.ts
CHANGED
|
@@ -3,6 +3,7 @@ import matter from "../core/frontmatter.ts";
|
|
|
3
3
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
4
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
5
|
import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
|
|
6
|
+
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
6
7
|
import { downlevelComponents } from "./component-markdown.ts";
|
|
7
8
|
import { applyAgentVisibility } from "./visibility.ts";
|
|
8
9
|
|
|
@@ -136,6 +137,20 @@ const buildIndex = (project: BlumeProject): string => {
|
|
|
136
137
|
);
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
// Surface the RSS feeds so agents can find fresh content (new blog posts,
|
|
141
|
+
// changelog entries) without re-crawling the index. `buildRssFeeds` is empty
|
|
142
|
+
// unless RSS is enabled and an absolute `site` is set — the same condition
|
|
143
|
+
// under which agent-readability.json lists `artifacts.feeds`.
|
|
144
|
+
const feeds = buildRssFeeds(project);
|
|
145
|
+
if (feeds.length > 0) {
|
|
146
|
+
blocks.push(
|
|
147
|
+
"## RSS Feeds",
|
|
148
|
+
feeds
|
|
149
|
+
.map((feed) => `- [${feed.title}](${pageUrl(feed.path, site, base)})`)
|
|
150
|
+
.join("\n")
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
const header = config.description
|
|
140
155
|
? `# ${config.title}\n\n> ${config.description}`
|
|
141
156
|
: `# ${config.title}`;
|
|
@@ -155,9 +170,11 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
|
155
170
|
// Resolve `<Visibility>` audiences (web-only content omitted from the
|
|
156
171
|
// agent-facing output, agents-only unwrapped), then downlevel supported
|
|
157
172
|
// components to plain Markdown.
|
|
173
|
+
const parsed = matter(raw);
|
|
158
174
|
const body = downlevelComponents(
|
|
159
|
-
applyAgentVisibility(
|
|
160
|
-
config.ai.markdownComponents
|
|
175
|
+
applyAgentVisibility(parsed.content),
|
|
176
|
+
config.ai.markdownComponents,
|
|
177
|
+
parsed.data
|
|
161
178
|
).trim();
|
|
162
179
|
const url = pageUrl(
|
|
163
180
|
page.route,
|
package/src/ai/markdown.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
import matter from "../core/frontmatter.ts";
|
|
3
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
5
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
6
|
import type { RouteManifestEntry } from "../core/types.ts";
|
|
@@ -47,9 +48,12 @@ export const buildRawMarkdown = async (
|
|
|
47
48
|
const entries = await Promise.all(
|
|
48
49
|
project.manifest.routes.map(async (route) => {
|
|
49
50
|
const source = applyAgentVisibility(await readRoute(route));
|
|
51
|
+
// The `.md` variant keeps the front-matter block in the output, but its
|
|
52
|
+
// data must also be in scope for `prop={frontmatter.*}` expressions.
|
|
50
53
|
const md = downlevelComponents(
|
|
51
54
|
source,
|
|
52
|
-
project.config.ai.markdownComponents
|
|
55
|
+
project.config.ai.markdownComponents,
|
|
56
|
+
matter(source).data
|
|
53
57
|
);
|
|
54
58
|
const entry: RawMarkdownEntry =
|
|
55
59
|
md === source ? { mdx: source } : { md, mdx: source };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
|
|
3
|
+
import type { AstroIntegration } from "astro";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Present a deploy adapter with `root` pointed at the real project root rather
|
|
7
|
+
* than the hidden `.blume` runtime.
|
|
8
|
+
*
|
|
9
|
+
* Astro's `root` and `outDir` normally sit together (`outDir` defaults to
|
|
10
|
+
* `<root>/dist`), and `@astrojs/vercel` leans on that: it writes its Build
|
|
11
|
+
* Output tree to `<root>/.vercel/output` and — the part that bites — traces the
|
|
12
|
+
* function's dependency closure with `@vercel/nft` using a base derived from
|
|
13
|
+
* `root`, silently dropping every traced file that falls outside it.
|
|
14
|
+
*
|
|
15
|
+
* Blume splits the two: `root` is `<project>/.blume`, so Astro resolves the
|
|
16
|
+
* runtime's own `package.json` and its `node_modules` junction, while `outDir`
|
|
17
|
+
* stays at `<project>/dist` so the build lands where users expect. That puts
|
|
18
|
+
* `build.server` (`<outDir>/server`) *outside* `root`, so nft's base excludes
|
|
19
|
+
* the server bundle entirely: the traced file list collapses to `entry.mjs`
|
|
20
|
+
* alone, and the deployed function dies on its first import with
|
|
21
|
+
* ERR_MODULE_NOT_FOUND — missing its chunks, its virtual middleware, and every
|
|
22
|
+
* npm dependency.
|
|
23
|
+
*
|
|
24
|
+
* A project inside a workspace accidentally escapes this, because nft's base
|
|
25
|
+
* search climbs past `.blume` to the workspace root, which does contain both
|
|
26
|
+
* `dist/` and `node_modules` — which is why the bug only ever surfaced in
|
|
27
|
+
* standalone projects.
|
|
28
|
+
*
|
|
29
|
+
* Handing the adapter the root its own `outDir` assumption implies restores the
|
|
30
|
+
* invariant without moving Astro's real root: the trace covers `dist/server`
|
|
31
|
+
* and `node_modules`, and the Build Output tree lands at the project root
|
|
32
|
+
* natively, where `vercel deploy --prebuilt` looks for it.
|
|
33
|
+
*
|
|
34
|
+
* `astro:config:setup` and `astro:config:done` are the only hooks handed a
|
|
35
|
+
* `config`; an adapter reads `root` from one or both and closes over it for its
|
|
36
|
+
* later build hooks, so overriding it there covers the whole adapter.
|
|
37
|
+
*/
|
|
38
|
+
const stripTrailingSlashes = (value: string): string => {
|
|
39
|
+
let end = value.length;
|
|
40
|
+
|
|
41
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
42
|
+
end -= 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return value.slice(0, end);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const withAdapterRoot = (
|
|
49
|
+
integration: AstroIntegration,
|
|
50
|
+
root: string
|
|
51
|
+
): AstroIntegration => {
|
|
52
|
+
const rootUrl = pathToFileURL(`${stripTrailingSlashes(root)}/`);
|
|
53
|
+
const setup = integration.hooks["astro:config:setup"];
|
|
54
|
+
const done = integration.hooks["astro:config:done"];
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...integration,
|
|
58
|
+
hooks: {
|
|
59
|
+
...integration.hooks,
|
|
60
|
+
...(setup && {
|
|
61
|
+
"astro:config:setup": (options) =>
|
|
62
|
+
setup({ ...options, config: { ...options.config, root: rootUrl } }),
|
|
63
|
+
}),
|
|
64
|
+
...(done && {
|
|
65
|
+
"astro:config:done": (options) =>
|
|
66
|
+
done({ ...options, config: { ...options.config, root: rootUrl } }),
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
package/src/astro/generate.ts
CHANGED
|
@@ -29,7 +29,10 @@ import type {
|
|
|
29
29
|
} from "../core/data.ts";
|
|
30
30
|
import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
|
|
31
31
|
import { resolveFallbackLocale } from "../core/i18n.ts";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
validateNavTargets,
|
|
34
|
+
validateSearchPopularIcons,
|
|
35
|
+
} from "../core/nav-diagnostics.ts";
|
|
33
36
|
import { packageRoot } from "../core/package-root.ts";
|
|
34
37
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
35
38
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
@@ -43,6 +46,7 @@ import { buildReferenceFiles } from "../openapi/scalar.ts";
|
|
|
43
46
|
import { isOpenApiSource } from "../openapi/source.ts";
|
|
44
47
|
import { registry } from "../registry/registry.ts";
|
|
45
48
|
import { buildSearchDocuments } from "../search/documents.ts";
|
|
49
|
+
import { resolveSearchPopular } from "../search/popular.ts";
|
|
46
50
|
import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
|
|
47
51
|
import {
|
|
48
52
|
examplesEntryTemplate,
|
|
@@ -145,23 +149,65 @@ const reactCompilerWarnings = (
|
|
|
145
149
|
]
|
|
146
150
|
: [];
|
|
147
151
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
* none resolves. Comparing this for `.blume/` against Blume's own deps tells
|
|
151
|
-
* whether the runtime would bind to the *same* astro Blume uses or a different
|
|
152
|
-
* one shadowing it (the hoisted-conflict failure mode).
|
|
153
|
-
*/
|
|
154
|
-
const resolvedAstroPath = (fromDir: string): string | null => {
|
|
152
|
+
/** Resolve Astro's package.json directly inside a node_modules directory. */
|
|
153
|
+
const resolveAstroPackageJson = (modulesDir: string): string | null => {
|
|
155
154
|
try {
|
|
156
|
-
|
|
157
|
-
pathToFileURL(join(fromDir, "_.js")).href
|
|
158
|
-
).resolve("astro/package.json");
|
|
159
|
-
return realpathSync(pkg);
|
|
155
|
+
return realpathSync(join(modulesDir, "astro", "package.json"));
|
|
160
156
|
} catch {
|
|
161
157
|
return null;
|
|
162
158
|
}
|
|
163
159
|
};
|
|
164
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Realpath of the `astro` package reachable through the normal node_modules
|
|
163
|
+
* ancestor walk from a generated runtime, or null when none resolves.
|
|
164
|
+
*
|
|
165
|
+
* This deliberately does not use `createRequire().resolve()`. pnpm's generated
|
|
166
|
+
* bin shim adds Blume's virtual-store dependencies to `NODE_PATH`, which
|
|
167
|
+
* CommonJS resolution honors but ESM package resolution ignores. The generated
|
|
168
|
+
* Astro config uses ESM imports, so treating a NODE_PATH-only result as
|
|
169
|
+
* reachable skips the dependency link and makes `import "astro/config"` fail.
|
|
170
|
+
* Walking the physical node_modules ancestors mirrors the lookup that config
|
|
171
|
+
* actually gets.
|
|
172
|
+
*/
|
|
173
|
+
const resolvedAstroPath = (fromDir: string): string | null => {
|
|
174
|
+
let dir = normalize(fromDir);
|
|
175
|
+
while (true) {
|
|
176
|
+
const resolved = resolveAstroPackageJson(join(dir, "node_modules"));
|
|
177
|
+
if (resolved) {
|
|
178
|
+
return resolved;
|
|
179
|
+
}
|
|
180
|
+
const parent = dirname(dir);
|
|
181
|
+
if (parent === dir) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
dir = parent;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The two places an installer can put Blume's dependencies:
|
|
190
|
+
* - `<blume>/node_modules` — deps nested under the package (workspace source,
|
|
191
|
+
* or npm nesting them away from a conflicting hoisted copy)
|
|
192
|
+
* - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
|
|
193
|
+
*
|
|
194
|
+
* `packageRoot()` resolves to Blume's real on-disk path (Node follows the
|
|
195
|
+
* install symlink), so its parent is the store's package directory where the
|
|
196
|
+
* isolated linker places the siblings.
|
|
197
|
+
*/
|
|
198
|
+
const depsCandidates = (pkgDir: string): string[] => [
|
|
199
|
+
join(pkgDir, "node_modules"),
|
|
200
|
+
dirname(pkgDir),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/** First dependency candidate containing the package dir `segments`, or null. */
|
|
204
|
+
const candidateHolding = (
|
|
205
|
+
pkgDir: string,
|
|
206
|
+
...segments: string[]
|
|
207
|
+
): string | null =>
|
|
208
|
+
depsCandidates(pkgDir).find((dir) => existsSync(join(dir, ...segments))) ??
|
|
209
|
+
null;
|
|
210
|
+
|
|
165
211
|
/**
|
|
166
212
|
* Locate the directory that holds Blume's installed dependencies (Astro and its
|
|
167
213
|
* integrations).
|
|
@@ -171,19 +217,28 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
171
217
|
* short-circuits before we need it. But under isolated linkers (Bun's
|
|
172
218
|
* `isolated` mode, pnpm) Blume's deps are NOT hoisted into the project; they
|
|
173
219
|
* live beside the Blume package in a virtual store, invisible to the upward
|
|
174
|
-
* walk from `.blume
|
|
175
|
-
* - `<blume>/node_modules` — deps nested under the package (workspace source)
|
|
176
|
-
* - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
|
|
220
|
+
* walk from `.blume/` — so probe the {@link depsCandidates}.
|
|
177
221
|
*
|
|
178
|
-
*
|
|
179
|
-
* install
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
222
|
+
* Astro alone is a bad probe: an npm split install (an `overrides` pin plus an
|
|
223
|
+
* incremental install) hoists `astro` to the project root while Blume's other
|
|
224
|
+
* deps stay nested, and probing for astro then picks the root directory — one
|
|
225
|
+
* that holds none of them. Prefer a candidate with the full set (astro beside
|
|
226
|
+
* `@astrojs/mdx`, the integration every generated runtime declares), then one
|
|
227
|
+
* with the integrations (astro hoisted away — the rest of Blume's deps sit
|
|
228
|
+
* there too), then one with astro alone.
|
|
183
229
|
*/
|
|
230
|
+
const holdsAstro = (dir: string): boolean => existsSync(join(dir, "astro"));
|
|
231
|
+
const holdsMdx = (dir: string): boolean =>
|
|
232
|
+
existsSync(join(dir, "@astrojs", "mdx"));
|
|
233
|
+
|
|
184
234
|
export const blumeDepsDir = (pkgDir: string = packageRoot()): string | null => {
|
|
185
|
-
const candidates =
|
|
186
|
-
return
|
|
235
|
+
const candidates = depsCandidates(pkgDir);
|
|
236
|
+
return (
|
|
237
|
+
candidates.find((dir) => holdsAstro(dir) && holdsMdx(dir)) ??
|
|
238
|
+
candidates.find(holdsMdx) ??
|
|
239
|
+
candidates.find(holdsAstro) ??
|
|
240
|
+
null
|
|
241
|
+
);
|
|
187
242
|
};
|
|
188
243
|
|
|
189
244
|
/**
|
|
@@ -248,7 +303,7 @@ const astroConflictWarning = (
|
|
|
248
303
|
|
|
249
304
|
/**
|
|
250
305
|
* Make the generated runtime resolve Astro and its integrations against Blume's
|
|
251
|
-
* own dependency set.
|
|
306
|
+
* own dependency set. Three failure modes this repairs:
|
|
252
307
|
*
|
|
253
308
|
* - Astro is *unreachable* from `.blume/` (workspaces under isolated linkers,
|
|
254
309
|
* pnpm) — the deps live in a store the upward walk can't see.
|
|
@@ -257,38 +312,49 @@ const astroConflictWarning = (
|
|
|
257
312
|
* `astro@7`, so `@astrojs/mdx@7` binds to it and crashes the build on a
|
|
258
313
|
* missing export. Resolving merely *an* astro isn't enough; it must be the
|
|
259
314
|
* same one Blume uses.
|
|
315
|
+
* - The *integrations* are unreachable while astro is fine — npm's split
|
|
316
|
+
* install. An `overrides` pin plus an incremental `npm install` hoists
|
|
317
|
+
* astro to the project root (deleting Blume's nested copy) but leaves
|
|
318
|
+
* `@astrojs/mdx` and friends nested under `blume/node_modules`, where the
|
|
319
|
+
* upward walk from `.blume/` can't see them.
|
|
260
320
|
*
|
|
261
|
-
*
|
|
321
|
+
* The repair is the same symlink: Blume's dependency directory linked in as
|
|
262
322
|
* `.blume/node_modules` so the generated config's bare specifiers (`astro`,
|
|
263
|
-
* `@astrojs/mdx`, …) bind to
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
323
|
+
* `@astrojs/mdx`, …) bind to a consistent set. That's safe when the linked
|
|
324
|
+
* directory holds the full set, or when it holds only the integrations but the
|
|
325
|
+
* runtime already resolves Blume's astro — the junction has no `astro` entry,
|
|
326
|
+
* so astro lookups fall through to the hoisted copy the integrations bind to
|
|
327
|
+
* anyway. What it can't fix is the inverse split: Blume's astro nested under a
|
|
328
|
+
* *conflicting* hoisted astro with the integrations hoisted away from it. No
|
|
329
|
+
* single directory yields a consistent set there; only a root `overrides`/
|
|
330
|
+
* `resolutions` pin does, so we return a diagnostic naming the conflict rather
|
|
331
|
+
* than silently shipping a runtime that crashes downstream. Returns the
|
|
332
|
+
* warning, or null when nothing needs saying.
|
|
270
333
|
*/
|
|
271
334
|
export const ensureDepsLink = async (
|
|
272
335
|
outDir: string,
|
|
273
336
|
pkgDir: string = packageRoot()
|
|
274
337
|
): Promise<string | null> => {
|
|
275
|
-
const
|
|
276
|
-
if (!
|
|
338
|
+
const astroDir = candidateHolding(pkgDir, "astro");
|
|
339
|
+
if (!astroDir) {
|
|
277
340
|
return null;
|
|
278
341
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const blumeAstro = resolvedAstroPath(depsDir);
|
|
342
|
+
const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
|
|
343
|
+
const blumeAstro = resolveAstroPackageJson(astroDir);
|
|
282
344
|
const outDirAstro = resolvedAstroPath(outDir);
|
|
283
|
-
|
|
345
|
+
// `.blume/` resolves the very same astro Blume's deps provide.
|
|
346
|
+
const astroCorrect = blumeAstro !== null && outDirAstro === blumeAstro;
|
|
347
|
+
// Clean hoisted install: astro is correct and the integrations sit beside
|
|
348
|
+
// it, so they resolve through the same walk — nothing to do.
|
|
349
|
+
if (astroCorrect && mdxDir === astroDir) {
|
|
284
350
|
return null;
|
|
285
351
|
}
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// replaced.
|
|
290
|
-
if (
|
|
291
|
-
await linkDepsJunction(join(outDir, "node_modules"),
|
|
352
|
+
// Linking the integrations' directory yields a consistent set when it also
|
|
353
|
+
// holds Blume's astro (the unreachable and repairable-conflict cases) or
|
|
354
|
+
// when the correct astro is reachable without it (the npm split install).
|
|
355
|
+
// Any existing link here is stale and gets replaced.
|
|
356
|
+
if (mdxDir && (mdxDir === astroDir || astroCorrect)) {
|
|
357
|
+
await linkDepsJunction(join(outDir, "node_modules"), mdxDir);
|
|
292
358
|
return null;
|
|
293
359
|
}
|
|
294
360
|
// Split layout: Blume's astro is nested (a conflicting astro took the root
|
|
@@ -299,7 +365,7 @@ export const ensureDepsLink = async (
|
|
|
299
365
|
|
|
300
366
|
/**
|
|
301
367
|
* Vite plugin that makes Blume's externalized runtime deps (zod, shiki, sharp,
|
|
302
|
-
*
|
|
368
|
+
* `takumi-js`, …) resolvable when Astro executes the static prerender
|
|
303
369
|
* bundle under an isolated linker (Bun's `isolated` mode, pnpm).
|
|
304
370
|
*
|
|
305
371
|
* Astro's static build emits a self-contained SSR bundle to
|
|
@@ -931,12 +997,14 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
931
997
|
// the optional schema type so the serialized shape stays `boolean`.
|
|
932
998
|
og: {
|
|
933
999
|
enabled: config.seo.og.enabled ?? false,
|
|
1000
|
+
fonts: config.seo.og.fonts ?? [],
|
|
934
1001
|
logo: ogLogo,
|
|
935
1002
|
palette: config.seo.og.palette,
|
|
936
1003
|
},
|
|
937
1004
|
repoUrl,
|
|
938
1005
|
search: {
|
|
939
1006
|
enabled: config.search.provider !== "none",
|
|
1007
|
+
popular: resolveSearchPopular(config.search.popular, config.basePath),
|
|
940
1008
|
provider: config.search.provider,
|
|
941
1009
|
},
|
|
942
1010
|
site: config.deployment.site ?? null,
|
|
@@ -1244,7 +1312,7 @@ export const generateRuntime = async (
|
|
|
1244
1312
|
// Custom pages that should get a generated OG card (the home most of all).
|
|
1245
1313
|
// Computed before the MCP `.well-known` routes are appended below — those are
|
|
1246
1314
|
// private and filtered out anyway, but the intent is the user's pages.
|
|
1247
|
-
const ogRoutes = customOgRoutes(pages, config.title);
|
|
1315
|
+
const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
|
|
1248
1316
|
|
|
1249
1317
|
// The hosted MCP server. The `.well-known` discovery docs are injected as
|
|
1250
1318
|
// prerendered routes alongside user pages; the server endpoint itself is a
|
|
@@ -1509,12 +1577,18 @@ export const generateRuntime = async (
|
|
|
1509
1577
|
if (hasGeneratedChangelog(project, pages)) {
|
|
1510
1578
|
navTargetRoutes.add("/changelog");
|
|
1511
1579
|
}
|
|
1580
|
+
// Curated `search.popular` icons live outside the navigation model, so they
|
|
1581
|
+
// miss `validateNavIcons` in the graph build — they're checked here too,
|
|
1582
|
+
// where the search config is known. A typo otherwise just renders the
|
|
1583
|
+
// default glyph.
|
|
1512
1584
|
warnings.push(
|
|
1513
|
-
...
|
|
1514
|
-
(
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1585
|
+
...[
|
|
1586
|
+
...validateNavTargets(project.graph.navigation, navTargetRoutes),
|
|
1587
|
+
...validateSearchPopularIcons(config.search.popular),
|
|
1588
|
+
].map((diagnostic) =>
|
|
1589
|
+
diagnostic.suggestion
|
|
1590
|
+
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1591
|
+
: diagnostic.message
|
|
1518
1592
|
)
|
|
1519
1593
|
);
|
|
1520
1594
|
|
package/src/astro/index.ts
CHANGED
package/src/astro/pages.ts
CHANGED
|
@@ -6,11 +6,25 @@ import type { BlumePageRoute } from "./integration.ts";
|
|
|
6
6
|
|
|
7
7
|
const PAGE_GLOB = ["**/*.astro"];
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Astro's routing convention: a file or folder whose name starts with `_` is a
|
|
11
|
+
* private partial — importable (shared layouts, home-page sections), but never
|
|
12
|
+
* built into a route. Blume injects pages itself, so it must reproduce the same
|
|
13
|
+
* exclusion or every `pages/_home/Hero.astro`-style component ships as an HTML
|
|
14
|
+
* page.
|
|
15
|
+
*/
|
|
16
|
+
const isPrivatePage = (rel: string): boolean =>
|
|
17
|
+
rel.split("/").some((segment) => segment.startsWith("_"));
|
|
18
|
+
|
|
9
19
|
/** Map discovered page files to routes; shared by the async/sync discoverers. */
|
|
10
20
|
const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
|
|
11
21
|
files.sort();
|
|
12
|
-
|
|
22
|
+
const routes: BlumePageRoute[] = [];
|
|
23
|
+
for (const file of files) {
|
|
13
24
|
const rel = relative(pagesRoot, file);
|
|
25
|
+
if (isPrivatePage(rel)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
14
28
|
const withoutExt = rel.slice(0, rel.length - extname(rel).length);
|
|
15
29
|
const parts = withoutExt.split("/");
|
|
16
30
|
// Only a trailing `index` maps to its parent dir; a folder literally named
|
|
@@ -19,8 +33,9 @@ const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
|
|
|
19
33
|
parts.pop();
|
|
20
34
|
}
|
|
21
35
|
const pattern = parts.length === 0 ? "/" : `/${parts.join("/")}`;
|
|
22
|
-
|
|
23
|
-
}
|
|
36
|
+
routes.push({ entrypoint: file, pattern });
|
|
37
|
+
}
|
|
38
|
+
return routes;
|
|
24
39
|
};
|
|
25
40
|
|
|
26
41
|
/**
|
|
@@ -134,14 +149,25 @@ const humanizeSegment = (segment: string): string =>
|
|
|
134
149
|
* — most importantly the landing `/`, the most-shared URL — would have no card.
|
|
135
150
|
*
|
|
136
151
|
* Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
|
|
137
|
-
* are skipped: they aren't shareable pages.
|
|
138
|
-
*
|
|
139
|
-
*
|
|
152
|
+
* are skipped: they aren't shareable pages. A `titles` entry (`seo.og.titles`,
|
|
153
|
+
* keyed by route) names a card outright — the only way to say "CLI" when the
|
|
154
|
+
* segment humanizes to "Cli". Otherwise the home is titled with the site title
|
|
155
|
+
* and a deeper page from its last path segment. The card's brand lockup,
|
|
156
|
+
* description, and footer come from the resolved config at render time.
|
|
140
157
|
*/
|
|
141
158
|
export const customOgRoutes = (
|
|
142
159
|
pages: BlumePageRoute[],
|
|
143
|
-
siteTitle: string
|
|
160
|
+
siteTitle: string,
|
|
161
|
+
titles: Record<string, string> = {}
|
|
144
162
|
): OgCustomRoute[] => {
|
|
163
|
+
// Keys normalized to `/`-joined segments so `cli`, `/cli`, and `/cli/` all
|
|
164
|
+
// address the page served at `/cli` (and `/` addresses the home).
|
|
165
|
+
const overrides = new Map(
|
|
166
|
+
Object.entries(titles).map(([route, title]) => [
|
|
167
|
+
`/${route.split("/").filter(Boolean).join("/")}`,
|
|
168
|
+
title,
|
|
169
|
+
])
|
|
170
|
+
);
|
|
145
171
|
const seen = new Set<string>();
|
|
146
172
|
const routes: OgCustomRoute[] = [];
|
|
147
173
|
// Extracted so the skip paths become early `return`s (one `continue` budget
|
|
@@ -157,7 +183,12 @@ export const customOgRoutes = (
|
|
|
157
183
|
}
|
|
158
184
|
seen.add(slug);
|
|
159
185
|
const last = segments.at(-1);
|
|
160
|
-
routes.push({
|
|
186
|
+
routes.push({
|
|
187
|
+
slug,
|
|
188
|
+
title:
|
|
189
|
+
overrides.get(`/${segments.join("/")}`) ??
|
|
190
|
+
(last ? humanizeSegment(last) : siteTitle),
|
|
191
|
+
});
|
|
161
192
|
};
|
|
162
193
|
for (const { pattern } of pages) {
|
|
163
194
|
collectRoute(pattern);
|