blume 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +126 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-74wfhv79.js → chunk-05f21r8h.js} +1 -1
  5. package/dist/cli/{chunk-9qzgg6mj.js → chunk-1cm0h819.js} +3 -3
  6. package/dist/cli/{chunk-nbyvgnnm.js → chunk-1rkxzg3n.js} +104 -47
  7. package/dist/cli/chunk-1rkxzg3n.js.map +36 -0
  8. package/dist/cli/{chunk-desvm72z.js → chunk-2nr29y95.js} +22 -26
  9. package/dist/cli/chunk-2nr29y95.js.map +13 -0
  10. package/dist/cli/{chunk-32khwcb6.js → chunk-49r370mb.js} +42 -69
  11. package/dist/cli/{chunk-32khwcb6.js.map → chunk-49r370mb.js.map} +9 -10
  12. package/dist/cli/{chunk-ms9z66nz.js → chunk-4rftmttv.js} +2 -2
  13. package/dist/cli/{chunk-s0bdnc7v.js → chunk-79bzzkx4.js} +5 -4
  14. package/dist/cli/chunk-79bzzkx4.js.map +10 -0
  15. package/dist/cli/{chunk-hvcxng5f.js → chunk-7tekknev.js} +6 -6
  16. package/dist/cli/{chunk-dv2z0rzf.js → chunk-8b489v1b.js} +38 -13
  17. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-8b489v1b.js.map} +5 -4
  18. package/dist/cli/{chunk-0pmb4tr4.js → chunk-8f8xw4x2.js} +1195 -222
  19. package/dist/cli/chunk-8f8xw4x2.js.map +44 -0
  20. package/dist/cli/chunk-96snx8yy.js +329 -0
  21. package/dist/cli/chunk-96snx8yy.js.map +13 -0
  22. package/dist/cli/{chunk-ckrcm08h.js → chunk-a3ydrqny.js} +48 -62
  23. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-a3ydrqny.js.map} +4 -5
  24. package/dist/cli/{chunk-bxwhtvw8.js → chunk-ahc5gc9h.js} +5 -5
  25. package/dist/cli/{chunk-dpy8qdjf.js → chunk-as09pafp.js} +73 -182
  26. package/dist/cli/chunk-as09pafp.js.map +18 -0
  27. package/dist/cli/{chunk-tfycddbt.js → chunk-bjk964fp.js} +2 -2
  28. package/dist/cli/{chunk-gfc5fqvf.js → chunk-br0n8n3e.js} +18 -18
  29. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-br0n8n3e.js.map} +1 -1
  30. package/dist/cli/{chunk-297ajdcv.js → chunk-c69tw29j.js} +6 -6
  31. package/dist/cli/{chunk-297ajdcv.js.map → chunk-c69tw29j.js.map} +3 -3
  32. package/dist/cli/chunk-cn3dy3hz.js +146 -0
  33. package/dist/cli/chunk-cn3dy3hz.js.map +11 -0
  34. package/dist/cli/{chunk-124fvmzq.js → chunk-dm4mg9bj.js} +11064 -8742
  35. package/dist/cli/{chunk-124fvmzq.js.map → chunk-dm4mg9bj.js.map} +78 -64
  36. package/dist/cli/{chunk-dqhg54mr.js → chunk-dmx6cezx.js} +1 -1
  37. package/dist/cli/{chunk-60hz265r.js → chunk-dpcahcrb.js} +24 -11
  38. package/dist/cli/chunk-dpcahcrb.js.map +10 -0
  39. package/dist/cli/{chunk-a57yh0h9.js → chunk-dyk3r7jw.js} +7 -7
  40. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-dyk3r7jw.js.map} +1 -1
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-enk76qd5.js} +14 -10
  42. package/dist/cli/chunk-enk76qd5.js.map +10 -0
  43. package/dist/cli/{chunk-k3gr1nm9.js → chunk-fqy4n3q2.js} +13 -16
  44. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-fqy4n3q2.js.map} +3 -3
  45. package/dist/cli/{chunk-sc93apfs.js → chunk-frk2wdq1.js} +17 -17
  46. package/dist/cli/{chunk-sc93apfs.js.map → chunk-frk2wdq1.js.map} +2 -2
  47. package/dist/cli/{chunk-kdpzw38a.js → chunk-gprwej7h.js} +95 -7
  48. package/dist/cli/chunk-gprwej7h.js.map +11 -0
  49. package/dist/cli/chunk-kkrteb15.js +157 -0
  50. package/dist/cli/chunk-kkrteb15.js.map +10 -0
  51. package/dist/cli/{chunk-ave1wcbr.js → chunk-m9bp2hc1.js} +1 -1
  52. package/dist/cli/{chunk-58bt3hag.js → chunk-mrvh4c88.js} +4 -4
  53. package/dist/cli/{chunk-5cmts6dv.js → chunk-p8zg3a2h.js} +7 -7
  54. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-p8zg3a2h.js.map} +1 -1
  55. package/dist/cli/{chunk-0w6hs479.js → chunk-rkqae7kh.js} +30 -18
  56. package/dist/cli/chunk-rkqae7kh.js.map +10 -0
  57. package/dist/cli/{chunk-bqxaqn5p.js → chunk-t5xx74rd.js} +8 -8
  58. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-t5xx74rd.js.map} +1 -1
  59. package/dist/cli/{chunk-1f882900.js → chunk-th0pgaj5.js} +2 -2
  60. package/dist/cli/{chunk-dhwtqnnd.js → chunk-vnbvhgmd.js} +2 -2
  61. package/dist/cli/{chunk-gxtg6ed2.js → chunk-vzy9rh6y.js} +21 -21
  62. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-vzy9rh6y.js.map} +3 -3
  63. package/dist/cli/{chunk-j1x92mx9.js → chunk-w6e455pz.js} +1 -1
  64. package/dist/cli/chunk-wehv26ry.js +155 -0
  65. package/dist/cli/chunk-wehv26ry.js.map +11 -0
  66. package/dist/cli/{chunk-y4msrwm9.js → chunk-xgq4eytj.js} +15 -15
  67. package/dist/cli/{chunk-36bxs9c7.js → chunk-xt0qvz1t.js} +27 -23
  68. package/dist/cli/chunk-xt0qvz1t.js.map +10 -0
  69. package/dist/cli/index.js +78 -42
  70. package/dist/cli/index.js.map +7 -6
  71. package/dist/types/ai/changelog-components.d.ts +18 -0
  72. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  73. package/dist/types/ai/openapi-components.d.ts +7 -7
  74. package/dist/types/ai/operation-details.d.ts +39 -0
  75. package/dist/types/ai/relative-links.d.ts +3 -1
  76. package/dist/types/ai/serializers.d.ts +6 -4
  77. package/dist/types/analytics/head.d.ts +4 -2
  78. package/dist/types/analytics/script.d.ts +8 -2
  79. package/dist/types/components/content/youtube.d.ts +20 -0
  80. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  81. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  82. package/dist/types/components/openapi/helpers.d.ts +53 -11
  83. package/dist/types/components/openapi/snippets.d.ts +7 -1
  84. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  85. package/dist/types/core/builtin-tags.d.ts +16 -0
  86. package/dist/types/core/changelog-list.d.ts +48 -0
  87. package/dist/types/core/closest-match.d.ts +6 -0
  88. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  89. package/dist/types/core/code-fences.d.ts +8 -1
  90. package/dist/types/core/config-input.d.ts +1 -1
  91. package/dist/types/core/content-assets.d.ts +16 -18
  92. package/dist/types/core/diagnostics.d.ts +14 -0
  93. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  94. package/dist/types/core/former-routes.d.ts +14 -0
  95. package/dist/types/core/github.d.ts +5 -4
  96. package/dist/types/core/heading-markers.d.ts +65 -2
  97. package/dist/types/core/hidden-pages.d.ts +3 -0
  98. package/dist/types/core/includes.d.ts +23 -0
  99. package/dist/types/core/last-modified.d.ts +18 -6
  100. package/dist/types/core/links.d.ts +42 -2
  101. package/dist/types/core/load-module.d.ts +19 -4
  102. package/dist/types/core/manifest.d.ts +14 -1
  103. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  104. package/dist/types/core/mdx-elements.d.ts +32 -0
  105. package/dist/types/core/mdx-syntax.d.ts +100 -0
  106. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  107. package/dist/types/core/meta.d.ts +32 -5
  108. package/dist/types/core/nav-diagnostics.d.ts +9 -2
  109. package/dist/types/core/navigation.d.ts +13 -0
  110. package/dist/types/core/ordering-prefix.d.ts +6 -0
  111. package/dist/types/core/pinned-ids.d.ts +23 -0
  112. package/dist/types/core/project-graph.d.ts +9 -1
  113. package/dist/types/core/redirect-patterns.d.ts +18 -0
  114. package/dist/types/core/relative-files.d.ts +31 -0
  115. package/dist/types/core/schema.d.ts +267 -7
  116. package/dist/types/core/sources/normalize.d.ts +101 -9
  117. package/dist/types/core/sources/types.d.ts +16 -0
  118. package/dist/types/core/static-files.d.ts +10 -0
  119. package/dist/types/core/static-host.d.ts +42 -0
  120. package/dist/types/core/svg-images.d.ts +26 -0
  121. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  122. package/dist/types/core/types.d.ts +42 -1
  123. package/dist/types/deploy/generated-files.d.ts +41 -0
  124. package/dist/types/deploy/redirects.d.ts +21 -7
  125. package/dist/types/markdown/code-title.d.ts +9 -5
  126. package/dist/types/markdown/directives.d.ts +8 -0
  127. package/dist/types/markdown/fence-language.d.ts +51 -0
  128. package/dist/types/markdown/fence-meta.d.ts +5 -0
  129. package/dist/types/markdown/github-alerts.d.ts +35 -0
  130. package/dist/types/openapi/asyncapi.d.ts +6 -5
  131. package/dist/types/openapi/checks.d.ts +10 -5
  132. package/dist/types/openapi/model.d.ts +55 -8
  133. package/dist/types/openapi/parse.d.ts +4 -1
  134. package/dist/types/sources/filesystem.d.ts +14 -4
  135. package/dist/types/sources/mdx-remote.d.ts +6 -3
  136. package/dist/types/sources/registry.d.ts +3 -3
  137. package/dist/types/theme/color.d.ts +33 -0
  138. package/dist/types/theme/palette.d.ts +122 -0
  139. package/docs/01-quickstart.mdx +1 -1
  140. package/docs/02-deployment.mdx +23 -8
  141. package/docs/04-migrating.mdx +16 -4
  142. package/docs/advanced/changelog.mdx +27 -1
  143. package/docs/advanced/skills.mdx +1 -1
  144. package/docs/cli/audit.mdx +1 -1
  145. package/docs/cli/doctor.mdx +2 -2
  146. package/docs/cli/evals.mdx +1 -1
  147. package/docs/cli/index.mdx +4 -2
  148. package/docs/cli/validate.mdx +10 -7
  149. package/docs/configuration/analytics.mdx +2 -0
  150. package/docs/configuration/customization.mdx +3 -1
  151. package/docs/configuration/index.mdx +2 -2
  152. package/docs/configuration/theming.mdx +5 -1
  153. package/docs/content/components.mdx +26 -6
  154. package/docs/content/frontmatter.mdx +1 -1
  155. package/docs/content/includes.mdx +6 -2
  156. package/docs/content/index.mdx +11 -1
  157. package/docs/content/islands.mdx +2 -0
  158. package/docs/content/meta.mdx +9 -3
  159. package/docs/content/navigation.mdx +14 -6
  160. package/docs/content/sources/obsidian.mdx +1 -1
  161. package/docs/content/sources/remote-mdx.mdx +3 -1
  162. package/docs/content/syntax.mdx +76 -8
  163. package/docs/content/variables.mdx +3 -1
  164. package/docs/discoverability/agent-discovery.mdx +1 -1
  165. package/docs/discoverability/json-api.mdx +1 -1
  166. package/docs/discoverability/llms-txt.mdx +1 -1
  167. package/docs/discoverability/markdown.mdx +2 -2
  168. package/docs/discoverability/open-graph.mdx +1 -1
  169. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  170. package/docs/references/graphql.mdx +2 -0
  171. package/docs/references/openapi.mdx +45 -7
  172. package/package.json +4 -4
  173. package/skills/blume/SKILL.md +2 -2
  174. package/skills/blume-migrate/SKILL.md +49 -27
  175. package/skills/blume-migrate/references/docsify.md +262 -0
  176. package/skills/blume-migrate/references/docus.md +298 -0
  177. package/skills/blume-migrate/references/docusaurus.md +3 -3
  178. package/skills/blume-migrate/references/fern.md +327 -0
  179. package/skills/blume-migrate/references/fumadocs.md +1 -1
  180. package/skills/blume-migrate/references/gitbook.md +267 -0
  181. package/skills/blume-migrate/references/github-wiki.md +259 -0
  182. package/skills/blume-migrate/references/jekyll.md +253 -0
  183. package/skills/blume-migrate/references/mdbook.md +256 -0
  184. package/skills/blume-migrate/references/mintlify.md +5 -5
  185. package/skills/blume-migrate/references/mkdocs.md +236 -0
  186. package/skills/blume-migrate/references/monorepo.md +20 -8
  187. package/skills/blume-migrate/references/nextra.md +2 -2
  188. package/skills/blume-migrate/references/readme.md +257 -0
  189. package/skills/blume-migrate/references/redocly.md +198 -0
  190. package/skills/blume-migrate/references/starlight.md +3 -3
  191. package/skills/blume-migrate/references/vitepress.md +204 -0
  192. package/skills/blume-migrate/references/vuepress.md +271 -0
  193. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  194. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  195. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  196. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  197. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  198. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  199. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  200. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  201. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  202. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  203. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  204. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  205. package/src/ai/changelog-components.ts +24 -0
  206. package/src/ai/changelog-markdown.ts +45 -41
  207. package/src/ai/component-markdown.ts +22 -9
  208. package/src/ai/llms.ts +36 -5
  209. package/src/ai/markdown.ts +2 -2
  210. package/src/ai/openapi-components.ts +25 -9
  211. package/src/ai/operation-details.ts +208 -0
  212. package/src/ai/relative-links.ts +48 -8
  213. package/src/ai/serializers.ts +7 -6
  214. package/src/ai/site-skill.ts +6 -2
  215. package/src/analytics/head.ts +11 -5
  216. package/src/analytics/script.ts +13 -3
  217. package/src/astro/generate.ts +32 -6
  218. package/src/astro/index.ts +1 -0
  219. package/src/astro/integration.ts +27 -1
  220. package/src/astro/mdx-source-errors.ts +46 -0
  221. package/src/astro/templates.ts +89 -185
  222. package/src/audit/checks/redirects.ts +5 -1
  223. package/src/audit/redirects.ts +36 -19
  224. package/src/audit/report.ts +22 -9
  225. package/src/audit/run.ts +12 -28
  226. package/src/audit/terms.ts +23 -0
  227. package/src/audit/url.ts +2 -2
  228. package/src/cli/build-failure.ts +256 -7
  229. package/src/cli/command-meta.ts +1 -1
  230. package/src/cli/commands/audit.ts +1 -1
  231. package/src/cli/commands/build.ts +12 -3
  232. package/src/cli/commands/check.ts +13 -2
  233. package/src/cli/commands/dev.ts +1 -1
  234. package/src/cli/commands/doctor.ts +4 -2
  235. package/src/cli/commands/init.ts +3 -3
  236. package/src/cli/commands/preview.ts +17 -1
  237. package/src/cli/commands/validate.ts +22 -10
  238. package/src/cli/prepare.ts +1 -1
  239. package/src/cli/preview-pages.ts +161 -0
  240. package/src/cli/unknown-flags.ts +1 -42
  241. package/src/components/code-copy.ts +51 -0
  242. package/src/components/code-language.ts +75 -0
  243. package/src/components/content/AccordionItem.astro +16 -1
  244. package/src/components/content/Callout.astro +11 -1
  245. package/src/components/content/Changelog.astro +189 -0
  246. package/src/components/content/CodeBlock.astro +8 -11
  247. package/src/components/content/Tabs.astro +16 -2
  248. package/src/components/content/YouTube.astro +6 -4
  249. package/src/components/content/code-block.ts +33 -0
  250. package/src/components/content/content-strings.ts +5 -1
  251. package/src/components/content/inline-markdown.ts +35 -8
  252. package/src/components/content/youtube.ts +49 -7
  253. package/src/components/layout/Analytics.astro +5 -1
  254. package/src/components/layout/NavTree.astro +8 -3
  255. package/src/components/layout/RootLayout.astro +18 -31
  256. package/src/components/layout/nav-utils.ts +14 -0
  257. package/src/components/layout/search/pagefind.ts +2 -1
  258. package/src/components/openapi/Operation.astro +4 -0
  259. package/src/components/openapi/PanelTabs.astro +9 -3
  260. package/src/components/openapi/RequestBody.astro +42 -8
  261. package/src/components/openapi/RequestPanel.astro +34 -53
  262. package/src/components/openapi/example-panels.ts +110 -0
  263. package/src/components/openapi/helpers.ts +195 -20
  264. package/src/components/openapi/operation-model.ts +63 -26
  265. package/src/components/openapi/request.ts +13 -0
  266. package/src/components/openapi/snippets.ts +14 -2
  267. package/src/core/alert-diagnostics.ts +89 -0
  268. package/src/core/builtin-tags.ts +76 -0
  269. package/src/core/changelog-list.ts +87 -0
  270. package/src/core/closest-match.ts +41 -0
  271. package/src/core/code-fence-diagnostics.ts +267 -0
  272. package/src/core/code-fences.ts +43 -7
  273. package/src/core/component-diagnostics.ts +65 -12
  274. package/src/core/config-input.ts +1 -1
  275. package/src/core/config.ts +46 -1
  276. package/src/core/content-assets.ts +64 -144
  277. package/src/core/diagnostics.ts +36 -3
  278. package/src/core/directive-diagnostics.ts +265 -47
  279. package/src/core/former-routes.ts +46 -0
  280. package/src/core/git-ignored.ts +53 -0
  281. package/src/core/github.ts +5 -4
  282. package/src/core/heading-markers.ts +156 -2
  283. package/src/core/hidden-pages.ts +64 -0
  284. package/src/core/includes.ts +181 -41
  285. package/src/core/last-modified.ts +47 -10
  286. package/src/core/links.ts +438 -83
  287. package/src/core/load-module.ts +98 -9
  288. package/src/core/manifest.ts +12 -9
  289. package/src/core/markdown-mirrors.ts +52 -0
  290. package/src/core/mdx-elements.ts +153 -0
  291. package/src/core/mdx-syntax.ts +377 -0
  292. package/src/core/meta-diagnostics.ts +134 -0
  293. package/src/core/meta.ts +154 -55
  294. package/src/core/nav-diagnostics.ts +16 -4
  295. package/src/core/navigation.ts +70 -10
  296. package/src/core/ordering-prefix.ts +22 -2
  297. package/src/core/pinned-ids.ts +141 -0
  298. package/src/core/project-graph.ts +221 -29
  299. package/src/core/redirect-patterns.ts +41 -0
  300. package/src/core/relative-files.ts +145 -0
  301. package/src/core/schema.ts +17 -3
  302. package/src/core/sources/mdx-remote.ts +75 -14
  303. package/src/core/sources/normalize.ts +885 -179
  304. package/src/core/sources/notion.ts +5 -4
  305. package/src/core/sources/obsidian.ts +2 -5
  306. package/src/core/sources/types.ts +17 -0
  307. package/src/core/static-files.ts +40 -0
  308. package/src/core/static-host.ts +125 -0
  309. package/src/core/svg-images.ts +98 -0
  310. package/src/core/syntax-diagnostics.ts +491 -0
  311. package/src/core/types.ts +43 -1
  312. package/src/deploy/artifacts.ts +2 -28
  313. package/src/deploy/generated-files.ts +128 -0
  314. package/src/deploy/platforms/vercel.ts +25 -1
  315. package/src/deploy/redirects.ts +43 -16
  316. package/src/deploy/sitemap.ts +2 -1
  317. package/src/markdown/base-links.ts +87 -6
  318. package/src/markdown/code-title.ts +29 -13
  319. package/src/markdown/content-assets.ts +210 -0
  320. package/src/markdown/directives.ts +69 -5
  321. package/src/markdown/fence-language.ts +106 -0
  322. package/src/markdown/fence-meta.ts +42 -0
  323. package/src/markdown/github-alerts.ts +172 -0
  324. package/src/markdown/heading-anchors.ts +265 -56
  325. package/src/markdown/include.ts +30 -3
  326. package/src/markdown/index.ts +145 -40
  327. package/src/markdown/prompt-text.ts +95 -0
  328. package/src/markdown/relative-links.ts +37 -2
  329. package/src/markdown/route-snapshot.ts +22 -12
  330. package/src/markdown/svg-fallback.ts +51 -0
  331. package/src/markdown/title-heading.ts +134 -0
  332. package/src/migrate/migrate.ts +127 -2
  333. package/src/og/card.ts +14 -4
  334. package/src/openapi/asyncapi.ts +14 -6
  335. package/src/openapi/checks.ts +160 -15
  336. package/src/openapi/model.ts +172 -33
  337. package/src/openapi/parse.ts +13 -4
  338. package/src/openapi/render-mdx.ts +55 -9
  339. package/src/openapi/source.ts +87 -6
  340. package/src/registry/eject.ts +0 -1
  341. package/src/search/documents.ts +10 -2
  342. package/src/sources/filesystem.ts +28 -3
  343. package/src/sources/mdx-remote.ts +7 -15
  344. package/src/theme/color.ts +14 -0
  345. package/src/theme/entry.ts +22 -1
  346. package/src/theme/palette.ts +74 -1
  347. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  348. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  349. package/dist/cli/chunk-150jk7km.js +0 -52
  350. package/dist/cli/chunk-150jk7km.js.map +0 -10
  351. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  352. package/dist/cli/chunk-53jqw8z0.js +0 -89
  353. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  354. package/dist/cli/chunk-60hz265r.js.map +0 -10
  355. package/dist/cli/chunk-desvm72z.js.map +0 -14
  356. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  357. package/dist/cli/chunk-es8h0v3j.js +0 -441
  358. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  359. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  360. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  361. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  362. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  363. package/dist/cli/chunk-ydp3065p.js +0 -64
  364. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  365. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-05f21r8h.js.map} +0 -0
  366. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-1cm0h819.js.map} +0 -0
  367. /package/dist/cli/{chunk-ms9z66nz.js.map → chunk-4rftmttv.js.map} +0 -0
  368. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-7tekknev.js.map} +0 -0
  369. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-ahc5gc9h.js.map} +0 -0
  370. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-bjk964fp.js.map} +0 -0
  371. /package/dist/cli/{chunk-dqhg54mr.js.map → chunk-dmx6cezx.js.map} +0 -0
  372. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-m9bp2hc1.js.map} +0 -0
  373. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-mrvh4c88.js.map} +0 -0
  374. /package/dist/cli/{chunk-1f882900.js.map → chunk-th0pgaj5.js.map} +0 -0
  375. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-vnbvhgmd.js.map} +0 -0
  376. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-w6e455pz.js.map} +0 -0
  377. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-xgq4eytj.js.map} +0 -0
