blume 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +133 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-74wfhv79.js → chunk-05f21r8h.js} +1 -1
  5. package/dist/cli/{chunk-9qzgg6mj.js → chunk-1cm0h819.js} +3 -3
  6. package/dist/cli/{chunk-nbyvgnnm.js → chunk-1rkxzg3n.js} +104 -47
  7. package/dist/cli/chunk-1rkxzg3n.js.map +36 -0
  8. package/dist/cli/{chunk-desvm72z.js → chunk-2nr29y95.js} +22 -26
  9. package/dist/cli/chunk-2nr29y95.js.map +13 -0
  10. package/dist/cli/{chunk-32khwcb6.js → chunk-49r370mb.js} +42 -69
  11. package/dist/cli/{chunk-32khwcb6.js.map → chunk-49r370mb.js.map} +9 -10
  12. package/dist/cli/{chunk-ms9z66nz.js → chunk-4rftmttv.js} +2 -2
  13. package/dist/cli/{chunk-s0bdnc7v.js → chunk-79bzzkx4.js} +5 -4
  14. package/dist/cli/chunk-79bzzkx4.js.map +10 -0
  15. package/dist/cli/{chunk-hvcxng5f.js → chunk-7tekknev.js} +6 -6
  16. package/dist/cli/{chunk-dv2z0rzf.js → chunk-8b489v1b.js} +38 -13
  17. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-8b489v1b.js.map} +5 -4
  18. package/dist/cli/{chunk-0pmb4tr4.js → chunk-8f8xw4x2.js} +1195 -222
  19. package/dist/cli/chunk-8f8xw4x2.js.map +44 -0
  20. package/dist/cli/chunk-96snx8yy.js +329 -0
  21. package/dist/cli/chunk-96snx8yy.js.map +13 -0
  22. package/dist/cli/{chunk-ckrcm08h.js → chunk-a3ydrqny.js} +48 -62
  23. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-a3ydrqny.js.map} +4 -5
  24. package/dist/cli/{chunk-bxwhtvw8.js → chunk-ahc5gc9h.js} +5 -5
  25. package/dist/cli/{chunk-dpy8qdjf.js → chunk-as09pafp.js} +73 -182
  26. package/dist/cli/chunk-as09pafp.js.map +18 -0
  27. package/dist/cli/{chunk-tfycddbt.js → chunk-bjk964fp.js} +2 -2
  28. package/dist/cli/{chunk-gfc5fqvf.js → chunk-br0n8n3e.js} +18 -18
  29. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-br0n8n3e.js.map} +1 -1
  30. package/dist/cli/{chunk-297ajdcv.js → chunk-c69tw29j.js} +6 -6
  31. package/dist/cli/{chunk-297ajdcv.js.map → chunk-c69tw29j.js.map} +3 -3
  32. package/dist/cli/chunk-cn3dy3hz.js +146 -0
  33. package/dist/cli/chunk-cn3dy3hz.js.map +11 -0
  34. package/dist/cli/{chunk-124fvmzq.js → chunk-dm4mg9bj.js} +11064 -8742
  35. package/dist/cli/{chunk-124fvmzq.js.map → chunk-dm4mg9bj.js.map} +78 -64
  36. package/dist/cli/{chunk-dqhg54mr.js → chunk-dmx6cezx.js} +1 -1
  37. package/dist/cli/{chunk-60hz265r.js → chunk-dpcahcrb.js} +24 -11
  38. package/dist/cli/chunk-dpcahcrb.js.map +10 -0
  39. package/dist/cli/{chunk-a57yh0h9.js → chunk-dyk3r7jw.js} +7 -7
  40. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-dyk3r7jw.js.map} +1 -1
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-enk76qd5.js} +14 -10
  42. package/dist/cli/chunk-enk76qd5.js.map +10 -0
  43. package/dist/cli/{chunk-k3gr1nm9.js → chunk-fqy4n3q2.js} +13 -16
  44. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-fqy4n3q2.js.map} +3 -3
  45. package/dist/cli/{chunk-sc93apfs.js → chunk-frk2wdq1.js} +17 -17
  46. package/dist/cli/{chunk-sc93apfs.js.map → chunk-frk2wdq1.js.map} +2 -2
  47. package/dist/cli/{chunk-kdpzw38a.js → chunk-gprwej7h.js} +95 -7
  48. package/dist/cli/chunk-gprwej7h.js.map +11 -0
  49. package/dist/cli/chunk-kkrteb15.js +157 -0
  50. package/dist/cli/chunk-kkrteb15.js.map +10 -0
  51. package/dist/cli/{chunk-ave1wcbr.js → chunk-m9bp2hc1.js} +1 -1
  52. package/dist/cli/{chunk-58bt3hag.js → chunk-mrvh4c88.js} +4 -4
  53. package/dist/cli/{chunk-5cmts6dv.js → chunk-p8zg3a2h.js} +7 -7
  54. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-p8zg3a2h.js.map} +1 -1
  55. package/dist/cli/{chunk-0w6hs479.js → chunk-rkqae7kh.js} +30 -18
  56. package/dist/cli/chunk-rkqae7kh.js.map +10 -0
  57. package/dist/cli/{chunk-bqxaqn5p.js → chunk-t5xx74rd.js} +8 -8
  58. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-t5xx74rd.js.map} +1 -1
  59. package/dist/cli/{chunk-1f882900.js → chunk-th0pgaj5.js} +2 -2
  60. package/dist/cli/{chunk-dhwtqnnd.js → chunk-vnbvhgmd.js} +2 -2
  61. package/dist/cli/{chunk-gxtg6ed2.js → chunk-vzy9rh6y.js} +21 -21
  62. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-vzy9rh6y.js.map} +3 -3
  63. package/dist/cli/{chunk-j1x92mx9.js → chunk-w6e455pz.js} +1 -1
  64. package/dist/cli/chunk-wehv26ry.js +155 -0
  65. package/dist/cli/chunk-wehv26ry.js.map +11 -0
  66. package/dist/cli/{chunk-y4msrwm9.js → chunk-xgq4eytj.js} +15 -15
  67. package/dist/cli/{chunk-36bxs9c7.js → chunk-xt0qvz1t.js} +27 -23
  68. package/dist/cli/chunk-xt0qvz1t.js.map +10 -0
  69. package/dist/cli/index.js +78 -42
  70. package/dist/cli/index.js.map +7 -6
  71. package/dist/types/ai/changelog-components.d.ts +18 -0
  72. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  73. package/dist/types/ai/openapi-components.d.ts +7 -7
  74. package/dist/types/ai/operation-details.d.ts +39 -0
  75. package/dist/types/ai/relative-links.d.ts +3 -1
  76. package/dist/types/ai/serializers.d.ts +6 -4
  77. package/dist/types/analytics/head.d.ts +4 -2
  78. package/dist/types/analytics/script.d.ts +8 -2
  79. package/dist/types/components/content/youtube.d.ts +20 -0
  80. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  81. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  82. package/dist/types/components/openapi/helpers.d.ts +53 -11
  83. package/dist/types/components/openapi/snippets.d.ts +7 -1
  84. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  85. package/dist/types/core/builtin-tags.d.ts +16 -0
  86. package/dist/types/core/changelog-list.d.ts +48 -0
  87. package/dist/types/core/closest-match.d.ts +6 -0
  88. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  89. package/dist/types/core/code-fences.d.ts +8 -1
  90. package/dist/types/core/config-input.d.ts +1 -1
  91. package/dist/types/core/content-assets.d.ts +16 -18
  92. package/dist/types/core/diagnostics.d.ts +14 -0
  93. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  94. package/dist/types/core/former-routes.d.ts +14 -0
  95. package/dist/types/core/github.d.ts +5 -4
  96. package/dist/types/core/heading-markers.d.ts +65 -2
  97. package/dist/types/core/hidden-pages.d.ts +3 -0
  98. package/dist/types/core/includes.d.ts +23 -0
  99. package/dist/types/core/last-modified.d.ts +18 -6
  100. package/dist/types/core/links.d.ts +42 -2
  101. package/dist/types/core/load-module.d.ts +19 -4
  102. package/dist/types/core/manifest.d.ts +14 -1
  103. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  104. package/dist/types/core/mdx-elements.d.ts +32 -0
  105. package/dist/types/core/mdx-syntax.d.ts +100 -0
  106. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  107. package/dist/types/core/meta.d.ts +32 -5
  108. package/dist/types/core/nav-diagnostics.d.ts +9 -2
  109. package/dist/types/core/navigation.d.ts +13 -0
  110. package/dist/types/core/ordering-prefix.d.ts +6 -0
  111. package/dist/types/core/pinned-ids.d.ts +23 -0
  112. package/dist/types/core/project-graph.d.ts +9 -1
  113. package/dist/types/core/redirect-patterns.d.ts +18 -0
  114. package/dist/types/core/relative-files.d.ts +31 -0
  115. package/dist/types/core/schema.d.ts +267 -7
  116. package/dist/types/core/sources/normalize.d.ts +101 -9
  117. package/dist/types/core/sources/types.d.ts +16 -0
  118. package/dist/types/core/static-files.d.ts +10 -0
  119. package/dist/types/core/static-host.d.ts +42 -0
  120. package/dist/types/core/svg-images.d.ts +26 -0
  121. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  122. package/dist/types/core/types.d.ts +42 -1
  123. package/dist/types/deploy/generated-files.d.ts +41 -0
  124. package/dist/types/deploy/redirects.d.ts +21 -7
  125. package/dist/types/markdown/code-title.d.ts +9 -5
  126. package/dist/types/markdown/directives.d.ts +8 -0
  127. package/dist/types/markdown/fence-language.d.ts +51 -0
  128. package/dist/types/markdown/fence-meta.d.ts +5 -0
  129. package/dist/types/markdown/github-alerts.d.ts +35 -0
  130. package/dist/types/openapi/asyncapi.d.ts +6 -5
  131. package/dist/types/openapi/checks.d.ts +10 -5
  132. package/dist/types/openapi/model.d.ts +55 -8
  133. package/dist/types/openapi/parse.d.ts +4 -1
  134. package/dist/types/sources/filesystem.d.ts +14 -4
  135. package/dist/types/sources/mdx-remote.d.ts +6 -3
  136. package/dist/types/sources/registry.d.ts +3 -3
  137. package/dist/types/theme/color.d.ts +33 -0
  138. package/dist/types/theme/palette.d.ts +122 -0
  139. package/docs/01-quickstart.mdx +1 -1
  140. package/docs/02-deployment.mdx +23 -8
  141. package/docs/04-migrating.mdx +16 -4
  142. package/docs/advanced/changelog.mdx +27 -1
  143. package/docs/advanced/skills.mdx +1 -1
  144. package/docs/cli/audit.mdx +1 -1
  145. package/docs/cli/doctor.mdx +2 -2
  146. package/docs/cli/evals.mdx +1 -1
  147. package/docs/cli/index.mdx +4 -2
  148. package/docs/cli/validate.mdx +10 -7
  149. package/docs/configuration/analytics.mdx +2 -0
  150. package/docs/configuration/customization.mdx +3 -1
  151. package/docs/configuration/index.mdx +2 -2
  152. package/docs/configuration/theming.mdx +5 -1
  153. package/docs/content/components.mdx +26 -6
  154. package/docs/content/frontmatter.mdx +1 -1
  155. package/docs/content/includes.mdx +6 -2
  156. package/docs/content/index.mdx +11 -1
  157. package/docs/content/islands.mdx +2 -0
  158. package/docs/content/meta.mdx +9 -3
  159. package/docs/content/navigation.mdx +14 -6
  160. package/docs/content/sources/obsidian.mdx +1 -1
  161. package/docs/content/sources/remote-mdx.mdx +3 -1
  162. package/docs/content/syntax.mdx +76 -8
  163. package/docs/content/variables.mdx +3 -1
  164. package/docs/discoverability/agent-discovery.mdx +1 -1
  165. package/docs/discoverability/json-api.mdx +1 -1
  166. package/docs/discoverability/llms-txt.mdx +1 -1
  167. package/docs/discoverability/markdown.mdx +2 -2
  168. package/docs/discoverability/open-graph.mdx +1 -1
  169. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  170. package/docs/references/graphql.mdx +2 -0
  171. package/docs/references/openapi.mdx +45 -7
  172. package/package.json +10 -9
  173. package/skills/blume/SKILL.md +2 -2
  174. package/skills/blume-migrate/SKILL.md +49 -27
  175. package/skills/blume-migrate/references/docsify.md +262 -0
  176. package/skills/blume-migrate/references/docus.md +298 -0
  177. package/skills/blume-migrate/references/docusaurus.md +3 -3
  178. package/skills/blume-migrate/references/fern.md +327 -0
  179. package/skills/blume-migrate/references/fumadocs.md +1 -1
  180. package/skills/blume-migrate/references/gitbook.md +267 -0
  181. package/skills/blume-migrate/references/github-wiki.md +259 -0
  182. package/skills/blume-migrate/references/jekyll.md +253 -0
  183. package/skills/blume-migrate/references/mdbook.md +256 -0
  184. package/skills/blume-migrate/references/mintlify.md +5 -5
  185. package/skills/blume-migrate/references/mkdocs.md +236 -0
  186. package/skills/blume-migrate/references/monorepo.md +20 -8
  187. package/skills/blume-migrate/references/nextra.md +2 -2
  188. package/skills/blume-migrate/references/readme.md +257 -0
  189. package/skills/blume-migrate/references/redocly.md +198 -0
  190. package/skills/blume-migrate/references/starlight.md +3 -3
  191. package/skills/blume-migrate/references/vitepress.md +204 -0
  192. package/skills/blume-migrate/references/vuepress.md +271 -0
  193. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  194. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  195. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  196. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  197. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  198. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  199. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  200. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  201. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  202. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  203. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  204. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  205. package/src/ai/changelog-components.ts +24 -0
  206. package/src/ai/changelog-markdown.ts +45 -41
  207. package/src/ai/component-markdown.ts +22 -9
  208. package/src/ai/llms.ts +36 -5
  209. package/src/ai/markdown.ts +2 -2
  210. package/src/ai/openapi-components.ts +25 -9
  211. package/src/ai/operation-details.ts +208 -0
  212. package/src/ai/relative-links.ts +48 -8
  213. package/src/ai/serializers.ts +7 -6
  214. package/src/ai/site-skill.ts +6 -2
  215. package/src/analytics/head.ts +11 -5
  216. package/src/analytics/script.ts +13 -3
  217. package/src/astro/generate.ts +32 -6
  218. package/src/astro/index.ts +1 -0
  219. package/src/astro/integration.ts +27 -1
  220. package/src/astro/mdx-source-errors.ts +46 -0
  221. package/src/astro/templates.ts +89 -185
  222. package/src/audit/checks/redirects.ts +5 -1
  223. package/src/audit/redirects.ts +36 -19
  224. package/src/audit/report.ts +22 -9
  225. package/src/audit/run.ts +12 -28
  226. package/src/audit/terms.ts +23 -0
  227. package/src/audit/url.ts +2 -2
  228. package/src/cli/build-failure.ts +256 -7
  229. package/src/cli/command-meta.ts +1 -1
  230. package/src/cli/commands/audit.ts +1 -1
  231. package/src/cli/commands/build.ts +12 -3
  232. package/src/cli/commands/check.ts +13 -2
  233. package/src/cli/commands/dev.ts +1 -1
  234. package/src/cli/commands/doctor.ts +4 -2
  235. package/src/cli/commands/init.ts +3 -3
  236. package/src/cli/commands/preview.ts +17 -1
  237. package/src/cli/commands/validate.ts +22 -10
  238. package/src/cli/prepare.ts +1 -1
  239. package/src/cli/preview-pages.ts +161 -0
  240. package/src/cli/unknown-flags.ts +1 -42
  241. package/src/components/code-copy.ts +51 -0
  242. package/src/components/code-language.ts +75 -0
  243. package/src/components/content/AccordionItem.astro +16 -1
  244. package/src/components/content/Callout.astro +11 -1
  245. package/src/components/content/Changelog.astro +189 -0
  246. package/src/components/content/CodeBlock.astro +8 -11
  247. package/src/components/content/Tabs.astro +16 -2
  248. package/src/components/content/YouTube.astro +6 -4
  249. package/src/components/content/code-block.ts +33 -0
  250. package/src/components/content/content-strings.ts +5 -1
  251. package/src/components/content/inline-markdown.ts +35 -8
  252. package/src/components/content/youtube.ts +49 -7
  253. package/src/components/layout/Analytics.astro +5 -1
  254. package/src/components/layout/NavTree.astro +8 -3
  255. package/src/components/layout/RootLayout.astro +18 -31
  256. package/src/components/layout/nav-utils.ts +14 -0
  257. package/src/components/layout/search/pagefind.ts +2 -1
  258. package/src/components/openapi/Operation.astro +4 -0
  259. package/src/components/openapi/PanelTabs.astro +9 -3
  260. package/src/components/openapi/RequestBody.astro +42 -8
  261. package/src/components/openapi/RequestPanel.astro +34 -53
  262. package/src/components/openapi/example-panels.ts +110 -0
  263. package/src/components/openapi/helpers.ts +195 -20
  264. package/src/components/openapi/operation-model.ts +63 -26
  265. package/src/components/openapi/request.ts +13 -0
  266. package/src/components/openapi/snippets.ts +14 -2
  267. package/src/core/alert-diagnostics.ts +89 -0
  268. package/src/core/builtin-tags.ts +76 -0
  269. package/src/core/changelog-list.ts +87 -0
  270. package/src/core/closest-match.ts +41 -0
  271. package/src/core/code-fence-diagnostics.ts +267 -0
  272. package/src/core/code-fences.ts +43 -7
  273. package/src/core/component-diagnostics.ts +65 -12
  274. package/src/core/config-input.ts +1 -1
  275. package/src/core/config.ts +46 -1
  276. package/src/core/content-assets.ts +64 -144
  277. package/src/core/diagnostics.ts +36 -3
  278. package/src/core/directive-diagnostics.ts +265 -47
  279. package/src/core/former-routes.ts +46 -0
  280. package/src/core/git-ignored.ts +53 -0
  281. package/src/core/github.ts +5 -4
  282. package/src/core/heading-markers.ts +156 -2
  283. package/src/core/hidden-pages.ts +64 -0
  284. package/src/core/includes.ts +181 -41
  285. package/src/core/last-modified.ts +47 -10
  286. package/src/core/links.ts +438 -83
  287. package/src/core/load-module.ts +98 -9
  288. package/src/core/manifest.ts +12 -9
  289. package/src/core/markdown-mirrors.ts +52 -0
  290. package/src/core/mdx-elements.ts +153 -0
  291. package/src/core/mdx-syntax.ts +377 -0
  292. package/src/core/meta-diagnostics.ts +134 -0
  293. package/src/core/meta.ts +154 -55
  294. package/src/core/nav-diagnostics.ts +16 -4
  295. package/src/core/navigation.ts +70 -10
  296. package/src/core/ordering-prefix.ts +22 -2
  297. package/src/core/pinned-ids.ts +141 -0
  298. package/src/core/project-graph.ts +221 -29
  299. package/src/core/redirect-patterns.ts +41 -0
  300. package/src/core/relative-files.ts +145 -0
  301. package/src/core/schema.ts +17 -3
  302. package/src/core/sources/mdx-remote.ts +75 -14
  303. package/src/core/sources/normalize.ts +885 -179
  304. package/src/core/sources/notion.ts +5 -4
  305. package/src/core/sources/obsidian.ts +2 -5
  306. package/src/core/sources/types.ts +17 -0
  307. package/src/core/static-files.ts +40 -0
  308. package/src/core/static-host.ts +125 -0
  309. package/src/core/svg-images.ts +98 -0
  310. package/src/core/syntax-diagnostics.ts +491 -0
  311. package/src/core/types.ts +43 -1
  312. package/src/deploy/artifacts.ts +2 -28
  313. package/src/deploy/generated-files.ts +128 -0
  314. package/src/deploy/platforms/vercel.ts +25 -1
  315. package/src/deploy/redirects.ts +43 -16
  316. package/src/deploy/sitemap.ts +2 -1
  317. package/src/markdown/base-links.ts +87 -6
  318. package/src/markdown/code-title.ts +29 -13
  319. package/src/markdown/content-assets.ts +210 -0
  320. package/src/markdown/directives.ts +69 -5
  321. package/src/markdown/fence-language.ts +106 -0
  322. package/src/markdown/fence-meta.ts +42 -0
  323. package/src/markdown/github-alerts.ts +172 -0
  324. package/src/markdown/heading-anchors.ts +265 -56
  325. package/src/markdown/include.ts +30 -3
  326. package/src/markdown/index.ts +145 -40
  327. package/src/markdown/prompt-text.ts +95 -0
  328. package/src/markdown/relative-links.ts +37 -2
  329. package/src/markdown/route-snapshot.ts +22 -12
  330. package/src/markdown/svg-fallback.ts +51 -0
  331. package/src/markdown/title-heading.ts +134 -0
  332. package/src/migrate/migrate.ts +127 -2
  333. package/src/og/card.ts +14 -4
  334. package/src/openapi/asyncapi.ts +14 -6
  335. package/src/openapi/checks.ts +160 -15
  336. package/src/openapi/model.ts +172 -33
  337. package/src/openapi/parse.ts +13 -4
  338. package/src/openapi/render-mdx.ts +55 -9
  339. package/src/openapi/source.ts +87 -6
  340. package/src/registry/eject.ts +0 -1
  341. package/src/search/documents.ts +10 -2
  342. package/src/sources/filesystem.ts +28 -3
  343. package/src/sources/mdx-remote.ts +7 -15
  344. package/src/theme/color.ts +14 -0
  345. package/src/theme/entry.ts +22 -1
  346. package/src/theme/palette.ts +74 -1
  347. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  348. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  349. package/dist/cli/chunk-150jk7km.js +0 -52
  350. package/dist/cli/chunk-150jk7km.js.map +0 -10
  351. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  352. package/dist/cli/chunk-53jqw8z0.js +0 -89
  353. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  354. package/dist/cli/chunk-60hz265r.js.map +0 -10
  355. package/dist/cli/chunk-desvm72z.js.map +0 -14
  356. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  357. package/dist/cli/chunk-es8h0v3j.js +0 -441
  358. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  359. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  360. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  361. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  362. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  363. package/dist/cli/chunk-ydp3065p.js +0 -64
  364. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  365. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-05f21r8h.js.map} +0 -0
  366. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-1cm0h819.js.map} +0 -0
  367. /package/dist/cli/{chunk-ms9z66nz.js.map → chunk-4rftmttv.js.map} +0 -0
  368. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-7tekknev.js.map} +0 -0
  369. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-ahc5gc9h.js.map} +0 -0
  370. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-bjk964fp.js.map} +0 -0
  371. /package/dist/cli/{chunk-dqhg54mr.js.map → chunk-dmx6cezx.js.map} +0 -0
  372. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-m9bp2hc1.js.map} +0 -0
  373. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-mrvh4c88.js.map} +0 -0
  374. /package/dist/cli/{chunk-1f882900.js.map → chunk-th0pgaj5.js.map} +0 -0
  375. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-vnbvhgmd.js.map} +0 -0
  376. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-w6e455pz.js.map} +0 -0
  377. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-xgq4eytj.js.map} +0 -0
