blume 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -6,6 +6,8 @@ import { join, relative } from "pathe";
6
6
  import { buildAskData } from "../ai/ask-data.ts";
7
7
  import { resolveAskBackend } from "../ai/ask.ts";
8
8
  import { buildRawMarkdown } from "../ai/markdown.ts";
9
+ import { buildMcpData } from "../ai/mcp/data.ts";
10
+ import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
9
11
  import { planComponentSlots } from "../astro/component-slots.ts";
10
12
  import { discoverExamples } from "../astro/examples.ts";
11
13
  import {
@@ -20,6 +22,7 @@ import {
20
22
  askEndpointTemplate,
21
23
  astroConfigTemplate,
22
24
  catchAllPageTemplate,
25
+ changelogIndexTemplate,
23
26
  contentConfigTemplate,
24
27
  envTemplate,
25
28
  exampleMapTemplate,
@@ -28,6 +31,8 @@ import {
28
31
  exampleSlug,
29
32
  islandMapTemplate,
30
33
  islandWrapperTemplate,
34
+ mcpEndpointTemplate,
35
+ mcpPageFile,
31
36
  mixedbreadSearchEndpointTemplate,
32
37
  notFoundPageTemplate,
33
38
  ogEndpointTemplate,
@@ -36,7 +41,9 @@ import {
36
41
  runtimeTsconfigTemplate,
37
42
  searchClientTemplate,
38
43
  searchEndpointTemplate,
44
+ staticJsonEndpointTemplate,
39
45
  } from "../astro/templates.ts";
46
+ import { packageRoot } from "../core/package-root.ts";
40
47
  import { scanProject } from "../core/project-graph.ts";
41
48
  import type { BlumeProject } from "../core/project-graph.ts";
42
49
  import type { ProjectContext } from "../core/types.ts";
@@ -55,6 +62,39 @@ import { twoslashCss } from "../theme/twoslash.ts";
55
62
 
56
63
  const toPosix = (path: string): string => path.split("\\").join("/");
57
64
 
65
+ /** The portable `@source` guess: blume in the project's own node_modules. */
66
+ const LOCAL_BLUME_SOURCE = "../../node_modules/blume/src/**/*.{astro,ts,tsx}";
67
+
68
+ /**
69
+ * The `@source` glob pointing Tailwind at Blume's own source, relative to the
70
+ * ejected `src/generated/app.css`. The project-local `node_modules/blume` is
71
+ * preferred (portable, and under pnpm the symlink survives version bumps), but
72
+ * hoisted installs (npm/yarn workspaces lift blume into the workspace root's
73
+ * node_modules) would make that guess match nothing and silently drop utility
74
+ * classes — so fall back to the package's real installed location, and when
75
+ * even that fails, warn instead of failing silently.
76
+ *
77
+ * Exported for testing.
78
+ */
79
+ export const blumeSourceGlob = (
80
+ root: string,
81
+ genDir: string,
82
+ resolveBlumeRoot: () => string = packageRoot
83
+ ): string => {
84
+ if (existsSync(join(root, "node_modules", "blume"))) {
85
+ return LOCAL_BLUME_SOURCE;
86
+ }
87
+ try {
88
+ const src = join(resolveBlumeRoot(), "src");
89
+ return `${toPosix(relative(genDir, src))}/**/*.{astro,ts,tsx}`;
90
+ } catch {
91
+ console.warn(
92
+ 'blume: could not locate the installed blume package; src/generated/app.css keeps its default `@source "../../node_modules/blume/..."` glob. If blume is hoisted elsewhere, point that glob at its install location or Blume\'s utility classes will be missing.'
93
+ );
94
+ return LOCAL_BLUME_SOURCE;
95
+ }
96
+ };
97
+
58
98
  /** The `blume:openapi` payload for the ejected app (`{}` when none). */
59
99
  const ejectOpenApiData = (project: BlumeProject): unknown => {
60
100
  const source = project.sources.find(isOpenApiSource);
@@ -90,6 +130,112 @@ const askFiles = async (
90
130
  return files;
91
131
  };
92
132
 
133
+ /** Whether the ejected app hosts the MCP server (enabled and route free). */
134
+ const hostsMcp = (
135
+ project: BlumeProject,
136
+ userPages: { pattern: string }[]
137
+ ): boolean =>
138
+ project.config.mcp.enabled &&
139
+ !routeIsTaken(userPages, project.graph.pages, project.config.mcp.route);
140
+
141
+ /**
142
+ * The `.well-known` MCP discovery routes, injected as prerendered pages
143
+ * alongside the user's own so the ejected Astro config wires them in. Empty
144
+ * when the server is disabled or its route is already owned by a page.
145
+ */
146
+ const mcpDiscoveryPages = (
147
+ project: BlumeProject,
148
+ userPages: { pattern: string }[]
149
+ ): { entrypoint: string; pattern: string }[] =>
150
+ hostsMcp(project, userPages)
151
+ ? [
152
+ {
153
+ entrypoint: "src/blume-mcp/discovery.ts",
154
+ pattern: "/.well-known/mcp.json",
155
+ },
156
+ {
157
+ entrypoint: "src/blume-mcp/server-card.ts",
158
+ pattern: "/.well-known/mcp/server-card.json",
159
+ },
160
+ ]
161
+ : [];
162
+
163
+ /**
164
+ * The MCP data snapshot, server endpoint, and `.well-known` discovery
165
+ * documents, mirroring `writeMcpFiles` in generate.ts. Empty when the server
166
+ * is disabled or its route is already owned by a page.
167
+ */
168
+ const mcpFiles = async (
169
+ project: BlumeProject,
170
+ userPages: { pattern: string }[],
171
+ srcDir: string,
172
+ genDir: string
173
+ ): Promise<{ content: string; path: string }[]> => {
174
+ if (!hostsMcp(project, userPages)) {
175
+ return [];
176
+ }
177
+ const { route } = project.config.mcp;
178
+ const data = await buildMcpData(project);
179
+ const discoveryInput = {
180
+ base: data.base,
181
+ name: data.name,
182
+ route,
183
+ site: data.site,
184
+ version: data.version,
185
+ };
186
+ return [
187
+ {
188
+ content: `${JSON.stringify(data)}\n`,
189
+ path: join(genDir, "mcp-data.json"),
190
+ },
191
+ {
192
+ content: mcpEndpointTemplate(route),
193
+ path: join(srcDir, "pages", mcpPageFile(route)),
194
+ },
195
+ {
196
+ content: staticJsonEndpointTemplate(buildMcpDiscovery(discoveryInput)),
197
+ path: join(srcDir, "blume-mcp", "discovery.ts"),
198
+ },
199
+ {
200
+ content: staticJsonEndpointTemplate(buildMcpServerCard(discoveryInput)),
201
+ path: join(srcDir, "blume-mcp", "server-card.ts"),
202
+ },
203
+ ];
204
+ };
205
+
206
+ /**
207
+ * The `/changelog` index page, mirroring `shouldGenerateChangelog` in
208
+ * generate.ts: emitted when `type: changelog` entries or a release-backed
209
+ * changelog source exist, unless a user page already owns the route.
210
+ */
211
+ const changelogFiles = (
212
+ project: BlumeProject,
213
+ userPages: { pattern: string }[],
214
+ srcDir: string,
215
+ options: Parameters<typeof changelogIndexTemplate>[0]
216
+ ): { content: string; path: string }[] => {
217
+ const hasChangelog = project.graph.pages.some(
218
+ (page) =>
219
+ page.contentType === "changelog" &&
220
+ !(page.meta.draft || page.meta.sidebar.hidden)
221
+ );
222
+ const hasChangelogSource = (project.config.content.sources ?? []).some(
223
+ (source) => source.type === "github-releases"
224
+ );
225
+ if (
226
+ !(hasChangelog || hasChangelogSource) ||
227
+ routeIsTaken(userPages, project.graph.pages, "/changelog")
228
+ ) {
229
+ return [];
230
+ }
231
+ return [
232
+ {
233
+ content: changelogIndexTemplate(options),
234
+ path: join(srcDir, "pages", "changelog.astro"),
235
+ },
236
+ ];
237
+ };
238
+
93
239
  /** Contents of the configured `examples.css`, or `""` when unset/absent. */
94
240
  const readExamplesCss = (
95
241
  root: string,
@@ -129,9 +275,13 @@ const examplesPreviewFiles = (
129
275
  * eject the project has a normal `astro.config.mjs` and `src/`, the `blume` CLI
130
276
  * is no longer required, and the `blume` package remains importable.
131
277
  *
132
- * Returns the list of written files.
278
+ * Returns the written files plus non-fatal warnings, mirroring the generated
279
+ * runtime (e.g. a Scalar reference spec that wasn't found, or a reference
280
+ * route colliding with a content page).
133
281
  */
134
- export const eject = async (root: string): Promise<string[]> => {
282
+ export const eject = async (
283
+ root: string
284
+ ): Promise<{ files: string[]; warnings: string[] }> => {
135
285
  const project = await scanProject(root, { mode: "build" });
136
286
  const { context, config } = project;
137
287
 
@@ -183,10 +333,13 @@ export const eject = async (root: string): Promise<string[]> => {
183
333
  const componentsImport = context.componentsFile
184
334
  ? `../../${toPosix(relative(root, context.componentsFile))}`
185
335
  : null;
186
- const relPages = pages.map((page) => ({
187
- entrypoint: toPosix(relative(root, page.entrypoint)),
188
- pattern: page.pattern,
189
- }));
336
+ const relPages = [
337
+ ...pages.map((page) => ({
338
+ entrypoint: toPosix(relative(root, page.entrypoint)),
339
+ pattern: page.pattern,
340
+ })),
341
+ ...mcpDiscoveryPages(project, pages),
342
+ ];
190
343
 
191
344
  // Non-filesystem sources eject their materialized MDX into `<root>/blume-staged`
192
345
  // (a dedicated dir so it never clashes with a content root literally named
@@ -275,9 +428,11 @@ export const eject = async (root: string): Promise<string[]> => {
275
428
  {
276
429
  content: tailwindEntryTemplate({
277
430
  configTokens: buildThemeCss(config.theme),
278
- // Relative paths from src/generated/app.css keep the ejected app portable.
431
+ // Relative paths from src/generated/app.css keep the ejected app
432
+ // portable; the blume glob resolves the real install location when
433
+ // the package is hoisted out of the project's own node_modules.
279
434
  sources: [
280
- "../../node_modules/blume/src/**/*.{astro,ts,tsx}",
435
+ blumeSourceGlob(root, genDir),
281
436
  "../../**/*.{astro,mdx,ts,tsx}",
282
437
  ],
283
438
  twoslashCss: twoslashCss(),
@@ -295,11 +450,11 @@ export const eject = async (root: string): Promise<string[]> => {
295
450
  path: join(genDir, "raw-markdown.json"),
296
451
  },
297
452
  {
298
- content: rawMarkdownEndpointTemplate(),
453
+ content: rawMarkdownEndpointTemplate("md"),
299
454
  path: join(srcDir, "pages", "[...slug].md.ts"),
300
455
  },
301
456
  {
302
- content: rawMarkdownEndpointTemplate(),
457
+ content: rawMarkdownEndpointTemplate("mdx"),
303
458
  path: join(srcDir, "pages", "[...slug].mdx.ts"),
304
459
  },
305
460
  ];
@@ -315,6 +470,19 @@ export const eject = async (root: string): Promise<string[]> => {
315
470
  });
316
471
  }
317
472
 
473
+ // The hosted MCP server and the `/changelog` index, mirrored from the
474
+ // generated runtime (each helper returns `[]` when its feature is off).
475
+ files.push(
476
+ ...(await mcpFiles(project, pages, srcDir, genDir)),
477
+ ...changelogFiles(project, pages, srcDir, {
478
+ askEnabled,
479
+ exportEpub,
480
+ exportPdf,
481
+ needsReact,
482
+ staged: hasStaged,
483
+ })
484
+ );
485
+
318
486
  // Default 404 page, unless the project already owns `/404` (a custom
319
487
  // `pages/404.astro` or a `404.md` content page). The ejected project owns the
320
488
  // file afterwards and can edit or remove it.
@@ -372,13 +540,17 @@ export const eject = async (root: string): Promise<string[]> => {
372
540
  }
373
541
 
374
542
  // Scalar API/AsyncAPI reference pages, mirrored from the generated runtime so
375
- // the ejected app keeps its reference routes.
543
+ // the ejected app keeps its reference routes — including the warnings (a
544
+ // missing spec file, a route collision), which the caller surfaces exactly
545
+ // like the generated-runtime path does.
546
+ const warnings: string[] = [];
376
547
  if (hasScalarReferences(config)) {
377
548
  const references = await buildReferenceFiles({
378
549
  config,
379
550
  contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
380
551
  root,
381
552
  });
553
+ warnings.push(...references.warnings);
382
554
  for (const file of references.files) {
383
555
  files.push({
384
556
  content: file.content,
@@ -433,5 +605,5 @@ export const eject = async (root: string): Promise<string[]> => {
433
605
  // The hidden runtime is no longer the source of truth.
434
606
  await rm(context.outDir, { force: true, recursive: true });
435
607
 
436
- return written.map((file) => file.path);
608
+ return { files: written.map((file) => file.path), warnings };
437
609
  };
@@ -23,9 +23,6 @@ export interface RegistryItem {
23
23
  /** Absolute path to the blume package `src` directory (the copy source root). */
24
24
  export const packageSrc = join(packageRoot(), "src");
25
25
 
26
- /** Absolute path to the bundled registry item sources. */
27
- export const itemsRoot = join(packageRoot(), "src", "registry", "items");
28
-
29
26
  /**
30
27
  * A built-in layout component offered as editable source. `blume add` rewrites
31
28
  * its relative imports to `blume/*`, so it renders identically to the built-in
@@ -1,3 +1,5 @@
1
+ import { applyAudienceVisibility } from "../ai/visibility.ts";
2
+ import type { VisibilityAudience } from "../ai/visibility.ts";
1
3
  import matter from "../core/frontmatter.ts";
2
4
  import { contentIndexable } from "../core/manifest.ts";
3
5
  import type { BlumeProject } from "../core/project-graph.ts";
@@ -93,9 +95,21 @@ interface Crumbs {
93
95
  */
94
96
  const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
95
97
  const index = new Map<string, Crumbs>();
98
+ // A config-sidebar section's landing page (the group's `root`) lives on the
99
+ // *group* node, not on any page leaf — record it under the group's own label
100
+ // so the section's landing page carries the same facet as its children. A
101
+ // real page leaf for the route (filesystem sidebars emit index pages as
102
+ // leaves) wins, so group routes are merged in only where no leaf claimed one.
103
+ const groupRoutes = new Map<string, Crumbs>();
96
104
  const walk = (nodes: NavNode[], trail: string[]): void => {
97
105
  for (const node of nodes) {
98
106
  if (node.kind === "group") {
107
+ if (node.route && !groupRoutes.has(node.route)) {
108
+ groupRoutes.set(node.route, {
109
+ breadcrumb: [...trail, node.label],
110
+ section: node.label,
111
+ });
112
+ }
99
113
  walk(node.children, [...trail, node.label]);
100
114
  } else if (node.route) {
101
115
  index.set(node.route, {
@@ -106,6 +120,11 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
106
120
  }
107
121
  };
108
122
  walk(sidebar, []);
123
+ for (const [route, crumbs] of groupRoutes) {
124
+ if (!index.has(route)) {
125
+ index.set(route, crumbs);
126
+ }
127
+ }
109
128
  return index;
110
129
  };
111
130
 
@@ -122,10 +141,19 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
122
141
  * searchable text; `"markdown"` keeps the body's Markdown — code blocks, lists,
123
142
  * headings — for Ask AI grounding, where fenced examples are often the answer
124
143
  * and stripping them makes the model unable to cite content the docs do contain.
144
+ *
145
+ * `audience` resolves `<Visibility>` blocks before extraction: `"web"`
146
+ * (default) keeps web-only content and drops agents-only blocks — the site
147
+ * search and hosted syncs must not surface content the page hides — while
148
+ * `"agents"` mirrors llms-full.txt/MCP `get_page` (web removed, agents kept).
125
149
  */
126
150
  export const buildSearchDocuments = async (
127
151
  project: BlumeProject,
128
- options?: { includeWhenDisabled?: boolean; content?: "markdown" | "plain" }
152
+ options?: {
153
+ includeWhenDisabled?: boolean;
154
+ content?: "markdown" | "plain";
155
+ audience?: VisibilityAudience;
156
+ }
129
157
  ): Promise<SearchDocument[]> => {
130
158
  const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
131
159
 
@@ -157,8 +185,12 @@ export const buildSearchDocuments = async (
157
185
  const page = pageById.get(route.id);
158
186
  const raw = page ? await readEntryText(project, page) : "";
159
187
  const source = raw ? matter(raw).content : "";
188
+ const visible = applyAudienceVisibility(
189
+ source,
190
+ options?.audience ?? "web"
191
+ );
160
192
  const body =
161
- options?.content === "markdown" ? source.trim() : toPlainText(source);
193
+ options?.content === "markdown" ? visible.trim() : toPlainText(visible);
162
194
  const tags = page?.meta?.search?.tags;
163
195
  const crumb = crumbs.get(route.path);
164
196
  return {
package/src/seo/jsonld.ts CHANGED
@@ -98,20 +98,21 @@ export const buildStructuredData = (
98
98
  }
99
99
  graph.push(node);
100
100
 
101
- if (input.breadcrumbs.length > 1) {
101
+ // Google requires `item` on every ListItem except the last; sidebar groups
102
+ // without an index page produce route-less crumbs, so those are dropped
103
+ // (positions renumbered) rather than emitted as invalid link-less items.
104
+ const linked = input.breadcrumbs.filter(
105
+ (crumb): crumb is Required<Crumb> => typeof crumb.route === "string"
106
+ );
107
+ if (linked.length > 1) {
102
108
  graph.push({
103
109
  "@type": "BreadcrumbList",
104
- itemListElement: input.breadcrumbs.map((crumb, index) => {
105
- const item: Record<string, unknown> = {
106
- "@type": "ListItem",
107
- name: crumb.label,
108
- position: index + 1,
109
- };
110
- if (crumb.route) {
111
- item.item = absolute(base, withBasePath(deployBase, crumb.route));
112
- }
113
- return item;
114
- }),
110
+ itemListElement: linked.map((crumb, index) => ({
111
+ "@type": "ListItem",
112
+ item: absolute(base, withBasePath(deployBase, crumb.route)),
113
+ name: crumb.label,
114
+ position: index + 1,
115
+ })),
115
116
  });
116
117
  }
117
118
  }