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.
Files changed (117) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli/index.js +13404 -10228
  3. package/dist/cli/index.js.map +94 -63
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +73 -4
  6. package/dist/types/core/data.d.ts +9 -0
  7. package/dist/types/core/deployment-env.d.ts +6 -0
  8. package/dist/types/core/diagnostics.d.ts +23 -0
  9. package/dist/types/core/i18n-ui.d.ts +8 -8
  10. package/dist/types/core/schema.d.ts +144 -22
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +41 -0
  13. package/dist/types/core/types.d.ts +20 -0
  14. package/dist/types/og/card.d.ts +63 -0
  15. package/dist/types/og/dimensions.d.ts +12 -0
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +11 -0
  19. package/docs/advanced/changelog.mdx +2 -2
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +3 -3
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +37 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +14 -0
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +5 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/reference/cli.mdx +80 -2
  34. package/docs/reference/frontmatter.mdx +31 -1
  35. package/package.json +4 -3
  36. package/skills/blume-migrate/SKILL.md +1 -1
  37. package/skills/blume-migrate/references/mintlify.md +3 -2
  38. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  39. package/src/ai/component-markdown.ts +39 -11
  40. package/src/ai/llms.ts +19 -2
  41. package/src/ai/markdown.ts +5 -1
  42. package/src/astro/adapter-root.ts +70 -0
  43. package/src/astro/generate.ts +124 -50
  44. package/src/astro/index.ts +1 -0
  45. package/src/astro/pages.ts +39 -8
  46. package/src/astro/templates.ts +93 -28
  47. package/src/audit/agent.ts +114 -0
  48. package/src/audit/catalog.ts +826 -0
  49. package/src/audit/checks/assets.ts +177 -0
  50. package/src/audit/checks/content.ts +231 -0
  51. package/src/audit/checks/duplicates.ts +131 -0
  52. package/src/audit/checks/i18n.ts +246 -0
  53. package/src/audit/checks/indexability.ts +213 -0
  54. package/src/audit/checks/links.ts +223 -0
  55. package/src/audit/checks/llms.ts +138 -0
  56. package/src/audit/checks/network.ts +272 -0
  57. package/src/audit/checks/og-image.ts +113 -0
  58. package/src/audit/checks/redirects.ts +87 -0
  59. package/src/audit/checks/robots.ts +114 -0
  60. package/src/audit/checks/sitemap.ts +229 -0
  61. package/src/audit/checks/social.ts +238 -0
  62. package/src/audit/crawl.ts +259 -0
  63. package/src/audit/graph.ts +74 -0
  64. package/src/audit/html.ts +54 -0
  65. package/src/audit/image-size.ts +63 -0
  66. package/src/audit/locate.ts +33 -0
  67. package/src/audit/redirects.ts +74 -0
  68. package/src/audit/report.ts +278 -0
  69. package/src/audit/run.ts +198 -0
  70. package/src/audit/snapshot.ts +189 -0
  71. package/src/audit/types.ts +214 -0
  72. package/src/audit/url.ts +103 -0
  73. package/src/cli/commands/audit.ts +205 -0
  74. package/src/cli/commands/build.ts +64 -13
  75. package/src/cli/index.ts +2 -0
  76. package/src/cli/prepare.ts +10 -2
  77. package/src/components/content/Tabs.astro +98 -15
  78. package/src/components/layout/Breadcrumbs.astro +1 -1
  79. package/src/components/layout/Header.astro +1 -0
  80. package/src/components/layout/PageFeedback.astro +1 -1
  81. package/src/components/layout/PageLayout.astro +5 -1
  82. package/src/components/layout/Pagination.astro +1 -1
  83. package/src/components/layout/RootLayout.astro +5 -3
  84. package/src/components/layout/Search.astro +35 -6
  85. package/src/components/layout/TableOfContents.astro +1 -1
  86. package/src/components/openapi/Authorization.astro +80 -0
  87. package/src/components/openapi/Operation.astro +19 -1
  88. package/src/components/openapi/ParametersTable.astro +1 -1
  89. package/src/components/openapi/security.ts +201 -0
  90. package/src/components/openapi/snippets.ts +42 -13
  91. package/src/core/config-input.ts +78 -4
  92. package/src/core/data.ts +9 -1
  93. package/src/core/deployment-env.ts +9 -0
  94. package/src/core/diagnostics.ts +61 -12
  95. package/src/core/graph.ts +23 -4
  96. package/src/core/links.ts +2 -91
  97. package/src/core/nav-diagnostics.ts +48 -4
  98. package/src/core/navigation.ts +169 -14
  99. package/src/core/probe.ts +136 -0
  100. package/src/core/project-graph.ts +54 -20
  101. package/src/core/schema.ts +93 -3
  102. package/src/core/sources/github-releases.ts +65 -2
  103. package/src/core/sources/normalize.ts +198 -25
  104. package/src/core/sources/types.ts +3 -1
  105. package/src/core/standard-schema.ts +54 -0
  106. package/src/core/types.ts +20 -0
  107. package/src/deploy/adapter-output.ts +27 -15
  108. package/src/deploy/headers.ts +66 -0
  109. package/src/deploy/redirects.ts +49 -9
  110. package/src/markdown/index.ts +1 -0
  111. package/src/markdown/twoslash.ts +60 -0
  112. package/src/og/card.ts +98 -33
  113. package/src/og/index.ts +1 -1
  114. package/src/registry/eject.ts +3 -1
  115. package/src/search/popular.ts +33 -0
  116. package/src/theme/entry.ts +6 -1
  117. /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. Expressions that reference
