blume 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +126 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-74wfhv79.js → chunk-05f21r8h.js} +1 -1
  5. package/dist/cli/{chunk-9qzgg6mj.js → chunk-1cm0h819.js} +3 -3
  6. package/dist/cli/{chunk-nbyvgnnm.js → chunk-1rkxzg3n.js} +104 -47
  7. package/dist/cli/chunk-1rkxzg3n.js.map +36 -0
  8. package/dist/cli/{chunk-desvm72z.js → chunk-2nr29y95.js} +22 -26
  9. package/dist/cli/chunk-2nr29y95.js.map +13 -0
  10. package/dist/cli/{chunk-32khwcb6.js → chunk-49r370mb.js} +42 -69
  11. package/dist/cli/{chunk-32khwcb6.js.map → chunk-49r370mb.js.map} +9 -10
  12. package/dist/cli/{chunk-ms9z66nz.js → chunk-4rftmttv.js} +2 -2
  13. package/dist/cli/{chunk-s0bdnc7v.js → chunk-79bzzkx4.js} +5 -4
  14. package/dist/cli/chunk-79bzzkx4.js.map +10 -0
  15. package/dist/cli/{chunk-hvcxng5f.js → chunk-7tekknev.js} +6 -6
  16. package/dist/cli/{chunk-dv2z0rzf.js → chunk-8b489v1b.js} +38 -13
  17. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-8b489v1b.js.map} +5 -4
  18. package/dist/cli/{chunk-0pmb4tr4.js → chunk-8f8xw4x2.js} +1195 -222
  19. package/dist/cli/chunk-8f8xw4x2.js.map +44 -0
  20. package/dist/cli/chunk-96snx8yy.js +329 -0
  21. package/dist/cli/chunk-96snx8yy.js.map +13 -0
  22. package/dist/cli/{chunk-ckrcm08h.js → chunk-a3ydrqny.js} +48 -62
  23. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-a3ydrqny.js.map} +4 -5
  24. package/dist/cli/{chunk-bxwhtvw8.js → chunk-ahc5gc9h.js} +5 -5
  25. package/dist/cli/{chunk-dpy8qdjf.js → chunk-as09pafp.js} +73 -182
  26. package/dist/cli/chunk-as09pafp.js.map +18 -0
  27. package/dist/cli/{chunk-tfycddbt.js → chunk-bjk964fp.js} +2 -2
  28. package/dist/cli/{chunk-gfc5fqvf.js → chunk-br0n8n3e.js} +18 -18
  29. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-br0n8n3e.js.map} +1 -1
  30. package/dist/cli/{chunk-297ajdcv.js → chunk-c69tw29j.js} +6 -6
  31. package/dist/cli/{chunk-297ajdcv.js.map → chunk-c69tw29j.js.map} +3 -3
  32. package/dist/cli/chunk-cn3dy3hz.js +146 -0
  33. package/dist/cli/chunk-cn3dy3hz.js.map +11 -0
  34. package/dist/cli/{chunk-124fvmzq.js → chunk-dm4mg9bj.js} +11064 -8742
  35. package/dist/cli/{chunk-124fvmzq.js.map → chunk-dm4mg9bj.js.map} +78 -64
  36. package/dist/cli/{chunk-dqhg54mr.js → chunk-dmx6cezx.js} +1 -1
  37. package/dist/cli/{chunk-60hz265r.js → chunk-dpcahcrb.js} +24 -11
  38. package/dist/cli/chunk-dpcahcrb.js.map +10 -0
  39. package/dist/cli/{chunk-a57yh0h9.js → chunk-dyk3r7jw.js} +7 -7
  40. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-dyk3r7jw.js.map} +1 -1
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-enk76qd5.js} +14 -10
  42. package/dist/cli/chunk-enk76qd5.js.map +10 -0
  43. package/dist/cli/{chunk-k3gr1nm9.js → chunk-fqy4n3q2.js} +13 -16
  44. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-fqy4n3q2.js.map} +3 -3
  45. package/dist/cli/{chunk-sc93apfs.js → chunk-frk2wdq1.js} +17 -17
  46. package/dist/cli/{chunk-sc93apfs.js.map → chunk-frk2wdq1.js.map} +2 -2
  47. package/dist/cli/{chunk-kdpzw38a.js → chunk-gprwej7h.js} +95 -7
  48. package/dist/cli/chunk-gprwej7h.js.map +11 -0
  49. package/dist/cli/chunk-kkrteb15.js +157 -0
  50. package/dist/cli/chunk-kkrteb15.js.map +10 -0
  51. package/dist/cli/{chunk-ave1wcbr.js → chunk-m9bp2hc1.js} +1 -1
  52. package/dist/cli/{chunk-58bt3hag.js → chunk-mrvh4c88.js} +4 -4
  53. package/dist/cli/{chunk-5cmts6dv.js → chunk-p8zg3a2h.js} +7 -7
  54. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-p8zg3a2h.js.map} +1 -1
  55. package/dist/cli/{chunk-0w6hs479.js → chunk-rkqae7kh.js} +30 -18
  56. package/dist/cli/chunk-rkqae7kh.js.map +10 -0
  57. package/dist/cli/{chunk-bqxaqn5p.js → chunk-t5xx74rd.js} +8 -8
  58. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-t5xx74rd.js.map} +1 -1
  59. package/dist/cli/{chunk-1f882900.js → chunk-th0pgaj5.js} +2 -2
  60. package/dist/cli/{chunk-dhwtqnnd.js → chunk-vnbvhgmd.js} +2 -2
  61. package/dist/cli/{chunk-gxtg6ed2.js → chunk-vzy9rh6y.js} +21 -21
  62. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-vzy9rh6y.js.map} +3 -3
  63. package/dist/cli/{chunk-j1x92mx9.js → chunk-w6e455pz.js} +1 -1
  64. package/dist/cli/chunk-wehv26ry.js +155 -0
  65. package/dist/cli/chunk-wehv26ry.js.map +11 -0
  66. package/dist/cli/{chunk-y4msrwm9.js → chunk-xgq4eytj.js} +15 -15
  67. package/dist/cli/{chunk-36bxs9c7.js → chunk-xt0qvz1t.js} +27 -23
  68. package/dist/cli/chunk-xt0qvz1t.js.map +10 -0
  69. package/dist/cli/index.js +78 -42
  70. package/dist/cli/index.js.map +7 -6
  71. package/dist/types/ai/changelog-components.d.ts +18 -0
  72. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  73. package/dist/types/ai/openapi-components.d.ts +7 -7
  74. package/dist/types/ai/operation-details.d.ts +39 -0
  75. package/dist/types/ai/relative-links.d.ts +3 -1
  76. package/dist/types/ai/serializers.d.ts +6 -4
  77. package/dist/types/analytics/head.d.ts +4 -2
  78. package/dist/types/analytics/script.d.ts +8 -2
  79. package/dist/types/components/content/youtube.d.ts +20 -0
  80. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  81. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  82. package/dist/types/components/openapi/helpers.d.ts +53 -11
  83. package/dist/types/components/openapi/snippets.d.ts +7 -1
  84. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  85. package/dist/types/core/builtin-tags.d.ts +16 -0
  86. package/dist/types/core/changelog-list.d.ts +48 -0
  87. package/dist/types/core/closest-match.d.ts +6 -0
  88. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  89. package/dist/types/core/code-fences.d.ts +8 -1
  90. package/dist/types/core/config-input.d.ts +1 -1
  91. package/dist/types/core/content-assets.d.ts +16 -18
  92. package/dist/types/core/diagnostics.d.ts +14 -0
  93. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  94. package/dist/types/core/former-routes.d.ts +14 -0
  95. package/dist/types/core/github.d.ts +5 -4
  96. package/dist/types/core/heading-markers.d.ts +65 -2
  97. package/dist/types/core/hidden-pages.d.ts +3 -0
  98. package/dist/types/core/includes.d.ts +23 -0
  99. package/dist/types/core/last-modified.d.ts +18 -6
  100. package/dist/types/core/links.d.ts +42 -2
  101. package/dist/types/core/load-module.d.ts +19 -4
  102. package/dist/types/core/manifest.d.ts +14 -1
  103. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  104. package/dist/types/core/mdx-elements.d.ts +32 -0
  105. package/dist/types/core/mdx-syntax.d.ts +100 -0
  106. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  107. package/dist/types/core/meta.d.ts +32 -5
  108. package/dist/types/core/nav-diagnostics.d.ts +9 -2
  109. package/dist/types/core/navigation.d.ts +13 -0
  110. package/dist/types/core/ordering-prefix.d.ts +6 -0
  111. package/dist/types/core/pinned-ids.d.ts +23 -0
  112. package/dist/types/core/project-graph.d.ts +9 -1
  113. package/dist/types/core/redirect-patterns.d.ts +18 -0
  114. package/dist/types/core/relative-files.d.ts +31 -0
  115. package/dist/types/core/schema.d.ts +267 -7
  116. package/dist/types/core/sources/normalize.d.ts +101 -9
  117. package/dist/types/core/sources/types.d.ts +16 -0
  118. package/dist/types/core/static-files.d.ts +10 -0
  119. package/dist/types/core/static-host.d.ts +42 -0
  120. package/dist/types/core/svg-images.d.ts +26 -0
  121. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  122. package/dist/types/core/types.d.ts +42 -1
  123. package/dist/types/deploy/generated-files.d.ts +41 -0
  124. package/dist/types/deploy/redirects.d.ts +21 -7
  125. package/dist/types/markdown/code-title.d.ts +9 -5
  126. package/dist/types/markdown/directives.d.ts +8 -0
  127. package/dist/types/markdown/fence-language.d.ts +51 -0
  128. package/dist/types/markdown/fence-meta.d.ts +5 -0
  129. package/dist/types/markdown/github-alerts.d.ts +35 -0
  130. package/dist/types/openapi/asyncapi.d.ts +6 -5
  131. package/dist/types/openapi/checks.d.ts +10 -5
  132. package/dist/types/openapi/model.d.ts +55 -8
  133. package/dist/types/openapi/parse.d.ts +4 -1
  134. package/dist/types/sources/filesystem.d.ts +14 -4
  135. package/dist/types/sources/mdx-remote.d.ts +6 -3
  136. package/dist/types/sources/registry.d.ts +3 -3
  137. package/dist/types/theme/color.d.ts +33 -0
  138. package/dist/types/theme/palette.d.ts +122 -0
  139. package/docs/01-quickstart.mdx +1 -1
  140. package/docs/02-deployment.mdx +23 -8
  141. package/docs/04-migrating.mdx +16 -4
  142. package/docs/advanced/changelog.mdx +27 -1
  143. package/docs/advanced/skills.mdx +1 -1
  144. package/docs/cli/audit.mdx +1 -1
  145. package/docs/cli/doctor.mdx +2 -2
  146. package/docs/cli/evals.mdx +1 -1
  147. package/docs/cli/index.mdx +4 -2
  148. package/docs/cli/validate.mdx +10 -7
  149. package/docs/configuration/analytics.mdx +2 -0
  150. package/docs/configuration/customization.mdx +3 -1
  151. package/docs/configuration/index.mdx +2 -2
  152. package/docs/configuration/theming.mdx +5 -1
  153. package/docs/content/components.mdx +26 -6
  154. package/docs/content/frontmatter.mdx +1 -1
  155. package/docs/content/includes.mdx +6 -2
  156. package/docs/content/index.mdx +11 -1
  157. package/docs/content/islands.mdx +2 -0
  158. package/docs/content/meta.mdx +9 -3
  159. package/docs/content/navigation.mdx +14 -6
  160. package/docs/content/sources/obsidian.mdx +1 -1
  161. package/docs/content/sources/remote-mdx.mdx +3 -1
  162. package/docs/content/syntax.mdx +76 -8
  163. package/docs/content/variables.mdx +3 -1
  164. package/docs/discoverability/agent-discovery.mdx +1 -1
  165. package/docs/discoverability/json-api.mdx +1 -1
  166. package/docs/discoverability/llms-txt.mdx +1 -1
  167. package/docs/discoverability/markdown.mdx +2 -2
  168. package/docs/discoverability/open-graph.mdx +1 -1
  169. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  170. package/docs/references/graphql.mdx +2 -0
  171. package/docs/references/openapi.mdx +45 -7
  172. package/package.json +4 -4
  173. package/skills/blume/SKILL.md +2 -2
  174. package/skills/blume-migrate/SKILL.md +49 -27
  175. package/skills/blume-migrate/references/docsify.md +262 -0
  176. package/skills/blume-migrate/references/docus.md +298 -0
  177. package/skills/blume-migrate/references/docusaurus.md +3 -3
  178. package/skills/blume-migrate/references/fern.md +327 -0
  179. package/skills/blume-migrate/references/fumadocs.md +1 -1
  180. package/skills/blume-migrate/references/gitbook.md +267 -0
  181. package/skills/blume-migrate/references/github-wiki.md +259 -0
  182. package/skills/blume-migrate/references/jekyll.md +253 -0
  183. package/skills/blume-migrate/references/mdbook.md +256 -0
  184. package/skills/blume-migrate/references/mintlify.md +5 -5
  185. package/skills/blume-migrate/references/mkdocs.md +236 -0
  186. package/skills/blume-migrate/references/monorepo.md +20 -8
  187. package/skills/blume-migrate/references/nextra.md +2 -2
  188. package/skills/blume-migrate/references/readme.md +257 -0
  189. package/skills/blume-migrate/references/redocly.md +198 -0
  190. package/skills/blume-migrate/references/starlight.md +3 -3
  191. package/skills/blume-migrate/references/vitepress.md +204 -0
  192. package/skills/blume-migrate/references/vuepress.md +271 -0
  193. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  194. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  195. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  196. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  197. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  198. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  199. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  200. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  201. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  202. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  203. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  204. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  205. package/src/ai/changelog-components.ts +24 -0
  206. package/src/ai/changelog-markdown.ts +45 -41
  207. package/src/ai/component-markdown.ts +22 -9
  208. package/src/ai/llms.ts +36 -5
  209. package/src/ai/markdown.ts +2 -2
  210. package/src/ai/openapi-components.ts +25 -9
  211. package/src/ai/operation-details.ts +208 -0
  212. package/src/ai/relative-links.ts +48 -8
  213. package/src/ai/serializers.ts +7 -6
  214. package/src/ai/site-skill.ts +6 -2
  215. package/src/analytics/head.ts +11 -5
  216. package/src/analytics/script.ts +13 -3
  217. package/src/astro/generate.ts +32 -6
  218. package/src/astro/index.ts +1 -0
  219. package/src/astro/integration.ts +27 -1
  220. package/src/astro/mdx-source-errors.ts +46 -0
  221. package/src/astro/templates.ts +89 -185
  222. package/src/audit/checks/redirects.ts +5 -1
  223. package/src/audit/redirects.ts +36 -19
  224. package/src/audit/report.ts +22 -9
  225. package/src/audit/run.ts +12 -28
  226. package/src/audit/terms.ts +23 -0
  227. package/src/audit/url.ts +2 -2
  228. package/src/cli/build-failure.ts +256 -7
  229. package/src/cli/command-meta.ts +1 -1
  230. package/src/cli/commands/audit.ts +1 -1
  231. package/src/cli/commands/build.ts +12 -3
  232. package/src/cli/commands/check.ts +13 -2
  233. package/src/cli/commands/dev.ts +1 -1
  234. package/src/cli/commands/doctor.ts +4 -2
  235. package/src/cli/commands/init.ts +3 -3
  236. package/src/cli/commands/preview.ts +17 -1
  237. package/src/cli/commands/validate.ts +22 -10
  238. package/src/cli/prepare.ts +1 -1
  239. package/src/cli/preview-pages.ts +161 -0
  240. package/src/cli/unknown-flags.ts +1 -42
  241. package/src/components/code-copy.ts +51 -0
  242. package/src/components/code-language.ts +75 -0
  243. package/src/components/content/AccordionItem.astro +16 -1
  244. package/src/components/content/Callout.astro +11 -1
  245. package/src/components/content/Changelog.astro +189 -0
  246. package/src/components/content/CodeBlock.astro +8 -11
  247. package/src/components/content/Tabs.astro +16 -2
  248. package/src/components/content/YouTube.astro +6 -4
  249. package/src/components/content/code-block.ts +33 -0
  250. package/src/components/content/content-strings.ts +5 -1
  251. package/src/components/content/inline-markdown.ts +35 -8
  252. package/src/components/content/youtube.ts +49 -7
  253. package/src/components/layout/Analytics.astro +5 -1
  254. package/src/components/layout/NavTree.astro +8 -3
  255. package/src/components/layout/RootLayout.astro +18 -31
  256. package/src/components/layout/nav-utils.ts +14 -0
  257. package/src/components/layout/search/pagefind.ts +2 -1
  258. package/src/components/openapi/Operation.astro +4 -0
  259. package/src/components/openapi/PanelTabs.astro +9 -3
  260. package/src/components/openapi/RequestBody.astro +42 -8
  261. package/src/components/openapi/RequestPanel.astro +34 -53
  262. package/src/components/openapi/example-panels.ts +110 -0
  263. package/src/components/openapi/helpers.ts +195 -20
  264. package/src/components/openapi/operation-model.ts +63 -26
  265. package/src/components/openapi/request.ts +13 -0
  266. package/src/components/openapi/snippets.ts +14 -2
  267. package/src/core/alert-diagnostics.ts +89 -0
  268. package/src/core/builtin-tags.ts +76 -0
  269. package/src/core/changelog-list.ts +87 -0
  270. package/src/core/closest-match.ts +41 -0
  271. package/src/core/code-fence-diagnostics.ts +267 -0
  272. package/src/core/code-fences.ts +43 -7
  273. package/src/core/component-diagnostics.ts +65 -12
  274. package/src/core/config-input.ts +1 -1
  275. package/src/core/config.ts +46 -1
  276. package/src/core/content-assets.ts +64 -144
  277. package/src/core/diagnostics.ts +36 -3
  278. package/src/core/directive-diagnostics.ts +265 -47
  279. package/src/core/former-routes.ts +46 -0
  280. package/src/core/git-ignored.ts +53 -0
  281. package/src/core/github.ts +5 -4
  282. package/src/core/heading-markers.ts +156 -2
  283. package/src/core/hidden-pages.ts +64 -0
  284. package/src/core/includes.ts +181 -41
  285. package/src/core/last-modified.ts +47 -10
  286. package/src/core/links.ts +438 -83
  287. package/src/core/load-module.ts +98 -9
  288. package/src/core/manifest.ts +12 -9
  289. package/src/core/markdown-mirrors.ts +52 -0
  290. package/src/core/mdx-elements.ts +153 -0
  291. package/src/core/mdx-syntax.ts +377 -0
  292. package/src/core/meta-diagnostics.ts +134 -0
  293. package/src/core/meta.ts +154 -55
  294. package/src/core/nav-diagnostics.ts +16 -4
  295. package/src/core/navigation.ts +70 -10
  296. package/src/core/ordering-prefix.ts +22 -2
  297. package/src/core/pinned-ids.ts +141 -0
  298. package/src/core/project-graph.ts +221 -29
  299. package/src/core/redirect-patterns.ts +41 -0
  300. package/src/core/relative-files.ts +145 -0
  301. package/src/core/schema.ts +17 -3
  302. package/src/core/sources/mdx-remote.ts +75 -14
  303. package/src/core/sources/normalize.ts +885 -179
  304. package/src/core/sources/notion.ts +5 -4
  305. package/src/core/sources/obsidian.ts +2 -5
  306. package/src/core/sources/types.ts +17 -0
  307. package/src/core/static-files.ts +40 -0
  308. package/src/core/static-host.ts +125 -0
  309. package/src/core/svg-images.ts +98 -0
  310. package/src/core/syntax-diagnostics.ts +491 -0
  311. package/src/core/types.ts +43 -1
  312. package/src/deploy/artifacts.ts +2 -28
  313. package/src/deploy/generated-files.ts +128 -0
  314. package/src/deploy/platforms/vercel.ts +25 -1
  315. package/src/deploy/redirects.ts +43 -16
  316. package/src/deploy/sitemap.ts +2 -1
  317. package/src/markdown/base-links.ts +87 -6
  318. package/src/markdown/code-title.ts +29 -13
  319. package/src/markdown/content-assets.ts +210 -0
  320. package/src/markdown/directives.ts +69 -5
  321. package/src/markdown/fence-language.ts +106 -0
  322. package/src/markdown/fence-meta.ts +42 -0
  323. package/src/markdown/github-alerts.ts +172 -0
  324. package/src/markdown/heading-anchors.ts +265 -56
  325. package/src/markdown/include.ts +30 -3
  326. package/src/markdown/index.ts +145 -40
  327. package/src/markdown/prompt-text.ts +95 -0
  328. package/src/markdown/relative-links.ts +37 -2
  329. package/src/markdown/route-snapshot.ts +22 -12
  330. package/src/markdown/svg-fallback.ts +51 -0
  331. package/src/markdown/title-heading.ts +134 -0
  332. package/src/migrate/migrate.ts +127 -2
  333. package/src/og/card.ts +14 -4
  334. package/src/openapi/asyncapi.ts +14 -6
  335. package/src/openapi/checks.ts +160 -15
  336. package/src/openapi/model.ts +172 -33
  337. package/src/openapi/parse.ts +13 -4
  338. package/src/openapi/render-mdx.ts +55 -9
  339. package/src/openapi/source.ts +87 -6
  340. package/src/registry/eject.ts +0 -1
  341. package/src/search/documents.ts +10 -2
  342. package/src/sources/filesystem.ts +28 -3
  343. package/src/sources/mdx-remote.ts +7 -15
  344. package/src/theme/color.ts +14 -0
  345. package/src/theme/entry.ts +22 -1
  346. package/src/theme/palette.ts +74 -1
  347. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  348. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  349. package/dist/cli/chunk-150jk7km.js +0 -52
  350. package/dist/cli/chunk-150jk7km.js.map +0 -10
  351. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  352. package/dist/cli/chunk-53jqw8z0.js +0 -89
  353. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  354. package/dist/cli/chunk-60hz265r.js.map +0 -10
  355. package/dist/cli/chunk-desvm72z.js.map +0 -14
  356. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  357. package/dist/cli/chunk-es8h0v3j.js +0 -441
  358. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  359. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  360. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  361. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  362. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  363. package/dist/cli/chunk-ydp3065p.js +0 -64
  364. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  365. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-05f21r8h.js.map} +0 -0
  366. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-1cm0h819.js.map} +0 -0
  367. /package/dist/cli/{chunk-ms9z66nz.js.map → chunk-4rftmttv.js.map} +0 -0
  368. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-7tekknev.js.map} +0 -0
  369. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-ahc5gc9h.js.map} +0 -0
  370. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-bjk964fp.js.map} +0 -0
  371. /package/dist/cli/{chunk-dqhg54mr.js.map → chunk-dmx6cezx.js.map} +0 -0
  372. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-m9bp2hc1.js.map} +0 -0
  373. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-mrvh4c88.js.map} +0 -0
  374. /package/dist/cli/{chunk-1f882900.js.map → chunk-th0pgaj5.js.map} +0 -0
  375. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-vnbvhgmd.js.map} +0 -0
  376. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-w6e455pz.js.map} +0 -0
  377. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-xgq4eytj.js.map} +0 -0
