blume 2.1.3 → 2.2.1

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 (397) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +134 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-y4msrwm9.js → chunk-080mfch6.js} +15 -15
  5. package/dist/cli/{chunk-gfc5fqvf.js → chunk-1ch3ct3r.js} +18 -18
  6. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-1ch3ct3r.js.map} +1 -1
  7. package/dist/cli/{chunk-32khwcb6.js → chunk-2ssh60qh.js} +42 -69
  8. package/dist/cli/{chunk-32khwcb6.js.map → chunk-2ssh60qh.js.map} +9 -10
  9. package/dist/cli/{chunk-9qzgg6mj.js → chunk-34wap3ed.js} +3 -3
  10. package/dist/cli/{chunk-k3gr1nm9.js → chunk-40vk8kgf.js} +13 -16
  11. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-40vk8kgf.js.map} +3 -3
  12. package/dist/cli/chunk-487b95qy.js +329 -0
  13. package/dist/cli/chunk-487b95qy.js.map +13 -0
  14. package/dist/cli/chunk-4zxh25hc.js +157 -0
  15. package/dist/cli/chunk-4zxh25hc.js.map +10 -0
  16. package/dist/cli/chunk-69rjf786.js +146 -0
  17. package/dist/cli/chunk-69rjf786.js.map +11 -0
  18. package/dist/cli/{chunk-dqhg54mr.js → chunk-9h8mh2tk.js} +3 -3
  19. package/dist/cli/{chunk-dqhg54mr.js.map → chunk-9h8mh2tk.js.map} +2 -2
  20. package/dist/cli/{chunk-bqxaqn5p.js → chunk-9jqmq5hv.js} +8 -8
  21. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-9jqmq5hv.js.map} +1 -1
  22. package/dist/cli/{chunk-dv2z0rzf.js → chunk-aafhwx74.js} +38 -13
  23. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-aafhwx74.js.map} +5 -4
  24. package/dist/cli/{chunk-desvm72z.js → chunk-b680sv30.js} +22 -26
  25. package/dist/cli/chunk-b680sv30.js.map +13 -0
  26. package/dist/cli/{chunk-0w6hs479.js → chunk-beb0z983.js} +30 -18
  27. package/dist/cli/chunk-beb0z983.js.map +10 -0
  28. package/dist/cli/{chunk-1f882900.js → chunk-c5bjxq8v.js} +2 -2
  29. package/dist/cli/chunk-e1n41w9m.js +155 -0
  30. package/dist/cli/chunk-e1n41w9m.js.map +11 -0
  31. package/dist/cli/{chunk-sc93apfs.js → chunk-etckvbrh.js} +17 -17
  32. package/dist/cli/{chunk-sc93apfs.js.map → chunk-etckvbrh.js.map} +2 -2
  33. package/dist/cli/{chunk-bxwhtvw8.js → chunk-f260jdrn.js} +5 -5
  34. package/dist/cli/{chunk-74wfhv79.js → chunk-fnxgtjeq.js} +1 -1
  35. package/dist/cli/{chunk-ms9z66nz.js → chunk-gwy55cx3.js} +2 -2
  36. package/dist/cli/{chunk-ms9z66nz.js.map → chunk-gwy55cx3.js.map} +2 -2
  37. package/dist/cli/{chunk-dhwtqnnd.js → chunk-gxhcf2hs.js} +2 -2
  38. package/dist/cli/{chunk-tfycddbt.js → chunk-h0qwdbem.js} +2 -2
  39. package/dist/cli/{chunk-nbyvgnnm.js → chunk-hktq4j5r.js} +109 -49
  40. package/dist/cli/chunk-hktq4j5r.js.map +36 -0
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-hn3g2b89.js} +14 -10
  42. package/dist/cli/chunk-hn3g2b89.js.map +10 -0
  43. package/dist/cli/{chunk-j1x92mx9.js → chunk-j4nfgn2j.js} +1 -1
  44. package/dist/cli/{chunk-dpy8qdjf.js → chunk-k45sq16q.js} +74 -183
  45. package/dist/cli/chunk-k45sq16q.js.map +18 -0
  46. package/dist/cli/{chunk-5cmts6dv.js → chunk-kmswjxj9.js} +7 -7
  47. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-kmswjxj9.js.map} +1 -1
  48. package/dist/cli/{chunk-36bxs9c7.js → chunk-nqna8dzp.js} +27 -23
  49. package/dist/cli/chunk-nqna8dzp.js.map +10 -0
  50. package/dist/cli/{chunk-a57yh0h9.js → chunk-p834rkcn.js} +7 -7
  51. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-p834rkcn.js.map} +1 -1
  52. package/dist/cli/{chunk-60hz265r.js → chunk-ppa6mg4d.js} +24 -11
  53. package/dist/cli/chunk-ppa6mg4d.js.map +10 -0
  54. package/dist/cli/{chunk-s0bdnc7v.js → chunk-q1wwrxzw.js} +5 -4
  55. package/dist/cli/chunk-q1wwrxzw.js.map +10 -0
  56. package/dist/cli/{chunk-ave1wcbr.js → chunk-qcyxkj44.js} +1 -1
  57. package/dist/cli/{chunk-124fvmzq.js → chunk-r5nm9w0c.js} +11183 -8806
  58. package/dist/cli/{chunk-124fvmzq.js.map → chunk-r5nm9w0c.js.map} +82 -66
  59. package/dist/cli/{chunk-ckrcm08h.js → chunk-rtvgmg2j.js} +48 -62
  60. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-rtvgmg2j.js.map} +4 -5
  61. package/dist/cli/{chunk-297ajdcv.js → chunk-t2h8pwy1.js} +6 -6
  62. package/dist/cli/{chunk-297ajdcv.js.map → chunk-t2h8pwy1.js.map} +3 -3
  63. package/dist/cli/{chunk-gxtg6ed2.js → chunk-x60e15st.js} +21 -21
  64. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-x60e15st.js.map} +3 -3
  65. package/dist/cli/{chunk-58bt3hag.js → chunk-x7bh6d9j.js} +4 -4
  66. package/dist/cli/{chunk-hvcxng5f.js → chunk-ydqzkhjn.js} +6 -6
  67. package/dist/cli/{chunk-kdpzw38a.js → chunk-yg8ckc3g.js} +95 -7
  68. package/dist/cli/chunk-yg8ckc3g.js.map +11 -0
  69. package/dist/cli/{chunk-0pmb4tr4.js → chunk-zxd5jrgx.js} +1205 -222
  70. package/dist/cli/chunk-zxd5jrgx.js.map +44 -0
  71. package/dist/cli/index.js +78 -42
  72. package/dist/cli/index.js.map +7 -6
  73. package/dist/types/ai/changelog-components.d.ts +18 -0
  74. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  75. package/dist/types/ai/openapi-components.d.ts +7 -7
  76. package/dist/types/ai/operation-details.d.ts +39 -0
  77. package/dist/types/ai/relative-links.d.ts +3 -1
  78. package/dist/types/ai/serializers.d.ts +6 -4
  79. package/dist/types/analytics/head.d.ts +4 -2
  80. package/dist/types/analytics/script.d.ts +8 -2
  81. package/dist/types/components/content/youtube.d.ts +20 -0
  82. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  83. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  84. package/dist/types/components/openapi/helpers.d.ts +53 -11
  85. package/dist/types/components/openapi/snippets.d.ts +7 -1
  86. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  87. package/dist/types/core/builtin-tags.d.ts +16 -0
  88. package/dist/types/core/changelog-list.d.ts +48 -0
  89. package/dist/types/core/closest-match.d.ts +6 -0
  90. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  91. package/dist/types/core/code-fences.d.ts +8 -1
  92. package/dist/types/core/config-input.d.ts +38 -6
  93. package/dist/types/core/content-assets.d.ts +16 -18
  94. package/dist/types/core/data.d.ts +6 -2
  95. package/dist/types/core/diagnostics.d.ts +14 -0
  96. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  97. package/dist/types/core/former-routes.d.ts +14 -0
  98. package/dist/types/core/github.d.ts +5 -4
  99. package/dist/types/core/heading-markers.d.ts +65 -2
  100. package/dist/types/core/hidden-pages.d.ts +3 -0
  101. package/dist/types/core/includes.d.ts +23 -0
  102. package/dist/types/core/last-modified.d.ts +18 -6
  103. package/dist/types/core/links.d.ts +42 -2
  104. package/dist/types/core/load-module.d.ts +19 -4
  105. package/dist/types/core/manifest.d.ts +14 -1
  106. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  107. package/dist/types/core/mcp-clients.d.ts +47 -0
  108. package/dist/types/core/mdx-elements.d.ts +32 -0
  109. package/dist/types/core/mdx-syntax.d.ts +100 -0
  110. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  111. package/dist/types/core/meta.d.ts +32 -5
  112. package/dist/types/core/nav-diagnostics.d.ts +16 -2
  113. package/dist/types/core/navigation.d.ts +13 -0
  114. package/dist/types/core/ordering-prefix.d.ts +6 -0
  115. package/dist/types/core/pinned-ids.d.ts +23 -0
  116. package/dist/types/core/project-graph.d.ts +9 -1
  117. package/dist/types/core/redirect-patterns.d.ts +18 -0
  118. package/dist/types/core/relative-files.d.ts +31 -0
  119. package/dist/types/core/schema.d.ts +322 -11
  120. package/dist/types/core/sources/normalize.d.ts +101 -9
  121. package/dist/types/core/sources/types.d.ts +16 -0
  122. package/dist/types/core/static-files.d.ts +10 -0
  123. package/dist/types/core/static-host.d.ts +42 -0
  124. package/dist/types/core/svg-images.d.ts +26 -0
  125. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  126. package/dist/types/core/types.d.ts +42 -1
  127. package/dist/types/deploy/generated-files.d.ts +41 -0
  128. package/dist/types/deploy/redirects.d.ts +21 -7
  129. package/dist/types/markdown/code-title.d.ts +9 -5
  130. package/dist/types/markdown/directives.d.ts +8 -0
  131. package/dist/types/markdown/fence-language.d.ts +51 -0
  132. package/dist/types/markdown/fence-meta.d.ts +5 -0
  133. package/dist/types/markdown/github-alerts.d.ts +35 -0
  134. package/dist/types/openapi/asyncapi.d.ts +6 -5
  135. package/dist/types/openapi/checks.d.ts +10 -5
  136. package/dist/types/openapi/model.d.ts +55 -8
  137. package/dist/types/openapi/parse.d.ts +4 -1
  138. package/dist/types/ratelimit/index.d.ts +2 -0
  139. package/dist/types/ratelimit/memory.d.ts +1 -1
  140. package/dist/types/ratelimit/schema.d.ts +40 -0
  141. package/dist/types/ratelimit/unkey.d.ts +48 -0
  142. package/dist/types/sources/filesystem.d.ts +14 -4
  143. package/dist/types/sources/mdx-remote.d.ts +6 -3
  144. package/dist/types/sources/registry.d.ts +3 -3
  145. package/dist/types/theme/color.d.ts +33 -0
  146. package/dist/types/theme/palette.d.ts +122 -0
  147. package/docs/01-quickstart.mdx +1 -1
  148. package/docs/02-deployment.mdx +24 -8
  149. package/docs/04-migrating.mdx +16 -4
  150. package/docs/advanced/changelog.mdx +27 -1
  151. package/docs/advanced/skills.mdx +1 -1
  152. package/docs/cli/audit.mdx +1 -1
  153. package/docs/cli/doctor.mdx +2 -2
  154. package/docs/cli/evals.mdx +1 -1
  155. package/docs/cli/index.mdx +4 -2
  156. package/docs/cli/validate.mdx +10 -7
  157. package/docs/configuration/analytics.mdx +2 -0
  158. package/docs/configuration/customization.mdx +3 -1
  159. package/docs/configuration/index.mdx +7 -6
  160. package/docs/configuration/rate-limiting.mdx +20 -1
  161. package/docs/configuration/theming.mdx +5 -1
  162. package/docs/content/components.mdx +26 -6
  163. package/docs/content/frontmatter.mdx +1 -1
  164. package/docs/content/i18n.mdx +1 -1
  165. package/docs/content/includes.mdx +6 -2
  166. package/docs/content/index.mdx +11 -1
  167. package/docs/content/islands.mdx +2 -0
  168. package/docs/content/meta.mdx +9 -3
  169. package/docs/content/navigation.mdx +14 -6
  170. package/docs/content/sources/obsidian.mdx +1 -1
  171. package/docs/content/sources/remote-mdx.mdx +3 -1
  172. package/docs/content/syntax.mdx +76 -8
  173. package/docs/content/variables.mdx +3 -1
  174. package/docs/discoverability/agent-discovery.mdx +1 -1
  175. package/docs/discoverability/json-api.mdx +1 -1
  176. package/docs/discoverability/llms-txt.mdx +1 -1
  177. package/docs/discoverability/markdown.mdx +2 -2
  178. package/docs/discoverability/mcp.mdx +40 -8
  179. package/docs/discoverability/open-graph.mdx +1 -1
  180. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  181. package/docs/references/graphql.mdx +2 -0
  182. package/docs/references/openapi.mdx +45 -7
  183. package/package.json +4 -4
  184. package/skills/blume/SKILL.md +2 -2
  185. package/skills/blume-migrate/SKILL.md +51 -29
  186. package/skills/blume-migrate/references/docsify.md +262 -0
  187. package/skills/blume-migrate/references/docus.md +298 -0
  188. package/skills/blume-migrate/references/docusaurus.md +5 -5
  189. package/skills/blume-migrate/references/fern.md +327 -0
  190. package/skills/blume-migrate/references/fumadocs.md +1 -1
  191. package/skills/blume-migrate/references/gitbook.md +267 -0
  192. package/skills/blume-migrate/references/github-wiki.md +259 -0
  193. package/skills/blume-migrate/references/jekyll.md +253 -0
  194. package/skills/blume-migrate/references/mdbook.md +256 -0
  195. package/skills/blume-migrate/references/mintlify.md +5 -5
  196. package/skills/blume-migrate/references/mkdocs.md +236 -0
  197. package/skills/blume-migrate/references/monorepo.md +20 -8
  198. package/skills/blume-migrate/references/nextra.md +5 -5
  199. package/skills/blume-migrate/references/readme.md +257 -0
  200. package/skills/blume-migrate/references/redocly.md +198 -0
  201. package/skills/blume-migrate/references/starlight.md +3 -3
  202. package/skills/blume-migrate/references/vitepress.md +204 -0
  203. package/skills/blume-migrate/references/vuepress.md +271 -0
  204. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  205. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  206. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  207. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  208. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  209. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  210. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  211. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  212. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  213. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  214. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  215. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  216. package/src/ai/changelog-components.ts +24 -0
  217. package/src/ai/changelog-markdown.ts +45 -41
  218. package/src/ai/component-markdown.ts +22 -9
  219. package/src/ai/llms.ts +36 -5
  220. package/src/ai/markdown.ts +2 -2
  221. package/src/ai/openapi-components.ts +25 -9
  222. package/src/ai/operation-details.ts +208 -0
  223. package/src/ai/relative-links.ts +48 -8
  224. package/src/ai/serializers.ts +7 -6
  225. package/src/ai/site-skill.ts +6 -2
  226. package/src/analytics/head.ts +11 -5
  227. package/src/analytics/script.ts +13 -3
  228. package/src/astro/generate.ts +47 -11
  229. package/src/astro/index.ts +1 -0
  230. package/src/astro/integration.ts +27 -1
  231. package/src/astro/mdx-source-errors.ts +46 -0
  232. package/src/astro/templates.ts +93 -189
  233. package/src/audit/checks/redirects.ts +5 -1
  234. package/src/audit/redirects.ts +36 -19
  235. package/src/audit/report.ts +22 -9
  236. package/src/audit/run.ts +12 -28
  237. package/src/audit/terms.ts +23 -0
  238. package/src/audit/url.ts +2 -2
  239. package/src/cli/build-failure.ts +256 -7
  240. package/src/cli/command-meta.ts +1 -1
  241. package/src/cli/commands/audit.ts +1 -1
  242. package/src/cli/commands/build.ts +12 -3
  243. package/src/cli/commands/check.ts +13 -2
  244. package/src/cli/commands/dev.ts +1 -1
  245. package/src/cli/commands/doctor.ts +4 -2
  246. package/src/cli/commands/init.ts +3 -3
  247. package/src/cli/commands/preview.ts +17 -1
  248. package/src/cli/commands/validate.ts +22 -10
  249. package/src/cli/prepare.ts +1 -1
  250. package/src/cli/preview-pages.ts +161 -0
  251. package/src/cli/required-secrets.ts +0 -1
  252. package/src/cli/unknown-flags.ts +1 -42
  253. package/src/components/code-copy.ts +51 -0
  254. package/src/components/code-language.ts +75 -0
  255. package/src/components/content/AccordionItem.astro +16 -1
  256. package/src/components/content/Callout.astro +11 -1
  257. package/src/components/content/Changelog.astro +189 -0
  258. package/src/components/content/CodeBlock.astro +8 -11
  259. package/src/components/content/Tabs.astro +16 -2
  260. package/src/components/content/YouTube.astro +6 -4
  261. package/src/components/content/code-block.ts +33 -0
  262. package/src/components/content/content-strings.ts +5 -1
  263. package/src/components/content/inline-markdown.ts +35 -8
  264. package/src/components/content/youtube.ts +49 -7
  265. package/src/components/layout/Analytics.astro +5 -1
  266. package/src/components/layout/NavTree.astro +8 -3
  267. package/src/components/layout/PageActions.astro +81 -23
  268. package/src/components/layout/RootLayout.astro +35 -32
  269. package/src/components/layout/SiteFooter.astro +31 -8
  270. package/src/components/layout/nav-utils.ts +14 -0
  271. package/src/components/layout/search/pagefind.ts +2 -1
  272. package/src/components/openapi/Operation.astro +4 -0
  273. package/src/components/openapi/PanelTabs.astro +9 -3
  274. package/src/components/openapi/RequestBody.astro +42 -8
  275. package/src/components/openapi/RequestPanel.astro +34 -53
  276. package/src/components/openapi/example-panels.ts +110 -0
  277. package/src/components/openapi/helpers.ts +195 -20
  278. package/src/components/openapi/operation-model.ts +63 -26
  279. package/src/components/openapi/request.ts +13 -0
  280. package/src/components/openapi/snippets.ts +14 -2
  281. package/src/core/alert-diagnostics.ts +89 -0
  282. package/src/core/builtin-tags.ts +76 -0
  283. package/src/core/changelog-list.ts +87 -0
  284. package/src/core/closest-match.ts +41 -0
  285. package/src/core/code-fence-diagnostics.ts +267 -0
  286. package/src/core/code-fences.ts +43 -7
  287. package/src/core/component-diagnostics.ts +65 -12
  288. package/src/core/config-input.ts +39 -5
  289. package/src/core/config.ts +46 -1
  290. package/src/core/content-assets.ts +64 -144
  291. package/src/core/data.ts +10 -3
  292. package/src/core/diagnostics.ts +36 -3
  293. package/src/core/directive-diagnostics.ts +265 -47
  294. package/src/core/former-routes.ts +46 -0
  295. package/src/core/git-ignored.ts +53 -0
  296. package/src/core/github.ts +5 -4
  297. package/src/core/heading-markers.ts +156 -2
  298. package/src/core/hidden-pages.ts +64 -0
  299. package/src/core/includes.ts +181 -41
  300. package/src/core/last-modified.ts +47 -10
  301. package/src/core/links.ts +438 -83
  302. package/src/core/load-module.ts +98 -9
  303. package/src/core/manifest.ts +12 -9
  304. package/src/core/markdown-mirrors.ts +52 -0
  305. package/src/core/mcp-clients.ts +87 -0
  306. package/src/core/mdx-elements.ts +153 -0
  307. package/src/core/mdx-syntax.ts +377 -0
  308. package/src/core/meta-diagnostics.ts +134 -0
  309. package/src/core/meta.ts +154 -55
  310. package/src/core/nav-diagnostics.ts +41 -4
  311. package/src/core/navigation.ts +70 -10
  312. package/src/core/ordering-prefix.ts +22 -2
  313. package/src/core/pinned-ids.ts +141 -0
  314. package/src/core/project-graph.ts +221 -29
  315. package/src/core/redirect-patterns.ts +41 -0
  316. package/src/core/relative-files.ts +145 -0
  317. package/src/core/schema.ts +110 -6
  318. package/src/core/sources/mdx-remote.ts +75 -14
  319. package/src/core/sources/normalize.ts +885 -179
  320. package/src/core/sources/notion.ts +5 -4
  321. package/src/core/sources/obsidian.ts +2 -5
  322. package/src/core/sources/types.ts +17 -0
  323. package/src/core/static-files.ts +40 -0
  324. package/src/core/static-host.ts +125 -0
  325. package/src/core/svg-images.ts +98 -0
  326. package/src/core/syntax-diagnostics.ts +491 -0
  327. package/src/core/types.ts +43 -1
  328. package/src/deploy/artifacts.ts +2 -28
  329. package/src/deploy/generated-files.ts +128 -0
  330. package/src/deploy/platforms/vercel.ts +25 -1
  331. package/src/deploy/redirects.ts +43 -16
  332. package/src/deploy/sitemap.ts +2 -1
  333. package/src/markdown/base-links.ts +87 -6
  334. package/src/markdown/code-title.ts +29 -13
  335. package/src/markdown/content-assets.ts +210 -0
  336. package/src/markdown/directives.ts +69 -5
  337. package/src/markdown/fence-language.ts +106 -0
  338. package/src/markdown/fence-meta.ts +42 -0
  339. package/src/markdown/github-alerts.ts +172 -0
  340. package/src/markdown/heading-anchors.ts +265 -56
  341. package/src/markdown/include.ts +30 -3
  342. package/src/markdown/index.ts +145 -40
  343. package/src/markdown/prompt-text.ts +95 -0
  344. package/src/markdown/relative-links.ts +37 -2
  345. package/src/markdown/route-snapshot.ts +22 -12
  346. package/src/markdown/svg-fallback.ts +51 -0
  347. package/src/markdown/title-heading.ts +134 -0
  348. package/src/migrate/migrate.ts +127 -2
  349. package/src/og/card.ts +14 -4
  350. package/src/openapi/asyncapi.ts +14 -6
  351. package/src/openapi/checks.ts +160 -15
  352. package/src/openapi/model.ts +172 -33
  353. package/src/openapi/parse.ts +13 -4
  354. package/src/openapi/render-mdx.ts +55 -9
  355. package/src/openapi/source.ts +87 -6
  356. package/src/ratelimit/index.ts +2 -0
  357. package/src/ratelimit/memory.ts +1 -1
  358. package/src/ratelimit/runtime.ts +81 -0
  359. package/src/ratelimit/schema.ts +2 -0
  360. package/src/ratelimit/unkey.ts +61 -0
  361. package/src/registry/eject.ts +0 -1
  362. package/src/registry/registry.ts +1 -1
  363. package/src/search/documents.ts +10 -2
  364. package/src/sources/filesystem.ts +28 -3
  365. package/src/sources/mdx-remote.ts +7 -15
  366. package/src/theme/color.ts +14 -0
  367. package/src/theme/entry.ts +22 -1
  368. package/src/theme/palette.ts +74 -1
  369. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  370. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  371. package/dist/cli/chunk-150jk7km.js +0 -52
  372. package/dist/cli/chunk-150jk7km.js.map +0 -10
  373. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  374. package/dist/cli/chunk-53jqw8z0.js +0 -89
  375. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  376. package/dist/cli/chunk-60hz265r.js.map +0 -10
  377. package/dist/cli/chunk-desvm72z.js.map +0 -14
  378. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  379. package/dist/cli/chunk-es8h0v3j.js +0 -441
  380. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  381. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  382. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  383. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  384. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  385. package/dist/cli/chunk-ydp3065p.js +0 -64
  386. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  387. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-080mfch6.js.map} +0 -0
  388. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-34wap3ed.js.map} +0 -0
  389. /package/dist/cli/{chunk-1f882900.js.map → chunk-c5bjxq8v.js.map} +0 -0
  390. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-f260jdrn.js.map} +0 -0
  391. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-fnxgtjeq.js.map} +0 -0
  392. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-gxhcf2hs.js.map} +0 -0
  393. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-h0qwdbem.js.map} +0 -0
  394. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-j4nfgn2j.js.map} +0 -0
  395. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-qcyxkj44.js.map} +0 -0
  396. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-x7bh6d9j.js.map} +0 -0
  397. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-ydqzkhjn.js.map} +0 -0
