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
@@ -11,10 +11,18 @@ import { mountBasePath } from "../base-path.ts";
11
11
  import { nextFenceState } from "../code-fences.ts";
12
12
  import type { FenceState } from "../code-fences.ts";
13
13
  import { diagnosticsFromIssues, diagnosticsFromZod } from "../diagnostics.ts";
14
- import { occupySlug, parseHeadingMarkers } from "../heading-markers.ts";
14
+ import {
15
+ headingSlug,
16
+ joinHeadingText,
17
+ occupySlug,
18
+ parseHeadingMarkers,
19
+ rawHeadingTag,
20
+ } from "../heading-markers.ts";
15
21
  import { localePlacement, localizeRoute } from "../i18n.ts";
22
+ import { extractElementUses } from "../mdx-elements.ts";
16
23
  import { titleWord } from "../navigation.ts";
17
- import { stripOrderingPrefix } from "../ordering-prefix.ts";
24
+ import { formerRouteName, stripOrderingPrefix } from "../ordering-prefix.ts";
25
+ import { escapePinnedIds } from "../pinned-ids.ts";
18
26
  import { relatedPageLinks } from "../related.ts";
19
27
  import { pageMetaSchema } from "../schema.ts";
20
28
  import type {
@@ -25,6 +33,7 @@ import type {
25
33
  import { trimChar } from "../trim.ts";
26
34
  import type {
27
35
  Diagnostic,
36
+ ElementUse,
28
37
  ExampleUse,
29
38
  Heading,
30
39
  PageLink,
@@ -157,6 +166,14 @@ export const frontmatterYamlDiagnostic = (
157
166
  'Wrap the value at that line in double quotes, e.g. description: "Setup: the easy way".',
158
167
  });
159
168
 
169
+ /**
170
+ * A file or folder name as its route segment spells it: without its ordering
171
+ * prefix, or — for the `former` route — as an earlier Blume dropped one (see
172
+ * {@link formerRouteName}).
173
+ */
174
+ const cleanName = (part: string, former: boolean): string =>
175
+ (former ? formerRouteName(part) : undefined) ?? stripNumericPrefix(part);
176
+
160
177
  /**
161
178
  * Fold one raw path part into the accumulating route segments/groups.
162
179
  * `ordered` parts are file or folder names, whose ordering prefix is dropped.
@@ -165,7 +182,8 @@ const addRouteSegment = (
165
182
  part: string,
166
183
  segments: string[],
167
184
  groups: string[],
168
- ordered: boolean
185
+ ordered: boolean,
186
+ former: boolean
169
187
  ): void => {
170
188
  // A leading/trailing/double slash yields an empty part; keeping it would
171
189
  // produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
@@ -177,7 +195,7 @@ const addRouteSegment = (
177
195
  groups.push(group);
178
196
  return;
179
197
  }
180
- const clean = ordered ? stripNumericPrefix(part) : part;
198
+ const clean = ordered ? cleanName(part, former) : part;
181
199
  if (clean === "index") {
182
200
  return;
183
201
  }
@@ -202,12 +220,14 @@ interface MappedRoute {
202
220
  * `ordered` says they are file and folder names: the route prefix, a slug,
203
221
  * a release tag, or a CMS slug is a route spelled out, kept as written. Not
204
222
  * exported: a source that needs to predict a route goes through
205
- * {@link resolveEntryRoute}, so there is exactly one derivation.
223
+ * {@link resolveEntryRoute}, so there is exactly one derivation. `former`
224
+ * maps the route an earlier Blume gave the path instead.
206
225
  */
207
226
  const mapRoute = (
208
227
  prefix: string | undefined,
209
228
  relativePath: string,
210
- ordered: boolean
229
+ ordered: boolean,
230
+ former = false
211
231
  ): MappedRoute => {
212
232
  const withoutExt = relativePath.slice(
213
233
  0,
@@ -218,10 +238,10 @@ const mapRoute = (
218
238
  const groups: string[] = [];
219
239
 
220
240
  for (const part of prefix ? prefix.split("/") : []) {
221
- addRouteSegment(part, segments, groups, false);
241
+ addRouteSegment(part, segments, groups, false, false);
222
242
  }
223
243
  for (const part of withoutExt.split("/")) {
224
- addRouteSegment(part, segments, groups, ordered);
244
+ addRouteSegment(part, segments, groups, ordered, former);
225
245
  }
226
246
 
227
247
  const route = segments.length === 0 ? "/" : `/${segments.join("/")}`;
@@ -271,6 +291,59 @@ const PROMPT_CLOSE = /<\/Prompt>/u;
271
291
  const BARE_CURLY_MARKER =
272
292
  /(?<!\\)(?<marker>\{:?\s*#(?<id>[^\s}]+)\s*\})(?:\s*\[(?:#[^\s\]]+|!?toc)\])*\s*$/u;
273
293
 
294
+ // One attribute in a brace-wrapped attribute list (MkDocs' `attr_list`,
295
+ // kramdown's IAL): `#id`, `.class`, or `key=value`, quoted or not.
296
+ export const ATTRIBUTE = String.raw`(?:#[\w-]+|\.[A-Za-z_-][\w-]*|[\w-]+=(?:"[^"\n]*"|'[^'\n]*'|[^\s"'{}]+))`;
297
+ const ATTRIBUTES = new RegExp(ATTRIBUTE, "gu");
298
+
299
+ // A heading's trailing attribute list of more than one attribute, in the
300
+ // place `BARE_CURLY_MARKER` reads (`## Setup { #setup .wide }`, `{: .wide
301
+ // #setup }`). Neither format reads it: it stays in an `.md` heading's text,
302
+ // and `.mdx` reads it as a JSX expression. One that sets an id is reported
303
+ // with the `{#id}` markers, since pinning that id is what it was for.
304
+ const HEADING_ATTRIBUTE_LIST = new RegExp(
305
+ String.raw`(?<!\\)(?<marker>\{:?[\t ]*${ATTRIBUTE}(?:[\t ]+${ATTRIBUTE})+[\t ]*\})(?:\s*\[(?:#[^\s\]]+|!?toc)\])*\s*$`,
306
+ "u"
307
+ );
308
+
309
+ /** A heading's trailing `{#id}` marker, where its text holds one. */
310
+ export interface TrailingCurlyMarker {
311
+ /** Whether the braces also set classes or attributes (`{ #id .wide }`). */
312
+ attributes: boolean;
313
+ id: string;
314
+ /** Where the marker starts in the text. */
315
+ index: number;
316
+ /** The braces as written. */
317
+ marker: string;
318
+ }
319
+
320
+ /**
321
+ * The `{#id}` marker a heading's text ends with: one of the spellings
322
+ * `BARE_CURLY_MARKER` matches, or an attribute list that sets an id among
323
+ * other attributes (see `HEADING_ATTRIBUTE_LIST`).
324
+ */
325
+ export const trailingCurlyMarker = (
326
+ text: string
327
+ ): TrailingCurlyMarker | undefined => {
328
+ const bare = BARE_CURLY_MARKER.exec(text);
329
+ if (bare?.groups?.id !== undefined && bare.groups.marker !== undefined) {
330
+ return {
331
+ attributes: false,
332
+ id: bare.groups.id,
333
+ index: bare.index,
334
+ marker: bare.groups.marker,
335
+ };
336
+ }
337
+ const list = HEADING_ATTRIBUTE_LIST.exec(text);
338
+ const marker = list?.groups?.marker ?? "";
339
+ const id = [...marker.matchAll(ATTRIBUTES)]
340
+ .find(([attribute]) => attribute.startsWith("#"))?.[0]
341
+ .slice(1);
342
+ return list && id !== undefined
343
+ ? { attributes: true, id, index: list.index, marker }
344
+ : undefined;
345
+ };
346
+
274
347
  // A raw HTML element carrying an `id` — `<a id="…">`, `<section id='…'>`,
275
348
  // the unquoted `<a id=plain>`, or the JSX spelling `<div id={"…"}>` — whose
276
349
  // element is a fragment-link target outside headings. Lowercase tags only: a
@@ -278,16 +351,30 @@ const BARE_CURLY_MARKER =
278
351
  // `JSX_OPEN` split below). `[^<>]` bounds the attribute run, so a wrapped tag
279
352
  // (`<div\n id="x"\n>`) still matches while a long tag-free line can't
280
353
  // backtrack quadratically, and the run never reaches into a neighboring tag.
354
+ //
355
+ // `HTML_ID`, `ELEMENT_HREF`, `COMPONENT_PATH`, and `JSX_OPEN` open with the
356
+ // lookbehind `(?<=(?:^|[^\\])(?:\\\\)*)`: a `<` opens a tag only after an even
357
+ // run of backslashes (none, or `\\` pairs, each an escaped backslash). An odd
358
+ // run escapes the `<` itself, so `\<a id="x">` and `Promise\<App>` render as
359
+ // text in `.md` and `.mdx` alike.
281
360
  const HTML_ID =
282
- /<[a-z][a-z0-9-]*(?:\s[^<>]*?)?\sid=(?:(?<quote>["'])(?<quoted>[^"'<>]+)\k<quote>|\{(?<jsxQuote>["'])(?<jsx>[^"'<>]+)\k<jsxQuote>\}|(?<bare>[^\s"'=<>`{}]+))/gu;
361
+ /(?<=(?:^|[^\\])(?:\\\\)*)<[a-z][a-z0-9-]*(?:\s[^<>]*?)?\sid=(?:(?<quote>["'])(?<quoted>[^"'<>]+)\k<quote>|\{(?<jsxQuote>["'])(?<jsx>[^"'<>]+)\k<jsxQuote>\}|(?<bare>[^\s"'=<>`{}]+))/gu;
362
+ // An `<a name="…">`, read like `HTML_ID`: a browser scrolls a fragment to an
363
+ // `a` element whose `name` matches it (the HTML spec's indicated part of the
364
+ // document), so it's an anchor target too. On any other element, `name` is
365
+ // no target.
366
+ const HTML_A_NAME =
367
+ /(?<=(?:^|[^\\])(?:\\\\)*)<a(?:\s[^<>]*?)?\sname=(?:(?<quote>["'])(?<quoted>[^"'<>]+)\k<quote>|\{(?<jsxQuote>["'])(?<jsx>[^"'<>]+)\k<jsxQuote>\}|(?<bare>[^\s"'=<>`{}]+))/gu;
283
368
  // Commented-out markup renders nothing; matched across lines once the
284
369
  // scannable lines are joined back together. Shared with the search
285
370
  // extractor so both agree on what a comment is.
286
371
  export const HTML_COMMENT = /<!--[\s\S]*?-->/gu;
287
372
  export const INLINE_CODE = /`[^`]*`/gu;
288
373
 
289
- /** A heading whose trailing `{#id}` marker is unescaped — see `BARE_CURLY_MARKER`. */
374
+ /** A heading whose trailing `{#id}` marker is unescaped — see `trailingCurlyMarker`. */
290
375
  export interface CurlyMarker {
376
+ /** Whether the braces also set classes or attributes (`{ #id .wide }`). */
377
+ attributes: boolean;
291
378
  id: string;
292
379
  /** 1-based line of the heading (a setext heading's first text line) in the body. */
293
380
  line: number;
@@ -403,6 +490,10 @@ const SMART_PUNCTUATION = /['"]|--|\.\.\./u;
403
490
  const REF_DEFINITION =
404
491
  /^(?: {0,3}|[ \t]*(?:(?:>|(?:[-*+]|\d{1,9}[.)])[ \t])[ \t]*)+)\[(?<label>[^\]]+)\]:[ \t]*(?<rest>.*)$/u;
405
492
 
493
+ /** A link label as CommonMark matches it: case-folded, whitespace collapsed. */
494
+ const labelKey = (label: string): string =>
495
+ label.trim().replaceAll(/\s+/gu, " ").toLowerCase();
496
+
406
497
  /**
407
498
  * The normalized labels of every link-reference definition in the body
408
499
  * (outside fenced code), footnote definitions (`^1`) included. A heading
@@ -437,7 +528,7 @@ const refDefinitionLabels = (lines: readonly string[]): Set<string> => {
437
528
  if (destination === "" && (lines[index + 1] ?? "").trim() === "") {
438
529
  continue;
439
530
  }
440
- labels.add(groups.label.trim().replaceAll(/\s+/gu, " ").toLowerCase());
531
+ labels.add(labelKey(groups.label));
441
532
  }
442
533
  return labels;
443
534
  };
@@ -534,9 +625,72 @@ const inlineText = (nodes: readonly Nodes[], context: HeadingContext): string =>
534
625
  })
535
626
  .join("");
536
627
 
628
+ /** A heading's text, with what its own empty anchor pins. */
629
+ interface HeadingContent {
630
+ /** The `id` (else `name`) of an empty `<a>` in the heading. */
631
+ target?: string;
632
+ text: string;
633
+ }
634
+
635
+ /** True for a raw `</a>`. */
636
+ const isAnchorClose = (node: Nodes | undefined): boolean =>
637
+ node?.type === "html" && rawHeadingTag(node.value).kind === "anchor-close";
638
+
639
+ /**
640
+ * A heading's text and own anchor, read the way `markdown/heading-anchors`
641
+ * reads them: an empty `<a>` (`<a id="x"></a>`, `<a name="x" />`) is the
642
+ * heading's own anchor, whose `id` (else `name`) pins the heading's id unless
643
+ * it is all the heading holds, and a `<Badge>`'s text is no part of the
644
+ * heading's text. In `.mdx` both are JSX, which this `.md` parse reads as the
645
+ * raw tags around their content, as the renderer does for `.md`.
646
+ */
647
+ const headingContent = (
648
+ children: readonly Nodes[],
649
+ context: HeadingContext
650
+ ): HeadingContent => {
651
+ const pieces: (string | null)[] = [];
652
+ let target: string | undefined;
653
+ let kept = 0;
654
+ let badges = false;
655
+ let badgeDepth = 0;
656
+ let closesAnchor = false;
657
+ for (const [index, child] of children.entries()) {
658
+ if (closesAnchor) {
659
+ closesAnchor = false;
660
+ continue;
661
+ }
662
+ const tag = child.type === "html" ? rawHeadingTag(child.value) : null;
663
+ if (
664
+ tag?.kind === "anchor-open" &&
665
+ (tag.selfClosing || isAnchorClose(children[index + 1]))
666
+ ) {
667
+ target ??= tag.target;
668
+ closesAnchor = !tag.selfClosing;
669
+ continue;
670
+ }
671
+ kept += 1;
672
+ if (tag?.kind === "badge") {
673
+ badges = true;
674
+ pieces.push(null);
675
+ if (!tag.selfClosing) {
676
+ badgeDepth = Math.max(0, badgeDepth + (tag.closing ? -1 : 1));
677
+ }
678
+ } else if (badgeDepth === 0) {
679
+ pieces.push(inlineText([child], context));
680
+ }
681
+ }
682
+ return {
683
+ target: kept === 0 ? undefined : target,
684
+ text: badges ? joinHeadingText(pieces) : pieces.join(""),
685
+ };
686
+ };
687
+
537
688
  /** A heading's rendered text plus the trailing markers the renderer strips. */
538
689
  interface RenderedHeading {
539
- /** Author-pinned anchor id from `[#id]`/`{#id}`, used verbatim. */
690
+ /**
691
+ * Author-pinned anchor id from `[#id]`/`{#id}`, else from the heading's own
692
+ * empty `<a>`, used verbatim.
693
+ */
540
694
  id?: string;
541
695
  /** The text content the renderer slugs, markers stripped, untrimmed. */
542
696
  text: string;
@@ -560,7 +714,8 @@ const plainHeading = (raw: string): RenderedHeading => {
560
714
  * text remains. Parsing (rather than regex-stripping) resolves escapes —
561
715
  * `\[toc]` still reaches the marker parse as `[toc]` — and emphasis the way
562
716
  * CommonMark does, so `snake_case` stays whole while `_note_` loses its
563
- * underscores.
717
+ * underscores. A pinned id's punctuation is escaped first, as the renderer
718
+ * escapes it, so smart punctuation leaves `[#a--b]` as written.
564
719
  */
565
720
  const renderHeading = (
566
721
  raw: string,
@@ -573,7 +728,10 @@ const renderHeading = (
573
728
  }
574
729
  const source = form === "atx" ? `# ${raw}` : `${raw}\n=`;
575
730
  const tree = markdownToMdast(
576
- `${source}${definitionsFor(raw, context.labels)}`,
731
+ escapePinnedIds(
732
+ `${source}${definitionsFor(raw, context.labels)}`,
733
+ "markdown"
734
+ ),
577
735
  parse
578
736
  );
579
737
  const heading = "children" in tree ? tree.children.at(0) : undefined;
@@ -584,18 +742,18 @@ const renderHeading = (
584
742
  return plainHeading(raw);
585
743
  }
586
744
  const { children } = heading;
587
- const text = inlineText(children, context);
745
+ const { target, text } = headingContent(children, context);
588
746
  const last = children.at(-1);
589
747
  if (last?.type !== "text") {
590
- return { text };
748
+ return { id: target, text };
591
749
  }
592
750
  const markers = parseHeadingMarkers(last.value);
593
751
  const stripped = last.value.length - markers.text.length;
594
752
  if (stripped === 0 || (markers.text === "" && children.length === 1)) {
595
- return { text };
753
+ return { id: target, text };
596
754
  }
597
755
  return {
598
- id: markers.id,
756
+ id: markers.id ?? target,
599
757
  text: text.slice(0, text.length - stripped),
600
758
  toc: markers.toc,
601
759
  };
@@ -640,7 +798,7 @@ const toHeading = (
640
798
  return { heading: { depth, slug: rendered.id, text }, pinned: true };
641
799
  }
642
800
  return {
643
- heading: { depth, slug: slugger.slug(rendered.text), text },
801
+ heading: { depth, slug: headingSlug(slugger, rendered.text), text },
644
802
  pinned: false,
645
803
  };
646
804
  };
@@ -662,9 +820,10 @@ const noteCurlyMarker = (
662
820
  line: number,
663
821
  state: HeadingScanState
664
822
  ): void => {
665
- const groups = text.match(BARE_CURLY_MARKER)?.groups;
666
- if (groups?.id !== undefined && groups.marker !== undefined) {
667
- state.curlyMarkers.push({ id: groups.id, line, marker: groups.marker });
823
+ const found = trailingCurlyMarker(text);
824
+ if (found) {
825
+ const { attributes, id, marker } = found;
826
+ state.curlyMarkers.push({ attributes, id, line, marker });
668
827
  }
669
828
  };
670
829
 
@@ -832,10 +991,10 @@ export interface HeadingSite {
832
991
  /** Everything one walk over a body yields for the anchor index. */
833
992
  export interface BodyScan {
834
993
  /**
835
- * Raw HTML element ids (`<a id="…">`) outside headings, fences, inline
836
- * code, comments, and `<Prompt>` regions — fragment-link targets that
837
- * `blume validate` accepts alongside heading slugs. Deduplicated, in
838
- * document order.
994
+ * Raw HTML element ids (`<a id="…">`) and `<a name="…">` names outside
995
+ * headings, fences, inline code, comments, and `<Prompt>` regions —
996
+ * fragment-link targets that `blume validate` accepts alongside heading
997
+ * slugs. Deduplicated, in document order.
839
998
  */
840
999
  anchors: string[];
841
1000
  /** Headings whose trailing `{#id}` marker is unescaped, for `.mdx` pages. */
@@ -888,7 +1047,10 @@ export const scanBody = (body: string): BodyScan => {
888
1047
  // can splice its neighbors into a new `<!--` (`<!-<!-- x -->->`), and a
889
1048
  // space keeps a tag from fusing with an `id=` that only a comment separated.
890
1049
  const markup = state.anchorLines.join("\n").replaceAll(HTML_COMMENT, " ");
891
- for (const match of markup.matchAll(HTML_ID)) {
1050
+ for (const match of [
1051
+ ...markup.matchAll(HTML_ID),
1052
+ ...markup.matchAll(HTML_A_NAME),
1053
+ ].toSorted((a, b) => a.index - b.index)) {
892
1054
  const id = match.groups?.quoted ?? match.groups?.jsx ?? match.groups?.bare;
893
1055
  if (id !== undefined) {
894
1056
  anchors.add(id);
@@ -905,131 +1067,273 @@ export const scanBody = (body: string): BodyScan => {
905
1067
  export const extractHeadings = (body: string): Heading[] =>
906
1068
  scanBody(body).headings;
907
1069
 
1070
+ // A backslash escape: before ASCII punctuation, a backslash makes the
1071
+ // character literal (CommonMark), and the renderer drops it.
1072
+ const ESCAPED = /\\(?<char>[!-/:-@[-`{-~])/gu;
1073
+ // One character of a bare link destination: an escape (`\(` is a literal
1074
+ // parenthesis, opening no group), a backslash that escapes nothing, or any
1075
+ // other character but a parenthesis or whitespace. The alternatives never
1076
+ // overlap, so a long run can't backtrack.
1077
+ const DESTINATION_CHAR = String.raw`\\[!-/:-@[-\x60{-~]|\\(?![!-/:-@[-\x60{-~])|[^()\s\\]`;
1078
+ // A link destination as written (CommonMark): `<…>`, which may hold spaces
1079
+ // and unbalanced parentheses (Prettier writes this form for a URL with
1080
+ // either), or a run that doesn't start with `<` and admits one level of
1081
+ // balanced parens, so a Wikipedia-style URL (`/wiki/Foo_(bar)`) isn't
1082
+ // truncated at its first `)`. {@link destinationLink} reads it.
1083
+ const DESTINATION = String.raw`<(?:\\[!-/:-@[-\x60{-~]|\\(?![!-/:-@[-\x60{-~])|[^<>\n\\])*>|(?!<)(?:${DESTINATION_CHAR}|\((?:${DESTINATION_CHAR})*\))+`;
1084
+ // A bare destination on its own, for telling whether a target can be written
1085
+ // back without angle brackets (see `writeDestination`).
1086
+ const BARE_DESTINATION = new RegExp(
1087
+ String.raw`^(?!<)(?:${DESTINATION_CHAR}|\((?:${DESTINATION_CHAR})*\))+$`,
1088
+ "u"
1089
+ );
1090
+ // A link title (CommonMark): `"…"`, `'…'`, or `(…)`, each holding backslash
1091
+ // escapes. Every alternative starts with its own delimiter, so they never
1092
+ // overlap.
1093
+ const TITLE = String.raw`"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'|\((?:[^()\\]|\\[\s\S])*\)`;
1094
+ // What follows a link's `[label]`: the destination, an optional title, and
1095
+ // the spaces CommonMark allows around both, in parentheses. The destination
1096
+ // holds no whitespace and the title starts with a delimiter, so each space
1097
+ // run has one home.
1098
+ const DESTINATION_TAIL = String.raw`\([ \t]*(?<target>${DESTINATION})(?:[ \t]+(?<title>${TITLE}))?[ \t]*\)`;
1099
+
908
1100
  // The label admits one level of nested brackets so an image-wrapped link
909
1101
  // (`[![alt](/img.png)](/target)`) matches as the *outer* link — with a flat
910
1102
  // `[^\]]*` label the match stopped at the image's `]` and the outer target was
911
- // never seen. The target admits one level of balanced parens so a Wikipedia-
912
- // style URL (`/wiki/Foo_(bar)`) isn't truncated at its first `)`.
913
- const MD_LINK =
914
- /\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
1103
+ // never seen. The target is the destination as written (see `DESTINATION`).
1104
+ // Matches carry indices (`d`), which locate the target past any spaces.
1105
+ const MD_LINK = new RegExp(
1106
+ String.raw`\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]${DESTINATION_TAIL}`,
1107
+ "dgu"
1108
+ );
915
1109
  // An image inside a link label; its target was matched (and so validated) as a
916
1110
  // link of its own before labels admitted nesting, and still should be.
917
- export const MD_IMAGE =
918
- /!\[[^\]]*\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
1111
+ const LABEL_IMAGE = new RegExp(
1112
+ String.raw`!\[[^\]]*\]${DESTINATION_TAIL}`,
1113
+ "dgu"
1114
+ );
1115
+ // An image embed, the way the include and content-asset rewriters move one:
1116
+ // every destination form (`<…>`, escapes, a title in any of its quotes),
1117
+ // read and written back by `rewriteImageTargets`.
1118
+ const MD_IMAGE = new RegExp(
1119
+ String.raw`!\[(?:[^[\]]|\[[^\]]*\])*\]${DESTINATION_TAIL}`,
1120
+ "dgu"
1121
+ );
1122
+
1123
+ /** Where a match's `target` group starts, as an offset into the matched text. */
1124
+ const targetStart = (match: RegExpExecArray | RegExpMatchArray): number =>
1125
+ (match.indices?.groups?.target?.[0] ?? match.index ?? 0) - (match.index ?? 0);
1126
+
1127
+ /** A destination as written, and the target the renderer reads from it. */
1128
+ interface ReadDestination {
1129
+ /** Whether it was written in `<…>`. */
1130
+ angled: boolean;
1131
+ /** The text the target is read from: inside any brackets. */
1132
+ source: string;
1133
+ target: string;
1134
+ }
919
1135
 
920
- /** Column (0-based, within `matched`) where a link/image match's target starts. */
921
- export const targetOffsetIn = (
922
- matched: string,
923
- target: string,
924
- title: string | undefined
925
- ): number => matched.length - 1 - (title?.length ?? 0) - target.length;
1136
+ /**
1137
+ * A destination as written, read the way the renderer reads it: angle
1138
+ * brackets dropped and backslash escapes resolved, so `<https://x.dev/a(b>`
1139
+ * is `https://x.dev/a(b` and `image%20\(1\).png` is `image%20(1).png`.
1140
+ */
1141
+ const readDestination = (written: string): ReadDestination => {
1142
+ const angled = written.startsWith("<");
1143
+ const source = angled ? written.slice(1, -1) : written;
1144
+ return { angled, source, target: source.replaceAll(ESCAPED, "$<char>") };
1145
+ };
1146
+
1147
+ /**
1148
+ * Write `target` back as a destination: as it stands where the bare form
1149
+ * reads it unchanged, else with its parentheses and backslashes escaped, and
1150
+ * in angle brackets where it was written in them or holds whitespace, which
1151
+ * no bare destination can.
1152
+ */
1153
+ const writeDestination = (target: string, angled: boolean): string => {
1154
+ if (!angled && BARE_DESTINATION.test(target) && !target.includes("\\")) {
1155
+ return target;
1156
+ }
1157
+ if (!(angled || /[\s<>]/u.test(target))) {
1158
+ return target.replaceAll(/[\\()]/gu, String.raw`\$&`);
1159
+ }
1160
+ return `<${target.replaceAll(/[\\<>]/gu, String.raw`\$&`)}>`;
1161
+ };
1162
+
1163
+ /**
1164
+ * Rewrite the target of every image embed on one line with `rewrite`, which
1165
+ * gets the target as the renderer reads it (see `readDestination`) and
1166
+ * returns the new one, or `null` to leave it as written. Inline code is
1167
+ * skipped: matches run on a copy with code spans blanked, and replacements
1168
+ * splice into the real line by index, which the same-length mask keeps in
1169
+ * step. A rewritten target is written back in the form its destination took.
1170
+ */
1171
+ export const rewriteImageTargets = (
1172
+ line: string,
1173
+ rewrite: (target: string) => string | null
1174
+ ): string => {
1175
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
1176
+ " ".repeat(span.length)
1177
+ );
1178
+ let out = "";
1179
+ let cursor = 0;
1180
+ for (const match of masked.matchAll(MD_IMAGE)) {
1181
+ const written = match.groups?.target ?? "";
1182
+ const { angled, target } = readDestination(written);
1183
+ const next = rewrite(target);
1184
+ if (next === null) {
1185
+ continue;
1186
+ }
1187
+ const at = match.index + targetStart(match);
1188
+ out += line.slice(cursor, at) + writeDestination(next, angled);
1189
+ cursor = at + written.length;
1190
+ }
1191
+ return out + line.slice(cursor);
1192
+ };
1193
+
1194
+ /**
1195
+ * The link a destination written at `at` (the 1-based position of its first
1196
+ * character) leads to, as the renderer reads it (see `readDestination`). It's
1197
+ * located where the target's text starts, inside any brackets. An empty `<>`
1198
+ * leads nowhere.
1199
+ */
1200
+ const destinationLink = (
1201
+ written: string,
1202
+ at: Pick<PageLink, "column" | "line">
1203
+ ): PageLink | undefined => {
1204
+ const { angled, source, target } = readDestination(written);
1205
+ if (target === "") {
1206
+ return undefined;
1207
+ }
1208
+ const link: PageLink = {
1209
+ column: at.column + (angled ? 1 : 0),
1210
+ line: at.line,
1211
+ target,
1212
+ };
1213
+ if (source.length !== target.length) {
1214
+ link.sourceLength = source.length;
1215
+ }
1216
+ return link;
1217
+ };
926
1218
 
927
1219
  // A link-reference definition's destination, at the start of what follows its
928
- // `[label]:` (see `REF_DEFINITION`): `<…>`, which may hold spaces, or else the
929
- // run up to the first space, before any title.
930
- const DEFINITION_DESTINATION = /^(?:<(?<angle>[^<>]*)>|(?<bare>\S+))/u;
1220
+ // `[label]:` (see `REF_DEFINITION`), and only when whitespace or the end of
1221
+ // the line follows it: `[1]: <src/x.ts` has an unclosed `<…>`, so it defines
1222
+ // nothing (CommonMark).
1223
+ const DEFINITION_DESTINATION = new RegExp(
1224
+ String.raw`^(?:${DESTINATION})(?=\s|$)`,
1225
+ "u"
1226
+ );
1227
+ // A whole definition on its line: the destination, then at most a title.
1228
+ // Anything else after it makes the line paragraph text (CommonMark).
1229
+ const DEFINITION_LINE = new RegExp(
1230
+ String.raw`^(?:${DESTINATION})(?:[ \t]+(?:${TITLE}))?[ \t]*$`,
1231
+ "u"
1232
+ );
931
1233
 
932
1234
  // A CommonMark autolink (`<https://example.com>`). Only http(s) targets are
933
1235
  // ever checked, so no other scheme is read. MDX rejects the syntax, so in
934
- // practice it's a `.md` page's.
935
- const AUTOLINK = /<(?<target>https?:\/\/[^\s<>]*)>/giu;
1236
+ // practice it's a `.md` page's. A link's `<…>` destination
1237
+ // (`[x](<https://…>)`) is no autolink.
1238
+ const AUTOLINK = /(?<!\]\()<(?<target>https?:\/\/[^\s<>]*)>/giu;
936
1239
 
937
1240
  /**
938
1241
  * Record the destination of a link-reference definition on `line`: a
939
1242
  * reference link (`[text][label]`, `[label]`) renders with it, so it's the
940
- * target to check, once for every link that cites it. A footnote definition
941
- * (`[^1]: …`) is no link; the links inside it are read like any others.
1243
+ * target to check, once for every link that cites it. Only the first
1244
+ * definition of a label counts (CommonMark), so a later one is skipped:
1245
+ * `defined` holds the labels defined so far. A footnote definition (`[^1]: …`)
1246
+ * is no link; the links inside it are read like any others. Returns whether
1247
+ * the line is a definition.
942
1248
  */
943
1249
  const scanDefinition = (
944
1250
  line: string,
945
1251
  lineNumber: number,
946
- links: PageLink[]
947
- ): void => {
1252
+ links: PageLink[],
1253
+ defined: Set<string>
1254
+ ): boolean => {
948
1255
  const groups = line.match(REF_DEFINITION)?.groups;
949
1256
  if (groups?.label === undefined || groups.label.startsWith("^")) {
950
- return;
1257
+ return false;
951
1258
  }
952
1259
  const rest = groups.rest ?? "";
953
- const destination = rest.match(DEFINITION_DESTINATION)?.groups;
954
- const target = destination?.angle ?? destination?.bare;
955
- if (target) {
956
- // `rest` runs to the end of the line; an angle-bracketed target starts
957
- // one past its `<`.
958
- const at = line.length - rest.length + (destination?.angle ? 1 : 0);
959
- links.push({ column: at + 1, line: lineNumber, target });
1260
+ const written = rest.match(DEFINITION_DESTINATION)?.[0];
1261
+ const key = labelKey(groups.label);
1262
+ if (written === undefined || key === "") {
1263
+ return false;
960
1264
  }
1265
+ if (!defined.has(key)) {
1266
+ defined.add(key);
1267
+ // `rest` runs to the end of the line, so the destination starts where it
1268
+ // does.
1269
+ const link = destinationLink(written, {
1270
+ column: line.length - rest.length + 1,
1271
+ line: lineNumber,
1272
+ });
1273
+ if (link) {
1274
+ links.push(link);
1275
+ }
1276
+ }
1277
+ return true;
961
1278
  };
962
1279
 
963
1280
  /**
964
- * Extract link targets from a markdown body for later validation, recording the
965
- * 1-based line/column of each target. Skips fenced code blocks and inline code.
966
- * `lineOffset` shifts every recorded line: the body is frontmatter-stripped, so
967
- * diagnostics that point into the raw file must add the stripped block's height.
1281
+ * Scan one line for link targets. The line comes masked (see `maskCode`):
1282
+ * code and comments are blanked with same-length padding, so recorded
1283
+ * columns stay accurate.
968
1284
  */
969
- /** Scan one line for link targets; returns the next fenced-block state. */
970
1285
  const scanLinkLine = (
971
- line: string,
1286
+ masked: string,
972
1287
  lineNumber: number,
973
- fence: FenceState,
974
- links: PageLink[]
975
- ): FenceState => {
976
- const next = nextFenceState(line, fence);
977
- // Skip fence delimiter lines themselves and anything inside a fence.
978
- if (fence !== null || next !== null) {
979
- return next;
980
- }
981
- // Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
982
- // with same-length padding so recorded columns stay accurate.
983
- const masked = line.replaceAll(INLINE_CODE, (span) =>
984
- " ".repeat(span.length)
985
- );
1288
+ links: PageLink[],
1289
+ defined: Set<string>
1290
+ ): void => {
986
1291
  for (const match of masked.matchAll(MD_LINK)) {
987
- const target = match.groups?.target;
988
- if (target === undefined || match.index === undefined) {
1292
+ const written = match.groups?.target;
1293
+ if (written === undefined || match.index === undefined) {
989
1294
  continue;
990
1295
  }
991
- // Locate the target by arithmetic from the match end rather than searching
992
- // for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
993
- // would otherwise report the column inside the label.
994
- const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
995
- const entry: PageLink = {
996
- column: match.index + targetOffset + 1,
1296
+ // Locate the target by its group's index rather than searching for its
1297
+ // text — a label that contains the same text (e.g. `[/a/b](/a/b)`) would
1298
+ // otherwise report the column inside the label.
1299
+ const entry = destinationLink(written, {
1300
+ column: match.index + targetStart(match) + 1,
997
1301
  line: lineNumber,
998
- target,
999
- };
1000
- // `MD_LINK` matches the `[label](target)` tail of an image embed too; the
1001
- // preceding `!` is what marks the target as going through the image
1002
- // pipeline rather than resolving as a site route.
1003
- if (masked[match.index - 1] === "!") {
1004
- entry.image = true;
1302
+ });
1303
+ if (entry) {
1304
+ // `MD_LINK` matches the `[label](target)` tail of an image embed too;
1305
+ // the preceding `!` is what marks the target as going through the
1306
+ // image pipeline rather than resolving as a site route.
1307
+ if (masked[match.index - 1] === "!") {
1308
+ entry.image = true;
1309
+ }
1310
+ links.push(entry);
1005
1311
  }
1006
- links.push(entry);
1007
1312
  // An image nested in the label (`[![alt](/img.png)](/target)`) carries its
1008
1313
  // own target; surface it too so a missing image is still caught.
1009
- const label = match[0].slice(0, targetOffset - "](".length);
1010
- for (const image of label.matchAll(MD_IMAGE)) {
1011
- const imageTarget = image.groups?.target;
1012
- if (imageTarget === undefined || image.index === undefined) {
1314
+ const label = `[${match.groups?.label ?? ""}`;
1315
+ for (const image of label.matchAll(LABEL_IMAGE)) {
1316
+ const imageWritten = image.groups?.target;
1317
+ if (imageWritten === undefined || image.index === undefined) {
1013
1318
  continue;
1014
1319
  }
1015
- links.push({
1016
- column:
1017
- match.index +
1018
- image.index +
1019
- targetOffsetIn(image[0], imageTarget, image.groups?.title) +
1020
- 1,
1021
- image: true,
1320
+ const imageLink = destinationLink(imageWritten, {
1321
+ column: match.index + image.index + targetStart(image) + 1,
1022
1322
  line: lineNumber,
1023
- target: imageTarget,
1024
1323
  });
1324
+ if (imageLink) {
1325
+ links.push({ ...imageLink, image: true });
1326
+ }
1025
1327
  }
1026
1328
  }
1329
+ // A definition's `<…>` destination is no autolink either.
1330
+ if (scanDefinition(masked, lineNumber, links, defined)) {
1331
+ return;
1332
+ }
1027
1333
  for (const match of masked.matchAll(AUTOLINK)) {
1028
1334
  const target = match.groups?.target ?? "";
1029
1335
  links.push({ column: match.index + 2, line: lineNumber, target });
1030
1336
  }
1031
- scanDefinition(masked, lineNumber, links);
1032
- return next;
1033
1337
  };
1034
1338
 
1035
1339
  /**
@@ -1037,10 +1341,59 @@ const scanLinkLine = (
1037
1341
  * `<a href>`: the attribute may sit on a later line than the tag name, where
1038
1342
  * a formatter wraps a long element, so the gap between them spans lines. An
1039
1343
  * expression-valued `href={…}` isn't a literal target, so it isn't matched,
1040
- * and no other lowercase tag is a link.
1344
+ * and no other lowercase tag is a link. An escaped `\<a href>` is text, not a
1345
+ * link (see `HTML_ID`).
1041
1346
  */
1042
1347
  const ELEMENT_HREF =
1043
- /<(?:[A-Z][\w.]*|(?<anchor>a))(?=[\s/>])[^<>]*?\shref=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1348
+ /(?<=(?:^|[^\\])(?:\\\\)*)<(?:[A-Z][\w.]*|(?<anchor>a))(?=[\s/>])[^<>]*?\shref=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1349
+
1350
+ /**
1351
+ * A media element's string `src` (`<img src="./diagram.png">`, `<source>`,
1352
+ * `<video>`, `<audio>`): raw HTML in a `.md` page, a plain element in an
1353
+ * `.mdx` one, with the attribute on the tag's line or a later one. An
1354
+ * expression-valued `src={…}` isn't a literal path, so it isn't matched, and
1355
+ * an escaped `\<img src>` is text (see `HTML_ID`).
1356
+ */
1357
+ const ELEMENT_SRC =
1358
+ /(?<=(?:^|[^\\])(?:\\\\)*)<(?<tag>img|source|video|audio)(?=[\s/>])[^<>]*?\ssrc=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1359
+
1360
+ /**
1361
+ * A `<Card>`'s string `img` (`<Card img="./cover.png">`), read the way
1362
+ * `ELEMENT_HREF` reads an `href`. It's an image embed: resolved from beside
1363
+ * the page source and published like `![](./cover.png)` (see
1364
+ * {@link rewriteCardImages}).
1365
+ */
1366
+ const CARD_IMG =
1367
+ /(?<=(?:^|[^\\])(?:\\\\)*)<Card(?=[\s/>])[^<>]*?\simg=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1368
+
1369
+ /**
1370
+ * Any element's string `href` or `src` (the first a tag carries), read the
1371
+ * way `ELEMENT_HREF` and `ELEMENT_SRC` read theirs: wrapped onto later lines
1372
+ * or not, an expression value unmatched, an escaped `\<tag>` text.
1373
+ */
1374
+ const ELEMENT_URL =
1375
+ /(?<=(?:^|[^\\])(?:\\\\)*)<(?<tag>[A-Za-z][\w.:-]*)(?=[\s/>])[^<>]*?\s(?<attribute>href|src)=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1376
+
1377
+ /** A string `href` or `src` on an element, as {@link rewriteElementUrls} reads it. */
1378
+ export interface ElementUrl {
1379
+ attribute: string;
1380
+ /** The element's name as written: `img`, `a`, `Card`. */
1381
+ tag: string;
1382
+ value: string;
1383
+ }
1384
+
1385
+ const MEDIA_TAGS = new Set(["audio", "img", "source", "video"]);
1386
+
1387
+ /**
1388
+ * Whether an element URL is one `blume validate` reads as a link (see
1389
+ * `ELEMENT_HREF` and `ELEMENT_SRC`): a media element's `src`, or an `<a>`'s or
1390
+ * a component's `href`. Only these name a file the build publishes from
1391
+ * beside the page.
1392
+ */
1393
+ export const isLinkElementUrl = (url: ElementUrl): boolean =>
1394
+ url.attribute === "src"
1395
+ ? MEDIA_TAGS.has(url.tag)
1396
+ : url.attribute === "href" && (url.tag === "a" || /^[A-Z]/u.test(url.tag));
1044
1397
 
1045
1398
  /**
1046
1399
  * An inline link whose label wraps onto later lines of its paragraph
@@ -1048,11 +1401,23 @@ const ELEMENT_HREF =
1048
1401
  * it. The label holds a line break but no blank line (which would end the
1049
1402
  * paragraph) and no brackets, so a match never repeats a one-line link.
1050
1403
  */
1051
- const WRAPPED_LINK =
1052
- /\[[^[\]\n]*(?:\n(?![ \t]*(?:\n|$))[^[\]\n]*)+\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
1404
+ const WRAPPED_LINK = new RegExp(
1405
+ String.raw`\[[^[\]\n]*(?:\n(?![ \t]*(?:\n|$))[^[\]\n]*)+\]${DESTINATION_TAIL}`,
1406
+ "dgu"
1407
+ );
1408
+
1409
+ // An MDX comment (`{/* … */}`): JavaScript, so nothing in it renders.
1410
+ const JSX_COMMENT = /\{\s*\/\*[\s\S]*?\*\/\s*\}/gu;
1053
1411
 
1054
- /** The body with fenced blocks and inline code blanked, shape preserved. */
1055
- const maskCode = (lines: readonly string[]): string => {
1412
+ // What each format reads as a comment. `.md` renders a `{/* … */}` as text,
1413
+ // links and all, and MDX rejects `<!-- … -->` outright.
1414
+ const COMMENTS = { md: HTML_COMMENT, mdx: JSX_COMMENT } as const;
1415
+
1416
+ /**
1417
+ * The body with fenced blocks and inline code blanked, shape preserved — and,
1418
+ * given the page's format, its comments too.
1419
+ */
1420
+ const maskCode = (lines: readonly string[], format?: "md" | "mdx"): string => {
1056
1421
  let fence: FenceState = null;
1057
1422
  const masked = lines.map((line) => {
1058
1423
  const next = nextFenceState(line, fence);
@@ -1062,7 +1427,12 @@ const maskCode = (lines: readonly string[]): string => {
1062
1427
  ? " ".repeat(line.length)
1063
1428
  : line.replaceAll(INLINE_CODE, (span) => " ".repeat(span.length));
1064
1429
  });
1065
- return masked.join("\n");
1430
+ const text = masked.join("\n");
1431
+ return format === undefined
1432
+ ? text
1433
+ : text.replaceAll(COMMENTS[format], (comment) =>
1434
+ comment.replaceAll(/[^\n]/gu, " ")
1435
+ );
1066
1436
  };
1067
1437
 
1068
1438
  /** The offset each of `lines` starts at once they're joined with `\n`. */
@@ -1103,18 +1473,244 @@ const positionIn = (
1103
1473
  };
1104
1474
 
1105
1475
  /**
1106
- * Every link in `body` that can span lines, with its 1-based position: an
1107
- * element's `href` (see `ELEMENT_HREF`) and a link whose label wraps (see
1108
- * `WRAPPED_LINK`). A lowercase `<a>` is raw HTML in `.md` and a plain element
1109
- * in `.mdx`; the Markdown pipeline passes either through as written, so its
1110
- * target is marked `raw`.
1476
+ * Every media element's string `src` in the masked `text` (see
1477
+ * `ELEMENT_SRC`), with its 1-based position. Like a raw `<a href>`, the
1478
+ * browser resolves it as written, unless it names a file beside the page,
1479
+ * which the build publishes and points it at (see `core/content-assets.ts`).
1480
+ */
1481
+ const mediaSourceLinks = (
1482
+ text: string,
1483
+ lineStarts: readonly number[],
1484
+ lineOffset: number
1485
+ ): PageLink[] =>
1486
+ [...text.matchAll(ELEMENT_SRC)].flatMap((match): PageLink[] => {
1487
+ const target = match.groups?.double ?? match.groups?.single ?? "";
1488
+ const tag = match.groups?.tag;
1489
+ if (target === "" || tag === undefined) {
1490
+ return [];
1491
+ }
1492
+ // The value ends one character (its closing quote) before the match does.
1493
+ const at = match.index + match[0].length - target.length - 1;
1494
+ return [
1495
+ {
1496
+ ...positionIn(lineStarts, at, lineOffset),
1497
+ raw: true,
1498
+ src: tag,
1499
+ target,
1500
+ },
1501
+ ];
1502
+ });
1503
+
1504
+ /** Each `<Card img>` value in the masked `text`, with its offset there. */
1505
+ const cardImages = (text: string): { at: number; target: string }[] =>
1506
+ [...text.matchAll(CARD_IMG)].map((match) => {
1507
+ const target = match.groups?.double ?? match.groups?.single ?? "";
1508
+ // The value ends one character (its closing quote) before the match does.
1509
+ return { at: match.index + match[0].length - target.length - 1, target };
1510
+ });
1511
+
1512
+ /**
1513
+ * Rewrite every `<Card img="…">` value in `text` (outside code) with
1514
+ * `rewrite`, which returns the new value or `null` to leave one as written.
1515
+ * The include expander rebases a partial's values with it and the
1516
+ * content-asset rewriter points them at their served URL, exactly as both
1517
+ * treat image embeds, so a card's image resolves like `![](./cover.png)`.
1518
+ */
1519
+ export const rewriteCardImages = (
1520
+ text: string,
1521
+ rewrite: (target: string) => string | null
1522
+ ): string => {
1523
+ if (!text.includes("<Card")) {
1524
+ return text;
1525
+ }
1526
+ let out = "";
1527
+ let cursor = 0;
1528
+ for (const { at, target } of cardImages(maskCode(text.split("\n")))) {
1529
+ const next = rewrite(target);
1530
+ if (next !== null) {
1531
+ out += text.slice(cursor, at) + next;
1532
+ cursor = at + target.length;
1533
+ }
1534
+ }
1535
+ return out + text.slice(cursor);
1536
+ };
1537
+
1538
+ /** A same-text edit: replace `length` characters at `at` with `text`. */
1539
+ interface Splice {
1540
+ at: number;
1541
+ length: number;
1542
+ text: string;
1543
+ }
1544
+
1545
+ /** Apply non-overlapping splices to `text`, in any order. */
1546
+ const applySplices = (text: string, splices: readonly Splice[]): string => {
1547
+ let out = "";
1548
+ let cursor = 0;
1549
+ for (const splice of splices.toSorted((a, b) => a.at - b.at)) {
1550
+ out += text.slice(cursor, splice.at) + splice.text;
1551
+ cursor = splice.at + splice.length;
1552
+ }
1553
+ return out + text.slice(cursor);
1554
+ };
1555
+
1556
+ /**
1557
+ * Rewrite every element's string `href` or `src` in `text` (outside code)
1558
+ * with `rewrite`, which returns the new value or `null` to leave one as
1559
+ * written. The include expander rebases a partial's files with it, the
1560
+ * content-asset rewriter points them at their served URL, and the render
1561
+ * plugins rewrite raw HTML with it.
1562
+ */
1563
+ export const rewriteElementUrls = (
1564
+ text: string,
1565
+ rewrite: (url: ElementUrl) => string | null
1566
+ ): string => {
1567
+ if (!(text.includes("href=") || text.includes("src="))) {
1568
+ return text;
1569
+ }
1570
+ const splices: Splice[] = [];
1571
+ for (const match of maskCode(text.split("\n")).matchAll(ELEMENT_URL)) {
1572
+ const value = match.groups?.double ?? match.groups?.single ?? "";
1573
+ const next = rewrite({
1574
+ attribute: match.groups?.attribute ?? "",
1575
+ tag: match.groups?.tag ?? "",
1576
+ value,
1577
+ });
1578
+ if (next !== null) {
1579
+ // The value ends one character (its closing quote) before the match.
1580
+ const at = match.index + match[0].length - value.length - 1;
1581
+ splices.push({ at, length: value.length, text: next });
1582
+ }
1583
+ }
1584
+ return applySplices(text, splices);
1585
+ };
1586
+
1587
+ /** A destination as written in a line, and its offset there. */
1588
+ interface WrittenDestination {
1589
+ at: number;
1590
+ written: string;
1591
+ }
1592
+
1593
+ /**
1594
+ * The destination of the link-reference definition on `line`, if it is one;
1595
+ * a footnote definition (`[^1]: …`) is no link.
1596
+ */
1597
+ const definitionDestination = (
1598
+ line: string
1599
+ ): WrittenDestination | undefined => {
1600
+ const groups = line.match(REF_DEFINITION)?.groups;
1601
+ const rest = groups?.label?.startsWith("^") ? "" : (groups?.rest ?? "");
1602
+ const written = rest.match(DEFINITION_DESTINATION)?.[0];
1603
+ return written === undefined
1604
+ ? undefined
1605
+ : { at: line.length - rest.length, written };
1606
+ };
1607
+
1608
+ /**
1609
+ * Rewrite the target of every Markdown link in `text` (outside code) with
1610
+ * `rewrite`, which gets the target as the renderer reads it and returns the
1611
+ * new one, or `null` to leave it as written: inline links (an image embed is
1612
+ * no link; see {@link rewriteImageTargets}), links whose label wraps onto
1613
+ * later lines, and link-reference definitions (`[spec]: ./spec.pdf`). Each
1614
+ * rewritten target is written back in the form its destination took.
1615
+ */
1616
+ export const rewriteLinkTargets = (
1617
+ text: string,
1618
+ rewrite: (target: string) => string | null
1619
+ ): string => {
1620
+ if (!(text.includes("](") || text.includes("]:"))) {
1621
+ return text;
1622
+ }
1623
+ const splices: Splice[] = [];
1624
+ const add = (at: number, written: string): void => {
1625
+ const { angled, target } = readDestination(written);
1626
+ const next = rewrite(target);
1627
+ if (next !== null) {
1628
+ splices.push({
1629
+ at,
1630
+ length: written.length,
1631
+ text: writeDestination(next, angled),
1632
+ });
1633
+ }
1634
+ };
1635
+ const lines = text.split("\n");
1636
+ const masked = maskCode(lines);
1637
+ const lineStarts = lineStartsOf(lines);
1638
+ for (const [index, line] of masked.split("\n").entries()) {
1639
+ const start = lineStarts[index] ?? 0;
1640
+ for (const match of line.matchAll(MD_LINK)) {
1641
+ if (line[match.index - 1] !== "!") {
1642
+ add(
1643
+ start + match.index + targetStart(match),
1644
+ match.groups?.target ?? ""
1645
+ );
1646
+ }
1647
+ }
1648
+ const definition = definitionDestination(line);
1649
+ if (definition) {
1650
+ add(start + definition.at, definition.written);
1651
+ }
1652
+ }
1653
+ for (const match of masked.matchAll(WRAPPED_LINK)) {
1654
+ if (masked[match.index - 1] !== "!") {
1655
+ add(match.index + targetStart(match), match.groups?.target ?? "");
1656
+ }
1657
+ }
1658
+ return applySplices(text, splices);
1659
+ };
1660
+
1661
+ /** A link-reference definition, as {@link linkDefinitions} reads one. */
1662
+ export interface LinkDefinition {
1663
+ /** The label as CommonMark matches it: case-folded, spaces collapsed. */
1664
+ key: string;
1665
+ /** The definition on a line of its own, out of any container. */
1666
+ text: string;
1667
+ }
1668
+
1669
+ /**
1670
+ * Every link-reference definition in `text` (`[label]: /url "Title"`,
1671
+ * outside code; footnotes aren't links), in order, the first of each label
1672
+ * only, since that's the one that renders. A definition is read from its own
1673
+ * line: its destination and any title there, and nothing else, since a line
1674
+ * with more after them is text.
1675
+ */
1676
+ export const linkDefinitions = (text: string): LinkDefinition[] => {
1677
+ const definitions = new Map<string, LinkDefinition>();
1678
+ const lines = text.split("\n");
1679
+ for (const [index, line] of maskCode(lines).split("\n").entries()) {
1680
+ const groups = line.match(REF_DEFINITION)?.groups;
1681
+ const label = groups?.label ?? "^";
1682
+ const key = labelKey(label);
1683
+ const rest = groups?.rest ?? "";
1684
+ if (
1685
+ label.startsWith("^") ||
1686
+ key === "" ||
1687
+ definitions.has(key) ||
1688
+ !DEFINITION_LINE.test(rest)
1689
+ ) {
1690
+ continue;
1691
+ }
1692
+ // The real line's text, at the same offset the masked one's starts.
1693
+ const written = (lines[index] ?? "").slice(line.length - rest.length);
1694
+ definitions.set(key, { key, text: `[${label}]: ${written}` });
1695
+ }
1696
+ return [...definitions.values()];
1697
+ };
1698
+
1699
+ /**
1700
+ * Every link in the masked `text` (see `maskCode`) of `lines` that can span
1701
+ * lines, with its 1-based position: an element's `href` (see `ELEMENT_HREF`),
1702
+ * a media element's `src` (see `ELEMENT_SRC`), a `<Card>`'s `img` (see
1703
+ * `CARD_IMG`, an image embed), and a link whose label wraps (see
1704
+ * `WRAPPED_LINK`). A lowercase `<a>` is raw
1705
+ * HTML in `.md` and a plain element in `.mdx`; the Markdown pipeline passes
1706
+ * either through as written, so its target is marked `raw`.
1111
1707
  */
1112
1708
  const multilineLinks = (
1709
+ text: string,
1113
1710
  lines: readonly string[],
1114
1711
  lineOffset: number
1115
1712
  ): PageLink[] => {
1116
1713
  const links: PageLink[] = [];
1117
- const text = maskCode(lines);
1118
1714
  const lineStarts = lineStartsOf(lines);
1119
1715
  for (const match of text.matchAll(ELEMENT_HREF)) {
1120
1716
  const target = match.groups?.double ?? match.groups?.single ?? "";
@@ -1129,44 +1725,62 @@ const multilineLinks = (
1129
1725
  }
1130
1726
  links.push(link);
1131
1727
  }
1728
+ links.push(
1729
+ ...mediaSourceLinks(text, lineStarts, lineOffset),
1730
+ ...cardImages(text).flatMap(({ at, target }): PageLink[] =>
1731
+ target === ""
1732
+ ? []
1733
+ : [{ ...positionIn(lineStarts, at, lineOffset), image: true, target }]
1734
+ )
1735
+ );
1132
1736
  for (const match of text.matchAll(WRAPPED_LINK)) {
1133
- const target = match.groups?.target ?? "";
1134
- const at =
1135
- match.index + targetOffsetIn(match[0], target, match.groups?.title);
1136
- const link: PageLink = {
1137
- ...positionIn(lineStarts, at, lineOffset),
1138
- target,
1139
- };
1140
- // As on one line, a preceding `!` makes it an image embed.
1141
- if (text[match.index - 1] === "!") {
1142
- link.image = true;
1737
+ const written = match.groups?.target ?? "";
1738
+ const at = match.index + targetStart(match);
1739
+ const link = destinationLink(
1740
+ written,
1741
+ positionIn(lineStarts, at, lineOffset)
1742
+ );
1743
+ if (link) {
1744
+ // As on one line, a preceding `!` makes it an image embed.
1745
+ if (text[match.index - 1] === "!") {
1746
+ link.image = true;
1747
+ }
1748
+ links.push(link);
1143
1749
  }
1144
- links.push(link);
1145
1750
  }
1146
1751
  return links;
1147
1752
  };
1148
1753
 
1149
- export const extractLinks = (body: string, lineOffset = 0): PageLink[] => {
1754
+ /**
1755
+ * Extract link targets from a markdown body for later validation, recording the
1756
+ * 1-based line/column of each target. Skips fenced code blocks, inline code,
1757
+ * and — given the page's `format` — comments, none of which render a link.
1758
+ * `lineOffset` shifts every recorded line: the body is frontmatter-stripped, so
1759
+ * diagnostics that point into the raw file must add the stripped block's height.
1760
+ */
1761
+ export const extractLinks = (
1762
+ body: string,
1763
+ lineOffset = 0,
1764
+ format?: "md" | "mdx"
1765
+ ): PageLink[] => {
1150
1766
  const links: PageLink[] = [];
1151
- let fence: FenceState = null;
1152
- let lineNumber = lineOffset;
1153
-
1767
+ const defined = new Set<string>();
1154
1768
  const lines = body.split("\n");
1155
- for (const line of lines) {
1156
- lineNumber += 1;
1157
- fence = scanLinkLine(line, lineNumber, fence, links);
1769
+ const text = maskCode(lines, format);
1770
+ for (const [index, line] of text.split("\n").entries()) {
1771
+ scanLinkLine(line, lineOffset + index + 1, links, defined);
1158
1772
  }
1159
-
1160
- return [...links, ...multilineLinks(lines, lineOffset)];
1773
+ return [...links, ...multilineLinks(text, lines, lineOffset)];
1161
1774
  };
1162
1775
 
1163
1776
  /**
1164
1777
  * A `<Component>` example's string `path` (`<Component path="forms/login" />`),
1165
1778
  * wrapped onto a later line or not, read the way `ELEMENT_HREF` reads an
1166
- * `href`. An expression-valued `path={…}` isn't a literal, so it isn't matched.
1779
+ * `href`. An expression-valued `path={…}` isn't a literal, so it isn't matched,
1780
+ * and an escaped `\<Component>` is text (see `HTML_ID`).
1167
1781
  */
1168
1782
  const COMPONENT_PATH =
1169
- /<Component(?=[\s/>])[^<>]*?\spath=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1783
+ /(?<=(?:^|[^\\])(?:\\\\)*)<Component(?=[\s/>])[^<>]*?\spath=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
1170
1784
 
1171
1785
  /**
1172
1786
  * Every `<Component path>` in an `.mdx` body, with the 1-based position of its
@@ -1195,13 +1809,15 @@ export const extractExampleUses = (
1195
1809
  // isn't a real usage. Single quotes are left alone so prose apostrophes don't
1196
1810
  // swallow a real tag between two words.
1197
1811
  const DOUBLE_QUOTED = /"[^"]*"/gu;
1198
- const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
1812
+ // An escaped `Promise\<App>` renders `Promise<App>` as text, so it uses no
1813
+ // component (see `HTML_ID`); `\\<App>` is an escaped backslash, then a tag.
1814
+ const JSX_OPEN = /(?<=(?:^|[^\\])(?:\\\\)*)<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
1199
1815
 
1200
1816
  /**
1201
1817
  * Capitalized JSX component tags used in an `.mdx` body (`<Callout>`,
1202
- * `<Tree.File>` → `Tree`). Skips fenced code, inline code, and double-quoted
1203
- * strings so code samples and prose don't count. Powers the missing-component
1204
- * diagnostic.
1818
+ * `<Tree.File>` → `Tree`). Skips fenced code, inline code, double-quoted
1819
+ * strings, and escaped `\<Tag>` text so code samples and prose don't count.
1820
+ * Powers the missing-component diagnostic.
1205
1821
  */
1206
1822
  /** Scan one line for JSX component tags; returns the next fenced-block state. */
1207
1823
  const scanTagLine = (
@@ -1314,26 +1930,93 @@ const entryLinks = <T extends Located>(
1314
1930
  * at render time. An included `.md` partial is spliced into the including
1315
1931
  * page and parsed in *its* format (see `markdown/include.ts`), so a partial's
1316
1932
  * markers count too and are reported against the partial via the expansion's
1317
- * origins. `.md` pages render the marker as text and get no diagnostic.
1933
+ * origins. `.md` pages pin the unspaced `{#id}` and are warned about the
1934
+ * other spellings instead (see {@link mdCurlyMarkerDiagnostics}). A marker
1935
+ * that also sets classes or attributes is an attribute list, which
1936
+ * `BLUME_MDX_ATTRIBUTE_LIST` reports (see `core/syntax-diagnostics.ts`).
1318
1937
  */
1319
1938
  const curlyMarkerDiagnostics = (
1320
1939
  entry: SourceEntry,
1321
1940
  markers: CurlyMarker[],
1322
1941
  sourceName: string
1323
1942
  ): Diagnostic[] =>
1324
- markers.map(({ id, line, marker }) => {
1325
- const origin = entry.expanded?.origins[line - 1];
1326
- const page = entry.sourcePath ?? `${sourceName}:${entry.ref}`;
1327
- const inPartial = origin !== undefined && origin.file !== entry.sourcePath;
1943
+ markers
1944
+ .filter(({ attributes }) => !attributes)
1945
+ .map(({ id, line, marker }) => {
1946
+ const origin = entry.expanded?.origins[line - 1];
1947
+ const page = entry.sourcePath ?? `${sourceName}:${entry.ref}`;
1948
+ const inPartial =
1949
+ origin !== undefined && origin.file !== entry.sourcePath;
1950
+ return {
1951
+ code: "BLUME_MDX_CURLY_ANCHOR",
1952
+ file: origin?.file ?? page,
1953
+ line: origin?.line ?? line + entryLineOffset(entry),
1954
+ message: inPartial
1955
+ ? `\`${marker}\` is a JSX expression once this partial is included in ${page} (.mdx), so that page fails to compile.`
1956
+ : `\`${marker}\` is a JSX expression in .mdx, so this page fails to compile.`,
1957
+ severity: "error",
1958
+ suggestion: `Write \`[#${id}]\` or escape it as \`\\{#${id}\\}\` — both pin the same anchor in .md and .mdx.`,
1959
+ };
1960
+ });
1961
+
1962
+ /**
1963
+ * Warnings for the spaced `{ #id }` and kramdown `{: #id }` heading markers
1964
+ * (MkDocs' `attr_list` writes both) in an `.md` page. Only the unspaced
1965
+ * `{#id}` pins an anchor there; the other spellings stay in the heading's
1966
+ * text, so the page shows them and the heading's id is slugged from them
1967
+ * (`## Setup { #setup }` anchors as `setup--setup`). So does an attribute
1968
+ * list that sets the id beside classes or attributes (`{ #setup .wide }`),
1969
+ * whose other attributes no heading takes either.
1970
+ */
1971
+ const mdCurlyMarkerDiagnostics = (
1972
+ entry: SourceEntry,
1973
+ markers: CurlyMarker[],
1974
+ sourceName: string
1975
+ ): Diagnostic[] =>
1976
+ markers
1977
+ .filter(({ id, marker }) => marker !== `{#${id}}`)
1978
+ .map(({ attributes, id, line, marker }) => {
1979
+ const origin = entry.expanded?.origins[line - 1];
1980
+ return {
1981
+ code: "BLUME_MD_CURLY_ANCHOR",
1982
+ file: origin?.file ?? entry.sourcePath ?? `${sourceName}:${entry.ref}`,
1983
+ line: origin?.line ?? line + entryLineOffset(entry),
1984
+ message: `\`${marker}\` doesn't pin an anchor: only the unspaced \`{#${id}}\` does, so this one shows in the heading and its id is slugged from it.`,
1985
+ severity: "warning",
1986
+ suggestion: `Write \`{#${id}}\` or \`[#${id}]\` to pin the anchor \`#${id}\`.${
1987
+ attributes
1988
+ ? " Blume sets no classes or other attributes on a heading."
1989
+ : ""
1990
+ }`,
1991
+ };
1992
+ });
1993
+
1994
+ const LIST = new Intl.ListFormat("en", { type: "conjunction" });
1995
+
1996
+ /**
1997
+ * Diagnostics for JavaScript event handlers on HTML elements in an `.mdx`
1998
+ * page (`<button onClick={() => …}>`). The page is static HTML: Astro writes
1999
+ * the function's source into the attribute, where the browser reads it as a
2000
+ * handler body that only defines a function, so nothing happens. Reported at
2001
+ * the element's line, in the partial that holds it when an `<include>`
2002
+ * brought it in.
2003
+ */
2004
+ const eventHandlerDiagnostics = (
2005
+ entry: SourceEntry,
2006
+ handlers: ElementUse[],
2007
+ sourceName: string
2008
+ ): Diagnostic[] =>
2009
+ handlers.map(({ column, file, line, props, tag }) => {
2010
+ const names = LIST.format(props.map((prop) => `\`${prop}\``));
1328
2011
  return {
1329
- code: "BLUME_MDX_CURLY_ANCHOR",
1330
- file: origin?.file ?? page,
1331
- line: origin?.line ?? line + entryLineOffset(entry),
1332
- message: inPartial
1333
- ? `\`${marker}\` is a JSX expression once this partial is included in ${page} (.mdx), so that page fails to compile.`
1334
- : `\`${marker}\` is a JSX expression in .mdx, so this page fails to compile.`,
1335
- severity: "error",
1336
- suggestion: `Write \`[#${id}]\` or escape it as \`\\{#${id}\\}\` — both pin the same anchor in .md and .mdx.`,
2012
+ code: "BLUME_MDX_EVENT_HANDLER",
2013
+ column,
2014
+ file: file ?? entry.sourcePath ?? `${sourceName}:${entry.ref}`,
2015
+ line,
2016
+ message: `${names} on \`<${tag}>\` ${props.length === 1 ? "is a JavaScript function" : "are JavaScript functions"}, which a static page never runs.`,
2017
+ severity: "warning",
2018
+ suggestion:
2019
+ "Move the interactive markup into an island (islands/Name.tsx), which runs in the browser, and use that component in the page.",
1337
2020
  };
1338
2021
  });
1339
2022
 
@@ -1437,6 +2120,12 @@ export interface EntryRoute extends Pick<
1437
2120
  * through {@link localizedRoute} for the route one locale publishes at.
1438
2121
  */
1439
2122
  logicalRoute: string;
2123
+ /**
2124
+ * The logical route an earlier Blume published the entry at, when it
2125
+ * differs: a date-named file or folder (`12-05-2022`) lost its first number
2126
+ * as an ordering prefix. Its old URL redirects to the new one.
2127
+ */
2128
+ formerLogicalRoute?: string;
1440
2129
  /** The prefixed, locale- and version-stripped nav path. */
1441
2130
  navPath: string;
1442
2131
  segments: string[];
@@ -1475,16 +2164,21 @@ export const resolveEntryRoute = (
1475
2164
  // A frontmatter slug is a route spelled out; an adapter's `entry.slug` is
1476
2165
  // one too unless the source's names are ordered file names (a vault note's
1477
2166
  // path), which lose their prefixes like the ref does.
1478
- const ordered =
1479
- ctx.orderingPrefixes === true && frontmatterSlug === undefined;
2167
+ const ordered = slug
2168
+ ? ctx.orderingPrefixes === true && frontmatterSlug === undefined
2169
+ : ctx.orderingPrefixes === true;
2170
+ const input = slug ? `${slug}${ext}` : navPath;
1480
2171
  const {
1481
2172
  segments,
1482
2173
  groups,
1483
2174
  route: versionKey,
1484
- } = slug
1485
- ? mapRoute(ctx.prefix, `${slug}${ext}`, ordered)
1486
- : mapRoute(ctx.prefix, navPath, ctx.orderingPrefixes === true);
2175
+ } = mapRoute(ctx.prefix, input, ordered);
2176
+ const former = ordered
2177
+ ? mapRoute(ctx.prefix, input, true, true).route
2178
+ : versionKey;
1487
2179
  return {
2180
+ formerLogicalRoute:
2181
+ former === versionKey ? undefined : versionizeRoute(former, version),
1488
2182
  groups,
1489
2183
  locales,
1490
2184
  logicalRoute: versionizeRoute(versionKey, version),
@@ -1686,19 +2380,12 @@ export const normalizeEntry = (
1686
2380
  return { diagnostics: parsed.diagnostics, pages: [] };
1687
2381
  }
1688
2382
 
2383
+ // The schema has already folded the top-level `hidden`/`noindex` shorthands
2384
+ // into `sidebar.hidden`/`seo.noindex` (see `foldShorthands`).
1689
2385
  const { meta } = parsed;
1690
2386
 
1691
- // Top-level `hidden`/`noindex` are accepted as shorthands for their nested
1692
- // equivalents — the schema declares them, so silently ignoring them would
1693
- // strand authors with no diagnostic.
1694
- if (meta.hidden) {
1695
- meta.sidebar.hidden = true;
1696
- }
1697
- if (meta.noindex) {
1698
- meta.seo.noindex = true;
1699
- }
1700
-
1701
2387
  const {
2388
+ formerLogicalRoute,
1702
2389
  groups,
1703
2390
  locales,
1704
2391
  logicalRoute,
@@ -1718,6 +2405,9 @@ export const normalizeEntry = (
1718
2405
  const bodyText = entry.expanded?.text ?? entry.body.text;
1719
2406
  const { anchors, curlyMarkers, headings } = scanBody(bodyText);
1720
2407
  const { staged } = ctx.source;
2408
+ const elements =
2409
+ format === "mdx" ? entryLinks(entry, extractElementUses) : [];
2410
+ const unknownProps = elements.filter((use) => use.kind === "prop");
1721
2411
 
1722
2412
  const base = {
1723
2413
  anchors,
@@ -1739,7 +2429,9 @@ export const normalizeEntry = (
1739
2429
  includes: entryIncludes(entry),
1740
2430
  lastModified: meta.lastModified ?? entry.lastModified,
1741
2431
  links: [
1742
- ...entryLinks(entry, extractLinks),
2432
+ ...entryLinks(entry, (text, lineOffset) =>
2433
+ extractLinks(text, lineOffset, format)
2434
+ ),
1743
2435
  ...relatedPageLinks(meta.related),
1744
2436
  ],
1745
2437
  meta,
@@ -1750,6 +2442,7 @@ export const normalizeEntry = (
1750
2442
  sourcePath: entry.sourcePath,
1751
2443
  title: deriveTitle(meta, headings, navPath),
1752
2444
  translationKey: logicalRoute,
2445
+ unknownProps: unknownProps.length > 0 ? unknownProps : undefined,
1753
2446
  version,
1754
2447
  versionKey,
1755
2448
  } satisfies Omit<PageRecord, "locale" | "route">;
@@ -1761,20 +2454,33 @@ export const normalizeEntry = (
1761
2454
  // stay base-less so the nav tree and translation matching are unaffected.
1762
2455
  // The base is mounted unconditionally: a `docs/` folder under a `/docs`
1763
2456
  // base is a real `/docs/docs/…` route, not an already-based one.
2457
+ const routeIn = (logical: string, locale: string): string =>
2458
+ mountBasePath(
2459
+ ctx.basePath ?? "",
2460
+ localizedRoute(logical, locale, ctx.i18n)
2461
+ );
1764
2462
  const pages = locales.map((locale) => ({
1765
2463
  ...base,
2464
+ formerRoute:
2465
+ formerLogicalRoute === undefined
2466
+ ? undefined
2467
+ : routeIn(formerLogicalRoute, locale),
1766
2468
  locale,
1767
- route: mountBasePath(
1768
- ctx.basePath ?? "",
1769
- localizedRoute(logicalRoute, locale, ctx.i18n)
1770
- ),
2469
+ route: routeIn(logicalRoute, locale),
1771
2470
  }));
1772
2471
 
1773
2472
  return {
1774
2473
  diagnostics:
1775
2474
  format === "mdx"
1776
- ? curlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name)
1777
- : [],
2475
+ ? [
2476
+ ...curlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name),
2477
+ ...eventHandlerDiagnostics(
2478
+ entry,
2479
+ elements.filter((use) => use.kind === "handler"),
2480
+ ctx.source.name
2481
+ ),
2482
+ ]
2483
+ : mdCurlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name),
1778
2484
  pages,
1779
2485
  };
1780
2486
  };