blume 2.1.2 → 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 +133 -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 +10 -9
  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/meta.ts CHANGED
@@ -90,17 +90,16 @@ const withinInclude = (
90
90
  );
91
91
 
92
92
  /**
93
- * The folder-meta files a filesystem source contributes: `patterns` (every
94
- * meta module name, shared `meta.$.*` included, by default) under the source's
95
- * root, minus its `exclude` globs, dependencies, and build output, and only in
96
- * folders its `include` globs reach. `blume translate` finds the meta files it
97
- * translates through this too, so it covers exactly the ones the scan reads.
93
+ * Every folder-meta file under a filesystem source's root: `patterns` (every
94
+ * meta module name, shared `meta.$.*` included, by default), minus the
95
+ * source's `exclude` globs, dependencies, and build output — whether or not
96
+ * its `include` globs reach the file's folder.
98
97
  */
99
- export const findFolderMetaFiles = async (
98
+ const globFolderMetaFiles = (
100
99
  source: FolderMetaSource,
101
- patterns: readonly string[] = META_FILES
102
- ): Promise<string[]> => {
103
- const found = await glob([...patterns], {
100
+ patterns: readonly string[]
101
+ ): Promise<string[]> =>
102
+ glob([...patterns], {
104
103
  absolute: true,
105
104
  cwd: source.root,
106
105
  // Never descend into dependencies or build output — relevant when the
@@ -116,9 +115,24 @@ export const findFolderMetaFiles = async (
116
115
  ],
117
116
  onlyFiles: true,
118
117
  });
119
- return found.filter((file) =>
120
- withinInclude(relative(source.root, dirname(file)), source.include)
121
- );
118
+
119
+ /** Whether the source's `include` globs reach a meta file's folder. */
120
+ const isReadMetaFile = (source: FolderMetaSource, file: string): boolean =>
121
+ withinInclude(relative(source.root, dirname(file)), source.include);
122
+
123
+ /**
124
+ * The folder-meta files a filesystem source contributes: `patterns` (every
125
+ * meta module name, shared `meta.$.*` included, by default) under the source's
126
+ * root, minus its `exclude` globs, dependencies, and build output, and only in
127
+ * folders its `include` globs reach. `blume translate` finds the meta files it
128
+ * translates through this too, so it covers exactly the ones the scan reads.
129
+ */
130
+ export const findFolderMetaFiles = async (
131
+ source: FolderMetaSource,
132
+ patterns: readonly string[] = META_FILES
133
+ ): Promise<string[]> => {
134
+ const found = await globFolderMetaFiles(source, patterns);
135
+ return found.filter((file) => isReadMetaFile(source, file));
122
136
  };
123
137
 
124
138
  /**
@@ -135,6 +149,29 @@ const metaKeyFor = (prefix: string | undefined, dir: string): string => {
135
149
  return dir ? `${clean}/${dir}` : clean;
136
150
  };
137
151
 
152
+ /** Folder meta by key: per-locale `meta.*` files, and shared `meta.$.*` ones. */
153
+ export interface FolderMetaMaps {
154
+ meta: Map<string, FolderMeta>;
155
+ shared: Map<string, FolderMeta>;
156
+ }
157
+
158
+ /** A folder-meta file Blume found but doesn't read, and the key it would have. */
159
+ export interface UnreadFolderMeta {
160
+ /** Its folder, relative to the source root. */
161
+ dir: string;
162
+ file: string;
163
+ key: string;
164
+ }
165
+
166
+ /** What {@link discoverFolderMeta} found across the sources. */
167
+ export interface DiscoveredFolderMeta extends FolderMetaMaps {
168
+ diagnostics: Diagnostic[];
169
+ /** The file each meta in `meta` and `shared` was read from. */
170
+ files: Map<FolderMeta, string>;
171
+ /** Meta files under a source root that no source's `include` globs reach. */
172
+ unread: UnreadFolderMeta[];
173
+ }
174
+
138
175
  /**
139
176
  * Discover `meta.{ts,js,mjs}` files across the given filesystem sources. Keys are
140
177
  * the source's route prefix joined with the directory relative to that source's
@@ -162,11 +199,7 @@ export const discoverFolderMeta = async (
162
199
  localeDirs?: readonly string[];
163
200
  versionDirs?: readonly string[];
164
201
  } = {}
165
- ): Promise<{
166
- meta: Map<string, FolderMeta>;
167
- shared: Map<string, FolderMeta>;
168
- diagnostics: Diagnostic[];
169
- }> => {
202
+ ): Promise<DiscoveredFolderMeta> => {
170
203
  const list: FolderMetaSource[] = Array.isArray(sources)
171
204
  ? sources
172
205
  : [{ root: sources }];
@@ -181,34 +214,67 @@ export const discoverFolderMeta = async (
181
214
  const load = createModuleLoader();
182
215
  const meta = new Map<string, FolderMeta>();
183
216
  const shared = new Map<string, FolderMeta>();
217
+ const files = new Map<FolderMeta, string>();
218
+ const unread: UnreadFolderMeta[] = [];
184
219
  const diagnostics: Diagnostic[] = [];
185
220
 
221
+ // A version snapshot dir is outermost, with a locale dir one level deeper;
222
+ // both are hoisted in front of the (prefixed) group path, in that order —
223
+ // the lookup key reads `version/locale/prefix/dir`.
224
+ const keyOf = (source: FolderMetaSource, file: string): string => {
225
+ const dir = relative(source.root, dirname(file));
226
+ const [head, ...tail] = dir.split("/");
227
+ const version = head && versionDirs.has(head) ? head : "";
228
+ const afterVersion = version ? tail : [head ?? "", ...tail];
229
+ const [localeHead, ...localeTail] = afterVersion;
230
+ const locale = localeHead
231
+ ? (localeDirs.get(localeHead.toLowerCase()) ?? "")
232
+ : "";
233
+ const rest = (locale ? localeTail : afterVersion).filter(Boolean).join("/");
234
+ return [version, locale, metaKeyFor(source.prefix, rest)]
235
+ .filter(Boolean)
236
+ .join("/");
237
+ };
238
+
186
239
  // Scan every source under its own root so a source rooted outside
187
240
  // `content.root` still contributes its folder meta.
188
241
  const perSource = await Promise.all(
189
242
  list.map(async (source) => {
190
- const files = await findFolderMetaFiles(source);
243
+ const found = await globFolderMetaFiles(source, META_FILES);
244
+ // Files outside every `include` glob are never imported; they're kept
245
+ // by key so a scan that finds pages in their group can say so.
246
+ for (const file of found) {
247
+ if (!isReadMetaFile(source, file)) {
248
+ unread.push({
249
+ dir: relative(source.root, dirname(file)),
250
+ file,
251
+ key: keyOf(source, file),
252
+ });
253
+ }
254
+ }
191
255
  const loaded = await Promise.all(
192
- files.map(
193
- async (
194
- file
195
- ): Promise<
196
- | { ok: true; file: string; value: unknown }
197
- | { ok: false; file: string; error: Error }
198
- > => {
199
- try {
200
- return {
201
- file,
202
- ok: true,
203
- value: await resolveMeta(await load(file)),
204
- };
205
- } catch (error) {
206
- // SAFETY: jiti surfaces load/evaluate failures as Error
207
- // instances, and only `message` is read downstream.
208
- return { error: error as Error, file, ok: false };
256
+ found
257
+ .filter((file) => isReadMetaFile(source, file))
258
+ .map(
259
+ async (
260
+ file
261
+ ): Promise<
262
+ | { ok: true; file: string; value: unknown }
263
+ | { ok: false; file: string; error: Error }
264
+ > => {
265
+ try {
266
+ return {
267
+ file,
268
+ ok: true,
269
+ value: await resolveMeta(await load(file)),
270
+ };
271
+ } catch (error) {
272
+ // SAFETY: jiti surfaces load/evaluate failures as Error
273
+ // instances, and only `message` is read downstream.
274
+ return { error: error as Error, file, ok: false };
275
+ }
209
276
  }
210
- }
211
- )
277
+ )
212
278
  );
213
279
  return { loaded, source };
214
280
  })
@@ -216,23 +282,7 @@ export const discoverFolderMeta = async (
216
282
 
217
283
  for (const { loaded, source } of perSource) {
218
284
  for (const entry of loaded) {
219
- const dir = relative(source.root, dirname(entry.file));
220
- // A version snapshot dir is outermost, with a locale dir one level
221
- // deeper; both are hoisted in front of the (prefixed) group path, in
222
- // that order — the lookup key reads `version/locale/prefix/dir`.
223
- const [head, ...tail] = dir.split("/");
224
- const version = head && versionDirs.has(head) ? head : "";
225
- const afterVersion = version ? tail : [head ?? "", ...tail];
226
- const [localeHead, ...localeTail] = afterVersion;
227
- const locale = localeHead
228
- ? (localeDirs.get(localeHead.toLowerCase()) ?? "")
229
- : "";
230
- const rest = (locale ? localeTail : afterVersion)
231
- .filter(Boolean)
232
- .join("/");
233
- const key = [version, locale, metaKeyFor(source.prefix, rest)]
234
- .filter(Boolean)
235
- .join("/");
285
+ const key = keyOf(source, entry.file);
236
286
 
237
287
  if (!entry.ok) {
238
288
  diagnostics.push({
@@ -250,6 +300,7 @@ export const discoverFolderMeta = async (
250
300
  ? shared
251
301
  : meta;
252
302
  target.set(key, result.data);
303
+ files.set(result.data, entry.file);
253
304
  } else {
254
305
  diagnostics.push(
255
306
  ...diagnosticsFromZod(result.error, {
@@ -261,5 +312,53 @@ export const discoverFolderMeta = async (
261
312
  }
262
313
  }
263
314
 
264
- return { diagnostics, meta, shared };
315
+ // Sources sharing a root partition it with `include` globs, so a file one
316
+ // source skips may be another's: only a file no source reads is unread.
317
+ const read = new Set(
318
+ perSource.flatMap(({ loaded }) => loaded.map((entry) => entry.file))
319
+ );
320
+ return {
321
+ diagnostics,
322
+ files,
323
+ meta,
324
+ shared,
325
+ unread: unread.filter((entry) => !read.has(entry.file)),
326
+ };
327
+ };
328
+
329
+ /**
330
+ * Lay the folder meta content sources derive (the OpenAPI source's tag
331
+ * titles and order) beneath the user's `meta.ts` and `meta.$.ts` files, field
332
+ * by field: a user file's own keys win, and the generated ones fill the rest,
333
+ * so a `meta.ts` that only renames a tag group keeps its generated `order`.
334
+ * Generated meta is locale-agnostic, keyed like `meta.$.*`, so it also sits
335
+ * beneath a locale's own `meta.ts` (`fr/reference/pets` over
336
+ * `reference/pets`). It doesn't reach into an archived version's snapshot,
337
+ * whose groups read only meta keyed under the version.
338
+ */
339
+ export const withGeneratedFolderMeta = (
340
+ user: FolderMetaMaps,
341
+ generated: Map<string, FolderMeta>,
342
+ localeDirs: readonly string[] = []
343
+ ): FolderMetaMaps => {
344
+ const locales = new Set(localeDirs);
345
+ const beneath = (key: string, meta: FolderMeta): FolderMeta => {
346
+ const [head, ...rest] = key.split("/");
347
+ const generatedMeta = generated.get(
348
+ head !== undefined && locales.has(head) ? rest.join("/") : key
349
+ );
350
+ return generatedMeta ? { ...generatedMeta, ...meta } : meta;
351
+ };
352
+ return {
353
+ meta: new Map(
354
+ [...user.meta].map(([key, meta]) => [key, beneath(key, meta)])
355
+ ),
356
+ shared: new Map([
357
+ ...generated,
358
+ ...[...user.shared].map(([key, meta]): [string, FolderMeta] => [
359
+ key,
360
+ { ...generated.get(key), ...meta },
361
+ ]),
362
+ ]),
363
+ };
265
364
  };
@@ -108,14 +108,25 @@ const resolvesToPages = (routes: Set<string>, path: string): boolean =>
108
108
  * Warn when a config-linked tab/selector target has no matching page. `routes`
109
109
  * must be the full set of servable routes — content, custom `.astro` pages, and
110
110
  * generated routes — so this runs where all three are known (`generateRuntime`),
111
- * not in the content-only graph build.
111
+ * not in the content-only graph build. `servesFile` answers for the files the
112
+ * site serves beside its pages — `public/` and the generated ones (see
113
+ * `staticFileResolver`) — so a featured link to `/spec.pdf` or `/llms.txt`
114
+ * resolves too.
115
+ *
116
+ * A tab is checked where it links: its `href` — the author's, or the
117
+ * section's first page that navigation resolved for a `path` with no page of
118
+ * its own — and its `path` only when it links there.
112
119
  */
113
120
  export const validateNavTargets = (
114
121
  navigation: Navigation,
115
- routes: Set<string>
122
+ routes: Set<string>,
123
+ servesFile: (path: string) => boolean = () => false
116
124
  ): Diagnostic[] => {
117
125
  const targets: { label: string; path: string }[] = [
118
- ...navigation.tabs.map((tab) => ({ label: tab.label, path: tab.path })),
126
+ ...navigation.tabs.map((tab) => ({
127
+ label: tab.label,
128
+ path: tab.href ?? tab.path,
129
+ })),
119
130
  ...navigation.selectors.flatMap((selector) =>
120
131
  selector.items.map((item) => ({ label: item.label, path: item.path }))
121
132
  ),
@@ -141,7 +152,8 @@ export const validateNavTargets = (
141
152
  }
142
153
  // A query or fragment (`/guides?tab=cli`) rides on the route; only the
143
154
  // path names a page.
144
- if (!resolvesToPages(routes, path.split(/[?#]/u)[0] ?? path)) {
155
+ const bare = path.split(/[?#]/u)[0] ?? path;
156
+ if (!(resolvesToPages(routes, bare) || servesFile(bare))) {
145
157
  seen.add(path);
146
158
  diagnostics.push({
147
159
  code: "BLUME_NAV_MISSING_PAGE",
@@ -161,6 +161,40 @@ const stemOf = (filename: string): string =>
161
161
  const navStem = (navPath: string): string =>
162
162
  stemOf(navPath.split("/").at(-1) ?? navPath);
163
163
 
164
+ /**
165
+ * A page's key in its folder meta `pages` list: its file name without the
166
+ * extension, ordering prefix, or parentheses (`01-quickstart.mdx` is
167
+ * `quickstart`).
168
+ */
169
+ export const pageKey = (filename: string): string =>
170
+ segmentKey(stemOf(filename));
171
+
172
+ /**
173
+ * The keys a folder meta `pages` list can name, by group path: each folder's
174
+ * pages and subfolders, keyed as `buildFileSystemSidebar` keys its nodes (the
175
+ * name without its extension, ordering prefix, or parentheses). Read off nav
176
+ * paths, so it covers pages the sidebar leaves out, like hidden ones.
177
+ */
178
+ export const folderChildKeys = (
179
+ navPaths: Iterable<string>
180
+ ): Map<string, Set<string>> => {
181
+ const keys = new Map<string, Set<string>>();
182
+ const add = (group: string, key: string): void => {
183
+ const children = keys.get(group) ?? new Set<string>();
184
+ children.add(key);
185
+ keys.set(group, children);
186
+ };
187
+ for (const navPath of navPaths) {
188
+ const dirs = navPath.split("/");
189
+ const filename = dirs.pop() ?? navPath;
190
+ for (const [depth, dir] of dirs.entries()) {
191
+ add(dirs.slice(0, depth).join("/"), segmentKey(dir));
192
+ }
193
+ add(dirs.join("/"), pageKey(filename));
194
+ }
195
+ return keys;
196
+ };
197
+
164
198
  interface MutablePage {
165
199
  kind: "page";
166
200
  key: string;
@@ -397,16 +431,21 @@ const applyFolderMeta = (
397
431
 
398
432
  /**
399
433
  * Warn when an index page's own frontmatter title diverges from its folder's
400
- * explicit `meta.title`. The sidebar label and the page's own `<title>`/heading
401
- * are resolved from two independent sources — under i18n, a translator can
402
- * update the folder's `meta.ts` and forget the index page's own frontmatter
403
- * (or vice versa), and a correct-looking sidebar hides the mismatch.
434
+ * explicit `meta.title`. The sidebar label and the page's own title (its
435
+ * heading, and its `<title>` unless `seo.title` replaces that) are resolved
436
+ * from two independent sources — under i18n, a translator can update the
437
+ * folder's `meta.ts` and forget the index page's own frontmatter (or vice
438
+ * versa), and a correct-looking sidebar hides the mismatch.
404
439
  *
405
440
  * Only fires when the index page's own sidebar row is hidden
406
441
  * (`sidebar.hidden`), so the linked group header is the only sidebar label the
407
442
  * page has. A visible index row shows the page's own label beneath the header,
408
443
  * so the sidebar already carries both titles and a divergence there is a
409
- * deliberate pairing ("CLI" over "Overview"), not drift.
444
+ * deliberate pairing ("CLI" over "Overview"), not drift. A hidden index keeps a
445
+ * different title on purpose the same way any page does: its `sidebar.label`
446
+ * names the folder title, so the label its own row would carry agrees with the
447
+ * header. The suggestion names only ways out that clear the warning:
448
+ * `validate --strict` fails on any warning, so leaving it is not one.
410
449
  *
411
450
  * Only fires when the page has an explicit frontmatter `title` of its own:
412
451
  * when it's absent, `page.title` is derived from the first heading or the
@@ -440,15 +479,19 @@ const indexTitleMismatchDiagnostic = (
440
479
  const meta =
441
480
  folderMeta.get(metaKey(folderPath, metaPrefix)) ??
442
481
  sharedMeta.get(metaKey(folderPath, sharedMetaPrefix));
443
- if (!meta?.title || meta.title === page.title) {
482
+ if (
483
+ !meta?.title ||
484
+ meta.title === page.title ||
485
+ meta.title === page.meta.sidebar.label
486
+ ) {
444
487
  return undefined;
445
488
  }
446
489
  return {
447
490
  code: "BLUME_NAV_INDEX_TITLE_MISMATCH",
448
491
  file: page.sourcePath ?? page.id,
449
- message: `Index page "${page.navPath}" has title "${page.title}", but its folder's meta.title is "${meta.title}" — the sidebar shows the folder title while the page's own <title>/heading still say "${page.title}".`,
492
+ message: `Index page "${page.navPath}" has title "${page.title}", but its folder's meta.title is "${meta.title}". The page hides its own sidebar row, so "${meta.title}" is the only sidebar label it has.`,
450
493
  severity: "warning",
451
- suggestion: `Update the page's frontmatter title to match ("${meta.title}"), or leave it if the divergence is intentional.`,
494
+ suggestion: `Change the page's title or the folder's meta.ts title so they match, set the page's sidebar.label to "${meta.title}" to keep its title on purpose, or remove sidebar.hidden so the sidebar also lists the page under its own label.`,
452
495
  };
453
496
  };
454
497
 
@@ -622,10 +665,16 @@ const hoistPages = (
622
665
  * group's *children*, not the tree root — so hoisting only the root leaves a
623
666
  * tab section's loose pages interleaved with its groups. Hoist the top level of
624
667
  * every group that owns a tab (matched on its URL path), mirroring the root.
668
+ *
669
+ * The section is the outermost group at the tab's path, the one render-time
670
+ * scoping shows (`sectionChildren`). A `(group)` folder inside it adds no
671
+ * route segment, so it shares that path, but it is a group in the section, not
672
+ * the section itself: below a matched group its path no longer matches, and
673
+ * the (group)'s own level hoists only by the `hoistPages` rules.
625
674
  */
626
675
  const hoistTabSections = (
627
676
  nodes: MutableNode[],
628
- tabPaths: Set<string>,
677
+ tabPaths: ReadonlySet<string>,
629
678
  display: SidebarDisplay
630
679
  ): void => {
631
680
  for (const node of nodes) {
@@ -634,6 +683,10 @@ const hoistTabSections = (
634
683
  }
635
684
  if (node.routePath !== undefined && tabPaths.has(node.routePath)) {
636
685
  hoistPages(node.children, display, true);
686
+ const inner = new Set(tabPaths);
687
+ inner.delete(node.routePath);
688
+ hoistTabSections(node.children, inner, display);
689
+ continue;
637
690
  }
638
691
  hoistTabSections(node.children, tabPaths, display);
639
692
  }
@@ -1049,6 +1102,11 @@ const resolveTabHref = (
1049
1102
  return path;
1050
1103
  }
1051
1104
  const rest = pathUnder(path, roots.tabs);
1105
+ // An archived tree's root tab links back to the current docs: its section
1106
+ // is the whole current tree, no page of which this tree holds.
1107
+ if (rest === "/" && roots.tree !== roots.tabs) {
1108
+ return path;
1109
+ }
1052
1110
  const section =
1053
1111
  rest === undefined || rest === "/" ? path : joinRoute(roots.tree, rest);
1054
1112
  let first: string | undefined;
@@ -1058,10 +1116,12 @@ const resolveTabHref = (
1058
1116
  if (route === section) {
1059
1117
  return true;
1060
1118
  }
1119
+ // `isUnderPath`, not a `${section}/` prefix: a root tab's section is
1120
+ // `/`, which every route sits under but none starts `//`.
1061
1121
  if (
1062
1122
  first === undefined &&
1063
1123
  route !== undefined &&
1064
- route.startsWith(`${section}/`)
1124
+ isUnderPath(route, section)
1065
1125
  ) {
1066
1126
  first = route;
1067
1127
  }
@@ -11,11 +11,21 @@ const ORDERING_PREFIX = /^(?<order>\d+)[-_.]/u;
11
11
  * (`2024-01-05`, `2024-01-05-first-post`), whose year would sort the sidebar
12
12
  * and leave only `01-05…` of the route.
13
13
  */
14
- const NOT_AN_ORDER = /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u;
14
+ const VERSION_OR_ISO_DATE = /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u;
15
+
16
+ /**
17
+ * The other dates a name can start with, kept whole the same way: day or
18
+ * month first with a four-digit year last (`12-05-2022`, `1-5-2022-launch`),
19
+ * and a year and month (`2024-01`, `2024-01-recap`). Blume once read their
20
+ * first number as an ordering prefix, so these names routed as `05-2022` and
21
+ * `01`; {@link formerRouteName} still knows those routes, so they redirect.
22
+ */
23
+ const DATE_NAME =
24
+ /^(?:(?:0?[1-9]|[12]\d|3[01])-(?:0?[1-9]|[12]\d|3[01])-\d{4}|\d{4}-(?:0[1-9]|1[0-2]))(?:[-_.]|$)/u;
15
25
 
16
26
  /** The digits of `name`'s ordering prefix (`01` of `01-intro`), if it has one. */
17
27
  export const orderingPrefix = (name: string): string | undefined =>
18
- NOT_AN_ORDER.test(name)
28
+ VERSION_OR_ISO_DATE.test(name) || DATE_NAME.test(name)
19
29
  ? undefined
20
30
  : name.match(ORDERING_PREFIX)?.groups?.order;
21
31
 
@@ -25,3 +35,13 @@ export const stripOrderingPrefix = (name: string): string => {
25
35
  // The prefix is the digits plus their one-character separator.
26
36
  return order === undefined ? name : name.slice(order.length + 1);
27
37
  };
38
+
39
+ /**
40
+ * The name an earlier Blume routed `name` as, when it differs from today's:
41
+ * a {@link DATE_NAME} lost its first number as an ordering prefix
42
+ * (`12-05-2022` routed as `05-2022`, `2024-01` as `01`).
43
+ */
44
+ export const formerRouteName = (name: string): string | undefined =>
45
+ DATE_NAME.test(name) && !VERSION_OR_ISO_DATE.test(name)
46
+ ? name.slice(name.indexOf("-") + 1)
47
+ : undefined;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A pinned heading id (`## Heading [#a--b]`, `{#a--b}`) is used verbatim, but
3
+ * the parse that reads it applies smart punctuation (Astro's `smartypants`,
4
+ * on by default) to the whole heading first: `--` became `–`, `...` became
5
+ * `…`, and quotes curled, so the id rendered as `a–b` while every link
6
+ * written to it says `#a--b`. Smart punctuation leaves a backslash-escaped
7
+ * character alone, so before a source is parsed, the punctuation in each
8
+ * heading's trailing markers is escaped where the author wrote it, and the
9
+ * parse reads the id as typed. An id with a typed `–` keeps it.
10
+ *
11
+ * The page render, each `<include>` splice, and the scan-time heading reader
12
+ * all escape their source this way, so the rendered id and the anchor index
13
+ * `blume validate` checks against agree.
14
+ */
15
+
16
+ import type { Heading, Nodes } from "mdast";
17
+ import { markdownToMdast, mdxToMdast } from "satteri";
18
+ import type { SourceFormat } from "satteri";
19
+
20
+ import {
21
+ MARKDOWN_BODY_FEATURES,
22
+ MDX_BODY_FEATURES,
23
+ } from "../markdown/features.ts";
24
+ import { parseHeadingMarkers } from "./heading-markers.ts";
25
+
26
+ /** What smart punctuation rewrites: dashes, an ellipsis, and quotes. */
27
+ const SMART_INPUT = /--|\.\.\.|['"]/u;
28
+
29
+ /**
30
+ * A run that opens a pinned id, up to whitespace or a closing bracket. The
31
+ * runs never overlap, so finding them all stays linear.
32
+ */
33
+ const PIN_RUN = /[[{]#[^\s\]}]*/gu;
34
+
35
+ /** A backslash escape (kept as is), or a character smart punctuation reads. */
36
+ const PUNCTUATION = /\\[\s\S]|[-.'"]/gu;
37
+
38
+ const escapePunctuation = (text: string): string =>
39
+ text.replaceAll(PUNCTUATION, (match) =>
40
+ match.length === 1 ? `\\${match}` : match
41
+ );
42
+
43
+ /** Whether any pinned id in the source holds something smart punctuation rewrites. */
44
+ const hasPunctuatedPin = (source: string): boolean => {
45
+ for (const [run] of source.matchAll(PIN_RUN)) {
46
+ if (SMART_INPUT.test(run)) {
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ };
52
+
53
+ /** One replacement in the source: `text` in place of `from`…`end`. */
54
+ interface Edit {
55
+ end: number;
56
+ from: number;
57
+ text: string;
58
+ }
59
+
60
+ /**
61
+ * The escape a heading's trailing markers need, if any. Markers only count at
62
+ * the end of a heading's last text node (see `markdown/heading-anchors.ts`),
63
+ * and a heading that is nothing but markers keeps them as literal text, so
64
+ * neither is touched here either.
65
+ */
66
+ const pinEdit = (heading: Heading, source: string): Edit | undefined => {
67
+ const last = heading.children.at(-1);
68
+ const start = last?.position?.start.offset;
69
+ const end = last?.position?.end.offset;
70
+ if (last?.type !== "text" || start === undefined || end === undefined) {
71
+ return undefined;
72
+ }
73
+ const parsed = parseHeadingMarkers(last.value);
74
+ if (
75
+ parsed.id === undefined ||
76
+ (parsed.text === "" && heading.children.length === 1)
77
+ ) {
78
+ return undefined;
79
+ }
80
+ // The same markers, read where the author wrote them.
81
+ const written = parseHeadingMarkers(source.slice(start, end));
82
+ const from = start + written.text.length;
83
+ const markers = source.slice(from, end);
84
+ if (written.id === undefined || !SMART_INPUT.test(markers)) {
85
+ return undefined;
86
+ }
87
+ return { end, from, text: escapePunctuation(markers) };
88
+ };
89
+
90
+ /** Collect the edits for every heading under `node`, in source order. */
91
+ const collectEdits = (node: Nodes, source: string, edits: Edit[]): void => {
92
+ if (node.type === "heading") {
93
+ const edit = pinEdit(node, source);
94
+ if (edit) {
95
+ edits.push(edit);
96
+ }
97
+ return;
98
+ }
99
+ if ("children" in node) {
100
+ for (const child of node.children) {
101
+ collectEdits(child, source, edits);
102
+ }
103
+ }
104
+ };
105
+
106
+ const parse = (source: string, format: SourceFormat): Nodes | undefined => {
107
+ try {
108
+ return format === "mdx"
109
+ ? mdxToMdast(source, { features: MDX_BODY_FEATURES })
110
+ : markdownToMdast(source, { features: MARKDOWN_BODY_FEATURES });
111
+ } catch {
112
+ // A source MDX can't parse fails its own render, with its own error.
113
+ return undefined;
114
+ }
115
+ };
116
+
117
+ /**
118
+ * `source` with the smart-punctuation characters in each heading's pinned id
119
+ * backslash-escaped, so a parse with smart punctuation reads the id as
120
+ * written. `source` must be the whole text the parse will read, link
121
+ * definitions included: they decide which brackets are links. A source
122
+ * without such a pin is returned as is, unparsed.
123
+ */
124
+ export const escapePinnedIds = (
125
+ source: string,
126
+ format: SourceFormat
127
+ ): string => {
128
+ const tree = hasPunctuatedPin(source) ? parse(source, format) : undefined;
129
+ if (!tree) {
130
+ return source;
131
+ }
132
+ const edits: Edit[] = [];
133
+ collectEdits(tree, source, edits);
134
+ let escaped = "";
135
+ let at = 0;
136
+ for (const edit of edits) {
137
+ escaped += source.slice(at, edit.from) + edit.text;
138
+ at = edit.end;
139
+ }
140
+ return escaped + source.slice(at);
141
+ };