blume 2.0.2 → 2.0.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 (293) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli/{chunk-6k4ftwze.js → chunk-1d7ve1dm.js} +1 -1
  3. package/dist/cli/{chunk-zp79m0ts.js → chunk-2eytanqx.js} +2 -2
  4. package/dist/cli/{chunk-8g8ytmgx.js → chunk-2hsdwb9n.js} +19 -19
  5. package/dist/cli/{chunk-8g8ytmgx.js.map → chunk-2hsdwb9n.js.map} +1 -1
  6. package/dist/cli/{chunk-g698a744.js → chunk-2z928egk.js} +5 -5
  7. package/dist/cli/{chunk-ppzjqwx2.js → chunk-35d4wj9f.js} +17 -18
  8. package/dist/cli/{chunk-ppzjqwx2.js.map → chunk-35d4wj9f.js.map} +3 -3
  9. package/dist/cli/{chunk-mqc662a6.js → chunk-364znk6q.js} +2 -2
  10. package/dist/cli/{chunk-gs7r695n.js → chunk-3em5wd2y.js} +21 -8
  11. package/dist/cli/{chunk-gs7r695n.js.map → chunk-3em5wd2y.js.map} +3 -3
  12. package/dist/cli/{chunk-k7pj68a8.js → chunk-5f86nr5m.js} +15 -15
  13. package/dist/cli/{chunk-hr8ne106.js → chunk-5m5nmvyq.js} +68 -43
  14. package/dist/cli/chunk-5m5nmvyq.js.map +13 -0
  15. package/dist/cli/{chunk-91ws1n6j.js → chunk-5xvm6tfj.js} +14 -14
  16. package/dist/cli/{chunk-w4bxdvsa.js → chunk-6dsbexzp.js} +14 -14
  17. package/dist/cli/{chunk-00gs3wqs.js → chunk-8ktnccpt.js} +1 -1
  18. package/dist/cli/{chunk-j85scx15.js → chunk-9t7a85s3.js} +130 -44
  19. package/dist/cli/chunk-9t7a85s3.js.map +10 -0
  20. package/dist/cli/{chunk-bbnwccaz.js → chunk-a58773jm.js} +2 -2
  21. package/dist/cli/{chunk-nfcyttvj.js → chunk-acanzt5p.js} +9 -9
  22. package/dist/cli/{chunk-nfcyttvj.js.map → chunk-acanzt5p.js.map} +1 -1
  23. package/dist/cli/{chunk-wdrt2k2v.js → chunk-akbpwfxc.js} +90 -26
  24. package/dist/cli/chunk-akbpwfxc.js.map +10 -0
  25. package/dist/cli/{chunk-sqw4ekg1.js → chunk-b07cmahc.js} +2 -2
  26. package/dist/cli/{chunk-xh43dwgw.js → chunk-c8chx29p.js} +42 -29
  27. package/dist/cli/{chunk-xh43dwgw.js.map → chunk-c8chx29p.js.map} +9 -9
  28. package/dist/cli/{chunk-7mbqtmgb.js → chunk-crgn1q09.js} +21 -12
  29. package/dist/cli/chunk-crgn1q09.js.map +10 -0
  30. package/dist/cli/chunk-e04dxsz1.js +39 -0
  31. package/dist/cli/chunk-e04dxsz1.js.map +10 -0
  32. package/dist/cli/{chunk-4e9b9ra6.js → chunk-ey84smr6.js} +3 -3
  33. package/dist/cli/{chunk-d1v5rhy0.js → chunk-g4hq16wv.js} +13 -13
  34. package/dist/cli/{chunk-273ygyr4.js → chunk-ga0pf4aj.js} +7 -7
  35. package/dist/cli/{chunk-273ygyr4.js.map → chunk-ga0pf4aj.js.map} +3 -3
  36. package/dist/cli/{chunk-pbg5a4s3.js → chunk-hm3vjy5s.js} +64 -45
  37. package/dist/cli/chunk-hm3vjy5s.js.map +19 -0
  38. package/dist/cli/{chunk-5r8g91qn.js → chunk-j85vccga.js} +306 -107
  39. package/dist/cli/chunk-j85vccga.js.map +36 -0
  40. package/dist/cli/{chunk-n1yg3tj3.js → chunk-p3v96n38.js} +6 -6
  41. package/dist/cli/{chunk-n1yg3tj3.js.map → chunk-p3v96n38.js.map} +3 -3
  42. package/dist/cli/{chunk-7vtckvaw.js → chunk-p73c0m7w.js} +14 -14
  43. package/dist/cli/{chunk-7vtckvaw.js.map → chunk-p73c0m7w.js.map} +1 -1
  44. package/dist/cli/{chunk-qkb5a8sa.js → chunk-pehfxfta.js} +3 -3
  45. package/dist/cli/{chunk-v6ya5kcb.js → chunk-pv29h0wf.js} +1223 -978
  46. package/dist/cli/{chunk-v6ya5kcb.js.map → chunk-pv29h0wf.js.map} +47 -46
  47. package/dist/cli/{chunk-6dtt0zfn.js → chunk-q58y5e6a.js} +10 -10
  48. package/dist/cli/{chunk-6dtt0zfn.js.map → chunk-q58y5e6a.js.map} +3 -3
  49. package/dist/cli/{chunk-fsmrqk8a.js → chunk-r20tn01b.js} +1 -1
  50. package/dist/cli/{chunk-bfwp9vp6.js → chunk-r9rcc4w7.js} +7 -7
  51. package/dist/cli/{chunk-h7k3nq3v.js → chunk-tkacnehg.js} +2 -2
  52. package/dist/cli/{chunk-h2ez8dzb.js → chunk-tzmab476.js} +4 -4
  53. package/dist/cli/{chunk-3yce002v.js → chunk-vg9r4eb9.js} +6 -6
  54. package/dist/cli/{chunk-3yce002v.js.map → chunk-vg9r4eb9.js.map} +4 -4
  55. package/dist/cli/{chunk-hqp2ajnh.js → chunk-wrr3j9w9.js} +16 -17
  56. package/dist/cli/{chunk-hqp2ajnh.js.map → chunk-wrr3j9w9.js.map} +7 -7
  57. package/dist/cli/{chunk-ddndchfr.js → chunk-yfyb25rh.js} +38 -26
  58. package/dist/cli/chunk-yfyb25rh.js.map +14 -0
  59. package/dist/cli/index.js +20 -18
  60. package/dist/cli/index.js.map +3 -3
  61. package/dist/types/ai/link-headers.d.ts +8 -1
  62. package/dist/types/ai/openapi-components.d.ts +5 -2
  63. package/dist/types/ai/relative-links.d.ts +9 -7
  64. package/dist/types/ai/skills.d.ts +4 -1
  65. package/dist/types/ai/tar.d.ts +1 -3
  66. package/dist/types/analytics/index.d.ts +2 -0
  67. package/dist/types/analytics/one-dollar-stats.d.ts +48 -0
  68. package/dist/types/analytics/schema.d.ts +14 -0
  69. package/dist/types/astro/integration.d.ts +3 -2
  70. package/dist/types/core/base-path.d.ts +21 -7
  71. package/dist/types/core/config-input.d.ts +9 -7
  72. package/dist/types/core/directive-diagnostics.d.ts +12 -0
  73. package/dist/types/core/heading-markers.d.ts +5 -7
  74. package/dist/types/core/i18n.d.ts +2 -0
  75. package/dist/types/core/last-modified.d.ts +10 -0
  76. package/dist/types/core/meta.d.ts +8 -0
  77. package/dist/types/core/schema.d.ts +7 -0
  78. package/dist/types/core/sources/lower.d.ts +29 -16
  79. package/dist/types/core/sources/normalize.d.ts +13 -1
  80. package/dist/types/core/sources/watch.d.ts +12 -5
  81. package/dist/types/core/standard-schema.d.ts +5 -0
  82. package/dist/types/deploy/artifacts.d.ts +6 -4
  83. package/dist/types/deploy/headers.d.ts +5 -0
  84. package/dist/types/deploy/platforms/types.d.ts +8 -0
  85. package/dist/types/deploy/redirects.d.ts +24 -13
  86. package/dist/types/markdown/directives.d.ts +62 -0
  87. package/dist/types/markdown/features.d.ts +21 -0
  88. package/dist/types/markdown/mdast.d.ts +63 -0
  89. package/dist/types/openapi/asyncapi.d.ts +4 -2
  90. package/docs/02-deployment.mdx +4 -2
  91. package/docs/08-faq.mdx +1 -1
  92. package/docs/advanced/custom-pages.mdx +3 -3
  93. package/docs/cli/audit.mdx +17 -1
  94. package/docs/cli/evals.mdx +3 -3
  95. package/docs/cli/translate.mdx +3 -3
  96. package/docs/cli/version.mdx +1 -1
  97. package/docs/configuration/analytics.mdx +20 -1
  98. package/docs/configuration/customization.mdx +2 -0
  99. package/docs/configuration/search.mdx +1 -1
  100. package/docs/content/components.mdx +1 -1
  101. package/docs/content/frontmatter.mdx +1 -1
  102. package/docs/content/i18n.mdx +2 -0
  103. package/docs/content/index.mdx +4 -2
  104. package/docs/content/islands.mdx +1 -1
  105. package/docs/content/meta.mdx +3 -1
  106. package/docs/content/sources.mdx +1 -5
  107. package/docs/content/syntax.mdx +14 -0
  108. package/docs/content/versioning.mdx +1 -1
  109. package/docs/discoverability/agent-discovery.mdx +1 -1
  110. package/docs/discoverability/index.mdx +2 -1
  111. package/docs/discoverability/markdown.mdx +2 -2
  112. package/docs/discoverability/open-graph.mdx +5 -3
  113. package/docs/discoverability/rss.mdx +1 -1
  114. package/docs/references/asyncapi.mdx +1 -1
  115. package/docs/references/graphql.mdx +1 -1
  116. package/docs/references/openapi.mdx +5 -3
  117. package/package.json +1 -1
  118. package/skills/blume-migrate/references/fumadocs.md +1 -1
  119. package/skills/blume-migrate/references/nextra.md +1 -1
  120. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +7 -2
  121. package/src/ai/agent-readability.ts +2 -2
  122. package/src/ai/ai-catalog.ts +6 -2
  123. package/src/ai/api/handlers.ts +2 -2
  124. package/src/ai/api/spec.ts +2 -2
  125. package/src/ai/api-catalog.ts +6 -2
  126. package/src/ai/changelog-markdown.ts +2 -2
  127. package/src/ai/link-headers.ts +14 -5
  128. package/src/ai/llms.ts +18 -7
  129. package/src/ai/mcp/discovery.ts +2 -2
  130. package/src/ai/mcp/query.ts +2 -2
  131. package/src/ai/mcp/server.ts +2 -2
  132. package/src/ai/openapi-components.ts +22 -6
  133. package/src/ai/relative-links.ts +42 -26
  134. package/src/ai/serializers.ts +2 -1
  135. package/src/ai/skills.ts +14 -1
  136. package/src/ai/tar.ts +139 -12
  137. package/src/analytics/head.ts +4 -0
  138. package/src/analytics/index.ts +5 -0
  139. package/src/analytics/one-dollar-stats.ts +86 -0
  140. package/src/analytics/schema.ts +2 -0
  141. package/src/astro/generate.ts +1 -1
  142. package/src/astro/include-hmr.ts +31 -12
  143. package/src/astro/include-refresh.ts +19 -8
  144. package/src/astro/integration.ts +108 -14
  145. package/src/astro/runtime-modules.ts +28 -13
  146. package/src/astro/templates.ts +81 -37
  147. package/src/audit/checks/links.ts +8 -1
  148. package/src/audit/graph.ts +3 -1
  149. package/src/audit/run.ts +12 -9
  150. package/src/audit/snapshot.ts +5 -0
  151. package/src/cli/args.ts +32 -0
  152. package/src/cli/commands/audit.ts +4 -1
  153. package/src/cli/commands/doctor.ts +3 -1
  154. package/src/cli/commands/eval.ts +19 -11
  155. package/src/cli/commands/translate.ts +9 -12
  156. package/src/cli/commands/validate.ts +3 -1
  157. package/src/cli/dev-lock.ts +157 -37
  158. package/src/cli/report-format.ts +11 -6
  159. package/src/components/colors.ts +19 -0
  160. package/src/components/content/Badge.astro +5 -3
  161. package/src/components/content/Component.astro +2 -2
  162. package/src/components/content/Tab.astro +0 -1
  163. package/src/components/content/Tabs.astro +4 -0
  164. package/src/components/content/badge-color.ts +5 -2
  165. package/src/components/content/base-href.ts +13 -36
  166. package/src/components/islands/assistant.tsx +21 -4
  167. package/src/components/islands/base-path.ts +47 -10
  168. package/src/components/islands/hooks.ts +35 -19
  169. package/src/components/islands/webmcp.ts +4 -2
  170. package/src/components/layout/Breadcrumbs.astro +5 -2
  171. package/src/components/layout/DiscoveryLinks.astro +9 -5
  172. package/src/components/layout/Header.astro +2 -2
  173. package/src/components/layout/LanguageSwitcher.astro +2 -2
  174. package/src/components/layout/NavSelector.astro +2 -2
  175. package/src/components/layout/NavTabMenu.astro +3 -3
  176. package/src/components/layout/NavTree.astro +16 -6
  177. package/src/components/layout/PageActions.astro +4 -3
  178. package/src/components/layout/PageLayout.astro +7 -6
  179. package/src/components/layout/Pagination.astro +3 -3
  180. package/src/components/layout/RootLayout.astro +5 -5
  181. package/src/components/layout/Search.astro +33 -13
  182. package/src/components/layout/VersionBanner.astro +2 -2
  183. package/src/components/layout/analytics-client.ts +12 -0
  184. package/src/components/layout/toc-active.ts +41 -0
  185. package/src/components/layout/toc-element.ts +8 -14
  186. package/src/components/openapi/ApiTagOperations.astro +2 -2
  187. package/src/components/openapi/AsyncApiOperation.astro +7 -4
  188. package/src/components/openapi/GraphqlChip.astro +2 -2
  189. package/src/components/openapi/Operation.astro +2 -0
  190. package/src/components/openapi/Playground.astro +4 -4
  191. package/src/components/openapi/RequestPanel.astro +5 -2
  192. package/src/components/openapi/SchemaTable.astro +7 -0
  193. package/src/components/openapi/async.ts +38 -6
  194. package/src/components/openapi/helpers.ts +19 -9
  195. package/src/components/openapi/message-composer.ts +6 -1
  196. package/src/components/openapi/message.ts +17 -2
  197. package/src/components/openapi/operation-model.ts +81 -11
  198. package/src/components/openapi/panel.ts +4 -2
  199. package/src/components/openapi/param-style.ts +181 -0
  200. package/src/components/openapi/playground-client.ts +73 -16
  201. package/src/components/openapi/request.ts +190 -26
  202. package/src/components/openapi/schema-tree.ts +30 -24
  203. package/src/components/openapi/snippets.ts +85 -6
  204. package/src/components/openapi/ws-client.ts +18 -2
  205. package/src/core/base-path.ts +62 -16
  206. package/src/core/config-input.ts +9 -7
  207. package/src/core/diagnostics.ts +2 -0
  208. package/src/core/directive-diagnostics.ts +99 -0
  209. package/src/core/frontmatter.ts +21 -18
  210. package/src/core/heading-markers.ts +5 -18
  211. package/src/core/i18n.ts +31 -3
  212. package/src/core/last-modified.ts +25 -3
  213. package/src/core/locale-links.ts +5 -1
  214. package/src/core/meta.ts +33 -19
  215. package/src/core/navigation.ts +28 -8
  216. package/src/core/project-graph.ts +27 -5
  217. package/src/core/schema.ts +24 -4
  218. package/src/core/sources/contentful-rich-text.ts +27 -20
  219. package/src/core/sources/filesystem.ts +20 -2
  220. package/src/core/sources/github-releases.ts +25 -6
  221. package/src/core/sources/lexical.ts +23 -18
  222. package/src/core/sources/lower.ts +201 -34
  223. package/src/core/sources/mdx-remote.ts +51 -16
  224. package/src/core/sources/normalize.ts +325 -90
  225. package/src/core/sources/notion.ts +52 -25
  226. package/src/core/sources/obsidian.ts +23 -6
  227. package/src/core/sources/portable-text.ts +38 -21
  228. package/src/core/sources/strapi-blocks.ts +20 -16
  229. package/src/core/sources/watch.ts +20 -7
  230. package/src/core/standard-schema.ts +10 -6
  231. package/src/core/version-cut.ts +52 -15
  232. package/src/deploy/artifacts.ts +27 -6
  233. package/src/deploy/cloudflare-negotiation.ts +4 -12
  234. package/src/deploy/headers.ts +8 -4
  235. package/src/deploy/node-headers.ts +1 -1
  236. package/src/deploy/platforms/cloudflare.ts +12 -3
  237. package/src/deploy/platforms/netlify.ts +1 -0
  238. package/src/deploy/platforms/node.ts +1 -0
  239. package/src/deploy/platforms/static.ts +1 -0
  240. package/src/deploy/platforms/types.ts +8 -0
  241. package/src/deploy/platforms/vercel.ts +1 -0
  242. package/src/deploy/redirects.ts +54 -18
  243. package/src/deploy/robots.ts +2 -2
  244. package/src/deploy/rss.ts +4 -3
  245. package/src/deploy/sitemap.ts +7 -5
  246. package/src/markdown/base-links.ts +34 -36
  247. package/src/markdown/directives.ts +242 -36
  248. package/src/markdown/features.ts +17 -0
  249. package/src/markdown/index.ts +7 -6
  250. package/src/markdown/mdast.ts +5 -2
  251. package/src/markdown/relative-links.ts +12 -4
  252. package/src/og/card.ts +129 -5
  253. package/src/og/derive.ts +41 -31
  254. package/src/og/index.ts +1 -0
  255. package/src/openapi/asyncapi.ts +4 -2
  256. package/src/openapi/model.ts +22 -14
  257. package/src/openapi/proxy.ts +63 -10
  258. package/src/openapi/render-mdx.ts +41 -2
  259. package/src/registry/eject.ts +181 -24
  260. package/src/search/adapters/version-scope.ts +30 -0
  261. package/src/search/documents.ts +67 -26
  262. package/src/search/popular.ts +2 -1
  263. package/src/seo/jsonld.ts +7 -3
  264. package/src/translate/meta.ts +68 -24
  265. package/src/translate/run.ts +3 -3
  266. package/src/translate/validate.ts +4 -1
  267. package/src/translate/work-list.ts +56 -17
  268. package/dist/cli/chunk-5r8g91qn.js.map +0 -34
  269. package/dist/cli/chunk-7mbqtmgb.js.map +0 -10
  270. package/dist/cli/chunk-ddndchfr.js.map +0 -14
  271. package/dist/cli/chunk-esh98wmb.js +0 -23
  272. package/dist/cli/chunk-esh98wmb.js.map +0 -10
  273. package/dist/cli/chunk-hr8ne106.js.map +0 -13
  274. package/dist/cli/chunk-j85scx15.js.map +0 -10
  275. package/dist/cli/chunk-pbg5a4s3.js.map +0 -19
  276. package/dist/cli/chunk-wdrt2k2v.js.map +0 -10
  277. /package/dist/cli/{chunk-6k4ftwze.js.map → chunk-1d7ve1dm.js.map} +0 -0
  278. /package/dist/cli/{chunk-zp79m0ts.js.map → chunk-2eytanqx.js.map} +0 -0
  279. /package/dist/cli/{chunk-g698a744.js.map → chunk-2z928egk.js.map} +0 -0
  280. /package/dist/cli/{chunk-mqc662a6.js.map → chunk-364znk6q.js.map} +0 -0
  281. /package/dist/cli/{chunk-k7pj68a8.js.map → chunk-5f86nr5m.js.map} +0 -0
  282. /package/dist/cli/{chunk-91ws1n6j.js.map → chunk-5xvm6tfj.js.map} +0 -0
  283. /package/dist/cli/{chunk-w4bxdvsa.js.map → chunk-6dsbexzp.js.map} +0 -0
  284. /package/dist/cli/{chunk-00gs3wqs.js.map → chunk-8ktnccpt.js.map} +0 -0
  285. /package/dist/cli/{chunk-bbnwccaz.js.map → chunk-a58773jm.js.map} +0 -0
  286. /package/dist/cli/{chunk-sqw4ekg1.js.map → chunk-b07cmahc.js.map} +0 -0
  287. /package/dist/cli/{chunk-4e9b9ra6.js.map → chunk-ey84smr6.js.map} +0 -0
  288. /package/dist/cli/{chunk-d1v5rhy0.js.map → chunk-g4hq16wv.js.map} +0 -0
  289. /package/dist/cli/{chunk-qkb5a8sa.js.map → chunk-pehfxfta.js.map} +0 -0
  290. /package/dist/cli/{chunk-fsmrqk8a.js.map → chunk-r20tn01b.js.map} +0 -0
  291. /package/dist/cli/{chunk-bfwp9vp6.js.map → chunk-r9rcc4w7.js.map} +0 -0
  292. /package/dist/cli/{chunk-h7k3nq3v.js.map → chunk-tkacnehg.js.map} +0 -0
  293. /package/dist/cli/{chunk-h2ez8dzb.js.map → chunk-tzmab476.js.map} +0 -0