@@ -1,10 +1,15 @@
1
- import { isAbsolute, relative } from "pathe";
1
+ import { existsSync } from "node:fs";
2
2
 
3
+ import { isAbsolute, join, relative } from "pathe";
4
+
5
+ import { alertDiagnostics } from "./alert-diagnostics.ts";
3
6
  import { normalizePath, withBasePath } from "./base-path.ts";
4
7
  import { CHANGELOG_INDEX_ROUTE, hasChangelogIndex } from "./changelog-index.ts";
8
+ import { codeFenceDiagnostics } from "./code-fence-diagnostics.ts";
5
9
  import { loadConfig } from "./config.ts";
6
10
  import { customStaticRoutes, discoverPages } from "./custom-pages.ts";
7
- import { unknownDirectiveDiagnostics } from "./directive-diagnostics.ts";
11
+ import { directiveDiagnostics } from "./directive-diagnostics.ts";
12
+ import { formerRouteRedirects } from "./former-routes.ts";
8
13
  import { buildContentGraph } from "./graph.ts";
9
14
  import { i18nDiagnostics } from "./i18n.ts";
10
15
  import { expandIncludes, hasIncludeStatements } from "./includes.ts";
@@ -15,8 +20,13 @@ import {
15
20
  realPath,
16
21
  resolveLastModifiedConfig,
17
22
  } from "./last-modified.ts";