@@ -0,0 +1,2542 @@
1
+ #!/usr/bin/env node
2
+ // docus-codemod.mjs — the mechanical part of a Docus → Blume content
3
+ // migration. Point it at the Docus `content/` directory (Blume's
4
+ // `content.root`). It rewrites pages only where the mapping is exact, and
5
+ // reports everything it leaves for review:
6
+ //
7
+ // - MDC block components, paired the way remark-mdc pairs them: every
8
+ // `::name` opener nests, and a closer (a line of colons) closes the
9
+ // innermost open block whose opener has exactly as many colons and isn't
10
+ // indented less than it, with everything still open inside it. A closer
11
+ // that matches nothing is text; an unclosed block runs to the end of its
12
+ // parent. Closers inside code fences are code.
13
+ // - Nuxt UI prose components → Blume:
14
+ // note / tip / warning / caution / callout → `:::type` directives (or
15
+ // `<Callout type icon>` for a non-default icon), card-group / card,
16
+ // field-group / field → `<ResponseField>`, steps, tabs / tabs-item,
17
+ // accordion / accordion-item, collapsible → `<Expandable>`,
18
+ // code-group → `<CodeGroup>` (or one `package-install` fence),
19
+ // code-collapse → `expandable` fences, code-tree, code-preview, prompt,
20
+ // badge, and the inline `:icon`, `:kbd`, `:badge`, `:u-badge`, `:video`,
21
+ // and `:u-color-mode-image`. `div` and `u-container` wrappers are removed.
22
+ // - `[text]{.class}` spans and `{…}` attributes on Markdown are stripped;
23
+ // `` `code`{lang="ts"} `` → `` `code{:ts}` ``.
24
+ // - Fence labels: ` ```ts [file.ts]{2} ` → ` ```ts file.ts {2} `. Blume
25
+ // reads the bracketed form too, range included, so this only tidies.
26
+ // - Frontmatter: `navigation` → `sidebar`, `seo.ogImage` → `seo.image`,
27
+ // `links` → `related`, `layout: docs` and `sitemap` removed, a body H1
28
+ // → `title` when there's none.
29
+ // - `.navigation.yml` → `meta.ts`, Iconify `i-lucide-*` icons → Lucide names.
30
+ // - A page that now uses MDX-only syntax is renamed `.md` → `.mdx`, and its
31
+ // prose made MDX-safe: `{` and `}` are escaped, every `<` that doesn't
32
+ // open a real tag becomes `&lt;`, HTML comments become `{/* … */}`,
33
+ // autolinks become links, and void tags close themselves.
34
+ //
35
+ // Everything else is a REVIEW line: custom and landing-page components, and
36
+ // blocks with a slot their conversion doesn't read (both left as written),
37
+ // unclosed blocks and stray closers, icons outside Lucide, dropped props,
38
+ // frontmatter keys Blume rejects, and file names whose route changes in Blume.
39
+ // CRLF pages are converted on LF and written back with CRLF.
40
+ //
41
+ // Design constraints (shared with the other codemods here):
42
+ // - ZERO dependencies: runs with a bare `node`.
43
+ // - Deterministic: files are processed in sorted order.
44
+ // - Idempotent: a second run changes nothing. It converts `.md` pages;
45
+ // `.mdx` files are its own output (Nuxt Content doesn't read MDX).
46
+ // - Reports every change and every finding, so nothing is silent.
47
+ //
48
+ // Usage:
49
+ // node docus-codemod.mjs <content-dir> # dry run, report only
50
+ // node docus-codemod.mjs --write <content-dir> # apply in place
51
+ // node docus-codemod.mjs --json <content-dir> # machine-readable report
52
+ // node docus-codemod.mjs --lucide <icons.json> <dir> # check icon names
53
+ //
54
+ // Without `--lucide`, icon names are checked against `@iconify-json/lucide`
55
+ // when it resolves from the content directory (an installed `blume` brings it).
56
+
57
+ import {
58
+ existsSync,
59
+ readdirSync,
60
+ readFileSync,
61
+ renameSync,
62
+ statSync,
63
+ unlinkSync,
64
+ writeFileSync,
65
+ } from "node:fs";
66
+ import { createRequire } from "node:module";
67
+ import path from "node:path";
68
+
69
+ // --- Tables ---------------------------------------------------------------------
70
+
71
+ // Nuxt UI's callout shortcuts → Blume types. Docus's caution is red, which is
72
+ // Blume's danger (Blume's own `caution` alias is a warning).
73
+ const CALLOUT_NAMES = new Map([
74
+ ["caution", "danger"],
75
+ ["note", "note"],
76
+ ["tip", "tip"],
77
+ ["warning", "warning"],
78
+ ]);
79
+
80
+ // `::callout{color}` → a Blume type. No color is Nuxt UI's neutral.
81
+ const CALLOUT_COLORS = new Map([
82
+ ["error", "danger"],
83
+ ["info", "info"],
84
+ ["neutral", "note"],
85
+ ["primary", "tip"],
86
+ ["secondary", "note"],
87
+ ["success", "success"],
88
+ ["warning", "warning"],
89
+ ]);
90
+
91
+ // The icons a callout type already shows: Blume's own, plus the one Nuxt UI
92
+ // gave the matching shortcut. Either is dropped instead of kept as `icon`.
93
+ const DEFAULT_ICONS = new Map([
94
+ ["danger", new Set(["circle-alert", "circle-x"])],
95
+ ["info", new Set(["info"])],
96
+ ["note", new Set(["info"])],
97
+ ["success", new Set(["circle-check"])],
98
+ ["tip", new Set(["lightbulb"])],
99
+ ["warning", new Set(["triangle-alert"])],
100
+ ]);
101
+
102
+ // Nuxt UI's `useKbd` keys. `meta` is ⌘ on macOS and Ctrl elsewhere.
103
+ const KBD_KEYS = new Map([
104
+ ["alt", "⌥"],
105
+ ["arrowdown", "↓"],
106
+ ["arrowleft", "←"],
107
+ ["arrowright", "→"],
108
+ ["arrowup", "↑"],
109
+ ["backspace", "⌫"],
110
+ ["capslock", "⇪"],
111
+ ["command", "⌘"],
112
+ ["control", "⌃"],
113
+ ["ctrl", "Ctrl"],
114
+ ["delete", "⌦"],
115
+ ["end", "↘"],
116
+ ["enter", "↵"],
117
+ ["escape", "Esc"],
118
+ ["home", "↖"],
119
+ ["meta", "⌘"],
120
+ ["option", "⌥"],
121
+ ["pagedown", "⇟"],
122
+ ["pageup", "⇞"],
123
+ ["shift", "⇧"],
124
+ ["tab", "⇥"],
125
+ ["win", "⊞"],
126
+ ]);
127
+
128
+ // Nuxt UI badge colors → Blume `variant` (the rest use Blume's default).
129
+ const BADGE_VARIANTS = new Map([
130
+ ["error", "danger"],
131
+ ["primary", "accent"],
132
+ ["success", "success"],
133
+ ["warning", "warning"],
134
+ ]);
135
+
136
+ const PACKAGE_MANAGERS = new Set(["bun", "npm", "pnpm", "yarn"]);
137
+ const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh"]);
138
+
139
+ // Fence keywords Blume reads; never usable as a bare title.
140
+ const RESERVED_META = new Set([
141
+ "expandable",
142
+ "lineNumbers",
143
+ "ts2js",
144
+ "twoslash",
145
+ "wrap",
146
+ ]);
147
+
148
+ // Blume's page frontmatter keys (its schema is strict).
149
+ const BLUME_KEYS = new Set([
150
+ "ai",
151
+ "api",
152
+ "authMethod",
153
+ "authors",
154
+ "changelog",
155
+ "date",
156
+ "deprecated",
157
+ "description",
158
+ "draft",
159
+ "hidden",
160
+ "icon",
161
+ "lastModified",
162
+ "mode",
163
+ "narration",
164
+ "noindex",
165
+ "pagination",
166
+ "playground",
167
+ "related",
168
+ "search",
169
+ "seo",
170
+ "sidebar",
171
+ "slug",
172
+ "title",
173
+ "type",
174
+ ]);
175
+
176
+ // The most entries Blume's `related` frontmatter takes.
177
+ const MAX_RELATED = 10;
178
+
179
+ const SEO_KEYS = new Set([
180
+ "canonical",
181
+ "description",
182
+ "image",
183
+ "noindex",
184
+ "title",
185
+ ]);
186
+
187
+ // Blume's no-import components: a `<Name>` with one of these names is a tag.
188
+ const COMPONENTS = new Set([
189
+ "Accordion",
190
+ "AccordionItem",
191
+ "Badge",
192
+ "Callout",
193
+ "Card",
194
+ "CardGroup",
195
+ "CodeBlock",
196
+ "CodeGroup",
197
+ "Color",
198
+ "Column",
199
+ "Columns",
200
+ "Component",
201
+ "Diff",
202
+ "Expandable",
203
+ "FileTree",
204
+ "Frame",
205
+ "GithubInfo",
206
+ "Icon",
207
+ "Math",
208
+ "Panel",
209
+ "ParamField",
210
+ "Prompt",
211
+ "RequestExample",
212
+ "ResponseExample",
213
+ "ResponseField",
214
+ "Step",
215
+ "Steps",
216
+ "Tab",
217
+ "Tabs",
218
+ "Tile",
219
+ "Tooltip",
220
+ "Tree",
221
+ "TypeTable",
222
+ "View",
223
+ "Visibility",
224
+ "YouTube",
225
+ ]);
226
+
227
+ // HTML elements a page may write as tags. `script`, `style`, and `template`
228
+ // aren't here: in prose they're text, and MDX can't hold them.
229
+ const HTML_TAGS = new Set([
230
+ "a",
231
+ "abbr",
232
+ "audio",
233
+ "b",
234
+ "blockquote",
235
+ "br",
236
+ "caption",
237
+ "center",
238
+ "code",
239
+ "col",
240
+ "dd",
241
+ "del",
242
+ "details",
243
+ "div",
244
+ "dl",
245
+ "dt",
246
+ "em",
247
+ "embed",
248
+ "figcaption",
249
+ "figure",
250
+ "h1",
251
+ "h2",
252
+ "h3",
253
+ "h4",
254
+ "h5",
255
+ "h6",
256
+ "hr",
257
+ "i",
258
+ "iframe",
259
+ "img",
260
+ "input",
261
+ "ins",
262
+ "kbd",
263
+ "li",
264
+ "mark",
265
+ "ol",
266
+ "p",
267
+ "picture",
268
+ "pre",
269
+ "q",
270
+ "s",
271
+ "samp",
272
+ "small",
273
+ "source",
274
+ "span",
275
+ "strong",
276
+ "sub",
277
+ "summary",
278
+ "sup",
279
+ "table",
280
+ "tbody",
281
+ "td",
282
+ "tfoot",
283
+ "th",
284
+ "thead",
285
+ "tr",
286
+ "track",
287
+ "u",
288
+ "ul",
289
+ "var",
290
+ "video",
291
+ "wbr",
292
+ ]);
293
+
294
+ const VOID_TAGS = new Set([
295
+ "br",
296
+ "col",
297
+ "embed",
298
+ "hr",
299
+ "img",
300
+ "input",
301
+ "source",
302
+ "track",
303
+ "wbr",
304
+ ]);
305
+
306
+ // Docus 1.x (Nuxt Elements) components: the reference maps them by hand.
307
+ const LEGACY = new Set([
308
+ "alert",
309
+ "block-hero",
310
+ "button-link",
311
+ "card-grid",
312
+ "code-block",
313
+ "list",
314
+ "terminal",
315
+ ]);
316
+
317
+ // Block components with a conversion (callouts are decided by name and color).
318
+ const CONVERTED = new Set([
319
+ "accordion",
320
+ "accordion-item",
321
+ "badge",
322
+ "card",
323
+ "card-group",
324
+ "code-collapse",
325
+ "code-group",
326
+ "code-preview",
327
+ "code-tree",
328
+ "collapsible",
329
+ "div",
330
+ "field",
331
+ "field-group",
332
+ "icon",
333
+ "kbd",
334
+ "prompt",
335
+ "steps",
336
+ "tabs",
337
+ "tabs-item",
338
+ "u-badge",
339
+ "u-color-mode-image",
340
+ "u-container",
341
+ "video",
342
+ ]);
343
+
344
+ // --- Patterns -------------------------------------------------------------------
345
+
346
+ // A backtick fence's info string holds no backtick: "```a``` b" is inline code.
347
+ const FENCE_RE =
348
+ /^(?<indent>[ \t]*)(?<marker>`{3,}(?![^`]*`)|~{3,})(?<info>.*)$/u;
349
+ const OPEN_RE =
350
+ /^(?<indent>[ \t]*)(?<colons>:{2,})(?<name>[A-Za-z][\w-]*)(?<label>\[[^\]]*\])?(?<attrs>\{.*\})?[ \t]*$/u;
351
+ const CLOSE_RE = /^(?<indent>[ \t]*)(?<colons>:{2,})[ \t]*$/u;
352
+ const SLOT_RE = /^[ \t]*#(?<name>[A-Za-z][\w-]*)(?<attrs>\{[^}]*\})?[ \t]*$/u;
353
+ const ATTR_TOKEN_RE =
354
+ /^[ \t]*(?:(?<cls>\.[\w-]+(?:\.[\w-]+)*)|(?<id>#[\w-]+)|(?<key>:?[\w-]+)(?:=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|(?<bare>[^\s"'}]+)))?)/u;
355
+ // MDC attribute content that can't be anything else: a class, an id, or a
356
+ // `key=value`. A bare `{word}` after text is left alone (`$x_{i}$`, `[1]{2}`).
357
+ const MDC_ATTRS_RE = /^[ \t]*(?:[.#][\w-]|:?[\w-]+=)/u;
358
+ const INLINE_RE =
359
+ /(?<![\w:/\\])(?<!\]):(?<name>[A-Za-z][\w-]*)(?<label>\[[^\]]*\])?(?<attrs>\{[^{}]*\})?/gu;
360
+
361
+ // A `$$` math block's opening and closing lines. Its LaTeX is left alone.
362
+ const MATH_OPEN_RE = /^[ \t]*\$\$(?!.*\$\$)/u;
363
+ const MATH_CLOSE_RE = /^[ \t]*\$\$[ \t]*$/u;
364
+
365
+ // --- Small helpers ----------------------------------------------------------------
366
+
367
+ const leading = (text) => /^[ \t]*/u.exec(text)[0].length;
368
+
369
+ const isFenceClose = (text, fence) => {
370
+ const m = /^[ \t]*(?<marker>`{3,}|~{3,})[ \t]*$/u.exec(text);
371
+ return (
372
+ m !== null &&
373
+ m.groups.marker[0] === fence.char &&
374
+ m.groups.marker.length >= fence.length
375
+ );
376
+ };
377
+
378
+ // An inline code span: a run of backticks, then the nearest run of exactly as
379
+ // many, so `` a `b` c `` is one span. An escaped backtick opens nothing.
380
+ const CODE_SPAN_RE =
381
+ /(?<![`\\])(?<ticks>`+)(?!`)[\s\S]*?(?<!`)\k<ticks>(?!`)/gu;
382
+
383
+ // Apply `fn` to the text outside inline code spans.
384
+ const outsideCode = (text, fn) => {
385
+ let result = "";
386
+ let last = 0;
387
+ for (const m of text.matchAll(CODE_SPAN_RE)) {
388
+ result += fn(text.slice(last, m.index)) + m[0];
389
+ last = m.index + m[0].length;
390
+ }
391
+ return result + fn(text.slice(last));
392
+ };
393
+
394
+ // A JSX string attribute.
395
+ const jsxAttr = (key, value) => {
396
+ if (value === true) {
397
+ return key;
398
+ }
399
+ const text = String(value);
400
+ return text.includes('"')
401
+ ? `${key}={${JSON.stringify(text)}}`
402
+ : `${key}="${text}"`;
403
+ };
404
+
405
+ // A fence title: a bare token when Blume reads one as a title, else `title="…"`.
406
+ const titleToken = (label) => {
407
+ if (/^[^\s"'{}=[\]]+$/u.test(label) && !RESERVED_META.has(label)) {
408
+ return label;
409
+ }
410
+ if (!label.includes('"')) {
411
+ return `title="${label}"`;
412
+ }
413
+ return label.includes("'") ? null : `title='${label}'`;
414
+ };
415
+
416
+ // A YAML scalar as written in frontmatter, unquoted.
417
+ const unquote = (value) => {
418
+ const text = value.trim();
419
+ const double = /^"(?<inner>.*)"$/u.exec(text);
420
+ if (double) {
421
+ return double.groups.inner.replaceAll('\\"', '"');
422
+ }
423
+ const single = /^'(?<inner>.*)'$/u.exec(text);
424
+ return single ? single.groups.inner.replaceAll("''", "'") : text;
425
+ };
426
+
427
+ // A YAML scalar's value: quotes removed, or a plain one's ` # comment`.
428
+ const yamlPlain = (value) =>
429
+ unquote(/^\s*["']/u.test(value) ? value : value.replace(/[ \t]+#.*$/u, ""));
430
+
431
+ // A YAML scalar for output: plain when safe, else JSON-quoted.
432
+ const yamlScalar = (value) =>
433
+ /^[A-Za-z0-9/][\w\s.,'()/&+?!-]*$/u.test(value) &&
434
+ !/\s$/u.test(value) &&
435
+ !/^(?:true|false|null|yes|no|on|off|~)$/iu.test(value)
436
+ ? value
437
+ : JSON.stringify(value);
438
+
439
+ // Plain text from a line of inline Markdown, for string props.
440
+ const plainText = (text) =>
441
+ text
442
+ .replaceAll(/\[(?<label>[^\]]*)\]\([^)]*\)/gu, "$<label>")
443
+ .replaceAll(/\[(?<label>[^\]]*)\]\{[^}]*\}/gu, "$<label>")
444
+ .replaceAll(/`|\*\*|__/gu, "")
445
+ .trim();
446
+
447
+ // A scalar from a YAML props block: quoted, boolean, a flow list, or bare.
448
+ const yamlValue = (raw) => {
449
+ const text = raw.trim();
450
+ if (text === "true" || text === "false") {
451
+ return text === "true";
452
+ }
453
+ const flow = /^\[(?<items>.*)\]$/u.exec(text);
454
+ if (flow) {
455
+ return flow.groups.items
456
+ .split(",")
457
+ .map((item) => unquote(item))
458
+ .filter((item) => item !== "");
459
+ }
460
+ return unquote(text);
461
+ };
462
+
463
+ // The YAML props block MDC reads after an opener: top-level `key: value` lines
464
+ // and `key:` lists of scalars. Null for anything richer (nested maps, block
465
+ // scalars), which the codemod leaves for review.
466
+ const parseYamlProps = (lines) => {
467
+ const out = {};
468
+ for (let i = 0; i < lines.length; i += 1) {
469
+ const raw = lines[i];
470
+ if (raw.trim() === "") {
471
+ continue;
472
+ }
473
+ const kv = /^(?<key>[\w-]+):(?:[ \t]+(?<value>.*))?$/u.exec(raw.trim());
474
+ if (!kv || leading(raw) > leading(lines[0])) {
475
+ return null;
476
+ }
477
+ const value = kv.groups.value?.trim() ?? "";
478
+ if (/^[|>]/u.test(value)) {
479
+ return null;
480
+ }
481
+ if (value !== "") {
482
+ out[kv.groups.key] = yamlValue(value);
483
+ continue;
484
+ }
485
+ const items = [];
486
+ while (i + 1 < lines.length && /^[ \t]*-[ \t]+/u.test(lines[i + 1])) {
487
+ i += 1;
488
+ const item = lines[i].replace(/^[ \t]*-[ \t]+/u, "");
489
+ if (/^[\w-]+:/u.test(item)) {
490
+ return null;
491
+ }
492
+ items.push(unquote(item));
493
+ }
494
+ out[kv.groups.key] = items;
495
+ }
496
+ return out;
497
+ };
498
+
499
+ // MDC inline attributes: `{key="v" key='v' key=v flag .class #id :json='[…]'}`.
500
+ const parseAttrs = (raw) => {
501
+ const out = {};
502
+ if (!raw) {
503
+ return out;
504
+ }
505
+ let rest = raw.slice(1, -1);
506
+ while (rest.trim() !== "") {
507
+ const m = ATTR_TOKEN_RE.exec(rest);
508
+ if (!m || m[0].trim() === "") {
509
+ return null;
510
+ }
511
+ rest = rest.slice(m[0].length);
512
+ const { bare, cls, dq, id, key, sq } = m.groups;
513
+ if (cls) {
514
+ out.class = cls.slice(1).split(".").join(" ");
515
+ } else if (id) {
516
+ out.id = id.slice(1);
517
+ } else if (key.startsWith(":")) {
518
+ try {
519
+ out[key.slice(1)] = JSON.parse(dq ?? sq ?? bare ?? "null");
520
+ } catch {
521
+ return null;
522
+ }
523
+ } else {
524
+ out[key] = dq ?? sq ?? bare ?? true;
525
+ }
526
+ }
527
+ return out;
528
+ };
529
+
530
+ // --- Icons -------------------------------------------------------------------------
531
+
532
+ const readIconSet = (file) => {
533
+ try {
534
+ const set = JSON.parse(readFileSync(file, "utf-8"));
535
+ return new Set([
536
+ ...Object.keys(set.icons ?? {}),
537
+ ...Object.keys(set.aliases ?? {}),
538
+ ]);
539
+ } catch {
540
+ return null;
541
+ }
542
+ };
543
+
544
+ // The Lucide icon names Blume resolves, or null when the set isn't found.
545
+ const loadLucide = (root, explicit) => {
546
+ if (explicit) {
547
+ return readIconSet(path.resolve(explicit));
548
+ }
549
+ const require = createRequire(path.join(root, "index.js"));
550
+ const attempts = [
551
+ () => require.resolve("@iconify-json/lucide/icons.json"),
552
+ () =>
553
+ createRequire(require.resolve("blume/package.json")).resolve(
554
+ "@iconify-json/lucide/icons.json"
555
+ ),
556
+ ];
557
+ for (const attempt of attempts) {
558
+ try {
559
+ return readIconSet(attempt());
560
+ } catch {
561
+ // Try the next location.
562
+ }
563
+ }
564
+ return null;
565
+ };
566
+
567
+ // An Iconify name → its Lucide name, or null (reported) when it isn't one.
568
+ const lucideName = (value, ctx, line, where) => {
569
+ const m = /^(?:i-lucide-|lucide:|lucide-)(?<name>[a-z0-9-]+)$/u.exec(
570
+ String(value).trim()
571
+ );
572
+ if (!m) {
573
+ ctx.review(
574
+ line,
575
+ `${where} icon \`${value}\` isn't a Lucide icon: pick the closest Lucide name, or save the SVG under public/ and use its path`
576
+ );
577
+ return null;
578
+ }
579
+ if (ctx.lucide && !ctx.lucide.has(m.groups.name)) {
580
+ ctx.review(
581
+ line,
582
+ `${where} icon \`${m.groups.name}\` isn't in Blume's Lucide set`
583
+ );
584
+ return null;
585
+ }
586
+ return m.groups.name;
587
+ };
588
+
589
+ // --- Body parsing ------------------------------------------------------------------
590
+
591
+ // Parse body lines into blocks the way remark-mdc does. Text is kept as line
592
+ // indexes; blocks keep their source span so one left as written can be
593
+ // copied back verbatim.
594
+ const parseBody = (lines, toLine, review) => {
595
+ const root = { kind: "root", sections: [{ children: [], name: "default" }] };
596
+ const stack = [root];
597
+ let fence = null;
598
+ const children = () => stack.at(-1).sections.at(-1).children;
599
+ const addText = (i) => {
600
+ const kids = children();
601
+ const last = kids.at(-1);
602
+ if (last?.kind === "text") {
603
+ last.lines.push(i);
604
+ } else {
605
+ kids.push({ kind: "text", lines: [i] });
606
+ }
607
+ };
608
+
609
+ for (let i = 0; i < lines.length; i += 1) {
610
+ const text = lines[i];
611
+ if (fence) {
612
+ fence = isFenceClose(text, fence) ? null : fence;
613
+ addText(i);
614
+ continue;
615
+ }
616
+ const f = FENCE_RE.exec(text);
617
+ if (f) {
618
+ fence = { char: f.groups.marker[0], length: f.groups.marker.length };
619
+ addText(i);
620
+ continue;
621
+ }
622
+ const close = CLOSE_RE.exec(text);
623
+ if (close) {
624
+ const colons = close.groups.colons.length;
625
+ const target = stack.findLastIndex(
626
+ (entry, index) => index > 0 && entry.colons === colons
627
+ );
628
+ if (target === -1 || leading(text) > stack[target].indent) {
629
+ review(
630
+ toLine(i),
631
+ target === -1
632
+ ? `stray \`${text.trim()}\`: it matches no open block, and MDC showed it as text too`
633
+ : `\`${text.trim()}\` is indented deeper than its opener, so MDC read it as text`
634
+ );
635
+ addText(i);
636
+ continue;
637
+ }
638
+ for (const inner of stack.slice(target + 1)) {
639
+ inner.end = i - 1;
640
+ review(
641
+ inner.line,
642
+ `\`::${inner.name}\` has no closer of its own: the closer on line ${toLine(i)} ends it`
643
+ );
644
+ }
645
+ stack[target].end = i;
646
+ stack.length = target;
647
+ continue;
648
+ }
649
+ const open = OPEN_RE.exec(text);
650
+ if (open) {
651
+ const block = {
652
+ attrs: parseAttrs(open.groups.attrs),
653
+ colons: open.groups.colons.length,
654
+ end: lines.length - 1,
655
+ indent: open.groups.indent.length,
656
+ indentText: open.groups.indent,
657
+ kind: "block",
658
+ label: open.groups.label,
659
+ line: toLine(i),
660
+ name: open.groups.name,
661
+ props: {},
662
+ propsLines: 0,
663
+ sections: [{ children: [], name: "default" }],
664
+ start: i,
665
+ };
666
+ if (lines[i + 1]?.trim() === "---") {
667
+ const endProps = lines.findIndex(
668
+ (line, j) => j > i + 1 && line.trim() === "---"
669
+ );
670
+ if (endProps === -1) {
671
+ block.props = null;
672
+ } else {
673
+ block.props = parseYamlProps(lines.slice(i + 2, endProps));
674
+ block.propsLines = endProps - i;
675
+ i = endProps;
676
+ }
677
+ }
678
+ children().push(block);
679
+ stack.push(block);
680
+ continue;
681
+ }
682
+ const slot = stack.length > 1 ? SLOT_RE.exec(text) : null;
683
+ if (slot) {
684
+ stack.at(-1).sections.push({
685
+ attrs: slot.groups.attrs,
686
+ children: [],
687
+ line: toLine(i),
688
+ name: slot.groups.name,
689
+ });
690
+ continue;
691
+ }
692
+ if (/^[ \t]*>[ \t>]*:{2,}[A-Za-z]/u.test(text)) {
693
+ review(
694
+ toLine(i),
695
+ "an MDC block inside a blockquote isn't converted: convert it by hand"
696
+ );
697
+ }
698
+ addText(i);
699
+ }
700
+ for (const open of stack.slice(1)) {
701
+ review(
702
+ open.line,
703
+ `\`::${open.name}\` is never closed: MDC runs it to the end of the page, and so does this conversion`
704
+ );
705
+ }
706
+ return root;
707
+ };
708
+
709
+ // --- Inline conversion --------------------------------------------------------------
710
+
711
+ // `[text]{…}` spans and `{…}` attributes on Markdown, which MDX can't parse.
712
+ const stripAttributes = (text, edit) => {
713
+ let out = text.replaceAll(
714
+ /`(?<code>[^`]+)`\{(?<attrs>[^{}]*)\}/gu,
715
+ (match, code, attrs) => {
716
+ if (!MDC_ATTRS_RE.test(attrs)) {
717
+ return match;
718
+ }
719
+ edit("inline code attributes");
720
+ const lang = /\blang=["']?(?<lang>[\w-]+)/u.exec(attrs)?.groups.lang;
721
+ return lang
722
+ ? `\`${code}{:${lang.replace(/-type$/u, "")}}\``
723
+ : `\`${code}\``;
724
+ }
725
+ );
726
+ out = outsideCode(out, (part) =>
727
+ part
728
+ .replaceAll(
729
+ /(?<link>\]\([^)]*\))\{(?<attrs>[^{}]*)\}/gu,
730
+ (match, link, attrs) => {
731
+ if (!MDC_ATTRS_RE.test(attrs)) {
732
+ return match;
733
+ }
734
+ edit("link and image attributes removed");
735
+ return link;
736
+ }
737
+ )
738
+ .replaceAll(
739
+ // Not an image's, and not an inline component's label (`:badge[x]{…}`).
740
+ /(?<!!|:[A-Za-z][\w-]*)\[(?<span>[^\]]*)\]\{(?<attrs>[^{}]*)\}/gu,
741
+ (match, span, attrs) => {
742
+ if (!MDC_ATTRS_RE.test(attrs)) {
743
+ return match;
744
+ }
745
+ edit("[span]{…} → text");
746
+ return span;
747
+ }
748
+ )
749
+ .replaceAll(
750
+ /(?<mark>\*\*|__|\*|_)\{(?<attrs>[^{}]*)\}/gu,
751
+ (match, mark, attrs) => {
752
+ if (!MDC_ATTRS_RE.test(attrs)) {
753
+ return match;
754
+ }
755
+ edit("emphasis attributes removed");
756
+ return mark;
757
+ }
758
+ )
759
+ );
760
+ return out;
761
+ };
762
+
763
+ // The `<img>` pair for `u-color-mode-image`, or null without both images.
764
+ const colorModeImages = (attrs) => {
765
+ if (!(attrs?.light && attrs?.dark)) {
766
+ return null;
767
+ }
768
+ const extra = ["alt", "width", "height"]
769
+ .filter((key) => attrs[key] !== undefined)
770
+ .map((key) => ` ${jsxAttr(key, attrs[key])}`)
771
+ .join("");
772
+ return `<img src="${attrs.light}"${extra} class="dark:hidden" /> <img src="${attrs.dark}"${extra} class="hidden dark:block" />`;
773
+ };
774
+
775
+ const inlineIcon = (text, attrs, ctx, line) => {
776
+ const icon = attrs.name ? lucideName(attrs.name, ctx, line, "`:icon`") : null;
777
+ if (!icon) {
778
+ return null;
779
+ }
780
+ ctx.mdx();
781
+ ctx.edit(":icon → <Icon />");
782
+ return `<Icon icon="${icon}" />`;
783
+ };
784
+
785
+ const inlineKbd = (text, attrs, ctx) => {
786
+ const value = String(attrs.value ?? text ?? "");
787
+ if (value === "") {
788
+ return null;
789
+ }
790
+ ctx.edit(":kbd → <kbd>");
791
+ return `<kbd>${KBD_KEYS.get(value.toLowerCase()) ?? value}</kbd>`;
792
+ };
793
+
794
+ const inlineBadge = (text, attrs, ctx) => {
795
+ const content = String(text ?? attrs.label ?? "");
796
+ if (content === "") {
797
+ return null;
798
+ }
799
+ const variant = BADGE_VARIANTS.get(String(attrs.color ?? ""));
800
+ ctx.mdx();
801
+ ctx.edit("badge → <Badge>");
802
+ return `<Badge${variant ? ` variant="${variant}"` : ""}>${content}</Badge>`;
803
+ };
804
+
805
+ const inlineVideo = (text, attrs, ctx) => {
806
+ if (!attrs.src) {
807
+ return null;
808
+ }
809
+ ctx.edit(":video → <video>");
810
+ const parts = Object.entries(attrs)
811
+ .filter(([key]) => key !== "class")
812
+ .map(([key, value]) => jsxAttr(key, value));
813
+ return `<video ${parts.join(" ")}></video>`;
814
+ };
815
+
816
+ const inlineColorModeImage = (text, attrs, ctx) => {
817
+ const images = colorModeImages(attrs);
818
+ if (images) {
819
+ ctx.edit(":u-color-mode-image → light and dark <img>");
820
+ }
821
+ return images;
822
+ };
823
+
824
+ // Each inline component the codemod converts → its Blume form, or null to
825
+ // leave it as written (reported).
826
+ const INLINE_CONVERTERS = new Map([
827
+ ["badge", inlineBadge],
828
+ ["icon", inlineIcon],
829
+ ["kbd", inlineKbd],
830
+ ["u-badge", inlineBadge],
831
+ ["u-color-mode-image", inlineColorModeImage],
832
+ ["video", inlineVideo],
833
+ ]);
834
+
835
+ const inlineComponent = (name, label, attrs, ctx, line) => {
836
+ if (attrs === null) {
837
+ ctx.review(line, `\`:${name}\` has attributes this can't read`);
838
+ return null;
839
+ }
840
+ const text = label ? label.slice(1, -1) : undefined;
841
+ return INLINE_CONVERTERS.get(name)(text, attrs, ctx, line);
842
+ };
843
+
844
+ // Inline components, spans, and attributes on one prose line.
845
+ const convertInline = (text, ctx, line) => {
846
+ if (/\{\{.*\}\}/u.test(text.replaceAll(CODE_SPAN_RE, ""))) {
847
+ ctx.review(
848
+ line,
849
+ "`{{ … }}` binding: substitute the value (Blume `variables` for shared ones)"
850
+ );
851
+ }
852
+ const stripped = stripAttributes(text, ctx.edit);
853
+ return outsideCode(stripped, (part) =>
854
+ part.replaceAll(INLINE_RE, (match, name, label, attrs) => {
855
+ if (!(label || attrs)) {
856
+ return match;
857
+ }
858
+ const lower = name.toLowerCase();
859
+ if (!INLINE_CONVERTERS.has(lower)) {
860
+ ctx.review(
861
+ line,
862
+ `inline component \`:${name}\` left as written: read its SFC and convert it by hand`
863
+ );
864
+ return match;
865
+ }
866
+ const converted = inlineComponent(
867
+ lower,
868
+ label,
869
+ parseAttrs(attrs),
870
+ ctx,
871
+ line
872
+ );
873
+ if (converted === null) {
874
+ ctx.review(line, `\`:${name}\` left as written`);
875
+ return match;
876
+ }
877
+ return converted;
878
+ })
879
+ );
880
+ };
881
+
882
+ // One fence opener's info string, or null to leave it as written.
883
+ const convertFenceInfo = (info, ctx, line) => {
884
+ const m = /^(?<lang>[^\s[{]*)(?<rest>.*)$/u.exec(info);
885
+ const { lang, rest } = m.groups;
886
+ const glued = /^[[{]/u.test(rest);
887
+ const label = /\[(?<label>.*)\]/u.exec(rest);
888
+ // A `title="…"` is Blume's form already (this codemod's own output).
889
+ if (!(label || glued) || /\btitle=["']/u.test(rest)) {
890
+ return null;
891
+ }
892
+ const withoutLabel = label ? rest.replace(label[0], "") : rest;
893
+ const highlight = /\{(?<lines>[^}]*)\}/u.exec(withoutLabel);
894
+ const meta = (
895
+ highlight ? withoutLabel.replace(highlight[0], "") : withoutLabel
896
+ ).trim();
897
+ const parts = [lang || "text"];
898
+ if (label) {
899
+ // MDC unescapes `\]` and the like in a label (`[pages/[slug\].vue]`).
900
+ const title = titleToken(
901
+ label.groups.label.replaceAll(/\\(?<char>[[\]{}().*+?^$|])/gu, "$<char>")
902
+ );
903
+ if (title === null) {
904
+ ctx.review(line, "fence label holds both quote kinds: title it by hand");
905
+ return null;
906
+ }
907
+ parts.push(title);
908
+ ctx.edit("fence [label] → title");
909
+ }
910
+ if (highlight) {
911
+ parts.push(`{${highlight.groups.lines}}`);
912
+ }
913
+ if (meta) {
914
+ parts.push(meta);
915
+ }
916
+ return parts.join(" ");
917
+ };
918
+
919
+ // --- Rendering --------------------------------------------------------------------
920
+
921
+ // Output lines carry `src` (prose from the page, which the MDX pass may escape),
922
+ // the source line they came from, and `pad` for a blank line added around a
923
+ // converted block.
924
+ const out = (text, line, src = false) => ({ line, pad: false, src, text });
925
+ const pad = (line) => ({ line, pad: true, src: false, text: "" });
926
+
927
+ // Remove up to `n` leading whitespace characters from each line.
928
+ const shift = (rendered, n) =>
929
+ n <= 0
930
+ ? rendered
931
+ : rendered.map((item) => ({
932
+ ...item,
933
+ text: item.text.slice(Math.min(n, leading(item.text))),
934
+ }));
935
+
936
+ const trimBlank = (rendered) => {
937
+ let start = 0;
938
+ let end = rendered.length;
939
+ while (start < end && rendered[start].text.trim() === "") {
940
+ start += 1;
941
+ }
942
+ while (end > start && rendered[end - 1].text.trim() === "") {
943
+ end -= 1;
944
+ }
945
+ return rendered.slice(start, end);
946
+ };
947
+
948
+ // A block's merged props: its YAML block, then its inline attributes, with
949
+ // kebab-case names (`default-value`) camel-cased as Vue reads them.
950
+ const propsOf = (block) =>
951
+ Object.fromEntries(
952
+ Object.entries({ ...block.props, ...block.attrs }).map(([key, value]) => [
953
+ key.replaceAll(/-(?<char>[a-z])/gu, (_, char) => char.toUpperCase()),
954
+ value,
955
+ ])
956
+ );
957
+
958
+ const calloutType = (block) => {
959
+ const name = block.name.replace(/^prose-/u, "");
960
+ if (CALLOUT_NAMES.has(name)) {
961
+ return CALLOUT_NAMES.get(name);
962
+ }
963
+ if (name !== "callout") {
964
+ return null;
965
+ }
966
+ const { color } = propsOf(block);
967
+ return color === undefined
968
+ ? "note"
969
+ : (CALLOUT_COLORS.get(String(color)) ?? null);
970
+ };
971
+
972
+ const canonicalName = (block) => block.name.replace(/^prose-/u, "");
973
+
974
+ // The named slots a conversion reads, besides `default`.
975
+ const SLOTS = new Map([
976
+ ["card", new Set(["default", "description", "title"])],
977
+ ["code-preview", new Set(["code", "default"])],
978
+ ]);
979
+
980
+ // Whether a slot holds one line of text and nothing else.
981
+ const oneLine = (entry, ctx) =>
982
+ entry.children.every((child) => child.kind === "text") &&
983
+ entry.children
984
+ .flatMap((child) => child.lines)
985
+ .filter((i) => ctx.lines[i].trim() !== "").length === 1;
986
+
987
+ // How a block converts, decided before rendering so callout fences can be
988
+ // lengthened around nested ones.
989
+ const classify = (block, ctx) => {
990
+ if (block.attrs === null || block.props === null || block.label) {
991
+ return "verbatim";
992
+ }
993
+ const name = canonicalName(block);
994
+ if (calloutType(block) !== null) {
995
+ if (block.sections.length > 1) {
996
+ return "verbatim";
997
+ }
998
+ const { icon } = propsOf(block);
999
+ const type = calloutType(block);
1000
+ if (icon === undefined) {
1001
+ return "directive";
1002
+ }
1003
+ const lucide = /^(?:i-lucide-|lucide:|lucide-)(?<name>[a-z0-9-]+)$/u.exec(
1004
+ String(icon)
1005
+ );
1006
+ if (lucide && DEFAULT_ICONS.get(type).has(lucide.groups.name)) {
1007
+ return "directive";
1008
+ }
1009
+ return lucide && (!ctx.lucide || ctx.lucide.has(lucide.groups.name))
1010
+ ? "callout-jsx"
1011
+ : "directive";
1012
+ }
1013
+ if (!CONVERTED.has(name)) {
1014
+ return "verbatim";
1015
+ }
1016
+ // A slot the conversion doesn't read would vanish: keep the block instead.
1017
+ const slots = SLOTS.get(name) ?? new Set(["default"]);
1018
+ const other = block.sections.find((entry) => !slots.has(entry.name));
1019
+ const title = block.sections.find((entry) => entry.name === "title");
1020
+ if (other || (title && !oneLine(title, ctx))) {
1021
+ block.reason = other
1022
+ ? `\`::${block.name}\` has a \`#${other.name}\` slot this can't map: convert it by hand`
1023
+ : `\`::${block.name}\` has a \`#title\` slot that isn't one line of text: convert it by hand`;
1024
+ return "verbatim";
1025
+ }
1026
+ return name;
1027
+ };
1028
+
1029
+ // The longest run of colons that starts a line outside code, not counting
1030
+ // an opener's (`:::name`).
1031
+ const longestColonRun = (rendered) => {
1032
+ let longest = 0;
1033
+ let fence = null;
1034
+ for (const { text } of rendered) {
1035
+ if (fence) {
1036
+ fence = isFenceClose(text, fence) ? null : fence;
1037
+ continue;
1038
+ }
1039
+ const f = FENCE_RE.exec(text);
1040
+ if (f) {
1041
+ fence = { char: f.groups.marker[0], length: f.groups.marker.length };
1042
+ continue;
1043
+ }
1044
+ const run = /^[ \t]*(?<colons>:{3,})(?![A-Za-z])/u.exec(text);
1045
+ longest = Math.max(longest, run ? run.groups.colons.length : 0);
1046
+ }
1047
+ return longest;
1048
+ };
1049
+
1050
+ const directiveDepth = (node) => {
1051
+ let depth = 0;
1052
+ for (const section of node.sections ?? []) {
1053
+ for (const child of section.children) {
1054
+ if (child.kind === "block") {
1055
+ const own = child.convert === "directive" ? 1 : 0;
1056
+ depth = Math.max(depth, own + directiveDepth(child));
1057
+ }
1058
+ }
1059
+ }
1060
+ return depth;
1061
+ };
1062
+
1063
+ const markBlocks = (node, ctx) => {
1064
+ for (const section of node.sections ?? []) {
1065
+ for (const child of section.children) {
1066
+ if (child.kind === "block") {
1067
+ child.convert = classify(child, ctx);
1068
+ if (child.convert !== "verbatim") {
1069
+ markBlocks(child, ctx);
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+ };
1075
+
1076
+ // Report props a conversion doesn't carry over. `class` and `ui` are styling.
1077
+ const dropProps = (block, kept, ctx) => {
1078
+ for (const key of Object.keys(propsOf(block)).toSorted()) {
1079
+ if (kept.includes(key)) {
1080
+ continue;
1081
+ }
1082
+ if (key === "class" || key === "ui" || key === "id") {
1083
+ ctx.edit("styling props removed");
1084
+ } else {
1085
+ ctx.review(
1086
+ block.line,
1087
+ `\`::${block.name}\` prop \`${key}\` has no Blume equivalent and was dropped`
1088
+ );
1089
+ }
1090
+ }
1091
+ };
1092
+
1093
+ // Wrap `inner` in JSX tags at the block's indentation, with blank lines so MDX
1094
+ // parses the content as Markdown.
1095
+ const wrapJsx = (block, open, inner, close) => {
1096
+ const ind = block.indentText;
1097
+ const body = trimBlank(inner);
1098
+ return [
1099
+ out(`${ind}${open}`, block.line),
1100
+ ...(body.length > 0
1101
+ ? [out("", block.line), ...body, out("", block.line)]
1102
+ : []),
1103
+ out(`${ind}${close}`, block.line),
1104
+ ];
1105
+ };
1106
+
1107
+ // How far a block's content is indented past its opener.
1108
+ const contentShift = (block, lines) => {
1109
+ let min = Number.POSITIVE_INFINITY;
1110
+ for (let i = block.start + 1; i < block.end; i += 1) {
1111
+ if (lines[i].trim() !== "") {
1112
+ min = Math.min(min, leading(lines[i]));
1113
+ }
1114
+ }
1115
+ return Number.isFinite(min) ? Math.max(0, min - block.indent) : 0;
1116
+ };
1117
+
1118
+ const section = (block, name) =>
1119
+ block.sections.find((entry) => entry.name === name);
1120
+
1121
+ const renderSection = (block, name, ctx) => {
1122
+ const entry = section(block, name);
1123
+ if (!entry) {
1124
+ return [];
1125
+ }
1126
+ return shift(
1127
+ ctx.renderChildren(entry.children, ctx),
1128
+ contentShift(block, ctx.lines)
1129
+ );
1130
+ };
1131
+
1132
+ // The text of a one-line slot, or null.
1133
+ const slotText = (block, name, ctx) => {
1134
+ const lines = trimBlank(renderSection(block, name, ctx));
1135
+ return lines.length === 1 ? plainText(lines[0].text) : null;
1136
+ };
1137
+
1138
+ // The non-blank source lines inside a block, after its props.
1139
+ const sourceContent = (block, ctx) =>
1140
+ ctx.lines
1141
+ .slice(block.start + 1, block.end)
1142
+ .filter((line, i) => line.trim() !== "" && i >= block.propsLines);
1143
+
1144
+ const verbatim = (block, ctx, reason) => {
1145
+ const message = (() => {
1146
+ if (reason ?? block.reason) {
1147
+ return reason ?? block.reason;
1148
+ }
1149
+ const name = canonicalName(block);
1150
+ if (block.attrs === null || block.props === null) {
1151
+ return `\`::${block.name}\` has props this can't read: convert it by hand`;
1152
+ }
1153
+ if (name.startsWith("u-")) {
1154
+ return `\`::${block.name}\` is a Nuxt UI page component: rebuild it by hand (references/docus.md, Landing page)`;
1155
+ }
1156
+ if (name === "callout") {
1157
+ return `callout color \`${propsOf(block).color}\` has no Blume type: pick one by hand`;
1158
+ }
1159
+ if (LEGACY.has(name)) {
1160
+ return `\`::${block.name}\` is a Docus 1.x component: convert it by hand (references/docus.md, Docus 1.x)`;
1161
+ }
1162
+ if (calloutType(block) !== null || block.label) {
1163
+ return `\`::${block.name}\` has slots or a label this can't map: convert it by hand`;
1164
+ }
1165
+ return `\`::${block.name}\` isn't a Nuxt UI prose component: read its SFC (app/components/content/ or a module) and convert it by hand`;
1166
+ })();
1167
+ ctx.review(block.line, `${message}. It's left as written`);
1168
+ const lines = [];
1169
+ for (let i = block.start; i <= block.end; i += 1) {
1170
+ lines.push(out(ctx.lines[i], ctx.toLine(i)));
1171
+ }
1172
+ return lines;
1173
+ };
1174
+
1175
+ const renderCallout = (block, ctx) => {
1176
+ const type = calloutType(block);
1177
+ const props = propsOf(block);
1178
+ if (props.to) {
1179
+ ctx.review(
1180
+ block.line,
1181
+ `callout linked to \`${props.to}\`: add the link to its text if it isn't there`
1182
+ );
1183
+ }
1184
+ if (props.icon !== undefined && block.convert === "directive") {
1185
+ const name = /^(?:i-lucide-|lucide:|lucide-)(?<name>[a-z0-9-]+)$/u.exec(
1186
+ String(props.icon)
1187
+ )?.groups.name;
1188
+ if (!(name && DEFAULT_ICONS.get(type).has(name))) {
1189
+ lucideName(props.icon, ctx, block.line, "callout");
1190
+ }
1191
+ }
1192
+ dropProps(block, ["color", "icon", "target", "to"], ctx);
1193
+ ctx.mdx();
1194
+ const inner = renderSection(block, "default", ctx);
1195
+ if (block.convert === "callout-jsx") {
1196
+ const icon = lucideName(props.icon, ctx, block.line, "callout");
1197
+ ctx.edit("callout → <Callout> with its icon");
1198
+ return wrapJsx(
1199
+ block,
1200
+ `<Callout type="${type}" icon="${icon}">`,
1201
+ inner,
1202
+ "</Callout>"
1203
+ );
1204
+ }
1205
+ ctx.edit(`callout → :::${type}`);
1206
+ // Longer than any colon line inside, a nested directive's or a stray one
1207
+ // MDC showed as text, which would otherwise close this one early.
1208
+ const colons = ":".repeat(
1209
+ Math.max(3 + directiveDepth(block), longestColonRun(inner) + 1)
1210
+ );
1211
+ return [
1212
+ out(`${block.indentText}${colons}${type}`, block.line),
1213
+ ...trimBlank(inner),
1214
+ out(`${block.indentText}${colons}`, block.line),
1215
+ ];
1216
+ };
1217
+
1218
+ const renderCard = (block, ctx) => {
1219
+ const props = propsOf(block);
1220
+ // Nuxt UI renders a `#title` slot in place of the `title` prop.
1221
+ const title = slotText(block, "title", ctx) ?? props.title;
1222
+ const attrs = [];
1223
+ if (title) {
1224
+ attrs.push(jsxAttr("title", title));
1225
+ }
1226
+ if (props.to) {
1227
+ attrs.push(jsxAttr("href", props.to));
1228
+ }
1229
+ if (props.icon) {
1230
+ attrs.push(
1231
+ jsxAttr(
1232
+ "icon",
1233
+ lucideName(props.icon, ctx, block.line, "card") ?? props.icon
1234
+ )
1235
+ );
1236
+ }
1237
+ if (props.color) {
1238
+ ctx.edit("card color removed");
1239
+ }
1240
+ dropProps(
1241
+ block,
1242
+ ["color", "description", "icon", "target", "title", "to"],
1243
+ ctx
1244
+ );
1245
+ const description = [
1246
+ ...(props.description
1247
+ ? [out(`${block.indentText}${props.description}`, block.line, true)]
1248
+ : []),
1249
+ ...renderSection(block, "description", ctx),
1250
+ ];
1251
+ if (trimBlank(description).length > 0) {
1252
+ ctx.review(
1253
+ block.line,
1254
+ "card description: Nuxt UI's prose card renders neither the `description` prop nor a `#description` slot, so the live page likely didn't show this text. It's now the card's body: check the live page, and delete it if it never showed"
1255
+ );
1256
+ }
1257
+ const body = [
1258
+ ...trimBlank(description),
1259
+ ...(description.length > 0 ? [out("", block.line)] : []),
1260
+ ...renderSection(block, "default", ctx),
1261
+ ];
1262
+ ctx.edit("card → <Card>");
1263
+ ctx.mdx();
1264
+ return wrapJsx(
1265
+ block,
1266
+ `<Card${attrs.length > 0 ? ` ${attrs.join(" ")}` : ""}>`,
1267
+ body,
1268
+ "</Card>"
1269
+ );
1270
+ };
1271
+
1272
+ const renderField = (block, ctx) => {
1273
+ const props = propsOf(block);
1274
+ const attrs = [
1275
+ jsxAttr("name", plainText(String(props.name ?? ""))),
1276
+ ...(props.type ? [jsxAttr("type", props.type)] : []),
1277
+ ...(props.required === true || props.required === "true"
1278
+ ? ["required"]
1279
+ : []),
1280
+ ];
1281
+ dropProps(block, ["description", "name", "required", "type"], ctx);
1282
+ const body = [
1283
+ ...(props.description
1284
+ ? [
1285
+ out(`${block.indentText}${props.description}`, block.line, true),
1286
+ out("", block.line),
1287
+ ]
1288
+ : []),
1289
+ ...renderSection(block, "default", ctx),
1290
+ ];
1291
+ ctx.edit("field → <ResponseField>");
1292
+ ctx.mdx();
1293
+ return wrapJsx(
1294
+ block,
1295
+ `<ResponseField ${attrs.join(" ")}>`,
1296
+ body,
1297
+ "</ResponseField>"
1298
+ );
1299
+ };
1300
+
1301
+ // A code group that only repeats one install command per package manager.
1302
+ const packageInstall = (block, ctx) => {
1303
+ const entry = section(block, "default");
1304
+ if (
1305
+ block.sections.length > 1 ||
1306
+ entry.children.some((c) => c.kind !== "text")
1307
+ ) {
1308
+ return null;
1309
+ }
1310
+ const lines = entry.children.flatMap((child) =>
1311
+ child.lines.map((i) => ctx.lines[i])
1312
+ );
1313
+ const commands = [];
1314
+ for (let i = 0; i < lines.length; i += 1) {
1315
+ if (lines[i].trim() === "") {
1316
+ continue;
1317
+ }
1318
+ const f = FENCE_RE.exec(lines[i]);
1319
+ const label = f ? /\[(?<label>[^\]]*)\]/u.exec(f.groups.info) : null;
1320
+ const lang = f ? /^[^\s[{]*/u.exec(f.groups.info)[0] : "";
1321
+ if (!(f && label && SHELL_LANGS.has(lang))) {
1322
+ return null;
1323
+ }
1324
+ const manager = label.groups.label.toLowerCase();
1325
+ const command =
1326
+ /^[ \t]*(?<pm>bun|npm|pnpm|yarn)[ \t]+(?:add|install|i)[ \t]+(?<args>\S.*?)[ \t]*$/u.exec(
1327
+ lines[i + 1] ?? ""
1328
+ );
1329
+ if (
1330
+ !(
1331
+ PACKAGE_MANAGERS.has(manager) &&
1332
+ command?.groups.pm === manager &&
1333
+ lines[i + 2] !== undefined &&
1334
+ isFenceClose(lines[i + 2], {
1335
+ char: f.groups.marker[0],
1336
+ length: f.groups.marker.length,
1337
+ })
1338
+ )
1339
+ ) {
1340
+ return null;
1341
+ }
1342
+ commands.push(command.groups.args);
1343
+ i += 2;
1344
+ }
1345
+ if (commands.length < 2 || new Set(commands).size !== 1) {
1346
+ return null;
1347
+ }
1348
+ ctx.mdx();
1349
+ ctx.edit("code-group → package-install fence");
1350
+ const ind = block.indentText;
1351
+ return [
1352
+ out(`${ind}\`\`\`package-install`, block.line),
1353
+ out(`${ind}npm install ${commands[0]}`, block.line),
1354
+ out(`${ind}\`\`\``, block.line),
1355
+ ];
1356
+ };
1357
+
1358
+ const renderCodeGroup = (block, ctx) => {
1359
+ const install = packageInstall(block, ctx);
1360
+ if (install) {
1361
+ return install;
1362
+ }
1363
+ dropProps(block, ["defaultValue", "sync"], ctx);
1364
+ if (canonicalName(block) === "code-tree") {
1365
+ ctx.review(
1366
+ block.line,
1367
+ "code-tree → <CodeGroup>: the file-tree view is gone (a <FileTree> before it can show the layout)"
1368
+ );
1369
+ }
1370
+ ctx.edit(`${canonicalName(block)} → <CodeGroup>`);
1371
+ ctx.mdx();
1372
+ return wrapJsx(
1373
+ block,
1374
+ "<CodeGroup>",
1375
+ renderSection(block, "default", ctx),
1376
+ "</CodeGroup>"
1377
+ );
1378
+ };
1379
+
1380
+ const renderCodeCollapse = (block, ctx) => {
1381
+ dropProps(block, ["closeText", "icon", "name", "openText"], ctx);
1382
+ const rendered = renderSection(block, "default", ctx);
1383
+ let fence = null;
1384
+ const lines = rendered.map((item) => {
1385
+ if (fence) {
1386
+ fence = isFenceClose(item.text, fence) ? null : fence;
1387
+ return item;
1388
+ }
1389
+ const f = FENCE_RE.exec(item.text);
1390
+ if (!f) {
1391
+ return item;
1392
+ }
1393
+ fence = { char: f.groups.marker[0], length: f.groups.marker.length };
1394
+ const info = f.groups.info.trim() === "" ? "text" : f.groups.info.trimEnd();
1395
+ return {
1396
+ ...item,
1397
+ text: `${f.groups.indent}${f.groups.marker}${info} expandable`,
1398
+ };
1399
+ });
1400
+ ctx.edit("code-collapse → expandable fence");
1401
+ return trimBlank(lines);
1402
+ };
1403
+
1404
+ const renderSteps = (block, ctx) => {
1405
+ const props = propsOf(block);
1406
+ const level = Number(props.level ?? 3);
1407
+ dropProps(block, ["level"], ctx);
1408
+ const marker = new RegExp(`^[ \\t]{0,3}#{${level}}[ \\t]`, "u");
1409
+ const offset = contentShift(block, ctx.lines);
1410
+ const steps = [];
1411
+ const before = [];
1412
+ let fence = null;
1413
+ for (const child of section(block, "default").children) {
1414
+ if (child.kind !== "text") {
1415
+ (steps.at(-1) ?? before).push(child);
1416
+ continue;
1417
+ }
1418
+ for (const i of child.lines) {
1419
+ const text = ctx.lines[i];
1420
+ if (fence) {
1421
+ fence = isFenceClose(text, fence) ? null : fence;
1422
+ } else {
1423
+ const f = FENCE_RE.exec(text);
1424
+ if (f) {
1425
+ fence = { char: f.groups.marker[0], length: f.groups.marker.length };
1426
+ } else if (
1427
+ marker.test(
1428
+ text.slice(Math.min(block.indent + offset, leading(text)))
1429
+ )
1430
+ ) {
1431
+ steps.push([]);
1432
+ }
1433
+ }
1434
+ const target = steps.at(-1) ?? before;
1435
+ const last = target.at(-1);
1436
+ if (last?.kind === "text") {
1437
+ last.lines.push(i);
1438
+ } else {
1439
+ target.push({ kind: "text", lines: [i] });
1440
+ }
1441
+ }
1442
+ }
1443
+ if (steps.length === 0) {
1444
+ return verbatim(
1445
+ block,
1446
+ ctx,
1447
+ `\`::steps\` has no level-${level} headings: convert it by hand`
1448
+ );
1449
+ }
1450
+ const head = trimBlank(shift(ctx.renderChildren(before, ctx), offset));
1451
+ if (head.length > 0) {
1452
+ ctx.review(
1453
+ block.line,
1454
+ "content before the first step heading was kept above <Steps>"
1455
+ );
1456
+ }
1457
+ ctx.mdx();
1458
+ ctx.edit("steps → <Steps>");
1459
+ ctx.edit("step heading → <Step>", steps.length);
1460
+ const ind = block.indentText;
1461
+ const body = steps.flatMap((step) => [
1462
+ ...wrapJsx(
1463
+ block,
1464
+ "<Step>",
1465
+ shift(ctx.renderChildren(step, ctx), offset),
1466
+ "</Step>"
1467
+ ),
1468
+ out("", block.line),
1469
+ ]);
1470
+ return [
1471
+ ...head,
1472
+ ...(head.length > 0 ? [out("", block.line)] : []),
1473
+ out(`${ind}<Steps>`, block.line),
1474
+ out("", block.line),
1475
+ ...trimBlank(body),
1476
+ out("", block.line),
1477
+ out(`${ind}</Steps>`, block.line),
1478
+ ];
1479
+ };
1480
+
1481
+ const renderTabs = (block, ctx) => {
1482
+ const props = propsOf(block);
1483
+ const attrs = [];
1484
+ if (props.sync) {
1485
+ attrs.push(jsxAttr("syncKey", props.sync));
1486
+ }
1487
+ if (/^\d+$/u.test(String(props.defaultValue ?? ""))) {
1488
+ attrs.push(`defaultTabIndex={${props.defaultValue}}`);
1489
+ }
1490
+ dropProps(block, ["defaultValue", "hash", "sync"], ctx);
1491
+ const stray = section(block, "default").children.some(
1492
+ (child) =>
1493
+ (child.kind === "block" && canonicalName(child) !== "tabs-item") ||
1494
+ (child.kind === "text" &&
1495
+ child.lines.some((i) => ctx.lines[i].trim() !== ""))
1496
+ );
1497
+ if (stray) {
1498
+ ctx.review(block.line, "`::tabs` holds content outside its items");
1499
+ }
1500
+ ctx.mdx();
1501
+ ctx.edit("tabs → <Tabs>");
1502
+ return wrapJsx(
1503
+ block,
1504
+ `<Tabs${attrs.length > 0 ? ` ${attrs.join(" ")}` : ""}>`,
1505
+ renderSection(block, "default", ctx),
1506
+ "</Tabs>"
1507
+ );
1508
+ };
1509
+
1510
+ // `tabs-item` and `accordion-item`: a `label`, an optional icon, the body.
1511
+ const renderItem = (block, ctx, tag) => {
1512
+ const props = propsOf(block);
1513
+ const attrs = [jsxAttr("title", String(props.label ?? ""))];
1514
+ if (props.icon) {
1515
+ attrs.push(
1516
+ jsxAttr(
1517
+ "icon",
1518
+ lucideName(props.icon, ctx, block.line, `\`::${block.name}\``) ??
1519
+ props.icon
1520
+ )
1521
+ );
1522
+ }
1523
+ if (tag === "AccordionItem" && props.description) {
1524
+ attrs.push(jsxAttr("description", props.description));
1525
+ }
1526
+ dropProps(
1527
+ block,
1528
+ tag === "AccordionItem"
1529
+ ? ["description", "icon", "label"]
1530
+ : ["icon", "label"],
1531
+ ctx
1532
+ );
1533
+ ctx.mdx();
1534
+ ctx.edit(`${canonicalName(block)} → <${tag}>`);
1535
+ return wrapJsx(
1536
+ block,
1537
+ `<${tag} ${attrs.join(" ")}>`,
1538
+ renderSection(block, "default", ctx),
1539
+ `</${tag}>`
1540
+ );
1541
+ };
1542
+
1543
+ const renderCodePreview = (block, ctx) => {
1544
+ dropProps(block, [], ctx);
1545
+ ctx.mdx();
1546
+ if (!section(block, "code")) {
1547
+ ctx.edit("code-preview without code → its preview");
1548
+ return trimBlank(renderSection(block, "default", ctx));
1549
+ }
1550
+ ctx.edit("code-preview → Preview and Code tabs");
1551
+ const ind = block.indentText;
1552
+ return [
1553
+ out(`${ind}<Tabs>`, block.line),
1554
+ out("", block.line),
1555
+ ...wrapJsx(
1556
+ block,
1557
+ '<Tab title="Preview">',
1558
+ renderSection(block, "default", ctx),
1559
+ "</Tab>"
1560
+ ),
1561
+ out("", block.line),
1562
+ ...wrapJsx(
1563
+ block,
1564
+ '<Tab title="Code">',
1565
+ renderSection(block, "code", ctx),
1566
+ "</Tab>"
1567
+ ),
1568
+ out("", block.line),
1569
+ out(`${ind}</Tabs>`, block.line),
1570
+ ];
1571
+ };
1572
+
1573
+ const renderPrompt = (block, ctx) => {
1574
+ const props = propsOf(block);
1575
+ const requested = Array.isArray(props.actions) ? props.actions : [];
1576
+ const actions = ["copy", ...(requested.includes("cursor") ? ["cursor"] : [])];
1577
+ for (const action of requested) {
1578
+ if (action !== "copy" && action !== "cursor") {
1579
+ ctx.edit(`prompt action \`${action}\` removed`);
1580
+ }
1581
+ }
1582
+ if (props.icon) {
1583
+ ctx.edit("prompt icon removed");
1584
+ }
1585
+ dropProps(block, ["actions", "description", "icon"], ctx);
1586
+ const attrs = [jsxAttr("description", String(props.description ?? ""))];
1587
+ if (actions.length > 1) {
1588
+ attrs.push(
1589
+ `actions={[${actions.map((action) => JSON.stringify(action)).join(", ")}]}`
1590
+ );
1591
+ }
1592
+ ctx.mdx();
1593
+ ctx.edit("prompt → <Prompt>");
1594
+ return wrapJsx(
1595
+ block,
1596
+ `<Prompt ${attrs.join(" ")}>`,
1597
+ renderSection(block, "default", ctx),
1598
+ "</Prompt>"
1599
+ );
1600
+ };
1601
+
1602
+ const renderCollapsible = (block, ctx) => {
1603
+ const props = propsOf(block);
1604
+ const title = `${props.openText ?? "Show"} ${props.name ?? "properties"}`;
1605
+ dropProps(block, ["closeText", "icon", "name", "openText"], ctx);
1606
+ ctx.mdx();
1607
+ ctx.edit("collapsible → <Expandable>");
1608
+ return wrapJsx(
1609
+ block,
1610
+ `<Expandable ${jsxAttr("title", title)}>`,
1611
+ renderSection(block, "default", ctx),
1612
+ "</Expandable>"
1613
+ );
1614
+ };
1615
+
1616
+ const renderBadge = (block, ctx) => {
1617
+ const lines = sourceContent(block, ctx);
1618
+ if (lines.length !== 1 || block.sections.length > 1) {
1619
+ return verbatim(
1620
+ block,
1621
+ ctx,
1622
+ "`::badge` holds more than one line: convert it by hand"
1623
+ );
1624
+ }
1625
+ const variant = BADGE_VARIANTS.get(String(propsOf(block).color ?? ""));
1626
+ dropProps(block, ["color"], ctx);
1627
+ ctx.mdx();
1628
+ ctx.edit("badge → <Badge>");
1629
+ return [
1630
+ out(
1631
+ `${block.indentText}<Badge${variant ? ` variant="${variant}"` : ""}>${convertInline(lines[0].trim(), ctx, block.line)}</Badge>`,
1632
+ block.line,
1633
+ true
1634
+ ),
1635
+ ];
1636
+ };
1637
+
1638
+ // A block written for an inline component (`::icon{name}` alone).
1639
+ const renderInlineBlock = (block, ctx) => {
1640
+ const empty =
1641
+ sourceContent(block, ctx).length === 0 && block.sections.length === 1;
1642
+ const converted = empty
1643
+ ? inlineComponent(
1644
+ canonicalName(block),
1645
+ undefined,
1646
+ block.attrs,
1647
+ ctx,
1648
+ block.line
1649
+ )
1650
+ : null;
1651
+ if (converted === null) {
1652
+ return verbatim(
1653
+ block,
1654
+ ctx,
1655
+ `\`::${block.name}\` can't be converted as written: convert it by hand`
1656
+ );
1657
+ }
1658
+ return [out(`${block.indentText}${converted}`, block.line, true)];
1659
+ };
1660
+
1661
+ const renderWrapper = (block, ctx) => {
1662
+ const inner = trimBlank(renderSection(block, "default", ctx));
1663
+ ctx.edit(
1664
+ inner.length === 0
1665
+ ? `empty \`::${block.name}\` removed`
1666
+ : `\`::${block.name}\` wrapper removed`
1667
+ );
1668
+ return shift(inner, 0);
1669
+ };
1670
+
1671
+ // A container rendered as one JSX component around its content.
1672
+ const renderContainer = (tag, kept) => (block, ctx) => {
1673
+ dropProps(block, kept, ctx);
1674
+ ctx.mdx();
1675
+ ctx.edit(`${canonicalName(block)} → <${tag}>`);
1676
+ return wrapJsx(
1677
+ block,
1678
+ `<${tag}>`,
1679
+ renderSection(block, "default", ctx),
1680
+ `</${tag}>`
1681
+ );
1682
+ };
1683
+
1684
+ const renderFieldGroup = (block, ctx) => {
1685
+ ctx.edit("field-group wrapper removed");
1686
+ return trimBlank(renderSection(block, "default", ctx));
1687
+ };
1688
+
1689
+ // Each conversion kind `classify` picks → its renderer.
1690
+ const RENDERERS = new Map([
1691
+ ["accordion", renderContainer("Accordion", ["type"])],
1692
+ ["accordion-item", (block, ctx) => renderItem(block, ctx, "AccordionItem")],
1693
+ ["badge", renderBadge],
1694
+ ["callout-jsx", renderCallout],
1695
+ ["card", renderCard],
1696
+ ["card-group", renderContainer("CardGroup", [])],
1697
+ ["code-collapse", renderCodeCollapse],
1698
+ ["code-group", renderCodeGroup],
1699
+ ["code-preview", renderCodePreview],
1700
+ ["code-tree", renderCodeGroup],
1701
+ ["collapsible", renderCollapsible],
1702
+ ["directive", renderCallout],
1703
+ ["div", renderWrapper],
1704
+ ["field", renderField],
1705
+ ["field-group", renderFieldGroup],
1706
+ ["prompt", renderPrompt],
1707
+ ["steps", renderSteps],
1708
+ ["tabs", renderTabs],
1709
+ ["tabs-item", (block, ctx) => renderItem(block, ctx, "Tab")],
1710
+ ["u-container", renderWrapper],
1711
+ ["verbatim", verbatim],
1712
+ ]);
1713
+
1714
+ const renderBlock = (block, ctx) =>
1715
+ (RENDERERS.get(block.convert) ?? renderInlineBlock)(block, ctx);
1716
+
1717
+ // Pad a converted block with blank lines so it never runs into a paragraph.
1718
+ const pushBlock = (lines, rendered) => {
1719
+ if (rendered.length === 0) {
1720
+ return;
1721
+ }
1722
+ if (lines.length > 0 && lines.at(-1).text.trim() !== "") {
1723
+ lines.push(pad(rendered[0].line));
1724
+ }
1725
+ lines.push(...rendered, pad(rendered.at(-1).line));
1726
+ };
1727
+
1728
+ // A section's children, rendered: blocks converted, prose lines converted inline.
1729
+ const renderChildren = (children, ctx) => {
1730
+ const lines = [];
1731
+ for (const child of children) {
1732
+ if (child.kind === "block") {
1733
+ pushBlock(lines, renderBlock(child, ctx));
1734
+ continue;
1735
+ }
1736
+ let fence = null;
1737
+ let math = false;
1738
+ for (const i of child.lines) {
1739
+ const text = ctx.lines[i];
1740
+ const line = ctx.toLine(i);
1741
+ if (fence) {
1742
+ fence = isFenceClose(text, fence) ? null : fence;
1743
+ lines.push(out(text, line, true));
1744
+ continue;
1745
+ }
1746
+ if (math || MATH_OPEN_RE.test(text)) {
1747
+ math = math ? !MATH_CLOSE_RE.test(text) : true;
1748
+ lines.push(out(text, line, true));
1749
+ continue;
1750
+ }
1751
+ const f = FENCE_RE.exec(text);
1752
+ if (f) {
1753
+ fence = { char: f.groups.marker[0], length: f.groups.marker.length };
1754
+ const info = convertFenceInfo(f.groups.info, ctx, line);
1755
+ lines.push(
1756
+ out(
1757
+ info === null
1758
+ ? text
1759
+ : `${f.groups.indent}${f.groups.marker}${info}`,
1760
+ line,
1761
+ true
1762
+ )
1763
+ );
1764
+ continue;
1765
+ }
1766
+ if (text.trim() === "" && lines.at(-1)?.pad) {
1767
+ continue;
1768
+ }
1769
+ lines.push(out(convertInline(text, ctx, line), line, true));
1770
+ }
1771
+ }
1772
+ return lines;
1773
+ };
1774
+
1775
+ // --- MDX safety ---------------------------------------------------------------------
1776
+
1777
+ // Where a tag that opened earlier ends in `text`: the index of its `>`, or -1.
1778
+ // `state.quote` carries an open attribute quote from line to line.
1779
+ const tagEnd = (text, state) => {
1780
+ for (let j = 0; j < text.length; j += 1) {
1781
+ const c = text[j];
1782
+ if (state.quote) {
1783
+ state.quote = c === state.quote ? null : state.quote;
1784
+ } else if (c === '"' || c === "'") {
1785
+ state.quote = c;
1786
+ } else if (c === ">") {
1787
+ return j;
1788
+ }
1789
+ }
1790
+ return -1;
1791
+ };
1792
+
1793
+ // A tag at `s[i]` (`<name …>` or `</name>`): its end index and name, or null.
1794
+ const readTag = (s, i) => {
1795
+ const m = /^<(?<close>\/)?(?<name>[A-Za-z][\w.-]*)/u.exec(s.slice(i));
1796
+ if (!m) {
1797
+ return null;
1798
+ }
1799
+ let quote = null;
1800
+ for (let j = i + m[0].length; j < s.length; j += 1) {
1801
+ const c = s[j];
1802
+ if (quote) {
1803
+ quote = c === quote ? null : quote;
1804
+ } else if (c === '"' || c === "'") {
1805
+ quote = c;
1806
+ } else if (c === ">") {
1807
+ return {
1808
+ close: m.groups.close === "/",
1809
+ end: j,
1810
+ name: m.groups.name,
1811
+ selfClosing: s[j - 1] === "/",
1812
+ };
1813
+ }
1814
+ }
1815
+ return null;
1816
+ };
1817
+
1818
+ // Case matters, as in JSX: `<input>` is HTML, but `useTool<Input>()` names a
1819
+ // component MDX would look up, so it's text.
1820
+ const isKnownTag = (name) => COMPONENTS.has(name) || HTML_TAGS.has(name);
1821
+
1822
+ // A real tag at `s[i]`, kept (or closed, for a void tag); null otherwise,
1823
+ // with a review line when it looked like markup.
1824
+ const keepTag = (s, i, ctx, line, rest) => {
1825
+ const tag = readTag(s, i);
1826
+ if (!tag) {
1827
+ return null;
1828
+ }
1829
+ const raw = s.slice(i, tag.end + 1);
1830
+ const closes = new RegExp(`</${tag.name.replaceAll(".", "\\.")}\\s*>`, "u");
1831
+ const closed =
1832
+ tag.close || tag.selfClosing || closes.test(s.slice(tag.end) + rest);
1833
+ const known = isKnownTag(tag.name);
1834
+ if (known && closed) {
1835
+ return { end: tag.end, text: raw };
1836
+ }
1837
+ if (known && VOID_TAGS.has(tag.name.toLowerCase())) {
1838
+ ctx.edit("void tag closed (<br />)");
1839
+ return { end: tag.end, text: `${raw.slice(0, -1).trimEnd()} />` };
1840
+ }
1841
+ if (
1842
+ /^(?:script|style|template)$/iu.test(tag.name) ||
1843
+ (closed && !tag.close)
1844
+ ) {
1845
+ ctx.review(
1846
+ line,
1847
+ `\`<${tag.name}>\` isn't a Blume component or an HTML tag MDX can hold: it's now text`
1848
+ );
1849
+ } else if (known && !tag.close) {
1850
+ ctx.review(line, `\`<${tag.name}>\` has no closing tag: it's now text`);
1851
+ }
1852
+ return null;
1853
+ };
1854
+
1855
+ // What a `<` at `s[i]` becomes: a comment, a link, a kept tag, or `&lt;`.
1856
+ const angle = (s, i, ctx, line, rest) => {
1857
+ if (s.startsWith("<!--", i)) {
1858
+ const end = s.indexOf("-->", i + 4);
1859
+ if (end !== -1) {
1860
+ ctx.edit("HTML comment → {/* */}");
1861
+ return { end: end + 2, text: `{/*${s.slice(i + 4, end)}*/}` };
1862
+ }
1863
+ ctx.review(line, "an HTML comment spans lines: rewrite it as {/* … */}");
1864
+ }
1865
+ const autolink = /^<(?<url>(?:https?:\/\/|mailto:)[^\s<>]+)>/u.exec(
1866
+ s.slice(i)
1867
+ );
1868
+ if (autolink) {
1869
+ ctx.edit("autolink → link");
1870
+ const { url } = autolink.groups;
1871
+ return {
1872
+ end: i + autolink[0].length - 1,
1873
+ text: `[${url.replace(/^mailto:/u, "")}](${url})`,
1874
+ };
1875
+ }
1876
+ const tag = keepTag(s, i, ctx, line, rest);
1877
+ if (tag) {
1878
+ return tag;
1879
+ }
1880
+ // A known tag whose `>` is on a later line (`<iframe` then its attributes)
1881
+ // stays a tag: the lines up to its `>` pass through as written.
1882
+ const open = /^<(?<name>[A-Za-z][\w.-]*)(?=\s|$)/u.exec(s.slice(i));
1883
+ if (open && isKnownTag(open.groups.name) && readTag(s, i) === null) {
1884
+ const state = { name: open.groups.name, quote: null };
1885
+ tagEnd(s.slice(i + open[0].length), state);
1886
+ ctx.openTag = state;
1887
+ ctx.edit("tag across lines kept");
1888
+ return { end: s.length - 1, text: s.slice(i) };
1889
+ }
1890
+ if (/^\S/u.test(s.slice(i + 1, i + 2))) {
1891
+ ctx.edit("`<` escaped as &lt;");
1892
+ return { end: i, text: "&lt;" };
1893
+ }
1894
+ return { end: i, text: "<" };
1895
+ };
1896
+
1897
+ // Escape what MDX can't parse in one prose segment (no inline code in it).
1898
+ const escapeSegment = (s, ctx, line, rest) => {
1899
+ let result = "";
1900
+ let start = 0;
1901
+ if (ctx.openTag) {
1902
+ const end = tagEnd(s, ctx.openTag);
1903
+ if (end === -1) {
1904
+ return s;
1905
+ }
1906
+ const closeVoid =
1907
+ VOID_TAGS.has(ctx.openTag.name.toLowerCase()) && s[end - 1] !== "/";
1908
+ result = closeVoid
1909
+ ? `${s.slice(0, end).trimEnd()} />`
1910
+ : s.slice(0, end + 1);
1911
+ start = end + 1;
1912
+ ctx.openTag = null;
1913
+ }
1914
+ for (let i = start; i < s.length; i += 1) {
1915
+ const c = s[i];
1916
+ if (c === "\\") {
1917
+ result += s.slice(i, i + 2);
1918
+ i += 1;
1919
+ } else if (c === "{" || c === "}") {
1920
+ ctx.edit("`{`/`}` escaped");
1921
+ result += `\\${c}`;
1922
+ } else if (c === "<") {
1923
+ const converted = angle(s, i, ctx, line, rest);
1924
+ result += converted.text;
1925
+ i = converted.end;
1926
+ } else {
1927
+ result += c;
1928
+ }
1929
+ }
1930
+ return result;
1931
+ };
1932
+
1933
+ // Apply `fn` to the text outside `$$…$$` math, whose braces are LaTeX.
1934
+ const outsideMath = (text, fn) =>
1935
+ text
1936
+ .split(/(?<math>\$\$[^$]+\$\$)/u)
1937
+ .map((part, i) => (i % 2 === 1 ? part : fn(part)))
1938
+ .join("");
1939
+
1940
+ // Make the page's own prose MDX-safe, skipping code, generated tags, and
1941
+ // blocks left as written.
1942
+ const mdxPass = (lines, ctx) => {
1943
+ let fence = null;
1944
+ let math = false;
1945
+ const texts = lines.map((item) => item.text);
1946
+ return lines.map((item, index) => {
1947
+ if (fence) {
1948
+ fence = isFenceClose(item.text, fence) ? null : fence;
1949
+ return item;
1950
+ }
1951
+ if (math || MATH_OPEN_RE.test(item.text)) {
1952
+ math = math ? !MATH_CLOSE_RE.test(item.text) : true;
1953
+ return item;
1954
+ }
1955
+ const f = FENCE_RE.exec(item.text);
1956
+ if (f) {
1957
+ fence = { char: f.groups.marker[0], length: f.groups.marker.length };
1958
+ return item;
1959
+ }
1960
+ if (!item.src) {
1961
+ return item;
1962
+ }
1963
+ const rest = texts.slice(index + 1).join("\n");
1964
+ return {
1965
+ ...item,
1966
+ text: outsideCode(item.text, (part) =>
1967
+ outsideMath(part, (prose) => escapeSegment(prose, ctx, item.line, rest))
1968
+ ),
1969
+ };
1970
+ });
1971
+ };
1972
+
1973
+ // --- Frontmatter --------------------------------------------------------------------
1974
+
1975
+ // Top-level frontmatter keys, each with its lines.
1976
+ const fmBlocks = (fm) => {
1977
+ const blocks = [];
1978
+ for (const line of fm) {
1979
+ const key = /^(?<key>[A-Za-z_][\w-]*):(?<value>.*)$/u.exec(line);
1980
+ if (key) {
1981
+ blocks.push({
1982
+ key: key.groups.key,
1983
+ lines: [line],
1984
+ value: key.groups.value.trim(),
1985
+ });
1986
+ } else if (blocks.length > 0 && line.trim() !== "") {
1987
+ blocks.at(-1).lines.push(line);
1988
+ } else {
1989
+ blocks.push({ key: null, lines: [line], value: "" });
1990
+ }
1991
+ }
1992
+ return blocks;
1993
+ };
1994
+
1995
+ const convertNavigation = (block, ctx) => {
1996
+ if (block.value === "false") {
1997
+ ctx.edit("navigation: false → sidebar.hidden");
1998
+ return [" hidden: true"];
1999
+ }
2000
+ if (block.value !== "" && block.value !== "true") {
2001
+ ctx.review(
2002
+ 1,
2003
+ "`navigation` is written inline: map it to `sidebar` by hand"
2004
+ );
2005
+ return null;
2006
+ }
2007
+ const lines = [];
2008
+ for (const line of block.lines.slice(1)) {
2009
+ const m = /^[ \t]+(?<key>[\w-]+):[ \t]*(?<value>.*)$/u.exec(line);
2010
+ if (!m) {
2011
+ ctx.review(1, "`navigation` has a value this can't read");
2012
+ return null;
2013
+ }
2014
+ const { key, value } = m.groups;
2015
+ if (key === "title") {
2016
+ ctx.edit("navigation.title → sidebar.label");
2017
+ lines.push(` label: ${value}`);
2018
+ } else if (key === "icon") {
2019
+ const icon = lucideName(unquote(value), ctx, 1, "`navigation.icon`");
2020
+ ctx.edit("navigation.icon → sidebar.icon");
2021
+ lines.push(` icon: ${icon ?? value}`);
2022
+ } else if (key === "description") {
2023
+ ctx.edit("navigation.description removed");
2024
+ } else {
2025
+ ctx.review(1, `\`navigation.${key}\` has no Blume equivalent: dropped`);
2026
+ }
2027
+ }
2028
+ return lines;
2029
+ };
2030
+
2031
+ // `links` items → `related` entries (`- "Label": /to`).
2032
+ const convertLinks = (block, ctx) => {
2033
+ if (block.value !== "" && block.value !== "[]") {
2034
+ ctx.review(1, "`links` is written inline: map it to `related` by hand");
2035
+ return null;
2036
+ }
2037
+ const items = [];
2038
+ for (const line of block.lines.slice(1)) {
2039
+ const item = /^[ \t]*-[ \t]+(?<key>[\w-]+):[ \t]*(?<value>.*)$/u.exec(line);
2040
+ const field = /^[ \t]+(?<key>[\w-]+):[ \t]*(?<value>.*)$/u.exec(line);
2041
+ if (item) {
2042
+ items.push({ [item.groups.key]: unquote(item.groups.value) });
2043
+ } else if (field && items.length > 0) {
2044
+ items.at(-1)[field.groups.key] = unquote(field.groups.value);
2045
+ } else {
2046
+ ctx.review(1, "`links` has a value this can't read: map it by hand");
2047
+ return null;
2048
+ }
2049
+ }
2050
+ const lines = [];
2051
+ for (const item of items) {
2052
+ if (!item.to) {
2053
+ ctx.review(1, "a `links` item has no `to`: dropped");
2054
+ continue;
2055
+ }
2056
+ if (!/^(?:\/|https?:\/\/)/u.test(item.to)) {
2057
+ ctx.review(
2058
+ 1,
2059
+ `\`links\` target \`${item.to}\`: Blume's \`related\` takes a root-relative path or a URL, so rewrite it`
2060
+ );
2061
+ }
2062
+ lines.push(
2063
+ item.label
2064
+ ? ` - ${JSON.stringify(item.label)}: ${yamlScalar(item.to)}`
2065
+ : ` - ${yamlScalar(item.to)}`
2066
+ );
2067
+ }
2068
+ if (lines.length > MAX_RELATED) {
2069
+ ctx.review(
2070
+ 1,
2071
+ `\`links\` has ${lines.length} entries and Blume's \`related\` holds at most ${MAX_RELATED}: keep the ones that matter, and link the rest from the page`
2072
+ );
2073
+ }
2074
+ ctx.edit("links → related", lines.length);
2075
+ return lines;
2076
+ };
2077
+
2078
+ const convertSeo = (block, ctx) =>
2079
+ block.lines.map((line, i) => {
2080
+ const m = /^(?<indent>[ \t]+)(?<key>[\w-]+):(?<value>.*)$/u.exec(line);
2081
+ if (i === 0 || !m) {
2082
+ return line;
2083
+ }
2084
+ if (m.groups.key === "ogImage") {
2085
+ ctx.edit("seo.ogImage → seo.image");
2086
+ return `${m.groups.indent}image:${m.groups.value}`;
2087
+ }
2088
+ if (!SEO_KEYS.has(m.groups.key)) {
2089
+ ctx.review(
2090
+ 1,
2091
+ `\`seo.${m.groups.key}\` isn't a Blume key: map or drop it`
2092
+ );
2093
+ }
2094
+ return line;
2095
+ });
2096
+
2097
+ // A converted key's lines under its new name: none when nothing carried over,
2098
+ // the original lines when the conversion gave up (null).
2099
+ const replacement = (block, key, lines) => {
2100
+ if (lines === null) {
2101
+ return block.lines;
2102
+ }
2103
+ return lines.length > 0 ? [`${key}:`, ...lines] : [];
2104
+ };
2105
+
2106
+ const convertFrontmatter = (fm, ctx) => {
2107
+ const blocks = fmBlocks(fm);
2108
+ const output = [];
2109
+ const keys = new Set(blocks.map((block) => block.key));
2110
+ for (const block of blocks) {
2111
+ switch (block.key) {
2112
+ case "navigation": {
2113
+ if (keys.has("sidebar")) {
2114
+ ctx.review(1, "both `navigation` and `sidebar`: merge them by hand");
2115
+ output.push(...block.lines);
2116
+ break;
2117
+ }
2118
+ output.push(
2119
+ ...replacement(block, "sidebar", convertNavigation(block, ctx))
2120
+ );
2121
+ break;
2122
+ }
2123
+ case "links": {
2124
+ output.push(...replacement(block, "related", convertLinks(block, ctx)));
2125
+ break;
2126
+ }
2127
+ case "seo": {
2128
+ output.push(...convertSeo(block, ctx));
2129
+ break;
2130
+ }
2131
+ case "sitemap": {
2132
+ ctx.review(
2133
+ 1,
2134
+ "`sitemap` removed: Blume has no sitemap-only switch; add `seo: { noindex: true }` if the page should leave search engines"
2135
+ );
2136
+ break;
2137
+ }
2138
+ case "icon": {
2139
+ ctx.review(
2140
+ 1,
2141
+ "top-level `icon`: Docus ignored it, and Blume shows it in the sidebar; make it a Lucide name or remove it"
2142
+ );
2143
+ output.push(...block.lines);
2144
+ break;
2145
+ }
2146
+ case "layout": {
2147
+ if (block.value === "docs") {
2148
+ ctx.edit("layout: docs removed");
2149
+ } else {
2150
+ ctx.review(
2151
+ 1,
2152
+ `\`layout: ${block.value}\`: pick a Blume \`mode\` and remove it`
2153
+ );
2154
+ output.push(...block.lines);
2155
+ }
2156
+ break;
2157
+ }
2158
+ default: {
2159
+ if (block.key !== null && !BLUME_KEYS.has(block.key)) {
2160
+ ctx.review(
2161
+ 1,
2162
+ `frontmatter \`${block.key}\` isn't a Blume key: map or drop it`
2163
+ );
2164
+ }
2165
+ output.push(...block.lines);
2166
+ }
2167
+ }
2168
+ }
2169
+ return output;
2170
+ };
2171
+
2172
+ // A body `# H1` → frontmatter `title`, when the page has none.
2173
+ const moveH1 = (fm, body, offset, ctx) => {
2174
+ const first = body.findIndex((line) => line.trim() !== "");
2175
+ const h1 =
2176
+ first === -1
2177
+ ? null
2178
+ : /^#[ \t]+(?<text>.+?)[ \t]*#*[ \t]*$/u.exec(body[first]);
2179
+ if (!h1) {
2180
+ return { body, fm };
2181
+ }
2182
+ const hasTitle = fm.some((line) => line.startsWith("title:"));
2183
+ const { text } = h1.groups;
2184
+ if (hasTitle || /[`*_[\]<{]/u.test(text)) {
2185
+ ctx.review(
2186
+ offset + first + 1,
2187
+ hasTitle
2188
+ ? "body H1 beside a frontmatter title: Docus showed both, so delete one"
2189
+ : "body H1 has inline markup: move it into `title` by hand"
2190
+ );
2191
+ return { body, fm };
2192
+ }
2193
+ ctx.edit("body H1 → title");
2194
+ const rest = body.slice(first + 1);
2195
+ while (rest.length > 0 && rest[0].trim() === "") {
2196
+ rest.shift();
2197
+ }
2198
+ // One blank line between the frontmatter and what follows.
2199
+ return {
2200
+ body: rest.length > 0 ? ["", ...rest] : rest,
2201
+ fm: [`title: ${yamlScalar(text)}`, ...fm],
2202
+ };
2203
+ };
2204
+
2205
+ // --- Files ---------------------------------------------------------------------------
2206
+
2207
+ const SKIP_DIRS = new Set(["node_modules", "public"]);
2208
+
2209
+ const collectFiles = (root) => {
2210
+ const pages = [];
2211
+ const navs = [];
2212
+ const walk = (dir) => {
2213
+ for (const name of readdirSync(dir).toSorted()) {
2214
+ const abs = path.join(dir, name);
2215
+ const rel = path.relative(root, abs).split(path.sep).join("/");
2216
+ if (statSync(abs).isDirectory()) {
2217
+ if (!(name.startsWith(".") || SKIP_DIRS.has(name))) {
2218
+ walk(abs);
2219
+ }
2220
+ } else if (name === ".navigation.yml" || name === ".navigation.yaml") {
2221
+ navs.push(rel);
2222
+ } else if (/\.mdx?$/u.test(name)) {
2223
+ pages.push(rel);
2224
+ }
2225
+ }
2226
+ };
2227
+ walk(root);
2228
+ return { navs, pages };
2229
+ };
2230
+
2231
+ const newReport = () => {
2232
+ const edits = new Map();
2233
+ const reviews = [];
2234
+ return {
2235
+ edit: (kind, count = 1) => {
2236
+ if (count > 0) {
2237
+ edits.set(kind, (edits.get(kind) ?? 0) + count);
2238
+ }
2239
+ },
2240
+ edits,
2241
+ review: (line, message) => reviews.push({ line, message }),
2242
+ reviews,
2243
+ };
2244
+ };
2245
+
2246
+ // Route segments Docus and Blume map differently.
2247
+ const reviewRoute = (file, report) => {
2248
+ for (const segment of file.replace(/\.mdx?$/u, "").split("/")) {
2249
+ if (/^\d+[-_]/u.test(segment)) {
2250
+ report.review(
2251
+ 1,
2252
+ `\`${segment}\`: Docus keeps a dash or underscore prefix in the URL and Blume strips it; pin \`slug\` or add a redirect`
2253
+ );
2254
+ } else if (/^\d+(?:\.\d+)*\.(?:\d+|x)$/u.test(segment)) {
2255
+ report.review(
2256
+ 1,
2257
+ `\`${segment}\`: Docus keeps a version name whole and Blume strips \`${segment.split(".")[0]}.\`; pin \`slug\``
2258
+ );
2259
+ } else if (segment.startsWith("_")) {
2260
+ report.review(
2261
+ 1,
2262
+ `\`${segment}\`: Docus publishes \`_\` files and Blume excludes them; rename it`
2263
+ );
2264
+ } else if (/[A-Z\s]/u.test(segment)) {
2265
+ report.review(
2266
+ 1,
2267
+ `\`${segment}\`: Docus lowercased and slugified this name in the URL; rename the file`
2268
+ );
2269
+ }
2270
+ }
2271
+ };
2272
+
2273
+ const convertPage = (file, project) => {
2274
+ const report = newReport();
2275
+ const before = readFileSync(path.join(project.root, file), "utf-8");
2276
+ reviewRoute(file, report);
2277
+ if (file.endsWith(".mdx")) {
2278
+ return { before, file, report, skipped: true };
2279
+ }
2280
+ // Pages checked out on Windows have CRLF endings: convert on LF, write CRLF.
2281
+ const crlf = before.includes("\r\n");
2282
+ const source = crlf ? before.replaceAll("\r\n", "\n") : before;
2283
+ const all = source.split("\n");
2284
+ let fm = null;
2285
+ let bodyStart = 0;
2286
+ if (all[0] === "---") {
2287
+ const end = all.indexOf("---", 1);
2288
+ if (end !== -1) {
2289
+ fm = all.slice(1, end);
2290
+ bodyStart = end + 1;
2291
+ }
2292
+ }
2293
+ let mdx = false;
2294
+ const ctx = {
2295
+ edit: report.edit,
2296
+ lucide: project.lucide,
2297
+ mdx: () => {
2298
+ mdx = true;
2299
+ },
2300
+ renderChildren,
2301
+ review: report.review,
2302
+ };
2303
+ let fmOut = fm === null ? null : convertFrontmatter(fm, ctx);
2304
+ const moved = moveH1(fmOut ?? [], all.slice(bodyStart), bodyStart, ctx);
2305
+ const removed = all.length - bodyStart - moved.body.length;
2306
+ fmOut = fm === null && moved.fm.length === 0 ? null : moved.fm;
2307
+ ctx.lines = moved.body;
2308
+ ctx.toLine = (i) => bodyStart + removed + i + 1;
2309
+ const tree = parseBody(moved.body, ctx.toLine, report.review);
2310
+ markBlocks(tree, ctx);
2311
+ let lines = renderChildren(tree.sections[0].children, ctx);
2312
+ if (mdx) {
2313
+ lines = mdxPass(lines, ctx);
2314
+ }
2315
+ const text = [
2316
+ ...(fmOut === null ? [] : ["---", ...fmOut, "---"]),
2317
+ ...lines.map((item) => item.text),
2318
+ ].join("\n");
2319
+ const lf = source.endsWith("\n") && !text.endsWith("\n") ? `${text}\n` : text;
2320
+ const after = crlf ? lf.replaceAll("\n", "\r\n") : lf;
2321
+ return { after, before, file, mdx, report };
2322
+ };
2323
+
2324
+ const metaFor = (title, icon) => {
2325
+ const fields = [
2326
+ ...(title ? [` title: ${JSON.stringify(title)},`] : []),
2327
+ ...(icon ? [` icon: ${JSON.stringify(icon)},`] : []),
2328
+ ];
2329
+ return [
2330
+ 'import { defineMeta } from "blume";',
2331
+ "",
2332
+ "export default defineMeta({",
2333
+ ...fields,
2334
+ "});",
2335
+ "",
2336
+ ].join("\n");
2337
+ };
2338
+
2339
+ // Why a `.navigation.yml` line can't move to `meta.ts`.
2340
+ const navReview = (key, value, line) =>
2341
+ key === "navigation" && value === "false"
2342
+ ? "`navigation: false` hides the folder: set `sidebar.hidden: true` on each of its pages"
2343
+ : `\`${line.trim()}\` has no meta.ts equivalent`;
2344
+
2345
+ // A `.navigation.yml`'s `title` and `icon`, and whether it held nothing else.
2346
+ // Nuxt Content reads them at the top level or under a `navigation:` map,
2347
+ // which wins.
2348
+ const readNav = (text, ctx) => {
2349
+ const fields = { nav: {}, top: {} };
2350
+ let complete = true;
2351
+ let nested = false;
2352
+ for (const [index, line] of text.split("\n").entries()) {
2353
+ const m = /^(?<indent>[ \t]*)(?<key>[\w-]+):[ \t]*(?<value>.*)$/u.exec(
2354
+ line
2355
+ );
2356
+ if (line.trim() === "" || line.trim().startsWith("#")) {
2357
+ continue;
2358
+ }
2359
+ const top = m?.groups.indent === "";
2360
+ nested = top
2361
+ ? m.groups.key === "navigation" && m.groups.value === ""
2362
+ : nested;
2363
+ const key = m && (top || nested) ? m.groups.key : null;
2364
+ const value = m ? yamlPlain(m.groups.value) : "";
2365
+ const into = top ? fields.top : fields.nav;
2366
+ if (key === "title") {
2367
+ into.title = value;
2368
+ } else if (key === "icon") {
2369
+ into.icon = lucideName(value, ctx, index + 1, "folder");
2370
+ complete &&= into.icon !== null;
2371
+ } else if (!(top && nested)) {
2372
+ complete = false;
2373
+ ctx.review(index + 1, navReview(key, value, line));
2374
+ }
2375
+ }
2376
+ return {
2377
+ complete,
2378
+ icon: fields.nav.icon ?? fields.top.icon,
2379
+ title: fields.nav.title ?? fields.top.title,
2380
+ };
2381
+ };
2382
+
2383
+ const convertNav = (file, project) => {
2384
+ const report = newReport();
2385
+ const dir = path.posix.dirname(file);
2386
+ const target = `${dir === "." ? "" : `${dir}/`}meta.ts`;
2387
+ const text = readFileSync(path.join(project.root, file), "utf-8").replaceAll(
2388
+ "\r\n",
2389
+ "\n"
2390
+ );
2391
+ if (existsSync(path.join(project.root, target))) {
2392
+ report.review(
2393
+ 1,
2394
+ `${target} already exists: merge this file into it by hand`
2395
+ );
2396
+ return { file, report, target: null };
2397
+ }
2398
+ const { complete, icon, title } = readNav(text, {
2399
+ edit: report.edit,
2400
+ lucide: project.lucide,
2401
+ review: report.review,
2402
+ });
2403
+ report.edit(".navigation.yml → meta.ts");
2404
+ return {
2405
+ complete,
2406
+ file,
2407
+ meta: metaFor(title, icon),
2408
+ report,
2409
+ target,
2410
+ };
2411
+ };
2412
+
2413
+ // `x.md` → `x.mdx` when the page now needs MDX and the name is free.
2414
+ const renameTarget = (page, pages) => {
2415
+ if (!page.mdx) {
2416
+ return page.file;
2417
+ }
2418
+ const wanted = `${page.file}x`;
2419
+ if (pages.includes(wanted)) {
2420
+ page.report.review(1, `${wanted} already exists: not renamed`);
2421
+ return page.file;
2422
+ }
2423
+ return wanted;
2424
+ };
2425
+
2426
+ const finish = (result) => ({
2427
+ edits: Object.fromEntries([...result.report.edits].toSorted()),
2428
+ reviews: result.report.reviews.toSorted((a, b) => a.line - b.line),
2429
+ });
2430
+
2431
+ const run = (root, write, lucidePath) => {
2432
+ const lucide = loadLucide(root, lucidePath);
2433
+ const project = { lucide, root };
2434
+ const { navs, pages } = collectFiles(root);
2435
+ const results = [];
2436
+ for (const file of pages) {
2437
+ const page = convertPage(file, project);
2438
+ if (page.skipped) {
2439
+ if (page.report.reviews.length > 0) {
2440
+ results.push({ changed: false, file, ...finish(page) });
2441
+ }
2442
+ continue;
2443
+ }
2444
+ const target = renameTarget(page, pages);
2445
+ const changed = page.after !== page.before || target !== file;
2446
+ if (write && page.after !== page.before) {
2447
+ writeFileSync(path.join(root, file), page.after, "utf-8");
2448
+ }
2449
+ if (write && target !== file) {
2450
+ renameSync(path.join(root, file), path.join(root, target));
2451
+ }
2452
+ if (changed || page.report.reviews.length > 0) {
2453
+ results.push({
2454
+ changed,
2455
+ file,
2456
+ renamed: target === file ? undefined : target,
2457
+ ...finish(page),
2458
+ });
2459
+ }
2460
+ }
2461
+ for (const file of navs) {
2462
+ const nav = convertNav(file, project);
2463
+ if (write && nav.target) {
2464
+ writeFileSync(path.join(root, nav.target), nav.meta, "utf-8");
2465
+ if (nav.complete) {
2466
+ unlinkSync(path.join(root, file));
2467
+ }
2468
+ }
2469
+ results.push({
2470
+ changed: nav.target !== null,
2471
+ file,
2472
+ renamed: nav.target ?? undefined,
2473
+ ...finish(nav),
2474
+ });
2475
+ }
2476
+ return { lucide: lucide !== null, results };
2477
+ };
2478
+
2479
+ const HELP = [
2480
+ "docus-codemod — the mechanical part of a Docus → Blume migration",
2481
+ "",
2482
+ " node docus-codemod.mjs <content-dir> dry run (report only)",
2483
+ " node docus-codemod.mjs --write <content-dir> apply in place",
2484
+ " node docus-codemod.mjs --json <content-dir> JSON report",
2485
+ " node docus-codemod.mjs --lucide <icons.json> <dir> check icon names",
2486
+ "",
2487
+ ].join("\n");
2488
+
2489
+ const main = () => {
2490
+ const argv = process.argv.slice(2);
2491
+ const lucideAt = argv.indexOf("--lucide");
2492
+ const lucidePath = lucideAt === -1 ? null : argv[lucideAt + 1];
2493
+ const dir = argv.find(
2494
+ (arg, i) =>
2495
+ !arg.startsWith("--") && !(lucideAt !== -1 && i === lucideAt + 1)
2496
+ );
2497
+ if (argv.includes("--help") || argv.includes("-h") || !dir) {
2498
+ process.stdout.write(`${HELP}\n`);
2499
+ return;
2500
+ }
2501
+ const write = argv.includes("--write");
2502
+ const { lucide, results } = run(path.resolve(dir), write, lucidePath);
2503
+ if (argv.includes("--json")) {
2504
+ process.stdout.write(
2505
+ `${JSON.stringify({ files: results, iconsChecked: lucide, wrote: write }, null, 2)}\n`
2506
+ );
2507
+ return;
2508
+ }
2509
+ const totals = new Map();
2510
+ let reviewCount = 0;
2511
+ for (const result of results) {
2512
+ const name = result.renamed
2513
+ ? `${result.file} → ${result.renamed}`
2514
+ : result.file;
2515
+ process.stdout.write(`\n${name}\n`);
2516
+ for (const [kind, count] of Object.entries(result.edits)) {
2517
+ process.stdout.write(` ${count} × ${kind}\n`);
2518
+ totals.set(kind, (totals.get(kind) ?? 0) + count);
2519
+ }
2520
+ for (const { line, message } of result.reviews) {
2521
+ process.stdout.write(` REVIEW line ${line}: ${message}\n`);
2522
+ }
2523
+ reviewCount += result.reviews.length;
2524
+ }
2525
+ process.stdout.write("\nTotals\n");
2526
+ for (const [kind, count] of [...totals].toSorted()) {
2527
+ process.stdout.write(` ${count} × ${kind}\n`);
2528
+ }
2529
+ if (!lucide) {
2530
+ process.stdout.write(
2531
+ "\nIcon names weren't checked: no @iconify-json/lucide found (install blume, or pass --lucide <icons.json>).\n"
2532
+ );
2533
+ }
2534
+ const changed = results.filter((result) => result.changed).length;
2535
+ const verb = write ? "changed" : "would change";
2536
+ const hint = write ? "" : " Re-run with --write to apply.";
2537
+ process.stdout.write(
2538
+ `\n${results.length} file(s) with findings, ${changed} ${verb}, ${reviewCount} item(s) to review.${hint}\n`
2539
+ );
2540
+ };
2541
+
2542
+ main();