@@ -15,14 +15,15 @@ import {
15
15
  pollingWatch,
16
16
  snapshotCache,
17
17
  } from "./cache.ts";
18
+ import type { InlineRun } from "./lower.ts";
18
19
  import {
19
20
  codeFence,
20
21
  guardBlockStart,
21
22
  image,
23
+ linkParts,
22
24
  renderInline,
23
- renderLink,
24
25
  } from "./lower.ts";
25
- import { slugifyPath } from "./normalize.ts";
26
+ import { slugify, slugifyPath } from "./normalize.ts";
26
27
  import type {
27
28
  ContentSource,
28
29
  SourceContext,
@@ -160,26 +161,43 @@ interface NotionFrontmatter {
160
161
  [key: string]: boolean | string | { order: number } | undefined;
161
162
  }
162
163
 
164
+ /** Neighboring rich-text runs that link to one `href` (or none), and it. */
165
+ interface LinkedRuns {
166
+ href?: string;
167
+ runs: InlineRun[];
168
+ }
169
+
163
170
  /**
164
- * Rich text as Markdown for an MDX body. Each run goes through the shared
171
+ * Rich text as Markdown for an MDX body. The runs go through the shared
165
172
  * lowering, so a literal `{`, `<`, `*`, or `[` typed in Notion renders as
166
- * itself instead of opening a JSX expression, a tag, or emphasis, and a code
167
- * run keeps its text verbatim inside a long-enough code span.
173
+ * itself instead of opening a JSX expression, a tag, or emphasis, a code run
174
+ * keeps its text verbatim inside a long-enough code span, neighbors share
175
+ * their marks' delimiters, and neighbors that link to one `href` share one
176
+ * link.
168
177
  */
169
- const richToMarkdown = (rich: NotionRichText[] = []): string =>
170
- rich
171
- .map(({ annotations = {}, href, plain_text: text }) =>
172
- renderLink(
173
- renderInline(text, {
174
- bold: annotations.bold,
175
- code: annotations.code,
176
- italic: annotations.italic,
177
- strike: annotations.strikethrough,
178
- }),
179
- href ?? undefined
180
- )
181
- )
182
- .join("");
178
+ const richToMarkdown = (rich: NotionRichText[] = []): string => {
179
+ const linked: LinkedRuns[] = [];
180
+ for (const { annotations = {}, href, plain_text: text } of rich) {
181
+ const run: InlineRun = {
182
+ marks: {
183
+ bold: annotations.bold,
184
+ code: annotations.code,
185
+ italic: annotations.italic,
186
+ strike: annotations.strikethrough,
187
+ },
188
+ text,
189
+ };
190
+ const last = linked.at(-1);
191
+ if (last && last.href === (href ?? undefined)) {
192
+ last.runs.push(run);
193
+ } else {
194
+ linked.push({ href: href ?? undefined, runs: [run] });
195
+ }
196
+ }
197
+ return renderInline(
198
+ linked.flatMap(({ href, runs }) => linkParts(runs, href))
199
+ );
200
+ };
183
201
 
184
202
  const isBlockPayload = (
185
203
  value: NotionBlockPayload | boolean | string | undefined
@@ -307,10 +325,14 @@ const renderVideo = (data: NotionBlockPayload): string => {
307
325
  const title = caption ? ` title=${jsxString(richToPlain(data.caption))}` : "";
308
326
  // Everything else (a Notion upload, or a direct link to a media file) is a
309
327
  // real video file: `materializeAssets` rewrites the `src`, which matters most
310
- // for uploads, whose Notion URLs are signed and expire.
328
+ // for uploads, whose Notion URLs are signed and expire. It finds the file by
329
+ // that quoted `src`, where MDX decodes no backslash escapes — a JSON
330
+ // string's `\\` and `\"` would corrupt the URL or end the attribute — so the
331
+ // URL goes in as written, with a double quote as the `%22` it means anyway.
332
+ // The embed's `url` takes the expression form, like every string prop here.
311
333
  const media = isYouTubeUrl(url)
312
- ? `<YouTube${title} url=${JSON.stringify(url)} />`
313
- : `<video controls src=${JSON.stringify(url)} />`;
334
+ ? `<YouTube${title} url=${jsxString(url)} />`
335
+ : `<video controls src="${url.replaceAll('"', "%22")}" />`;
314
336
  return caption
315
337
  ? `<Frame caption=${jsxString(caption.replaceAll(FRAME_ESCAPED, ""))}>\n${media}\n</Frame>`
316
338
  : media;
@@ -577,9 +599,14 @@ export const notionSource = (
577
599
  const slugProp = richToPlain(
578
600
  page.properties[props.slug ?? "Slug"]?.rich_text
579
601
  );
580
- // Path-aware: a `guides/setup` slug keeps its `/` (per-segment slugging)
581
- // instead of mashing into `guidessetup`.
582
- const slug = slugifyPath(slugProp || title) || page.id;
602
+ // A Slug property is path-aware: `guides/setup` keeps its `/` (per-segment
603
+ // slugging) instead of mashing into `guidessetup`. A title is one name, so
604
+ // its slashes become hyphens: "CI/CD Setup" routes to `ci-cd-setup`, not
605
+ // to `cd-setup` inside an invented `ci` group.
606
+ const slug =
607
+ (slugProp
608
+ ? slugifyPath(slugProp)
609
+ : slugify(title.replaceAll("/", "-"))) || page.id;
583
610
  return { data, slug };
584
611
  };
585
612
 
@@ -21,6 +21,7 @@ import {
21
21
  resolveEntryRoute,
22
22
  slugifyPath,
23
23
  strippedLineOffset,
24
+ unloadablePathDiagnostic,
24
25
  } from "./normalize.ts";
25
26
  import type {
26
27
  ContentSource,
@@ -587,10 +588,11 @@ const transformBody = (
587
588
  * `[#custom-id]` pin and the `setup-1` a later collision gets are honored
588
589
  * rather than re-slugged. When no heading holds anything the rewrite changes,
589
590
  * the raw body's headings are the staged body's; otherwise the body is lowered
590
- * once more so the headings are scanned exactly as they ship. A link inside a
591
- * heading is rewritten here before the other notes' anchors are known, so its
592
- * own anchor is left off; that only moves the manifest id of a heading that
593
- * itself holds a heading link, which the docs already flag as unaddressable.
591
+ * once more so the headings are scanned exactly as they ship. A heading link
592
+ * inside a heading is rewritten here before every note's anchors are known,
593
+ * so its href may still lack the anchor it ships with. That moves no id: a
594
+ * heading is slugged by its rendered text, where the link is its label alone,
595
+ * so the heading stays as addressable as any other.
594
596
  */
595
597
  const fillAnchors = (pair: IndexedPair, index: LinkIndex): void => {
596
598
  const raw = extractHeadings(pair.note.content);
@@ -958,7 +960,19 @@ export const obsidianSource = (
958
960
  const exclude = new Set([...BLUME_IGNORE_DIRS, ...(options.exclude ?? [])]);
959
961
 
960
962
  const load = async (): Promise<SourceLoadResult> => {
961
- const files = await walkVault(vaultDir, vaultDir, exclude);
963
+ // A note is staged under its vault path, where Astro's content loader
964
+ // can't read a name holding `#` or `?`; such a note is reported and left
965
+ // out, and links to it degrade like links to any missing note.
966
+ const skipped: Diagnostic[] = [];
967
+ const files: string[] = [];
968
+ for (const rel of await walkVault(vaultDir, vaultDir, exclude)) {
969
+ const unloadable = unloadablePathDiagnostic(rel, join(vaultDir, rel));
970
+ if (unloadable) {
971
+ skipped.push(unloadable);
972
+ } else {
973
+ files.push(rel);
974
+ }
975
+ }
962
976
  const read = await Promise.all(
963
977
  files.map((rel) => readNote(vaultDir, rel, options))
964
978
  );
@@ -971,7 +985,10 @@ export const obsidianSource = (
971
985
  noteToEntry(pair, index, options, unresolved)
972
986
  );
973
987
  return {
974
- diagnostics: unresolvedDiagnostics(options.name, unresolved),
988
+ diagnostics: [
989
+ ...skipped,
990
+ ...unresolvedDiagnostics(options.name, unresolved),
991
+ ],
975
992
  entries,
976
993
  };
977
994
  };
@@ -4,11 +4,12 @@
4
4
  * user-supplied serializer or are skipped with a noted comment. Output is
5
5
  * Markdown text that flows through Blume's normal pipeline.
6
6
  */
7
+ import type { InlineRun } from "./lower.ts";
7
8
  import {
8
9
  guardBlockStart,
9
10
  image,
11
+ linkParts,
10
12
  renderInline,
11
- renderLink,
12
13
  unsupported,
13
14
  writesMdx,
14
15
  } from "./lower.ts";
@@ -73,38 +74,54 @@ const HEADING_STYLES = new Map([
73
74
  ["h6", "###### "],
74
75
  ]);
75
76
 
76
- /**
77
- * A span as Markdown for its marks, through the shared lowering every CMS
78
- * uses: prose is escaped, a code span outruns the backticks inside it, edge
79
- * whitespace stays outside emphasis, and a link def wraps last (so its label
80
- * keeps emphasis) only when its destination is safe to click.
81
- */
82
- const renderSpan = (
83
- span: PortableTextSpan,
84
- defs: Map<string, PortableTextMarkDef>
85
- ): string => {
77
+ /** A span's text and the marks Markdown has syntax for. */
78
+ const spanRun = (span: PortableTextSpan): InlineRun => {
86
79
  const marks = span.marks ?? [];
87
- const link = marks
88
- .map((mark) => defs.get(mark))
89
- .findLast((def) => def?._type === "link");
90
- return renderLink(
91
- renderInline(span.text ?? "", {
80
+ return {
81
+ marks: {
92
82
  bold: marks.includes("strong"),
93
83
  code: marks.includes("code"),
94
84
  italic: marks.includes("em"),
95
85
  strike: marks.includes("strike-through"),
96
- }),
97
- link?.href
98
- );
86
+ },
87
+ text: span.text ?? "",
88
+ };
99
89
  };
100
90
 
101
- /** Render the inline children of a block to a single Markdown string. */
91
+ /** Neighboring spans under one link def (or none), and that def. */
92
+ interface LinkedSpans {
93
+ link?: PortableTextMarkDef;
94
+ runs: InlineRun[];
95
+ }
96
+
97
+ /**
98
+ * Render the inline children of a block to a single Markdown string, through
99
+ * the shared lowering every CMS uses: prose is escaped, a code span outruns
100
+ * the backticks inside it, edge whitespace stays outside emphasis, neighbors
101
+ * share their marks' delimiters, and neighbors under one link def share one
102
+ * link — wrapped last (so its label keeps emphasis), and only when its
103
+ * destination is safe to click.
104
+ */
102
105
  const renderChildren = (block: PortableTextBlock): string => {
103
106
  const defs = new Map(
104
107
  (block.markDefs ?? []).map((def) => [def._key, def] as const)
105
108
  );
109
+ const linked: LinkedSpans[] = [];
110
+ for (const span of block.children ?? []) {
111
+ const link = (span.marks ?? [])
112
+ .map((mark) => defs.get(mark))
113
+ .findLast((def) => def?._type === "link");
114
+ const last = linked.at(-1);
115
+ if (last && last.link === link) {
116
+ last.runs.push(spanRun(span));
117
+ } else {
118
+ linked.push({ link, runs: [spanRun(span)] });
119
+ }
120
+ }
106
121
  return guardBlockStart(
107
- (block.children ?? []).map((span) => renderSpan(span, defs)).join("")
122
+ renderInline(
123
+ linked.flatMap(({ link, runs }) => linkParts(runs, link?.href))
124
+ )
108
125
  );
109
126
  };
110
127
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { JsonObject, JsonValue } from "./json.ts";
7
7
  import { asNumber, asObject, asString, objectsIn } from "./json.ts";
8
+ import type { InlinePart } from "./lower.ts";
8
9
  import {
9
10
  absoluteUrl,
10
11
  blockquote,
@@ -12,10 +13,10 @@ import {
12
13
  guardBlockStart,
13
14
  headingPrefix,
14
15
  image,
16
+ linkParts,
15
17
  listItem,
16
18
  markdownDocument,
17
19
  renderInline,
18
- renderLink,
19
20
  unsupported,
20
21
  } from "./lower.ts";
21
22
 
@@ -28,34 +29,37 @@ const children = (node: JsonObject): JsonObject[] => objectsIn(node.children);
28
29
 
29
30
  const typeOf = (node: JsonObject): string => asString(node.type) ?? "";
30
31
 
31
- const renderInlines = (nodes: JsonObject[]): string =>
32
- nodes
33
- // oxlint-disable-next-line no-use-before-define -- mutual recursion: a link's label is itself inline content
34
- .map((node) => renderInlineNode(node))
35
- .join("");
32
+ const inlineParts = (nodes: JsonObject[]): InlinePart[] =>
33
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion: a link's label is itself inline content
34
+ nodes.flatMap((node) => inlineNodeParts(node));
36
35
 
37
- const renderInlineNode = (node: JsonObject): string => {
36
+ const inlineNodeParts = (node: JsonObject): InlinePart[] => {
38
37
  switch (typeOf(node)) {
39
38
  case "text": {
40
- return renderInline(asString(node.text) ?? "", {
41
- bold: node.bold === true,
42
- code: node.code === true,
43
- italic: node.italic === true,
44
- strike: node.strikethrough === true,
45
- });
39
+ return [
40
+ {
41
+ marks: {
42
+ bold: node.bold === true,
43
+ code: node.code === true,
44
+ italic: node.italic === true,
45
+ strike: node.strikethrough === true,
46
+ },
47
+ text: asString(node.text) ?? "",
48
+ },
49
+ ];
46
50
  }
47
51
  case "link": {
48
- return renderLink(renderInlines(children(node)), asString(node.url));
52
+ return linkParts(inlineParts(children(node)), asString(node.url));
49
53
  }
50
54
  default: {
51
- return renderInlines(children(node));
55
+ return inlineParts(children(node));
52
56
  }
53
57
  }
54
58
  };
55
59
 
56
60
  /** A text block's inline nodes, joined and guarded as one block. */
57
61
  const blockText = (nodes: JsonObject[]): string =>
58
- guardBlockStart(renderInlines(nodes));
62
+ guardBlockStart(renderInline(inlineParts(nodes)));
59
63
 
60
64
  const renderList = (node: JsonObject): string => {
61
65
  const ordered = asString(node.format) === "ordered";
@@ -51,18 +51,26 @@ export const baselineScanIgnore = (): string[] =>
51
51
  */
52
52
  export const BLUME_WATCH_IGNORE_DIRS = BLUME_IGNORE_DIRS;
53
53
 
54
- /** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
54
+ /**
55
+ * Extract root-anchored ignore dirs (`/foo`) from `foo/**`-style excludes. The
56
+ * scan's glob `foo/**` matches `foo` at the source root only, so the watcher
57
+ * skips that one directory: a nested `guides/foo/` is still content, and an
58
+ * edit there must still reload.
59
+ */
55
60
  export const excludeDirSegments = (patterns: readonly string[]): string[] =>
56
61
  patterns
57
62
  .map((pattern) => /^(?<dir>[^*/]+)\/\*\*$/u.exec(pattern)?.groups?.dir)
58
- .filter((dir): dir is string => dir !== undefined);
63
+ .filter((dir): dir is string => dir !== undefined)
64
+ .map((dir) => `/${dir}`);
59
65
 
60
66
  /**
61
67
  * Build a recursive-watch listener that fires `onChange` for content changes but
62
- * ignores events whose path crosses an ignored directory segment — one in
63
- * `ignoreDirs`, or one `ignoreSegment` rejects, for a scan whose skip rule is
64
- * a shape (every dot-name) rather than a list. A missing `filename` — rare;
65
- * the platform couldn't name the changed path — falls through to `onChange`
68
+ * ignores events whose path crosses an ignored directory — a segment named in
69
+ * `ignoreDirs` at any depth, or one `ignoreSegment` rejects, for a scan whose
70
+ * skip rule is a shape (every dot-name) rather than a list. An entry written
71
+ * with a leading slash (`/drafts`, see {@link excludeDirSegments}) names a
72
+ * directory at the watched root only. A missing `filename` — rare; the
73
+ * platform couldn't name the changed path — falls through to `onChange`
66
74
  * rather than silently dropping a real edit. Exported for testing.
67
75
  */
68
76
  export const ignoringWatchListener = (
@@ -76,7 +84,12 @@ export const ignoringWatchListener = (
76
84
  filename !== null &&
77
85
  filename
78
86
  .split(/[/\\]/u)
79
- .some((segment) => ignore.has(segment) || ignoreSegment(segment))
87
+ .some(
88
+ (segment, index) =>
89
+ ignore.has(segment) ||
90
+ (index === 0 && ignore.has(`/${segment}`)) ||
91
+ ignoreSegment(segment)
92
+ )
80
93
  ) {
81
94
  return;
82
95
  }
@@ -50,11 +50,15 @@ export interface StandardSchema<Input = unknown, Output = Input> {
50
50
  * Whether a config-supplied value implements the `~standard` contract. Generic
51
51
  * so it can decode any input at the config boundary (`z.custom` hands it the
52
52
  * raw config value) while narrowing whatever type the caller holds.
53
+ *
54
+ * The contract is the `~standard` property, whatever carries it: Zod and
55
+ * Valibot schemas are objects, but an ArkType type is a callable function. The
56
+ * optional-chained probe reads the property off either, and off `null`,
57
+ * `undefined`, or a primitive it finds nothing.
53
58
  */
54
59
  export const isStandardSchema = <T>(value: T): value is T & StandardSchema =>
55
- typeof value === "object" &&
56
- value !== null &&
57
- // SAFETY: the assertion only widens the checked object for property probing;
58
- // the trailing typeof check is what verifies `~standard.validate` exists.
59
- typeof (value as { "~standard"?: { validate?: unknown } })["~standard"]
60
- ?.validate === "function";
60
+ // SAFETY: the assertion only widens the value for property probing; the
61
+ // typeof check is what verifies `~standard.validate` exists.
62
+ typeof (
63
+ value as { "~standard"?: { validate?: unknown } } | null | undefined
64
+ )?.["~standard"]?.validate === "function";
@@ -3,7 +3,7 @@ import { cp, readdir, readFile, rm } from "node:fs/promises";
3
3
 
4
4
  import { join, relative } from "pathe";
5
5
 
6
- import { stripBasePath } from "./base-path.ts";
6
+ import { mountBasePath, stripBasePath } from "./base-path.ts";
7
7
  import { nextFenceState } from "./code-fences.ts";
8
8
  import type { FenceState } from "./code-fences.ts";
9
9
  import { writeTextAtomic } from "./fs-atomic.ts";
@@ -45,18 +45,22 @@ export class CutError extends Error {
45
45
  // matching so replacements can splice into the real line by index (the mask
46
46
  // preserves length). Mirrors the content-assets rewriter.
47
47
  const INLINE_CODE = /`[^`]*`/gu;
48
- // A markdown link/image target or an HTML href/src attribute whose value is
49
- // root-absolute. Named groups carry the prefix (kept) and target (rewritten).
50
- const ROOT_LINK = /(?<prefix>\]\(|href="|src=")(?<target>\/[^\s"')]*)/gu;
48
+ // A root-absolute markdown link/image target, reference-style link definition
49
+ // (`[setup]: /guides/setup`, up to three spaces in, never a `[^note]:`
50
+ // footnote), or HTML href/src attribute in either quote style. Named groups
51
+ // carry the prefix (kept) and target (rewritten).
52
+ const ROOT_LINK =
53
+ /(?<prefix>\]\(|^ {0,3}\[(?!\^)[^\]]+\]:[ \t]*<?|(?:href|src)=["'])(?<target>\/[^\s"'<>)]*)/gu;
51
54
 
52
55
  /**
53
56
  * Build the link-rewrite table: every current-version route (basePath
54
- * stripped, since authors write root-absolute links as if mounted at root)
55
- * mapped to the same page's route inside the new snapshot. Only pages the
56
- * snapshot actually contains qualify — filesystem pages under the content
57
- * root. Spec-rendered references (`/api`, `/events`) and remote sources
58
- * aren't copied, so links to them keep pointing at the live pages instead of
59
- * a 404 inside the snapshot.
57
+ * stripped, since authors write root-absolute links as if mounted at root;
58
+ * a link that spells the base out is looked up without it, see
59
+ * `snapshotTarget`) mapped to the same page's route inside the new snapshot.
60
+ * Only pages the snapshot actually contains qualify — filesystem pages under
61
+ * the content root. Spec-rendered references (`/api`, `/events`) and remote
62
+ * sources aren't copied, so links to them keep pointing at the live pages
63
+ * instead of a 404 inside the snapshot.
60
64
  */
61
65
  const buildRouteRewrites = (
62
66
  project: BlumeProject,
@@ -105,8 +109,35 @@ const buildRouteRewrites = (
105
109
  return rewrites;
106
110
  };
107
111
 
112
+ /**
113
+ * The snapshot link a root-absolute path rewrites to, or undefined when the
114
+ * snapshot has no copy of its page. A path written with the base path by hand
115
+ * (`/docs/guides/x` under `basePath: "/docs"`) names the same page as
116
+ * `/guides/x` — rendering leaves an already-based link alone (see
117
+ * `withBasePath`) — so it is looked up without the base and keeps it.
118
+ */
119
+ const snapshotTarget = (
120
+ path: string,
121
+ rewrites: Map<string, string>,
122
+ basePath: string
123
+ ): string | undefined => {
124
+ const based =
125
+ basePath !== "" && (path === basePath || path.startsWith(`${basePath}/`));
126
+ if (!based) {
127
+ return rewrites.get(path);
128
+ }
129
+ const replacement = rewrites.get(stripBasePath(basePath, path));
130
+ return replacement === undefined
131
+ ? undefined
132
+ : mountBasePath(basePath, replacement);
133
+ };
134
+
108
135
  /** Rewrite one line's root-absolute internal links via the rewrite table. */
109
- const rewriteLine = (line: string, rewrites: Map<string, string>): string => {
136
+ const rewriteLine = (
137
+ line: string,
138
+ rewrites: Map<string, string>,
139
+ basePath: string
140
+ ): string => {
110
141
  const masked = line.replaceAll(INLINE_CODE, (span) =>
111
142
  " ".repeat(span.length)
112
143
  );
@@ -120,7 +151,7 @@ const rewriteLine = (line: string, rewrites: Map<string, string>): string => {
120
151
  const path = hash === -1 ? target : target.slice(0, hash);
121
152
  const suffix = hash === -1 ? "" : target.slice(hash);
122
153
  const bare = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
123
- const replacement = rewrites.get(bare);
154
+ const replacement = snapshotTarget(bare, rewrites, basePath);
124
155
  if (replacement === undefined) {
125
156
  continue;
126
157
  }
@@ -142,10 +173,12 @@ export interface SnapshotRewrite {
142
173
  * Rewrite a copied page's root-absolute internal links to their snapshot
143
174
  * equivalents, skipping fenced and inline code. Relative links need no
144
175
  * rewriting — the whole tree copies together, so they stay self-contained.
176
+ * `basePath` is the site's, for links that spell it out by hand.
145
177
  */
146
178
  export const rewriteSnapshotLinks = (
147
179
  source: string,
148
- rewrites: Map<string, string>
180
+ rewrites: Map<string, string>,
181
+ basePath = ""
149
182
  ): SnapshotRewrite => {
150
183
  let fence: FenceState = null;
151
184
  let count = 0;
@@ -156,7 +189,7 @@ export const rewriteSnapshotLinks = (
156
189
  if (inFence) {
157
190
  return line;
158
191
  }
159
- const rewrittenLine = rewriteLine(line, rewrites);
192
+ const rewrittenLine = rewriteLine(line, rewrites, basePath);
160
193
  if (rewrittenLine !== line) {
161
194
  count += 1;
162
195
  }
@@ -416,7 +449,11 @@ export const cutVersion = async (
416
449
  return;
417
450
  }
418
451
  const source = await readFile(abs, "utf-8");
419
- const { text, count } = rewriteSnapshotLinks(source, rewrites);
452
+ const { text, count } = rewriteSnapshotLinks(
453
+ source,
454
+ rewrites,
455
+ project.config.basePath
456
+ );
420
457
  if (count > 0) {
421
458
  await writeTextAtomic(abs, text);
422
459
  rewritten.push({ count, file: relative(dir, abs) });
@@ -25,6 +25,7 @@ import {
25
25
  buildSignaturesDirectory,
26
26
  SIGNATURES_DIRECTORY_PATH,
27
27
  } from "../ai/web-bot-auth.ts";
28
+ import { normalizeBasePath } from "../core/base-path.ts";
28
29
  import { discoverPagesSync } from "../core/custom-pages.ts";
29
30
  import type { BlumeProject } from "../core/project-graph.ts";
30
31
  import type { ResolvedConfig } from "../core/schema.ts";
@@ -51,10 +52,12 @@ import { buildSitemapFiles, describeSitemapFiles } from "./sitemap.ts";
51
52
  * Written from the integration's `astro:build:done` hook — Astro's channel
52
53
  * for exactly this kind of post-build work — into the directory Astro
53
54
  * reports as the client output (`dist/`, or `dist/client` for a server
54
- * build). The Vercel adapter copies that directory into its Build Output
55
- * static tree in a later hook, so the artifacts ride along; every other
56
- * adapter serves it directly. Running inside the hook rather than after
57
- * `astro build` returns means an ejected project keeps producing them.
55
+ * build, or `dist/client/<base>/` on Cloudflare, whose `_headers` goes to
56
+ * the root the platform serves above it). The Vercel adapter copies that
57
+ * directory into its Build Output static tree in a later hook, so the
58
+ * artifacts ride along; every other adapter serves it directly. Running
59
+ * inside the hook rather than after `astro build` returns means an ejected
60
+ * project keeps producing them.
58
61
  *
59
62
  * Every file yields to one the user ships in `public/` (Astro copied it into
60
63
  * the output before this runs).
@@ -88,7 +91,7 @@ const emitRedirectFiles = async (
88
91
  return;
89
92
  }
90
93
  const platform = deployPlatform(config.deployment);
91
- const redirects = platformRedirects(config);
94
+ const redirects = platformRedirects(project);
92
95
  const vercelHeaders = platform.redirectFiles.includes(VERCEL_JSON_FILE)
93
96
  ? buildVercelHeaders(
94
97
  config,
@@ -127,6 +130,24 @@ const emitRedirectFiles = async (
127
130
  logger.info(`Emitted redirect files for ${redirects.length} redirect(s)`);
128
131
  };
129
132
 
133
+ /**
134
+ * The root of the static assets the platform serves, where it reads
135
+ * `_headers`: `distDir` itself, except on a server build whose adapter moved
136
+ * the client output under `deployment.base` (`@astrojs/cloudflare` hands
137
+ * `astro:build:done` `dist/client/<base>/` but serves `dist/client`). A file
138
+ * left in `distDir` there would never be read, and would be served publicly
139
+ * at `<base>/_headers`.
140
+ */
141
+ const assetsRoot = (config: ResolvedConfig, distDir: string): string => {
142
+ const base = normalizeBasePath(config.deployment.options.base);
143
+ const nested =
144
+ config.deployment.options.output === "server" &&
145
+ deployPlatform(config.deployment).serverClientUnderBase;
146
+ // One `..` per base segment climbs back out of `dist/client/<base>/`.
147
+ const depth = base.split("/").length - 1;
148
+ return nested && base ? join(distDir, "../".repeat(depth)) : distDir;
149
+ };
150
+
130
151
  /**
131
152
  * Emit a `_headers` file so Netlify / Cloudflare serve the raw AI-ready
132
153
  * endpoints (`*.md`, `*.mdx`, `*.txt`) with an explicit `charset=utf-8`. Without
@@ -179,7 +200,7 @@ export const emitHeaderFiles = async (
179
200
  // An adapter may have written its own rules here already (Cloudflare adds an
180
201
  // immutable Cache-Control for /_astro/*). Keep them and append ours: both
181
202
  // sets are wanted, and `_headers` has no merge semantics beyond order.
182
- const target = join(distDir, "_headers");
203
+ const target = join(assetsRoot(config, distDir), "_headers");
183
204
  const existing = existsSync(target) ? await readFile(target, "utf-8") : "";
184
205
  await writeFile(
185
206
  target,
@@ -84,7 +84,7 @@ import {
84
84
  API_PAGES_PATH,
85
85
  OPENAPI_PATH,
86
86
  } from "../ai/api/paths.ts";
87
- import { normalizePath } from "../core/base-path.ts";
87
+ import { normalizeBasePath, normalizePath } from "../core/base-path.ts";
88
88
  import { CONTENT_ASSETS_ROOT, SVG_ASSET_HEADERS } from "./headers.ts";
89
89
  import type { NotFoundVariants } from "./vercel-negotiation.ts";
90
90
 
@@ -132,14 +132,6 @@ const STATIC_EXEMPTIONS = [
132
132
  API_NAVIGATION_PATH,
133
133
  ];
134
134
 
135
- /**
136
- * The deployment base as a rule/URL prefix: trailing slash stripped, empty
137
- * for a root deploy — the same normalization the dev middleware applies in
138
- * `astro/markdown-negotiation.ts`.
139
- */
140
- const basePrefix = (base?: string): string =>
141
- base && base !== "/" ? base.replace(/\/$/u, "") : "";
142
-
143
135
  /** A value as `JSON.parse` produces it (the wrangler config's value space). */
144
136
  type JsonValue =
145
137
  | string
@@ -180,7 +172,7 @@ const ruleBody = (rule: string): string =>
180
172
  * status (see the module comment).
181
173
  */
182
174
  export const buildRunWorkerFirstRules = (base?: string): string[] => {
183
- const prefix = encodeURI(basePrefix(base));
175
+ const prefix = encodeURI(normalizeBasePath(base));
184
176
  const exemptions = STATIC_EXEMPTIONS.map((path) => `!${prefix}${path}`);
185
177
  return prefix
186
178
  ? [prefix, `${prefix}/*`, ...exemptions]
@@ -282,7 +274,7 @@ export const buildNegotiationWorker = (
282
274
  const routes = JSON.stringify(options.routePaths);
283
275
  const pageJson = JSON.stringify(options.pageJsonPaths ?? []);
284
276
  const binding = JSON.stringify(options.assetsBinding);
285
- const prefix = JSON.stringify(encodeURI(basePrefix(options.base)));
277
+ const prefix = JSON.stringify(encodeURI(normalizeBasePath(options.base)));
286
278
  const homeLinkHeader = JSON.stringify(options.homeLinkHeader ?? null);
287
279
  const homeTokens = JSON.stringify(
288
280
  options.homeTokens === undefined ? null : String(options.homeTokens)
@@ -680,7 +672,7 @@ export const injectWorkerNegotiation = (
680
672
  // The content-route guard compares against redirect `from`s, which carry the
681
673
  // full `{deployment.base}{basePath}` stack; the routes carry only
682
674
  // `basePath`, so the deployment base is applied here.
683
- const deployPrefix = basePrefix(options.base);
675
+ const deployPrefix = normalizeBasePath(options.base);
684
676
  const guardRoutes = new Set(
685
677
  contentRoutePaths.map((route) =>
686
678
  normalizePath(