blume 2.1.1 → 2.1.2

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 (171) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/{chunk-ncj7w596.js → chunk-0pmb4tr4.js} +22 -18
  3. package/dist/cli/{chunk-ncj7w596.js.map → chunk-0pmb4tr4.js.map} +3 -3
  4. package/dist/cli/{chunk-nq1d20w2.js → chunk-0w6hs479.js} +15 -15
  5. package/dist/cli/{chunk-ehweg4fr.js → chunk-124fvmzq.js} +1031 -903
  6. package/dist/cli/{chunk-ehweg4fr.js.map → chunk-124fvmzq.js.map} +20 -18
  7. package/dist/cli/{chunk-kma9f6sf.js → chunk-1f882900.js} +2 -2
  8. package/dist/cli/{chunk-e8xw9qkh.js → chunk-297ajdcv.js} +4 -4
  9. package/dist/cli/{chunk-e6atmn4h.js → chunk-32khwcb6.js} +7 -7
  10. package/dist/cli/{chunk-073rdvgb.js → chunk-36bxs9c7.js} +16 -16
  11. package/dist/cli/{chunk-mrcccgnh.js → chunk-53jqw8z0.js} +3 -3
  12. package/dist/cli/{chunk-ft9vfys6.js → chunk-58bt3hag.js} +4 -4
  13. package/dist/cli/{chunk-sds923fa.js → chunk-5cmts6dv.js} +3 -3
  14. package/dist/cli/{chunk-cfyg47zg.js → chunk-60hz265r.js} +4 -4
  15. package/dist/cli/{chunk-j134vrp2.js → chunk-74wfhv79.js} +1 -1
  16. package/dist/cli/{chunk-nrns10mj.js → chunk-9qzgg6mj.js} +3 -3
  17. package/dist/cli/{chunk-tphac74f.js → chunk-a57yh0h9.js} +5 -5
  18. package/dist/cli/{chunk-hebn0tpx.js → chunk-ave1wcbr.js} +1 -1
  19. package/dist/cli/{chunk-htrwgabr.js → chunk-bqxaqn5p.js} +6 -6
  20. package/dist/cli/{chunk-5vjtgs6e.js → chunk-bxwhtvw8.js} +5 -5
  21. package/dist/cli/{chunk-66w9j7p6.js → chunk-ckrcm08h.js} +5 -3
  22. package/dist/cli/{chunk-66w9j7p6.js.map → chunk-ckrcm08h.js.map} +3 -3
  23. package/dist/cli/{chunk-n0v7m8ry.js → chunk-desvm72z.js} +3 -3
  24. package/dist/cli/{chunk-3v3ffbrj.js → chunk-dhwtqnnd.js} +2 -2
  25. package/dist/cli/{chunk-gb420vvz.js → chunk-dpy8qdjf.js} +7 -4
  26. package/dist/cli/chunk-dpy8qdjf.js.map +17 -0
  27. package/dist/cli/{chunk-c1y538kd.js → chunk-dqhg54mr.js} +1 -1
  28. package/dist/cli/{chunk-42t4jxdx.js → chunk-dv2z0rzf.js} +1 -1
  29. package/dist/cli/{chunk-cm89scdv.js → chunk-es8h0v3j.js} +9 -11
  30. package/dist/cli/{chunk-cm89scdv.js.map → chunk-es8h0v3j.js.map} +3 -3
  31. package/dist/cli/{chunk-hx3xqnk9.js → chunk-gfc5fqvf.js} +14 -14
  32. package/dist/cli/{chunk-7ppf1rva.js → chunk-gxtg6ed2.js} +16 -16
  33. package/dist/cli/{chunk-8y2kmjv8.js → chunk-hvcxng5f.js} +6 -6
  34. package/dist/cli/{chunk-54ztw1nj.js → chunk-j1x92mx9.js} +1 -1
  35. package/dist/cli/{chunk-313vnn40.js → chunk-k3gr1nm9.js} +11 -9
  36. package/dist/cli/chunk-k3gr1nm9.js.map +10 -0
  37. package/dist/cli/{chunk-c887fj0s.js → chunk-kdpzw38a.js} +2 -2
  38. package/dist/cli/{chunk-t2m5j4pr.js → chunk-ms9z66nz.js} +2 -2
  39. package/dist/cli/{chunk-t2m5j4pr.js.map → chunk-ms9z66nz.js.map} +2 -2
  40. package/dist/cli/{chunk-ep6nd0rn.js → chunk-nbyvgnnm.js} +26 -14
  41. package/dist/cli/{chunk-ep6nd0rn.js.map → chunk-nbyvgnnm.js.map} +7 -7
  42. package/dist/cli/{chunk-w1r4vase.js → chunk-s0bdnc7v.js} +2 -2
  43. package/dist/cli/{chunk-fdrkhpjy.js → chunk-sc93apfs.js} +14 -14
  44. package/dist/cli/{chunk-dzwehpy7.js → chunk-tfycddbt.js} +2 -2
  45. package/dist/cli/{chunk-3x6x3pph.js → chunk-twy7psdz.js} +7 -7
  46. package/dist/cli/{chunk-e9q7a2ak.js → chunk-y4msrwm9.js} +15 -15
  47. package/dist/cli/{chunk-tdqhyxfz.js → chunk-ydp3065p.js} +5 -5
  48. package/dist/cli/index.js +18 -18
  49. package/dist/types/ai/ask.d.ts +24 -5
  50. package/dist/types/analytics/clics.d.ts +37 -0
  51. package/dist/types/analytics/index.d.ts +2 -0
  52. package/dist/types/analytics/schema.d.ts +18 -0
  53. package/dist/types/astro/integration.d.ts +6 -0
  54. package/dist/types/core/config-input.d.ts +7 -3
  55. package/dist/types/core/powered-by.d.ts +12 -0
  56. package/dist/types/core/schema.d.ts +109 -3
  57. package/dist/types/core/sources/lower.d.ts +10 -2
  58. package/dist/types/core/sources/notion.d.ts +8 -0
  59. package/dist/types/deploy/cloudflare-negotiation.d.ts +5 -2
  60. package/dist/types/deploy/node-headers.d.ts +12 -4
  61. package/dist/types/deploy/platforms/vercel.d.ts +2 -2
  62. package/dist/types/deploy/vercel-negotiation.d.ts +4 -2
  63. package/dist/types/narration/build.d.ts +34 -12
  64. package/dist/types/narration/provider.d.ts +96 -8
  65. package/dist/types/openapi/sentence.d.ts +11 -1
  66. package/dist/types/seo/jsonld.d.ts +11 -6
  67. package/dist/types/sources/notion.d.ts +8 -0
  68. package/dist/types/sources/registry.d.ts +9 -0
  69. package/docs/02-deployment.mdx +29 -1
  70. package/docs/03-upgrading.mdx +2 -1
  71. package/docs/04-migrating.mdx +9 -9
  72. package/docs/08-faq.mdx +12 -12
  73. package/docs/advanced/changelog.mdx +19 -2
  74. package/docs/advanced/custom-pages.mdx +1 -1
  75. package/docs/cli/doctor.mdx +1 -1
  76. package/docs/configuration/analytics.mdx +19 -1
  77. package/docs/configuration/index.mdx +1 -0
  78. package/docs/configuration/narration.mdx +43 -6
  79. package/docs/content/navigation.mdx +4 -2
  80. package/docs/content/sources/contentful.mdx +2 -0
  81. package/docs/content/sources/github-releases.mdx +2 -0
  82. package/docs/content/sources/notion.mdx +16 -1
  83. package/docs/content/sources/obsidian.mdx +2 -0
  84. package/docs/content/sources/payload.mdx +2 -0
  85. package/docs/content/sources/remote-mdx.mdx +2 -0
  86. package/docs/content/sources/sanity.mdx +2 -0
  87. package/docs/content/sources/strapi.mdx +2 -0
  88. package/docs/discoverability/mcp.mdx +2 -0
  89. package/docs/discoverability/structured-data.mdx +8 -6
  90. package/docs/references/asyncapi.mdx +2 -0
  91. package/docs/references/graphql.mdx +2 -0
  92. package/docs/references/openapi.mdx +14 -0
  93. package/package.json +1 -1
  94. package/skills/blume/SKILL.md +1 -1
  95. package/skills/blume-migrate/SKILL.md +1 -1
  96. package/skills/blume-migrate/assets/{oxfmt@0.67.0.patch → oxfmt@0.71.0.patch} +7 -7
  97. package/skills/blume-migrate/references/monorepo.md +5 -5
  98. package/src/ai/ask.ts +24 -6
  99. package/src/analytics/clics.ts +58 -0
  100. package/src/analytics/head.ts +5 -0
  101. package/src/analytics/index.ts +2 -0
  102. package/src/analytics/schema.ts +2 -0
  103. package/src/astro/generate.ts +13 -8
  104. package/src/astro/integration.ts +56 -9
  105. package/src/astro/render-deps.ts +10 -5
  106. package/src/astro/runtime-deps.ts +23 -2
  107. package/src/astro/templates.ts +4 -1
  108. package/src/cli/init/scaffold.ts +6 -3
  109. package/src/cli/required-secrets.ts +2 -1
  110. package/src/components/content/view-switcher.ts +7 -4
  111. package/src/components/layout/NavTree.astro +49 -5
  112. package/src/components/layout/NavTreeScript.astro +18 -0
  113. package/src/components/layout/PageLayout.astro +13 -3
  114. package/src/components/layout/RootLayout.astro +17 -12
  115. package/src/components/layout/analytics-client.ts +8 -8
  116. package/src/components/openapi/playground-client.ts +53 -53
  117. package/src/components/powered-by-middleware.ts +33 -0
  118. package/src/core/config-input.ts +11 -3
  119. package/src/core/powered-by.ts +12 -0
  120. package/src/core/schema.ts +5 -4
  121. package/src/core/sources/lower.ts +19 -3
  122. package/src/core/sources/notion.ts +27 -9
  123. package/src/deploy/cloudflare-negotiation.ts +36 -7
  124. package/src/deploy/headers.ts +15 -1
  125. package/src/deploy/node-headers.ts +32 -20
  126. package/src/deploy/platforms/cloudflare.ts +1 -0
  127. package/src/deploy/platforms/vercel.ts +4 -3
  128. package/src/deploy/vercel-negotiation.ts +38 -4
  129. package/src/markdown/base-links.ts +15 -14
  130. package/src/markdown/package-commands.ts +9 -8
  131. package/src/narration/build.ts +218 -40
  132. package/src/narration/provider.ts +89 -9
  133. package/src/openapi/render-mdx.ts +68 -8
  134. package/src/openapi/sentence.ts +29 -2
  135. package/src/registry/eject.ts +5 -1
  136. package/src/seo/jsonld.ts +38 -20
  137. package/src/sources/notion.ts +7 -0
  138. package/src/theme/color.ts +9 -10
  139. package/src/theme/entry.ts +14 -3
  140. package/dist/cli/chunk-313vnn40.js.map +0 -10
  141. package/dist/cli/chunk-gb420vvz.js.map +0 -17
  142. /package/dist/cli/{chunk-nq1d20w2.js.map → chunk-0w6hs479.js.map} +0 -0
  143. /package/dist/cli/{chunk-kma9f6sf.js.map → chunk-1f882900.js.map} +0 -0
  144. /package/dist/cli/{chunk-e8xw9qkh.js.map → chunk-297ajdcv.js.map} +0 -0
  145. /package/dist/cli/{chunk-e6atmn4h.js.map → chunk-32khwcb6.js.map} +0 -0
  146. /package/dist/cli/{chunk-073rdvgb.js.map → chunk-36bxs9c7.js.map} +0 -0
  147. /package/dist/cli/{chunk-mrcccgnh.js.map → chunk-53jqw8z0.js.map} +0 -0
  148. /package/dist/cli/{chunk-ft9vfys6.js.map → chunk-58bt3hag.js.map} +0 -0
  149. /package/dist/cli/{chunk-sds923fa.js.map → chunk-5cmts6dv.js.map} +0 -0
  150. /package/dist/cli/{chunk-cfyg47zg.js.map → chunk-60hz265r.js.map} +0 -0
  151. /package/dist/cli/{chunk-j134vrp2.js.map → chunk-74wfhv79.js.map} +0 -0
  152. /package/dist/cli/{chunk-nrns10mj.js.map → chunk-9qzgg6mj.js.map} +0 -0
  153. /package/dist/cli/{chunk-tphac74f.js.map → chunk-a57yh0h9.js.map} +0 -0
  154. /package/dist/cli/{chunk-hebn0tpx.js.map → chunk-ave1wcbr.js.map} +0 -0
  155. /package/dist/cli/{chunk-htrwgabr.js.map → chunk-bqxaqn5p.js.map} +0 -0
  156. /package/dist/cli/{chunk-5vjtgs6e.js.map → chunk-bxwhtvw8.js.map} +0 -0
  157. /package/dist/cli/{chunk-n0v7m8ry.js.map → chunk-desvm72z.js.map} +0 -0
  158. /package/dist/cli/{chunk-3v3ffbrj.js.map → chunk-dhwtqnnd.js.map} +0 -0
  159. /package/dist/cli/{chunk-c1y538kd.js.map → chunk-dqhg54mr.js.map} +0 -0
  160. /package/dist/cli/{chunk-42t4jxdx.js.map → chunk-dv2z0rzf.js.map} +0 -0
  161. /package/dist/cli/{chunk-hx3xqnk9.js.map → chunk-gfc5fqvf.js.map} +0 -0
  162. /package/dist/cli/{chunk-7ppf1rva.js.map → chunk-gxtg6ed2.js.map} +0 -0
  163. /package/dist/cli/{chunk-8y2kmjv8.js.map → chunk-hvcxng5f.js.map} +0 -0
  164. /package/dist/cli/{chunk-54ztw1nj.js.map → chunk-j1x92mx9.js.map} +0 -0
  165. /package/dist/cli/{chunk-c887fj0s.js.map → chunk-kdpzw38a.js.map} +0 -0
  166. /package/dist/cli/{chunk-w1r4vase.js.map → chunk-s0bdnc7v.js.map} +0 -0
  167. /package/dist/cli/{chunk-fdrkhpjy.js.map → chunk-sc93apfs.js.map} +0 -0
  168. /package/dist/cli/{chunk-dzwehpy7.js.map → chunk-tfycddbt.js.map} +0 -0
  169. /package/dist/cli/{chunk-3x6x3pph.js.map → chunk-twy7psdz.js.map} +0 -0
  170. /package/dist/cli/{chunk-e9q7a2ak.js.map → chunk-y4msrwm9.js.map} +0 -0
  171. /package/dist/cli/{chunk-tdqhyxfz.js.map → chunk-ydp3065p.js.map} +0 -0