@@ -0,0 +1,2526 @@
1
+ #!/usr/bin/env node
2
+ // mkdocs-codemod.mjs — deterministic, idempotent content codemod for the
3
+ // MkDocs / Material for MkDocs / Zensical → Blume migration. Run it on the
4
+ // content root (MkDocs' `docs_dir`), from the directory that holds mkdocs.yml,
5
+ // before any hand edits. Per page it:
6
+ //
7
+ // - converts Python-Markdown block syntax: `!!!` admonitions → `:::`
8
+ // directives, `???`/`???+` → <Expandable>, `===` content tabs →
9
+ // <CodeGroup> (every tab one code block) or <Tabs>/<Tab> — indentation
10
+ // aware and recursive (tabs in admonitions in list items), with a longer
11
+ // outer `::::` fence around nested callouts;
12
+ // - turns `--8<--` snippets into <include>: paths resolve from the snippets
13
+ // `base_path` (default: where MkDocs runs, taken as the directory that
14
+ // holds mkdocs.yml), not from the page; a snippet outside the content root
15
+ // is copied into `_snippets/`; a code block holding only a snippet line
16
+ // becomes a code include; an include indented inside a list item makes
17
+ // the page `.mdx`, the only format that splices it there;
18
+ // - rewrites code fences (`hl_lines` → `{…}`, `linenums` → `lineNumbers`,
19
+ // `{ .py title="…" }` → `py title="…"`, Pygments-only languages, options
20
+ // without a language → `text`) and `#!lang` inline code → `{:lang}`;
21
+ // - pins heading ids (`{ #id }`, `{: #id }`, `{#id}` → `[#id]`), drops other
22
+ // attribute lists, and converts keys (`++ctrl+c++` → <kbd>), `==mark==`,
23
+ // `^^ins^^`, abbreviation lines, `[TOC]`, `\(x\)` math, and Material icon
24
+ // shortcodes (→ <Icon> with a Lucide name);
25
+ // - moves the body H1 (ATX or setext) into frontmatter `title` (inline
26
+ // Markdown stripped), a `nav` title that differs into `sidebar.label`, a
27
+ // frontmatter title that differs into `seo.title`, and maps Material keys
28
+ // (`hide`, `status`, `tags`, `icon`, …);
29
+ // - renames a page to `.mdx` ONLY when it now uses an MDX-only feature (or
30
+ // includes a partial that does); on those pages HTML comments become
31
+ // `{/* … */}` on one line (Prettier mangles multi-line MDX comments),
32
+ // `<https://…>` autolinks become links, and void tags self-close;
33
+ // - renames `README.md` to `index.md` when the folder has no index page, and
34
+ // rewrites relative links and `<include>`s to every renamed file (Blume
35
+ // resolves a file link only when it names the file that exists).
36
+ //
37
+ // A `zensical.toml` with no extensions configured gets Zensical's default set.
38
+ //
39
+ // It reports — and never fakes — what needs judgment: mkdocstrings `:::`
40
+ // blocks, macros/Jinja, template overrides, hooks, plugins, snippet line
41
+ // ranges, `///` blocks, grid cards, code annotations, unknown icons, raw
42
+ // `<img>`s with relative paths, pages left without a title, snippets that are
43
+ // pages too, and every MDX hazard left in a renamed page.
44
+ //
45
+ // Design constraints (as mintlify-codemod.mjs): zero dependencies, sorted and
46
+ // deterministic, idempotent (a second run changes nothing), dry run by default.
47
+ //
48
+ // Usage:
49
+ // node mkdocs-codemod.mjs [--config <mkdocs.yml>] <content-dir> # dry run
50
+ // node mkdocs-codemod.mjs --write [--config <mkdocs.yml>] <content-dir> # apply
51
+ // node mkdocs-codemod.mjs --json … <content-dir> # JSON report
52
+ //
53
+ // Without --config it looks for mkdocs.yml, mkdocs.yaml, zensical.toml,
54
+ // properdocs.yml, or properdocs.yaml in the current directory, then beside the
55
+ // content dir. With no config at all, every extension's syntax is converted
56
+ // except the ones that could misread plain Markdown (math, mark, caret, keys),
57
+ // and snippets resolve from the current directory.
58
+
59
+ import {
60
+ existsSync,
61
+ mkdirSync,
62
+ readdirSync,
63
+ readFileSync,
64
+ renameSync,
65
+ statSync,
66
+ writeFileSync,
67
+ } from "node:fs";
68
+ import path from "node:path";
69
+
70
+ // --- Tables -------------------------------------------------------------------
71
+
72
+ // Material 9.7 styles 12 admonition types; every other name renders like
73
+ // `note`. Blume's callouts: note, tip, warning, danger, info, success.
74
+ const ADMONITION_TYPES = {
75
+ abstract: "info",
76
+ attention: "warning",
77
+ bug: "danger",
78
+ caution: "warning",
79
+ check: "success",
80
+ danger: "danger",
81
+ done: "success",
82
+ error: "danger",
83
+ example: "note",
84
+ fail: "danger",
85
+ failure: "danger",
86
+ faq: "info",
87
+ help: "info",
88
+ hint: "tip",
89
+ important: "note",
90
+ info: "info",
91
+ missing: "danger",
92
+ note: "note",
93
+ question: "info",
94
+ success: "success",
95
+ summary: "info",
96
+ tip: "tip",
97
+ tldr: "info",
98
+ todo: "note",
99
+ warning: "warning",
100
+ };
101
+
102
+ // Pygments-only (or differently spelled) fence languages → Shiki ids.
103
+ const LANGS = {
104
+ cfg: "ini",
105
+ "console-session": "console",
106
+ ps1con: "powershell",
107
+ "pwsh-session": "powershell",
108
+ py3: "python",
109
+ pycon: "python",
110
+ python3: "python",
111
+ "sh-session": "shellsession",
112
+ "shell-session": "shellsession",
113
+ };
114
+
115
+ // Material icon shortcodes (and frontmatter icon paths, `/` → `-`) → Lucide.
116
+ // `null` drops a purely decorative icon.
117
+ const ICONS = {
118
+ "fontawesome-brands-github": "github",
119
+ "fontawesome-solid-check": "check",
120
+ "fontawesome-solid-xmark": "x",
121
+ "material-account": "user",
122
+ "material-account-group": "users",
123
+ "material-alert": "triangle-alert",
124
+ "material-alert-circle": "circle-alert",
125
+ "material-alert-octagon": "octagon-alert",
126
+ "material-alert-outline": "triangle-alert",
127
+ "material-apple": "apple",
128
+ "material-arrow-right": "arrow-right",
129
+ "material-bell": "bell",
130
+ "material-book": "book",
131
+ "material-book-open-variant": "book-open",
132
+ "material-bug": "bug",
133
+ "material-calendar": "calendar",
134
+ "material-check": "check",
135
+ "material-check-all": "check-check",
136
+ "material-check-bold": "check",
137
+ "material-check-circle": "circle-check",
138
+ "material-clock": "clock",
139
+ "material-clock-outline": "clock",
140
+ "material-close": "x",
141
+ "material-close-circle": "circle-x",
142
+ "material-cloud": "cloud",
143
+ "material-code-braces": "braces",
144
+ "material-cog": "settings",
145
+ "material-console": "terminal",
146
+ "material-content-copy": "copy",
147
+ "material-database": "database",
148
+ "material-delete": "trash-2",
149
+ "material-download": "download",
150
+ "material-earth": "earth",
151
+ "material-email": "mail",
152
+ "material-eye": "eye",
153
+ "material-file-document": "file-text",
154
+ "material-file-document-outline": "file-text",
155
+ "material-flask": "flask-conical",
156
+ "material-folder": "folder",
157
+ "material-format-list-bulleted": "list",
158
+ "material-github": "github",
159
+ "material-hammer": "hammer",
160
+ "material-heart": "heart",
161
+ "material-help-circle": "circle-help",
162
+ "material-image": "image",
163
+ "material-information": "info",
164
+ "material-information-outline": "info",
165
+ "material-key": "key",
166
+ "material-lightbulb": "lightbulb",
167
+ "material-lightning-bolt": "zap",
168
+ "material-link": "link",
169
+ "material-lock": "lock",
170
+ "material-magnify": "search",
171
+ "material-minus": "minus",
172
+ "material-open-in-new": null,
173
+ "material-package-variant": "package",
174
+ "material-pencil": "pencil",
175
+ "material-play": "play",
176
+ "material-plus": "plus",
177
+ "material-puzzle": "puzzle",
178
+ "material-refresh": "refresh-cw",
179
+ "material-rocket-launch": "rocket",
180
+ "material-server": "server",
181
+ "material-shield": "shield",
182
+ "material-star": "star",
183
+ "material-tag": "tag",
184
+ "material-tag-outline": "tag",
185
+ "material-timer": "timer",
186
+ "material-timer-sand": "hourglass",
187
+ "material-web": "globe",
188
+ "material-wrench": "wrench",
189
+ "octicons-copy-16": "copy",
190
+ "octicons-link-external-16": null,
191
+ "octicons-link-external-24": null,
192
+ };
193
+
194
+ // pymdownx.keys names → labels; anything else is title-cased.
195
+ const KEY_LABELS = {
196
+ alt: "Alt",
197
+ backspace: "Backspace",
198
+ cmd: "Cmd",
199
+ command: "Cmd",
200
+ ctrl: "Ctrl",
201
+ del: "Del",
202
+ delete: "Delete",
203
+ down: "↓",
204
+ enter: "Enter",
205
+ esc: "Esc",
206
+ escape: "Esc",
207
+ left: "←",
208
+ meta: "Meta",
209
+ option: "Option",
210
+ return: "Return",
211
+ right: "→",
212
+ shift: "Shift",
213
+ space: "Space",
214
+ tab: "Tab",
215
+ up: "↑",
216
+ win: "Win",
217
+ windows: "Win",
218
+ };
219
+
220
+ // Top-level page frontmatter keys Blume's strict schema accepts.
221
+ const BLUME_KEYS = new Set([
222
+ "ai",
223
+ "api",
224
+ "authMethod",
225
+ "authors",
226
+ "changelog",
227
+ "date",
228
+ "deprecated",
229
+ "description",
230
+ "draft",
231
+ "hidden",
232
+ "icon",
233
+ "lastModified",
234
+ "mode",
235
+ "narration",
236
+ "noindex",
237
+ "pagination",
238
+ "playground",
239
+ "related",
240
+ "search",
241
+ "seo",
242
+ "sidebar",
243
+ "slug",
244
+ "title",
245
+ "type",
246
+ ]);
247
+
248
+ // Material/MkDocs page keys with no Blume counterpart: dropped and reported.
249
+ const DROP_KEYS = [
250
+ "comments",
251
+ "render_macros",
252
+ "social",
253
+ "subtitle",
254
+ "template",
255
+ ];
256
+
257
+ // Parts of Python-Markdown's `extra` (and pymdownx.extra).
258
+ const EXTRA_PARTS = new Set([
259
+ "abbr",
260
+ "attr_list",
261
+ "def_list",
262
+ "footnotes",
263
+ "md_in_html",
264
+ "tables",
265
+ ]);
266
+
267
+ // What to do with each plugin; anything unlisted is reported as unknown.
268
+ const PLUGIN_HINTS = {
269
+ "awesome-nav": "manual: convert each .nav.yml to a meta.ts",
270
+ "awesome-pages": "manual: convert each .pages file to a meta.ts",
271
+ blog: "manual: posts become type: blog pages (see the reference's Blog section)",
272
+ exclude: "map to content.exclude",
273
+ "gen-files":
274
+ "manual: generated pages — keep the generator, point it at the content root",
275
+ "git-authors": "drop (no equivalent)",
276
+ "git-committers": "drop (no equivalent)",
277
+ "git-revision-date": 'lastModified: "git"',
278
+ "git-revision-date-localized": 'lastModified: "git"',
279
+ glightbox: "built in (image zoom) — delete",
280
+ i18n: "manual: i18n (see the reference)",
281
+ "include-markdown": "manual: {% include %} → <include> (see Snippets)",
282
+ "literate-nav": "manual: SUMMARY.md is the nav — convert to folders/meta.ts",
283
+ llmstxt: "built in — markdown_description → agents.llmsTxt.details",
284
+ macros:
285
+ "manual: plain values → variables; macro calls and Jinja logic by hand",
286
+ meta: "manual: apply each .meta.yml to its pages' frontmatter",
287
+ mike: "manual: versioning (see the reference)",
288
+ minify: "drop",
289
+ "mkdocs-jupyter": "manual: notebooks have no Blume renderer",
290
+ mkdocstrings: "manual: no Python API generator in Blume",
291
+ offline: "drop",
292
+ optimize: "drop (Blume optimizes relative images)",
293
+ privacy: "drop",
294
+ redirects: "map redirect_maps to redirects (Markdown paths → routes)",
295
+ rss: "built in for type: blog pages at /blog/rss.xml",
296
+ search: "built in — delete",
297
+ "section-index": "built in (folder index pages)",
298
+ social: "built in (Open Graph images) — delete",
299
+ "static-i18n": "manual: i18n (see the reference)",
300
+ tags: "tags → search.tags (the codemod moves the frontmatter); tag index pages drop",
301
+ typeset: "drop",
302
+ };
303
+
304
+ // HTML elements MDX accepts as written; anything else lowercase is a hazard.
305
+ const KNOWN_TAGS = new Set(
306
+ "a abbr b blockquote br center code dd del details div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i iframe img input ins kbd li mark ol p picture pre s section small source span strong sub summary sup table tbody td th thead tr u ul video".split(
307
+ " "
308
+ )
309
+ );
310
+
311
+ const CONFIG_NAMES = [
312
+ "mkdocs.yml",
313
+ "mkdocs.yaml",
314
+ "zensical.toml",
315
+ "properdocs.yml",
316
+ "properdocs.yaml",
317
+ ];
318
+
319
+ const SKIP_DIRS = new Set([".blume", ".git", "dist", "node_modules"]);
320
+
321
+ // --- Patterns -----------------------------------------------------------------
322
+
323
+ const FENCE_OPEN = /^(?<indent> *)(?<marker>`{3,}|~{3,})(?<info>.*)$/u;
324
+ const FENCE_CLOSE = /^ *(?<marker>`{3,}|~{3,}) *$/u;
325
+ // As Python-Markdown and pymdownx.details read them: at most one space after
326
+ // the marker (`!!!note` is valid), a type for `!!!`, a type or a title for
327
+ // `???`, and only a double-quoted title.
328
+ const ADMONITION =
329
+ /^(?<indent> *)(?<kind>!!!|\?\?\?\+?) ?(?<words>[\w-]+(?: +[\w-]+)*?)?(?: +"(?<title>.*)")? *$/u;
330
+ const TAB = /^(?<indent> *)===(?<flags>[+!]*) +"(?<label>.*)" *$/u;
331
+ // pymdownx.snippets: nothing may follow the closing quote or the block marker.
332
+ const SNIPPET_LINE =
333
+ /^(?<indent>[\t ]*)(?<escape>;*)-+8<-+[\t ]+(?<q>["'])(?<target>.+?)\k<q>$/u;
334
+ const SNIPPET_BLOCK = /^(?<indent>[\t ]*)-+8<-+$/u;
335
+ const SNIPPET_SUFFIX =
336
+ /^(?<file>.*?)(?<suffix>(?::-?\d*){1,2}(?:,-?\d*(?::-?\d*)?)*|:[a-z][\w-]*)?$/iu;
337
+ const CODE_SPAN = /(?<code>`+[^`]*`+)/u;
338
+ const ATTR_TOKEN = String.raw`(?:[#.][\w-]+|[\w-]+=(?:"[^"]*"|'[^']*'|[^\s}]+))`;
339
+ const INLINE_ATTRS = new RegExp(
340
+ String.raw`(?<target>\]\([^)]*\)|\]\[[^\]]*\]|${"`"})\{:? *(?<attrs>${ATTR_TOKEN}(?: +${ATTR_TOKEN})*) *\}`,
341
+ "gu"
342
+ );
343
+ const TRAILING_ATTRS = / *\{:? *[#.][\w-]+(?: +[#.][\w-]+)* *\}\s*$/u;
344
+ const HEADING_ATTRS =
345
+ /^(?<text>#{1,6} .*?) *(?<!\\)\{:? *(?<attrs>[^}]*)\}\s*$/u;
346
+ const KEYS =
347
+ /\+\+(?<keys>(?:[a-z0-9-]+|"[^"]*")(?:\+(?:[a-z0-9-]+|"[^"]*"))*)\+\+/giu;
348
+ const ICON_SHORTCODE =
349
+ /:(?<name>(?:material|fontawesome|octicons|simple)-[a-z0-9-]+):(?:\{[^}]*\})?/gu;
350
+ const MD_LINK =
351
+ /(?<lead>\]\(|^ *\[[^\]]+\]: +)(?<target>(?![a-z][\w+.-]*:|#|\/)[^)\s#]+\.(?:md|markdown))(?<hash>#[^)\s]*)?/giu;
352
+ const MULTIMARKDOWN_META =
353
+ /^(?:title|description|template|authors?|date|summary|tags|hide|icon|status): \S/iu;
354
+
355
+ // --- Small helpers ------------------------------------------------------------
356
+
357
+ const expandTabs = (line) =>
358
+ line.replace(/^[\t ]+/u, (lead) => {
359
+ let col = 0;
360
+ for (const ch of lead) {
361
+ col = ch === "\t" ? col + 4 - (col % 4) : col + 1;
362
+ }
363
+ return " ".repeat(col);
364
+ });
365
+ const indentOf = (line) => /^ */u.exec(expandTabs(line))[0].length;
366
+ const isBlank = (line) => line.trim() === "";
367
+ const toPosix = (p) => p.split(path.sep).join("/");
368
+ const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
369
+ const unquote = (value) =>
370
+ value.trim().replace(/^(?<q>["'])(?<inner>.*)\k<q>$/u, "$<inner>");
371
+ const maskCode = (line) =>
372
+ line.replaceAll(/`+[^`]*`+/gu, (span) => " ".repeat(span.length));
373
+
374
+ const isFenceClose = (line, open) => {
375
+ const m = FENCE_CLOSE.exec(line);
376
+ return Boolean(
377
+ m && m.groups.marker[0] === open[0] && m.groups.marker.length >= open.length
378
+ );
379
+ };
380
+
381
+ /** End (exclusive) of the fenced block that opens at `lines[start]`. */
382
+ const fenceEnd = (lines, start) => {
383
+ const open = FENCE_OPEN.exec(lines[start]).groups.marker;
384
+ for (let j = start + 1; j < lines.length; j += 1) {
385
+ if (isFenceClose(lines[j], open)) {
386
+ return j + 1;
387
+ }
388
+ }
389
+ return lines.length;
390
+ };
391
+
392
+ /** Lines after `start` that are blank or indented ≥ `min`, dedented. */
393
+ const collectBody = (lines, start, min) => {
394
+ let j = start;
395
+ let last = start - 1;
396
+ while (j < lines.length) {
397
+ if (!isBlank(lines[j])) {
398
+ if (indentOf(lines[j]) < min) {
399
+ break;
400
+ }
401
+ last = j;
402
+ }
403
+ j += 1;
404
+ }
405
+ const body = lines
406
+ .slice(start, last + 1)
407
+ .map((l) => (isBlank(l) ? "" : expandTabs(l).slice(min)));
408
+ return [body, last + 1];
409
+ };
410
+
411
+ const trimBlankEdges = (body) => {
412
+ let a = 0;
413
+ let b = body.length;
414
+ while (a < b && isBlank(body[a])) {
415
+ a += 1;
416
+ }
417
+ while (b > a && isBlank(body[b - 1])) {
418
+ b -= 1;
419
+ }
420
+ return body.slice(a, b);
421
+ };
422
+
423
+ const prefix = (indent, lines) =>
424
+ lines.map((l) => (l === "" ? "" : " ".repeat(indent) + l));
425
+
426
+ /** Apply `fn` to the prose of a line, leaving inline code spans alone. */
427
+ const mapProse = (line, fn) =>
428
+ line
429
+ .split(CODE_SPAN)
430
+ .map((part, i) => (i % 2 === 1 ? part : fn(part)))
431
+ .join("");
432
+
433
+ /** Apply `fn` to each line outside fenced code. */
434
+ const mapFenced = (lines, fn) => {
435
+ let fence = null;
436
+ return lines.map((line) => {
437
+ if (fence) {
438
+ if (isFenceClose(line, fence)) {
439
+ fence = null;
440
+ }
441
+ return line;
442
+ }
443
+ const open = FENCE_OPEN.exec(line);
444
+ if (open) {
445
+ fence = open.groups.marker;
446
+ return line;
447
+ }
448
+ return fn(line);
449
+ });
450
+ };
451
+
452
+ /** A JSX string attribute, falling back to an expression for quotes. */
453
+ const jsxAttr = (name, value) =>
454
+ value.includes('"')
455
+ ? `${name}={${JSON.stringify(value)}}`
456
+ : `${name}="${value}"`;
457
+
458
+ /** A YAML scalar, quoted when plain style would misparse. */
459
+ const yamlScalar = (value) =>
460
+ /^[\s`'"&*!|>%@#{[\]?,-]|[:#] |: *$|^(?:true|false|null|yes|no|on|off|~)$|^[\d.+-]/iu.test(
461
+ value
462
+ ) || value.includes("`")
463
+ ? JSON.stringify(value)
464
+ : value;
465
+
466
+ /** `text` without `pattern`'s matches, removed again until none is left. */
467
+ const removeAll = (text, pattern) => {
468
+ let out = text;
469
+ let previous;
470
+ do {
471
+ previous = out;
472
+ out = out.replaceAll(pattern, "");
473
+ } while (out !== previous);
474
+ return out;
475
+ };
476
+
477
+ /** Strip inline Markdown from a heading so it reads as a plain title. */
478
+ const plainTitle = (text) =>
479
+ removeAll(
480
+ text
481
+ .replace(/\s*\{:?[^}]*\}\s*$/u, "")
482
+ .replace(/\s*\[#[\w.-]+\]\s*$/u, "")
483
+ .replaceAll(/!?\[(?<label>[^\]]*)\]\([^)]*\)/gu, "$<label>"),
484
+ /<[^>]+>/gu
485
+ )
486
+ .replaceAll(/`+(?<code>[^`]*)`+/gu, "$<code>")
487
+ .replaceAll(/(?<mark>\*\*|__)(?<inner>.+?)\k<mark>/gu, "$<inner>")
488
+ .replaceAll(/(?<![\w*])\*(?<inner>[^*\s][^*]*?)\*(?![\w*])/gu, "$<inner>")
489
+ .replaceAll(/(?<!\w)_(?<inner>[^_\s][^_]*?)_(?!\w)/gu, "$<inner>")
490
+ .trim();
491
+
492
+ // --- Config: a zero-dependency reader for the keys the codemod needs ----------
493
+
494
+ /** Lines of a top-level YAML key's block (column-0 list items included). */
495
+ const yamlBlock = (text, key) => {
496
+ const lines = text.split(/\r?\n/u);
497
+ const start = lines.findIndex((l) => l.startsWith(`${key}:`));
498
+ if (start === -1) {
499
+ return null;
500
+ }
501
+ const block = [];
502
+ for (const l of lines.slice(start + 1)) {
503
+ if (!(l.trim() === "" || /^\s|^-(?:\s|$)|^#/u.test(l))) {
504
+ break;
505
+ }
506
+ block.push(l);
507
+ }
508
+ return block;
509
+ };
510
+
511
+ /** First-level item names of a YAML list or map block. */
512
+ const yamlNames = (block) => {
513
+ const content = block.filter((l) => l.trim() !== "" && !/^\s*#/u.test(l));
514
+ if (content.length === 0) {
515
+ return [];
516
+ }
517
+ const level = indentOf(content[0]);
518
+ return content
519
+ .filter((l) => indentOf(l) === level)
520
+ .map((l) => /^ *(?:- +)?["']?(?<name>[\w./-]+)["']? *(?::|$)/u.exec(l))
521
+ .filter(Boolean)
522
+ .map((m) => m.groups.name);
523
+ };
524
+
525
+ /** The sub-block of one first-level item (`- pymdownx.snippets:` …). */
526
+ const yamlItemBlock = (block, name) => {
527
+ const pattern = new RegExp(
528
+ String.raw`^ *(?:- +)?["']?${name.replaceAll(".", String.raw`\.`)}["']? *:`,
529
+ "u"
530
+ );
531
+ const start = block.findIndex((l) => pattern.test(l));
532
+ if (start === -1) {
533
+ return [];
534
+ }
535
+ const level = indentOf(block[start]);
536
+ const out = [];
537
+ for (const l of block.slice(start + 1)) {
538
+ if (l.trim() !== "" && indentOf(l) <= level) {
539
+ break;
540
+ }
541
+ out.push(l);
542
+ }
543
+ return out;
544
+ };
545
+
546
+ /** A YAML list value: `[a, b]`, `a`, or `- a` lines under `key:`. */
547
+ const yamlList = (lines, key) => {
548
+ const keyLine = new RegExp(`^ *${key}:`, "u");
549
+ const i = lines.findIndex((l) => keyLine.test(l));
550
+ if (i === -1) {
551
+ return [];
552
+ }
553
+ const inline = lines[i].replace(keyLine, "").trim();
554
+ if (inline.startsWith("[")) {
555
+ return inline
556
+ .slice(1, inline.lastIndexOf("]"))
557
+ .split(",")
558
+ .map(unquote)
559
+ .filter(Boolean);
560
+ }
561
+ if (inline) {
562
+ return [unquote(inline)];
563
+ }
564
+ const out = [];
565
+ for (const l of lines.slice(i + 1)) {
566
+ const m = /^ *- +(?<value>.+)$/u.exec(l);
567
+ if (l.trim() !== "" && (!m || indentOf(l) < indentOf(lines[i]))) {
568
+ break;
569
+ }
570
+ if (m) {
571
+ out.push(unquote(m.groups.value));
572
+ }
573
+ }
574
+ return out;
575
+ };
576
+
577
+ const normalizeExtension = (name) =>
578
+ name.replaceAll('"', "").replace(/^markdown\.extensions\./u, "");
579
+
580
+ /** The `nav` titles and files a YAML config lists. */
581
+ const readYamlNav = (block, config) => {
582
+ for (const l of block ?? []) {
583
+ const titled = /^\s*- +(?<title>.+?): +(?<file>\S+\.md)\s*$/u.exec(l);
584
+ const bare = /^\s*- +(?<file>\S+\.md)\s*$/u.exec(l);
585
+ const file = unquote((titled ?? bare)?.groups.file ?? "");
586
+ if (file) {
587
+ config.navFiles.push(file);
588
+ }
589
+ if (titled) {
590
+ config.nav.set(file, unquote(titled.groups.title));
591
+ }
592
+ }
593
+ };
594
+
595
+ /** Snippet base paths, auto_append, and extend_pygments_lang from extensions. */
596
+ const readExtensionOptions = (block, config) => {
597
+ const snippets = yamlItemBlock(block, "pymdownx.snippets");
598
+ const bases = yamlList(snippets, "base_path");
599
+ if (bases.length > 0) {
600
+ config.basePaths = bases.map((b) => path.resolve(config.dir, b));
601
+ }
602
+ config.autoAppend = yamlList(snippets, "auto_append");
603
+ const highlight = yamlItemBlock(block, "pymdownx.highlight").join("\n");
604
+ const names = [...highlight.matchAll(/name: *["']?(?<n>[\w+.-]+)/gu)];
605
+ const langs = [...highlight.matchAll(/lang: *["']?(?<l>[\w+.-]+)/gu)];
606
+ for (const [i, n] of names.entries()) {
607
+ if (langs[i]) {
608
+ config.langMap[n.groups.n] = langs[i].groups.l;
609
+ }
610
+ }
611
+ };
612
+
613
+ const readYamlConfig = (text, config) => {
614
+ let combined = text;
615
+ const inherit = /^INHERIT: *["']?(?<p>[^"'\s#]+)/mu.exec(text);
616
+ if (inherit) {
617
+ config.inherit = inherit.groups.p;
618
+ const parent = path.resolve(config.dir, inherit.groups.p);
619
+ if (existsSync(parent)) {
620
+ // Child keys come first, so the first match below is the child's.
621
+ combined = `${text}\n${readFileSync(parent, "utf-8")}`;
622
+ }
623
+ }
624
+ const extensions = yamlBlock(combined, "markdown_extensions");
625
+ if (extensions) {
626
+ for (const name of yamlNames(extensions)) {
627
+ config.extensions.add(normalizeExtension(name));
628
+ }
629
+ readExtensionOptions(extensions, config);
630
+ }
631
+ config.plugins = [
632
+ ...new Set(yamlNames(yamlBlock(combined, "plugins") ?? [])),
633
+ ];
634
+ readYamlNav(yamlBlock(combined, "nav"), config);
635
+ config.docsDir =
636
+ /^docs_dir: *["']?(?<v>[^"'\s#]+)/mu.exec(combined)?.groups.v ?? null;
637
+ config.customDir =
638
+ /^\s+custom_dir: *["']?(?<v>[^"'\s#]+)/mu.exec(combined)?.groups.v ?? null;
639
+ config.useDirectoryUrls = !/^use_directory_urls: *false/mu.test(combined);
640
+ config.extraCss = /^extra_css:/mu.test(combined);
641
+ config.extraJs = /^extra_javascript:/mu.test(combined);
642
+ config.hooks = /^hooks:/mu.test(combined);
643
+ const extra = (yamlBlock(combined, "extra") ?? []).join("\n");
644
+ config.mike = /^\s+provider: *["']?mike/mu.test(extra);
645
+ config.alternate = /^\s+alternate:/mu.test(extra);
646
+ };
647
+
648
+ // Zensical's extensions when a config sets none (zensical/zensical
649
+ // `python/zensical/config.py`, DEFAULT_MARKDOWN_EXTENSIONS). No snippets.
650
+ const ZENSICAL_DEFAULT_EXTENSIONS = [
651
+ "abbr",
652
+ "admonition",
653
+ "attr_list",
654
+ "def_list",
655
+ "footnotes",
656
+ "md_in_html",
657
+ "toc",
658
+ "pymdownx.arithmatex",
659
+ "pymdownx.betterem",
660
+ "pymdownx.caret",
661
+ "pymdownx.details",
662
+ "pymdownx.emoji",
663
+ "pymdownx.highlight",
664
+ "pymdownx.inlinehilite",
665
+ "pymdownx.keys",
666
+ "pymdownx.magiclink",
667
+ "pymdownx.mark",
668
+ "pymdownx.smartsymbols",
669
+ "pymdownx.superfences",
670
+ "pymdownx.tabbed",
671
+ "pymdownx.tasklist",
672
+ "pymdownx.tilde",
673
+ ];
674
+
675
+ /** The extension a dotted TOML key names: `pymdownx.tabbed.alternate_style` → `pymdownx.tabbed`. */
676
+ const tomlExtension = (key) => {
677
+ const parts = normalizeExtension(key.replaceAll("'", "")).split(".");
678
+ return parts[0] === "pymdownx" ? parts.slice(0, 2).join(".") : parts[0];
679
+ };
680
+
681
+ /** Extensions from `[project.markdown_extensions.x]` headers and `x = …` keys under `[project.markdown_extensions]`. */
682
+ const readTomlExtensions = (text, config) => {
683
+ let inTable = false;
684
+ for (const line of text.split(/\r?\n/u)) {
685
+ const header = /^\s*\[(?<name>[^\]]+)\]/u.exec(line);
686
+ if (header) {
687
+ const name = header.groups.name.replaceAll(/["'\s]/gu, "");
688
+ inTable = name === "project.markdown_extensions";
689
+ const sub = /^project\.markdown_extensions\.(?<ext>.+)$/u.exec(name);
690
+ if (sub) {
691
+ config.extensions.add(tomlExtension(sub.groups.ext));
692
+ }
693
+ continue;
694
+ }
695
+ const key = /^\s*(?<key>"[^"]+"|[\w.-]+)\s*=/u.exec(line);
696
+ if (inTable && key) {
697
+ config.extensions.add(tomlExtension(key.groups.key));
698
+ }
699
+ }
700
+ if (config.extensions.size === 0) {
701
+ for (const name of ZENSICAL_DEFAULT_EXTENSIONS) {
702
+ config.extensions.add(name);
703
+ }
704
+ }
705
+ };
706
+
707
+ const readTomlConfig = (text, config) => {
708
+ readTomlExtensions(text, config);
709
+ const bases = /^\s*base_path\s*=\s*(?<v>\[[^\]]*\]|"[^"]*")/mu.exec(text);
710
+ if (bases) {
711
+ config.basePaths = [...bases.groups.v.matchAll(/"(?<p>[^"]*)"/gu)].map(
712
+ (m) => path.resolve(config.dir, m.groups.p)
713
+ );
714
+ }
715
+ const plugins = [
716
+ ...text.matchAll(/^\[project\.plugins\.(?<name>[^.\]]+)/gmu),
717
+ ].map((m) => m.groups.name.replaceAll('"', ""));
718
+ config.plugins = [...new Set(plugins)];
719
+ for (const m of text.matchAll(
720
+ /\{ *"(?<title>[^"]+)" *= *"(?<file>[^"]+\.md)" *\}/gu
721
+ )) {
722
+ config.nav.set(m.groups.file, m.groups.title);
723
+ config.navFiles.push(m.groups.file);
724
+ }
725
+ for (const m of text.matchAll(/^\s*"(?<file>[^"]+\.md)",?\s*$/gmu)) {
726
+ config.navFiles.push(m.groups.file);
727
+ }
728
+ config.docsDir = /^docs_dir *= *"(?<v>[^"]+)"/mu.exec(text)?.groups.v ?? null;
729
+ config.customDir =
730
+ /^custom_dir *= *"(?<v>[^"]+)"/mu.exec(text)?.groups.v ?? null;
731
+ config.useDirectoryUrls = !/^use_directory_urls *= *false/mu.test(text);
732
+ config.extraCss = /^extra_css *=/mu.test(text);
733
+ config.extraJs = /^extra_javascript *=/mu.test(text);
734
+ config.hooks = /^hooks *=/mu.test(text);
735
+ };
736
+
737
+ const readConfig = (file) => {
738
+ const dir = path.dirname(file);
739
+ const config = {
740
+ alternate: false,
741
+ autoAppend: [],
742
+ basePaths: [dir],
743
+ customDir: null,
744
+ dir,
745
+ docsDir: null,
746
+ extensions: new Set(),
747
+ extraCss: false,
748
+ extraJs: false,
749
+ hooks: false,
750
+ inherit: null,
751
+ langMap: {},
752
+ mike: false,
753
+ nav: new Map(),
754
+ navFiles: [],
755
+ plugins: [],
756
+ useDirectoryUrls: true,
757
+ };
758
+ const text = readFileSync(file, "utf-8");
759
+ if (file.endsWith(".toml")) {
760
+ readTomlConfig(text, config);
761
+ } else {
762
+ readYamlConfig(text, config);
763
+ }
764
+ return config;
765
+ };
766
+
767
+ const findConfig = (explicit, contentDir) => {
768
+ if (explicit) {
769
+ return path.resolve(explicit);
770
+ }
771
+ for (const dir of [process.cwd(), path.dirname(contentDir)]) {
772
+ const name = CONFIG_NAMES.find((n) => existsSync(path.join(dir, n)));
773
+ if (name) {
774
+ return path.join(dir, name);
775
+ }
776
+ }
777
+ return null;
778
+ };
779
+
780
+ /**
781
+ * Which extensions' syntax to convert. With a config, only the extensions it
782
+ * enables (`extra` expands to its parts); without one, everything except the
783
+ * conversions that could misread plain Markdown (`risky`).
784
+ */
785
+ const makeEnabled =
786
+ (config) =>
787
+ (name, risky = false) => {
788
+ if (!config) {
789
+ return !risky;
790
+ }
791
+ const ext = config.extensions;
792
+ return (
793
+ ext.has(name) ||
794
+ ((ext.has("extra") || ext.has("pymdownx.extra")) && EXTRA_PARTS.has(name))
795
+ );
796
+ };
797
+
798
+ // --- Code fences ----------------------------------------------------------------
799
+
800
+ /** Pull `hl_lines`/`linenums` out of fence options. */
801
+ const fenceOptions = (rest, ctx) => {
802
+ let lines = "";
803
+ let lineNumbers = false;
804
+ const remaining = rest
805
+ .replace(/hl_lines=(?<q>["'])(?<v>[^"']*)\k<q>/u, (...m) => {
806
+ lines = `{${m
807
+ .at(-1)
808
+ .v.trim()
809
+ .split(/[\s,]+/u)
810
+ .join(",")}}`;
811
+ ctx.change("fence-hl", `hl_lines → ${lines}`);
812
+ return "";
813
+ })
814
+ .replace(/linenums=(?<q>["'])(?<v>[^"']*)\k<q>/u, (...m) => {
815
+ lineNumbers = true;
816
+ const [start] = m.at(-1).v.trim().split(/\s+/u);
817
+ ctx.change("fence-linenums", "linenums → lineNumbers");
818
+ if (start !== "1") {
819
+ ctx.report(
820
+ "linenums-start",
821
+ `start ${start} dropped (Blume numbers from 1)`
822
+ );
823
+ }
824
+ return "";
825
+ })
826
+ .replaceAll(/\s+/gu, " ")
827
+ .trim();
828
+ return { lineNumbers, lines, remaining };
829
+ };
830
+
831
+ const rewriteFenceInfo = (indent, marker, info, ctx) => {
832
+ let rest = info.trim();
833
+ const attr = /^\{ *\.(?<lang>[\w+#.-]+) *(?<more>.*?) *\}$/u.exec(rest);
834
+ if (attr) {
835
+ rest = `${attr.groups.lang} ${attr.groups.more}`.trim();
836
+ ctx.change("fence-attr", "{ .lang … } → lang …");
837
+ }
838
+ let lang = /^(?<lang>[^\s="'{]+)(?=\s|$)/u.exec(rest)?.groups.lang ?? "";
839
+ rest = rest.slice(lang.length).trim();
840
+ const mapped =
841
+ ctx.langMap[lang] ?? LANGS[lang.toLowerCase()] ?? lang.toLowerCase();
842
+ if (mapped !== lang) {
843
+ ctx.change("fence-lang", `${lang} → ${mapped}`);
844
+ lang = mapped;
845
+ }
846
+ const { lineNumbers, lines, remaining } = fenceOptions(rest, ctx);
847
+ if (!lang && (remaining || lines || lineNumbers)) {
848
+ lang = "text";
849
+ ctx.change("fence-text", "options without a language → text");
850
+ }
851
+ const parts = [lang, remaining, lines, lineNumbers ? "lineNumbers" : ""];
852
+ return `${" ".repeat(indent)}${marker}${parts.filter(Boolean).join(" ")}`;
853
+ };
854
+
855
+ // --- Snippets -------------------------------------------------------------------
856
+
857
+ /** Resolve a snippet path the way pymdownx.snippets does: from base_path. */
858
+ const resolveSnippet = (target, ctx) => {
859
+ if (/^https?:/u.test(target)) {
860
+ ctx.report(
861
+ "snippet-url",
862
+ `${target} — download it into _snippets/ by hand`
863
+ );
864
+ return null;
865
+ }
866
+ const { file, suffix } = SNIPPET_SUFFIX.exec(target).groups;
867
+ if (suffix) {
868
+ ctx.report(
869
+ "snippet-range",
870
+ `${target} — line ranges and sections have no equivalent: split the excerpt into its own file`
871
+ );
872
+ return null;
873
+ }
874
+ for (const base of ctx.basePaths) {
875
+ const abs = path.resolve(base, file);
876
+ if (existsSync(abs) && statSync(abs).isFile()) {
877
+ return abs;
878
+ }
879
+ }
880
+ ctx.report(
881
+ "snippet-missing",
882
+ `${target} — not found under the snippets base_path (MkDocs dropped it silently)`
883
+ );
884
+ return null;
885
+ };
886
+
887
+ /** The `<include>` path for a resolved snippet, copying it in when outside. */
888
+ const includePath = (abs, ctx) => {
889
+ const rel = toPosix(path.relative(ctx.root, abs));
890
+ if (!rel.startsWith("../") && !path.isAbsolute(rel)) {
891
+ return `/${rel}`;
892
+ }
893
+ const fromConfig = toPosix(path.relative(ctx.configDir, abs));
894
+ const copyRel = `_snippets/${fromConfig.startsWith("../") ? path.basename(abs) : fromConfig}`;
895
+ ctx.copies.set(copyRel, abs);
896
+ ctx.report(
897
+ "snippet-copied",
898
+ `${fromConfig} is outside the content root → copied to ${copyRel} (keep it in sync with a predev/prebuild script)`
899
+ );
900
+ return `/${copyRel}`;
901
+ };
902
+
903
+ /**
904
+ * An include indented like code (4+ columns, inside a list item) splices only
905
+ * in `.mdx`: Blume's `.md` include matcher skips it and the page ships a bare
906
+ * tag with a green build.
907
+ */
908
+ const markIndentedInclude = (line, ctx) => {
909
+ if (indentOf(line) >= 4) {
910
+ ctx.mdx = true;
911
+ }
912
+ };
913
+
914
+ /** A fence holding only one snippet line → a code include; else null. */
915
+ const fencedSnippet = (lines, i, end, ctx) => {
916
+ const only = lines.slice(i + 1, end - 1).filter((l) => !isBlank(l));
917
+ const snip = only.length === 1 ? SNIPPET_LINE.exec(only[0]) : null;
918
+ if (!snip || snip.groups.escape) {
919
+ if (only.some((l) => SNIPPET_LINE.test(l))) {
920
+ ctx.report(
921
+ "snippet-in-code",
922
+ "a code block mixes snippet lines with code — split it by hand"
923
+ );
924
+ }
925
+ return null;
926
+ }
927
+ const abs = resolveSnippet(snip.groups.target, ctx);
928
+ if (!abs) {
929
+ return null;
930
+ }
931
+ const { indent, info } = FENCE_OPEN.exec(lines[i]).groups;
932
+ const [lang, ...meta] = rewriteFenceInfo(0, "", info, ctx).trim().split(" ");
933
+ const langAttr = lang ? ` lang="${lang}"` : "";
934
+ const metaAttr = meta.length > 0 ? ` meta='${meta.join(" ")}'` : "";
935
+ ctx.change(
936
+ "snippet-code",
937
+ `code block → code include of ${snip.groups.target}`
938
+ );
939
+ markIndentedInclude(lines[i], ctx);
940
+ return `${indent}<include${langAttr}${metaAttr}>${includePath(abs, ctx)}</include>`;
941
+ };
942
+
943
+ /** `--8<-- "file"` → an include line; null when it can't be converted. */
944
+ const lineSnippet = (snip, i, ctx) => {
945
+ if (snip.groups.escape) {
946
+ ctx.report(
947
+ "snippet-escaped",
948
+ "an escaped ;--8<-- line (documents the syntax) — left as is"
949
+ );
950
+ return null;
951
+ }
952
+ const abs = resolveSnippet(snip.groups.target, ctx);
953
+ if (!abs) {
954
+ return null;
955
+ }
956
+ ctx.change("snippet", `--8<-- ${snip.groups.target} → <include>`);
957
+ markIndentedInclude(snip.input, ctx);
958
+ return {
959
+ lines: [`${snip.groups.indent}<include>${includePath(abs, ctx)}</include>`],
960
+ next: i + 1,
961
+ };
962
+ };
963
+
964
+ /** A `--8<--` block of file lines → include lines; null when it can't be converted. */
965
+ const blockSnippet = (lines, i, ctx) => {
966
+ const block = SNIPPET_BLOCK.exec(lines[i]);
967
+ const close = block
968
+ ? lines.findIndex((l, k) => k > i && SNIPPET_BLOCK.test(l))
969
+ : -1;
970
+ if (close === -1) {
971
+ return null;
972
+ }
973
+ const targets = lines
974
+ .slice(i + 1, close)
975
+ .map((l) => l.trim())
976
+ .filter((l) => l && !l.startsWith(";"));
977
+ const resolved = targets.map((t) => resolveSnippet(t, ctx));
978
+ if (resolved.some((abs) => !abs)) {
979
+ return null;
980
+ }
981
+ ctx.change("snippet", `--8<-- block → ${targets.length} <include>`);
982
+ markIndentedInclude(lines[i], ctx);
983
+ return {
984
+ lines: resolved.map(
985
+ (abs) =>
986
+ `${block.groups.indent}<include>${includePath(abs, ctx)}</include>`
987
+ ),
988
+ next: close + 1,
989
+ };
990
+ };
991
+
992
+ // --- Block conversion: admonitions, details, tabs ---------------------------------
993
+
994
+ /** Do a title's unescaped brackets nest and close? */
995
+ const bracketsBalanced = (text) => {
996
+ let depth = 0;
997
+ for (const m of text.matchAll(/\\.|[[\]]/gu)) {
998
+ if (m[0] === "[") {
999
+ depth += 1;
1000
+ } else if (m[0] === "]") {
1001
+ depth -= 1;
1002
+ }
1003
+ if (depth < 0) {
1004
+ return false;
1005
+ }
1006
+ }
1007
+ return depth === 0;
1008
+ };
1009
+
1010
+ /**
1011
+ * A callout's `[label]`. Blume renders the label's inline Markdown, and
1012
+ * balanced brackets (code spans included) parse as written; an unbalanced
1013
+ * title is flattened and every bracket escaped, since an escape inside a code
1014
+ * span would end the label early and drop the title.
1015
+ */
1016
+ const directiveLabel = (title) => {
1017
+ if (!title) {
1018
+ return "";
1019
+ }
1020
+ const text = bracketsBalanced(title)
1021
+ ? title
1022
+ : plainTitle(title).replaceAll(/(?<b>[[\]])/gu, String.raw`\$<b>`);
1023
+ return `[${text}]`;
1024
+ };
1025
+
1026
+ const renderCallout = (type, title, body, ctx) => {
1027
+ const mapped = ADMONITION_TYPES[type] ?? "note";
1028
+ if (!ADMONITION_TYPES[type]) {
1029
+ ctx.report("admonition-unknown", `'${type}' → note`);
1030
+ } else if (mapped !== type) {
1031
+ ctx.change("admonition-type", `${type} → ${mapped}`);
1032
+ }
1033
+ const inner = Math.max(
1034
+ 0,
1035
+ ...body.map((l) => /^ *(?<c>:{3,})/u.exec(l)?.groups.c.length ?? 0)
1036
+ );
1037
+ const colons = ":".repeat(Math.max(3, inner + 1));
1038
+ const label = directiveLabel(title);
1039
+ ctx.change("admonition", `!!! ${type} → ${colons}${mapped}`);
1040
+ return [`${colons}${mapped}${label}`, ...body, colons];
1041
+ };
1042
+
1043
+ const renderAdmonition = (adm, body, ctx) => {
1044
+ const [first = "note", ...modifiers] = (adm.words ?? "note").split(/ +/u);
1045
+ const type = first.toLowerCase();
1046
+ if (modifiers.some((w) => w === "inline" || w === "end")) {
1047
+ ctx.report("admonition-inline", "inline/inline end placement dropped");
1048
+ }
1049
+ if (adm.kind.startsWith("???")) {
1050
+ const open = adm.kind === "???+" ? " defaultOpen" : "";
1051
+ // Expandable titles render their inline Markdown, but raw HTML as text.
1052
+ const label = adm.title
1053
+ ? removeAll(adm.title, /<[^>]+>/gu).trim()
1054
+ : capitalize(type);
1055
+ ctx.change("details", `${adm.kind} ${type} → <Expandable>`);
1056
+ return [
1057
+ `<Expandable ${jsxAttr("title", label)}${open}>`,
1058
+ "",
1059
+ ...body,
1060
+ "",
1061
+ "</Expandable>",
1062
+ ];
1063
+ }
1064
+ if (type === "quote" || type === "cite") {
1065
+ ctx.change("admonition", "quote → blockquote");
1066
+ return body.map((l) => (l === "" ? ">" : `> ${l}`));
1067
+ }
1068
+ return renderCallout(type, adm.title, body, ctx);
1069
+ };
1070
+
1071
+ const isLoneFence = (body) =>
1072
+ body.length >= 2 &&
1073
+ FENCE_OPEN.test(body[0]) &&
1074
+ indentOf(body[0]) === 0 &&
1075
+ fenceEnd(body, 0) === body.length;
1076
+
1077
+ /** Is `line` a tab of the group at `indent` (and not the start of a new one)? */
1078
+ const continuesGroup = (line, indent) => {
1079
+ const t = line === undefined ? null : TAB.exec(line);
1080
+ return Boolean(
1081
+ t && t.groups.indent.length === indent && !t.groups.flags.includes("!")
1082
+ );
1083
+ };
1084
+
1085
+ const renderCodeGroup = (tabs, ctx) => {
1086
+ const block = ["<CodeGroup>", ""];
1087
+ for (const t of tabs) {
1088
+ const { info, marker } = FENCE_OPEN.exec(t.body[0]).groups;
1089
+ const existing = /title=(?<q>["'])(?<v>.*?)\k<q>/u.exec(info);
1090
+ if (existing && existing.groups.v !== t.label) {
1091
+ ctx.report(
1092
+ "tab-title",
1093
+ `tab '${t.label}' wraps a block titled '${existing.groups.v}': kept the tab label`
1094
+ );
1095
+ }
1096
+ const kept =
1097
+ info.replace(/\s*title=(?<q>["']).*?\k<q>/u, "").trim() || "text";
1098
+ const quote = t.label.includes('"') ? "'" : '"';
1099
+ block.push(
1100
+ `${marker}${kept} title=${quote}${t.label}${quote}`,
1101
+ ...t.body.slice(1),
1102
+ ""
1103
+ );
1104
+ }
1105
+ block.push("</CodeGroup>");
1106
+ ctx.change("tabs", `=== → <CodeGroup> (${tabs.length} tabs)`);
1107
+ return block;
1108
+ };
1109
+
1110
+ const renderTabs = (tabs, ctx) => {
1111
+ const selected = tabs.findIndex((t) => t.selected);
1112
+ const block = [
1113
+ selected > 0 ? `<Tabs defaultTabIndex={${selected}}>` : "<Tabs>",
1114
+ ];
1115
+ for (const t of tabs) {
1116
+ block.push(
1117
+ `<Tab ${jsxAttr("title", t.label)}>`,
1118
+ "",
1119
+ ...t.body,
1120
+ "",
1121
+ "</Tab>"
1122
+ );
1123
+ }
1124
+ block.push("</Tabs>");
1125
+ ctx.change("tabs", `=== → <Tabs> (${tabs.length} tabs)`);
1126
+ return block;
1127
+ };
1128
+
1129
+ /**
1130
+ * Convert one block level of lines (recursive for admonition and tab
1131
+ * bodies). Each handler converts the construct at `lines[i]` or returns null.
1132
+ */
1133
+ const convertBlock = (lines, ctx) => {
1134
+ const handleFence = (i) => {
1135
+ const fence = FENCE_OPEN.exec(lines[i]);
1136
+ if (!fence) {
1137
+ return null;
1138
+ }
1139
+ const end = fenceEnd(lines, i);
1140
+ const include = ctx.enabled("pymdownx.snippets")
1141
+ ? fencedSnippet(lines, i, end, ctx)
1142
+ : null;
1143
+ if (include) {
1144
+ return { lines: [include], next: end };
1145
+ }
1146
+ const { indent, info, marker } = fence.groups;
1147
+ return {
1148
+ lines: [
1149
+ rewriteFenceInfo(indent.length, marker, info, ctx),
1150
+ ...lines.slice(i + 1, end),
1151
+ ],
1152
+ next: end,
1153
+ };
1154
+ };
1155
+
1156
+ const handleAdmonition = (i) => {
1157
+ const m = ADMONITION.exec(lines[i]);
1158
+ const ext = m?.groups.kind === "!!!" ? "admonition" : "pymdownx.details";
1159
+ const named =
1160
+ m &&
1161
+ (m.groups.words ||
1162
+ (ext === "pymdownx.details" && m.groups.title !== undefined));
1163
+ if (!(named && ctx.enabled(ext))) {
1164
+ return null;
1165
+ }
1166
+ const indent = m.groups.indent.length;
1167
+ const [rawBody, next] = collectBody(lines, i + 1, indent + 4);
1168
+ const body = trimBlankEdges(convertBlock(rawBody, ctx));
1169
+ ctx.mdx = true;
1170
+ return {
1171
+ lines: prefix(indent, renderAdmonition(m.groups, body, ctx)),
1172
+ next,
1173
+ };
1174
+ };
1175
+
1176
+ const handleTabs = (i) => {
1177
+ const m = TAB.exec(lines[i]);
1178
+ if (!(m && ctx.enabled("pymdownx.tabbed"))) {
1179
+ return null;
1180
+ }
1181
+ const indent = m.groups.indent.length;
1182
+ const tabs = [];
1183
+ let j = i;
1184
+ let more = true;
1185
+ while (more) {
1186
+ const t = TAB.exec(lines[j]);
1187
+ const [rawBody, next] = collectBody(lines, j + 1, indent + 4);
1188
+ tabs.push({
1189
+ body: trimBlankEdges(convertBlock(rawBody, ctx)),
1190
+ label: t.groups.label,
1191
+ selected: t.groups.flags.includes("+"),
1192
+ });
1193
+ let k = next;
1194
+ while (k < lines.length && isBlank(lines[k])) {
1195
+ k += 1;
1196
+ }
1197
+ more = continuesGroup(lines[k], indent);
1198
+ j = more ? k : next;
1199
+ }
1200
+ ctx.mdx = true;
1201
+ const block = tabs.every((t) => isLoneFence(t.body))
1202
+ ? renderCodeGroup(tabs, ctx)
1203
+ : renderTabs(tabs, ctx);
1204
+ return { lines: prefix(indent, block), next: j };
1205
+ };
1206
+
1207
+ const handleSnippet = (i) => {
1208
+ if (!ctx.enabled("pymdownx.snippets")) {
1209
+ return null;
1210
+ }
1211
+ const snip = SNIPPET_LINE.exec(lines[i]);
1212
+ return snip ? lineSnippet(snip, i, ctx) : blockSnippet(lines, i, ctx);
1213
+ };
1214
+
1215
+ const handlers = [handleFence, handleAdmonition, handleTabs, handleSnippet];
1216
+ const out = [];
1217
+ let i = 0;
1218
+ while (i < lines.length) {
1219
+ let handled = null;
1220
+ for (const handler of handlers) {
1221
+ handled ??= handler(i);
1222
+ }
1223
+ if (handled) {
1224
+ out.push(...handled.lines);
1225
+ i = handled.next;
1226
+ } else {
1227
+ if (/^ *\/{3,} *[\w-]+/u.test(lines[i])) {
1228
+ ctx.report(
1229
+ "blocks",
1230
+ `pymdownx.blocks syntax '${lines[i].trim()}' — convert by hand`
1231
+ );
1232
+ }
1233
+ out.push(lines[i]);
1234
+ i += 1;
1235
+ }
1236
+ }
1237
+ return out;
1238
+ };
1239
+
1240
+ // --- Line rewrites ------------------------------------------------------------------
1241
+
1242
+ /** `## Title { #id .class }` → `## Title [#id]`; drops a redundant self-link. */
1243
+ const pinHeading = (line, ctx) => {
1244
+ const heading = HEADING_ATTRS.exec(line);
1245
+ const isAttrList =
1246
+ heading &&
1247
+ ctx.enabled("attr_list") &&
1248
+ /(?:^|\s)[#.]|=/u.test(heading.groups.attrs);
1249
+ if (!isAttrList) {
1250
+ return line;
1251
+ }
1252
+ let { text } = heading.groups;
1253
+ const id = /(?:^|\s)#(?<id>[\w.:-]+)/u.exec(heading.groups.attrs)?.groups.id;
1254
+ const self =
1255
+ /^(?<hashes>#{1,6} +)\[(?<label>.+)\]\(#(?<target>[\w.:-]+)\) *$/u.exec(
1256
+ text
1257
+ );
1258
+ if (self && self.groups.target === id) {
1259
+ text = `${self.groups.hashes}${self.groups.label}`;
1260
+ ctx.change(
1261
+ "heading-self-link",
1262
+ "self-link dropped (Blume links headings itself)"
1263
+ );
1264
+ }
1265
+ const rest = heading.groups.attrs.replace(/(?:^|\s)#[\w.:-]+/u, "").trim();
1266
+ if (rest) {
1267
+ ctx.report("attr-dropped", `heading attributes '${rest}' dropped`);
1268
+ }
1269
+ ctx.change(
1270
+ "heading-id",
1271
+ id ? `{ #${id} } → [#${id}]` : "heading attribute list dropped"
1272
+ );
1273
+ return id ? `${text} [#${id}]` : text;
1274
+ };
1275
+
1276
+ const attrKind = (attrs) => {
1277
+ if (attrs.includes("md-button")) {
1278
+ return "button";
1279
+ }
1280
+ return attrs.includes("target=") ? "new-tab" : "attributes";
1281
+ };
1282
+
1283
+ const dropAttrLists = (p, ctx) =>
1284
+ p
1285
+ .replace(INLINE_ATTRS, (...m) => {
1286
+ const { attrs, target } = m.at(-1);
1287
+ const kind = attrKind(attrs);
1288
+ const hint =
1289
+ kind === "new-tab" ? " (markdown.externalLinks: true covers it)" : "";
1290
+ ctx.report("attr-dropped", `${kind} '{ ${attrs} }' dropped${hint}`);
1291
+ ctx.change("attr-list", "attribute list dropped");
1292
+ return target;
1293
+ })
1294
+ .replace(TRAILING_ATTRS, (m) => {
1295
+ ctx.report("attr-dropped", `block attributes '${m.trim()}' dropped`);
1296
+ ctx.change("attr-list", "attribute list dropped");
1297
+ return "";
1298
+ });
1299
+
1300
+ const keyLabel = (key) => {
1301
+ const word = unquote(key);
1302
+ const known = KEY_LABELS[word.toLowerCase()];
1303
+ if (known) {
1304
+ return known;
1305
+ }
1306
+ return word.length === 1
1307
+ ? word.toUpperCase()
1308
+ : word.split("-").map(capitalize).join(" ");
1309
+ };
1310
+
1311
+ const convertKeys = (p, ctx) =>
1312
+ p.replace(KEYS, (...m) => {
1313
+ ctx.change("keys", `${m[0]} → <kbd>`);
1314
+ return m
1315
+ .at(-1)
1316
+ .keys.split("+")
1317
+ .map((k) => `<kbd>${keyLabel(k)}</kbd>`)
1318
+ .join("+");
1319
+ });
1320
+
1321
+ const convertMark = (p, ctx) =>
1322
+ p.replaceAll(/(?<![=\w])==(?<t>\S(?:[^=\n]*?\S)?)==(?![=\w])/gu, (...m) => {
1323
+ ctx.change("mark", "==mark== → <mark>");
1324
+ return `<mark>${m.at(-1).t}</mark>`;
1325
+ });
1326
+
1327
+ const convertIns = (p, ctx) =>
1328
+ p.replaceAll(/(?<!\^)\^\^(?<t>\S(?:[^^\n]*?\S)?)\^\^(?!\^)/gu, (...m) => {
1329
+ ctx.change("ins", "^^ins^^ → <ins>");
1330
+ return `<ins>${m.at(-1).t}</ins>`;
1331
+ });
1332
+
1333
+ const convertIcons = (p, ctx) =>
1334
+ p.replace(ICON_SHORTCODE, (...m) => {
1335
+ const { name } = m.at(-1);
1336
+ if (!(name in ICONS)) {
1337
+ ctx.report(
1338
+ "icon-unknown",
1339
+ `:${name}: — no mapping; pick a Lucide name or drop it`
1340
+ );
1341
+ return m[0];
1342
+ }
1343
+ const lucide = ICONS[name];
1344
+ if (lucide === null) {
1345
+ ctx.change("icon", `:${name}: dropped (decorative)`);
1346
+ return "";
1347
+ }
1348
+ ctx.mdx = true;
1349
+ ctx.change("icon", `:${name}: → <Icon icon="${lucide}" />`);
1350
+ return `<Icon icon="${lucide}" />`;
1351
+ });
1352
+
1353
+ const convertMathInline = (p, ctx) => {
1354
+ const out = p.replaceAll(/\\\((?<m>.+?)\\\)/gu, (...m) => {
1355
+ ctx.mdx = true;
1356
+ ctx.change("math", String.raw`\(…\) → $$…$$`);
1357
+ return `$$${m.at(-1).m}$$`;
1358
+ });
1359
+ if (/(?<![$\\])\$(?!\$)[^$\s][^$]*\$(?!\$)/u.test(out)) {
1360
+ ctx.counts.dollar += 1;
1361
+ }
1362
+ return out;
1363
+ };
1364
+
1365
+ /** Prose-level rewrites, each only when its extension is on: [extension, risky, step]. */
1366
+ const PROSE_STEPS = [
1367
+ ["attr_list", false, dropAttrLists],
1368
+ ["pymdownx.keys", true, convertKeys],
1369
+ ["pymdownx.mark", true, convertMark],
1370
+ ["pymdownx.caret", true, convertIns],
1371
+ ["pymdownx.emoji", false, convertIcons],
1372
+ ["pymdownx.arithmatex", true, convertMathInline],
1373
+ ];
1374
+
1375
+ const countProse = (p, ctx) => {
1376
+ const emoji = /(?<![\w:]):[a-z0-9_+-]+:(?![\w:])/u.test(
1377
+ p.replaceAll(ICON_SHORTCODE, "")
1378
+ );
1379
+ if (ctx.enabled("pymdownx.emoji") && emoji && !/https?:/u.test(p)) {
1380
+ ctx.counts.emoji += 1;
1381
+ }
1382
+ if (/\{\{|\{%/u.test(p)) {
1383
+ ctx.counts.jinja += 1;
1384
+ }
1385
+ if (/\{(?:\+\+|--|~~|==|>>)/u.test(p)) {
1386
+ ctx.report(
1387
+ "critic",
1388
+ "critic markup — apply the edit or use <ins>/<del>/<mark>"
1389
+ );
1390
+ }
1391
+ };
1392
+
1393
+ const rewriteProse = (p, ctx) => {
1394
+ let out = p;
1395
+ for (const [ext, risky, step] of PROSE_STEPS) {
1396
+ if (ctx.enabled(ext, risky)) {
1397
+ out = step(out, ctx);
1398
+ }
1399
+ }
1400
+ countProse(out, ctx);
1401
+ return out;
1402
+ };
1403
+
1404
+ const inlineHilite = (line, ctx) =>
1405
+ line.replaceAll(
1406
+ /`(?:#!|:::)(?<lang>[\w+#.-]+) +(?<code>[^`]+?)`/gu,
1407
+ (...m) => {
1408
+ const { code, lang } = m.at(-1);
1409
+ const mapped = LANGS[lang.toLowerCase()] ?? lang.toLowerCase();
1410
+ ctx.change("inline-code", `#!${lang} → {:${mapped}}`);
1411
+ return `\`${code}{:${mapped}}\``;
1412
+ }
1413
+ );
1414
+
1415
+ const mathBlockLine = (line, ctx) => {
1416
+ if (/^ *\\\[ *$/u.test(line)) {
1417
+ ctx.change("math", String.raw`\[ → $$`);
1418
+ return line.replace("\\[", "$$$$");
1419
+ }
1420
+ return /^ *\\\] *$/u.test(line) ? line.replace("\\]", "$$$$") : line;
1421
+ };
1422
+
1423
+ const stripMarkdownAttr = (line, ctx) => {
1424
+ if (/class="[^"]*\bgrid\b/u.test(line)) {
1425
+ ctx.report(
1426
+ "grid-cards",
1427
+ "Material grid cards — rebuild as <CardGroup>/<Card>"
1428
+ );
1429
+ }
1430
+ ctx.mdx = true;
1431
+ ctx.change(
1432
+ "md-in-html",
1433
+ "markdown attribute removed (Markdown inside HTML renders in .mdx)"
1434
+ );
1435
+ return line.replace(/\smarkdown(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/u, "");
1436
+ };
1437
+
1438
+ /** Whole-line checks: returns null to drop the line. */
1439
+ const filterLine = (line, ctx) => {
1440
+ if (ctx.enabled("abbr") && /^\*\[[^\]]+\]: /u.test(line)) {
1441
+ ctx.report(
1442
+ "abbr",
1443
+ `abbreviation '${line.slice(2, line.indexOf("]"))}' removed — use <Tooltip> where it matters`
1444
+ );
1445
+ ctx.change("abbr", "abbreviation definition removed");
1446
+ return null;
1447
+ }
1448
+ if (/^ *\[TOC\] *$/u.test(line)) {
1449
+ ctx.change("toc", "[TOC] removed (the TOC is automatic)");
1450
+ return null;
1451
+ }
1452
+ if (ctx.enabled("def_list") && /^: {3}\S/u.test(line)) {
1453
+ ctx.counts.deflist += 1;
1454
+ }
1455
+ if (/^::: +[\w.]+ *$/u.test(line)) {
1456
+ ctx.report(
1457
+ "mkdocstrings",
1458
+ `'${line.trim()}' — mkdocstrings has no Blume equivalent`
1459
+ );
1460
+ }
1461
+ if (
1462
+ /<img\b[^>]*\ssrc=["']?(?![a-z][\w+.-]*:|\/|#)[^"'\s>]/iu.test(
1463
+ maskCode(line)
1464
+ )
1465
+ ) {
1466
+ ctx.report(
1467
+ "raw-img",
1468
+ "a raw <img> with a relative src — Blume reads it from the page's folder (MkDocs read it from the page's URL, a level deeper) and doesn't optimize it: make it a Markdown image"
1469
+ );
1470
+ }
1471
+ return line;
1472
+ };
1473
+
1474
+ /** Line-level rewrites outside fences; returns null to drop the line. */
1475
+ const rewriteLine = (raw, ctx) => {
1476
+ let line = mapProse(pinHeading(raw, ctx), (p) => rewriteProse(p, ctx));
1477
+ if (ctx.enabled("pymdownx.inlinehilite")) {
1478
+ line = inlineHilite(line, ctx);
1479
+ }
1480
+ if (ctx.enabled("pymdownx.arithmatex", true)) {
1481
+ line = mathBlockLine(line, ctx);
1482
+ ctx.mdx ||= /^ *\$\$/u.test(line);
1483
+ }
1484
+ const markdownInHtml =
1485
+ /^ *<(?:div|section|figure|details|span|p)\b[^>]*\smarkdown(?:=(?:"[^"]*"|'[^']*'|\S+))?/u;
1486
+ if (ctx.enabled("md_in_html") && markdownInHtml.test(line)) {
1487
+ line = stripMarkdownAttr(line, ctx);
1488
+ }
1489
+ return filterLine(line, ctx);
1490
+ };
1491
+
1492
+ const rewriteLines = (lines, ctx) => {
1493
+ const out = [];
1494
+ let fence = null;
1495
+ for (const line of lines) {
1496
+ if (fence) {
1497
+ out.push(line);
1498
+ fence = isFenceClose(line, fence) ? null : fence;
1499
+ if (/(?:#|\/\/) \(\d+\)!?\s*$/u.test(line)) {
1500
+ ctx.counts.annotations += 1;
1501
+ }
1502
+ continue;
1503
+ }
1504
+ const open = FENCE_OPEN.exec(line);
1505
+ if (open) {
1506
+ fence = open.groups.marker;
1507
+ ctx.mdx ||= /^ *(?:`{3,}|~{3,}) *mermaid\b/u.test(line);
1508
+ out.push(line);
1509
+ continue;
1510
+ }
1511
+ const next = rewriteLine(line, ctx);
1512
+ if (next !== null) {
1513
+ out.push(next);
1514
+ }
1515
+ }
1516
+ return out;
1517
+ };
1518
+
1519
+ /**
1520
+ * Percent-encode angle-bracket link destinations. Blume reads both forms, in
1521
+ * a page and in a partial it splices into one, so this only tidies.
1522
+ */
1523
+ const unwrapAngleDestinations = (lines, ctx) =>
1524
+ mapFenced(lines, (line) =>
1525
+ mapProse(line, (p) =>
1526
+ p.replaceAll(
1527
+ /\]\(<(?<url>[^>\s]+(?: [^>]*)?)>(?<title> +"[^"]*")?\)/gu,
1528
+ (...m) => {
1529
+ const { title, url } = m.at(-1);
1530
+ ctx.change(
1531
+ "link-destination",
1532
+ "<…> link destination → percent-encoded"
1533
+ );
1534
+ const encoded = url
1535
+ .replaceAll(" ", "%20")
1536
+ .replaceAll("(", "%28")
1537
+ .replaceAll(")", "%29");
1538
+ return `](${encoded}${title ?? ""})`;
1539
+ }
1540
+ )
1541
+ )
1542
+ );
1543
+
1544
+ // --- MDX-only rewrites ------------------------------------------------------------
1545
+
1546
+ const commentText = (text) =>
1547
+ text.replace(/^-+/u, "").replace(/-+$/u, "").trim().replaceAll("*/", "* /");
1548
+
1549
+ /** Close single-line comments in place; `start` is an open comment's index, or -1. */
1550
+ const closeComments = (line, ctx) => {
1551
+ let out = line;
1552
+ let start = maskCode(out).indexOf("<!--");
1553
+ while (start !== -1) {
1554
+ const close = out.indexOf("-->", start + 4);
1555
+ if (close === -1) {
1556
+ return { line: out, start };
1557
+ }
1558
+ out = `${out.slice(0, start)}{/* ${commentText(out.slice(start + 4, close))} */}${out.slice(close + 3)}`;
1559
+ ctx.change("comment", "HTML comment → {/* */}");
1560
+ start = maskCode(out).indexOf("<!--");
1561
+ }
1562
+ return { line: out, start };
1563
+ };
1564
+
1565
+ const mdxProse = (line, ctx) =>
1566
+ mapProse(line, (p) =>
1567
+ p
1568
+ .replaceAll(/(?<pre>^|[^(])<(?<url>https?:\/\/[^>\s]+)>/gu, (...m) => {
1569
+ const { pre, url } = m.at(-1);
1570
+ ctx.change("autolink", "<https://…> → [url](url)");
1571
+ return `${pre}[${url}](${url})`;
1572
+ })
1573
+ .replaceAll(
1574
+ /<(?<tag>br|hr|img|input)(?<attrs>\s[^>]*?)?\s*(?<!\/)>/gu,
1575
+ (...m) => {
1576
+ const { attrs, tag } = m.at(-1);
1577
+ ctx.change("void-tag", `<${tag}> self-closed`);
1578
+ return `<${tag}${attrs ?? ""} />`;
1579
+ }
1580
+ )
1581
+ );
1582
+
1583
+ /**
1584
+ * MDX-only rewrites: HTML comments (always on one line — Prettier rewrites a
1585
+ * multi-line `{/* … *\/}` into `{/_ … _/}`), autolinks, and void tags.
1586
+ */
1587
+ const rewriteForMdx = (lines, ctx) => {
1588
+ const out = [];
1589
+ let fence = null;
1590
+ let comment = null;
1591
+ for (const raw of lines) {
1592
+ let line = raw;
1593
+ if (comment) {
1594
+ const end = line.indexOf("-->");
1595
+ if (end === -1) {
1596
+ comment.parts.push(line.trim());
1597
+ continue;
1598
+ }
1599
+ comment.parts.push(line.slice(0, end).trim());
1600
+ line = `${comment.before}{/* ${commentText(comment.parts.filter(Boolean).join(" "))} */}${line.slice(end + 3)}`;
1601
+ comment = null;
1602
+ ctx.change("comment", "multi-line HTML comment → one-line {/* */}");
1603
+ } else if (fence) {
1604
+ out.push(line);
1605
+ fence = isFenceClose(line, fence) ? null : fence;
1606
+ continue;
1607
+ } else if (FENCE_OPEN.test(line)) {
1608
+ fence = FENCE_OPEN.exec(line).groups.marker;
1609
+ out.push(line);
1610
+ continue;
1611
+ }
1612
+ const closed = closeComments(line, ctx);
1613
+ if (closed.start === -1) {
1614
+ out.push(mdxProse(closed.line, ctx));
1615
+ } else {
1616
+ comment = {
1617
+ before: closed.line.slice(0, closed.start),
1618
+ parts: [closed.line.slice(closed.start + 4)],
1619
+ };
1620
+ }
1621
+ }
1622
+ if (comment) {
1623
+ ctx.report("comment", "unclosed HTML comment — fix by hand");
1624
+ out.push(`${comment.before}<!--${comment.parts.join("\n")}`);
1625
+ }
1626
+ return out;
1627
+ };
1628
+
1629
+ /** Hazards MDX would reject in one prose line. */
1630
+ const lineHazards = (line) => {
1631
+ const prose = maskCode(line).replaceAll(/\$\$.*?\$\$/gu, "");
1632
+ const hits = [];
1633
+ for (const m of prose.matchAll(/<\/?(?<tag>[A-Za-z][\w.-]*)/gu)) {
1634
+ const { tag } = m.groups;
1635
+ const fine =
1636
+ KNOWN_TAGS.has(tag.toLowerCase()) ||
1637
+ /^[A-Z]/u.test(tag) ||
1638
+ tag === "include";
1639
+ if (!fine) {
1640
+ hits.push(`<${tag}>`);
1641
+ }
1642
+ }
1643
+ const braces = prose
1644
+ .replaceAll(/\{\/\*.*?\*\/\}/gu, "")
1645
+ .replaceAll(/[=]\{[^}]*\}/gu, "")
1646
+ .replaceAll(/\{[\d,-]+\}/gu, "")
1647
+ .replaceAll(/\\[{}]/gu, "");
1648
+ if (/[{}]/u.test(braces)) {
1649
+ hits.push("{…}");
1650
+ }
1651
+ for (const token of ["<!--", "--8<--"]) {
1652
+ if (prose.includes(token)) {
1653
+ hits.push(token);
1654
+ }
1655
+ }
1656
+ return [...new Set(hits)];
1657
+ };
1658
+
1659
+ /** Report MDX hazards left in prose (outside fences, code, and math). */
1660
+ const scanHazards = (lines, ctx) => {
1661
+ let fence = null;
1662
+ let math = false;
1663
+ for (const [n, line] of lines.entries()) {
1664
+ if (fence) {
1665
+ fence = isFenceClose(line, fence) ? null : fence;
1666
+ } else if (FENCE_OPEN.test(line)) {
1667
+ fence = FENCE_OPEN.exec(line).groups.marker;
1668
+ } else if (/^ *\$\$ *$/u.test(line)) {
1669
+ math = !math;
1670
+ } else if (!math) {
1671
+ const hits = lineHazards(line);
1672
+ if (hits.length > 0) {
1673
+ ctx.report(
1674
+ "mdx-hazard",
1675
+ `line ${n + 1}: ${hits.join(" ")} — ${line.trim().slice(0, 80)}`
1676
+ );
1677
+ }
1678
+ }
1679
+ }
1680
+ };
1681
+
1682
+ // --- Frontmatter ----------------------------------------------------------------
1683
+
1684
+ /** Split frontmatter into top-level entries `{ key, lines, value }`, keeping raw lines. */
1685
+ const parseFrontmatter = (fmLines) => {
1686
+ const entries = [];
1687
+ for (const line of fmLines) {
1688
+ const m = /^(?<q>["']?)(?<key>[\w-]+)\k<q> *:(?<rest>.*)$/u.exec(line);
1689
+ if (m) {
1690
+ entries.push({
1691
+ key: m.groups.key,
1692
+ lines: [line],
1693
+ value: m.groups.rest.trim(),
1694
+ });
1695
+ } else if (entries.length > 0) {
1696
+ entries.at(-1).lines.push(line);
1697
+ } else {
1698
+ entries.push({ key: null, lines: [line], value: "" });
1699
+ }
1700
+ }
1701
+ return entries;
1702
+ };
1703
+
1704
+ const entryList = (entry) => {
1705
+ if (entry.value.startsWith("[")) {
1706
+ return entry.value
1707
+ .slice(1, entry.value.lastIndexOf("]"))
1708
+ .split(",")
1709
+ .map(unquote)
1710
+ .filter(Boolean);
1711
+ }
1712
+ if (entry.value) {
1713
+ return [unquote(entry.value)];
1714
+ }
1715
+ return entry.lines
1716
+ .slice(1)
1717
+ .map((l) => /^\s*- +(?<v>.+)$/u.exec(l)?.groups.v)
1718
+ .filter(Boolean)
1719
+ .map(unquote);
1720
+ };
1721
+
1722
+ /** A small editable model over the frontmatter entries. */
1723
+ const frontmatterModel = (entries, ctx) => ({
1724
+ add: (key, value) =>
1725
+ entries.push({ key, lines: [`${key}: ${value}`], value }),
1726
+ entries,
1727
+ get: (key) => entries.find((e) => e.key === key),
1728
+ remove: (key) => {
1729
+ const i = entries.findIndex((e) => e.key === key);
1730
+ if (i !== -1) {
1731
+ entries.splice(i, 1);
1732
+ }
1733
+ },
1734
+ /** Set `parent.child: value` (block style), unless a child already exists. */
1735
+ setChild: (parent, child, value) => {
1736
+ let entry = entries.find((e) => e.key === parent);
1737
+ if (!entry) {
1738
+ entry = { key: parent, lines: [`${parent}:`], value: "" };
1739
+ entries.push(entry);
1740
+ }
1741
+ if (entry.value !== "") {
1742
+ ctx.report(
1743
+ "frontmatter-conflict",
1744
+ `${parent} is written inline; add ${parent}.${child} by hand`
1745
+ );
1746
+ return false;
1747
+ }
1748
+ const childLine = new RegExp(String.raw`^\s+${child}:`, "u");
1749
+ if (entry.lines.some((l) => childLine.test(l))) {
1750
+ return false;
1751
+ }
1752
+ entry.lines.push(` ${child}: ${value}`);
1753
+ return true;
1754
+ },
1755
+ });
1756
+
1757
+ /** Remove the body H1 (the first content line) and return its text. */
1758
+ const takeH1 = (body, ctx) => {
1759
+ for (const [i, line] of body.entries()) {
1760
+ const skip =
1761
+ isBlank(line) || /^\s*(?:<!--[\s\S]*-->|\{\/\*.*\*\/\})\s*$/u.test(line);
1762
+ if (!skip) {
1763
+ const m = /^# +(?<text>.+?)(?: +#+)? *$/u.exec(line);
1764
+ // A setext H1: the text line, then a `===` underline.
1765
+ const setext =
1766
+ !m &&
1767
+ /^ {0,3}(?![-*+] |\d+[.)] |[#>|<]|`{3}|~{3})\S/u.test(line) &&
1768
+ /^ {0,3}=+ *$/u.test(body[i + 1] ?? "");
1769
+ if (!(m || setext)) {
1770
+ break;
1771
+ }
1772
+ const taken = setext ? 2 : 1;
1773
+ body.splice(i, isBlank(body[i + taken] ?? "x") ? taken + 1 : taken);
1774
+ return m ? m.groups.text : line.trim();
1775
+ }
1776
+ }
1777
+ const marked = mapFenced(body, (l) => (l.startsWith("# ") ? "\0h1" : ""));
1778
+ if (marked.includes("\0h1")) {
1779
+ ctx.report("h1-later", "an H1 below other content was left in place");
1780
+ }
1781
+ return null;
1782
+ };
1783
+
1784
+ const setTitle = (fm, existing, title) => {
1785
+ const line = `title: ${yamlScalar(title)}`;
1786
+ if (existing) {
1787
+ existing.lines = [line];
1788
+ existing.value = title;
1789
+ } else {
1790
+ fm.entries.unshift({ key: "title", lines: [line], value: title });
1791
+ }
1792
+ };
1793
+
1794
+ /** title ← H1 (or nav title); sidebar.label ← nav title; seo.title ← a differing frontmatter title. */
1795
+ const applyTitles = (fm, h1, ctx) => {
1796
+ const existing = fm.get("title");
1797
+ const fmTitle = existing ? unquote(existing.value) : null;
1798
+ let title = fmTitle;
1799
+ if (h1) {
1800
+ title = plainTitle(h1);
1801
+ if (title !== h1.trim()) {
1802
+ ctx.report(
1803
+ "title-markup",
1804
+ `inline Markdown stripped from the title: ${h1.trim()}`
1805
+ );
1806
+ }
1807
+ ctx.change("title", `body H1 → title: ${title}`);
1808
+ } else if (!fmTitle && ctx.navTitle) {
1809
+ title = ctx.navTitle;
1810
+ ctx.change("title", `nav title → title: ${title}`);
1811
+ }
1812
+ if (title !== null && title !== fmTitle) {
1813
+ setTitle(fm, existing, title);
1814
+ }
1815
+ const differs = Boolean(h1 && fmTitle && fmTitle !== title);
1816
+ if (differs && fm.setChild("seo", "title", yamlScalar(fmTitle))) {
1817
+ ctx.change("seo-title", `frontmatter title → seo.title: ${fmTitle}`);
1818
+ }
1819
+ const label = ctx.navTitle ?? (differs ? fmTitle : null);
1820
+ const labelSet =
1821
+ label &&
1822
+ label !== title &&
1823
+ fm.setChild("sidebar", "label", yamlScalar(label));
1824
+ if (labelSet) {
1825
+ ctx.change("sidebar-label", `nav title → sidebar.label: ${label}`);
1826
+ }
1827
+ };
1828
+
1829
+ const mapHide = (fm, ctx) => {
1830
+ const hide = fm.get("hide");
1831
+ if (!hide) {
1832
+ return;
1833
+ }
1834
+ const values = entryList(hide);
1835
+ fm.remove("hide");
1836
+ const nav = values.includes("navigation");
1837
+ if (nav || values.includes("toc")) {
1838
+ const mode = nav ? "center" : "wide";
1839
+ if (!fm.get("mode")) {
1840
+ fm.add("mode", mode);
1841
+ }
1842
+ ctx.change("hide", `hide ${values.join(", ")} → mode: ${mode}`);
1843
+ }
1844
+ if (values.includes("footer") && !fm.get("pagination")) {
1845
+ fm.add("pagination", "false");
1846
+ ctx.change("hide", "hide footer → pagination: false");
1847
+ }
1848
+ const dropped = values.filter(
1849
+ (v) => !["navigation", "toc", "footer"].includes(v)
1850
+ );
1851
+ if (dropped.length > 0) {
1852
+ ctx.report(
1853
+ "hide",
1854
+ `hide ${dropped.join(", ")} has no equivalent — dropped`
1855
+ );
1856
+ }
1857
+ };
1858
+
1859
+ const mapStatus = (fm, ctx) => {
1860
+ const status = fm.get("status");
1861
+ if (!status) {
1862
+ return;
1863
+ }
1864
+ const value = unquote(status.value);
1865
+ fm.remove("status");
1866
+ if (value === "deprecated") {
1867
+ fm.add("deprecated", "true");
1868
+ ctx.change("status", "status: deprecated → deprecated: true");
1869
+ return;
1870
+ }
1871
+ fm.setChild("sidebar", "badge", yamlScalar(capitalize(value)));
1872
+ ctx.change("status", `status: ${value} → sidebar.badge`);
1873
+ };
1874
+
1875
+ const mapTags = (fm, ctx) => {
1876
+ const tags = fm.get("tags");
1877
+ if (!tags) {
1878
+ return;
1879
+ }
1880
+ const values = entryList(tags);
1881
+ fm.remove("tags");
1882
+ if (fm.setChild("search", "tags", `[${values.map(yamlScalar).join(", ")}]`)) {
1883
+ ctx.change("tags", "tags → search.tags");
1884
+ }
1885
+ };
1886
+
1887
+ const mapIcon = (fm, ctx) => {
1888
+ const icon = fm.get("icon");
1889
+ const raw = icon ? unquote(icon.value) : "";
1890
+ if (!raw.includes("/")) {
1891
+ return;
1892
+ }
1893
+ const lucide = ICONS[raw.replaceAll("/", "-")];
1894
+ if (lucide) {
1895
+ icon.lines = [`icon: ${lucide}`];
1896
+ ctx.change("icon", `icon ${raw} → ${lucide}`);
1897
+ return;
1898
+ }
1899
+ fm.remove("icon");
1900
+ ctx.report("icon-unknown", `icon ${raw} dropped — set a Lucide name by hand`);
1901
+ ctx.change("icon", `icon ${raw} dropped`);
1902
+ };
1903
+
1904
+ const reportKeys = (fm, ctx) => {
1905
+ for (const key of DROP_KEYS) {
1906
+ if (fm.get(key)) {
1907
+ fm.remove(key);
1908
+ ctx.report("dropped-key", `${key} has no equivalent — dropped`);
1909
+ ctx.change("dropped-key", key);
1910
+ }
1911
+ }
1912
+ for (const entry of fm.entries) {
1913
+ if (entry.key && !BLUME_KEYS.has(entry.key)) {
1914
+ ctx.report(
1915
+ "unknown-key",
1916
+ `frontmatter '${entry.key}' isn't in Blume's schema — map or remove it (blog posts: see the reference)`
1917
+ );
1918
+ }
1919
+ }
1920
+ if (fm.get("date")?.value === "") {
1921
+ ctx.report(
1922
+ "blog-date",
1923
+ "date is an object (created/updated) — flatten to date + lastModified"
1924
+ );
1925
+ }
1926
+ };
1927
+
1928
+ const convertFrontmatter = (fmLines, bodyLines, ctx) => {
1929
+ const fm = frontmatterModel(parseFrontmatter(fmLines), ctx);
1930
+ const body = [...bodyLines];
1931
+ applyTitles(fm, takeH1(body, ctx), ctx);
1932
+ if (!fm.get("title")) {
1933
+ ctx.report(
1934
+ "no-title",
1935
+ "no H1, nav title, or frontmatter title — set `title` (an H1 that only an included partial brings would render twice)"
1936
+ );
1937
+ }
1938
+ mapHide(fm, ctx);
1939
+ mapStatus(fm, ctx);
1940
+ mapTags(fm, ctx);
1941
+ mapIcon(fm, ctx);
1942
+ reportKeys(fm, ctx);
1943
+ return { body, fm: fm.entries.flatMap((e) => e.lines) };
1944
+ };
1945
+
1946
+ // --- Files and links ---------------------------------------------------------------
1947
+
1948
+ /** Every file under `root` that `keep` accepts, in sorted order. */
1949
+ const walkFiles = (root, keep) => {
1950
+ const out = [];
1951
+ const walk = (dir) => {
1952
+ for (const name of readdirSync(dir).toSorted()) {
1953
+ const abs = path.join(dir, name);
1954
+ if (statSync(abs).isDirectory()) {
1955
+ if (!SKIP_DIRS.has(name)) {
1956
+ walk(abs);
1957
+ }
1958
+ } else if (keep(name)) {
1959
+ out.push(abs);
1960
+ }
1961
+ }
1962
+ };
1963
+ walk(root);
1964
+ return out;
1965
+ };
1966
+
1967
+ const isPartial = (rel) =>
1968
+ rel.split("/").some((s) => s.startsWith("_") || s.startsWith("."));
1969
+
1970
+ const safeDecode = (target) => {
1971
+ try {
1972
+ return decodeURI(target);
1973
+ } catch {
1974
+ // A malformed escape: resolve the path as written.
1975
+ return target;
1976
+ }
1977
+ };
1978
+
1979
+ /** Rewrite relative links to renamed files, as seen from `fromDir`. */
1980
+ const rewriteLinks = (lines, fromDir, renamed, ctx) =>
1981
+ mapFenced(lines, (line) =>
1982
+ mapProse(line, (p) =>
1983
+ p.replace(MD_LINK, (...m) => {
1984
+ const { hash, lead, target } = m.at(-1);
1985
+ const next = renamed.get(path.resolve(fromDir, safeDecode(target)));
1986
+ if (!next) {
1987
+ return m[0];
1988
+ }
1989
+ let rel = toPosix(path.relative(fromDir, next));
1990
+ if (target.startsWith("./") && !rel.startsWith(".")) {
1991
+ rel = `./${rel}`;
1992
+ }
1993
+ ctx.change("link", `${target} → ${rel}`);
1994
+ return `${lead}${rel}${hash ?? ""}`;
1995
+ })
1996
+ )
1997
+ );
1998
+
1999
+ // --- Pages ---------------------------------------------------------------------------
2000
+
2001
+ const COUNT_REPORTS = [
2002
+ [
2003
+ "annotations",
2004
+ "annotations",
2005
+ (n) => `${n} code annotation marker(s) — move the notes into prose`,
2006
+ ],
2007
+ [
2008
+ "emoji",
2009
+ "emoji",
2010
+ (n) => `${n} line(s) with emoji shortcodes — paste the emoji`,
2011
+ ],
2012
+ [
2013
+ "dollar",
2014
+ "math",
2015
+ (n) => `${n} line(s) with single-$ math — write $$…$$ where it is math`,
2016
+ ],
2017
+ [
2018
+ "jinja",
2019
+ "macros",
2020
+ (n) => `${n} line(s) with {{ }} / {% %} — variables or by hand`,
2021
+ ],
2022
+ [
2023
+ "deflist",
2024
+ "deflist",
2025
+ (n) => `${n} definition list item(s) — <dl> or a list`,
2026
+ ],
2027
+ ];
2028
+
2029
+ /** Split a file into frontmatter lines (or null) and body lines. */
2030
+ const splitSource = (source) => {
2031
+ const lines = source.split(/\r?\n/u);
2032
+ const end = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
2033
+ return end === -1
2034
+ ? { body: lines, fm: null }
2035
+ : { body: lines.slice(end + 1), fm: lines.slice(1, end) };
2036
+ };
2037
+
2038
+ const reportCounts = (ctx) => {
2039
+ for (const [key, kind, message] of COUNT_REPORTS) {
2040
+ if (ctx.counts[key] > 0) {
2041
+ ctx.report(kind, message(ctx.counts[key]));
2042
+ }
2043
+ }
2044
+ };
2045
+
2046
+ /** Convert one file's text; renames and links are decided across files later. */
2047
+ const convertPage = (abs, source, run) => {
2048
+ const rel = toPosix(path.relative(run.root, abs));
2049
+ const ctx = run.makeCtx(abs);
2050
+ const split = splitSource(source);
2051
+ const partial = isPartial(rel);
2052
+ if (!(split.fm || partial) && MULTIMARKDOWN_META.test(split.body[0] ?? "")) {
2053
+ ctx.report(
2054
+ "multimarkdown-meta",
2055
+ "MultiMarkdown meta lines at the top — turn them into YAML frontmatter"
2056
+ );
2057
+ }
2058
+ let body = unwrapAngleDestinations(
2059
+ rewriteLines(convertBlock(split.body, ctx), ctx),
2060
+ ctx
2061
+ );
2062
+ let { fm } = split;
2063
+ if (partial && (body.find((l) => !isBlank(l)) ?? "").startsWith("# ")) {
2064
+ ctx.report(
2065
+ "partial-h1",
2066
+ "starts with an H1 — the including page's title already renders one; drop it"
2067
+ );
2068
+ } else if (!partial) {
2069
+ const converted = convertFrontmatter(fm ?? [], body, ctx);
2070
+ fm = fm !== null || converted.fm.length > 0 ? converted.fm : null;
2071
+ ({ body } = converted);
2072
+ }
2073
+ reportCounts(ctx);
2074
+ const includes = [
2075
+ ...body.join("\n").matchAll(/<include[^>]*>(?<t>[^<]+)<\/include>/gu),
2076
+ ].map((m) => m.groups.t.trim());
2077
+ return {
2078
+ body,
2079
+ ctx,
2080
+ eol: source.includes("\r\n") ? "\r\n" : "\n",
2081
+ fm,
2082
+ hadFrontmatter: split.fm !== null,
2083
+ includes,
2084
+ rel,
2085
+ };
2086
+ };
2087
+
2088
+ const resolveInclude = (run, fromAbs, target) =>
2089
+ target.startsWith("/")
2090
+ ? path.join(run.root, target)
2091
+ : path.resolve(path.dirname(fromAbs), target);
2092
+
2093
+ /** Does a page use an MDX-only feature, itself or through a partial it includes? */
2094
+ const needsMdx = (run, abs, seen = new Set()) => {
2095
+ const page = run.pages.get(abs);
2096
+ if (!page || seen.has(abs)) {
2097
+ return false;
2098
+ }
2099
+ seen.add(abs);
2100
+ return (
2101
+ page.ctx.mdx ||
2102
+ page.includes.some((t) => needsMdx(run, resolveInclude(run, abs, t), seen))
2103
+ );
2104
+ };
2105
+
2106
+ /** README.md → index.md (when the folder has none). */
2107
+ const readmeTarget = (abs, page) => {
2108
+ const dir = path.dirname(abs);
2109
+ if (["index.md", "index.mdx"].some((n) => existsSync(path.join(dir, n)))) {
2110
+ page.ctx.report(
2111
+ "readme",
2112
+ "README.md beside an index page — MkDocs didn't publish it; delete it or keep it as /…/README"
2113
+ );
2114
+ return abs;
2115
+ }
2116
+ page.ctx.change(
2117
+ "rename",
2118
+ "README.md → index.md (Blume routes README at /…/README)"
2119
+ );
2120
+ return path.join(dir, "index.md");
2121
+ };
2122
+
2123
+ /** README.md → index.md, and .md → .mdx where the page needs it. */
2124
+ const planRename = (abs, page) => {
2125
+ if (isPartial(page.rel)) {
2126
+ return abs;
2127
+ }
2128
+ let next = /^readme\.md$/iu.test(path.basename(abs))
2129
+ ? readmeTarget(abs, page)
2130
+ : abs;
2131
+ if (page.mdx && /\.(?:md|markdown)$/u.test(next)) {
2132
+ next = next.replace(/\.(?:md|markdown)$/u, ".mdx");
2133
+ page.ctx.change("rename", "→ .mdx (uses MDX-only features)");
2134
+ }
2135
+ return next;
2136
+ };
2137
+
2138
+ /** The directory a page's relative links resolve from (a partial's includer). */
2139
+ const linkBase = (run, abs, page) => {
2140
+ if (!isPartial(page.rel)) {
2141
+ return path.dirname(abs);
2142
+ }
2143
+ const dirs = [
2144
+ ...new Set((run.includers.get(abs) ?? []).map((i) => path.dirname(i))),
2145
+ ];
2146
+ if (dirs.length === 1) {
2147
+ return dirs[0];
2148
+ }
2149
+ const relativeMdLink = /\]\((?![a-z][\w+.-]*:|#|\/)[^)]+\.md/iu;
2150
+ if (page.body.some((l) => relativeMdLink.test(l))) {
2151
+ page.ctx.report(
2152
+ "partial-links",
2153
+ "relative links in a partial resolve from each including page — check them by hand"
2154
+ );
2155
+ }
2156
+ return null;
2157
+ };
2158
+
2159
+ const decideMdx = (run) => {
2160
+ for (const [abs, page] of run.pages) {
2161
+ for (const t of page.includes) {
2162
+ const target = resolveInclude(run, abs, t);
2163
+ run.includers.set(target, [...(run.includers.get(target) ?? []), abs]);
2164
+ }
2165
+ }
2166
+ for (const [abs, page] of run.pages) {
2167
+ const viaIncluder =
2168
+ isPartial(page.rel) &&
2169
+ (run.includers.get(abs) ?? []).some(
2170
+ (i) => i.endsWith(".mdx") || needsMdx(run, i)
2171
+ );
2172
+ page.mdx = abs.endsWith(".mdx") || needsMdx(run, abs) || viaIncluder;
2173
+ }
2174
+ for (const [abs, page] of run.pages) {
2175
+ const next = planRename(abs, page);
2176
+ if (next !== abs) {
2177
+ run.renamed.set(abs, next);
2178
+ }
2179
+ }
2180
+ };
2181
+
2182
+ /**
2183
+ * Point `<include>`s at files this run renamed. A snippet that is itself a
2184
+ * page (not under `_`/`.`) publishes on its own too, as it did in MkDocs:
2185
+ * report it, since a partial usually belongs in `_snippets/`.
2186
+ */
2187
+ const rewriteIncludes = (run, abs, page) => {
2188
+ page.body = mapFenced(page.body, (line) =>
2189
+ line.replace(
2190
+ /(?<open><include[^>]*>)(?<target>[^<]+)(?<close><\/include>)/u,
2191
+ (...m) => {
2192
+ const { close, open, target } = m.at(-1);
2193
+ const resolved = resolveInclude(run, abs, target.trim());
2194
+ const included = run.pages.get(resolved);
2195
+ if (included && !isPartial(included.rel)) {
2196
+ page.ctx.report(
2197
+ "include-page",
2198
+ `${target.trim()} is a page too (it publishes on its own) — move it under _snippets/ unless it should`
2199
+ );
2200
+ }
2201
+ const next = run.renamed.get(resolved);
2202
+ if (!next) {
2203
+ return m[0];
2204
+ }
2205
+ const rel = toPosix(path.relative(run.root, next));
2206
+ const local = toPosix(path.relative(path.dirname(abs), next));
2207
+ let out = local.startsWith(".") ? local : `./${local}`;
2208
+ if (target.startsWith("/")) {
2209
+ out = `/${rel}`;
2210
+ }
2211
+ page.ctx.change("include", `${target.trim()} → ${out}`);
2212
+ return `${open}${out}${close}`;
2213
+ }
2214
+ )
2215
+ );
2216
+ };
2217
+
2218
+ const finishPages = (run) => {
2219
+ for (const [abs, page] of run.pages) {
2220
+ rewriteIncludes(run, abs, page);
2221
+ if (page.mdx) {
2222
+ page.body = rewriteForMdx(page.body, page.ctx);
2223
+ scanHazards(page.body, page.ctx);
2224
+ }
2225
+ const base = linkBase(run, abs, page);
2226
+ if (base) {
2227
+ page.body = rewriteLinks(page.body, base, run.renamed, page.ctx);
2228
+ }
2229
+ }
2230
+ };
2231
+
2232
+ const pageText = (page) => {
2233
+ const fmBlock = page.fm === null ? [] : ["---", ...page.fm, "---"];
2234
+ const needsGap =
2235
+ fmBlock.length > 0 && !page.hadFrontmatter && page.body[0] !== "";
2236
+ return [...fmBlock, ...(needsGap ? [""] : []), ...page.body].join(page.eol);
2237
+ };
2238
+
2239
+ const uniqueChanges = (changes) =>
2240
+ changes.filter(
2241
+ (c, i, all) =>
2242
+ all.findIndex((o) => o.kind === c.kind && o.detail === c.detail) === i
2243
+ );
2244
+
2245
+ const relToCwd = (p) => toPosix(path.relative(process.cwd(), p));
2246
+
2247
+ const writePage = (abs, target, text) => {
2248
+ mkdirSync(path.dirname(target), { recursive: true });
2249
+ if (target !== abs && existsSync(abs)) {
2250
+ renameSync(abs, target);
2251
+ }
2252
+ writeFileSync(target, text, "utf-8");
2253
+ };
2254
+
2255
+ const writePages = (run) => {
2256
+ const report = [];
2257
+ let changed = 0;
2258
+ const sorted = [...run.pages].toSorted(([a], [b]) => a.localeCompare(b));
2259
+ for (const [abs, page] of sorted) {
2260
+ const text = pageText(page);
2261
+ const target = run.renamed.get(abs) ?? abs;
2262
+ const original = existsSync(abs) ? readFileSync(abs, "utf-8") : null;
2263
+ const isChanged = text !== original || target !== abs;
2264
+ if (isChanged) {
2265
+ changed += 1;
2266
+ if (run.write) {
2267
+ writePage(abs, target, text);
2268
+ }
2269
+ }
2270
+ if (page.ctx.changes.length > 0 || isChanged) {
2271
+ report.push({
2272
+ changes: uniqueChanges(page.ctx.changes),
2273
+ copiedFrom: page.copiedFrom ? relToCwd(page.copiedFrom) : undefined,
2274
+ file: relToCwd(abs),
2275
+ renamedTo: target === abs ? undefined : relToCwd(target),
2276
+ });
2277
+ }
2278
+ }
2279
+ return { changed, report };
2280
+ };
2281
+
2282
+ // --- Site-level findings ---------------------------------------------------------
2283
+
2284
+ const configFindings = (config, missingNav) => {
2285
+ const out = config.plugins.map(
2286
+ (plugin) =>
2287
+ `plugin ${plugin}: ${PLUGIN_HINTS[plugin.replace(/^material\//u, "")] ?? "unknown — read what it does and report it"}`
2288
+ );
2289
+ const flags = [
2290
+ [
2291
+ config.inherit,
2292
+ `INHERIT: ${config.inherit} — merge it by hand; the codemod read both files for extensions, plugins, and nav`,
2293
+ ],
2294
+ [
2295
+ config.customDir,
2296
+ `theme.custom_dir ${config.customDir} — template overrides: read each one (banner, metatags, analytics, layout slots)`,
2297
+ ],
2298
+ [config.hooks, "hooks — read each hook and report what it did"],
2299
+ [
2300
+ config.extraCss,
2301
+ "extra_css — port what still applies to a project-root theme.css",
2302
+ ],
2303
+ [
2304
+ config.extraJs,
2305
+ "extra_javascript — delete math/mermaid loaders; script() the rest (a root-relative src loads under deployment.base)",
2306
+ ],
2307
+ [
2308
+ config.autoAppend.length > 0,
2309
+ `snippets auto_append ${config.autoAppend.join(", ")} — abbreviations: delete; link definitions: <include> the file at the end of each page that uses them (an included file's definitions resolve in the page)`,
2310
+ ],
2311
+ [
2312
+ !config.useDirectoryUrls,
2313
+ "use_directory_urls: false — every /x.html URL needs a redirect to /x",
2314
+ ],
2315
+ [
2316
+ config.mike,
2317
+ "extra.version provider mike — versioning (see the reference)",
2318
+ ],
2319
+ [
2320
+ config.alternate,
2321
+ "extra.alternate — a language selector: i18n (see the reference)",
2322
+ ],
2323
+ ];
2324
+ out.push(
2325
+ ...flags.filter(([on]) => on).map(([, text]) => text),
2326
+ ...missingNav.map(
2327
+ (f) =>
2328
+ `nav entry ${f} has no file — generated at build time? find the generator`
2329
+ )
2330
+ );
2331
+ return out;
2332
+ };
2333
+
2334
+ const siteFindings = (run) => {
2335
+ const out = run.config ? configFindings(run.config, run.missingNav) : [];
2336
+ for (const name of [".pages", ".nav.yml", "SUMMARY.md", ".meta.yml"]) {
2337
+ const found = walkFiles(run.root, (n) => n === name);
2338
+ if (found.length > 0) {
2339
+ out.push(
2340
+ `${found.length} ${name} file(s) — convert to meta.ts / frontmatter, then delete`
2341
+ );
2342
+ }
2343
+ }
2344
+ return out;
2345
+ };
2346
+
2347
+ // --- Driver -----------------------------------------------------------------------
2348
+
2349
+ const parseArgs = (argv) => {
2350
+ const configIndex = argv.indexOf("--config");
2351
+ const target = argv.find(
2352
+ (a, i) => !a.startsWith("--") && argv[i - 1] !== "--config"
2353
+ );
2354
+ return {
2355
+ asJson: argv.includes("--json"),
2356
+ explicit: configIndex === -1 ? null : argv[configIndex + 1],
2357
+ root: path.resolve(target),
2358
+ write: argv.includes("--write"),
2359
+ };
2360
+ };
2361
+
2362
+ /**
2363
+ * The spellings a `nav` file can have on disk after an earlier run renamed
2364
+ * it: `page.md` as `page.mdx`, `README.md` as `index.md(x)`.
2365
+ */
2366
+ const navSpellings = (file) => {
2367
+ const mdx = file.replace(/\.md$/u, ".mdx");
2368
+ const index = /(?:^|\/)readme\.md$/iu.test(file)
2369
+ ? ["index.md", "index.mdx"].map((n) =>
2370
+ path.posix.join(path.posix.dirname(file), n)
2371
+ )
2372
+ : [];
2373
+ return [file, mdx, ...index];
2374
+ };
2375
+
2376
+ /** The `nav` title of a page, under any of its spellings. */
2377
+ const navTitleFor = (config, rel) => {
2378
+ const md = rel.replace(/\.mdx$/u, ".md");
2379
+ const readme = /(?:^|\/)index\.mdx?$/u.test(rel)
2380
+ ? path.posix.join(path.posix.dirname(rel), "README.md")
2381
+ : null;
2382
+ return (
2383
+ config.nav.get(rel) ??
2384
+ config.nav.get(md) ??
2385
+ (readme ? config.nav.get(readme) : undefined) ??
2386
+ null
2387
+ );
2388
+ };
2389
+
2390
+ const createRun = (argv) => {
2391
+ const args = parseArgs(argv);
2392
+ const configFile = findConfig(args.explicit, args.root);
2393
+ const config = configFile ? readConfig(configFile) : null;
2394
+ const configDir = config ? config.dir : process.cwd();
2395
+ const docsDir = path.resolve(configDir, config?.docsDir ?? "docs");
2396
+ const enabled = makeEnabled(config);
2397
+ const copies = new Map();
2398
+ const makeCtx = (abs) => {
2399
+ const changes = [];
2400
+ return {
2401
+ basePaths: config ? config.basePaths : [process.cwd()],
2402
+ change: (kind, detail) => changes.push({ detail, kind, mutating: true }),
2403
+ changes,
2404
+ configDir,
2405
+ copies,
2406
+ counts: { annotations: 0, deflist: 0, dollar: 0, emoji: 0, jinja: 0 },
2407
+ enabled,
2408
+ langMap: config?.langMap ?? {},
2409
+ mdx: false,
2410
+ navTitle: config
2411
+ ? navTitleFor(config, toPosix(path.relative(docsDir, abs)))
2412
+ : null,
2413
+ report: (kind, detail) => changes.push({ detail, kind, mutating: false }),
2414
+ root: args.root,
2415
+ };
2416
+ };
2417
+ // Before anything is renamed: nav entries whose file doesn't exist.
2418
+ const missingNav = config
2419
+ ? [...new Set(config.navFiles)]
2420
+ .toSorted()
2421
+ .filter(
2422
+ (f) => !navSpellings(f).some((s) => existsSync(path.join(docsDir, s)))
2423
+ )
2424
+ : [];
2425
+ return {
2426
+ ...args,
2427
+ config,
2428
+ configFile,
2429
+ copies,
2430
+ includers: new Map(),
2431
+ makeCtx,
2432
+ missingNav,
2433
+ pages: new Map(),
2434
+ renamed: new Map(),
2435
+ };
2436
+ };
2437
+
2438
+ const pendingCopies = (run) =>
2439
+ [...run.copies]
2440
+ .filter(([rel]) => !run.pages.has(path.join(run.root, rel)))
2441
+ .toSorted(([a], [b]) => a.localeCompare(b));
2442
+
2443
+ const isPage = (name) => /\.(?:md|mdx|markdown)$/u.test(name);
2444
+
2445
+ const loadPages = (run) => {
2446
+ for (const abs of walkFiles(run.root, isPage)) {
2447
+ run.pages.set(abs, convertPage(abs, readFileSync(abs, "utf-8"), run));
2448
+ }
2449
+ // Snippets copied in from outside the root are partials too (and may copy more).
2450
+ for (let pending = pendingCopies(run); pending.length > 0;) {
2451
+ for (const [rel, source] of pending) {
2452
+ const abs = path.join(run.root, rel);
2453
+ const page = convertPage(abs, readFileSync(source, "utf-8"), run);
2454
+ page.copiedFrom = source;
2455
+ run.pages.set(abs, page);
2456
+ }
2457
+ pending = pendingCopies(run);
2458
+ }
2459
+ };
2460
+
2461
+ const printEntry = (entry) => {
2462
+ const head = entry.renamedTo
2463
+ ? `${entry.file} → ${entry.renamedTo}`
2464
+ : entry.file;
2465
+ const copied = entry.copiedFrom ? ` (copied from ${entry.copiedFrom})` : "";
2466
+ process.stdout.write(`\n${head}${copied}\n`);
2467
+ const tally = new Map();
2468
+ for (const c of entry.changes.filter((x) => x.mutating)) {
2469
+ tally.set(c.kind, (tally.get(c.kind) ?? 0) + 1);
2470
+ }
2471
+ for (const [kind, n] of tally) {
2472
+ process.stdout.write(` ${kind}: ${n}\n`);
2473
+ }
2474
+ for (const c of entry.changes.filter((x) => !x.mutating)) {
2475
+ process.stdout.write(` ! ${c.kind}: ${c.detail}\n`);
2476
+ }
2477
+ };
2478
+
2479
+ const printReport = (run, { changed, report }, site) => {
2480
+ const configName = run.configFile ? relToCwd(run.configFile) : null;
2481
+ if (run.asJson) {
2482
+ const json = { config: configName, files: report, site, wrote: run.write };
2483
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
2484
+ return;
2485
+ }
2486
+ process.stdout.write(
2487
+ `config: ${configName ?? "none found (converting every extension's syntax)"}\n`
2488
+ );
2489
+ for (const entry of report) {
2490
+ printEntry(entry);
2491
+ }
2492
+ if (site.length > 0) {
2493
+ const lines = site.map((s) => ` - ${s}\n`).join("");
2494
+ process.stdout.write(`\nNeeds a human (site-level):\n${lines}`);
2495
+ }
2496
+ const verb = run.write ? "changed" : "would change";
2497
+ const hint = run.write ? "" : " Re-run with --write to apply.";
2498
+ process.stdout.write(
2499
+ `\n${report.length} file(s) with findings, ${changed} ${verb}.${hint}\n`
2500
+ );
2501
+ };
2502
+
2503
+ const HELP = [
2504
+ "mkdocs-codemod — MkDocs / Material for MkDocs content pass for Blume",
2505
+ "",
2506
+ " node mkdocs-codemod.mjs [--config mkdocs.yml] <content-dir> dry run (report only)",
2507
+ " node mkdocs-codemod.mjs --write [--config mkdocs.yml] <content-dir> apply in place",
2508
+ " node mkdocs-codemod.mjs --json … <content-dir> JSON report",
2509
+ "",
2510
+ ].join("\n");
2511
+
2512
+ const main = () => {
2513
+ const argv = process.argv.slice(2);
2514
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
2515
+ process.stdout.write(`${HELP}\n`);
2516
+ return;
2517
+ }
2518
+ const run = createRun(argv);
2519
+ loadPages(run);
2520
+ decideMdx(run);
2521
+ finishPages(run);
2522
+ const result = writePages(run);
2523
+ printReport(run, result, siteFindings(run));
2524
+ };
2525
+
2526
+ main();