package/src/core/links.ts CHANGED
@@ -1,16 +1,23 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
 
3
3
  import { basename, dirname, join, normalize, relative, resolve } from "pathe";
4
4
 
5
- import { stripBasePath, withBasePath } from "./base-path.ts";
6
- import {
7
- isRelativeImageTarget,
8
- resolveRelativeImage,
9
- } from "./content-assets.ts";
5
+ import { isInternalPath, stripBasePath, withBasePath } from "./base-path.ts";
6
+ import { locatePath } from "./diagnostics.ts";
10
7
  import type { LocaleRouting } from "./i18n.ts";
11
8
  import { localizeLinkPath } from "./locale-links.ts";
12
9
  import { gradeExternal, probeAll } from "./probe.ts";
13
- import { isPatternPath, pathsUnderPattern } from "./redirect-patterns.ts";
10
+ import {
11
+ destinationPrefix,
12
+ isPatternPath,
13
+ pathsUnderPattern,
14
+ } from "./redirect-patterns.ts";
15
+ import {
16
+ isRelativeImageTarget,
17
+ resolveRelativeFile,
18
+ resolveRelativeImage,
19
+ } from "./relative-files.ts";
20
+ import { staticFileResolver } from "./static-files.ts";
14
21
  import type {
15
22
  ContentGraph,
16
23
  Diagnostic,
@@ -22,6 +29,22 @@ const HTTP = /^https?:\/\//iu;
22
29
  const PROTOCOL_RELATIVE = /^\/\//u;
23
30
  const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
24
31
 
32
+ /**
33
+ * Link schemes only one docs platform understands: ReadMe resolves
34
+ * `doc:slug`, `ref:slug`, `page:slug`, `changelog:slug`, and `blog:slug` to
35
+ * its own pages, but no browser or app handles them, so each ships as a dead
36
+ * `href`. A denylist, not an allowlist of web schemes: an app's deep link
37
+ * (`vscode:`, `cursor://`, `slack://`) is as real a link as `mailto:`, and
38
+ * which apps a reader has installed can't be known here.
39
+ */
40
+ const PLATFORM_SCHEMES = new Set([
41
+ "blog:",
42
+ "changelog:",
43
+ "doc:",
44
+ "page:",
45
+ "ref:",
46
+ ]);
47
+
25
48
  /** Percent-decode a link piece; malformed sequences stay verbatim. */
26
49
  const decodePercent = (value: string): string => {
27
50
  try {
@@ -78,6 +101,9 @@ interface LinkContext {
78
101
  anchors: Map<string, Set<string>>;
79
102
  /** Site-wide route mount point (`""` or `/seg`); routes carry it, assets don't. */
80
103
  basePath: string;
104
+ /** The route a root-relative link to a content file (`/guide/new.md`)
105
+ * lands on, when it names one (see `resolveRootFileHref`). */
106
+ rootFileRoute: (href: string) => string | undefined;
81
107
  /** Servable routes outside the graph (custom pages, generated routes); their
82
108
  * headings are unknown, so anchors there are accepted unchecked. */
83
109
  extraRoutes: Set<string>;
@@ -85,20 +111,18 @@ interface LinkContext {
85
111
  fileRoutes: FileRouteIndex;
86
112
  /** Locale routing, when the site is multi-locale; drives served-route resolution. */
87
113
  i18n: LocaleRouting | null;
88
- publicDir: string | null;
89
114
  /** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
90
115
  redirects: Set<string>;
91
116
  /** Pattern `redirect.from`s (`/beta/:slug*`), based like `redirects`. */
92
117
  redirectPatterns: string[];
93
118
  routes: Set<string>;
119
+ /** Whether a base-less path is a `public/` file (a folder's `index.html`
120
+ * included) or a file Blume generates (see `staticFileResolver`). */
121
+ servesFile: (path: string) => boolean;
94
122
  }
95
123
 
96
- /** Whether a resolved asset path exists under `public/`. */
97
- const assetIsPresent = (resolved: string, ctx: LinkContext): boolean =>
98
- ctx.publicDir !== null && existsSync(join(ctx.publicDir, resolved));
99
-
100
124
  /** Outcome of classifying one link target. */
101
- type LinkResult = Diagnostic | "asset-unchecked" | null;
125
+ type LinkResult = Diagnostic | null;
102
126
 
103
127
  // Mirrors the ordering-prefix strip in `sources/normalize.ts`: route mapping
104
128
  // drops the prefix before recognizing `index`, so `01-index.mdx` is an index.
@@ -173,6 +197,10 @@ export interface RelativeLinkBase {
173
197
  /** A root-relative path, a bare `#fragment` or `?query`, or any scheme. */
174
198
  const NOT_RELATIVE = /^(?:[#?/]|[a-z][a-z0-9+.-]*:)/iu;
175
199
 
200
+ /** The same file under the other Markdown extension: `x.md` ↔ `x.mdx`. */
201
+ const otherDocExtension = (path: string): string =>
202
+ /x$/iu.test(path) ? path.slice(0, -1) : `${path}x`;
203
+
176
204
  /**
177
205
  * Where a relative page link written on `from` lands: a root-relative route
178
206
  * with the authored `?query#hash` kept — or `undefined` when `href` isn't a
@@ -188,7 +216,9 @@ const NOT_RELATIVE = /^(?:[#?/]|[a-z][a-z0-9+.-]*:)/iu;
188
216
  * `/install`. A `.md`/`.mdx` target is a file link: `resolveFile` maps its
189
217
  * decoded path, relative to the linking file, to the route that file
190
218
  * publishes at, so a target with its own `slug` or an ordering prefix still
191
- * lands on its page instead of on its raw Markdown source. Without
219
+ * lands on its page instead of on its raw Markdown source. A file renamed
220
+ * from `.md` to `.mdx`, or back, is found under its other extension first,
221
+ * so a link written before the rename keeps that slug or prefix. Without
192
222
  * `resolveFile`, or for a file it doesn't know, the target resolves
193
223
  * route-relative with the extension dropped.
194
224
  *
@@ -212,12 +242,52 @@ export const resolveRelativeHref = (
212
242
  const route = toRoute(resolveRelative(from.route, path, from.isIndex));
213
243
  return hasRoute?.(decodePercent(route)) ? `${route}${suffix}` : undefined;
214
244
  }
245
+ const file = decodePercent(path);
215
246
  const fileRoute = DOC_EXT.test(path)
216
- ? resolveFile?.(decodePercent(path))
247
+ ? (resolveFile?.(file) ?? resolveFile?.(otherDocExtension(file)))
217
248
  : undefined;
218
249
  return `${fileRoute ?? toRoute(resolveRelative(from.route, path, from.isIndex))}${suffix}`;
219
250
  };
220
251
 
252
+ /**
253
+ * Where a root-relative link to a content file (`/guide/new.md`, the way
254
+ * VitePress, Docsify, and TypeDoc write one) lands: the route of the source
255
+ * file it names under the content root, with the authored `?query#hash`
256
+ * kept, or `undefined` when it names no source file (or isn't a root-relative
257
+ * `.md`/`.mdx` link at all). It's read as written from the content root, or,
258
+ * when it starts with `deployment.base`, as including the base (see
259
+ * `withBasePath`). `routeOfFile` maps an absolute source path to the route
260
+ * that file publishes at. A link that names no source file is left alone:
261
+ * `/guide/new.md` is also the URL of a page's Markdown copy, which a link may
262
+ * mean on purpose.
263
+ */
264
+ export const resolveRootFileHref = (
265
+ href: string,
266
+ options: {
267
+ contentRoot: string;
268
+ deployBase: string;
269
+ routeOfFile: (file: string) => string | undefined;
270
+ }
271
+ ): string | undefined => {
272
+ if (!isInternalPath(href)) {
273
+ return undefined;
274
+ }
275
+ const suffixAt = href.search(/[?#]/u);
276
+ const path = suffixAt === -1 ? href : href.slice(0, suffixAt);
277
+ if (!DOC_EXT.test(path)) {
278
+ return undefined;
279
+ }
280
+ const route = options.routeOfFile(
281
+ join(
282
+ options.contentRoot,
283
+ stripBasePath(options.deployBase, decodePercent(path))
284
+ )
285
+ );
286
+ return route === undefined
287
+ ? undefined
288
+ : `${route}${suffixAt === -1 ? "" : href.slice(suffixAt)}`;
289
+ };
290
+
221
291
  /**
222
292
  * Whether a content file is its folder's index, from its name as written on
223
293
  * disk: `index.md(x)` after an ordering prefix, optionally carrying one of
@@ -300,6 +370,124 @@ const servedRoute = (
300
370
  })
301
371
  : authored;
302
372
 
373
+ /**
374
+ * The fix for a relative link to a content file that exists but is a
375
+ * partial: a name starting with `_`, or a file under such a folder, which
376
+ * the default `content.exclude` leaves unpublished. Undefined for any other
377
+ * link, whose file is missing or was left out for another reason.
378
+ */
379
+ const partialLinkHint = (
380
+ link: PageLink,
381
+ page: PageRecord
382
+ ): string | undefined => {
383
+ const path = decodePercent(link.target.split(/[?#]/u)[0] ?? "");
384
+ if (
385
+ link.raw ||
386
+ !page.sourcePath ||
387
+ path.startsWith("/") ||
388
+ !DOC_EXT.test(path) ||
389
+ !path.split("/").some((segment) => segment.startsWith("_")) ||
390
+ !existsSync(resolve(dirname(page.sourcePath), path))
391
+ ) {
392
+ return undefined;
393
+ }
394
+ return `${path} exists, but a name starting with "_" marks a partial, which isn't published as a page. Splice it in with <include> instead, rename it to publish it, or add "!**/_*" to content.exclude to publish every underscore file.`;
395
+ };
396
+
397
+ const HTML_EXT = /\.html?$/iu;
398
+
399
+ /**
400
+ * The page an `.html` link means, when one is served there: `/guides/setup`
401
+ * for `/guides/setup.html`, `/guides` for `/guides/index.html`. A page is
402
+ * served at its route, never at an `.html` URL (only a `public/` file is),
403
+ * so the link 404s, and this is the route to link instead. Resolved like
404
+ * any page link: based unless raw, then moved into the reader's locale.
405
+ */
406
+ const htmlPageRoute = (
407
+ resolved: string,
408
+ page: PageRecord,
409
+ link: PageLink,
410
+ ctx: LinkContext
411
+ ): string | undefined => {
412
+ if (!HTML_EXT.test(resolved)) {
413
+ return undefined;
414
+ }
415
+ const path = resolved.replace(HTML_EXT, "");
416
+ const authored = toRoute(link.raw ? path : withBasePath(ctx.basePath, path));
417
+ const route = servedRoute(authored, page, ctx);
418
+ return ctx.routes.has(route) || ctx.extraRoutes.has(route)
419
+ ? authored
420
+ : undefined;
421
+ };
422
+
423
+ /**
424
+ * Validate a link to a file (`./spec.pdf`, `/logo.png`, `![](./a.png)`) that
425
+ * no route, redirect, or public file answers: an image embed or a link
426
+ * beside the page, or else a missing file.
427
+ */
428
+ const checkFileLink = (
429
+ resolved: string,
430
+ assetPath: string,
431
+ page: PageRecord,
432
+ link: PageLink,
433
+ site: LinkSite,
434
+ ctx: LinkContext,
435
+ via: string
436
+ ): LinkResult => {
437
+ // A colocated image embed (`![](./diagram.png)`) is resolved from beside
438
+ // the page source and emitted to `_astro/` by the image pipeline, so it
439
+ // never lands in `public/`. The *raw* target goes through the same
440
+ // resolver that decides what the `/blume-assets/content` endpoint serves,
441
+ // so a reference the rewriter skips (a `?v=2` suffix, a double-encoded
442
+ // name) is reported here, not accepted.
443
+ if (link.image && page.sourcePath && isRelativeImageTarget(link.target)) {
444
+ if (resolveRelativeImage(dirname(page.sourcePath), link.target)) {
445
+ return null;
446
+ }
447
+ // A partial-origin image was rebased into the page's directory for
448
+ // resolution but reports against the partial — cite the path as
449
+ // authored there, so file, path, and "next to" agree.
450
+ const authored = link.file
451
+ ? authoredImageTarget(link.target, page.sourcePath, link.file)
452
+ : link.target;
453
+ return {
454
+ ...site,
455
+ code: "BLUME_BROKEN_ASSET",
456
+ message: `Image ${authored} was not found next to ${basename(link.file ?? page.sourcePath)}.`,
457
+ severity: "warning",
458
+ suggestion: "Add the file next to the page source or fix the reference.",
459
+ };
460
+ }
461
+ // A relative link to any other file beside the page (`./spec.pdf`) is
462
+ // published with it and pointed at its served copy, by the same resolver
463
+ // (see `core/content-assets.ts`); an image embed of one isn't.
464
+ if (
465
+ !link.image &&
466
+ page.sourcePath &&
467
+ resolveRelativeFile(dirname(page.sourcePath), link.target)
468
+ ) {
469
+ return null;
470
+ }
471
+ const pageRoute = htmlPageRoute(resolved, page, link, ctx);
472
+ if (pageRoute !== undefined) {
473
+ return {
474
+ ...site,
475
+ code: "BLUME_BROKEN_ASSET",
476
+ message: `Link ${link.target}${via} points at ${assetPath}, which public/ doesn't have, and pages aren't served at .html URLs.`,
477
+ severity: "warning",
478
+ suggestion: `Link the page at ${pageRoute} instead.`,
479
+ };
480
+ }
481
+ // With no `public/` folder at all, the site ships no such file either.
482
+ return {
483
+ ...site,
484
+ code: "BLUME_BROKEN_ASSET",
485
+ message: `Asset ${assetPath} was not found in the public directory.`,
486
+ severity: "warning",
487
+ suggestion: `Add the file at public${assetPath} or fix the link.`,
488
+ };
489
+ };
490
+
303
491
  /** Validate a resolved internal path: asset, route, then optional anchor. */
304
492
  const checkPathLink = (
305
493
  resolved: string,
@@ -342,58 +530,70 @@ const checkPathLink = (
342
530
  }
343
531
 
344
532
  // Assets live in `public/` at the site root, unaffected by the base, so strip
345
- // it back off before probing the filesystem.
533
+ // it back off before probing the filesystem. Blume's own generated files
534
+ // (`/llms.txt`, `/sitemap.xml`) sit beside them, and a `public/` folder
535
+ // with an `index.html` is served at its path, as a static host serves it.
346
536
  const assetPath = stripBasePath(ctx.basePath, resolved);
537
+ if (ctx.servesFile(assetPath)) {
538
+ return null;
539
+ }
347
540
  if (FILE_EXT.test(assetPath) && !DOC_EXT.test(assetPath)) {
348
- if (assetIsPresent(assetPath, ctx)) {
349
- return null;
350
- }
351
- // A colocated image embed (`![](./diagram.png)`) is resolved from beside
352
- // the page source and emitted to `_astro/` by the image pipeline, so it
353
- // never lands in `public/`. The *raw* target goes through the same
354
- // resolver that decides what the `/blume-assets/content` endpoint serves,
355
- // so a reference the rewriter skips (a `?v=2` suffix, a double-encoded
356
- // name) is reported here, not accepted. Plain links to the same path stay
357
- // on the public-dir probe — an href resolves as a site route, and only
358
- // image nodes are rewritten.
359
- if (link.image && page.sourcePath && isRelativeImageTarget(link.target)) {
360
- if (resolveRelativeImage(dirname(page.sourcePath), link.target)) {
361
- return null;
362
- }
363
- // A partial-origin image was rebased into the page's directory for
364
- // resolution but reports against the partial — cite the path as
365
- // authored there, so file, path, and "next to" agree.
366
- const authored = link.file
367
- ? authoredImageTarget(link.target, page.sourcePath, link.file)
368
- : link.target;
369
- return {
370
- ...site,
371
- code: "BLUME_BROKEN_ASSET",
372
- message: `Image ${authored} was not found next to ${basename(link.file ?? page.sourcePath)}.`,
373
- severity: "warning",
374
- suggestion:
375
- "Add the file next to the page source or fix the reference.",
376
- };
377
- }
378
- // Nowhere to look: no `public/` directory.
379
- if (ctx.publicDir === null) {
380
- return "asset-unchecked";
381
- }
541
+ return checkFileLink(resolved, assetPath, page, link, site, ctx, via);
542
+ }
543
+
544
+ return {
545
+ ...site,
546
+ code: "BLUME_BROKEN_LINK",
547
+ message: `Broken link to ${link.target}${via}: no page resolves to ${route}.`,
548
+ severity: "error",
549
+ suggestion:
550
+ partialLinkHint(link, page) ??
551
+ "Check the path, or create the target page.",
552
+ };
553
+ };
554
+
555
+ /**
556
+ * Validate a media element's `src` (`<img src>`, `<video src>`), which
557
+ * `resolved` reads the way the browser does: a file the site serves at that
558
+ * URL (in `public/`, or one Blume generates), or a relative path naming a
559
+ * file beside the page, which the build publishes and points the `src` at
560
+ * (see `core/content-assets.ts`). A partial's was rebased onto the page when
561
+ * its file sits beside the partial.
562
+ */
563
+ const checkSourceLink = (
564
+ rawPath: string,
565
+ resolved: string,
566
+ page: PageRecord,
567
+ link: PageLink,
568
+ site: LinkSite,
569
+ ctx: LinkContext,
570
+ via: string
571
+ ): LinkResult => {
572
+ if (ctx.servesFile(resolved)) {
573
+ return null;
574
+ }
575
+ const sourceFile = link.file ?? page.sourcePath;
576
+ if (rawPath.startsWith("/") || sourceFile === undefined) {
382
577
  return {
383
578
  ...site,
384
579
  code: "BLUME_BROKEN_ASSET",
385
- message: `Asset ${assetPath} was not found in the public directory.`,
580
+ message: `<${link.src} src="${link.target}">${via} points at ${resolved}, which isn't in the public directory.`,
386
581
  severity: "warning",
387
- suggestion: `Add the file at public${assetPath} or fix the link.`,
582
+ suggestion: `Add the file at public${resolved} or fix the src.`,
388
583
  };
389
584
  }
390
-
585
+ if (
586
+ page.sourcePath &&
587
+ resolveRelativeFile(dirname(page.sourcePath), link.target)
588
+ ) {
589
+ return null;
590
+ }
391
591
  return {
392
592
  ...site,
393
- code: "BLUME_BROKEN_LINK",
394
- message: `Broken link to ${link.target}${via}: no page resolves to ${route}.`,
395
- severity: "error",
396
- suggestion: "Check the path, or create the target page.",
593
+ code: "BLUME_BROKEN_ASSET",
594
+ message: `<${link.src} src="${link.target}">${via} points at ${resolved}, which isn't in the public directory, and there's no ${rawPath} next to ${basename(sourceFile)} either.`,
595
+ severity: "warning",
596
+ suggestion: "Fix the path, or add the file.",
397
597
  };
398
598
  };
399
599
 
@@ -524,6 +724,28 @@ const relativeTarget = (
524
724
  );
525
725
  };
526
726
 
727
+ /** The path a decoded, suffix-less internal link on `page` lands on. */
728
+ const resolveLinkPath = (
729
+ page: PageRecord,
730
+ link: PageLink,
731
+ rawPath: string,
732
+ ctx: LinkContext
733
+ ): string => {
734
+ if (!rawPath.startsWith("/")) {
735
+ // A raw `<a href>` isn't rewritten, so the browser resolves it against
736
+ // the page's slashless URL: its parent directory, even on an index page.
737
+ return link.raw
738
+ ? resolveRelative(page.route, rawPath, false)
739
+ : relativeTarget(page, rawPath, ctx);
740
+ }
741
+ // A root-relative link naming a content file lands on that file's page,
742
+ // as the Markdown pipeline rewrites it; one naming no file stays as
743
+ // written (a page's Markdown copy, say).
744
+ return link.raw || link.image
745
+ ? rawPath
746
+ : (ctx.rootFileRoute(rawPath) ?? rawPath);
747
+ };
748
+
527
749
  /** Classify a single link, queueing external refs via `onExternal`. */
528
750
  const classifyLink = (
529
751
  page: PageRecord,
@@ -547,9 +769,19 @@ const classifyLink = (
547
769
  });
548
770
  return null;
549
771
  }
550
- if (SCHEME.test(target)) {
551
- // mailto:, tel:, and other non-HTTP schemes are not validated.
552
- return null;
772
+ const scheme = SCHEME.exec(target)?.[0];
773
+ if (scheme !== undefined) {
774
+ // mailto:, tel:, and other non-HTTP schemes are not validated — except a
775
+ // platform's own, which no browser follows.
776
+ return PLATFORM_SCHEMES.has(scheme.toLowerCase())
777
+ ? {
778
+ ...site,
779
+ code: "BLUME_UNSUPPORTED_LINK_SCHEME",
780
+ message: `Link ${target} uses ReadMe's ${scheme} scheme, which browsers can't follow, so it ships as a dead link.`,
781
+ severity: "warning",
782
+ suggestion: "Link the page by its path instead, like /guides/setup.",
783
+ }
784
+ : null;
553
785
  }
554
786
 
555
787
  const hashIndex = target.indexOf("#");
@@ -575,17 +807,115 @@ const classifyLink = (
575
807
  return fragment ? checkAnchor(page.route, fragment, site, ctx, via) : null;
576
808
  }
577
809
 
578
- let resolved = rawPath;
579
- if (!rawPath.startsWith("/")) {
580
- // A raw `<a href>` isn't rewritten, so the browser resolves it against
581
- // the page's slashless URL: its parent directory, even on an index page.
582
- resolved = link.raw
583
- ? resolveRelative(page.route, rawPath, false)
584
- : relativeTarget(page, rawPath, ctx);
810
+ const resolved = resolveLinkPath(page, link, rawPath, ctx);
811
+ if (link.src) {
812
+ return checkSourceLink(rawPath, resolved, page, link, site, ctx, via);
585
813
  }
586
814
  return checkPathLink(resolved, fragment, page, link, site, ctx, via);
587
815
  };
588
816
 
817
+ /**
818
+ * Whether an exact redirect target lands somewhere the site serves: a page, a
819
+ * custom or generated route, a `public/` or generated file, or another
820
+ * redirect (a chain, which `blume audit` reports on the built site). `self`
821
+ * is the redirect's own based `from`, which leads nowhere but back.
822
+ */
823
+ const redirectLands = (
824
+ path: string,
825
+ self: string,
826
+ ctx: LinkContext
827
+ ): boolean => {
828
+ const route = toRoute(withBasePath(ctx.basePath, path));
829
+ const redirected =
830
+ ctx.redirects.has(route) ||
831
+ ctx.redirectPatterns.some(
832
+ (from) => pathsUnderPattern(from, [route]).length > 0
833
+ );
834
+ return (
835
+ ctx.routes.has(route) ||
836
+ ctx.extraRoutes.has(route) ||
837
+ (route !== self && redirected) ||
838
+ ctx.servesFile(stripBasePath(ctx.basePath, path))
839
+ );
840
+ };
841
+
842
+ /**
843
+ * Whether anything the site serves sits under a pattern target's literal
844
+ * start (`/guides/` for `/guides/:slug*`): a page, a custom or generated
845
+ * route, a redirect, or a `public/` folder. Which path a capture fills in
846
+ * can't be known before a request, so the prefix is what's checked.
847
+ */
848
+ const redirectLandsUnder = (
849
+ prefix: string,
850
+ ctx: LinkContext,
851
+ publicDir: string | null
852
+ ): boolean => {
853
+ const based = withBasePath(ctx.basePath, prefix);
854
+ const folder = prefix.slice(0, prefix.lastIndexOf("/") + 1);
855
+ return (
856
+ [...ctx.routes, ...ctx.extraRoutes, ...ctx.redirects].some((route) =>
857
+ `${route}/`.startsWith(based)
858
+ ) ||
859
+ (publicDir !== null &&
860
+ statSync(join(publicDir, folder), {
861
+ throwIfNoEntry: false,
862
+ })?.isDirectory() === true)
863
+ );
864
+ };
865
+
866
+ /**
867
+ * Warn about each internal redirect `to` that lands nowhere, located at its
868
+ * `to` in the config file when the redirects are written there. `blume audit`
869
+ * follows redirects through the built site; this catches a dead end before
870
+ * the build. External targets aren't checked.
871
+ */
872
+ const redirectTargetDiagnostics = (
873
+ redirects: readonly { from: string; to: string }[],
874
+ ctx: LinkContext,
875
+ options: { configFile?: string; publicDir: string | null }
876
+ ): Diagnostic[] => {
877
+ const { configFile, publicDir } = options;
878
+ let source: string | undefined;
879
+ return redirects.flatMap((redirect, index): Diagnostic[] => {
880
+ if (!isInternalPath(redirect.to)) {
881
+ return [];
882
+ }
883
+ const path = decodePercent(redirect.to.split(/[?#]/u)[0] ?? "");
884
+ const prefix = destinationPrefix(path);
885
+ const lands =
886
+ prefix === undefined
887
+ ? redirectLands(
888
+ path,
889
+ toRoute(withBasePath(ctx.basePath, redirect.from)),
890
+ ctx
891
+ )
892
+ : redirectLandsUnder(prefix, ctx, publicDir);
893
+ if (lands) {
894
+ return [];
895
+ }
896
+ if (configFile && source === undefined) {
897
+ source = readFileSync(configFile, "utf-8");
898
+ }
899
+ const position = source
900
+ ? locatePath(source, ["redirects", index, "to"])
901
+ : undefined;
902
+ return [
903
+ {
904
+ code: "BLUME_BROKEN_REDIRECT",
905
+ column: position?.column,
906
+ file: configFile,
907
+ line: position?.line,
908
+ message:
909
+ prefix === undefined
910
+ ? `The redirect from ${redirect.from} sends readers to ${redirect.to}, which no page, file, or other redirect serves.`
911
+ : `The redirect from ${redirect.from} sends readers to ${redirect.to}, but nothing is served under ${prefix}.`,
912
+ severity: "warning",
913
+ suggestion: "Point `to` at a page that exists, or remove the redirect.",
914
+ },
915
+ ];
916
+ });
917
+ };
918
+
589
919
  /**
590
920
  * Validate every link discovered in the content graph: internal page links and
591
921
  * anchors against the route map, asset links against the public dir, and
@@ -603,24 +933,45 @@ export const validateLinks = async (
603
933
  * full-route-set resolution in `nav-diagnostics.ts`/`generateRuntime`.
604
934
  */
605
935
  extraRoutes?: string[];
936
+ /**
937
+ * The files the build generates beside `public/` (`/llms.txt`,
938
+ * `/sitemap.xml`; see `deploy/generated-files.ts`), base-less: links to
939
+ * them resolve like links to `public/` files.
940
+ */
941
+ generatedFiles?: string[];
606
942
  /** Locale routing when i18n is on; links then resolve into the page's locale. */
607
943
  i18n?: LocaleRouting | null;
944
+ /** The project's `public/` folder, or `null` when it has none (and so
945
+ * ships no public files). */
608
946
  publicDir: string | null;
609
947
  checkExternal?: boolean;
610
948
  /** External URLs not to request (`--ignore`). Internal links are always checked. */
611
949
  ignore?: (url: string) => boolean;
612
- /** Configured redirects; their `from` paths count as valid link targets. */
613
- redirects?: { from: string }[];
950
+ /**
951
+ * Configured redirects: their `from` paths count as valid link targets,
952
+ * and an internal `to` must land somewhere the site serves.
953
+ */
954
+ redirects?: { from: string; to: string }[];
955
+ /** The config file the redirects are written in, for their positions. */
956
+ configFile?: string;
957
+ /**
958
+ * The `docs` collection's content root, which a root-relative link to a
959
+ * content file (`/guide/new.md`) is read from.
960
+ */
961
+ contentRoot?: string;
962
+ /** `deployment.base` (`""` or `/seg`), which such a link may start with. */
963
+ deployBase?: string;
614
964
  }
615
965
  ): Promise<Diagnostic[]> => {
616
966
  const basePath = options.basePath ?? "";
967
+ const fileRoutes = buildFileRouteIndex(graph.pages, options.i18n ?? null);
968
+ const { contentRoot } = options;
617
969
  const ctx: LinkContext = {
618
970
  anchors: buildAnchorIndex(graph.pages),
619
971
  basePath,
620
972
  extraRoutes: new Set((options.extraRoutes ?? []).map(toRoute)),
621
- fileRoutes: buildFileRouteIndex(graph.pages, options.i18n ?? null),
973
+ fileRoutes,
622
974
  i18n: options.i18n ?? null,
623
- publicDir: options.publicDir,
624
975
  redirectPatterns: (options.redirects ?? []).flatMap((redirect) =>
625
976
  isPatternPath(redirect.from)
626
977
  ? [withBasePath(basePath, redirect.from)]
@@ -631,30 +982,34 @@ export const validateLinks = async (
631
982
  toRoute(withBasePath(basePath, redirect.from))
632
983
  )
633
984
  ),
985
+ rootFileRoute: (href) =>
986
+ contentRoot === undefined
987
+ ? undefined
988
+ : resolveRootFileHref(href, {
989
+ contentRoot,
990
+ deployBase: options.deployBase ?? "",
991
+ routeOfFile: (file) => fileRoutes.bySource.get(file),
992
+ }),
634
993
  routes: new Set(graph.routes.keys()),
994
+ servesFile: staticFileResolver(options.publicDir, options.generatedFiles),
635
995
  };
636
996
  const diagnostics: Diagnostic[] = [];
637
997
  const external: ExternalRef[] = [];
638
- let uncheckedAssets = 0;
639
998
 
640
999
  for (const page of graph.pages) {
641
1000
  for (const link of page.links) {
642
1001
  const result = classifyLink(page, link, ctx, (ref) => external.push(ref));
643
- if (result === "asset-unchecked") {
644
- uncheckedAssets += 1;
645
- } else if (result) {
1002
+ if (result) {
646
1003
  diagnostics.push(result);
647
1004
  }
648
1005
  }
649
1006
  }
650
-
651
- if (uncheckedAssets > 0) {
652
- diagnostics.push({
653
- code: "BLUME_ASSETS_UNCHECKED",
654
- message: `${uncheckedAssets} asset link(s) not checked: no public/ directory found.`,
655
- severity: "info",
656
- });
657
- }
1007
+ diagnostics.push(
1008
+ ...redirectTargetDiagnostics(options.redirects ?? [], ctx, {
1009
+ configFile: options.configFile,
1010
+ publicDir: options.publicDir,
1011
+ })
1012
+ );
658
1013
 
659
1014
  // `--ignore` names URLs that can't be checked from where this runs: a
660
1015
  // placeholder host, a local server, a site that turns bots away.