82
- * imports or scope throw and report as not evaluable.
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 = (raw: string): { ok: boolean; value: unknown } => {
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 = (node: MdastNode): EvaluatedProps => {
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 full source and the active registry. */
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({ registry, source }, tree.children ?? [], splices);
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(matter(raw).content),
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,
@@ -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
+ };
@@ -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 { validateNavTargets } from "../core/nav-diagnostics.ts";
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
- * Realpath of the `astro` package node resolves from a directory, or null when
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
- const pkg = createRequire(
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/`. Two layouts are possible, so probe for `astro`:
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
- * `packageRoot()` resolves to Blume's real on-disk path (Node follows the
179
- * install symlink), so its parent is the store's package directory where the
180
- * isolated linker places the siblings. The previous fixed
181
- * `packageRoot()/node_modules` assumption missed the sibling layout entirely,
182
- * which is why isolated-linker projects had to redeclare Blume's deps by hand.
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 = [join(pkgDir, "node_modules"), dirname(pkgDir)];
186
- return candidates.find((dir) => existsSync(join(dir, "astro"))) ?? null;
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. Two failure modes this repairs:
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
- * In both cases we symlink Blume's dependency directory in as
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 the matching set. We only do this when those deps
264
- * are a *co-located, consistent* set (astro beside the `@astrojs/mdx` that binds
265
- * to it). A split layout — an integration hoisted away from a conflicting astro
266
- * — can't be made consistent by a single symlink and needs a root `overrides`/
267
- * `resolutions` pin instead. We can't fix that from `.blume/`, so we return a
268
- * diagnostic naming the conflict rather than silently shipping a runtime that
269
- * crashes downstream. Returns the warning, or null when nothing needs saying.
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 depsDir = blumeDepsDir(pkgDir);
276
- if (!depsDir) {
338
+ const astroDir = candidateHolding(pkgDir, "astro");
339
+ if (!astroDir) {
277
340
  return null;
278
341
  }
279
- // Already correct when `.blume/` resolves the very same astro Blume's deps
280
- // provide — the clean hoisted case, nothing to do.
281
- const blumeAstro = resolvedAstroPath(depsDir);
342
+ const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
343
+ const blumeAstro = resolveAstroPackageJson(astroDir);
282
344
  const outDirAstro = resolvedAstroPath(outDir);
283
- if (blumeAstro && outDirAstro === blumeAstro) {
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
- // A co-located, consistent set (astro beside the @astrojs/mdx that binds to
287
- // it) can be linked in wholesale; this repairs the unreachable and the
288
- // repairable-conflict cases. Any existing link here is stale and gets
289
- // replaced.
290
- if (existsSync(join(depsDir, "@astrojs", "mdx"))) {
291
- await linkDepsJunction(join(outDir, "node_modules"), depsDir);
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
- * `@takumi-rs/core`, …) resolvable when Astro executes the static prerender
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
- ...validateNavTargets(project.graph.navigation, navTargetRoutes).map(
1514
- (diagnostic) =>
1515
- diagnostic.suggestion
1516
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1517
- : diagnostic.message
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
 
@@ -1,3 +1,4 @@
1
+ export { withAdapterRoot } from "./adapter-root.ts";
1
2
  export {
2
3
  generateRuntime,
3
4
  prerenderDepsPlugin,
@@ -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
- return files.map((file) => {
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
- return { entrypoint: file, pattern };
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. The home is titled with the site
138
- * title; a deeper page is titled from its last path segment. The card's brand
139
- * lockup, description, and footer come from the resolved config at render time.
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({ slug, title: last ? humanizeSegment(last) : siteTitle });
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);