23
+ import { routeSetFor } from "./locale-links.ts";
24
+ import type { RouteSet } from "./locale-links.ts";
18
25
  import { buildManifest } from "./manifest.ts";
19
- import { discoverFolderMeta } from "./meta.ts";
26
+ import { mirroredPage } from "./markdown-mirrors.ts";
27
+ import { mdxSyntaxCheck } from "./mdx-syntax.ts";
28
+ import { folderMetaDiagnostics } from "./meta-diagnostics.ts";
29
+ import { discoverFolderMeta, withGeneratedFolderMeta } from "./meta.ts";
20
30
  import type { FolderMetaSource } from "./meta.ts";
21
31
  import { resolveProjectContext } from "./project.ts";
22
32
  import { isPatternPath, pathsUnderPattern } from "./redirect-patterns.ts";
@@ -29,6 +39,8 @@ import type {
29
39
  SourceEntry,
30
40
  SourceLoadResult,
31
41
  } from "./sources/types.ts";
42
+ import { unmeasurableSvgDiagnostics } from "./svg-images.ts";
43
+ import { indexPageNames, syntaxDiagnostics } from "./syntax-diagnostics.ts";
32
44
  import type {
33
45
  BlumeManifest,
34
46
  ContentGraph,
@@ -96,8 +108,16 @@ export interface BlumeProject {
96
108
  graph: ContentGraph;
97
109
  manifest: BlumeManifest;
98
110
  diagnostics: Diagnostic[];
99
- /** Entries excluded from the graph because their frontmatter failed validation. */
111
+ /**
112
+ * Entries excluded from the graph because their frontmatter failed
113
+ * validation, or (in a build) their MDX doesn't parse.
114
+ */
100
115
  droppedPages: number;
116
+ /**
117
+ * The local `.mdx` files a build dropped because they don't parse, which
118
+ * the docs collection leaves out so Astro never compiles them.
119
+ */
120
+ unparsable: string[];
101
121
  /** The instantiated content sources, for lazy entry reads (search/AI/raw). */
102
122
  sources: ContentSource[];
103
123
  /**
@@ -162,11 +182,14 @@ const entryIdDiagnostics = (
162
182
  * collecting pages, diagnostics, and the count of entries dropped outright —
163
183
  * an entry that yields no pages but did yield diagnostics was rejected for
164
184
  * invalid frontmatter, and callers surface that count so a build with missing
165
- * pages can't read as clean.
185
+ * pages can't read as clean. With `dropUnparsable`, an `.mdx` page whose own
186
+ * text doesn't parse is dropped too, and its file listed in `unparsable` so
187
+ * the docs collection leaves it out.
166
188
  */
167
189
  const normalizeLoadedEntries = (
168
190
  loaded: ({ source: ContentSource } & SourceLoadResult)[],
169
- config: ResolvedConfig
191
+ config: ResolvedConfig,
192
+ dropUnparsable: boolean
170
193
  ) => {
171
194
  // Only thread `frontmatter.extend` / `content.types` through when a project
172
195
  // opts in, so the known-key split in `normalizeEntry` stays off the default
@@ -187,10 +210,25 @@ const normalizeLoadedEntries = (
187
210
 
188
211
  const pages: PageRecord[] = [];
189
212
  const allDiagnostics: Diagnostic[] = [];
213
+ const syntaxErrors: Diagnostic[] = [];
214
+ const unparsable: string[] = [];
215
+ // Published entries, checked for other tools' syntax once every page is
216
+ // known: a wiki link is reported when it names one.
217
+ const published: { entry: SourceEntry; locale: string; source: string }[] =
218
+ [];
190
219
  let droppedPages = 0;
191
220
  for (const { source, entries, diagnostics } of loaded) {
192
221
  allDiagnostics.push(...diagnostics);
193
222
  for (const entry of entries) {
223
+ const syntax = mdxSyntaxCheck(entry, source.name, config.variables);
224
+ syntaxErrors.push(...syntax.diagnostics);
225
+ // A build drops a page whose MDX doesn't parse, as it does one whose
226
+ // front matter fails: with --no-strict the rest of the site still
227
+ // builds. Dev keeps it, so opening it shows the error in place.
228
+ const drop = syntax.unparsable && dropUnparsable;
229
+ if (drop && entry.sourcePath && !source.staged) {
230
+ unparsable.push(entry.sourcePath);
231
+ }
194
232
  const normalized = normalizeEntry(entry, {
195
233
  basePath: config.basePath,
196
234
  defaultType: config.content.defaultType,
@@ -206,17 +244,41 @@ const normalizeLoadedEntries = (
206
244
  typeFrontmatter,
207
245
  versions: config.versions,
208
246
  });
209
- if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
247
+ const [page] = normalized.pages;
248
+ if (page ? drop : normalized.diagnostics.length > 0) {
210
249
  droppedPages += 1;
211
250
  }
212
- pages.push(...normalized.pages);
251
+ if (!drop) {
252
+ pages.push(...normalized.pages);
253
+ }
213
254
  allDiagnostics.push(...normalized.diagnostics);
214
- if (normalized.pages.length > 0) {
215
- allDiagnostics.push(...unknownDirectiveDiagnostics(entry, source.name));
255
+ if (page) {
256
+ allDiagnostics.push(
257
+ ...directiveDiagnostics(entry, source.name),
258
+ ...codeFenceDiagnostics(entry, source.name),
259
+ ...alertDiagnostics(entry, source.name)
260
+ );
261
+ published.push({ entry, locale: page.locale, source: source.name });
216
262
  }
217
263
  }
218
264
  }
219
- return { diagnostics: allDiagnostics, droppedPages, pages };
265
+ const names = indexPageNames(pages);
266
+ for (const { entry, locale, source } of published) {
267
+ allDiagnostics.push(...syntaxDiagnostics(entry, source, names, locale));
268
+ }
269
+ // A parse error gives way to a diagnostic as severe at its line, which
270
+ // names the cause (`BLUME_MDX_CURLY_ANCHOR` for a `{#id}` heading marker,
271
+ // `BLUME_MDX_ATTRIBUTE_LIST` in a partial).
272
+ const covered = (diagnostic: Diagnostic): boolean =>
273
+ allDiagnostics.some(
274
+ (other) =>
275
+ other.file === diagnostic.file &&
276
+ other.line === diagnostic.line &&
277
+ (other.severity === "error" ||
278
+ (other.severity === "warning" && diagnostic.severity === "warning"))
279
+ );
280
+ allDiagnostics.push(...syntaxErrors.filter((error) => !covered(error)));
281
+ return { diagnostics: allDiagnostics, droppedPages, pages, unparsable };
220
282
  };
221
283
 
222
284
  /**
@@ -315,29 +377,45 @@ const substituteLoadedVariables = (
315
377
  * Astro ranks its route above the page's catch-all, in dev and build alike,
316
378
  * and writes the redirect page where the page would go, so the page never
317
379
  * publishes. That's a warning rather than an error, since the outcome doesn't
318
- * depend on the host.
380
+ * depend on the host. So is one from a page's Markdown copy (`/guide.md`, or
381
+ * the root's `/index.md`), which it takes over the same way. `mirrored` are
382
+ * the routes served with copies: the content pages.
319
383
  */
320
384
  const redirectPageDiagnostics = (
321
385
  config: ResolvedConfig,
322
386
  pages: readonly string[],
387
+ mirrored: RouteSet,
323
388
  configFile: string | null
324
389
  ): Diagnostic[] =>
325
390
  config.redirects.flatMap((redirect): Diagnostic[] => {
326
391
  const from = withBasePath(config.basePath, redirect.from);
327
392
  if (!isPatternPath(redirect.from)) {
328
393
  const page = normalizePath(from);
329
- return pages.includes(page)
330
- ? [
394
+ if (pages.includes(page)) {
395
+ return [
396
+ {
397
+ code: "BLUME_REDIRECT_MATCHES_PAGE",
398
+ file: configFile ?? undefined,
399
+ message: `The redirect from ${redirect.from} is also the page ${page}, which never publishes: its URL redirects to ${redirect.to} instead.`,
400
+ severity: "warning",
401
+ suggestion:
402
+ "If the page moved, delete it or give it a new slug; otherwise remove the redirect.",
403
+ } satisfies Diagnostic,
404
+ ];
405
+ }
406
+ const owner = mirroredPage(mirrored, page);
407
+ return owner === undefined
408
+ ? []
409
+ : [
331
410
  {
332
411
  code: "BLUME_REDIRECT_MATCHES_PAGE",
333
412
  file: configFile ?? undefined,
334
- message: `The redirect from ${redirect.from} is also the page ${page}, which never publishes: its URL redirects to ${redirect.to} instead.`,
413
+ message: `The redirect from ${redirect.from} is also the Markdown copy of the page ${owner}, so that copy never publishes: its URL redirects to ${redirect.to} instead.`,
335
414
  severity: "warning",
336
415
  suggestion:
337
- "If the page moved, delete it or give it a new slug; otherwise remove the redirect.",
416
+ "Remove the redirect: that URL serves the page's Markdown to agents.",
338
417
  } satisfies Diagnostic,
339
- ]
340
- : [];
418
+ ];
341
419
  }
342
420
  const matched = pathsUnderPattern(from, pages);
343
421
  const [first] = matched;
@@ -357,6 +435,83 @@ const redirectPageDiagnostics = (
357
435
  ];
358
436
  });
359
437
 
438
+ /**
439
+ * The config with the redirects sources ask for added after its own (see
440
+ * `SourceLoadResult.redirects`), so every surface that reads
441
+ * `config.redirects` — Astro's redirect pages, the host files, the link
442
+ * checker — serves them alike. Each is repeated under every locale prefix
443
+ * whose copy of the target page is served (a translation, or the fallback
444
+ * copy), and is kept only where its target is a page and nothing else
445
+ * answers for its `from`: a page there, or a configured redirect, wins.
446
+ * `pages` are the served page paths, which carry `basePath`.
447
+ */
448
+ const withSourceRedirects = (
449
+ config: ResolvedConfig,
450
+ loaded: readonly SourceLoadResult[],
451
+ pages: RouteSet
452
+ ): ResolvedConfig => {
453
+ const requested = loaded.flatMap(({ redirects }) => redirects ?? []);
454
+ if (requested.length === 0) {
455
+ return config;
456
+ }
457
+ const served = (route: string): string =>
458
+ normalizePath(withBasePath(config.basePath, route));
459
+ const prefixes = [
460
+ "",
461
+ ...(config.i18n?.locales.map((locale) => `/${locale.code}`) ?? []),
462
+ ];
463
+ const taken = new Set(
464
+ config.redirects.map((redirect) => served(redirect.from))
465
+ );
466
+ const added: ResolvedConfig["redirects"] = [];
467
+ for (const redirect of requested) {
468
+ for (const prefix of prefixes) {
469
+ const from = `${prefix}${redirect.from}`;
470
+ const to = `${prefix}${redirect.to}`;
471
+ if (
472
+ !pages.has(served(to)) ||
473
+ pages.has(served(from)) ||
474
+ taken.has(served(from))
475
+ ) {
476
+ continue;
477
+ }
478
+ taken.add(served(from));
479
+ added.push({ from, status: 301, to });
480
+ }
481
+ }
482
+ return added.length > 0
483
+ ? { ...config, redirects: [...config.redirects, ...added] }
484
+ : config;
485
+ };
486
+
487
+ /**
488
+ * A `public/openapi.json` is served at `/openapi.json`, where Blume
489
+ * publishes the OpenAPI description of the site's own JSON docs API: the
490
+ * build stops generating that description, while the API catalog
491
+ * (`/.well-known/api-catalog`) still lists `/openapi.json` as the docs API's
492
+ * `service-desc`, so agents reading it get the project's file instead. Only
493
+ * when the docs API is on; otherwise Blume publishes nothing there.
494
+ */
495
+ const publicOpenApiDiagnostics = (
496
+ config: ResolvedConfig,
497
+ root: string
498
+ ): Diagnostic[] => {
499
+ const file = join(root, "public", "openapi.json");
500
+ return config.agents.api && existsSync(file)
501
+ ? [
502
+ {
503
+ code: "BLUME_PUBLIC_OPENAPI_JSON",
504
+ file,
505
+ message:
506
+ "public/openapi.json is served at /openapi.json, where Blume publishes the OpenAPI description of the site's JSON docs API, so that description isn't generated, and /.well-known/api-catalog still lists /openapi.json as the docs API's description.",
507
+ severity: "warning",
508
+ suggestion:
509
+ "Move the file to another path, like public/specs/openapi.json, and update what links to it. A spec an openapi() reference renders can live outside public/ altogether. Or set agents.api to false if the site shouldn't publish the JSON docs API.",
510
+ },
511
+ ]
512
+ : [];
513
+ };
514
+
360
515
  /** The configured banner link target, when the banner has a link. */
361
516
  const bannerLinkHref = (
362
517
  banner: ResolvedConfig["banner"]
@@ -495,19 +650,30 @@ export const scanProject = async (
495
650
  // Folder meta contributed by the sources themselves (the OpenAPI source
496
651
  // labels each tag directory with the spec's own tag name). It applies to
497
652
  // every locale, so it merges into the shared map — beneath user-authored
498
- // entries, which are spread last and win.
499
- const sharedFolderMeta = new Map([
500
- ...loaded.flatMap(({ folderMeta: sourceMeta }) =>
501
- Object.entries(sourceMeta ?? {})
653
+ // entries, whose own fields win while the generated ones fill the rest.
654
+ const mergedFolderMeta = withGeneratedFolderMeta(
655
+ folderMeta,
656
+ new Map(
657
+ loaded.flatMap(({ folderMeta: sourceMeta }) =>
658
+ Object.entries(sourceMeta ?? {})
659
+ )
502
660
  ),
503
- ...folderMeta.shared,
504
- ]);
661
+ localeDirs
662
+ );
505
663
 
506
664
  const {
507
665
  diagnostics: contentDiagnostics,
508
666
  droppedPages,
509
667
  pages: allPages,
510
- } = normalizeLoadedEntries(loaded, config);
668
+ unparsable,
669
+ } = normalizeLoadedEntries(loaded, config, mode === "build");
670
+
671
+ // Checked against every page, drafts included: a `pages` entry naming a
672
+ // draft still orders it wherever the draft renders.
673
+ const metaDiagnostics = await folderMetaDiagnostics(folderMeta, allPages, {
674
+ localeDirs,
675
+ versionDirs: config.versions?.archived.map((version) => version.id),
676
+ });
511
677
 
512
678
  // Drafts render in dev and in preview, but are excluded from production builds.
513
679
  const pages =
@@ -573,10 +739,10 @@ export const scanProject = async (
573
739
  basePath: config.basePath,
574
740
  brandHref: logoHref(config.logo),
575
741
  extraRoutes,
576
- folderMeta: folderMeta.meta,
742
+ folderMeta: mergedFolderMeta.meta,
577
743
  i18n: config.i18n,
578
744
  navigation: config.navigation,
579
- sharedFolderMeta,
745
+ sharedFolderMeta: mergedFolderMeta.shared,
580
746
  versions: config.versions,
581
747
  });
582
748
  const manifest = buildManifest({ config, context, graph });
@@ -584,18 +750,40 @@ export const scanProject = async (
584
750
  const i18nWarnings = config.i18n
585
751
  ? i18nDiagnostics(pages, config.i18n, config.versions)
586
752
  : [];
753
+ const svgWarnings = await unmeasurableSvgDiagnostics(pages);
587
754
  const versionWarnings = config.versions
588
755
  ? versionsDiagnostics(pages, config.versions)
589
756
  : [];
590
757
 
758
+ const servedPaths = [
759
+ ...manifest.routes.map((route) => route.path),
760
+ ...extraRoutes,
761
+ ];
762
+ // A date-named page's old URL, from before its name was kept whole,
763
+ // redirects to its new one, beside the redirects the config lists.
764
+ const redirects = [
765
+ ...config.redirects,
766
+ ...formerRouteRedirects(pages, new Set(servedPaths), config),
767
+ ];
768
+
591
769
  return {
592
- config,
770
+ // Then the routes the API references served under earlier versions'
771
+ // rules redirect to where those operations live now.
772
+ config: withSourceRedirects(
773
+ redirects.length === config.redirects.length
774
+ ? config
775
+ : { ...config, redirects },
776
+ loaded,
777
+ new Set([...routeSetFor(manifest.routes), ...extraRoutes])
778
+ ),
593
779
  context,
594
780
  diagnostics: [
781
+ ...configResult.diagnostics,
595
782
  ...contentDiagnostics,
596
783
  ...includeDiagnostics,
597
784
  ...variableDiagnostics,
598
785
  ...folderMeta.diagnostics,
786
+ ...metaDiagnostics,
599
787
  ...entryIdDiagnostics(
600
788
  pages,
601
789
  resolveDocsCollection(config, context.root).base
@@ -604,9 +792,12 @@ export const scanProject = async (
604
792
  ...i18nWarnings,
605
793
  ...versionWarnings,
606
794
  ...lastModifiedWarnings,
795
+ ...svgWarnings,
796
+ ...publicOpenApiDiagnostics(config, context.root),
607
797
  ...redirectPageDiagnostics(
608
798
  config,
609
- [...manifest.routes.map((route) => route.path), ...extraRoutes],
799
+ servedPaths,
800
+ routeSetFor(manifest.routes),
610
801
  context.configFile
611
802
  ),
612
803
  ],
@@ -616,5 +807,6 @@ export const scanProject = async (
616
807
  mode,
617
808
  sources,
618
809
  themeFontsConfigured: configResult.themeFontsConfigured,
810
+ unparsable,
619
811
  };
620
812
  };
@@ -184,6 +184,21 @@ const replaceReferences = (
184
184
  replace(referenceName(name), match.startsWith("/"))
185
185
  );
186
186
 
187
+ /**
188
+ * The literal start of a `to` that reads a capture, up to its first
189
+ * reference: `/guides/` for `/guides/:slug*`, `/new/article-` for
190
+ * `/new/article-*`. `undefined` for a `to` that reads none, which is one
191
+ * exact path.
192
+ */
193
+ export const destinationPrefix = (to: string): string | undefined => {
194
+ const at = to.search(REFERENCE);
195
+ if (at === -1) {
196
+ return undefined;
197
+ }
198
+ // A whole-segment reference starts at its slash, which the prefix keeps.
199
+ return to.slice(0, to[at] === "/" ? at + 1 : at);
200
+ };
201
+
187
202
  /** Whether a path is a pattern, rather than one exact path. */
188
203
  export const isPatternPath = (path: string): boolean => {
189
204
  const parsed = parseFrom(path);
@@ -278,6 +293,32 @@ export const expandRedirect = (redirect: RedirectEnds): RedirectRule[] => {
278
293
  ];
279
294
  };
280
295
 
296
+ /**
297
+ * The bare path a pattern ending in a rest also matches, as the exact
298
+ * redirect every host is given for it: `/beta/:slug*` (or `/beta/*`) to
299
+ * `/v2/:slug*` sends `/beta` itself to `/v2`. Null for an exact redirect,
300
+ * a pattern with no rest, and one whose bare path still holds a capture
301
+ * (`/:lang/*`), which names no single path.
302
+ */
303
+ export const bareRedirect = (
304
+ redirect: RedirectEnds
305
+ ): { from: string; to: string } | null => {
306
+ // A rest expands to two rules, the bare path first (see `expandRedirect`).
307
+ const rules = expandRedirect(redirect);
308
+ const [bare] = rules;
309
+ if (rules.length !== 2 || bare === undefined) {
310
+ return null;
311
+ }
312
+ let from = "";
313
+ for (const part of bare.parts) {
314
+ if (part.kind !== "text") {
315
+ return null;
316
+ }
317
+ from += part.text;
318
+ }
319
+ return { from, to: bare.to };
320
+ };
321
+
281
322
  /** Put exact redirects ahead of patterns, which hosts try in order. */
282
323
  export const exactFirst = <Redirect extends { from: string }>(
283
324
  redirects: readonly Redirect[]
@@ -0,0 +1,145 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+
3
+ import { basename, dirname, extname, normalize, resolve } from "pathe";
4
+
5
+ /**
6
+ * The files a page names beside it: an image it embeds (`![](./diagram.png)`)
7
+ * and any other file it links (`[spec](./spec.pdf)`, `<img src>`). One
8
+ * reading, shared by everything that handles them: the include expander that
9
+ * rebases a partial's, the content-asset rewriter that publishes them, the
10
+ * render plugin that points the built page at them, and link validation.
11
+ */
12
+
13
+ // The formats Astro's image pipeline accepts, plus the web-safe pass-throughs;
14
+ // anything else referenced relatively (a `.pdf`, a source file) is left alone.
15
+ const IMAGE_EXTENSIONS = new Set([
16
+ ".apng",
17
+ ".avif",
18
+ ".bmp",
19
+ ".gif",
20
+ ".ico",
21
+ ".jpeg",
22
+ ".jpg",
23
+ ".png",
24
+ ".svg",
25
+ ".tiff",
26
+ ".webp",
27
+ ]);
28
+
29
+ // A link to one of these names a page (see `resolveRelativeHref` in
30
+ // `links.ts`), never a file to publish.
31
+ const PAGE_EXTENSIONS = new Set([".md", ".mdx"]);
32
+
33
+ /** Whether a link target is a relative filesystem path (not URL/absolute/hash). */
34
+ const isRelativeTarget = (target: string): boolean =>
35
+ !(target.startsWith("/") || target.startsWith("#")) && !URL.canParse(target);
36
+
37
+ /** Percent-decode a target; malformed escapes stay verbatim. */
38
+ const decodeTarget = (target: string): string => {
39
+ try {
40
+ return decodeURI(target);
41
+ } catch {
42
+ return target;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Whether a target is *shaped* like a colocated image reference: a relative
48
+ * filesystem path with an image extension. Exported so link validation can
49
+ * tell "not a colocated candidate" apart from "a candidate that resolves
50
+ * nowhere" — the former falls through to the public-dir probe, the latter is
51
+ * a broken reference beside the page source.
52
+ */
53
+ export const isRelativeImageTarget = (target: string): boolean =>
54
+ isRelativeTarget(target) &&
55
+ IMAGE_EXTENSIONS.has(extname(decodeTarget(target)).toLowerCase());
56
+
57
+ /**
58
+ * Whether `abs` is a file whose trailing `segments` names match the on-disk
59
+ * entries exactly. A bare `existsSync` accepts `./Diagram.PNG` for
60
+ * `diagram.png` (or a directory named like an image) on a case-insensitive
61
+ * filesystem — the reference then validates and serves locally but breaks on
62
+ * the case-sensitive Linux build.
63
+ */
64
+ const existsAsWritten = (abs: string, segments: number): boolean => {
65
+ let current = abs;
66
+ for (let i = 0; i < segments; i += 1) {
67
+ const parent = dirname(current);
68
+ let entries: string[];
69
+ try {
70
+ entries = readdirSync(parent);
71
+ } catch {
72
+ return false;
73
+ }
74
+ if (!entries.includes(basename(current))) {
75
+ return false;
76
+ }
77
+ current = parent;
78
+ }
79
+ const stat = statSync(abs, { throwIfNoEntry: false });
80
+ return stat !== undefined && stat.isFile();
81
+ };
82
+
83
+ /**
84
+ * The file a decoded relative `path` names from `sourceDir`, when it's on
85
+ * disk as written. Only the segments the author wrote are checked against
86
+ * on-disk names; `sourceDir`'s own casing is the filesystem's business, not
87
+ * the target's.
88
+ */
89
+ const fileAsWritten = (sourceDir: string, path: string): string | null => {
90
+ const segments = normalize(path)
91
+ .split("/")
92
+ .filter((part) => part !== "" && part !== "..").length;
93
+ const abs = resolve(sourceDir, path);
94
+ return existsAsWritten(abs, segments) ? abs : null;
95
+ };
96
+
97
+ /**
98
+ * Resolve one image target against its page's directory. Returns the absolute
99
+ * file path when the target is relative, is an image, and exists on disk —
100
+ * anything else (remote URLs, `public/` absolutes, broken refs, code-block
101
+ * examples that happen to look like paths) is null and left untouched. Shared
102
+ * with link validation, so what counts as a colocated image is decided once.
103
+ */
104
+ export const resolveRelativeImage = (
105
+ sourceDir: string,
106
+ target: string
107
+ ): string | null =>
108
+ isRelativeImageTarget(target)
109
+ ? fileAsWritten(sourceDir, decodeTarget(target))
110
+ : null;
111
+
112
+ /** A file a link names beside its page, and the link's `?query#fragment`. */
113
+ export interface RelativeFile {
114
+ path: string;
115
+ suffix: string;
116
+ }
117
+
118
+ /**
119
+ * Resolve the file a link, an element's `src`, or an `href` names beside its
120
+ * page (`./spec.pdf`, `../files/data.csv#L2`): a relative path whose name
121
+ * carries an extension other than a page's (`.md`, `.mdx`), on disk as
122
+ * written (see `existsAsWritten`). Its `?query` or `#fragment` is set aside,
123
+ * so `./spec.pdf#page=2` opens the served file at that page. Anything else is
124
+ * null: a page link, a URL, a root path, a missing file. Shared with link
125
+ * validation, which accepts what this resolves.
126
+ */
127
+ export const resolveRelativeFile = (
128
+ sourceDir: string,
129
+ target: string
130
+ ): RelativeFile | null => {
131
+ const at = target.search(/[?#]/u);
132
+ const path = decodeTarget(at === -1 ? target : target.slice(0, at));
133
+ const extension = extname(path).toLowerCase();
134
+ if (
135
+ !isRelativeTarget(target) ||
136
+ extension === "" ||
137
+ PAGE_EXTENSIONS.has(extension)
138
+ ) {
139
+ return null;
140
+ }
141
+ const file = fileAsWritten(sourceDir, path);
142
+ return file === null
143
+ ? null
144
+ : { path: file, suffix: at === -1 ? "" : target.slice(at) };
145
+ };