@@ -103,6 +103,25 @@ export const assistantProviderDependencies = (
103
103
  )
104
104
  : [];
105
105
 
106
+ /**
107
+ * The narration provider's SDK, when missing: `openai()` speaks through
108
+ * `@ai-sdk/openai`, which the build loads to generate the clips, while
109
+ * `gateway()` needs nothing beyond the core `ai` package Blume ships.
110
+ */
111
+ export const narrationProviderDependencies = (
112
+ narration: ResolvedConfig["narration"],
113
+ root: string,
114
+ pkgDir: string = packageRoot()
115
+ ): MissingDependency[] =>
116
+ narration.enabled && narration.provider
117
+ ? unresolved(
118
+ `Narration provider "${narration.provider.kind}"`,
119
+ narration.provider.runtimeDeps,
120
+ root,
121
+ pkgDir
122
+ )
123
+ : [];
124
+
106
125
  /**
107
126
  * The deployment adapter's package, when missing. Node and Vercel ship with
108
127
  * Blume, so theirs always resolve; Netlify and Cloudflare are optional peers.
@@ -152,8 +171,9 @@ export const islandFrameworkDependencies = (
152
171
  /**
153
172
  * Every package the resolved config's adapters (and the given island
154
173
  * frameworks) import that isn't installed: the search SDK, content source
155
- * SDKs, the assistant provider SDK, the deployment adapter, and island
156
- * integrations. The adapters' `runtimeDeps` are the one place that knows.
174
+ * SDKs, the assistant and narration provider SDKs, the deployment adapter,
175
+ * and island integrations. The adapters' `runtimeDeps` are the one place that
176
+ * knows.
157
177
  */
