blume 2.1.1 → 2.1.3

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 +23 -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 +7 -6
  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
@@ -22,6 +22,7 @@ import {
22
22
  image,
23
23
  linkParts,
24
24
  renderInline,
25
+ renderInlineMdx,
25
26
  } from "./lower.ts";
26
27
  import { slugify, slugifyPath } from "./normalize.ts";
27
28
  import { missingSecretError } from "./remote.ts";
@@ -143,6 +144,14 @@ export interface NotionSourceOptions {
143
144
  * Status/select property are always imported as published.
144
145
  */
145
146
  publishedValue?: string;
147
+ /**
148
+ * Read the text of text blocks (paragraphs, headings, list items, quotes,
149
+ * callouts) as MDX source, so a component, expression, or Markdown typed in
150
+ * Notion renders instead of showing as written. MDX runs code at build
151
+ * time, so this hands everyone who can edit the database the site's build:
152
+ * set it only when you trust them with that. Default false.
153
+ */
154
+ mdx?: boolean;
146
155
  /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
147
156
  pollInterval?: number;
148
157
  /** Injected for tests; otherwise built from `@notionhq/client`. */
@@ -174,9 +183,10 @@ interface LinkedRuns {
174
183
  * itself instead of opening a JSX expression, a tag, or emphasis, a code run
175
184
  * keeps its text verbatim inside a long-enough code span, neighbors share
176
185
  * their marks' delimiters, and neighbors that link to one `href` share one
177
- * link.
186
+ * link. With `mdx`, the text goes in as typed instead (see
187
+ * `NotionSourceOptions.mdx`).
178
188
  */
179
- const richToMarkdown = (rich: NotionRichText[] = []): string => {
189
+ const richToMarkdown = (rich: NotionRichText[] = [], mdx = false): string => {
180
190
  const linked: LinkedRuns[] = [];
181
191
  for (const { annotations = {}, href, plain_text: text } of rich) {
182
192
  const run: InlineRun = {
@@ -195,11 +205,18 @@ const richToMarkdown = (rich: NotionRichText[] = []): string => {
195
205
  linked.push({ href: href ?? undefined, runs: [run] });
196
206
  }
197
207
  }
198
- return renderInline(
199
- linked.flatMap(({ href, runs }) => linkParts(runs, href))
200
- );
208
+ const parts = linked.flatMap(({ href, runs }) => linkParts(runs, href, mdx));
209
+ return mdx ? renderInlineMdx(parts) : renderInline(parts);
201
210
  };
202
211
 
212
+ /**
213
+ * A text block's rich text: Markdown that renders as written, guarded at its
214
+ * block start — or, with `mdx`, MDX source as typed, which an `.mdx` file's
215
+ * own block starts (`import`, `#`, indentation) read the same way.
216
+ */
217
+ const blockText = (rich: NotionRichText[] | undefined, mdx: boolean): string =>
218
+ mdx ? richToMarkdown(rich, true) : guardBlockStart(richToMarkdown(rich));
219
+
203
220
  const isBlockPayload = (
204
221
  value: NotionBlockPayload | boolean | string | undefined
205
222
  ): value is NotionBlockPayload => typeof value === "object";
@@ -340,9 +357,9 @@ const renderVideo = (data: NotionBlockPayload): string => {
340
357
  };
341
358
 
342
359
  /** Render a leaf (non-container) block to Markdown, or null for containers. */
343
- const renderLeaf = (block: NotionBlock): string | null => {
360
+ const renderLeaf = (block: NotionBlock, mdx: boolean): string | null => {
344
361
  const data = payloadOf(block) ?? {};
345
- const text = guardBlockStart(richToMarkdown(blockField(block)));
362
+ const text = blockText(blockField(block), mdx);
346
363
  switch (block.type) {
347
364
  case "paragraph": {
348
365
  return text;
@@ -398,6 +415,7 @@ export const notionSource = (
398
415
  ctx?: SourceContext
399
416
  ): ContentSource => {
400
417
  const props = options.properties ?? {};
418
+ const textAsMdx = options.mdx ?? false;
401
419
  // A FIFO semaphore: at most N calls run at once, the rest queue. Notion's
402
420
  // rate limit is per-integration (an average of 3 req/s), and a large
403
421
  // database fans out one block-children request per page plus one per nested
@@ -481,7 +499,7 @@ export const notionSource = (
481
499
 
482
500
  if (block.type === "callout") {
483
501
  const body = [
484
- guardBlockStart(richToMarkdown(blockField(block))),
502
+ blockText(blockField(block), textAsMdx),
485
503
  await children(block),
486
504
  ]
487
505
  .filter(Boolean)
@@ -509,7 +527,7 @@ export const notionSource = (
509
527
  ): Promise<string> => {
510
528
  const parts = await Promise.all(
511
529
  blocks.map(async (block) => {
512
- const leaf = renderLeaf(block);
530
+ const leaf = renderLeaf(block, textAsMdx);
513
531
  if (leaf === null) {
514
532
  return renderContainer(client, block, renderBlocks);
515
533
  }
@@ -40,8 +40,9 @@
40
40
  * Cloudflare does not apply `_headers` to worker-first routes, so the wrapper
41
41
  * also re-stamps what the static layer would otherwise add on the routes it
42
42
  * takes over: the homepage agent-discovery `Link` header, the Markdown
43
- * `charset=utf-8`, and the sandbox on the SVGs a content source downloaded
44
- * (see `deploy/headers.ts`). The raw `.md`/`.mdx` URLs are
43
+ * `charset=utf-8`, the sandbox on the SVGs a content source downloaded (see
44
+ * `deploy/headers.ts`), and `X-Powered-By` unless `poweredBy` is `false`. The
45
+ * raw `.md`/`.mdx` URLs are
45
46
  * exempted from worker-first routing with negative rules, keeping their
46
47
  * `_headers` treatment and their zero-Worker serving path.
47
48
  *
@@ -85,6 +86,7 @@ import {
85
86
  OPENAPI_PATH,
86
87
  } from "../ai/api/paths.ts";
87
88
  import { normalizeBasePath, normalizePath } from "../core/base-path.ts";
89
+ import { POWERED_BY_HEADERS } from "../core/powered-by.ts";
88
90
  import { compileRedirects, isPatternPath } from "../core/redirect-patterns.ts";
89
91
  import { CONTENT_ASSETS_ROOT, SVG_ASSET_HEADERS } from "./headers.ts";
90
92
  import type { NotFoundVariants } from "./vercel-negotiation.ts";
@@ -244,6 +246,8 @@ export interface NegotiationWorkerOptions {
244
246
  base?: string;
245
247
  /** Homepage agent-discovery `Link` header (see `ai/link-headers.ts`). */
246
248
  homeLinkHeader?: string | null;
249
+ /** Whether responses identify Blume with `X-Powered-By`. Defaults to `true`. */
250
+ poweredBy?: boolean;
247
251
  /** Estimated token count of the homepage Markdown mirror. */
248
252
  homeTokens?: number;
249
253
  /** Configured redirects the wrapper answers with their exact status. */
@@ -277,6 +281,9 @@ export const buildNegotiationWorker = (
277
281
  const binding = JSON.stringify(options.assetsBinding);
278
282
  const prefix = JSON.stringify(encodeURI(normalizeBasePath(options.base)));
279
283
  const homeLinkHeader = JSON.stringify(options.homeLinkHeader ?? null);
284
+ const poweredBy = JSON.stringify(
285
+ options.poweredBy === false ? {} : POWERED_BY_HEADERS
286
+ );
280
287
  const homeTokens = JSON.stringify(
281
288
  options.homeTokens === undefined ? null : String(options.homeTokens)
282
289
  );
@@ -331,6 +338,7 @@ const PATTERN_REDIRECTS = ${patternRedirects}.map(
331
338
  );
332
339
  const NOT_FOUND = ${notFound};
333
340
  const SVG_ASSET_HEADERS = ${JSON.stringify(SVG_ASSET_HEADERS)};
341
+ const POWERED_BY = ${poweredBy};
334
342
 
335
343
  // An SVG a content source downloaded: a document that could run script as the
336
344
  // docs site if opened directly, so it goes out sandboxed. Matched on the
@@ -542,12 +550,30 @@ const isHomePath = (pathname) => {
542
550
  return rest === "" || rest === "/";
543
551
  };
544
552
 
553
+ // Blume's X-Powered-By, on every answer the wrapper gives unless the
554
+ // response already names its own (the project's middleware, say).
555
+ const identify = (headers) => {
556
+ for (const [name, value] of Object.entries(POWERED_BY)) {
557
+ if (!headers.has(name)) {
558
+ headers.set(name, value);
559
+ }
560
+ }
561
+ };
562
+
545
563
  const withHeaders = (response, apply) => {
546
564
  const patched = new Response(response.body, response);
547
565
  apply(patched.headers);
566
+ identify(patched.headers);
548
567
  return patched;
549
568
  };
550
569
 
570
+ // A response that already carries the header (Blume's runtime middleware sets
571
+ // it on every page Astro renders) goes out as it is.
572
+ const withPoweredBy = (response) =>
573
+ Object.keys(POWERED_BY).every((name) => response.headers.has(name))
574
+ ? response
575
+ : withHeaders(response, () => {});
576
+
551
577
  export default {
552
578
  async fetch(request, env, context) {
553
579
  const url = new URL(request.url);
@@ -556,7 +582,10 @@ export default {
556
582
  const redirect = redirectFor(url.pathname);
557
583
  if (redirect !== null) {
558
584
  return new Response(null, {
559
- headers: { location: redirectLocation(redirect[0], url.search) },
585
+ headers: {
586
+ ...POWERED_BY,
587
+ location: redirectLocation(redirect[0], url.search),
588
+ },
560
589
  status: redirect[1],
561
590
  });
562
591
  }
@@ -568,7 +597,7 @@ export default {
568
597
  });
569
598
  }
570
599
  if (request.method !== "GET" && request.method !== "HEAD") {
571
- return server.fetch(request, env, context);
600
+ return withPoweredBy(await server.fetch(request, env, context));
572
601
  }
573
602
  const assets = env[ASSETS_BINDING];
574
603
  // The request goes through untouched, so a conditional revalidation
@@ -577,7 +606,7 @@ export default {
577
606
  if (assets !== undefined && isPageJson(url.pathname)) {
578
607
  const asset = await assets.fetch(request);
579
608
  if (asset.status !== 404) {
580
- return asset;
609
+ return withPoweredBy(asset);
581
610
  }
582
611
  }
583
612
  const variant = markdownVariantUrl(url.pathname + url.search);
@@ -608,10 +637,10 @@ export default {
608
637
  const response = await server.fetch(request, env, context);
609
638
  const twin = await notFoundTwin(request, url, response, assets);
610
639
  if (twin !== null) {
611
- return twin;
640
+ return withPoweredBy(twin);
612
641
  }
613
642
  if (variant === null && !(home && HOME_LINK_HEADER !== null)) {
614
- return response;
643
+ return withPoweredBy(response);
615
644
  }
616
645
  return withHeaders(response, (headers) => {
617
646
  if (variant !== null) {
@@ -10,6 +10,7 @@ import {
10
10
  SIGNATURES_DIRECTORY_TYPE,
11
11
  } from "../ai/web-bot-auth.ts";
12
12
  import { normalizeBasePath } from "../core/base-path.ts";
13
+ import { POWERED_BY_HEADERS } from "../core/powered-by.ts";
13
14
  import type { ResolvedConfig } from "../core/schema.ts";
14
15
 
15
16
  /**
@@ -98,6 +99,13 @@ export const headerRules = (
98
99
  path: `${deployBase}/*.${rule.ext}`,
99
100
  value: rule.contentType,
100
101
  }));
102
+ // One rule for the whole site: `/*` matches the homepage too, and a host
103
+ // that joins the values of overlapping rules would send a second one twice.
104
+ if (config.poweredBy) {
105
+ for (const [name, value] of Object.entries(POWERED_BY_HEADERS)) {
106
+ rules.push({ name, path: `${deployBase}/*`, value });
107
+ }
108
+ }
101
109
  if (homeLinkHeader) {
102
110
  rules.push({ name: "Link", path: `${deployBase}/`, value: homeLinkHeader });
103
111
  }
@@ -212,9 +220,15 @@ export const escapeVercelSource = (path: string): string =>
212
220
  * A `_headers` path as a `vercel.json` `source`, which `path-to-regexp` reads:
213
221
  * the literal parts are escaped, and a `*` glob becomes the `(.*)` group that
214
222
  * spans path segments as the glob does. An exact directory path (the homepage
215
- * rule's `/docs/`) drops its trailing slash, the URL Vercel serves it at.
223
+ * rule's `/docs/`) drops its trailing slash, the URL Vercel serves it at. A
224
+ * whole-directory glob (`/docs/*`) becomes an optional group,
225
+ * `/docs/:path(.*)?`, so it matches that bare URL as well as every path
226
+ * beneath it.
216
227
  */
217
228
  const vercelSource = (path: string): string => {
229
+ if (path.endsWith("/*")) {
230
+ return `${escapeVercelSource(path.slice(0, -2))}/:path(.*)?`;
231
+ }
218
232
  const trimmed =
219
233
  path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
220
234
  return trimmed.split("*").map(escapeVercelSource).join("(.*)");
@@ -14,6 +14,7 @@ import {
14
14
  SIGNATURES_DIRECTORY_TYPE,
15
15
  } from "../ai/web-bot-auth.ts";
16
16
  import { normalizeBasePath, normalizePath } from "../core/base-path.ts";
17
+ import { POWERED_BY_HEADERS } from "../core/powered-by.ts";
17
18
  import type { BlumeProject } from "../core/project-graph.ts";
18
19
  import { compileRedirects, isPatternPath } from "../core/redirect-patterns.ts";
19
20
  import type { CompiledRedirect } from "../core/redirect-patterns.ts";
@@ -159,6 +160,8 @@ export const nodePatternRedirects = (
159
160
  export interface NodeEntryOptions {
160
161
  /** `deployment.base`, normalized (`""` at the root). */
161
162
  base?: string;
163
+ /** Whether responses identify Blume with `X-Powered-By`. Defaults to `true`. */
164
+ poweredBy?: boolean;
162
165
  /** The configured redirects, from {@link nodeRedirects}. */
163
166
  redirects?: NodeRedirects;
164
167
  /** The configured pattern redirects, from {@link nodePatternRedirects}. */
@@ -169,7 +172,12 @@ export interface NodeEntryOptions {
169
172
  * The entry that replaces Astro's: it answers a configured redirect with its
170
173
  * exact status, and otherwise stamps the rules' headers on the response and
171
174
  * hands the request to Astro's handler. `send` leaves a Content-Type that is
172
- * already set alone, so the registered media type wins.
175
+ * already set alone, so the registered media type wins. Unless `poweredBy` is
176
+ * `false`, every answer also names Blume in `X-Powered-By` — the redirects,
177
+ * and the prerendered pages and files the static handler serves, which
178
+ * Blume's runtime middleware never sees. A middleware-mode entry leaves that
179
+ * header to the middleware: Astro hands a request it has no route for back to
180
+ * the host app through `next()`, and that response is the host's own.
173
181
  *
174
182
  * A rule matches the file the static handler will serve, not the raw URL:
175
183
  * Astro strips the base when it leads the path and serves the rest from the
@@ -194,10 +202,11 @@ export const nodeEntryWrapper = (
194
202
  // This wrapper answers the configured redirects with their exact status and
195
203
  // sets the headers the standalone server's static handler can't (the media
196
204
  // types and CORS headers of the .well-known discovery files, the sandbox on
197
- // downloaded SVGs), then hands every other request to Astro.
205
+ // downloaded SVGs, X-Powered-By), then hands every other request to Astro.
198
206
  import { posix } from "node:path";
199
207
 
200
208
  const BASE = ${JSON.stringify(options.base ?? "")};
209
+ const POWERED_BY = ${JSON.stringify(options.poweredBy === false ? {} : POWERED_BY_HEADERS)};
201
210
  const RULES = new Map(${JSON.stringify(exactRules(rules))});
202
211
  const PATTERNS = ${JSON.stringify(patternRules(rules))};
203
212
  const REDIRECTS = ${JSON.stringify(options.redirects ?? {})};
@@ -221,20 +230,20 @@ const servedPath = (req) => {
221
230
  : path;
222
231
  return BASE + (rest || "/");
223
232
  };
224
- const applyHeaders = (req, res) => {
233
+ const applyHeaders = (req, res, identify) => {
225
234
  const path = servedPath(req);
226
235
  const lower = path.toLowerCase();
227
- const headers =
228
- RULES.get(path) ??
229
- PATTERNS.find(
230
- ([prefix, suffix]) =>
231
- lower.startsWith(prefix.toLowerCase()) &&
232
- lower.endsWith(suffix.toLowerCase())
233
- )?.[2];
234
- if (headers) {
235
- for (const [name, value] of Object.entries(headers)) {
236
- res.setHeader(name, value);
237
- }
236
+ const headers = {
237
+ ...(identify ? POWERED_BY : {}),
238
+ ...(RULES.get(path) ??
239
+ PATTERNS.find(
240
+ ([prefix, suffix]) =>
241
+ lower.startsWith(prefix.toLowerCase()) &&
242
+ lower.endsWith(suffix.toLowerCase())
243
+ )?.[2]),
244
+ };
245
+ for (const [name, value] of Object.entries(headers)) {
246
+ res.setHeader(name, value);
238
247
  }
239
248
  };
240
249
  // A pattern redirect's Location: its template, each $n filled with that
@@ -268,7 +277,7 @@ const answerRedirect = (req, res) => {
268
277
  return false;
269
278
  }
270
279
  const [location, status] = redirect;
271
- res.writeHead(status, { location });
280
+ res.writeHead(status, { ...POWERED_BY, location });
272
281
  res.end();
273
282
  return true;
274
283
  };
@@ -285,7 +294,7 @@ export const handler = (req, res, ...rest) => {
285
294
  if (answerRedirect(req, res)) {
286
295
  return;
287
296
  }
288
- applyHeaders(req, res);
297
+ applyHeaders(req, res, options.mode !== "middleware");
289
298
  return astro.handler(req, res, ...rest);
290
299
  };
291
300
  export const startServer = () => {
@@ -301,7 +310,7 @@ export const startServer = () => {
301
310
  if (answerRedirect(req, res)) {
302
311
  return;
303
312
  }
304
- applyHeaders(req, res);
313
+ applyHeaders(req, res, true);
305
314
  for (const listener of astroListeners) {
306
315
  listener.call(httpServer, req, res);
307
316
  }
@@ -315,9 +324,10 @@ if (options.mode === "standalone" && previous !== "disabled") {
315
324
  `;
316
325
 
317
326
  /**
318
- * Put the wrapper in front of a Node server build's entry. A build with no
319
- * rule (no API catalog, AI catalog, MCP server, signatures directory, or
320
- * downloaded content assets) and no redirect leaves Astro's entry alone.
327
+ * Put the wrapper in front of a Node server build's entry. A build with
328
+ * `poweredBy` off, no rule (no API catalog, AI catalog, MCP server,
329
+ * signatures directory, or downloaded content assets), and no redirect leaves
330
+ * Astro's entry alone.
321
331
  */
322
332
  export const wrapNodeEntry = async (
323
333
  project: BlumeProject,
@@ -341,6 +351,7 @@ export const wrapNodeEntry = async (
341
351
  const redirects = nodeRedirects(project);
342
352
  const patternRedirects = nodePatternRedirects(project);
343
353
  if (
354
+ !project.config.poweredBy &&
344
355
  rules.length === 0 &&
345
356
  Object.keys(redirects).length === 0 &&
346
357
  patternRedirects.length === 0
@@ -365,6 +376,7 @@ export const wrapNodeEntry = async (
365
376
  nodeEntryWrapper(rules, {
366
377
  base: normalizeBasePath(project.config.deployment.options.base),
367
378
  patternRedirects,
379
+ poweredBy: project.config.poweredBy,
368
380
  redirects,
369
381
  }),
370
382
  "utf-8"
@@ -122,6 +122,7 @@ export const emitCloudflareNegotiation = async (
122
122
  )
123
123
  .map((route) => pageJsonPath(route.path))
124
124
  : [],
125
+ poweredBy: config.poweredBy,
125
126
  // The wrapper Worker matches full served URLs, so the redirects are
126
127
  // based the same way the platform files are — it answers any the
127
128
  // worker-first rules claim, where `_redirects` is never consulted and
@@ -151,7 +151,8 @@ export const emitVercelNegotiation = async (
151
151
  // Downloaded content assets are prerendered static files; only a build
152
152
  // that has them needs the SVG sandbox route.
153
153
  existsSync(join(builtDir, "blume-assets")),
154
- base
154
+ base,
155
+ config.poweredBy
155
156
  );
156
157
  if (injected === null) {
157
158
  log.warn(
@@ -269,8 +270,8 @@ export const rebaseVercelRoutes = async (
269
270
  * hidden runtime silently drops the function's chunks and `node_modules`.
270
271
  * Static assets are served from the tree's `static/` half, so the deploy
271
272
  * artifacts are written there; headers (the discovery files', the sandbox on
272
- * downloaded SVGs) arrive through the routing config rather than a
273
- * `_headers` file, which Vercel never reads. The adapter ignores
273
+ * downloaded SVGs, `X-Powered-By`) arrive through the routing config rather
274
+ * than a `_headers` file, which Vercel never reads. The adapter ignores
274
275
  * `deployment.base`, so under one the build moves the static files and the
275
276
  * routes beneath it.
276
277
  */
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { mountBasePath } from "../core/base-path.ts";
24
+ import { POWERED_BY_HEADERS } from "../core/powered-by.ts";
24
25
  import { SVG_ASSET_HEADERS } from "./headers.ts";
25
26
 
26
27
  /*
@@ -374,6 +375,31 @@ const svgAssetRoute = (base: string): VercelRoute => ({
374
375
  src: svgAssetSource(base),
375
376
  });
376
377
 
378
+ /** The `src` of the `X-Powered-By` route: the base and every path beneath it. */
379
+ const poweredBySource = (base: string): string =>
380
+ `^${routePattern(base)}(?:/.*)?$`;
381
+
382
+ /**
383
+ * A main-phase `continue` route naming Blume in `X-Powered-By` (see
384
+ * `core/powered-by.ts`) on every response: the prerendered pages and files
385
+ * the static layer serves, which Blume's runtime middleware never sees, the
386
+ * functions, and the redirects.
387
+ */
388
+ const poweredByRoute = (base: string): VercelRoute => ({
389
+ continue: true,
390
+ headers: Object.fromEntries(
391
+ Object.entries(POWERED_BY_HEADERS).map(([name, value]) => [
392
+ name.toLowerCase(),
393
+ value,
394
+ ])
395
+ ),
396
+ src: poweredBySource(base),
397
+ });
398
+
399
+ /** Whether a route is the {@link poweredByRoute}. */
400
+ const isPoweredByRoute = (route: VercelRoute, base: string): boolean =>
401
+ route.continue === true && route.src === poweredBySource(base);
402
+
377
403
  /** Whether a route is a `corsRoute` — the same three-field shape test as the others. */
378
404
  const isCorsRoute = (route: VercelRoute): boolean =>
379
405
  route.continue === true &&
@@ -389,7 +415,8 @@ const isCorsRoute = (route: VercelRoute): boolean =>
389
415
  * the one re-added); the homepage `Link` route by its three-field
390
416
  * continue-with-link shape (the Build Output config is adapter-generated, so
391
417
  * no user-authored route competes in this file); the Markdown 404 routes by
392
- * their `/404.md` destination.
418
+ * their `/404.md` destination; the SVG and `X-Powered-By` routes by their
419
+ * `src`.
393
420
  */
394
421
  const isNegotiationRoute = (route: VercelRoute, base: string): boolean =>
395
422
  route.has?.some((condition) => MARKDOWN_VALUES.has(condition.value)) ===
@@ -407,7 +434,8 @@ const isNegotiationRoute = (route: VercelRoute, base: string): boolean =>
407
434
  route.src === homeSource(base) &&
408
435
  Object.keys(route).length === 3) ||
409
436
  isCorsRoute(route) ||
410
- (route.continue === true && route.src === svgAssetSource(base));
437
+ (route.continue === true && route.src === svgAssetSource(base)) ||
438
+ isPoweredByRoute(route, base);
411
439
 
412
440
  /**
413
441
  * Splice the negotiation routes into a Build Output `config.json`, plus — when
@@ -426,7 +454,9 @@ const isNegotiationRoute = (route: VercelRoute, base: string): boolean =>
426
454
  * phase right before the adapter's `/404.html` fallback — and nowhere when
427
455
  * that fallback is absent, since a `dest` with no file behind it would serve
428
456
  * nothing. With `svgAssets`, a main-phase route also sandboxes the SVGs a
429
- * content source downloaded (see {@link svgAssetRoute}). Under a `base`, every
457
+ * content source downloaded (see {@link svgAssetRoute}). With `poweredBy`, a
458
+ * route at the head of the config, ahead of the adapter's redirects, names
459
+ * Blume on every response (see {@link poweredByRoute}). Under a `base`, every
430
460
  * route matches and names paths beneath it, and the fallback anchor is the
431
461
  * one {@link rebaseAdapterRoutes} moved there. Returns the updated JSON
432
462
  * text (tab-indented, like the adapter's own output), or `null` when there is
@@ -442,7 +472,8 @@ export const injectNegotiationRoutes = (
442
472
  notFound: NotFoundVariants = {},
443
473
  corsPaths: readonly string[] = [],
444
474
  svgAssets = false,
445
- base = ""
475
+ base = "",
476
+ poweredBy = false
446
477
  ): string | null => {
447
478
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
448
479
  let config: {
@@ -491,6 +522,9 @@ export const injectNegotiationRoutes = (
491
522
  // route then terminates (Markdown negotiation on the homepage) still carries
492
523
  // the Link header.
493
524
  routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes);
525
+ if (poweredBy) {
526
+ routes.unshift(poweredByRoute(base));
527
+ }
494
528
  const notFoundRoutes = [
495
529
  ...(notFound.markdown ? notFoundMarkdownRoutes(base) : []),
496
530
  ...(notFound.json ? notFoundJsonRoutes(base) : []),
@@ -24,6 +24,21 @@ interface MdastUrlContext {
24
24
  const isUrl = (url: string | null | undefined): url is string =>
25
25
  typeof url === "string";
26
26
 
27
+ /** Record `based(url)` on the node when its root-relative URL changes. */
28
+ const rebase = (
29
+ node: UrlNode,
30
+ ctx: MdastUrlContext,
31
+ based: (url: string) => string
32
+ ): void => {
33
+ const { url } = node;
34
+ if (isUrl(url) && isInternalPath(url)) {
35
+ const next = based(url);
36
+ if (next !== url) {
37
+ ctx.setProperty(node, "url", next);
38
+ }
39
+ }
40
+ };
41
+
27
42
  export interface BaseLinksPluginOptions {
28
43
  /**
29
44
  * The `blume:data` JSON file, for an ejected app: with no CLI in the process
@@ -72,20 +87,6 @@ export const baseLinksPlugin = (
72
87
  const servesPage = (route: string): boolean =>
73
88
  servesRoute(servedRoutes(), route);
74
89
 
75
- /** Record `based(url)` on the node when its root-relative URL changes. */
76
- const rebase = (
77
- node: UrlNode,
78
- ctx: MdastUrlContext,
79
- based: (url: string) => string
80
- ): void => {
81
- const { url } = node;
82
- if (isUrl(url) && isInternalPath(url)) {
83
- const next = based(url);
84
- if (next !== url) {
85
- ctx.setProperty(node, "url", next);
86
- }
87
- }
88
- };
89
90
  const rebaseLink = (node: UrlNode, ctx: MdastUrlContext): void =>
90
91
  rebase(node, ctx, (url) =>
91
92
  withAuthoredBasePath(deployBase, basePath, url, servesPage)
@@ -205,6 +205,9 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
205
205
  return words.join(" ");
206
206
  };
207
207
 
208
+ const normalizeCommand = (command: string): string =>
209
+ command.replaceAll(WHITESPACE_RUN, " ").trim();
210
+
208
211
  /**
209
212
  * Convert an install command into the equivalent for every supported package
210
213
  * manager. Accepts a bare package list (`react`) or a full command
@@ -212,14 +215,12 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
212
215
  */
213
216
  export const toPackageCommands = (input: string) => {
214
217
  const intent = parseIntent(input);
215
- const normalize = (command: string): string =>
216
- command.replaceAll(WHITESPACE_RUN, " ").trim();
217
218
  return {
218
- aube: normalize(buildCommand("aube", intent)),
219
- bun: normalize(buildCommand("bun", intent)),
220
- npm: normalize(buildCommand("npm", intent)),
221
- nub: normalize(buildCommand("nub", intent)),
222
- pnpm: normalize(buildCommand("pnpm", intent)),
223
- yarn: normalize(buildCommand("yarn", intent)),
219
+ aube: normalizeCommand(buildCommand("aube", intent)),
220
+ bun: normalizeCommand(buildCommand("bun", intent)),
221
+ npm: normalizeCommand(buildCommand("npm", intent)),
222
+ nub: normalizeCommand(buildCommand("nub", intent)),
223
+ pnpm: normalizeCommand(buildCommand("pnpm", intent)),
224
+ yarn: normalizeCommand(buildCommand("yarn", intent)),
224
225
  } satisfies Record<PackageManager, string>;
225
226
  };