158
178
  export const missingRuntimeDependencies = (
159
179
  config: ResolvedConfig,
@@ -164,6 +184,7 @@ export const missingRuntimeDependencies = (
164
184
  ...searchProviderDependencies(config.search.provider, root, pkgDir),
165
185
  ...sourceAdapterDependencies(config.content.sources, root, pkgDir),
166
186
  ...assistantProviderDependencies(config.ai.assistant, root, pkgDir),
187
+ ...narrationProviderDependencies(config.narration, root, pkgDir),
167
188
  ...deploymentAdapterDependencies(config.deployment, root, pkgDir),
168
189
  ...islandFrameworkDependencies(frameworks, root),
169
190
  ];
@@ -567,7 +567,10 @@ const blumeIntegrationOptions = (options: {
567
567
  pages: BlumePageRoute[];
568
568
  redirects: CompiledRedirect[];
569
569
  }): BlumeIntegrationOptions => {
570
- const shared: BlumeIntegrationOptions = { pages: options.pages };
570
+ const shared: BlumeIntegrationOptions = {
571
+ pages: options.pages,
572
+ poweredBy: options.config.poweredBy,
573
+ };
571
574
  if (options.redirects.length > 0) {
572
575
  shared.redirects = options.redirects;
573
576
  }
@@ -557,6 +557,8 @@ const pnpmWorkspaceSchema = z.looseObject({
557
557
  // A glob's leading `./` and trailing `/`, which pnpm ignores.
558
558
  const GLOB_EDGES = /^\.\/|\/+$/gu;
559
559
 
560
+ const globPattern = (glob: string): string => glob.replaceAll(GLOB_EDGES, "");
561
+
560
562
  /** Whether any of `globs` matches the workspace-relative path `at`. */
561
563
  const matchesAny = (globs: string[], at: string): boolean =>
562
564
  globs.length > 0 && picomatch(globs)(at);
@@ -589,11 +591,12 @@ const listsPackage = (workspace: string, root: string): boolean => {
589
591
  return true;
590
592
  }
591
593
  const globs = parsed.data.packages;
592
- const pattern = (glob: string): string => glob.replaceAll(GLOB_EDGES, "");
593
- const included = globs.filter((glob) => !glob.startsWith("!")).map(pattern);
594
+ const included = globs
595
+ .filter((glob) => !glob.startsWith("!"))
596
+ .map(globPattern);
594
597
  const excluded = globs
595
598
  .filter((glob) => glob.startsWith("!"))
596
- .map((glob) => pattern(glob.slice(1)));
599
+ .map((glob) => globPattern(glob.slice(1)));
597
600
  return matchesAny(included, at) && !matchesAny(excluded, at);
598
601
  };
599
602
 
@@ -92,7 +92,8 @@ export const checkRequiredSecrets = (config: ResolvedConfig): Diagnostic[] => {
92
92
  }
93
93
 
94
94
  // Generated narration reads its key at build, not at runtime; without it the
95
- // build skips the audio and pages read with browser voices.
95
+ // build skips the audio and pages read with browser voices. `openai()` with
96
+ // a `baseUrl` requires only a key it names (see `narrationProviderSchema`).
96
97
  const { narration } = config;
97
98
  if (narration.enabled && narration.provider) {
98
99
  for (const env of narration.provider.requiredSecrets) {
@@ -42,6 +42,10 @@ const cssString = (value: string): string =>
42
42
  .replaceAll("<", String.raw`\3c `)
43
43
  .replaceAll(/[\n\r\f]/gu, " ")}"`;
44
44
 
45
+ /** Everything tagged with a view other than `title`. */
46
+ const otherViews = (title: string): string =>
47
+ `[data-blume-view]:not([data-blume-view=${cssString(title)}])`;
48
+
45
49
  /**
46
50
  * The page's view CSS: with a view chosen on `<body>`, everything tagged with
47
51
  * another is hidden; before a choice, everything but the first view is.
@@ -51,12 +55,11 @@ export const viewStyle = (titles: readonly string[]): string => {
51
55
  if (first === undefined) {
52
56
  return "";
53
57
  }
54
- const other = (title: string): string =>
55
- `[data-blume-view]:not([data-blume-view=${cssString(title)}])`;
56
58
  const selectors = [
57
- `body:not([data-blume-view]) ${other(first)}`,
59
+ `body:not([data-blume-view]) ${otherViews(first)}`,
58
60
  ...titles.map(
59
- (title) => `body[data-blume-view=${cssString(title)}] ${other(title)}`
61
+ (title) =>
62
+ `body[data-blume-view=${cssString(title)}] ${otherViews(title)}`
60
63
  ),
61
64
  ];
62
65
  return `${selectors.join(",")}{display:none!important}`;
@@ -307,15 +307,59 @@ const initialId =
307
307
  const spacing =
308
308
  index === 0 ? "mt-0" : display === "flat" ? "mt-6" : "mt-0.5";
309
309
 
310
- // `page`: a single row that drills into this group's panel. It
311
- // controls that panel; NavTreeScript keeps `aria-expanded` in step
312
- // with the panel shown (static markup, since cached subtrees and
313
- // fragments can't carry per-page state).
310
+ // `page`: a single row that drills into this group's panel. Its drill
311
+ // button controls that panel; NavTreeScript keeps `aria-expanded` in
312
+ // step with the panel shown (static markup, since cached subtrees and
313
+ // fragments can't carry per-page state). A group with an index page
314
+ // splits the row, like the `group` summary: the label links that page
315
+ // (which renders with the panel open), so the row opens the section
316
+ // even when the index hides its own row, and the chevron stays a
317
+ // button that slides the panel in without leaving the page. Without
318
+ // one, the whole row is the drill button, so it never links a missing
319
+ // page. `aria-current` marks the row whose panel holds the current
320
+ // page (`page` on the section's own page), so the parent panel still
321
+ // shows where the reader is after the back arrow. Only rows on the
322
+ // active path carry it, and those always render live, never from a
323
+ // cache.
314
324
  if (display === "page") {
315
- return (
325
+ const current = hasActivePage(item)
326
+ ? isGroupRowCurrent(item, currentRoute)
327
+ ? "page"
328
+ : "true"
329
+ : undefined;
330
+ return item.route ? (
331
+ <li class={`flex items-center gap-0.5 ${spacing}`}>
332
+ <a
333
+ aria-current={current}
334
+ class="blume-nav-drill-link"
335
+ href={withMountedBase(item.route)}
336
+ >
337
+ {item.icon && (
338
+ <Icon
339
+ class="shrink-0 text-muted-foreground"
340
+ name={item.icon}
341
+ size={14}
342
+ />
343
+ )}
344
+ <span class="flex-1 truncate">{item.label}</span>
345
+ {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
346
+ </a>
347
+ <button
348
+ aria-controls={navPanelId(id)}
349
+ aria-expanded="false"
350
+ aria-label={item.label}
351
+ class="blume-nav-drill-toggle"
352
+ data-nav-to={id}
353
+ type="button"
354
+ >
355
+ <Icon class="rtl:-scale-x-100" name="chevron-right" size={14} />
356
+ </button>
357
+ </li>
358
+ ) : (
316
359
  <li class={spacing}>
317
360
  <button
318
361
  aria-controls={navPanelId(id)}
362
+ aria-current={current}
319
363
  aria-expanded="false"
320
364
  class="blume-nav-drill"
321
365
  data-nav-to={id}
@@ -146,6 +146,24 @@
146
146
  }
147
147
  return;
148
148
  }
149
+ // After the back arrow, the current section's name links the page
150
+ // already on screen, so a plain click slides its panel back in rather
151
+ // than reloading that page. Modified clicks stay the browser's.
152
+ const own = target.closest('.blume-nav-drill-link[aria-current="page"]');
153
+ const ownId = own?.parentElement
154
+ ?.querySelector("[data-nav-to]")
155
+ ?.getAttribute("data-nav-to");
156
+ if (
157
+ own &&
158
+ ownId &&
159
+ this.contains(own) &&
160
+ this.panel(ownId) &&
161
+ !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
162
+ ) {
163
+ event.preventDefault();
164
+ void this.show(ownId, true);
165
+ return;
166
+ }
149
167
  const back = target.closest("[data-nav-back]");
150
168
  if (back && this.contains(back)) {
151
169
  event.preventDefault();
@@ -126,10 +126,17 @@ interface Props {
126
126
  canonical?: string | null;
127
127
  /**
128
128
  * Emit schema.org JSON-LD for this page (`data.config.structuredData`) — a
129
- * WebSite node, plus an article node on non-root routes. Defaults to on,
130
- * matching RootLayout.
129
+ * WebSite node, plus a WebPage node on the homepage and an article node on
130
+ * other routes. Defaults to on, matching RootLayout.
131
131
  */
132
132
  structuredDataEnabled?: boolean;
133
+ /**
134
+ * When the page last changed, emitted as the JSON-LD `dateModified`. A
135
+ * custom page has no content file to date, so pass it by hand; without a
136
+ * machine-readable date, search engines take whatever other date the page
137
+ * shows.
138
+ */
139
+ lastModified?: string | Date | null;
133
140
  /**
134
141
  * Which agent-discovery resources exist, advertised as `describedby` head
135
142
  * links plus the AI Catalog/ARD pair — the same `DiscoveryLinks` partial
@@ -184,6 +191,7 @@ const {
184
191
  ogImageSize,
185
192
  canonical,
186
193
  structuredDataEnabled,
194
+ lastModified,
187
195
  discovery = data.config.discovery,
188
196
  x,
189
197
  noindex,
@@ -279,7 +287,8 @@ const xSite = normalizeXHandle(x?.handle);
279
287
  const xCreator = normalizeXHandle(x?.creator);
280
288
 
281
289
  // JSON-LD, mirroring RootLayout: skipped when disabled or noindexed. A custom
282
- // page has no breadcrumb trail; the home route yields the WebSite node alone.
290
+ // page has no breadcrumb trail; the home route is a WebPage, dated by
291
+ // `lastModified` when the page passes one.
283
292
  const structuredData =
284
293
  structuredDataEnabled === false || noindex
285
294
  ? null
@@ -289,6 +298,7 @@ const structuredData =
289
298
  description,
290
299
  identity: data.config.identity,
291
300
  locale,
301
+ modified: lastModified,
292
302
  route,
293
303
  siteName: site.title,
294
304
  siteUrl: siteUrl ?? null,
@@ -432,10 +432,13 @@ const description = page.description ?? site.description;
432
432
  // pages (and the changelog index, which has no `type`) stay `website`.
433
433
  const ogType =
434
434
  pageType === "blog" || pageType === "changelog" ? "article" : "website";
435
+ // The last-modified date as ISO 8601, parsed once for every surface that
436
+ // carries it; undefined when absent or unparseable.
437
+ const lastModifiedIso = toIso(lastModified);
435
438
  // `article:*` timestamps only belong on an article; a docs page carrying them
436
439
  // would be declaring properties its `og:type` doesn't define.
437
440
  const articlePublished = ogType === "article" ? toIso(published) : undefined;
438
- const articleModified = ogType === "article" ? toIso(lastModified) : undefined;
441
+ const articleModified = ogType === "article" ? lastModifiedIso : undefined;
439
442
  // X sizes the card from `twitter:card`: a large image needs the wide variant,
440
443
  // and a page with no image still gets a card (the compact `summary`) rather
441
444
  // than rendering as a bare link.
@@ -448,14 +451,12 @@ const xCreator = normalizeXHandle(x?.creator);
448
451
 
449
452
  // "Last updated on <date>" — the configured `dateFormat`, in UTC (unless the
450
453
  // config names a zone) so it matches the changelog timeline.
451
- const lastModifiedDate = lastModified ? new Date(lastModified) : null;
452
- const formattedLastModified =
453
- lastModifiedDate && !Number.isNaN(lastModifiedDate.getTime())
454
- ? new Intl.DateTimeFormat(
455
- locale || "en",
456
- resolveDateFormatOptions(dateFormat)
457
- ).format(lastModifiedDate)
458
- : null;
454
+ const formattedLastModified = lastModifiedIso
455
+ ? new Intl.DateTimeFormat(
456
+ locale || "en",
457
+ resolveDateFormatOptions(dateFormat)
458
+ ).format(new Date(lastModifiedIso))
459
+ : null;
459
460
 
460
461
  // This page's raw-Markdown mirror, advertised as a `text/markdown` alternate
461
462
  // in the head. Content routes always have one (see `markdownRoutePaths`, which
@@ -521,6 +522,7 @@ const structuredData =
521
522
  base: import.meta.env.BASE_URL,
522
523
  breadcrumbs: crumbs,
523
524
  description,
525
+ homeRoute: navigation.root,
524
526
  identity: data.config.identity,
525
527
  locale,
526
528
  modified: lastModified,
@@ -929,7 +931,7 @@ const localeRedirect = localeTargets
929
931
  />
930
932
  {formattedLastModified && (
931
933
  <p class="mx-auto mt-10 max-w-content text-muted-foreground text-sm">
932
- {strings.page.lastUpdated} {formattedLastModified}
934
+ {strings.page.lastUpdated} <time datetime={lastModifiedIso}>{formattedLastModified}</time>
933
935
  </p>
934
936
  )}
935
937
  {feedback && (
@@ -1378,10 +1380,13 @@ const localeRedirect = localeTargets
1378
1380
  }
1379
1381
  @media (prefers-reduced-motion: reduce) {
1380
1382
  /* html prefix outranks the library's rules regardless of sheet
1381
- order; its transition declarations carry !important. */
1383
+ order; its transition declarations carry !important. Near-instant,
1384
+ never `none`: medium-zoom only finishes opening and closing on the
1385
+ image's `transitionend`, so with no transition a zoomed image
1386
+ ignores Escape, clicks, and scrolls forever. */
1382
1387
  html .medium-zoom-image,
1383
1388
  html .medium-zoom-overlay {
1384
- transition: none !important;
1389
+ transition-duration: 1ms !important;
1385
1390
  }
1386
1391
  }
1387
1392
  </style>
@@ -2,14 +2,14 @@
2
2
  * Send a custom analytics event to every analytics platform configured in
3
3
  * `blume.config.ts`. Mirrors the adapters `Analytics.astro` wires
4
4
  * (`src/analytics/*`): each one with a custom-event API is called through the
5
- * global its snippet defines (Cloudflare and Clearbit have none to forward
6
- * to), and the `blume:track` CustomEvent fires unconditionally so a project
7
- * can bridge the event to anything else — a `script()` adapter, say. Every
8
- * call no-ops cleanly when a provider isn't present — for example during
9
- * `blume dev`, where `Analytics.astro` injects nothing — and a provider that
10
- * throws (a consent shim that stubs `gtag` with a raise, a broken snippet) is
11
- * isolated so it neither starves the providers after it nor surfaces in the
12
- * feature that reported the event.
5
+ * global its snippet defines (Cloudflare, Clearbit, and Clics have none to
6
+ * forward to), and the `blume:track` CustomEvent fires unconditionally so a
7
+ * project can bridge the event to anything else — a `script()` adapter, say.
8
+ * Every call no-ops cleanly when a provider isn't present — for example
9
+ * during `blume dev`, where `Analytics.astro` injects nothing — and a provider
10
+ * that throws (a consent shim that stubs `gtag` with a raise, a broken
11
+ * snippet) is isolated so it neither starves the providers after it nor
12
+ * surfaces in the feature that reported the event.
13
13
  */
14
14
  import { track as vercelTrack } from "@vercel/analytics";
15
15
 
@@ -271,6 +271,59 @@ const line = (className: string, text: string): HTMLElement => {
271
271
  return el;
272
272
  };
273
273
 
274
+ /** Render one settled HTTP exchange: status + time, headers, pretty body. */
275
+ const renderResponse = (
276
+ region: HTMLElement,
277
+ res: Response,
278
+ ms: number,
279
+ text: string
280
+ ): void => {
281
+ region.textContent = "";
282
+ region.append(
283
+ line(
284
+ "font-mono font-semibold text-foreground text-sm",
285
+ `${res.status} ${res.statusText} \u00B7 ${ms} ms`.trim()
286
+ )
287
+ );
288
+ const table = document.createElement("table");
289
+ table.className = "w-full text-start text-xs";
290
+ for (const [name, value] of res.headers) {
291
+ const row = document.createElement("tr");
292
+ const header = document.createElement("th");
293
+ header.setAttribute("scope", "row");
294
+ header.className = "pe-3 align-top font-medium text-muted-foreground";
295
+ header.textContent = name;
296
+ const cell = document.createElement("td");
297
+ cell.className = "break-all font-mono text-foreground";
298
+ cell.textContent = value;
299
+ row.append(header, cell);
300
+ table.append(row);
301
+ }
302
+ region.append(table);
303
+ const pre = document.createElement("pre");
304
+ pre.className =
305
+ "overflow-x-auto rounded-blume border border-border p-3 font-mono text-foreground text-xs";
306
+ const code = document.createElement("code");
307
+ code.textContent = prettyBody(text);
308
+ pre.append(code);
309
+ region.append(pre);
310
+ // At xl the panel is its own scroll region capped to the viewport, so a
311
+ // response appended under a tall form can land below the panel's fold
312
+ // where nothing brings it into view. Scroll the panel alone — "nearest",
313
+ // by hand — and never the document: below xl the panel does not scroll,
314
+ // and a document scroll would carry the form (Send, the body editor, its
315
+ // errors) off the top on a phone.
316
+ const panel = region.closest<HTMLElement>("[data-operation-panel]");
317
+ if (panel && panel.scrollHeight > panel.clientHeight) {
318
+ const box = panel.getBoundingClientRect();
319
+ const target = region.getBoundingClientRect();
320
+ const delta = Math.min(target.bottom - box.bottom, target.top - box.top);
321
+ if (delta > 0) {
322
+ panel.scrollBy({ top: delta });
323
+ }
324
+ }
325
+ };
326
+
274
327
  /**
275
328
  * Wire the playground inside `root` (the `<blume-playground>` element). Reads
276
329
  * the server-rendered model JSON, keeps the request samples in sync with the
@@ -470,59 +523,6 @@ export const initPlayground = (root: HTMLElement): void => {
470
523
  }
471
524
  };
472
525
 
473
- /** Render one settled HTTP exchange: status + time, headers, pretty body. */
474
- const renderResponse = (
475
- region: HTMLElement,
476
- res: Response,
477
- ms: number,
478
- text: string
479
- ): void => {
480
- region.textContent = "";
481
- region.append(
482
- line(
483
- "font-mono font-semibold text-foreground text-sm",
484
- `${res.status} ${res.statusText} \u00B7 ${ms} ms`.trim()
485
- )
486
- );
487
- const table = document.createElement("table");
488
- table.className = "w-full text-start text-xs";
489
- for (const [name, value] of res.headers) {
490
- const row = document.createElement("tr");
491
- const header = document.createElement("th");
492
- header.setAttribute("scope", "row");
493
- header.className = "pe-3 align-top font-medium text-muted-foreground";
494
- header.textContent = name;
495
- const cell = document.createElement("td");
496
- cell.className = "break-all font-mono text-foreground";
497
- cell.textContent = value;
498
- row.append(header, cell);
499
- table.append(row);
500
- }
501
- region.append(table);
502
- const pre = document.createElement("pre");
503
- pre.className =
504
- "overflow-x-auto rounded-blume border border-border p-3 font-mono text-foreground text-xs";
505
- const code = document.createElement("code");
506
- code.textContent = prettyBody(text);
507
- pre.append(code);
508
- region.append(pre);
509
- // At xl the panel is its own scroll region capped to the viewport, so a
510
- // response appended under a tall form can land below the panel's fold
511
- // where nothing brings it into view. Scroll the panel alone — "nearest",
512
- // by hand — and never the document: below xl the panel does not scroll,
513
- // and a document scroll would carry the form (Send, the body editor, its
514
- // errors) off the top on a phone.
515
- const panel = region.closest<HTMLElement>("[data-operation-panel]");
516
- if (panel && panel.scrollHeight > panel.clientHeight) {
517
- const box = panel.getBoundingClientRect();
518
- const target = region.getBoundingClientRect();
519
- const delta = Math.min(target.bottom - box.bottom, target.top - box.top);
520
- if (delta > 0) {
521
- panel.scrollBy({ top: delta });
522
- }
523
- }
524
- };
525
-
526
526
  /**
527
527
  * Send the real request. HTTP error statuses render like any response; only
528
528
  * a rejected fetch (the CORS wall) gets the explanatory message. One request
@@ -0,0 +1,33 @@
1
+ import type { MiddlewareHandler } from "astro";
2
+
3
+ import { POWERED_BY_HEADERS } from "../core/powered-by.ts";
4
+
5
+ /**
6
+ * Names Blume in the responses Astro renders (wired by the integration unless
7
+ * `poweredBy` is `false`), the server-rendered ones a host's own header rules
8
+ * don't reach. Innermost, so a page or the project's own middleware that sets
9
+ * a header of the same name keeps its value. Headers are set in place; a
10
+ * response whose headers are immutable (a proxied `fetch()` result) is copied
11
+ * first.
12
+ */
13
+ export const onRequest: MiddlewareHandler = async (_context, render) => {
14
+ const response = await render();
15
+ const missing = Object.entries(POWERED_BY_HEADERS).filter(
16
+ ([name]) => !response.headers.has(name)
17
+ );
18
+ if (missing.length === 0) {
19
+ return response;
20
+ }
21
+ try {
22
+ for (const [name, value] of missing) {
23
+ response.headers.set(name, value);
24
+ }
25
+ return response;
26
+ } catch {
27
+ const copy = new Response(response.body, response);
28
+ for (const [name, value] of missing) {
29
+ copy.headers.set(name, value);
30
+ }
31
+ return copy;
32
+ }
33
+ };
@@ -2,7 +2,11 @@ import type { AstroIntegration } from "astro";
2
2
  import type { z } from "zod";
3
3
 
4
4
  import type { AskRetrievalOptions } from "../ai/ask-context.ts";
5
- import type { AssistantAdapter, AssistantGatewayAdapter } from "../ai/ask.ts";
5
+ import type {
6
+ AssistantAdapter,
7
+ AssistantGatewayAdapter,
8
+ AssistantOpenAIAdapter,
9
+ } from "../ai/ask.ts";
6
10
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
7
11
  import type { AnalyticsAdapter } from "../analytics/schema.ts";
8
12
  import type { CaptchaAdapter } from "../captcha/schema.ts";
@@ -1399,9 +1403,11 @@ export type NarrationConfig =
1399
1403
  /**
1400
1404
  * Generate audio at build with a speech model:
1401
1405
  * `gateway({ model: "openai/tts-1-hd", voice: "alloy" })` from
1402
- * `blume/ai`. Without it, pages are read with browser voices.
1406
+ * `blume/ai`, or `openai({ model, voice })` for OpenAI or, with a
1407
+ * `baseUrl`, any OpenAI-compatible speech endpoint. Without it, pages
1408
+ * are read with browser voices.
1403
1409
  */
1404
- provider?: AssistantGatewayAdapter;
1410
+ provider?: AssistantGatewayAdapter | AssistantOpenAIAdapter;
1405
1411
  };
1406
1412
 
1407
1413
  /**
@@ -1585,6 +1591,8 @@ export interface BlumeConfig {
1585
1591
  narration?: NarrationConfig;
1586
1592
  /** Header, sidebar, tabs, and switchers. */
1587
1593
  navigation?: NavigationConfig;
1594
+ /** Send `X-Powered-By: Blume` with responses. Defaults to `true`. */
1595
+ poweredBy?: boolean;
1588
1596
  /**
1589
1597
  * Limit how often one reader (by IP address) can call the server routes —
1590
1598
  * the assistant, the API playground proxy, server-side search — with an
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The header that names Blume as the framework behind a response, sent unless
3
+ * `poweredBy` is `false`. The dev server and Blume's runtime middleware
4
+ * (`components/powered-by-middleware.ts`) send it, and so does every host
5
+ * for the built site: static hosts through `deploy/headers.ts`'s rules,
6
+ * server builds through each platform's own mechanism (`deploy/platforms`).
7
+ * It lives apart from those modules so the runtime middleware can import it
8
+ * without pulling in the deploy code.
9
+ */
10
+ export const POWERED_BY_HEADERS = {
11
+ "X-Powered-By": "Blume",
12
+ } satisfies Record<string, string>;
@@ -1206,10 +1206,10 @@ const feedbackConfigSchema = z
1206
1206
 
1207
1207
  // "Listen to this page". Off by default. `true` reads pages aloud with the
1208
1208
  // reader's browser voices, which needs no key and works on any host; an object
1209
- // with a `provider` (`gateway()` from `blume/ai`) generates neural audio at
1210
- // build instead, one cached clip per sentence, and falls back to the browser's
1211
- // voices where no clips exist (`blume dev`, a build without the key). Both
1212
- // normalize to `{ enabled, provider }`.
1209
+ // with a `provider` (`gateway()` or `openai()` from `blume/ai`) generates
1210
+ // neural audio at build instead, one cached clip per sentence, and falls back
1211
+ // to the browser's voices where no clips exist (`blume dev`, a build without
1212
+ // the key). Both normalize to `{ enabled, provider }`.
1213
1213
  const narrationConfigSchema = z
1214
1214
  .union([
1215
1215
  z.boolean(),
@@ -2159,6 +2159,7 @@ export const blumeConfigSchema = z
2159
2159
  markdown: markdownConfigSchema.prefault({}),
2160
2160
  narration: narrationConfigSchema.prefault(false),
2161
2161
  navigation: navigationConfigSchema.prefault({}),
2162
+ poweredBy: z.boolean().default(true),
2162
2163
  // An adapter from `blume/ratelimit`, `memory()` by default; `false` is off.
2163
2164
  rateLimit: rateLimitConfigSchema,
2164
2165
  react: reactConfigSchema.prefault({}),
@@ -299,6 +299,17 @@ const renderParts = (
299
299
  export const renderInline = (parts: InlinePart[]): string =>
300
300
  renderParts(parts, escapeProse);
301
301
 
302
+ const asTyped = (text: string): string => text;
303
+
304
+ /**
305
+ * A block's inline content as MDX source, for a source whose site author
306
+ * opted in to reading its text that way: marks still become delimiters and
307
+ * code stays a code span, but the text goes in as typed, so a component, an
308
+ * expression, or Markdown written in the CMS renders.
309
+ */
310
+ export const renderInlineMdx = (parts: InlinePart[]): string =>
311
+ renderParts(parts, asTyped);
312
+
302
313
  // A destination with whitespace, a control character, or parentheses ends
303
314
  // early in `[label](…)`; CommonMark's pointy-bracket form carries it intact.
304
315
  // oxlint-disable-next-line no-control-regex -- control characters are exactly what the bare form refuses.
@@ -331,13 +342,18 @@ export const destination = (url: string): string => {
331
342
  * `data:`) never becomes a clickable link on the docs site (see
332
343
  * `safe-links.ts`). The parts stay parts then, so their runs merge with their
333
344
  * neighbors'. A label escapes a bare URL too: GFM autolinks nothing inside a
334
- * link, so its specials would read as syntax.
345
+ * link, so its specials would read as syntax — unless `mdx` reads the label
346
+ * as MDX source, like {@link renderInlineMdx}.
335
347
  */
336
- export const linkParts = (parts: InlinePart[], href?: string): InlinePart[] =>
348
+ export const linkParts = (
349
+ parts: InlinePart[],
350
+ href?: string,
351
+ mdx = false
352
+ ): InlinePart[] =>
337
353
  href && isSafeHref(href)
338
354
  ? [
339
355
  {
340
- markdown: `[${renderParts(parts, escapeMarkdownText)}](${destination(href)})`,
356
+ markdown: `[${renderParts(parts, mdx ? asTyped : escapeMarkdownText)}](${destination(href)})`,
341
357
  },
342
358
  ]
343
359
  : parts;