blume 2.1.3 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (397) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +134 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-y4msrwm9.js → chunk-080mfch6.js} +15 -15
  5. package/dist/cli/{chunk-gfc5fqvf.js → chunk-1ch3ct3r.js} +18 -18
  6. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-1ch3ct3r.js.map} +1 -1
  7. package/dist/cli/{chunk-32khwcb6.js → chunk-2ssh60qh.js} +42 -69
  8. package/dist/cli/{chunk-32khwcb6.js.map → chunk-2ssh60qh.js.map} +9 -10
  9. package/dist/cli/{chunk-9qzgg6mj.js → chunk-34wap3ed.js} +3 -3
  10. package/dist/cli/{chunk-k3gr1nm9.js → chunk-40vk8kgf.js} +13 -16
  11. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-40vk8kgf.js.map} +3 -3
  12. package/dist/cli/chunk-487b95qy.js +329 -0
  13. package/dist/cli/chunk-487b95qy.js.map +13 -0
  14. package/dist/cli/chunk-4zxh25hc.js +157 -0
  15. package/dist/cli/chunk-4zxh25hc.js.map +10 -0
  16. package/dist/cli/chunk-69rjf786.js +146 -0
  17. package/dist/cli/chunk-69rjf786.js.map +11 -0
  18. package/dist/cli/{chunk-dqhg54mr.js → chunk-9h8mh2tk.js} +3 -3
  19. package/dist/cli/{chunk-dqhg54mr.js.map → chunk-9h8mh2tk.js.map} +2 -2
  20. package/dist/cli/{chunk-bqxaqn5p.js → chunk-9jqmq5hv.js} +8 -8
  21. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-9jqmq5hv.js.map} +1 -1
  22. package/dist/cli/{chunk-dv2z0rzf.js → chunk-aafhwx74.js} +38 -13
  23. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-aafhwx74.js.map} +5 -4
  24. package/dist/cli/{chunk-desvm72z.js → chunk-b680sv30.js} +22 -26
  25. package/dist/cli/chunk-b680sv30.js.map +13 -0
  26. package/dist/cli/{chunk-0w6hs479.js → chunk-beb0z983.js} +30 -18
  27. package/dist/cli/chunk-beb0z983.js.map +10 -0
  28. package/dist/cli/{chunk-1f882900.js → chunk-c5bjxq8v.js} +2 -2
  29. package/dist/cli/chunk-e1n41w9m.js +155 -0
  30. package/dist/cli/chunk-e1n41w9m.js.map +11 -0
  31. package/dist/cli/{chunk-sc93apfs.js → chunk-etckvbrh.js} +17 -17
  32. package/dist/cli/{chunk-sc93apfs.js.map → chunk-etckvbrh.js.map} +2 -2
  33. package/dist/cli/{chunk-bxwhtvw8.js → chunk-f260jdrn.js} +5 -5
  34. package/dist/cli/{chunk-74wfhv79.js → chunk-fnxgtjeq.js} +1 -1
  35. package/dist/cli/{chunk-ms9z66nz.js → chunk-gwy55cx3.js} +2 -2
  36. package/dist/cli/{chunk-ms9z66nz.js.map → chunk-gwy55cx3.js.map} +2 -2
  37. package/dist/cli/{chunk-dhwtqnnd.js → chunk-gxhcf2hs.js} +2 -2
  38. package/dist/cli/{chunk-tfycddbt.js → chunk-h0qwdbem.js} +2 -2
  39. package/dist/cli/{chunk-nbyvgnnm.js → chunk-hktq4j5r.js} +109 -49
  40. package/dist/cli/chunk-hktq4j5r.js.map +36 -0
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-hn3g2b89.js} +14 -10
  42. package/dist/cli/chunk-hn3g2b89.js.map +10 -0
  43. package/dist/cli/{chunk-j1x92mx9.js → chunk-j4nfgn2j.js} +1 -1
  44. package/dist/cli/{chunk-dpy8qdjf.js → chunk-k45sq16q.js} +74 -183
  45. package/dist/cli/chunk-k45sq16q.js.map +18 -0
  46. package/dist/cli/{chunk-5cmts6dv.js → chunk-kmswjxj9.js} +7 -7
  47. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-kmswjxj9.js.map} +1 -1
  48. package/dist/cli/{chunk-36bxs9c7.js → chunk-nqna8dzp.js} +27 -23
  49. package/dist/cli/chunk-nqna8dzp.js.map +10 -0
  50. package/dist/cli/{chunk-a57yh0h9.js → chunk-p834rkcn.js} +7 -7
  51. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-p834rkcn.js.map} +1 -1
  52. package/dist/cli/{chunk-60hz265r.js → chunk-ppa6mg4d.js} +24 -11
  53. package/dist/cli/chunk-ppa6mg4d.js.map +10 -0
  54. package/dist/cli/{chunk-s0bdnc7v.js → chunk-q1wwrxzw.js} +5 -4
  55. package/dist/cli/chunk-q1wwrxzw.js.map +10 -0
  56. package/dist/cli/{chunk-ave1wcbr.js → chunk-qcyxkj44.js} +1 -1
  57. package/dist/cli/{chunk-124fvmzq.js → chunk-r5nm9w0c.js} +11183 -8806
  58. package/dist/cli/{chunk-124fvmzq.js.map → chunk-r5nm9w0c.js.map} +82 -66
  59. package/dist/cli/{chunk-ckrcm08h.js → chunk-rtvgmg2j.js} +48 -62
  60. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-rtvgmg2j.js.map} +4 -5
  61. package/dist/cli/{chunk-297ajdcv.js → chunk-t2h8pwy1.js} +6 -6
  62. package/dist/cli/{chunk-297ajdcv.js.map → chunk-t2h8pwy1.js.map} +3 -3
  63. package/dist/cli/{chunk-gxtg6ed2.js → chunk-x60e15st.js} +21 -21
  64. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-x60e15st.js.map} +3 -3
  65. package/dist/cli/{chunk-58bt3hag.js → chunk-x7bh6d9j.js} +4 -4
  66. package/dist/cli/{chunk-hvcxng5f.js → chunk-ydqzkhjn.js} +6 -6
  67. package/dist/cli/{chunk-kdpzw38a.js → chunk-yg8ckc3g.js} +95 -7
  68. package/dist/cli/chunk-yg8ckc3g.js.map +11 -0
  69. package/dist/cli/{chunk-0pmb4tr4.js → chunk-zxd5jrgx.js} +1205 -222
  70. package/dist/cli/chunk-zxd5jrgx.js.map +44 -0
  71. package/dist/cli/index.js +78 -42
  72. package/dist/cli/index.js.map +7 -6
  73. package/dist/types/ai/changelog-components.d.ts +18 -0
  74. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  75. package/dist/types/ai/openapi-components.d.ts +7 -7
  76. package/dist/types/ai/operation-details.d.ts +39 -0
  77. package/dist/types/ai/relative-links.d.ts +3 -1
  78. package/dist/types/ai/serializers.d.ts +6 -4
  79. package/dist/types/analytics/head.d.ts +4 -2
  80. package/dist/types/analytics/script.d.ts +8 -2
  81. package/dist/types/components/content/youtube.d.ts +20 -0
  82. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  83. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  84. package/dist/types/components/openapi/helpers.d.ts +53 -11
  85. package/dist/types/components/openapi/snippets.d.ts +7 -1
  86. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  87. package/dist/types/core/builtin-tags.d.ts +16 -0
  88. package/dist/types/core/changelog-list.d.ts +48 -0
  89. package/dist/types/core/closest-match.d.ts +6 -0
  90. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  91. package/dist/types/core/code-fences.d.ts +8 -1
  92. package/dist/types/core/config-input.d.ts +38 -6
  93. package/dist/types/core/content-assets.d.ts +16 -18
  94. package/dist/types/core/data.d.ts +6 -2
  95. package/dist/types/core/diagnostics.d.ts +14 -0
  96. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  97. package/dist/types/core/former-routes.d.ts +14 -0
  98. package/dist/types/core/github.d.ts +5 -4
  99. package/dist/types/core/heading-markers.d.ts +65 -2
  100. package/dist/types/core/hidden-pages.d.ts +3 -0
  101. package/dist/types/core/includes.d.ts +23 -0
  102. package/dist/types/core/last-modified.d.ts +18 -6
  103. package/dist/types/core/links.d.ts +42 -2
  104. package/dist/types/core/load-module.d.ts +19 -4
  105. package/dist/types/core/manifest.d.ts +14 -1
  106. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  107. package/dist/types/core/mcp-clients.d.ts +47 -0
  108. package/dist/types/core/mdx-elements.d.ts +32 -0
  109. package/dist/types/core/mdx-syntax.d.ts +100 -0
  110. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  111. package/dist/types/core/meta.d.ts +32 -5
  112. package/dist/types/core/nav-diagnostics.d.ts +16 -2
  113. package/dist/types/core/navigation.d.ts +13 -0
  114. package/dist/types/core/ordering-prefix.d.ts +6 -0
  115. package/dist/types/core/pinned-ids.d.ts +23 -0
  116. package/dist/types/core/project-graph.d.ts +9 -1
  117. package/dist/types/core/redirect-patterns.d.ts +18 -0
  118. package/dist/types/core/relative-files.d.ts +31 -0
  119. package/dist/types/core/schema.d.ts +322 -11
  120. package/dist/types/core/sources/normalize.d.ts +101 -9
  121. package/dist/types/core/sources/types.d.ts +16 -0
  122. package/dist/types/core/static-files.d.ts +10 -0
  123. package/dist/types/core/static-host.d.ts +42 -0
  124. package/dist/types/core/svg-images.d.ts +26 -0
  125. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  126. package/dist/types/core/types.d.ts +42 -1
  127. package/dist/types/deploy/generated-files.d.ts +41 -0
  128. package/dist/types/deploy/redirects.d.ts +21 -7
  129. package/dist/types/markdown/code-title.d.ts +9 -5
  130. package/dist/types/markdown/directives.d.ts +8 -0
  131. package/dist/types/markdown/fence-language.d.ts +51 -0
  132. package/dist/types/markdown/fence-meta.d.ts +5 -0
  133. package/dist/types/markdown/github-alerts.d.ts +35 -0
  134. package/dist/types/openapi/asyncapi.d.ts +6 -5
  135. package/dist/types/openapi/checks.d.ts +10 -5
  136. package/dist/types/openapi/model.d.ts +55 -8
  137. package/dist/types/openapi/parse.d.ts +4 -1
  138. package/dist/types/ratelimit/index.d.ts +2 -0
  139. package/dist/types/ratelimit/memory.d.ts +1 -1
  140. package/dist/types/ratelimit/schema.d.ts +40 -0
  141. package/dist/types/ratelimit/unkey.d.ts +48 -0
  142. package/dist/types/sources/filesystem.d.ts +14 -4
  143. package/dist/types/sources/mdx-remote.d.ts +6 -3
  144. package/dist/types/sources/registry.d.ts +3 -3
  145. package/dist/types/theme/color.d.ts +33 -0
  146. package/dist/types/theme/palette.d.ts +122 -0
  147. package/docs/01-quickstart.mdx +1 -1
  148. package/docs/02-deployment.mdx +24 -8
  149. package/docs/04-migrating.mdx +16 -4
  150. package/docs/advanced/changelog.mdx +27 -1
  151. package/docs/advanced/skills.mdx +1 -1
  152. package/docs/cli/audit.mdx +1 -1
  153. package/docs/cli/doctor.mdx +2 -2
  154. package/docs/cli/evals.mdx +1 -1
  155. package/docs/cli/index.mdx +4 -2
  156. package/docs/cli/validate.mdx +10 -7
  157. package/docs/configuration/analytics.mdx +2 -0
  158. package/docs/configuration/customization.mdx +3 -1
  159. package/docs/configuration/index.mdx +7 -6
  160. package/docs/configuration/rate-limiting.mdx +20 -1
  161. package/docs/configuration/theming.mdx +5 -1
  162. package/docs/content/components.mdx +26 -6
  163. package/docs/content/frontmatter.mdx +1 -1
  164. package/docs/content/i18n.mdx +1 -1
  165. package/docs/content/includes.mdx +6 -2
  166. package/docs/content/index.mdx +11 -1
  167. package/docs/content/islands.mdx +2 -0
  168. package/docs/content/meta.mdx +9 -3
  169. package/docs/content/navigation.mdx +14 -6
  170. package/docs/content/sources/obsidian.mdx +1 -1
  171. package/docs/content/sources/remote-mdx.mdx +3 -1
  172. package/docs/content/syntax.mdx +76 -8
  173. package/docs/content/variables.mdx +3 -1
  174. package/docs/discoverability/agent-discovery.mdx +1 -1
  175. package/docs/discoverability/json-api.mdx +1 -1
  176. package/docs/discoverability/llms-txt.mdx +1 -1
  177. package/docs/discoverability/markdown.mdx +2 -2
  178. package/docs/discoverability/mcp.mdx +40 -8
  179. package/docs/discoverability/open-graph.mdx +1 -1
  180. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  181. package/docs/references/graphql.mdx +2 -0
  182. package/docs/references/openapi.mdx +45 -7
  183. package/package.json +4 -4
  184. package/skills/blume/SKILL.md +2 -2
  185. package/skills/blume-migrate/SKILL.md +51 -29
  186. package/skills/blume-migrate/references/docsify.md +262 -0
  187. package/skills/blume-migrate/references/docus.md +298 -0
  188. package/skills/blume-migrate/references/docusaurus.md +5 -5
  189. package/skills/blume-migrate/references/fern.md +327 -0
  190. package/skills/blume-migrate/references/fumadocs.md +1 -1
  191. package/skills/blume-migrate/references/gitbook.md +267 -0
  192. package/skills/blume-migrate/references/github-wiki.md +259 -0
  193. package/skills/blume-migrate/references/jekyll.md +253 -0
  194. package/skills/blume-migrate/references/mdbook.md +256 -0
  195. package/skills/blume-migrate/references/mintlify.md +5 -5
  196. package/skills/blume-migrate/references/mkdocs.md +236 -0
  197. package/skills/blume-migrate/references/monorepo.md +20 -8
  198. package/skills/blume-migrate/references/nextra.md +5 -5
  199. package/skills/blume-migrate/references/readme.md +257 -0
  200. package/skills/blume-migrate/references/redocly.md +198 -0
  201. package/skills/blume-migrate/references/starlight.md +3 -3
  202. package/skills/blume-migrate/references/vitepress.md +204 -0
  203. package/skills/blume-migrate/references/vuepress.md +271 -0
  204. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  205. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  206. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  207. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  208. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  209. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  210. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  211. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  212. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  213. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  214. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  215. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  216. package/src/ai/changelog-components.ts +24 -0
  217. package/src/ai/changelog-markdown.ts +45 -41
  218. package/src/ai/component-markdown.ts +22 -9
  219. package/src/ai/llms.ts +36 -5
  220. package/src/ai/markdown.ts +2 -2
  221. package/src/ai/openapi-components.ts +25 -9
  222. package/src/ai/operation-details.ts +208 -0
  223. package/src/ai/relative-links.ts +48 -8
  224. package/src/ai/serializers.ts +7 -6
  225. package/src/ai/site-skill.ts +6 -2
  226. package/src/analytics/head.ts +11 -5
  227. package/src/analytics/script.ts +13 -3
  228. package/src/astro/generate.ts +47 -11
  229. package/src/astro/index.ts +1 -0
  230. package/src/astro/integration.ts +27 -1
  231. package/src/astro/mdx-source-errors.ts +46 -0
  232. package/src/astro/templates.ts +93 -189
  233. package/src/audit/checks/redirects.ts +5 -1
  234. package/src/audit/redirects.ts +36 -19
  235. package/src/audit/report.ts +22 -9
  236. package/src/audit/run.ts +12 -28
  237. package/src/audit/terms.ts +23 -0
  238. package/src/audit/url.ts +2 -2
  239. package/src/cli/build-failure.ts +256 -7
  240. package/src/cli/command-meta.ts +1 -1
  241. package/src/cli/commands/audit.ts +1 -1
  242. package/src/cli/commands/build.ts +12 -3
  243. package/src/cli/commands/check.ts +13 -2
  244. package/src/cli/commands/dev.ts +1 -1
  245. package/src/cli/commands/doctor.ts +4 -2
  246. package/src/cli/commands/init.ts +3 -3
  247. package/src/cli/commands/preview.ts +17 -1
  248. package/src/cli/commands/validate.ts +22 -10
  249. package/src/cli/prepare.ts +1 -1
  250. package/src/cli/preview-pages.ts +161 -0
  251. package/src/cli/required-secrets.ts +0 -1
  252. package/src/cli/unknown-flags.ts +1 -42
  253. package/src/components/code-copy.ts +51 -0
  254. package/src/components/code-language.ts +75 -0
  255. package/src/components/content/AccordionItem.astro +16 -1
  256. package/src/components/content/Callout.astro +11 -1
  257. package/src/components/content/Changelog.astro +189 -0
  258. package/src/components/content/CodeBlock.astro +8 -11
  259. package/src/components/content/Tabs.astro +16 -2
  260. package/src/components/content/YouTube.astro +6 -4
  261. package/src/components/content/code-block.ts +33 -0
  262. package/src/components/content/content-strings.ts +5 -1
  263. package/src/components/content/inline-markdown.ts +35 -8
  264. package/src/components/content/youtube.ts +49 -7
  265. package/src/components/layout/Analytics.astro +5 -1
  266. package/src/components/layout/NavTree.astro +8 -3
  267. package/src/components/layout/PageActions.astro +81 -23
  268. package/src/components/layout/RootLayout.astro +35 -32
  269. package/src/components/layout/SiteFooter.astro +31 -8
  270. package/src/components/layout/nav-utils.ts +14 -0
  271. package/src/components/layout/search/pagefind.ts +2 -1
  272. package/src/components/openapi/Operation.astro +4 -0
  273. package/src/components/openapi/PanelTabs.astro +9 -3
  274. package/src/components/openapi/RequestBody.astro +42 -8
  275. package/src/components/openapi/RequestPanel.astro +34 -53
  276. package/src/components/openapi/example-panels.ts +110 -0
  277. package/src/components/openapi/helpers.ts +195 -20
  278. package/src/components/openapi/operation-model.ts +63 -26
  279. package/src/components/openapi/request.ts +13 -0
  280. package/src/components/openapi/snippets.ts +14 -2
  281. package/src/core/alert-diagnostics.ts +89 -0
  282. package/src/core/builtin-tags.ts +76 -0
  283. package/src/core/changelog-list.ts +87 -0
  284. package/src/core/closest-match.ts +41 -0
  285. package/src/core/code-fence-diagnostics.ts +267 -0
  286. package/src/core/code-fences.ts +43 -7
  287. package/src/core/component-diagnostics.ts +65 -12
  288. package/src/core/config-input.ts +39 -5
  289. package/src/core/config.ts +46 -1
  290. package/src/core/content-assets.ts +64 -144
  291. package/src/core/data.ts +10 -3
  292. package/src/core/diagnostics.ts +36 -3
  293. package/src/core/directive-diagnostics.ts +265 -47
  294. package/src/core/former-routes.ts +46 -0
  295. package/src/core/git-ignored.ts +53 -0
  296. package/src/core/github.ts +5 -4
  297. package/src/core/heading-markers.ts +156 -2
  298. package/src/core/hidden-pages.ts +64 -0
  299. package/src/core/includes.ts +181 -41
  300. package/src/core/last-modified.ts +47 -10
  301. package/src/core/links.ts +438 -83
  302. package/src/core/load-module.ts +98 -9
  303. package/src/core/manifest.ts +12 -9
  304. package/src/core/markdown-mirrors.ts +52 -0
  305. package/src/core/mcp-clients.ts +87 -0
  306. package/src/core/mdx-elements.ts +153 -0
  307. package/src/core/mdx-syntax.ts +377 -0
  308. package/src/core/meta-diagnostics.ts +134 -0
  309. package/src/core/meta.ts +154 -55
  310. package/src/core/nav-diagnostics.ts +41 -4
  311. package/src/core/navigation.ts +70 -10
  312. package/src/core/ordering-prefix.ts +22 -2
  313. package/src/core/pinned-ids.ts +141 -0
  314. package/src/core/project-graph.ts +221 -29
  315. package/src/core/redirect-patterns.ts +41 -0
  316. package/src/core/relative-files.ts +145 -0
  317. package/src/core/schema.ts +110 -6
  318. package/src/core/sources/mdx-remote.ts +75 -14
  319. package/src/core/sources/normalize.ts +885 -179
  320. package/src/core/sources/notion.ts +5 -4
  321. package/src/core/sources/obsidian.ts +2 -5
  322. package/src/core/sources/types.ts +17 -0
  323. package/src/core/static-files.ts +40 -0
  324. package/src/core/static-host.ts +125 -0
  325. package/src/core/svg-images.ts +98 -0
  326. package/src/core/syntax-diagnostics.ts +491 -0
  327. package/src/core/types.ts +43 -1
  328. package/src/deploy/artifacts.ts +2 -28
  329. package/src/deploy/generated-files.ts +128 -0
  330. package/src/deploy/platforms/vercel.ts +25 -1
  331. package/src/deploy/redirects.ts +43 -16
  332. package/src/deploy/sitemap.ts +2 -1
  333. package/src/markdown/base-links.ts +87 -6
  334. package/src/markdown/code-title.ts +29 -13
  335. package/src/markdown/content-assets.ts +210 -0
  336. package/src/markdown/directives.ts +69 -5
  337. package/src/markdown/fence-language.ts +106 -0
  338. package/src/markdown/fence-meta.ts +42 -0
  339. package/src/markdown/github-alerts.ts +172 -0
  340. package/src/markdown/heading-anchors.ts +265 -56
  341. package/src/markdown/include.ts +30 -3
  342. package/src/markdown/index.ts +145 -40
  343. package/src/markdown/prompt-text.ts +95 -0
  344. package/src/markdown/relative-links.ts +37 -2
  345. package/src/markdown/route-snapshot.ts +22 -12
  346. package/src/markdown/svg-fallback.ts +51 -0
  347. package/src/markdown/title-heading.ts +134 -0
  348. package/src/migrate/migrate.ts +127 -2
  349. package/src/og/card.ts +14 -4
  350. package/src/openapi/asyncapi.ts +14 -6
  351. package/src/openapi/checks.ts +160 -15
  352. package/src/openapi/model.ts +172 -33
  353. package/src/openapi/parse.ts +13 -4
  354. package/src/openapi/render-mdx.ts +55 -9
  355. package/src/openapi/source.ts +87 -6
  356. package/src/ratelimit/index.ts +2 -0
  357. package/src/ratelimit/memory.ts +1 -1
  358. package/src/ratelimit/runtime.ts +81 -0
  359. package/src/ratelimit/schema.ts +2 -0
  360. package/src/ratelimit/unkey.ts +61 -0
  361. package/src/registry/eject.ts +0 -1
  362. package/src/registry/registry.ts +1 -1
  363. package/src/search/documents.ts +10 -2
  364. package/src/sources/filesystem.ts +28 -3
  365. package/src/sources/mdx-remote.ts +7 -15
  366. package/src/theme/color.ts +14 -0
  367. package/src/theme/entry.ts +22 -1
  368. package/src/theme/palette.ts +74 -1
  369. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  370. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  371. package/dist/cli/chunk-150jk7km.js +0 -52
  372. package/dist/cli/chunk-150jk7km.js.map +0 -10
  373. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  374. package/dist/cli/chunk-53jqw8z0.js +0 -89
  375. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  376. package/dist/cli/chunk-60hz265r.js.map +0 -10
  377. package/dist/cli/chunk-desvm72z.js.map +0 -14
  378. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  379. package/dist/cli/chunk-es8h0v3j.js +0 -441
  380. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  381. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  382. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  383. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  384. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  385. package/dist/cli/chunk-ydp3065p.js +0 -64
  386. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  387. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-080mfch6.js.map} +0 -0
  388. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-34wap3ed.js.map} +0 -0
  389. /package/dist/cli/{chunk-1f882900.js.map → chunk-c5bjxq8v.js.map} +0 -0
  390. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-f260jdrn.js.map} +0 -0
  391. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-fnxgtjeq.js.map} +0 -0
  392. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-gxhcf2hs.js.map} +0 -0
  393. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-h0qwdbem.js.map} +0 -0
  394. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-j4nfgn2j.js.map} +0 -0
  395. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-qcyxkj44.js.map} +0 -0
  396. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-x7bh6d9j.js.map} +0 -0
  397. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-ydqzkhjn.js.map} +0 -0
@@ -0,0 +1,4139 @@
1
+ #!/usr/bin/env node
2
+ // readme-codemod.mjs: the mechanical part of a ReadMe → Blume migration, for a
3
+ // repository in ReadMe's bi-directional Git sync layout (`docs/`, `reference/`,
4
+ // `recipes/`, `custom_pages/`, `custom_blocks/`, `_order.yaml` files, and the
5
+ // changelog from the `main` branch). Two passes:
6
+ //
7
+ // 1. Convert (default). Moves the content into the layout the ReadMe
8
+ // reference describes, rewrites every page, and writes what the later
9
+ // steps need:
10
+ // - `custom_pages/` → `page/`, `changelogs/` → `changelog/`, category
11
+ // folders → `(Category)` group folders, `.md` → `.mdx`;
12
+ // - frontmatter mapped to Blume's schema, with a `slug` pin wherever
13
+ // Blume's route would differ from ReadMe's flat URL;
14
+ // - callouts (emoji blockquotes, `<Callout>`, `[block:callout]`), links
15
+ // (`doc:`, `ref:`, `page:`, `changelog:`, `blog:`, and absolute links
16
+ // to `--site`), `<Image>`, `<Embed>`, `<Table>`, `<Cards>`,
17
+ // `<Columns>`, `<Accordion>`, `<HTMLBlock>`, `<Anchor>`, `<Recipe>`,
18
+ // `<Glossary>` (with `--glossary`), variables, reusable content, the
19
+ // other magic blocks, Font Awesome icons, emoji shortcodes, code-fence
20
+ // titles and languages, adjacent fences → `<CodeGroup>`, and body
21
+ // headings;
22
+ // - `_order.yaml` → `meta.ts`;
23
+ // - endpoint pages, frontmatter-only tag pages, and empty parent pages
24
+ // deleted, and recorded in `readme-migration.json`;
25
+ // - `reference/overlays/<spec>.overlay.json`, carrying endpoint-page prose
26
+ // and the ReadMe callouts in the specs' own descriptions (JSON specs);
27
+ // - `readme-redirects.json`, with the redirects it can already compute.
28
+ // 2. Routes (`--routes <file>`), after `blume build` and
29
+ // `operation-routes.mjs`: redirects every old endpoint and tag-page URL
30
+ // to its generated operation page, rewrites links to them, and writes the
31
+ // tag-level `meta.ts` files that restore ReadMe's endpoint order.
32
+ //
33
+ // It reports what it changed and what it left for a person: custom
34
+ // components, inline components, `<style>`/`<script>`, event handlers,
35
+ // unknown components, links to pages that don't exist, and so on.
36
+ //
37
+ // Zero dependencies, deterministic (sorted traversal, no clock or randomness),
38
+ // idempotent (a second run changes nothing), and a dry run by default.
39
+ //
40
+ // Usage:
41
+ // node readme-codemod.mjs [options] <project-root>
42
+ // --write apply the changes (default: report only)
43
+ // --json print the report as JSON
44
+ // --site <origin> the hub's origin; absolute links to it become
45
+ // root-relative (repeatable)
46
+ // --glossary <file> glossary terms: JSON ({ "Term": "Definition" }
47
+ // or [{ "term", "definition" }]), or a saved page
48
+ // of the hub, whose settings carry glossaryTerms
49
+ // node readme-codemod.mjs --routes <routes.json> [--spec <route>=<file>]... [--write] <project-root>
50
+ // <routes.json> is operation-routes.mjs output; pass it the same --spec
51
+ // pairs so operationIds resolve.
52
+
53
+ import {
54
+ existsSync,
55
+ mkdirSync,
56
+ readdirSync,
57
+ readFileSync,
58
+ rmdirSync,
59
+ rmSync,
60
+ statSync,
61
+ writeFileSync,
62
+ } from "node:fs";
63
+ import path from "node:path";
64
+
65
+ // --- Data ---------------------------------------------------------------------
66
+
67
+ // Font Awesome names (style prefixes and `fa-` stripped) → Lucide. `null`
68
+ // means no Lucide counterpart: the icon is dropped and reported.
69
+ const FONT_AWESOME = {
70
+ "arrow-left": "arrow-left",
71
+ "arrow-right": "arrow-right",
72
+ "arrow-right-from-bracket": "log-out",
73
+ "arrow-up-right-from-square": "external-link",
74
+ "arrows-rotate": "refresh-cw",
75
+ "bag-shopping": "shopping-bag",
76
+ ban: "ban",
77
+ bell: "bell",
78
+ bolt: "zap",
79
+ book: "book",
80
+ "book-open": "book-open",
81
+ bookmark: "bookmark",
82
+ box: "box",
83
+ boxes: "boxes",
84
+ brain: "brain",
85
+ briefcase: "briefcase",
86
+ brush: "brush",
87
+ bug: "bug",
88
+ building: "building",
89
+ "building-columns": "landmark",
90
+ bullhorn: "megaphone",
91
+ calculator: "calculator",
92
+ calendar: "calendar",
93
+ "cart-shopping": "shopping-cart",
94
+ "chart-bar": "chart-column",
95
+ "chart-column": "chart-column",
96
+ "chart-line": "chart-line",
97
+ "chart-pie": "chart-pie",
98
+ "chart-simple": "chart-column",
99
+ check: "check",
100
+ "check-circle": "circle-check",
101
+ "circle-check": "circle-check",
102
+ "circle-exclamation": "circle-alert",
103
+ "circle-info": "info",
104
+ "circle-play": "circle-play",
105
+ "circle-question": "circle-help",
106
+ "circle-xmark": "circle-x",
107
+ clipboard: "clipboard",
108
+ clock: "clock",
109
+ cloud: "cloud",
110
+ code: "code",
111
+ "code-branch": "git-branch",
112
+ "code-simple": "code",
113
+ cog: "settings",
114
+ comment: "message-square",
115
+ "comment-question": "message-circle-question",
116
+ comments: "messages-square",
117
+ compass: "compass",
118
+ computer: "computer",
119
+ copy: "copy",
120
+ "credit-card": "credit-card",
121
+ cube: "box",
122
+ cubes: "boxes",
123
+ database: "database",
124
+ desktop: "monitor",
125
+ "diagram-project": "workflow",
126
+ display: "monitor",
127
+ "dollar-sign": "dollar-sign",
128
+ download: "download",
129
+ envelope: "mail",
130
+ "exchange-alt": "arrow-left-right",
131
+ "exclamation-circle": "circle-alert",
132
+ "exclamation-triangle": "triangle-alert",
133
+ "external-link": "external-link",
134
+ eye: "eye",
135
+ "eye-slash": "eye-off",
136
+ file: "file",
137
+ "file-chart-column": "file-chart-column",
138
+ "file-lines": "file-text",
139
+ "file-signature": "signature",
140
+ filter: "filter",
141
+ filters: "funnel",
142
+ fingerprint: "fingerprint",
143
+ flag: "flag",
144
+ flask: "flask-conical",
145
+ folder: "folder",
146
+ "folder-open": "folder-open",
147
+ gauge: "gauge",
148
+ "gauge-high": "gauge",
149
+ gavel: "gavel",
150
+ gear: "settings",
151
+ gears: "settings",
152
+ gift: "gift",
153
+ github: "github",
154
+ globe: "globe",
155
+ "graduation-cap": "graduation-cap",
156
+ "hand-wave": "hand",
157
+ handshake: "handshake",
158
+ heart: "heart",
159
+ "help-circle": "circle-help",
160
+ home: "house",
161
+ hourglass: "hourglass",
162
+ "hourglass-half": "hourglass",
163
+ house: "house",
164
+ "id-card": "id-card",
165
+ image: "image",
166
+ inbox: "inbox",
167
+ info: "info",
168
+ "info-circle": "info",
169
+ key: "key",
170
+ laptop: "laptop",
171
+ "laptop-code": "laptop",
172
+ "layer-group": "layers",
173
+ layers: "layers",
174
+ "life-ring": "life-buoy",
175
+ lightbulb: "lightbulb",
176
+ link: "link",
177
+ list: "list",
178
+ "location-dot": "map-pin",
179
+ lock: "lock",
180
+ magic: "sparkles",
181
+ "magnifying-glass": "search",
182
+ "magnifying-glass-chart": "search",
183
+ map: "map",
184
+ "map-marker": "map-pin",
185
+ microchip: "cpu",
186
+ minus: "minus",
187
+ mobile: "smartphone",
188
+ "money-bill": "banknote",
189
+ "money-bill-wave": "banknote",
190
+ newspaper: "newspaper",
191
+ "paint-brush": "paintbrush",
192
+ palette: "palette",
193
+ "paper-plane": "send",
194
+ pause: "pause",
195
+ pen: "pencil",
196
+ "pen-to-square": "square-pen",
197
+ pencil: "pencil",
198
+ percent: "percent",
199
+ "person-snowboarding": null,
200
+ phone: "phone",
201
+ play: "play",
202
+ plug: "plug",
203
+ plus: "plus",
204
+ "plus-circle": "circle-plus",
205
+ "puzzle-piece": "puzzle",
206
+ question: "circle-help",
207
+ "question-circle": "circle-help",
208
+ receipt: "receipt",
209
+ "right-to-bracket": "log-in",
210
+ robot: "bot",
211
+ rocket: "rocket",
212
+ "rocket-launch": "rocket",
213
+ rotate: "refresh-cw",
214
+ rss: "rss",
215
+ "rss-square": "rss",
216
+ "satellite-dish": "satellite-dish",
217
+ "scale-balanced": "scale",
218
+ "screwdriver-wrench": "wrench",
219
+ search: "search",
220
+ server: "server",
221
+ shield: "shield",
222
+ "shield-alt": "shield",
223
+ "shield-halved": "shield",
224
+ signal: "signal",
225
+ sitemap: "network",
226
+ slack: "slack",
227
+ smile: "smile",
228
+ sparkles: "sparkles",
229
+ "square-rss": "rss",
230
+ star: "star",
231
+ store: "store",
232
+ sync: "refresh-cw",
233
+ table: "table",
234
+ tag: "tag",
235
+ tags: "tags",
236
+ terminal: "terminal",
237
+ "thumbs-down": "thumbs-down",
238
+ "thumbs-up": "thumbs-up",
239
+ times: "x",
240
+ "times-circle": "circle-x",
241
+ toolbox: "wrench",
242
+ trash: "trash",
243
+ "trash-alt": "trash-2",
244
+ "trash-can": "trash-2",
245
+ "triangle-exclamation": "triangle-alert",
246
+ truck: "truck",
247
+ unlock: "lock-open",
248
+ upload: "upload",
249
+ user: "user",
250
+ "user-check": "user-check",
251
+ users: "users",
252
+ video: "video",
253
+ wallet: "wallet",
254
+ "wand-magic-sparkles": "sparkles",
255
+ webhook: "webhook",
256
+ wifi: "wifi",
257
+ wrench: "wrench",
258
+ "x-twitter": null,
259
+ xmark: "x",
260
+ };
261
+
262
+ // Style prefixes in a Font Awesome class string (`fad fa-hand-wave`,
263
+ // `fa-duotone fa-solid fa-rocket-launch`).
264
+ const FA_STYLE =
265
+ /^(?:fa|fas|far|fad|fal|fat|fab|fast|fasl|fasr|fass|fa-solid|fa-regular|fa-light|fa-thin|fa-duotone|fa-brands|fa-sharp|fa-sharp-duotone|fa-fw|fa-lg|fa-xl|fa-2x)$/u;
266
+
267
+ // GitHub-style emoji shortcodes (the gemoji names ReadMe renders), a common
268
+ // subset as "name emoji" pairs; anything else is reported.
269
+ const SHORTCODE_PAIRS = `100 💯 1234 🔢 +1 👍 -1 👎 1st_place_medal 🥇 abc 🔤 airplane ✈️
270
+ alarm_clock ⏰ anchor ⚓ arrow_down ⬇️ arrow_forward ▶️ arrow_left ⬅️
271
+ arrow_lower_right ↘️ arrow_right ➡️ arrow_up ⬆️ arrow_upper_right ↗️
272
+ arrows_counterclockwise 🔄 art 🎨 balloon 🎈 ballot_box_with_check ☑️
273
+ bangbang ‼️ bank 🏦 bar_chart 📊 battery 🔋 bee 🐝 beer 🍺 bell 🔔 bike 🚲
274
+ birthday 🎂 black_circle ⚫ blue_heart 💙 blush 😊 bomb 💣 book 📖
275
+ bookmark 🔖 bookmark_tabs 📑 books 📚 boom 💥 brain 🧠 briefcase 💼
276
+ broken_heart 💔 bug 🐛 building_construction 🏗️ bulb 💡 cake 🍰
277
+ calendar 📆 calling 📲 camera 📷 car 🚗 card_index 📇 cat 🐱 chains ⛓️
278
+ chart 💹 chart_with_downwards_trend 📉 chart_with_upwards_trend 📈
279
+ checkered_flag 🏁 christmas_tree 🎄 clap 👏 clipboard 📋 clock1 🕐
280
+ closed_book 📕 closed_lock_with_key 🔐 cloud ☁️ coffee ☕ compass 🧭
281
+ computer 💻 confetti_ball 🎊 construction 🚧 convenience_store 🏪 cool 🆒
282
+ credit_card 💳 crown 👑 cry 😢 dart 🎯 date 📅 department_store 🏬
283
+ desktop_computer 🖥️ dna 🧬 dog 🐶 dollar 💵 e-mail 📧 earth_africa 🌍
284
+ earth_americas 🌎 earth_asia 🌏 electric_plug 🔌 email 📧 envelope ✉️
285
+ envelope_with_arrow 📩 exclamation ❗ eyes 👀 factory 🏭 fallen_leaf 🍂
286
+ file_folder 📁 fire 🔥 fireworks 🎆 flushed 😳 free 🆓 gear ⚙️ gem 💎
287
+ ghost 👻 gift 🎁 gift_heart 💝 globe_with_meridians 🌐 green_circle 🟢
288
+ green_heart 💚 grey_exclamation ❕ grey_question ❔ grin 😁 grinning 😀
289
+ hammer 🔨 hammer_and_pick ⚒️ hammer_and_wrench 🛠️ hand ✋ handshake 🤝
290
+ hatching_chick 🐣 headphones 🎧 heart ❤️ heart_decoration 💟
291
+ heart_eyes 😍 heavy_check_mark ✔️ heavy_division_sign ➗
292
+ heavy_dollar_sign 💲 heavy_exclamation_mark ❗ heavy_minus_sign ➖
293
+ heavy_multiplication_x ✖️ heavy_plus_sign ➕ hospital 🏥 hourglass ⌛
294
+ hourglass_flowing_sand ⏳ house 🏠 hugs 🤗 inbox_tray 📥
295
+ incoming_envelope 📨 information_desk_person 💁 information_source ℹ️
296
+ interrobang ⁉️ iphone 📱 jack_o_lantern 🎃 joy 😂 key 🔑 keyboard ⌨️
297
+ label 🏷️ large_blue_circle 🔵 laughing 😆 leaves 🍃 ledger 📒
298
+ left_speech_bubble 🗨️ link 🔗 lock 🔒 lock_with_ink_pen 🔏 loud_sound 🔊
299
+ loudspeaker 📢 mag 🔍 mag_right 🔎 magnet 🧲 mailbox 📫
300
+ mailbox_with_mail 📬 maple_leaf 🍁 mask 😷 medal_military 🎖️
301
+ medal_sports 🏅 mega 📣 memo 📝 microscope 🔬 money_with_wings 💸
302
+ moneybag 💰 mortar_board 🎓 movie_camera 🎥 muscle 💪 musical_note 🎵
303
+ mute 🔇 negative_squared_cross_mark ❎ nerd_face 🤓 new 🆕 no_bell 🔕
304
+ no_entry ⛔ no_entry_sign 🚫 notebook 📓 nut_and_bolt 🔩 office 🏢
305
+ ok 🆗 ok_hand 👌 old_key 🗝️ open_file_folder 📂 orange_circle 🟠
306
+ orange_heart 🧡 outbox_tray 📤 owl 🦉 package 📦 page_facing_up 📄
307
+ page_with_curl 📃 paperclip 📎 partly_sunny ⛅ partying_face 🥳
308
+ pencil 📝 pencil2 ✏️ penguin 🐧 performing_arts 🎭 phone ☎️ pick ⛏️
309
+ pizza 🍕 point_down 👇 point_left 👈 point_right 👉 point_up ☝️
310
+ point_up_2 👆 pray 🙏 purple_circle 🟣 purple_heart 💜 pushpin 📌
311
+ question ❓ rabbit 🐰 radio_button 🔘 rage 😡 rainbow 🌈 raised_hand ✋
312
+ raised_hands 🙌 receipt 🧾 recycle ♻️ red_circle 🔴 relaxed ☺️
313
+ relieved 😌 repeat 🔁 ribbon 🎀 robot 🤖 rocket 🚀 rofl 🤣
314
+ rotating_light 🚨 round_pushpin 📍 satellite 📡 school 🏫 scissors ✂️
315
+ scream 😱 scroll 📜 see_no_evil 🙈 seedling 🌱 shield 🛡️ ship 🚢
316
+ shushing_face 🤫 skull 💀 slightly_smiling_face 🙂 smile 😄 smiley 😃
317
+ smirk 😏 snowflake ❄️ sob 😭 sos 🆘 sound 🔉 sparkler 🎇 sparkles ✨
318
+ sparkling_heart 💖 speaker 🔈 speech_balloon 💬 spiral_calendar 🗓️
319
+ star ⭐ star2 🌟 star_struck 🤩 stop_sign 🛑 stopwatch ⏱️
320
+ straight_ruler 📏 sunglasses 😎 sunny ☀️ sunrise 🌅 sweat 😓
321
+ sweat_smile 😅 tada 🎉 telephone_receiver 📞 telescope 🔭 test_tube 🧪
322
+ thinking 🤔 thought_balloon 💭 thumbsdown 👎 thumbsup 👍 ticket 🎫
323
+ timer_clock ⏲️ toolbox 🧰 train 🚋 triangular_flag_on_post 🚩
324
+ triangular_ruler 📐 trophy 🏆 turtle 🐢 umbrella ☔ unicorn 🦄 unlock 🔓
325
+ up 🆙 upside_down_face 🙃 video_game 🎮 warning ⚠️ watch ⌚ wave 👋
326
+ white_check_mark ✅ white_circle ⚪ wink 😉 world_map 🗺️ wrench 🔧
327
+ writing_hand ✍️ x ❌ yellow_circle 🟡 yellow_heart 💛 zap ⚡
328
+ zipper_mouth_face 🤐`;
329
+
330
+ const readPairs = (text) => {
331
+ const tokens = text.split(/\s+/u);
332
+ const pairs = new Map();
333
+ for (let index = 0; index + 1 < tokens.length; index += 2) {
334
+ pairs.set(tokens[index], tokens[index + 1]);
335
+ }
336
+ return pairs;
337
+ };
338
+ const SHORTCODES = readPairs(SHORTCODE_PAIRS);
339
+
340
+ // Emoji → callout type, as ReadMe's renderer themes them (components/Callout).
341
+ const CALLOUT_EMOJI = [
342
+ // A bare ℹ (no variation selector) isn't in ReadMe's list: default theme.
343
+ [/^(?:📘|ℹ️)/u, "info"],
344
+ [/^(?:👍|✅)/u, "success"],
345
+ [/^(?:🚧|⚠️|⚠)/u, "warning"],
346
+ [/^(?:❗️|❗|🛑|‼️|⁉️)/u, "danger"],
347
+ ];
348
+ const CALLOUT_THEME = {
349
+ default: "note",
350
+ error: "danger",
351
+ info: "info",
352
+ okay: "success",
353
+ warn: "warning",
354
+ };
355
+ const MAGIC_CALLOUT = {
356
+ danger: "danger",
357
+ error: "danger",
358
+ info: "info",
359
+ success: "success",
360
+ warning: "warning",
361
+ };
362
+ // A leading emoji, with its variation selectors and ZWJ sequence, a flag, or a
363
+ // keycap: what ReadMe's emoji-regex matches.
364
+ const LEADING_EMOJI =
365
+ /^(?:\p{Extended_Pictographic}(?:\uFE0F|\u200D\p{Extended_Pictographic}|\p{Emoji_Modifier})*\uFE0F?|\p{Regional_Indicator}{2}|[#*0-9]\uFE0F?\u20E3)/u;
366
+
367
+ // Fence languages Shiki doesn't know, and ReadMe's tab labels for untitled
368
+ // fences (its renderer shows the language).
369
+ const FENCE_LANGUAGE = {
370
+ cplusplus: "cpp",
371
+ curl: "bash",
372
+ node: "js",
373
+ objectivec: "objc",
374
+ };
375
+ const LANGUAGE_LABEL = {
376
+ bash: "Bash",
377
+ c: "C",
378
+ cpp: "C++",
379
+ csharp: "C#",
380
+ curl: "cURL",
381
+ go: "Go",
382
+ html: "HTML",
383
+ http: "HTTP",
384
+ java: "Java",
385
+ javascript: "JavaScript",
386
+ js: "JavaScript",
387
+ json: "JSON",
388
+ kotlin: "Kotlin",
389
+ node: "Node",
390
+ php: "PHP",
391
+ python: "Python",
392
+ ruby: "Ruby",
393
+ sh: "Shell",
394
+ shell: "Shell",
395
+ swift: "Swift",
396
+ text: "Text",
397
+ ts: "TypeScript",
398
+ typescript: "TypeScript",
399
+ xml: "XML",
400
+ yaml: "YAML",
401
+ };
402
+
403
+ // Components Blume renders in MDX; anything else capitalized is reported.
404
+ const BLUME_COMPONENTS = new Set([
405
+ "Accordion",
406
+ "AccordionItem",
407
+ "AutoTypeTable",
408
+ "Badge",
409
+ "Callout",
410
+ "Card",
411
+ "CardGroup",
412
+ "CodeBlock",
413
+ "CodeGroup",
414
+ "Color",
415
+ "Column",
416
+ "Columns",
417
+ "Component",
418
+ "Diff",
419
+ "Expandable",
420
+ "FileTree",
421
+ "Frame",
422
+ "GithubInfo",
423
+ "Icon",
424
+ "Math",
425
+ "Panel",
426
+ "ParamField",
427
+ "Prompt",
428
+ "RequestExample",
429
+ "ResponseExample",
430
+ "ResponseField",
431
+ "Step",
432
+ "Steps",
433
+ "Tab",
434
+ "Tabs",
435
+ "Tile",
436
+ "Tooltip",
437
+ "Tree",
438
+ "TypeTable",
439
+ "View",
440
+ "Visibility",
441
+ "YouTube",
442
+ ]);
443
+
444
+ // JSX attributes: quoted strings, or `{…}` with one level of nested braces.
445
+ const ATTRS = String.raw`(?:[^>"'{}]|"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\})*?`;
446
+ const VOID_TAGS = new RegExp(
447
+ String.raw`<(?<tag>br|hr|img|input|source|wbr|col|area|embed|track|param)\b(?<rest>${ATTRS})\s*(?<!\/)>`,
448
+ "gu"
449
+ );
450
+
451
+ const SECTIONS = {
452
+ changelog: "changelog",
453
+ changelogs: "changelog",
454
+ custom_pages: "page",
455
+ docs: "docs",
456
+ page: "page",
457
+ recipes: "recipes",
458
+ reference: "reference",
459
+ };
460
+ const URL_PREFIX = {
461
+ changelog: "/changelog/",
462
+ docs: "/docs/",
463
+ page: "/page/",
464
+ recipes: "/recipes/",
465
+ reference: "/reference/",
466
+ };
467
+ const SCHEME_PREFIX = {
468
+ blog: "/changelog/",
469
+ changelog: "/changelog/",
470
+ doc: "/docs/",
471
+ page: "/page/",
472
+ ref: "/reference/",
473
+ };
474
+ const NEXT_PREFIX = {
475
+ basic: "/docs/",
476
+ changelog: "/changelog/",
477
+ custom_page: "/page/",
478
+ endpoint: "/reference/",
479
+ };
480
+ const MAGIC_TYPES = new Set([
481
+ "api-header",
482
+ "callout",
483
+ "code",
484
+ "embed",
485
+ "html",
486
+ "image",
487
+ "parameters",
488
+ "recipe",
489
+ "table",
490
+ "tutorial-tile",
491
+ ]);
492
+ const CHANGELOG_TYPES = new Set([
493
+ "added",
494
+ "deprecated",
495
+ "fixed",
496
+ "improved",
497
+ "none",
498
+ "removed",
499
+ ]);
500
+ const NAV_KEYS = [
501
+ "category",
502
+ "categorySlug",
503
+ "order",
504
+ "parent",
505
+ "parentDoc",
506
+ "parentDocSlug",
507
+ "position",
508
+ ];
509
+ // Keys a Blume page accepts, so a second run passes them through quietly.
510
+ const BLUME_KEYS = new Set([
511
+ "ai",
512
+ "api",
513
+ "authMethod",
514
+ "authors",
515
+ "changelog",
516
+ "date",
517
+ "deprecated",
518
+ "description",
519
+ "draft",
520
+ "hidden",
521
+ "icon",
522
+ "lastModified",
523
+ "mode",
524
+ "narration",
525
+ "noindex",
526
+ "pagination",
527
+ "playground",
528
+ "related",
529
+ "search",
530
+ "seo",
531
+ "sidebar",
532
+ "slug",
533
+ "title",
534
+ "type",
535
+ ]);
536
+ const KEY_ORDER = [
537
+ "title",
538
+ "description",
539
+ "type",
540
+ "date",
541
+ "authors",
542
+ "changelog",
543
+ "slug",
544
+ "icon",
545
+ "mode",
546
+ "hidden",
547
+ "draft",
548
+ "deprecated",
549
+ "noindex",
550
+ "sidebar",
551
+ "seo",
552
+ "search",
553
+ "related",
554
+ ];
555
+
556
+ const INVENTORY = "readme-migration.json";
557
+ const REDIRECTS = "readme-redirects.json";
558
+ // Private-use placeholders for code and inline code while prose is rewritten.
559
+ const CODE_MARK = "\uE000";
560
+ const SPAN_MARK = "\uE001";
561
+ const CODE_PLACEHOLDER = /\uE000(?<index>\d+)\uE000/gu;
562
+ const SPAN_PLACEHOLDER = /\uE001(?<index>\d+)\uE001/gu;
563
+
564
+ // --- Report -------------------------------------------------------------------
565
+
566
+ const report = { counts: new Map(), files: new Map(), todo: [] };
567
+ const count = (kind, amount = 1) => {
568
+ report.counts.set(kind, (report.counts.get(kind) ?? 0) + amount);
569
+ };
570
+ const note = (file, message) => {
571
+ const list = report.files.get(file) ?? [];
572
+ list.push(message);
573
+ report.files.set(file, list);
574
+ };
575
+ const todo = (message) => {
576
+ report.todo.push(message);
577
+ };
578
+
579
+ // --- Values -------------------------------------------------------------------
580
+
581
+ // Frontmatter comes from the YAML reader below and specs from JSON.parse:
582
+ // strings, numbers, booleans, null, arrays, and plain objects. These read the
583
+ // values back as the types the conversion needs.
584
+ const isRecord = (value) =>
585
+ value !== null &&
586
+ value !== undefined &&
587
+ !Array.isArray(value) &&
588
+ Object.getPrototypeOf(value) === Object.prototype;
589
+ const textOf = (value) => {
590
+ if (
591
+ value === null ||
592
+ value === undefined ||
593
+ value === true ||
594
+ value === false ||
595
+ Array.isArray(value) ||
596
+ isRecord(value)
597
+ ) {
598
+ return;
599
+ }
600
+ const text = String(value).trim();
601
+ return text === "" ? undefined : text;
602
+ };
603
+ const recordOf = (value) => (isRecord(value) ? value : {});
604
+ const listOf = (value) => (Array.isArray(value) ? value : []);
605
+
606
+ // A replace() callback that reads named groups.
607
+ const byGroups =
608
+ (read) =>
609
+ (...args) =>
610
+ read(args.at(-1), args[0]);
611
+
612
+ const sortedEntries = (map) =>
613
+ [...map.entries()].toSorted(([a], [b]) => a.localeCompare(b));
614
+
615
+ // --- Minimal YAML (the frontmatter subset ReadMe writes) ----------------------
616
+
617
+ class YamlError extends Error {
618
+ constructor(message) {
619
+ super(message);
620
+ this.name = "YamlError";
621
+ }
622
+ }
623
+
624
+ const indentOf = (line) => line.match(/^ */u)[0].length;
625
+ const isSkippable = (line) => /^\s*(?:#.*)?$/u.test(line);
626
+
627
+ const ESCAPES = {
628
+ " ": " ",
629
+ '"': '"',
630
+ "/": "/",
631
+ 0: "\0",
632
+ L: "\u2028",
633
+ N: "\u0085",
634
+ P: "\u2029",
635
+ "\\": "\\",
636
+ _: "\u00A0",
637
+ a: "\u0007",
638
+ b: "\b",
639
+ e: "\u001B",
640
+ f: "\f",
641
+ n: "\n",
642
+ r: "\r",
643
+ t: "\t",
644
+ v: "\v",
645
+ };
646
+
647
+ const unescapeDouble = (body) =>
648
+ body.replaceAll(
649
+ /\\(?:x(?<x>[0-9a-fA-F]{2})|u(?<u>[0-9a-fA-F]{4})|U(?<big>[0-9a-fA-F]{8})|(?<char>.))/gu,
650
+ byGroups(({ big, char, u, x }) => {
651
+ const hex = x ?? u ?? big;
652
+ if (hex) {
653
+ return String.fromCodePoint(Number.parseInt(hex, 16));
654
+ }
655
+ if (!Object.hasOwn(ESCAPES, char)) {
656
+ throw new YamlError(`unknown escape \\${char}`);
657
+ }
658
+ return ESCAPES[char];
659
+ })
660
+ );
661
+
662
+ const splitFlow = (inner) => {
663
+ const items = [];
664
+ let current = "";
665
+ let quote = null;
666
+ for (const char of inner) {
667
+ if (quote) {
668
+ current += char;
669
+ if (char === quote) {
670
+ quote = null;
671
+ }
672
+ } else if (char === '"' || char === "'") {
673
+ quote = char;
674
+ current += char;
675
+ } else if (char === "[" || char === "{") {
676
+ throw new YamlError("nested flow collections");
677
+ } else if (char === ",") {
678
+ items.push(current.trim());
679
+ current = "";
680
+ } else {
681
+ current += char;
682
+ }
683
+ }
684
+ if (current.trim() !== "") {
685
+ items.push(current.trim());
686
+ }
687
+ return items;
688
+ };
689
+
690
+ const parseQuoted = (text) => {
691
+ if (text.startsWith('"')) {
692
+ const match = /^"(?<inner>(?:[^"\\]|\\.)*)"\s*(?:#.*)?$/u.exec(text);
693
+ if (!match) {
694
+ throw new YamlError("unterminated double-quoted string");
695
+ }
696
+ return unescapeDouble(match.groups.inner);
697
+ }
698
+ const match = /^'(?<inner>(?:[^']|'')*)'\s*(?:#.*)?$/u.exec(text);
699
+ if (!match) {
700
+ throw new YamlError("unterminated single-quoted string");
701
+ }
702
+ return match.groups.inner.replaceAll("''", "'");
703
+ };
704
+
705
+ const parsePlain = (text) => {
706
+ const plain = text.replace(/\s+#.*$/u, "");
707
+ if (/^(?:true|True|TRUE)$/u.test(plain)) {
708
+ return true;
709
+ }
710
+ if (/^(?:false|False|FALSE)$/u.test(plain)) {
711
+ return false;
712
+ }
713
+ if (
714
+ /^[-+]?(?:0|[1-9]\d*)$/u.test(plain) ||
715
+ /^[-+]?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$/u.test(plain)
716
+ ) {
717
+ return Number(plain);
718
+ }
719
+ return plain;
720
+ };
721
+
722
+ const parseScalar = (raw) => {
723
+ const text = raw.trim();
724
+ if (text === "" || text === "~" || /^null$/iu.test(text)) {
725
+ return null;
726
+ }
727
+ if (/^[&*!|>]/u.test(text)) {
728
+ throw new YamlError(`unsupported value ${text}`);
729
+ }
730
+ if (text.startsWith('"') || text.startsWith("'")) {
731
+ return parseQuoted(text);
732
+ }
733
+ if (text.startsWith("[")) {
734
+ if (!text.endsWith("]")) {
735
+ throw new YamlError("unterminated flow sequence");
736
+ }
737
+ return splitFlow(text.slice(1, -1)).map(parseScalar);
738
+ }
739
+ if (text.startsWith("{")) {
740
+ if (/^\{\s*\}$/u.test(text)) {
741
+ return {};
742
+ }
743
+ throw new YamlError("flow mappings");
744
+ }
745
+ return parsePlain(text);
746
+ };
747
+
748
+ const foldLines = (content) => {
749
+ let value = "";
750
+ let previous = null;
751
+ for (const line of content) {
752
+ if (line === "") {
753
+ value += "\n";
754
+ } else if (previous === null || previous === "") {
755
+ value += line;
756
+ } else if (line.startsWith(" ") || previous.startsWith(" ")) {
757
+ value += `\n${line}`;
758
+ } else {
759
+ value += ` ${line}`;
760
+ }
761
+ previous = line;
762
+ }
763
+ return value;
764
+ };
765
+
766
+ const chomp = (value, indicator) => {
767
+ const body = value.replace(/\n+$/u, "");
768
+ if (indicator === "-") {
769
+ return body;
770
+ }
771
+ if (indicator === "+") {
772
+ return `${body}${value.slice(body.length)}\n`;
773
+ }
774
+ return body === "" ? "" : `${body}\n`;
775
+ };
776
+
777
+ const parseBlockScalar = (lines, start, header, parentIndent) => {
778
+ const match = /^(?<style>[|>])(?<indicator>[-+]?)$/u.exec(header);
779
+ if (!match) {
780
+ throw new YamlError(`unsupported block scalar header ${header}`);
781
+ }
782
+ let end = start;
783
+ while (
784
+ end < lines.length &&
785
+ (lines[end].trim() === "" || indentOf(lines[end]) > parentIndent)
786
+ ) {
787
+ end += 1;
788
+ }
789
+ const block = lines.slice(start, end);
790
+ const first = block.find((line) => line.trim() !== "");
791
+ const indent = first === undefined ? 0 : indentOf(first);
792
+ const content = block.map((line) =>
793
+ line.trim() === "" ? "" : line.slice(indent)
794
+ );
795
+ const value =
796
+ match.groups.style === "|" ? content.join("\n") : foldLines(content);
797
+ return [chomp(value, match.groups.indicator), end];
798
+ };
799
+
800
+ const KEY_LINE =
801
+ /^(?:"(?<dq>(?:[^"\\]|\\.)*)"|'(?<sq>(?:[^']|'')*)'|(?<plain>[^\s"'#\-?:,[\]{}][^#]*?|-[^\s#][^#]*?))\s*:(?:\s+(?<rest>.*))?$/u;
802
+
803
+ const nextContent = (lines, index) => {
804
+ let cursor = index;
805
+ while (cursor < lines.length && isSkippable(lines[cursor])) {
806
+ cursor += 1;
807
+ }
808
+ return cursor;
809
+ };
810
+
811
+ const isSequenceLine = (line, indent) =>
812
+ indentOf(line) === indent && /^-(?:\s|$)/u.test(line.slice(indent));
813
+
814
+ const keyOf = ({ dq, plain, sq }) => {
815
+ if (dq !== undefined) {
816
+ return unescapeDouble(dq);
817
+ }
818
+ if (sq !== undefined) {
819
+ return sq.replaceAll("''", "'");
820
+ }
821
+ return plain;
822
+ };
823
+
824
+ // One `key: value` line; `parseBlock` reads a nested block.
825
+ const parseEntry = (lines, index, indent, parseBlock) => {
826
+ const content = lines[index].slice(indent);
827
+ const match = KEY_LINE.exec(content);
828
+ if (!match) {
829
+ throw new YamlError(`can't read line ${index + 1}: ${content}`);
830
+ }
831
+ const key = keyOf(match.groups);
832
+ const value = (match.groups.rest ?? "").trim();
833
+ if (value === "" || value.startsWith("#")) {
834
+ const next = nextContent(lines, index + 1);
835
+ const nested =
836
+ next < lines.length &&
837
+ (indentOf(lines[next]) > indent || isSequenceLine(lines[next], indent));
838
+ return nested ? [key, ...parseBlock(next, indent)] : [key, null, index + 1];
839
+ }
840
+ if (/^[|>][-+]?(?:\s+#.*)?$/u.test(value)) {
841
+ const header = value.replace(/\s+#.*$/u, "");
842
+ return [key, ...parseBlockScalar(lines, index + 1, header, indent)];
843
+ }
844
+ const next = nextContent(lines, index + 1);
845
+ if (next < lines.length && indentOf(lines[next]) > indent) {
846
+ throw new YamlError(`multi-line value on line ${index + 1}`);
847
+ }
848
+ return [key, parseScalar(value), index + 1];
849
+ };
850
+
851
+ const parseMapping = (lines, start, indent, parseBlock) => {
852
+ const object = {};
853
+ let index = start;
854
+ for (;;) {
855
+ index = nextContent(lines, index);
856
+ if (index >= lines.length || indentOf(lines[index]) < indent) {
857
+ return [object, index];
858
+ }
859
+ if (indentOf(lines[index]) > indent) {
860
+ throw new YamlError(`unexpected indentation on line ${index + 1}`);
861
+ }
862
+ if (/^-(?:\s|$)/u.test(lines[index].slice(indent))) {
863
+ return [object, index];
864
+ }
865
+ const [key, value, next] = parseEntry(lines, index, indent, parseBlock);
866
+ object[key] = value;
867
+ index = next;
868
+ }
869
+ };
870
+
871
+ const parseSequence = (lines, start, indent, parseBlock) => {
872
+ const array = [];
873
+ let index = start;
874
+ for (;;) {
875
+ index = nextContent(lines, index);
876
+ if (index >= lines.length || !isSequenceLine(lines[index], indent)) {
877
+ return [array, index];
878
+ }
879
+ const after = lines[index].slice(indent + 1);
880
+ const rest = after.trimStart();
881
+ let value;
882
+ if (rest === "" || rest.startsWith("#")) {
883
+ [value, index] = parseBlock(index + 1, indent + 1);
884
+ } else if (KEY_LINE.test(rest) && !/^["'[{]/u.test(rest)) {
885
+ // `- key: value` starts a mapping at the key's column.
886
+ const itemIndent = indent + 1 + (after.length - rest.length);
887
+ lines[index] = `${" ".repeat(itemIndent)}${rest}`;
888
+ [value, index] = parseMapping(lines, index, itemIndent, parseBlock);
889
+ } else {
890
+ value = parseScalar(rest);
891
+ index += 1;
892
+ }
893
+ array.push(value);
894
+ }
895
+ };
896
+
897
+ const parseYaml = (text) => {
898
+ if (/^\t|\n\t/u.test(text)) {
899
+ throw new YamlError("tab indentation");
900
+ }
901
+ const lines = text.split("\n");
902
+ const parseBlock = (start, minIndent) => {
903
+ const index = nextContent(lines, start);
904
+ if (index >= lines.length || indentOf(lines[index]) < minIndent) {
905
+ return [null, index];
906
+ }
907
+ const indent = indentOf(lines[index]);
908
+ return isSequenceLine(lines[index], indent)
909
+ ? parseSequence(lines, index, indent, parseBlock)
910
+ : parseMapping(lines, index, indent, parseBlock);
911
+ };
912
+ const [value, end] = parseBlock(0, 0);
913
+ if (nextContent(lines, end) < lines.length) {
914
+ throw new YamlError(`can't read line ${end + 1}`);
915
+ }
916
+ return value ?? {};
917
+ };
918
+
919
+ const plainSafe = (text) =>
920
+ text !== "" &&
921
+ !/^\s|\s$/u.test(text) &&
922
+ !/^[-?:,[\]{}#&*!|>'"%@`]/u.test(text) &&
923
+ !/:(?:\s|$)|\s#|[\n\r\t\\]/u.test(text) &&
924
+ !/^(?:true|false|null|~|yes|no|on|off|y|n)$/iu.test(text) &&
925
+ !/^[-+]?(?:\d|\.\d)/u.test(text);
926
+
927
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/u;
928
+
929
+ const yamlKey = (key) =>
930
+ /^[A-Za-z_][\w-]*$/u.test(key) ? key : JSON.stringify(key);
931
+
932
+ const yamlScalar = (value, key) => {
933
+ if (value === null) {
934
+ return "null";
935
+ }
936
+ if (value === true || value === false || Number.isFinite(value)) {
937
+ return String(value);
938
+ }
939
+ const text = String(value);
940
+ if ((key === "date" || key === "lastModified") && DATE_ONLY.test(text)) {
941
+ return text;
942
+ }
943
+ return plainSafe(text) ? text : JSON.stringify(text);
944
+ };
945
+
946
+ const emitYaml = (value, indent = 0) => {
947
+ const pad = " ".repeat(indent);
948
+ const lines = [];
949
+ for (const [key, entry] of Object.entries(value)) {
950
+ if (entry === undefined) {
951
+ continue;
952
+ }
953
+ if (Array.isArray(entry) && entry.length === 0) {
954
+ lines.push(`${pad}${yamlKey(key)}: []`);
955
+ } else if (Array.isArray(entry)) {
956
+ lines.push(`${pad}${yamlKey(key)}:`);
957
+ for (const item of entry) {
958
+ if (isRecord(item)) {
959
+ const [first, ...more] = emitYaml(item, indent + 4).split("\n");
960
+ lines.push(`${pad} - ${first.trimStart()}`, ...more);
961
+ } else {
962
+ lines.push(`${pad} - ${yamlScalar(item)}`);
963
+ }
964
+ }
965
+ } else if (isRecord(entry) && Object.keys(entry).length === 0) {
966
+ lines.push(`${pad}${yamlKey(key)}: {}`);
967
+ } else if (isRecord(entry)) {
968
+ lines.push(`${pad}${yamlKey(key)}:`, emitYaml(entry, indent + 2));
969
+ } else {
970
+ lines.push(`${pad}${yamlKey(key)}: ${yamlScalar(entry, key)}`);
971
+ }
972
+ }
973
+ return lines.join("\n");
974
+ };
975
+
976
+ // --- Files --------------------------------------------------------------------
977
+
978
+ const walk = (dir) => {
979
+ const out = [];
980
+ if (!existsSync(dir)) {
981
+ return out;
982
+ }
983
+ for (const name of readdirSync(dir).toSorted()) {
984
+ if (name === "node_modules" || name.startsWith(".")) {
985
+ continue;
986
+ }
987
+ const full = path.join(dir, name);
988
+ if (statSync(full).isDirectory()) {
989
+ out.push(...walk(full));
990
+ } else {
991
+ out.push(full);
992
+ }
993
+ }
994
+ return out;
995
+ };
996
+
997
+ const splitFrontmatter = (text) => {
998
+ const match = /^---\n(?<raw>[\s\S]*?)\n---[ \t]*(?:\n|$)/u.exec(text);
999
+ return match
1000
+ ? { body: text.slice(match[0].length), raw: match.groups.raw }
1001
+ : { body: text, raw: null };
1002
+ };
1003
+
1004
+ const readOrder = (file) => {
1005
+ if (!existsSync(file)) {
1006
+ return null;
1007
+ }
1008
+ const value = parseYaml(
1009
+ readFileSync(file, "utf-8")
1010
+ .replace(/^\uFEFF/u, "")
1011
+ .replaceAll("\r\n", "\n")
1012
+ );
1013
+ return [
1014
+ ...new Set(
1015
+ listOf(value)
1016
+ .map((entry) => textOf(entry))
1017
+ .filter(Boolean)
1018
+ ),
1019
+ ];
1020
+ };
1021
+
1022
+ const isGroup = (segment) => /^\(.+\)$/u.test(segment);
1023
+
1024
+ const readJson = (file, fallback) =>
1025
+ existsSync(file) ? JSON.parse(readFileSync(file, "utf-8")) : fallback;
1026
+
1027
+ // meta.ts fields in the order people write them.
1028
+ const META_ORDER = ["title", "display", "order", "pages"];
1029
+
1030
+ const metaModule = (fields) => {
1031
+ const lines = META_ORDER.filter((key) => fields[key] !== undefined).map(
1032
+ (key) => ` ${key}: ${JSON.stringify(fields[key])},`
1033
+ );
1034
+ return `import { defineMeta } from "blume";\n\nexport default defineMeta({\n${lines.join("\n")}\n});\n`;
1035
+ };
1036
+
1037
+ const stripSpacers = (text) =>
1038
+ text
1039
+ .replaceAll(/^[ \t]*<br\s*\/?>[ \t]*$/gmu, "")
1040
+ .replaceAll(/\n{3,}/gu, "\n\n")
1041
+ .trim();
1042
+
1043
+ // --- Links --------------------------------------------------------------------
1044
+
1045
+ const escapeRegExp = (text) =>
1046
+ text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
1047
+
1048
+ const fromScheme = (url) => {
1049
+ const match =
1050
+ /^(?<kind>doc|ref|page|changelog|blog):(?<slug>[^#?\s]*)(?<rest>.*)$/u.exec(
1051
+ url
1052
+ );
1053
+ if (!match) {
1054
+ return url;
1055
+ }
1056
+ const { kind, rest, slug } = match.groups;
1057
+ count(`link ${kind}: rewritten`);
1058
+ return `${SCHEME_PREFIX[kind]}${slug.toLowerCase()}${rest}`;
1059
+ };
1060
+
1061
+ const fromSite = (url, sites) => {
1062
+ for (const site of sites) {
1063
+ if (url === site || url.startsWith(`${site}/`)) {
1064
+ count("link to the hub's own origin made root-relative");
1065
+ return url.slice(site.length) || "/";
1066
+ }
1067
+ }
1068
+ return url;
1069
+ };
1070
+
1071
+ const checkTarget = (target, url, file, knownUrls) => {
1072
+ if (/^\/v\d[^/]*\//u.test(target)) {
1073
+ note(file, `link through a version prefix, check it: ${url}`);
1074
+ return;
1075
+ }
1076
+ const [, section] = target.split("/");
1077
+ const listed =
1078
+ !knownUrls ||
1079
+ knownUrls.has(target) ||
1080
+ /^\/(?:docs|reference|recipes|changelog|page)?$/u.test(target);
1081
+ if (!listed && ["docs", "page", "recipes", "changelog"].includes(section)) {
1082
+ note(
1083
+ file,
1084
+ `link to ${target}, which no file in this repo serves (dead on the hub too, or in another version): point it at the closest page, or unlink it`
1085
+ );
1086
+ }
1087
+ };
1088
+
1089
+ const createLinkRewriter = (context) => (url, file) => {
1090
+ let next = fromSite(fromScheme(url), context.sites);
1091
+ if (next.startsWith("/") && !next.startsWith("//")) {
1092
+ // ReadMe's hash-router leftovers and trailing slashes.
1093
+ next = next.replace(/#\/?$/u, "").replace(/(?<=.)\/(?=[#?]|$)/u, "");
1094
+ checkTarget(next.split(/[#?]/u)[0], url, file, context.knownUrls);
1095
+ } else if (/^https?:\/\/(?!files\.|cdn\.)[^/]+\.readme\.io\b/u.test(next)) {
1096
+ note(file, `link to a readme.io hub, pass its origin with --site: ${url}`);
1097
+ }
1098
+ return next;
1099
+ };
1100
+
1101
+ // Rewrite link targets in Markdown links, reference definitions, and href=.
1102
+ const rewriteLinks = (text, file, rewrite) =>
1103
+ text
1104
+ .replaceAll(
1105
+ /\]\((?<url><[^>]*>|[^)\s]+)(?<title>(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)\)/gu,
1106
+ byGroups(({ title, url }, all) => {
1107
+ const bare = url.startsWith("<") ? url.slice(1, -1) : url;
1108
+ const next = rewrite(bare, file);
1109
+ return next === bare ? all : `](${next}${title})`;
1110
+ })
1111
+ )
1112
+ .replaceAll(
1113
+ /^(?<lead>\s*\[[^\]]+\]:\s+)(?<url><[^>]*>|\S+)/gmu,
1114
+ byGroups(({ lead, url }, all) => {
1115
+ const angled = url.startsWith("<");
1116
+ const bare = angled ? url.slice(1, -1) : url;
1117
+ const next = rewrite(bare, file);
1118
+ if (next === bare) {
1119
+ return all;
1120
+ }
1121
+ return angled ? `${lead}<${next}>` : `${lead}${next}`;
1122
+ })
1123
+ )
1124
+ .replaceAll(
1125
+ /\bhref=(?<quote>["'])(?<url>[^"']+)\k<quote>/gu,
1126
+ byGroups(({ quote, url }, all) => {
1127
+ const next = rewrite(url, file);
1128
+ return next === url ? all : `href=${quote}${next}${quote}`;
1129
+ })
1130
+ );
1131
+
1132
+ // --- Icons --------------------------------------------------------------------
1133
+
1134
+ const lucideFor = (raw, file) => {
1135
+ const value = textOf(raw);
1136
+ if (value === undefined) {
1137
+ return;
1138
+ }
1139
+ const parts = value.split(/\s+/u).filter((part) => !FA_STYLE.test(part));
1140
+ const [first] = parts;
1141
+ if (
1142
+ parts.length === 1 &&
1143
+ !first.startsWith("fa-") &&
1144
+ /^[a-z0-9-]+$/u.test(first)
1145
+ ) {
1146
+ // Already a bare name (a second run, or a Lucide name).
1147
+ return first;
1148
+ }
1149
+ const name = parts.find((part) => part.startsWith("fa-"))?.slice(3);
1150
+ let reason;
1151
+ if (name === undefined) {
1152
+ reason = `emoji or not Font Awesome: ${value}`;
1153
+ } else if (!Object.hasOwn(FONT_AWESOME, name)) {
1154
+ reason = `no mapping for fa-${name}; pick a Lucide name`;
1155
+ } else if (FONT_AWESOME[name] === null) {
1156
+ reason = `no Lucide equivalent: fa-${name}`;
1157
+ }
1158
+ if (reason) {
1159
+ note(file, `icon dropped (${reason})`);
1160
+ count("icon dropped");
1161
+ return;
1162
+ }
1163
+ count("icon Font Awesome → Lucide");
1164
+ return FONT_AWESOME[name];
1165
+ };
1166
+
1167
+ // --- Code and prose -----------------------------------------------------------
1168
+
1169
+ const FENCE = /^(?<indent>[ \t]*)(?<ticks>`{3,}|~{3,})(?<info>.*)$/u;
1170
+
1171
+ // A fence line's parts, or undefined. A backtick fence's info string can't
1172
+ // hold a backtick (CommonMark), so a line opening with ```inline``` code is a
1173
+ // paragraph, not a fence.
1174
+ const fenceOf = (line) => {
1175
+ const groups = FENCE.exec(line)?.groups;
1176
+ return groups && !(groups.ticks[0] === "`" && groups.info.includes("`"))
1177
+ ? groups
1178
+ : undefined;
1179
+ };
1180
+
1181
+ const segmentBody = (body) => {
1182
+ const segments = [];
1183
+ let current = { code: false, lines: [] };
1184
+ let fence = null;
1185
+ for (const line of body.split("\n")) {
1186
+ const match = fenceOf(line);
1187
+ if (fence === null && match) {
1188
+ if (current.lines.length > 0) {
1189
+ segments.push(current);
1190
+ }
1191
+ current = { code: true, lines: [line] };
1192
+ fence = match.ticks;
1193
+ } else if (fence === null) {
1194
+ current.lines.push(line);
1195
+ } else {
1196
+ current.lines.push(line);
1197
+ const closes =
1198
+ match &&
1199
+ match.ticks[0] === fence[0] &&
1200
+ match.ticks.length >= fence.length &&
1201
+ match.info.trim() === "";
1202
+ if (closes) {
1203
+ segments.push(current);
1204
+ current = { code: false, lines: [] };
1205
+ fence = null;
1206
+ }
1207
+ }
1208
+ }
1209
+ if (current.lines.length > 0) {
1210
+ segments.push(current);
1211
+ }
1212
+ return segments;
1213
+ };
1214
+
1215
+ const stashSpans = (text, spans) =>
1216
+ text.replaceAll(
1217
+ /(?<!`)(?<ticks>`+)(?!`)[\s\S]*?[^`]\k<ticks>(?!`)/gu,
1218
+ (span) =>
1219
+ span.includes("\n\n")
1220
+ ? span
1221
+ : `${SPAN_MARK}${spans.push(span) - 1}${SPAN_MARK}`
1222
+ );
1223
+
1224
+ const restore = (text, pattern, stash) =>
1225
+ text.replaceAll(
1226
+ pattern,
1227
+ byGroups(({ index }) => stash[Number(index)])
1228
+ );
1229
+
1230
+ // `convert` applied to the prose between fenced code blocks only.
1231
+ const mapProse = (text, convert) =>
1232
+ segmentBody(text)
1233
+ .map((segment) =>
1234
+ segment.code
1235
+ ? segment.lines.join("\n")
1236
+ : convert(segment.lines.join("\n"))
1237
+ )
1238
+ .join("\n");
1239
+
1240
+ // --- Attributes ---------------------------------------------------------------
1241
+
1242
+ const attributeValue = (groups) => {
1243
+ const quoted =
1244
+ groups.dq ?? groups.sq ?? groups.edq ?? groups.esq ?? groups.tpl;
1245
+ if (quoted !== undefined) {
1246
+ return quoted;
1247
+ }
1248
+ if (groups.expression === undefined) {
1249
+ return true;
1250
+ }
1251
+ const trimmed = groups.expression.trim();
1252
+ if (trimmed === "true" || trimmed === "false") {
1253
+ return trimmed === "true";
1254
+ }
1255
+ return trimmed;
1256
+ };
1257
+
1258
+ const readAttributes = (source) => {
1259
+ const attributes = {};
1260
+ const pattern =
1261
+ /(?<name>[\w:-]+)(?:\s*=\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|\{\s*(?:"(?<edq>[^"]*)"|'(?<esq>[^']*)'|`(?<tpl>[^`]*)`)\s*\}|\{(?<expression>[^}]*)\}))?/gu;
1262
+ for (const match of source.matchAll(pattern)) {
1263
+ attributes[match.groups.name] = attributeValue(match.groups);
1264
+ }
1265
+ return attributes;
1266
+ };
1267
+
1268
+ const attribute = (value) =>
1269
+ `"${String(value).replaceAll("&", "&amp;").replaceAll('"', "&quot;")}"`;
1270
+
1271
+ // Text moved from a prop or JSON into MDX children: braces would start an
1272
+ // expression, and `<` a tag.
1273
+ const escapeMdxText = (text) =>
1274
+ text
1275
+ .replaceAll("{", String.raw`\{`)
1276
+ .replaceAll("}", String.raw`\}`)
1277
+ .replaceAll("<", "&lt;");
1278
+
1279
+ // Markdown moved from JSON into MDX children: escaped like prop text, except
1280
+ // inline code and fenced code, which show their text as written.
1281
+ const escapeMdxMarkdown = (text) =>
1282
+ mapProse(text, (prose) => {
1283
+ const spans = [];
1284
+ return restore(
1285
+ escapeMdxText(stashSpans(prose, spans)),
1286
+ SPAN_PLACEHOLDER,
1287
+ spans
1288
+ );
1289
+ });
1290
+
1291
+ const altFromFile = (src) => {
1292
+ const name = String(src).split(/[?#]/u)[0].split("/").pop() ?? "";
1293
+ const base = decodeURIComponent(name)
1294
+ .replace(/\.[a-z0-9]+$/iu, "")
1295
+ .replace(/^(?:[0-9a-f]{7,}-)+/iu, "")
1296
+ .replaceAll(/[-_]+/gu, " ")
1297
+ .replace(/\s*\(\d+\)$/u, "")
1298
+ .trim();
1299
+ const generic =
1300
+ /^(?:screen ?shot|image|img|group|untitled)\b/iu.test(base) ||
1301
+ /^[\d\s]*$/u.test(base) ||
1302
+ base.length < 3;
1303
+ return generic ? "Screenshot" : base;
1304
+ };
1305
+
1306
+ const usableAlt = (value) => {
1307
+ const text = textOf(value);
1308
+ return (
1309
+ text !== undefined &&
1310
+ !/^\{?\s*\d+\s*\}?$/u.test(text) &&
1311
+ !/\.(?:png|jpe?g|gif|svg|webp)$/iu.test(text)
1312
+ );
1313
+ };
1314
+
1315
+ // --- Callouts -----------------------------------------------------------------
1316
+
1317
+ const calloutType = (emoji) =>
1318
+ CALLOUT_EMOJI.find(([pattern]) => pattern.test(emoji))?.[1] ?? "note";
1319
+
1320
+ const directive = (type, title, body, indent = "") => {
1321
+ const label = title ? `[${title}]` : "";
1322
+ const lines = body === "" ? [] : body.split("\n");
1323
+ return [
1324
+ `${indent}:::${type}${label}`,
1325
+ ...lines.map((line) => (line === "" ? "" : `${indent}${line}`)),
1326
+ `${indent}:::`,
1327
+ ].join("\n");
1328
+ };
1329
+
1330
+ const QUOTE_LINE = /^(?<indent>[ \t]*)>[ \t]?(?<content>.*)$/u;
1331
+ // Lines that start a block of their own, so they can't continue a quoted
1332
+ // paragraph lazily (without their `>`).
1333
+ const BLOCK_START =
1334
+ /^[ \t]*(?:>|[-*+][ \t]|\d+[.)][ \t]|#{1,6}(?:[ \t]|$)|`{3}|~{3}|<|:::|\||(?:[-*_][ \t]*){3,}$)/u;
1335
+ const NOT_PARAGRAPH = /^(?:#{1,6}(?:[ \t]|$)|(?:[-*_][ \t]*){3,}$)/u;
1336
+
1337
+ // The emoji opening a callout: ReadMe wants whitespace (or nothing) after it.
1338
+ const calloutEmoji = (content) => {
1339
+ const text = content.trimStart();
1340
+ const emoji = LEADING_EMOJI.exec(text)?.[0];
1341
+ return emoji && /^(?:\s|$)/u.test(text.slice(emoji.length))
1342
+ ? emoji
1343
+ : undefined;
1344
+ };
1345
+
1346
+ // One emoji blockquote from `start`: its title line, the quoted lines after
1347
+ // it (fenced code inside them included), and lazy continuation lines, which
1348
+ // belong to the quote's paragraph as in CommonMark. Returns the body and the
1349
+ // index past the quote.
1350
+ const readQuote = (lines, start, indent) => {
1351
+ const body = [];
1352
+ let fence = null;
1353
+ let paragraph = true;
1354
+ let index = start;
1355
+ for (; index < lines.length; index += 1) {
1356
+ const line = lines[index];
1357
+ if (line.startsWith(`${indent}>`)) {
1358
+ const content = line.slice(indent.length + 1).replace(/^[ \t]/u, "");
1359
+ const fenceLine = fenceOf(content);
1360
+ if (fenceLine && fence === null) {
1361
+ fence = fenceLine.ticks;
1362
+ } else if (
1363
+ fenceLine &&
1364
+ fenceLine.ticks[0] === fence[0] &&
1365
+ fenceLine.ticks.length >= fence.length &&
1366
+ fenceLine.info.trim() === ""
1367
+ ) {
1368
+ fence = null;
1369
+ }
1370
+ paragraph =
1371
+ fence === null &&
1372
+ !fenceLine &&
1373
+ content.trim() !== "" &&
1374
+ !NOT_PARAGRAPH.test(content.trim());
1375
+ body.push(content);
1376
+ } else if (
1377
+ paragraph &&
1378
+ line.trim() !== "" &&
1379
+ line.startsWith(indent) &&
1380
+ !BLOCK_START.test(line)
1381
+ ) {
1382
+ body.push(line.slice(indent.length).trimStart());
1383
+ } else {
1384
+ break;
1385
+ }
1386
+ }
1387
+ return [body.join("\n").replaceAll(/^\n+|\n+$/gu, ""), index];
1388
+ };
1389
+
1390
+ const convertBlockquoteCallouts = (text, file) => {
1391
+ const lines = text.split("\n");
1392
+ const out = [];
1393
+ for (let index = 0; index < lines.length;) {
1394
+ const match = QUOTE_LINE.exec(lines[index]);
1395
+ const emoji = match ? calloutEmoji(match.groups.content) : undefined;
1396
+ if (!emoji) {
1397
+ out.push(lines[index]);
1398
+ index += 1;
1399
+ continue;
1400
+ }
1401
+ const { content, indent } = match.groups;
1402
+ const type = calloutType(emoji);
1403
+ const title = content
1404
+ .trimStart()
1405
+ .slice(emoji.length)
1406
+ .trim()
1407
+ .replace(/^#{1,6}\s*/u, "");
1408
+ const [body, next] = readQuote(lines, index + 1, indent);
1409
+ if (/[[\]]/u.test(title)) {
1410
+ note(file, `callout title holds brackets, check it renders: ${title}`);
1411
+ }
1412
+ count(`emoji blockquote → :::${type}`);
1413
+ out.push(directive(type, title, body, indent));
1414
+ index = next;
1415
+ }
1416
+ return out.join("\n");
1417
+ };
1418
+
1419
+ // Dedent `text` (a JSX child block) to its least-indented line. A stashed
1420
+ // code block's own lines move with its placeholder line, less `keep` (the
1421
+ // indentation the caller puts back on every line it writes).
1422
+ const dedent = (text, code = [], keep = 0) => {
1423
+ const lines = text.replaceAll(/^\n+|\s+$/gu, "").split("\n");
1424
+ const indents = lines
1425
+ .filter((line) => line.trim() !== "")
1426
+ .map((line) => line.match(/^[ \t]*/u)[0].length);
1427
+ const cut = indents.length > 0 ? Math.min(...indents) : 0;
1428
+ const strip = new RegExp(`^[ \\t]{0,${Math.max(cut - keep, 0)}}`, "u");
1429
+ return lines
1430
+ .map((line) => {
1431
+ for (const { groups } of line.matchAll(CODE_PLACEHOLDER)) {
1432
+ const index = Number(groups.index);
1433
+ code[index] = code[index]
1434
+ .split("\n")
1435
+ .map((codeLine, position) =>
1436
+ position === 0 ? codeLine : codeLine.replace(strip, "")
1437
+ )
1438
+ .join("\n");
1439
+ }
1440
+ return line.slice(cut);
1441
+ })
1442
+ .join("\n");
1443
+ };
1444
+
1445
+ const convertCalloutComponents = (text, code) =>
1446
+ text.replaceAll(
1447
+ /^(?<indent>[ \t]*)<Callout\b(?<source>[^>]*)>(?<inner>[\s\S]*?)<\/Callout>/gmu,
1448
+ byGroups(({ indent, inner, source }, all) => {
1449
+ const attributes = readAttributes(source);
1450
+ // Blume's own <Callout type> stays.
1451
+ if (attributes.type !== undefined && attributes.theme === undefined) {
1452
+ return all;
1453
+ }
1454
+ const type =
1455
+ CALLOUT_THEME[attributes.theme] ??
1456
+ calloutType(textOf(attributes.icon) ?? "");
1457
+ const lines = dedent(inner, code, indent.length).split("\n");
1458
+ let title = "";
1459
+ if (/^#{1,6}\s/u.test(lines[0] ?? "")) {
1460
+ title = lines
1461
+ .shift()
1462
+ .replace(/^#{1,6}\s*/u, "")
1463
+ .trim();
1464
+ }
1465
+ count(`<Callout> → :::${type}`);
1466
+ const body = lines.join("\n").replace(/^\n+/u, "");
1467
+ return directive(type, title, body, indent);
1468
+ })
1469
+ );
1470
+
1471
+ // --- Components ---------------------------------------------------------------
1472
+
1473
+ const tableCells = (inner) =>
1474
+ [...inner.matchAll(/<tr\b[^>]*>(?<row>[\s\S]*?)<\/tr>/gu)].map((row) =>
1475
+ [
1476
+ ...row.groups.row.matchAll(
1477
+ /<t(?<kind>[hd])\b[^>]*>(?<cell>[\s\S]*?)<\/t\k<kind>>/gu
1478
+ ),
1479
+ ].map((cell) =>
1480
+ cell.groups.cell
1481
+ .split(/\n[ \t]*\n/u)
1482
+ .map((paragraph) =>
1483
+ paragraph
1484
+ .split("\n")
1485
+ .map((line) => line.trim())
1486
+ .join(" ")
1487
+ .trim()
1488
+ )
1489
+ .filter(Boolean)
1490
+ )
1491
+ );
1492
+
1493
+ const hasBlocks = (rows) =>
1494
+ rows.some((row) =>
1495
+ row.some((cell) =>
1496
+ cell.some((paragraph) =>
1497
+ /^(?:[-*+] |\d+\. |:::|<(?:ul|ol|table|div|pre|Tabs|Accordion)\b)|\uE000/u.test(
1498
+ paragraph
1499
+ )
1500
+ )
1501
+ )
1502
+ );
1503
+
1504
+ // A cell spanning columns or rows has no Markdown form.
1505
+ const SPANNING_CELL =
1506
+ /<t[hd]\b[^>]*\b(?:colspan|rowspan)\s*=\s*\{?\s*["']?\s*(?!1\b)\d/iu;
1507
+
1508
+ const tableToMarkdown = (inner, file, spans = []) => {
1509
+ const rows = tableCells(inner);
1510
+ if (rows.length === 0) {
1511
+ return null;
1512
+ }
1513
+ if (hasBlocks(rows)) {
1514
+ note(file, "table with lists or blocks in its cells kept as <table>");
1515
+ return null;
1516
+ }
1517
+ if (SPANNING_CELL.test(inner)) {
1518
+ note(file, "table with cells spanning columns or rows kept as <table>");
1519
+ return null;
1520
+ }
1521
+ const width = Math.max(...rows.map((row) => row.length));
1522
+ // A pipe splits a Markdown table cell even inside inline code, unless
1523
+ // escaped (GFM drops the backslash there).
1524
+ const escapePipes = (cell) =>
1525
+ cell.replaceAll("|", String.raw`\|`).replaceAll(
1526
+ SPAN_PLACEHOLDER,
1527
+ byGroups(({ index }, all) => {
1528
+ spans[Number(index)] = spans[Number(index)].replaceAll(
1529
+ "|",
1530
+ String.raw`\|`
1531
+ );
1532
+ return all;
1533
+ })
1534
+ );
1535
+ const render = (row) =>
1536
+ `| ${Array.from({ length: width }, (_, index) =>
1537
+ escapePipes((row[index] ?? []).join("<br />"))
1538
+ ).join(" | ")} |`;
1539
+ const [head, ...rest] = rows;
1540
+ const kept = rest.filter((row) => row.some((cell) => cell.length > 0));
1541
+ if (kept.length !== rest.length) {
1542
+ count("table rows dropped (empty)", rest.length - kept.length);
1543
+ }
1544
+ return [
1545
+ render(head),
1546
+ `| ${Array.from({ length: width }, () => "---").join(" | ")} |`,
1547
+ ...kept.map(render),
1548
+ ].join("\n");
1549
+ };
1550
+
1551
+ const pickAlt = (attributes, caption) => {
1552
+ for (const candidate of [attributes.alt, caption, attributes.title]) {
1553
+ if (usableAlt(candidate)) {
1554
+ return textOf(candidate);
1555
+ }
1556
+ }
1557
+ return null;
1558
+ };
1559
+
1560
+ const imageMarkdown = (attributes, caption, file) => {
1561
+ const src = textOf(attributes.src);
1562
+ if (src === undefined) {
1563
+ note(file, "an <Image> without src was left as is");
1564
+ return null;
1565
+ }
1566
+ let alt = pickAlt(attributes, caption);
1567
+ if (alt === null) {
1568
+ alt = altFromFile(src);
1569
+ note(file, `image alt derived from its file name ("${alt}"), review it`);
1570
+ count("image alt derived");
1571
+ }
1572
+ for (const key of [
1573
+ "align",
1574
+ "width",
1575
+ "height",
1576
+ "border",
1577
+ "wrap",
1578
+ "className",
1579
+ "lazy",
1580
+ ]) {
1581
+ if (attributes[key] !== undefined) {
1582
+ count(`<Image> ${key} dropped`);
1583
+ }
1584
+ }
1585
+ const markdown = `![${alt.replaceAll(/[[\]]/gu, "")}](${src})`;
1586
+ if (caption) {
1587
+ count("<Image> caption → <Frame>");
1588
+ return `<Frame caption=${attribute(caption)}>\n\n${markdown}\n\n</Frame>`;
1589
+ }
1590
+ return markdown;
1591
+ };
1592
+
1593
+ const decodeRepeatedly = (text) => {
1594
+ let decoded = text;
1595
+ for (let pass = 0; pass < 3; pass += 1) {
1596
+ try {
1597
+ decoded = decodeURIComponent(decoded);
1598
+ } catch {
1599
+ return decoded;
1600
+ }
1601
+ }
1602
+ return decoded;
1603
+ };
1604
+
1605
+ const youTubeId = (...candidates) => {
1606
+ for (const candidate of candidates.map(textOf).filter(Boolean)) {
1607
+ const match =
1608
+ /(?:youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^"'\s]*&)?v=|embed\/|shorts\/)|youtu\.be\/)(?<id>[\w-]{11})/u.exec(
1609
+ decodeRepeatedly(candidate)
1610
+ );
1611
+ if (match) {
1612
+ return match.groups.id;
1613
+ }
1614
+ }
1615
+ };
1616
+
1617
+ const embedReplacement = (attributes, file) => {
1618
+ const id = youTubeId(attributes.url, attributes.href, attributes.html);
1619
+ if (id) {
1620
+ count("<Embed> → <YouTube>");
1621
+ const title = textOf(attributes.title);
1622
+ return `<YouTube id="${id}"${title ? ` title=${attribute(title)}` : ""} />`;
1623
+ }
1624
+ const url = textOf(attributes.url) ?? textOf(attributes.href);
1625
+ if (url === undefined) {
1626
+ note(file, "an <Embed> without url was left as is");
1627
+ return null;
1628
+ }
1629
+ const name =
1630
+ textOf(attributes.title) ??
1631
+ decodeURIComponent(url.split("/").pop() ?? url).replace(/\.pdf$/iu, "");
1632
+ const label = attributes.typeOfEmbed === "pdf" ? `${name} (PDF)` : name;
1633
+ note(
1634
+ file,
1635
+ `embed of ${url} became a link; use an <iframe> if it must stay inline`
1636
+ );
1637
+ count("<Embed> → link");
1638
+ return `[${escapeMdxText(label)}](${url})`;
1639
+ };
1640
+
1641
+ const convertImages = (text, file) => {
1642
+ let out = text.replaceAll(
1643
+ new RegExp(
1644
+ String.raw`<Image\b(?<source>${ATTRS})(?<!\/)>(?<children>[\s\S]*?)<\/Image>`,
1645
+ "gu"
1646
+ ),
1647
+ byGroups(
1648
+ ({ children, source }, all) =>
1649
+ imageMarkdown(
1650
+ readAttributes(source),
1651
+ children.trim().replaceAll(/\s+/gu, " "),
1652
+ file
1653
+ ) ?? all
1654
+ )
1655
+ );
1656
+ out = out.replaceAll(
1657
+ new RegExp(String.raw`<Image\b(?<source>${ATTRS})\/>`, "gu"),
1658
+ byGroups(({ source }, all) => {
1659
+ const attributes = readAttributes(source);
1660
+ return (
1661
+ imageMarkdown(attributes, textOf(attributes.caption) ?? "", file) ?? all
1662
+ );
1663
+ })
1664
+ );
1665
+ // Legacy Markdown images whose alt is a pixel width and whose title is the
1666
+ // original file name: ![1544](url "image (1).png").
1667
+ return out.replaceAll(
1668
+ /!\[\d+\]\((?<src>[^)\s]+)(?:\s+"(?<title>[^"]*)")?\)/gu,
1669
+ byGroups(({ src, title }) => {
1670
+ const alt = usableAlt(title) ? title : altFromFile(src);
1671
+ note(file, `image alt was a pixel width, now "${alt}": review it`);
1672
+ count("image alt derived");
1673
+ return `![${alt.replaceAll(/[[\]]/gu, "")}](${src})`;
1674
+ })
1675
+ );
1676
+ };
1677
+
1678
+ const convertEmbeds = (text, file) =>
1679
+ text
1680
+ .replaceAll(
1681
+ new RegExp(String.raw`<Embed\b(?<source>${ATTRS})\/>`, "gu"),
1682
+ byGroups(
1683
+ ({ source }, all) =>
1684
+ embedReplacement(readAttributes(source), file) ?? all
1685
+ )
1686
+ )
1687
+ .replaceAll(
1688
+ /\[(?<label>[^\]]*)\]\((?<url>[^)\s]+)\s+"@embed"\)/gu,
1689
+ byGroups(
1690
+ ({ label, url }, all) =>
1691
+ embedReplacement({ title: label, url }, file) ?? all
1692
+ )
1693
+ );
1694
+
1695
+ const convertTables = (text, file, spans) =>
1696
+ text.replaceAll(
1697
+ /<Table\b[^>]*>(?<inner>[\s\S]*?)<\/Table>/gu,
1698
+ byGroups(({ inner }, all) => {
1699
+ const markdown = tableToMarkdown(inner, file, spans);
1700
+ if (markdown === null) {
1701
+ count("<Table> → <table>");
1702
+ return all
1703
+ .replace(/^<Table\b[^>]*>/u, "<table>")
1704
+ .replace(/<\/Table>$/u, "</table>");
1705
+ }
1706
+ count("<Table> → Markdown table");
1707
+ return `\n${markdown}\n`;
1708
+ })
1709
+ );
1710
+
1711
+ const CSS_COLOR = /^(?:#[0-9a-f]{3,8}|[a-z]+|(?:rgb|hsl)a?\(.*\)|var\(.*\))$/iu;
1712
+
1713
+ const cardProps = (attributes, file, context) => {
1714
+ const props = [];
1715
+ if (attributes.title !== undefined) {
1716
+ props.push(`title=${attribute(attributes.title)}`);
1717
+ }
1718
+ const icon = lucideFor(attributes.icon, file);
1719
+ if (icon) {
1720
+ props.push(`icon="${icon}"`);
1721
+ }
1722
+ const href = textOf(attributes.href);
1723
+ if (href !== undefined) {
1724
+ props.push(`href=${attribute(context.rewrite(href, file))}`);
1725
+ }
1726
+ const color = textOf(attributes.iconColor);
1727
+ if (color !== undefined && CSS_COLOR.test(color)) {
1728
+ props.push(`color=${attribute(color)}`);
1729
+ } else if (color !== undefined) {
1730
+ note(file, `card iconColor dropped (not a CSS color): ${color}`);
1731
+ }
1732
+ if (attributes.kind !== undefined) {
1733
+ note(file, `card kind="${attributes.kind}" dropped`);
1734
+ }
1735
+ return props;
1736
+ };
1737
+
1738
+ const convertCards = (text, file, context) =>
1739
+ text
1740
+ .replaceAll(
1741
+ /<Cards\b(?<source>[^>]*)>(?<inner>[\s\S]*?)<\/Cards>/gu,
1742
+ byGroups(({ inner, source }) => {
1743
+ const declared = Number(
1744
+ String(readAttributes(source).columns ?? "").replaceAll(/\D/gu, "")
1745
+ );
1746
+ const cards = (inner.match(/<Card\b/gu) ?? []).length;
1747
+ const cols = declared > 0 ? declared : Math.max(1, Math.min(cards, 3));
1748
+ count("<Cards> → <CardGroup>");
1749
+ return `<CardGroup cols={${cols}}>${inner}</CardGroup>`;
1750
+ })
1751
+ )
1752
+ .replaceAll(
1753
+ new RegExp(String.raw`<Card\b(?<source>${ATTRS})(?<close>\/?)>`, "gu"),
1754
+ byGroups(({ close, source }, all) => {
1755
+ const attributes = readAttributes(source);
1756
+ const readmeProps = ["iconColor", "badge", "target", "kind"].some(
1757
+ (key) => attributes[key] !== undefined
1758
+ );
1759
+ const icon = textOf(attributes.icon);
1760
+ if (
1761
+ !readmeProps &&
1762
+ (icon === undefined || /^[a-z0-9-]+$/u.test(icon))
1763
+ ) {
1764
+ return all;
1765
+ }
1766
+ count("<Card> props mapped");
1767
+ const props = cardProps(attributes, file, context).join(" ");
1768
+ if (close === "/") {
1769
+ return `<Card ${props} />`;
1770
+ }
1771
+ const badge = textOf(attributes.badge);
1772
+ return `<Card ${props}>${badge ? `\n<Badge>${escapeMdxText(badge)}</Badge>\n` : ""}`;
1773
+ })
1774
+ );
1775
+
1776
+ const convertColumns = (text) =>
1777
+ text.replaceAll(
1778
+ /<Columns\b(?<source>[^>]*)>(?<inner>[\s\S]*?)<\/Columns>/gu,
1779
+ byGroups(({ inner, source }, all) => {
1780
+ if (readAttributes(source).cols !== undefined) {
1781
+ return all;
1782
+ }
1783
+ const columns = (inner.match(/<Column\b/gu) ?? []).length || 2;
1784
+ count("<Columns> layout → cols");
1785
+ return `<Columns cols={${columns}}>${inner}</Columns>`;
1786
+ })
1787
+ );
1788
+
1789
+ const accordionItem = ({ groups }, file) => {
1790
+ const attributes = readAttributes(groups.source);
1791
+ const icon = lucideFor(attributes.icon, file);
1792
+ return `${groups.indent} <AccordionItem title=${attribute(attributes.title)}${icon ? ` icon="${icon}"` : ""}>${groups.inner}</AccordionItem>`;
1793
+ };
1794
+
1795
+ const accordionRuns = (text) => {
1796
+ const runs = [];
1797
+ for (const match of text.matchAll(
1798
+ /^(?<indent>[ \t]*)<Accordion\b(?<source>[^>]*\btitle=[^>]*)>(?<inner>[\s\S]*?)<\/Accordion>[ \t]*$/gmu
1799
+ )) {
1800
+ const last = runs.at(-1);
1801
+ const end = last ? last.at(-1).index + last.at(-1)[0].length : -1;
1802
+ if (last && /^\s*$/u.test(text.slice(end, match.index))) {
1803
+ last.push(match);
1804
+ } else {
1805
+ runs.push([match]);
1806
+ }
1807
+ }
1808
+ return runs;
1809
+ };
1810
+
1811
+ // ReadMe's <Accordion title> is one collapsible: a run of them becomes one
1812
+ // Blume <Accordion> of <AccordionItem>s, a lone one an <Expandable> (which
1813
+ // takes no icon, so a lone one with an icon stays a one-item <Accordion>).
1814
+ const convertAccordions = (text, file) => {
1815
+ let out = text;
1816
+ for (const run of accordionRuns(text).toReversed()) {
1817
+ const start = run[0].index;
1818
+ const end = run.at(-1).index + run.at(-1)[0].length;
1819
+ if (run.some((match) => /<Accordion\b/u.test(match.groups.inner))) {
1820
+ note(file, "nested <Accordion> left as is");
1821
+ continue;
1822
+ }
1823
+ const { indent, inner, source } = run[0].groups;
1824
+ let replacement;
1825
+ if (run.length === 1 && textOf(readAttributes(source).icon) === undefined) {
1826
+ const title = attribute(readAttributes(source).title);
1827
+ replacement = `${indent}<Expandable title=${title}>${inner}</Expandable>`;
1828
+ count("<Accordion> → <Expandable>");
1829
+ } else {
1830
+ const items = run.map((match) => accordionItem(match, file));
1831
+ replacement = `${indent}<Accordion>\n${items.join("\n\n")}\n${indent}</Accordion>`;
1832
+ count("<Accordion> run → <Accordion> of <AccordionItem>");
1833
+ }
1834
+ out = `${out.slice(0, start)}${replacement}${out.slice(end)}`;
1835
+ }
1836
+ return out;
1837
+ };
1838
+
1839
+ const convertTabs = (text, file) =>
1840
+ text.replaceAll(
1841
+ /<Tab\b(?<source>[^>]*)>/gu,
1842
+ byGroups(({ source }, all) => {
1843
+ const attributes = readAttributes(source);
1844
+ const icon = textOf(attributes.icon);
1845
+ const bare = icon === undefined || /^[a-z0-9-]+$/u.test(icon);
1846
+ if (bare && attributes.iconColor === undefined) {
1847
+ return all;
1848
+ }
1849
+ const lucide = lucideFor(icon, file);
1850
+ count("<Tab> icon mapped");
1851
+ return `<Tab title=${attribute(attributes.title ?? "")}${lucide ? ` icon="${lucide}"` : ""}>`;
1852
+ })
1853
+ );
1854
+
1855
+ const convertGlossary = (text, file, context) => {
1856
+ const replace = (term, all) => {
1857
+ const definition = context.glossary.get(term.trim().toLowerCase());
1858
+ if (definition === undefined) {
1859
+ note(
1860
+ file,
1861
+ `glossary term "${term.trim()}" left as is: pass its definition with --glossary`
1862
+ );
1863
+ return all;
1864
+ }
1865
+ count("glossary term → <Tooltip>");
1866
+ return `<Tooltip tip=${attribute(definition)}>${term.trim()}</Tooltip>`;
1867
+ };
1868
+ return text
1869
+ .replaceAll(
1870
+ /<Glossary>(?<term>[^<]+)<\/Glossary>/gu,
1871
+ byGroups(({ term }, all) => replace(term, all))
1872
+ )
1873
+ .replaceAll(
1874
+ /<<glossary:(?<term>[^<>\n]+)>>/gu,
1875
+ byGroups(({ term }, all) => replace(term, all))
1876
+ );
1877
+ };
1878
+
1879
+ const convertLinksAndRecipes = (text) =>
1880
+ text
1881
+ .replaceAll(
1882
+ /<Anchor\b(?<source>[^>]*)>(?<label>[\s\S]*?)<\/Anchor>/gu,
1883
+ byGroups(({ label, source }, all) => {
1884
+ const attributes = readAttributes(source);
1885
+ const href = textOf(attributes.href) ?? textOf(attributes.url);
1886
+ if (href === undefined) {
1887
+ return all;
1888
+ }
1889
+ count("<Anchor> → Markdown link");
1890
+ return `[${label.trim()}](${href})`;
1891
+ })
1892
+ )
1893
+ .replaceAll(
1894
+ /<Recipe\b(?<source>[^>]*)\/>/gu,
1895
+ byGroups(({ source }, all) => {
1896
+ const attributes = readAttributes(source);
1897
+ const slug = textOf(attributes.slug);
1898
+ if (slug === undefined) {
1899
+ return all;
1900
+ }
1901
+ count("<Recipe> → <Card>");
1902
+ return `<Card title=${attribute(attributes.title ?? slug)} href="/recipes/${slug}" />`;
1903
+ })
1904
+ );
1905
+
1906
+ const convertVariables = (text, file, context) => {
1907
+ const variable = (name, all) => {
1908
+ if (!/^[A-Za-z_][\w-]*$/u.test(name)) {
1909
+ note(
1910
+ file,
1911
+ `variable ${all} left as is (Blume names take letters, digits, _ and -)`
1912
+ );
1913
+ return all;
1914
+ }
1915
+ context.variables.add(name);
1916
+ count("variable → {{name}}");
1917
+ return `{{${name}}}`;
1918
+ };
1919
+ return text
1920
+ .replaceAll(
1921
+ /<Variable\b[^>]*\bname=["'](?<name>[^"']+)["'][^>]*\/>/gu,
1922
+ byGroups(({ name }, all) => variable(name, all))
1923
+ )
1924
+ .replaceAll(
1925
+ /\{\s*user\.(?<name>[\w-]+)\s*\}/gu,
1926
+ byGroups(({ name }, all) => variable(name, all))
1927
+ )
1928
+ .replaceAll(
1929
+ /\{\s*user\[["'](?<name>[^"']+)["']\]\s*\}/gu,
1930
+ byGroups(({ name }, all) => variable(name, all))
1931
+ )
1932
+ .replaceAll(
1933
+ /<<(?!glossary:)(?<name>[A-Za-z_][\w.-]*)>>/gu,
1934
+ byGroups(({ name }, all) => variable(name, all))
1935
+ );
1936
+ };
1937
+
1938
+ const convertComponents = (text, file, context) => {
1939
+ let out = convertImages(text, file);
1940
+ out = convertEmbeds(out, file);
1941
+ out = convertTables(out, file, context.spans);
1942
+ out = convertCards(out, file, context);
1943
+ out = convertColumns(out);
1944
+ out = convertAccordions(out, file);
1945
+ out = convertTabs(out, file);
1946
+ out = convertGlossary(out, file, context);
1947
+ out = convertLinksAndRecipes(out);
1948
+ return convertVariables(out, file, context);
1949
+ };
1950
+
1951
+ const convertShortcodes = (text, file) =>
1952
+ text.replaceAll(
1953
+ /(?<lead>^|[^\w:/\\])(?<code>:(?<name>[a-z0-9_+-]+):)(?![\w:])/gmu,
1954
+ byGroups(({ code, lead, name }, all) => {
1955
+ if (SHORTCODES.has(name)) {
1956
+ count(":shortcode: → emoji");
1957
+ return `${lead}${SHORTCODES.get(name)}`;
1958
+ }
1959
+ if (name.startsWith("fa-")) {
1960
+ note(file, `Font Awesome shortcode ${code} dropped`);
1961
+ return lead;
1962
+ }
1963
+ if (/[a-z]/u.test(name)) {
1964
+ note(
1965
+ file,
1966
+ `unknown emoji shortcode ${code} left as text: paste the emoji`
1967
+ );
1968
+ }
1969
+ return all;
1970
+ })
1971
+ );
1972
+
1973
+ // --- Magic blocks ---------------------------------------------------------------
1974
+
1975
+ const magicTable = (data) => {
1976
+ const cols = Number(data.cols) || 0;
1977
+ const rows = Number(data.rows) || 0;
1978
+ const cells = recordOf(data.data);
1979
+ const cell = (key) =>
1980
+ String(cells[key] ?? "")
1981
+ .split("\n")
1982
+ .map((line) => line.trim())
1983
+ .filter(Boolean)
1984
+ .join("<br />")
1985
+ .replaceAll("|", String.raw`\|`);
1986
+ const line = (prefix) =>
1987
+ `| ${Array.from({ length: cols }, (_, column) => cell(`${prefix}-${column}`)).join(" | ")} |`;
1988
+ return [
1989
+ line("h"),
1990
+ `| ${Array.from({ length: cols }, () => "---").join(" | ")} |`,
1991
+ ...Array.from({ length: rows }, (_, row) => line(String(row))),
1992
+ ].join("\n");
1993
+ };
1994
+
1995
+ const magicImage = (data, file) => {
1996
+ const image = listOf(data.images).find((entry) =>
1997
+ Array.isArray(entry?.image)
1998
+ );
1999
+ if (!image) {
2000
+ return "";
2001
+ }
2002
+ const [src, title, alt] = image.image;
2003
+ return imageMarkdown({ alt, src, title }, textOf(image.caption) ?? "", file);
2004
+ };
2005
+
2006
+ const MAGIC = {
2007
+ "api-header": (data) =>
2008
+ `${"#".repeat(Math.min(Math.max(Number(data.level) || 2, 1), 6))} ${textOf(data.title) ?? ""}`,
2009
+ callout: (data) =>
2010
+ directive(
2011
+ MAGIC_CALLOUT[data.type] ?? "note",
2012
+ textOf(data.title) ?? "",
2013
+ escapeMdxMarkdown(textOf(data.body) ?? "")
2014
+ ),
2015
+ code: (data) =>
2016
+ listOf(data.codes)
2017
+ .map(
2018
+ (code) =>
2019
+ `\`\`\`${textOf(code.language) ?? ""}${code.name ? ` ${code.name}` : ""}\n${String(code.code ?? "").replace(/\n$/u, "")}\n\`\`\``
2020
+ )
2021
+ .join("\n"),
2022
+ embed: (data, file) => embedReplacement(data, file),
2023
+ html: (data) => {
2024
+ const html = textOf(data.html);
2025
+ return html === undefined
2026
+ ? null
2027
+ : `<HTMLBlock>{\`${html.replaceAll("`", "\\`")}\`}</HTMLBlock>`;
2028
+ },
2029
+ image: magicImage,
2030
+ parameters: magicTable,
2031
+ recipe: (data) =>
2032
+ `<Card title=${attribute(data.title ?? data.slug ?? "")} href="/recipes/${textOf(data.slug) ?? ""}" />`,
2033
+ table: magicTable,
2034
+ "tutorial-tile": (data) =>
2035
+ `<Card title=${attribute(data.title ?? data.slug ?? "")} href="/recipes/${textOf(data.slug) ?? ""}" />`,
2036
+ };
2037
+
2038
+ // A block's JSON never holds a line opening another block, so one missing its
2039
+ // `[/block]` can't swallow the next.
2040
+ const convertMagicBlocks = (text, file) => {
2041
+ const out = text.replaceAll(
2042
+ /^\[block:(?<type>[\w-]+)\]\n(?<json>(?:(?!\n\[block:)[\s\S])*?)\n\[\/block\]$/gmu,
2043
+ byGroups(({ json, type }, all) => {
2044
+ if (!MAGIC_TYPES.has(type)) {
2045
+ note(file, `[block:${type}] has no conversion; rewrite it by hand`);
2046
+ return all;
2047
+ }
2048
+ let data;
2049
+ try {
2050
+ data = recordOf(JSON.parse(json));
2051
+ } catch {
2052
+ note(file, `[block:${type}] JSON didn't parse; convert it by hand`);
2053
+ return all;
2054
+ }
2055
+ count(`[block:${type}] converted`);
2056
+ return MAGIC[type](data, file) ?? all;
2057
+ })
2058
+ );
2059
+ for (const { groups } of out.matchAll(
2060
+ /^\[block:(?<type>[\w-]+)\]$(?<rest>(?:(?!\n\[block:)[\s\S])*)/gmu
2061
+ )) {
2062
+ if (!/^\[\/block\]$/mu.test(groups.rest)) {
2063
+ note(file, `[block:${groups.type}] has no [/block]; convert it by hand`);
2064
+ }
2065
+ }
2066
+ return out;
2067
+ };
2068
+
2069
+ // --- HTML and judgment ------------------------------------------------------------
2070
+
2071
+ /** `text` without `pattern`'s matches, removed again until none is left. */
2072
+ const removeAll = (text, pattern) => {
2073
+ let out = text;
2074
+ let previous;
2075
+ do {
2076
+ previous = out;
2077
+ out = out.replaceAll(pattern, "");
2078
+ } while (out !== previous);
2079
+ return out;
2080
+ };
2081
+
2082
+ const htmlIsSafe = (html) =>
2083
+ !/<\s*(?:style|script)\b/iu.test(html) && !/\son[a-z]+\s*=/iu.test(html);
2084
+
2085
+ const unwrapHtmlBlocks = (text, file) =>
2086
+ text.replaceAll(
2087
+ /<HTMLBlock\b[^>]*>\s*\{\s*`(?<raw>[\s\S]*?)`\s*\}\s*<\/HTMLBlock>/gu,
2088
+ byGroups(({ raw }, all) => {
2089
+ const html = raw.replaceAll("\\`", "`").replaceAll("\\${", "${");
2090
+ if (!htmlIsSafe(html)) {
2091
+ note(
2092
+ file,
2093
+ "<HTMLBlock> with <style>, <script>, or event handlers left as is: rebuild it with Blume components or an island"
2094
+ );
2095
+ count("<HTMLBlock> left for review");
2096
+ return all;
2097
+ }
2098
+ count("<HTMLBlock> unwrapped");
2099
+ const cleaned = removeAll(html, /<!--[\s\S]*?-->/gu)
2100
+ .replaceAll("{", "&#123;")
2101
+ .replaceAll("}", "&#125;")
2102
+ .replaceAll(VOID_TAGS, "<$<tag>$<rest> />")
2103
+ .trim();
2104
+ return `\n${cleaned}\n`;
2105
+ })
2106
+ );
2107
+
2108
+ const JUDGMENT = [
2109
+ [
2110
+ /\son[A-Z]\w*\s*=/u,
2111
+ "inline JSX event handlers (onClick, …) do nothing in Blume: rebuild that widget as an island",
2112
+ ],
2113
+ [
2114
+ /<\s*style\b/iu,
2115
+ "<style> block: page styles apply to the whole page, sidebar included; replace the markup with Blume components",
2116
+ ],
2117
+ [
2118
+ /<\s*script\b/iu,
2119
+ "<script> block: carry what it did over as an island or a script() adapter",
2120
+ ],
2121
+ [
2122
+ /\bclass(?:Name)?=["'][^"']*\bfa-[a-z]/u,
2123
+ 'Font Awesome <i> icons in HTML: replace with <Icon icon="…" /> (Lucide)',
2124
+ ],
2125
+ [
2126
+ /\bclass(?:Name)?=["'][^"']*\b(?:p[xytblr]?|m[xytblr]?|bg|text|rounded|flex|grid|gap|w|h)-[\w[\]./-]+/u,
2127
+ "Tailwind utility classes in HTML: they don't style anything in Blume; restyle or use components",
2128
+ ],
2129
+ ];
2130
+
2131
+ const reportJudgment = (text, file, context) => {
2132
+ for (const [pattern, message] of JUDGMENT) {
2133
+ if (pattern.test(text)) {
2134
+ note(file, message);
2135
+ }
2136
+ }
2137
+ if (context.hasEsm) {
2138
+ note(
2139
+ file,
2140
+ "import/export in the page, left untouched: a component using React hooks must move to islands/, and one defined on several pages goes in islands/ once"
2141
+ );
2142
+ }
2143
+ const unknown = new Set();
2144
+ for (const match of text.matchAll(/<(?<name>[A-Z][\w.]*)\b/gu)) {
2145
+ const { name } = match.groups;
2146
+ const builtIn =
2147
+ BLUME_COMPONENTS.has(name) ||
2148
+ name.startsWith("Tree.") ||
2149
+ name.startsWith("Color.");
2150
+ if (!builtIn) {
2151
+ unknown.add(name);
2152
+ }
2153
+ }
2154
+ for (const name of [...unknown].toSorted()) {
2155
+ note(
2156
+ file,
2157
+ context.customComponents.has(name)
2158
+ ? `custom component <${name}> (custom_blocks/${name}.mdx): port it to islands/ or replace it with a Blume component`
2159
+ : `unknown component <${name}>: replace it, or the build fails`
2160
+ );
2161
+ }
2162
+ };
2163
+
2164
+ // --- Code fences --------------------------------------------------------------
2165
+
2166
+ const tabLabel = (language) => {
2167
+ if (language === "") {
2168
+ return "Text";
2169
+ }
2170
+ return (
2171
+ LANGUAGE_LABEL[language] ??
2172
+ `${language[0].toUpperCase()}${language.slice(1)}`
2173
+ );
2174
+ };
2175
+
2176
+ const fixFence = (line, file, label) => {
2177
+ const match =
2178
+ /^(?<indent>[ \t]*)(?<ticks>`{3,}|~{3,})(?<language>[^\s`]*)\s*(?<meta>.*)$/u.exec(
2179
+ line
2180
+ );
2181
+ if (!match) {
2182
+ return line;
2183
+ }
2184
+ const { indent, language: rawLanguage, meta: rawMeta, ticks } = match.groups;
2185
+ const lower = rawLanguage.toLowerCase();
2186
+ const language = FENCE_LANGUAGE[lower] ?? lower;
2187
+ if (language !== rawLanguage) {
2188
+ count(`fence language ${rawLanguage} → ${language}`);
2189
+ }
2190
+ let meta = rawMeta.trim();
2191
+ if (meta === "" && label) {
2192
+ meta = tabLabel(lower);
2193
+ count("code tab labeled with its language");
2194
+ }
2195
+ const blumeMeta =
2196
+ /(?:^|\s)(?:title=|lineNumbers|wrap|\{[\d,-]+\}|twoslash)/u.test(meta);
2197
+ if (meta !== "" && !blumeMeta && /\s/u.test(meta)) {
2198
+ meta = `title=${JSON.stringify(meta)}`;
2199
+ count('fence title → title="…"');
2200
+ }
2201
+ return `${indent}${ticks}${language}${meta ? ` ${meta}` : ""}`;
2202
+ };
2203
+
2204
+ const withFixedFence = (segment, file, label) => ({
2205
+ ...segment,
2206
+ lines: [fixFence(segment.lines[0], file, label), ...segment.lines.slice(1)],
2207
+ });
2208
+
2209
+ const groupAdjacentFences = (segments, file) => {
2210
+ const out = [];
2211
+ for (let index = 0; index < segments.length; index += 1) {
2212
+ const segment = segments[index];
2213
+ if (!segment.code) {
2214
+ out.push(segment);
2215
+ continue;
2216
+ }
2217
+ const run = [segment];
2218
+ while (segments[index + 1]?.code) {
2219
+ index += 1;
2220
+ run.push(segments[index]);
2221
+ }
2222
+ const before = out.at(-1)?.lines.join("\n").trimEnd() ?? "";
2223
+ if (run.length === 1 || /<CodeGroup\b[^>]*>$/u.test(before)) {
2224
+ out.push(...run.map((entry) => withFixedFence(entry, file, false)));
2225
+ continue;
2226
+ }
2227
+ count("adjacent fences → <CodeGroup>");
2228
+ const [indent] = run[0].lines[0].match(/^[ \t]*/u);
2229
+ out.push({ code: false, lines: [`${indent}<CodeGroup>`, ""] });
2230
+ for (const [position, entry] of run.entries()) {
2231
+ out.push(withFixedFence(entry, file, true));
2232
+ if (position < run.length - 1) {
2233
+ out.push({ code: false, lines: [""] });
2234
+ }
2235
+ }
2236
+ out.push({ code: false, lines: ["", `${indent}</CodeGroup>`] });
2237
+ }
2238
+ return out;
2239
+ };
2240
+
2241
+ // --- Inline ESM ---------------------------------------------------------------
2242
+
2243
+ // A quote opens a string only after an operator or a keyword, so JSX text
2244
+ // like "don't" inside a component doesn't.
2245
+ const OPENS_STRING = /^(?:|[=(,:[{!&|?+\-*%;]|=>|return)$/u;
2246
+
2247
+ const nextToken = (previous, char) => {
2248
+ if (/\s/u.test(char)) {
2249
+ return previous;
2250
+ }
2251
+ if (/\w/u.test(char)) {
2252
+ return /\w$/u.test(previous) ? `${previous}${char}` : char;
2253
+ }
2254
+ return previous === "=" && char === ">" ? "=>" : char;
2255
+ };
2256
+
2257
+ // Skip a comment starting at `index`, or return -1 when there's none.
2258
+ const skipComment = (text, index) => {
2259
+ if (text[index] !== "/") {
2260
+ return -1;
2261
+ }
2262
+ if (text[index + 1] === "/") {
2263
+ const newline = text.indexOf("\n", index);
2264
+ return newline === -1 ? text.length : newline - 1;
2265
+ }
2266
+ if (text[index + 1] === "*") {
2267
+ const close = text.indexOf("*/", index + 2);
2268
+ return close === -1 ? text.length : close + 1;
2269
+ }
2270
+ return -1;
2271
+ };
2272
+
2273
+ // Advance through a string; returns the new index and whether it's still open.
2274
+ const stepString = (state, text, index) => {
2275
+ const char = text[index];
2276
+ if (char === "\\") {
2277
+ return index + 1;
2278
+ }
2279
+ if (state.quote === "`" && char === "$" && text[index + 1] === "{") {
2280
+ state.stack.push("`");
2281
+ state.quote = null;
2282
+ return index + 1;
2283
+ }
2284
+ if (char === state.quote || (char === "\n" && state.quote !== "`")) {
2285
+ state.quote = null;
2286
+ }
2287
+ return index;
2288
+ };
2289
+
2290
+ const stepCode = (state, text, index) => {
2291
+ const char = text[index];
2292
+ if (
2293
+ char === "`" ||
2294
+ ((char === '"' || char === "'") && OPENS_STRING.test(state.previous))
2295
+ ) {
2296
+ state.quote = char;
2297
+ return false;
2298
+ }
2299
+ if ("([{".includes(char)) {
2300
+ state.stack.push(char);
2301
+ } else if (")]}".includes(char) && state.stack.pop() === "`") {
2302
+ state.quote = "`";
2303
+ } else if (char === "\n" && state.stack.length === 0) {
2304
+ return true;
2305
+ }
2306
+ state.previous = nextToken(state.previous, char);
2307
+ return false;
2308
+ };
2309
+
2310
+ // The end of the statement starting at `start` (an `import` or `export` at
2311
+ // column 0): the first line end where every bracket it opened has closed.
2312
+ const statementEnd = (text, start) => {
2313
+ const state = { previous: "", quote: null, stack: [] };
2314
+ for (let index = start; index < text.length; index += 1) {
2315
+ if (state.quote) {
2316
+ index = stepString(state, text, index);
2317
+ continue;
2318
+ }
2319
+ const skipped = skipComment(text, index);
2320
+ if (skipped !== -1) {
2321
+ index = skipped;
2322
+ continue;
2323
+ }
2324
+ if (stepCode(state, text, index)) {
2325
+ return index;
2326
+ }
2327
+ }
2328
+ return text.length;
2329
+ };
2330
+
2331
+ // Swap each column-0 import/export statement for a placeholder, so no
2332
+ // conversion touches the code: those components move to islands/ by hand.
2333
+ const stashEsm = (text, stash) => {
2334
+ let out = "";
2335
+ let cursor = 0;
2336
+ const pattern = /^(?:import|export)\s/gmu;
2337
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
2338
+ const end = statementEnd(text, match.index);
2339
+ out += text.slice(cursor, match.index);
2340
+ out += `${CODE_MARK}${stash.push(text.slice(match.index, end)) - 1}${CODE_MARK}`;
2341
+ cursor = end;
2342
+ pattern.lastIndex = end;
2343
+ }
2344
+ return out + text.slice(cursor);
2345
+ };
2346
+
2347
+ // A code block's placeholder keeps the block's indentation, so a JSX child
2348
+ // block around it dedents as one.
2349
+ const stashCode = (segments, stash) =>
2350
+ segments
2351
+ .map((segment) => {
2352
+ const text = segment.lines.join("\n");
2353
+ if (!segment.code) {
2354
+ return text;
2355
+ }
2356
+ const [indent] = text.match(/^[ \t]*/u);
2357
+ return `${indent}${CODE_MARK}${stash.push(text.slice(indent.length)) - 1}${CODE_MARK}`;
2358
+ })
2359
+ .join("\n");
2360
+
2361
+ // --- Spec descriptions -------------------------------------------------------
2362
+
2363
+ // ReadMe renders its own Markdown in spec descriptions too: callouts, emoji
2364
+ // shortcodes, and doc:/ref: links. Nothing else is touched.
2365
+ const convertDescription = (value, file, context) => {
2366
+ const code = [];
2367
+ let out = stashCode(
2368
+ segmentBody(
2369
+ mapProse(value, (text) => convertBlockquoteCallouts(text, file))
2370
+ ),
2371
+ code
2372
+ );
2373
+ out = convertCalloutComponents(out, code);
2374
+ out = convertShortcodes(out, file);
2375
+ out = rewriteLinks(out, file, context.rewrite);
2376
+ return stripSpacers(restore(out, CODE_PLACEHOLDER, code));
2377
+ };
2378
+
2379
+ // --- Page body ----------------------------------------------------------------
2380
+
2381
+ const convertCustomSnippets = (text, context) =>
2382
+ text.replaceAll(
2383
+ /^(?<indent>[ \t]*)<(?<name>[A-Z]\w*)\s*\/>[ \t]*$/gmu,
2384
+ byGroups(({ indent, name }, all) => {
2385
+ const snippet = context.snippets.get(name);
2386
+ if (!snippet) {
2387
+ return all;
2388
+ }
2389
+ count("reusable content → <include>");
2390
+ return `${indent}<include>/_snippets/${snippet}.mdx</include>`;
2391
+ })
2392
+ );
2393
+
2394
+ // A body H1 that repeats the title goes; any other H1 means the page used
2395
+ // H1s for sections, so every body heading moves down a level.
2396
+ const fixHeadings = (text, title) => {
2397
+ let out = text;
2398
+ const first = /^\s*#\s+(?<heading>.+?)\s*#*\s*(?:\n|$)/u.exec(out);
2399
+ const titleText = textOf(title)?.toLowerCase();
2400
+ if (
2401
+ first &&
2402
+ titleText &&
2403
+ first.groups.heading.replaceAll(/[*_`]/gu, "").trim().toLowerCase() ===
2404
+ titleText
2405
+ ) {
2406
+ out = out.slice(first[0].length);
2407
+ count("duplicate H1 removed");
2408
+ }
2409
+ if (/^[ \t]*#[ \t]/mu.test(out)) {
2410
+ out = out.replaceAll(
2411
+ /^(?<indent>[ \t]*)(?<marks>#{1,5})(?=[ \t])/gmu,
2412
+ "$<indent>#$<marks>"
2413
+ );
2414
+ count("body headings demoted one level (the page used H1s)");
2415
+ }
2416
+ return out;
2417
+ };
2418
+
2419
+ const commentCount = (text) => (text.match(/<!--[\s\S]*?-->/gu) ?? []).length;
2420
+
2421
+ // A comment on lines of its own goes with its lines; one inside a line leaves
2422
+ // a space if it stood between words. Removing one can join the text around it
2423
+ // into another (`<!-<!-- x -->-`), so this repeats until none is left.
2424
+ const dropComments = (text) => {
2425
+ let out = text;
2426
+ let comments = commentCount(out);
2427
+ while (comments > 0) {
2428
+ count("HTML comment deleted", comments);
2429
+ out = out
2430
+ .replaceAll(/^[ \t]*<!--(?:(?!-->)[\s\S])*-->[ \t]*(?:\n|$)/gmu, "")
2431
+ .replaceAll(/[ \t]*<!--(?:(?!-->)[\s\S])*-->[ \t]*/gu, (comment) =>
2432
+ /^[ \t]|[ \t]$/u.test(comment) ? " " : ""
2433
+ );
2434
+ comments = commentCount(out);
2435
+ }
2436
+ return out;
2437
+ };
2438
+
2439
+ const selfCloseVoidTags = (text) =>
2440
+ text.replaceAll(
2441
+ VOID_TAGS,
2442
+ byGroups(({ rest, tag }) => {
2443
+ count(`<${tag}> self-closed`);
2444
+ return `<${tag}${rest} />`;
2445
+ })
2446
+ );
2447
+
2448
+ const convertBody = (body, file, context, title) => {
2449
+ const code = [];
2450
+ // Magic blocks and emoji callouts first, outside code: both can turn into
2451
+ // fenced code, which is then set aside with the rest.
2452
+ const prose = mapProse(
2453
+ mapProse(body, (text) => convertMagicBlocks(text, file)),
2454
+ (text) => convertBlockquoteCallouts(text, file)
2455
+ );
2456
+ const segments = groupAdjacentFences(segmentBody(prose), file);
2457
+ let text = stashCode(segments, code);
2458
+ const hasEsm = /^(?:import|export)\s/mu.test(text);
2459
+ text = unwrapHtmlBlocks(stashEsm(text, code), file);
2460
+ const spans = [];
2461
+ text = dropComments(stashSpans(text, spans));
2462
+ text = convertCalloutComponents(text, code);
2463
+ text = convertComponents(text, file, { ...context, spans });
2464
+ text = selfCloseVoidTags(text);
2465
+ text = convertShortcodes(text, file);
2466
+ text = rewriteLinks(text, file, context.rewrite);
2467
+ text = convertCustomSnippets(text, context);
2468
+ reportJudgment(text, file, { ...context, hasEsm });
2469
+ text = fixHeadings(text, title);
2470
+ text = restore(
2471
+ restore(text, SPAN_PLACEHOLDER, spans),
2472
+ CODE_PLACEHOLDER,
2473
+ code
2474
+ );
2475
+ if (code.some((block) => /<<[A-Za-z_][\w-]*>>/u.test(block))) {
2476
+ note(
2477
+ file,
2478
+ "a <<variable>> inside a code block was left as is; ReadMe filled it per reader"
2479
+ );
2480
+ }
2481
+ return text.replaceAll(/\n{3,}/gu, "\n\n").replace(/^\n+/u, "");
2482
+ };
2483
+
2484
+ // --- Frontmatter --------------------------------------------------------------
2485
+
2486
+ const ordered = (fields) => {
2487
+ const out = {};
2488
+ for (const key of KEY_ORDER) {
2489
+ if (fields.has(key)) {
2490
+ out[key] = fields.get(key);
2491
+ }
2492
+ }
2493
+ for (const [key, value] of fields) {
2494
+ if (!(key in out) && value !== undefined) {
2495
+ out[key] = value;
2496
+ }
2497
+ }
2498
+ return out;
2499
+ };
2500
+
2501
+ // A frontmatter map the mapping consumes key by key.
2502
+ const createSource = (data) => {
2503
+ const source = new Map(Object.entries(recordOf(data)));
2504
+ const take = (key) => {
2505
+ const value = source.get(key);
2506
+ source.delete(key);
2507
+ return value;
2508
+ };
2509
+ return { source, take };
2510
+ };
2511
+
2512
+ const merge = (fields, key, value) => {
2513
+ fields.set(key, { ...recordOf(fields.get(key)), ...value });
2514
+ };
2515
+
2516
+ const mapChangelog = ({ take }, fields, file) => {
2517
+ fields.set("type", "changelog");
2518
+ const kind = textOf(take("type"));
2519
+ if (kind !== undefined && CHANGELOG_TYPES.has(kind.toLowerCase())) {
2520
+ if (kind.toLowerCase() !== "none") {
2521
+ const category = `${kind[0].toUpperCase()}${kind.slice(1).toLowerCase()}`;
2522
+ merge(fields, "changelog", { category });
2523
+ }
2524
+ } else if (kind !== undefined && kind !== "changelog") {
2525
+ note(file, `changelog type "${kind}" dropped`);
2526
+ }
2527
+ const date =
2528
+ textOf(take("date")) ??
2529
+ textOf(take("published_at")) ??
2530
+ textOf(take("created_at"));
2531
+ if (date === undefined) {
2532
+ note(file, "changelog entry without a date: take it from the live entry");
2533
+ } else {
2534
+ fields.set("date", date.slice(0, 10));
2535
+ }
2536
+ const author = take("author") ?? take("authors");
2537
+ if (Array.isArray(author) || textOf(author) !== undefined) {
2538
+ fields.set("authors", author);
2539
+ } else if (isRecord(author)) {
2540
+ note(file, "changelog author is an ID object; dropped");
2541
+ }
2542
+ const existing = take("changelog");
2543
+ if (isRecord(existing)) {
2544
+ merge(fields, "changelog", existing);
2545
+ }
2546
+ };
2547
+
2548
+ const mapVisibility = ({ take }, fields, info, data) => {
2549
+ const hidden = take("hidden");
2550
+ if (
2551
+ hidden !== undefined &&
2552
+ hidden !== null &&
2553
+ hidden !== true &&
2554
+ hidden !== false
2555
+ ) {
2556
+ info.notes.push(
2557
+ `hidden: ${JSON.stringify(hidden)} isn't true or false: dropped; set hidden: true if the page was hidden`
2558
+ );
2559
+ }
2560
+ const privacy = recordOf(take("privacy"));
2561
+ const unlisted = hidden === true || privacy.view === "anyone_with_link";
2562
+ if (info.section === "page") {
2563
+ // rdme@10 writes a custom page's flag as appearance.fullscreen.
2564
+ const fullscreen =
2565
+ take("fullscreen") ?? recordOf(data.appearance).fullscreen;
2566
+ const mode = textOf(take("mode")) ?? textOf(data.mode);
2567
+ fields.set("hidden", true);
2568
+ fields.set("mode", fullscreen === true ? "custom" : (mode ?? "center"));
2569
+ count("custom page → hidden + mode");
2570
+ } else if (unlisted) {
2571
+ fields.set("hidden", true);
2572
+ if (info.section !== "changelog") {
2573
+ merge(fields, "search", { exclude: true });
2574
+ }
2575
+ count("hidden → hidden (+ search.exclude)");
2576
+ }
2577
+ if (hidden === true && info.legacyRdme) {
2578
+ info.notes.push(
2579
+ "hidden: true in rdme ≤ 9 frontmatter: check the live URL, and use draft: true if it 404s"
2580
+ );
2581
+ }
2582
+ const deprecated = take("deprecated");
2583
+ if (deprecated === true || take("state") === "deprecated") {
2584
+ fields.set("deprecated", true);
2585
+ }
2586
+ if (take("allow_crawlers") === "disabled") {
2587
+ fields.set("noindex", true);
2588
+ }
2589
+ };
2590
+
2591
+ const mapMetadata = ({ take }, fields, info) => {
2592
+ const metadata = recordOf(take("metadata"));
2593
+ const seo = {};
2594
+ for (const key of ["title", "description"]) {
2595
+ if (textOf(metadata[key]) !== undefined) {
2596
+ seo[key] = textOf(metadata[key]);
2597
+ }
2598
+ }
2599
+ const image = textOf(metadata.image) ?? textOf(recordOf(metadata.image).url);
2600
+ if (image !== undefined) {
2601
+ seo.image = image;
2602
+ } else if (isRecord(metadata.image)) {
2603
+ // rdme@10 writes `{ uri: "/images/<id>" }`, an id rather than a URL.
2604
+ info.notes.push(
2605
+ "metadata.image has no URL (an rdme image id?): set seo.image by hand"
2606
+ );
2607
+ }
2608
+ if (Object.keys(seo).length > 0) {
2609
+ merge(fields, "seo", seo);
2610
+ count("metadata → seo");
2611
+ }
2612
+ const keywords = Array.isArray(metadata.keywords)
2613
+ ? metadata.keywords.map((keyword) => textOf(keyword)).filter(Boolean)
2614
+ : (textOf(metadata.keywords) ?? "")
2615
+ .split(",")
2616
+ .map((keyword) => keyword.trim())
2617
+ .filter(Boolean);
2618
+ if (keywords.length > 0) {
2619
+ merge(fields, "search", { keywords });
2620
+ count("metadata.keywords → search.keywords");
2621
+ }
2622
+ if (metadata.robots === "noindex") {
2623
+ fields.set("noindex", true);
2624
+ }
2625
+ };
2626
+
2627
+ const relatedEntry = (entry, context, notes) => {
2628
+ const page = recordOf(entry);
2629
+ const url = textOf(page.url);
2630
+ if (page.type === "link" && url !== undefined) {
2631
+ const title = textOf(page.title);
2632
+ return title ? { [title]: url } : url;
2633
+ }
2634
+ const prefix = NEXT_PREFIX[page.type];
2635
+ const slug = textOf(page.slug);
2636
+ if (prefix === undefined || slug === undefined) {
2637
+ return;
2638
+ }
2639
+ const target = `${prefix}${slug}`;
2640
+ if (context.knownUrls && !context.knownUrls.has(target)) {
2641
+ notes.push(`next.pages entry ${target} dropped: no such page in this repo`);
2642
+ return;
2643
+ }
2644
+ return target;
2645
+ };
2646
+
2647
+ const mapNext = ({ take }, fields, info, context) => {
2648
+ const content = recordOf(take("content"));
2649
+ const description = textOf(take("excerpt")) ?? textOf(take("description"));
2650
+ const excerpt = description ?? textOf(content.excerpt);
2651
+ if (excerpt !== undefined) {
2652
+ fields.set("description", excerpt);
2653
+ }
2654
+ const next = recordOf(take("next") ?? content.next);
2655
+ const related = listOf(next.pages)
2656
+ .map((entry) => relatedEntry(entry, context, info.notes))
2657
+ .filter((entry) => entry !== undefined);
2658
+ if (related.length > 0) {
2659
+ // Blume's schema takes at most 10 related pages.
2660
+ fields.set("related", related.slice(0, 10));
2661
+ count("next.pages → related");
2662
+ if (related.length > 10) {
2663
+ info.notes.push(
2664
+ `related keeps the first 10 of ${related.length} next.pages entries (Blume's limit)`
2665
+ );
2666
+ }
2667
+ }
2668
+ const link = recordOf(take("link") ?? content.link);
2669
+ info.linkUrl = textOf(link.url);
2670
+ };
2671
+
2672
+ const mapRest = ({ source, take }, fields, info) => {
2673
+ const appearance = recordOf(take("appearance"));
2674
+ const icon = lucideFor(
2675
+ take("icon") ?? recordOf(appearance.icon).name,
2676
+ info.file
2677
+ );
2678
+ if (icon) {
2679
+ fields.set("icon", icon);
2680
+ }
2681
+ if (take("table_of_contents") === false) {
2682
+ if (!fields.has("mode")) {
2683
+ fields.set("mode", "wide");
2684
+ }
2685
+ info.notes.push("table_of_contents: false approximated with mode: wide");
2686
+ }
2687
+ const recipes = listOf(take("recipes"));
2688
+ if (recipes.length > 0) {
2689
+ info.notes.push(`related recipes dropped: ${recipes.join(", ")}`);
2690
+ }
2691
+ for (const key of ["recipe", "x-import", "x_import", "api_config", "name"]) {
2692
+ take(key);
2693
+ }
2694
+ if (isRecord(source.get("api")) || source.get("api") === null) {
2695
+ take("api");
2696
+ }
2697
+ if (info.section !== "changelog") {
2698
+ const kind = textOf(take("type"));
2699
+ if (
2700
+ kind !== undefined &&
2701
+ !["basic", "link", "error", "endpoint", "webhook", "api_config"].includes(
2702
+ kind
2703
+ )
2704
+ ) {
2705
+ fields.set("type", kind);
2706
+ }
2707
+ }
2708
+ for (const key of NAV_KEYS) {
2709
+ if (source.has(key)) {
2710
+ info.notes.push(
2711
+ `${key}: rdme-upload navigation key left in place: rebuild the tree (see the reference), then remove it`
2712
+ );
2713
+ }
2714
+ }
2715
+ };
2716
+
2717
+ const passThrough = (source, fields, info) => {
2718
+ for (const [key, value] of source) {
2719
+ if (!fields.has(key)) {
2720
+ fields.set(key, value);
2721
+ if (!BLUME_KEYS.has(key) && !NAV_KEYS.includes(key)) {
2722
+ info.notes.push(
2723
+ `frontmatter key "${key}" kept as is: Blume rejects unknown keys`
2724
+ );
2725
+ }
2726
+ } else if (isRecord(value) && isRecord(fields.get(key))) {
2727
+ fields.set(key, { ...value, ...fields.get(key) });
2728
+ }
2729
+ }
2730
+ };
2731
+
2732
+ const mapFrontmatter = (data, info, context) => {
2733
+ const reader = createSource(data);
2734
+ const fields = new Map();
2735
+ const title = reader.take("title");
2736
+ if (title !== undefined) {
2737
+ fields.set("title", title);
2738
+ }
2739
+ if (info.section === "changelog") {
2740
+ mapChangelog(reader, fields, info.file);
2741
+ }
2742
+ mapNext(reader, fields, info, context);
2743
+ mapVisibility(reader, fields, info, data);
2744
+ mapMetadata(reader, fields, info);
2745
+ mapRest(reader, fields, info);
2746
+ if (info.slug) {
2747
+ fields.set("slug", info.slug);
2748
+ }
2749
+ passThrough(reader.source, fields, info);
2750
+ for (const message of info.notes) {
2751
+ note(info.file, message);
2752
+ }
2753
+ return ordered(fields);
2754
+ };
2755
+
2756
+ // --- Project: reading -----------------------------------------------------------
2757
+
2758
+ // ReadMe's flat URL for a page, from its path in the synced repo.
2759
+ const readmeUrl = (section, parts, stem) => {
2760
+ const real = parts.filter((part) => !isGroup(part));
2761
+ const slug = stem === "index" ? real.at(-1) : stem;
2762
+ return `${URL_PREFIX[section]}${slug}`;
2763
+ };
2764
+
2765
+ const isSyncLayout = (root) =>
2766
+ existsSync(path.join(root, INVENTORY)) ||
2767
+ ["docs", "reference", "recipes", "custom_pages", "page"].some((dir) =>
2768
+ existsSync(path.join(root, dir, "_order.yaml"))
2769
+ ) ||
2770
+ walk(path.join(root, "docs")).some((file) => file.endsWith("_order.yaml"));
2771
+
2772
+ const loadSpecs = (root) => {
2773
+ const specs = new Map();
2774
+ const dir = path.join(root, "reference");
2775
+ for (const file of walk(dir).filter((entry) => path.dirname(entry) === dir)) {
2776
+ const name = path.basename(file);
2777
+ if (/\.json$/iu.test(name)) {
2778
+ try {
2779
+ specs.set(name, {
2780
+ data: JSON.parse(readFileSync(file, "utf-8")),
2781
+ file,
2782
+ });
2783
+ } catch {
2784
+ specs.set(name, { data: null, file });
2785
+ }
2786
+ } else if (
2787
+ /\.ya?ml$/iu.test(name) &&
2788
+ /^(?:openapi|swagger):/mu.test(readFileSync(file, "utf-8"))
2789
+ ) {
2790
+ specs.set(name, { data: null, file, yaml: true });
2791
+ }
2792
+ }
2793
+ return specs;
2794
+ };
2795
+
2796
+ const findOperation = (items, operationId) => {
2797
+ for (const [route, item] of Object.entries(recordOf(items))) {
2798
+ for (const [method, operation] of Object.entries(recordOf(item))) {
2799
+ if (recordOf(operation).operationId === operationId) {
2800
+ return {
2801
+ method: method.toUpperCase(),
2802
+ path: route,
2803
+ tag: textOf(listOf(operation.tags)[0]) ?? null,
2804
+ };
2805
+ }
2806
+ }
2807
+ }
2808
+ };
2809
+
2810
+ const operationOf = (spec, operationId) => {
2811
+ if (!spec?.data) {
2812
+ return;
2813
+ }
2814
+ return (
2815
+ findOperation(spec.data.paths, operationId) ??
2816
+ findOperation(spec.data.webhooks ?? spec.data["x-webhooks"], operationId)
2817
+ );
2818
+ };
2819
+
2820
+ // --glossary takes JSON, or a saved page of the hub, whose embedded settings
2821
+ // carry the glossary as "glossaryTerms": [{ "term", "definition" }].
2822
+ const bracketEnd = (text, from) => {
2823
+ let depth = 0;
2824
+ let quoted = false;
2825
+ for (let index = from; index < text.length; index += 1) {
2826
+ const char = text[index];
2827
+ if (quoted && char === "\\") {
2828
+ index += 1;
2829
+ } else if (char === '"') {
2830
+ quoted = !quoted;
2831
+ } else if (!quoted && char === "[") {
2832
+ depth += 1;
2833
+ } else if (!quoted && char === "]") {
2834
+ depth -= 1;
2835
+ if (depth === 0) {
2836
+ return index;
2837
+ }
2838
+ }
2839
+ }
2840
+ return -1;
2841
+ };
2842
+
2843
+ const readGlossary = (file) => {
2844
+ const content = readFileSync(file, "utf-8");
2845
+ try {
2846
+ return JSON.parse(content);
2847
+ } catch {
2848
+ const start = content.indexOf('"glossaryTerms":');
2849
+ const from = start === -1 ? -1 : content.indexOf("[", start);
2850
+ const end = from === -1 ? -1 : bracketEnd(content, from);
2851
+ if (end === -1) {
2852
+ throw new Error(
2853
+ `${file} is neither JSON nor a hub page with glossaryTerms`
2854
+ );
2855
+ }
2856
+ return JSON.parse(content.slice(from, end + 1));
2857
+ }
2858
+ };
2859
+
2860
+ const loadGlossary = (file) => {
2861
+ const glossary = new Map();
2862
+ if (!file) {
2863
+ return glossary;
2864
+ }
2865
+ const raw = readGlossary(file);
2866
+ const entries = Array.isArray(raw)
2867
+ ? raw.map((entry) => [recordOf(entry).term, recordOf(entry).definition])
2868
+ : Object.entries(recordOf(raw));
2869
+ for (const [term, definition] of entries) {
2870
+ if (textOf(term) !== undefined && textOf(definition) !== undefined) {
2871
+ glossary.set(textOf(term).toLowerCase(), textOf(definition));
2872
+ }
2873
+ }
2874
+ return glossary;
2875
+ };
2876
+
2877
+ const loadBlocks = (root) => {
2878
+ const snippets = new Map();
2879
+ const components = new Set();
2880
+ for (const file of walk(path.join(root, "custom_blocks"))) {
2881
+ const name = path.basename(file).replace(/\.mdx?$/u, "");
2882
+ if (file.endsWith(".mdx")) {
2883
+ components.add(name);
2884
+ } else if (file.endsWith(".md")) {
2885
+ snippets.set(
2886
+ name,
2887
+ name
2888
+ .replaceAll(
2889
+ /(?<lower>[a-z0-9])(?<upper>[A-Z])/gu,
2890
+ "$<lower>-$<upper>"
2891
+ )
2892
+ .toLowerCase()
2893
+ );
2894
+ }
2895
+ }
2896
+ return { components, snippets };
2897
+ };
2898
+
2899
+ const readPage = (root, dir, file) => {
2900
+ const relative = path.relative(path.join(root, dir), file).split(path.sep);
2901
+ // A byte order mark would hide the frontmatter; it goes.
2902
+ const raw = readFileSync(file, "utf-8").replace(/^\uFEFF/u, "");
2903
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
2904
+ const source = raw.replaceAll("\r\n", "\n");
2905
+ const { body, raw: frontmatter } = splitFrontmatter(source);
2906
+ const page = {
2907
+ body,
2908
+ data: {},
2909
+ dir,
2910
+ eol,
2911
+ file,
2912
+ fm: frontmatter,
2913
+ parseError: null,
2914
+ parts: relative.slice(0, -1),
2915
+ section: SECTIONS[dir],
2916
+ source,
2917
+ stem: path.basename(file).replace(/\.(?:mdx?|html)$/u, ""),
2918
+ };
2919
+ if (frontmatter !== null) {
2920
+ try {
2921
+ page.data = recordOf(parseYaml(frontmatter));
2922
+ } catch (error) {
2923
+ page.parseError = error.message;
2924
+ }
2925
+ }
2926
+ return page;
2927
+ };
2928
+
2929
+ const readPages = (root, sectionDirs) => {
2930
+ const pages = [];
2931
+ for (const dir of sectionDirs) {
2932
+ for (const file of walk(path.join(root, dir))) {
2933
+ if (/\.(?:mdx?|html)$/u.test(file)) {
2934
+ pages.push(readPage(root, dir, file));
2935
+ }
2936
+ }
2937
+ }
2938
+ return pages;
2939
+ };
2940
+
2941
+ const isEndpoint = (page) =>
2942
+ textOf(recordOf(page.data.api).operationId) !== undefined;
2943
+
2944
+ const tagFolderOf = (page) =>
2945
+ page.parts.length > 0 ? path.join(page.dir, ...page.parts) : null;
2946
+
2947
+ // --- Project: state ---------------------------------------------------------------
2948
+
2949
+ const createProject = (root, options) => {
2950
+ const syncLayout = isSyncLayout(root);
2951
+ const inventory = readJson(path.join(root, INVENTORY), {
2952
+ endpoints: [],
2953
+ redirects: [],
2954
+ referenceOrder: [],
2955
+ tagFolders: [],
2956
+ urls: [],
2957
+ });
2958
+ const redirects = new Map(
2959
+ readJson(path.join(root, REDIRECTS), []).map((entry) => [entry.from, entry])
2960
+ );
2961
+ const sectionDirs = Object.keys(SECTIONS).filter((dir) =>
2962
+ existsSync(path.join(root, dir))
2963
+ );
2964
+ const blocks = loadBlocks(root);
2965
+ const project = {
2966
+ deletes: new Set(),
2967
+ handled: new Set(),
2968
+ inventory,
2969
+ knownUrls: new Set(inventory.urls.map((entry) => entry.url)),
2970
+ options,
2971
+ pages: readPages(root, sectionDirs),
2972
+ redirects,
2973
+ root,
2974
+ sectionDirs,
2975
+ specs: loadSpecs(root),
2976
+ syncLayout,
2977
+ writes: new Map(),
2978
+ };
2979
+ project.context = {
2980
+ customComponents: blocks.components,
2981
+ glossary: loadGlossary(options.glossary),
2982
+ knownUrls: syncLayout ? project.knownUrls : null,
2983
+ snippets: blocks.snippets,
2984
+ variables: new Set(),
2985
+ };
2986
+ project.context.rewrite = createLinkRewriter({
2987
+ knownUrls: project.context.knownUrls,
2988
+ sites: options.sites,
2989
+ });
2990
+ return project;
2991
+ };
2992
+
2993
+ const addRedirect = (project, from, to, status) => {
2994
+ if (from !== to && !project.redirects.has(from)) {
2995
+ project.redirects.set(from, status ? { from, status, to } : { from, to });
2996
+ }
2997
+ };
2998
+
2999
+ const setKind = (project, url, kind) => {
3000
+ const entry = project.inventory.urls.find(
3001
+ (candidate) => candidate.url === url
3002
+ );
3003
+ if (entry) {
3004
+ entry.kind = kind;
3005
+ }
3006
+ };
3007
+
3008
+ const drop = (project, page, kind) => {
3009
+ project.deletes.add(page.file);
3010
+ project.handled.add(page.file);
3011
+ if (kind) {
3012
+ setKind(project, page.url, kind);
3013
+ }
3014
+ };
3015
+
3016
+ const recordUrls = (project) => {
3017
+ for (const page of project.pages) {
3018
+ if (!project.syncLayout || page.parseError) {
3019
+ continue;
3020
+ }
3021
+ page.url = readmeUrl(page.section, page.parts, page.stem);
3022
+ if (!project.knownUrls.has(page.url)) {
3023
+ project.knownUrls.add(page.url);
3024
+ project.inventory.urls.push({
3025
+ hidden: page.data.hidden === true || undefined,
3026
+ kind: isEndpoint(page) ? "endpoint" : "page",
3027
+ url: page.url,
3028
+ });
3029
+ }
3030
+ }
3031
+ };
3032
+
3033
+ // --- Project: the API reference ---------------------------------------------------
3034
+
3035
+ const proseOf = (project, page, title) =>
3036
+ stripSpacers(
3037
+ convertBody(
3038
+ page.body,
3039
+ path.relative(project.root, page.file),
3040
+ project.context,
3041
+ title
3042
+ )
3043
+ );
3044
+
3045
+ const recordEndpoint = (project, page) => {
3046
+ const relative = path.relative(project.root, page.file);
3047
+ const api = recordOf(page.data.api);
3048
+ const specName = path.basename(String(api.file ?? ""));
3049
+ const operationId = textOf(api.operationId);
3050
+ const spec = project.specs.get(specName);
3051
+ const found = operationOf(spec, operationId);
3052
+ if (!spec) {
3053
+ note(
3054
+ relative,
3055
+ `endpoint page names ${specName}, which isn't in reference/`
3056
+ );
3057
+ } else if (spec.yaml) {
3058
+ note(
3059
+ relative,
3060
+ `${specName} is YAML: convert it to JSON so the redirect and order steps can read it`
3061
+ );
3062
+ } else if (!found) {
3063
+ note(relative, `operationId ${operationId} isn't in ${specName}`);
3064
+ }
3065
+ const order =
3066
+ readOrder(
3067
+ path.join(project.root, tagFolderOf(page) ?? "reference", "_order.yaml")
3068
+ ) ?? [];
3069
+ const prose = proseOf(project, page);
3070
+ if (/<[A-Za-z]/u.test(prose)) {
3071
+ note(
3072
+ relative,
3073
+ "endpoint prose holds JSX or HTML, which the operation description escapes: rewrite it as Markdown in the overlay"
3074
+ );
3075
+ }
3076
+ if (page.data.hidden === true) {
3077
+ note(
3078
+ relative,
3079
+ "hidden endpoint: ReadMe hid this operation; remove it with an overlay (remove: true) if it should stay hidden"
3080
+ );
3081
+ }
3082
+ if (!project.inventory.endpoints.some((entry) => entry.oldUrl === page.url)) {
3083
+ project.inventory.endpoints.push({
3084
+ category: page.parts[0] ?? null,
3085
+ folder: page.parts.at(-1) ?? null,
3086
+ method: found?.method ?? null,
3087
+ oldUrl: page.url,
3088
+ operationId,
3089
+ path: found?.path ?? null,
3090
+ position: order.indexOf(page.stem),
3091
+ prose,
3092
+ spec: specName,
3093
+ tag: found?.tag ?? null,
3094
+ });
3095
+ }
3096
+ drop(project, page);
3097
+ count("endpoint page deleted (generated from the spec)");
3098
+ };
3099
+
3100
+ const recordTagPage = (project, page) => {
3101
+ const folder = tagFolderOf(page);
3102
+ const prose = proseOf(project, page, page.data.title);
3103
+ const order = readOrder(path.join(project.root, folder, "_order.yaml")) ?? [];
3104
+ const categoryOrder =
3105
+ readOrder(path.join(project.root, path.dirname(folder), "_order.yaml")) ??
3106
+ [];
3107
+ if (
3108
+ !project.inventory.tagFolders.some((entry) => entry.oldUrl === page.url)
3109
+ ) {
3110
+ project.inventory.tagFolders.push({
3111
+ category: page.parts.length > 1 ? page.parts[0] : null,
3112
+ endpoints: order,
3113
+ oldUrl: page.url,
3114
+ position: categoryOrder.indexOf(page.parts.at(-1)),
3115
+ prose,
3116
+ title: textOf(page.data.title) ?? null,
3117
+ });
3118
+ }
3119
+ drop(project, page, "tag");
3120
+ count("tag page deleted");
3121
+ };
3122
+
3123
+ const recordConfigPage = (project, page) => {
3124
+ const relative = path.relative(project.root, page.file);
3125
+ const kind = page.data.api_config;
3126
+ if (proseOf(project, page, page.data.title) === "") {
3127
+ drop(project, page, "config");
3128
+ todo(
3129
+ `${page.url} (ReadMe's ${kind} page) had no prose and was deleted: redirect it to the closest page`
3130
+ );
3131
+ count("API config page deleted (empty)");
3132
+ } else {
3133
+ page.moveTo = path.join(project.root, "reference", `${page.stem}.mdx`);
3134
+ note(
3135
+ relative,
3136
+ `ReadMe's ${kind} page: kept as a plain page; its personalized API key widget has no equivalent`
3137
+ );
3138
+ }
3139
+ };
3140
+
3141
+ const classifyReference = (project) => {
3142
+ const reference = project.pages.filter(
3143
+ (page) => page.dir === "reference" && !page.parseError
3144
+ );
3145
+ if (!project.syncLayout || reference.length === 0) {
3146
+ return;
3147
+ }
3148
+ const order = readOrder(path.join(project.root, "reference", "_order.yaml"));
3149
+ if (order && project.inventory.referenceOrder.length === 0) {
3150
+ project.inventory.referenceOrder = order;
3151
+ }
3152
+ const endpointFolders = new Set(
3153
+ reference.filter(isEndpoint).map(tagFolderOf)
3154
+ );
3155
+ for (const page of reference.filter(isEndpoint)) {
3156
+ recordEndpoint(project, page);
3157
+ }
3158
+ for (const page of reference) {
3159
+ if (page.stem === "index" && endpointFolders.has(tagFolderOf(page))) {
3160
+ recordTagPage(project, page);
3161
+ } else if (page.data.api_config && !project.handled.has(page.file)) {
3162
+ recordConfigPage(project, page);
3163
+ }
3164
+ }
3165
+ };
3166
+
3167
+ // --- Project: empty parents and section roots ------------------------------------
3168
+
3169
+ const firstPageIn = (project, folder) => {
3170
+ const order = readOrder(path.join(folder, "_order.yaml")) ?? [];
3171
+ const live = project.pages.filter((page) => !project.handled.has(page.file));
3172
+ for (const entry of order) {
3173
+ const page = live.find(
3174
+ (candidate) =>
3175
+ path.dirname(candidate.file) === folder && candidate.stem === entry
3176
+ );
3177
+ if (page) {
3178
+ return page.url;
3179
+ }
3180
+ const sub = path.join(folder, entry);
3181
+ if (existsSync(sub)) {
3182
+ const index = live.find(
3183
+ (candidate) =>
3184
+ path.dirname(candidate.file) === sub &&
3185
+ candidate.stem === "index" &&
3186
+ candidate.body.trim() !== ""
3187
+ );
3188
+ return index?.url ?? firstPageIn(project, sub);
3189
+ }
3190
+ }
3191
+ };
3192
+
3193
+ const isEmptyParent = (project, page) => {
3194
+ if (
3195
+ project.handled.has(page.file) ||
3196
+ page.stem !== "index" ||
3197
+ page.parseError ||
3198
+ page.section === "changelog" ||
3199
+ page.body.trim() !== "" ||
3200
+ page.parts.length === 0 ||
3201
+ isGroup(page.parts.at(-1))
3202
+ ) {
3203
+ return false;
3204
+ }
3205
+ const categorized = page.section === "docs" || page.section === "reference";
3206
+ const folder = path.dirname(page.file);
3207
+ return (
3208
+ page.parts.length > (categorized ? 1 : 0) &&
3209
+ project.pages.some(
3210
+ (other) => other !== page && other.file.startsWith(folder + path.sep)
3211
+ )
3212
+ );
3213
+ };
3214
+
3215
+ // ReadMe answers an empty parent page's URL with a 302 to its first child.
3216
+ const removeEmptyParents = (project) => {
3217
+ if (!project.syncLayout) {
3218
+ return;
3219
+ }
3220
+ for (const page of project.pages.filter((entry) =>
3221
+ isEmptyParent(project, entry)
3222
+ )) {
3223
+ const target = firstPageIn(project, path.dirname(page.file));
3224
+ drop(project, page, "parent");
3225
+ page.emptyParentTitle = textOf(page.data.title);
3226
+ if (target) {
3227
+ addRedirect(project, page.url, target, 302);
3228
+ } else {
3229
+ todo(
3230
+ `${page.url}: empty parent page deleted, but its first child couldn't be found; add a redirect`
3231
+ );
3232
+ }
3233
+ count("empty parent page deleted (302 to its first child)");
3234
+ }
3235
+ };
3236
+
3237
+ // ReadMe answers /docs and /reference with a 301 to the section's first page;
3238
+ // these are 302s, since that first page can change.
3239
+ const addSectionRedirects = (project) => {
3240
+ if (!project.syncLayout) {
3241
+ return;
3242
+ }
3243
+ const docs = path.join(project.root, "docs");
3244
+ const docsTarget = existsSync(docs) ? firstPageIn(project, docs) : undefined;
3245
+ if (docsTarget) {
3246
+ addRedirect(project, "/docs", docsTarget, 302);
3247
+ }
3248
+ const [firstCategory] = project.inventory.referenceOrder;
3249
+ const referenceTarget = firstCategory
3250
+ ? firstPageIn(project, path.join(project.root, "reference", firstCategory))
3251
+ : undefined;
3252
+ if (referenceTarget) {
3253
+ addRedirect(project, "/reference", referenceTarget, 302);
3254
+ }
3255
+ if (
3256
+ existsSync(path.join(project.root, "changelogs")) ||
3257
+ existsSync(path.join(project.root, "changelog"))
3258
+ ) {
3259
+ addRedirect(project, "/changelog.rss", "/changelog/rss.xml");
3260
+ }
3261
+ todo(
3262
+ "/ : ReadMe sent it to the landing page or the first section; add an index.mdx or a redirect"
3263
+ );
3264
+ };
3265
+
3266
+ // --- Project: pages -----------------------------------------------------------------
3267
+
3268
+ const destination = (project, absolute) => {
3269
+ const [top, ...rest] = path.relative(project.root, absolute).split(path.sep);
3270
+ const section = project.syncLayout ? (SECTIONS[top] ?? top) : top;
3271
+ const categorized = section === "docs" || section === "reference";
3272
+ if (
3273
+ project.syncLayout &&
3274
+ categorized &&
3275
+ rest.length > 1 &&
3276
+ !isGroup(rest[0])
3277
+ ) {
3278
+ rest[0] = `(${rest[0]})`;
3279
+ }
3280
+ return path.join(project.root, section, ...rest);
3281
+ };
3282
+
3283
+ // Pin `slug` when Blume's own route would differ from ReadMe's URL: groups add
3284
+ // nothing, `index` is its folder, and a leading digit can be read as an
3285
+ // ordering prefix (`1-setup`). Blume keeps dates (`12-05-2022`, `2024-01`)
3286
+ // and versions whole, so pinning every digit-led segment is only cautious.
3287
+ const slugPin = (project, page) => {
3288
+ if (!project.syncLayout || !page.url || page.moveTo) {
3289
+ return;
3290
+ }
3291
+ const categorized = page.section === "docs" || page.section === "reference";
3292
+ const nested = categorized ? page.parts.slice(1) : page.parts;
3293
+ const segments = [
3294
+ ...nested.filter((part) => !isGroup(part)),
3295
+ page.stem === "index" ? null : page.stem,
3296
+ ].filter(Boolean);
3297
+ const route = `/${[page.section, ...segments].join("/")}`;
3298
+ const digitLed = segments.some((segment) => /^\d/u.test(segment));
3299
+ return route !== page.url || digitLed ? page.url.slice(1) : undefined;
3300
+ };
3301
+
3302
+ const pageOutput = (project, page, front, relative) => {
3303
+ const body = convertBody(page.body, relative, project.context, front.title);
3304
+ const header =
3305
+ Object.keys(front).length > 0 ? `---\n${emitYaml(front)}\n---\n\n` : "";
3306
+ const output = `${header}${body.trimEnd()}\n`;
3307
+ return page.eol === "\r\n" ? output.replaceAll("\n", "\r\n") : output;
3308
+ };
3309
+
3310
+ const convertPage = (project, page) => {
3311
+ const relative = path.relative(project.root, page.file);
3312
+ if (page.file.endsWith(".html")) {
3313
+ note(
3314
+ relative,
3315
+ "HTML custom page: rebuild it as page/<slug>.mdx (HTML as JSX) or pages/page/<slug>.astro"
3316
+ );
3317
+ todo(`${relative}: HTML custom page left for you`);
3318
+ return;
3319
+ }
3320
+ if (page.parseError) {
3321
+ note(
3322
+ relative,
3323
+ `frontmatter not converted (${page.parseError}); the file was left as is`
3324
+ );
3325
+ todo(`${relative}: convert by hand (frontmatter didn't parse)`);
3326
+ return;
3327
+ }
3328
+ const info = {
3329
+ file: relative,
3330
+ legacyRdme:
3331
+ "parentDoc" in page.data ||
3332
+ /^[0-9a-f]{24}$/u.test(textOf(page.data.category) ?? ""),
3333
+ notes: [],
3334
+ section: page.section,
3335
+ slug: slugPin(project, page),
3336
+ };
3337
+ const front = mapFrontmatter(page.data, info, project.context);
3338
+ if (info.linkUrl && page.url) {
3339
+ addRedirect(project, page.url, info.linkUrl, 302);
3340
+ drop(project, page, "link");
3341
+ todo(
3342
+ `${page.url} was a link page: it now redirects to ${info.linkUrl}; add it to navigation.featured if it should stay in the sidebar`
3343
+ );
3344
+ count("link page → redirect");
3345
+ return;
3346
+ }
3347
+ if (info.linkUrl) {
3348
+ note(
3349
+ relative,
3350
+ `link page to ${info.linkUrl}: delete it and redirect its URL there`
3351
+ );
3352
+ }
3353
+ const target =
3354
+ page.moveTo ?? destination(project, page.file).replace(/\.md$/u, ".mdx");
3355
+ if (target !== page.file && existsSync(target)) {
3356
+ note(
3357
+ relative,
3358
+ `${path.relative(project.root, target)} already exists; left both in place`
3359
+ );
3360
+ return;
3361
+ }
3362
+ const output = pageOutput(project, page, front, relative);
3363
+ if (target !== page.file) {
3364
+ project.deletes.add(page.file);
3365
+ count(page.file.endsWith(".md") ? ".md → .mdx" : "file moved");
3366
+ }
3367
+ if (
3368
+ output !== page.source.replaceAll("\n", page.eol) ||
3369
+ target !== page.file
3370
+ ) {
3371
+ project.writes.set(target, output);
3372
+ }
3373
+ };
3374
+
3375
+ const convertSnippetFiles = (project) => {
3376
+ for (const file of walk(path.join(project.root, "custom_blocks"))) {
3377
+ if (!file.endsWith(".md")) {
3378
+ continue;
3379
+ }
3380
+ const name = path.basename(file, ".md");
3381
+ const target = path.join(
3382
+ project.root,
3383
+ "_snippets",
3384
+ `${project.context.snippets.get(name)}.mdx`
3385
+ );
3386
+ if (existsSync(target)) {
3387
+ continue;
3388
+ }
3389
+ const { body } = splitFrontmatter(
3390
+ readFileSync(file, "utf-8").replaceAll("\r\n", "\n")
3391
+ );
3392
+ const converted = convertBody(
3393
+ body,
3394
+ path.relative(project.root, file),
3395
+ project.context
3396
+ );
3397
+ project.writes.set(target, `${converted.trimEnd()}\n`);
3398
+ count("reusable content → _snippets/");
3399
+ }
3400
+ for (const name of [...project.context.customComponents].toSorted()) {
3401
+ todo(
3402
+ `custom_blocks/${name}.mdx is a React component: move it to islands/${name}.tsx (export default) or replace its uses`
3403
+ );
3404
+ }
3405
+ };
3406
+
3407
+ // --- Project: _order.yaml → meta.ts -------------------------------------------------
3408
+
3409
+ const orderFiles = (project) =>
3410
+ project.sectionDirs.flatMap((dir) =>
3411
+ walk(path.join(project.root, dir))
3412
+ .filter((file) => path.basename(file) === "_order.yaml")
3413
+ .map((file) => ({ dir, file }))
3414
+ );
3415
+
3416
+ const metaFields = (project, dir, folder, remaining, order) => {
3417
+ const fields = {};
3418
+ const section = SECTIONS[dir];
3419
+ const parts = path
3420
+ .relative(path.join(project.root, dir), folder)
3421
+ .split(path.sep)
3422
+ .filter(Boolean);
3423
+ const isCategory =
3424
+ (section === "docs" || section === "reference") && parts.length === 1;
3425
+ const index = project.pages.find(
3426
+ (page) => path.dirname(page.file) === folder && page.stem === "index"
3427
+ );
3428
+ if (!isCategory && parts.length > 0 && index) {
3429
+ const title = index.emptyParentTitle ?? textOf(index.data.title);
3430
+ if (title) {
3431
+ fields.title = title;
3432
+ }
3433
+ fields.display = "group";
3434
+ }
3435
+ // Blume labels a group folder by its name, capitalized and with `-` and `_`
3436
+ // read as spaces: keep the category's own spelling where that differs.
3437
+ const [category] = parts;
3438
+ if (isCategory && /^\p{Ll}|[-_]|^\d+\./u.test(category)) {
3439
+ fields.title = category;
3440
+ }
3441
+ if (section === "reference" && isCategory) {
3442
+ const position = project.inventory.referenceOrder.indexOf(parts[0]);
3443
+ if (position !== -1) {
3444
+ fields.order = position;
3445
+ }
3446
+ }
3447
+ fields.pages = order.filter((entry) =>
3448
+ remaining.some((page) => {
3449
+ const [first] = path.relative(folder, page.file).split(path.sep);
3450
+ return first === entry || first.replace(/\.mdx?$/u, "") === entry;
3451
+ })
3452
+ );
3453
+ return fields;
3454
+ };
3455
+
3456
+ const writeMeta = (project, { dir, file }) => {
3457
+ if (project.deletes.has(file)) {
3458
+ return;
3459
+ }
3460
+ const folder = path.dirname(file);
3461
+ const section = SECTIONS[dir];
3462
+ let order;
3463
+ try {
3464
+ order = readOrder(file);
3465
+ } catch (error) {
3466
+ todo(
3467
+ `${path.relative(project.root, file)} didn't parse (${error.message}); write its meta.ts by hand`
3468
+ );
3469
+ return;
3470
+ }
3471
+ project.deletes.add(file);
3472
+ if (
3473
+ section === "page" ||
3474
+ (section === "reference" && folder === path.join(project.root, dir))
3475
+ ) {
3476
+ count("_order.yaml with no sidebar to order, deleted");
3477
+ return;
3478
+ }
3479
+ const remaining = project.pages.filter(
3480
+ (page) =>
3481
+ !project.handled.has(page.file) && page.file.startsWith(folder + path.sep)
3482
+ );
3483
+ if (remaining.length === 0) {
3484
+ return;
3485
+ }
3486
+ const target = path.join(
3487
+ destination(project, path.join(folder, "x")).replace(/[/\\]x$/u, ""),
3488
+ "meta.ts"
3489
+ );
3490
+ if (existsSync(target)) {
3491
+ note(
3492
+ path.relative(project.root, file),
3493
+ `${path.relative(project.root, target)} already exists; merge the order by hand`
3494
+ );
3495
+ return;
3496
+ }
3497
+ project.writes.set(
3498
+ target,
3499
+ metaModule(metaFields(project, dir, folder, remaining, order))
3500
+ );
3501
+ count("_order.yaml → meta.ts");
3502
+ };
3503
+
3504
+ // --- Project: overlays ----------------------------------------------------------------
3505
+
3506
+ const operationActions = (project, name, spec, describe) => {
3507
+ const actions = [];
3508
+ for (const [route, item] of Object.entries(recordOf(spec.data.paths))) {
3509
+ for (const [method, raw] of Object.entries(recordOf(item))) {
3510
+ const operation = recordOf(raw);
3511
+ if (!("responses" in operation || "operationId" in operation)) {
3512
+ continue;
3513
+ }
3514
+ const original = textOf(operation.description) ?? "";
3515
+ const converted = original === "" ? "" : describe(original);
3516
+ const prose = project.inventory.endpoints.find(
3517
+ (entry) =>
3518
+ entry.spec === name &&
3519
+ entry.operationId === operation.operationId &&
3520
+ entry.prose
3521
+ )?.prose;
3522
+ if (converted === original && !prose) {
3523
+ continue;
3524
+ }
3525
+ const reasons = [
3526
+ converted !== original && "ReadMe callouts as directives",
3527
+ prose && "the ReadMe endpoint page's prose",
3528
+ ].filter(Boolean);
3529
+ actions.push({
3530
+ description: `${reasons.join("; ")} (${operation.operationId ?? `${method.toUpperCase()} ${route}`})`,
3531
+ target: `$.paths['${route.replaceAll("'", String.raw`\'`)}'].${method}`,
3532
+ update: {
3533
+ description: [converted, prose].filter(Boolean).join("\n\n"),
3534
+ },
3535
+ });
3536
+ }
3537
+ }
3538
+ return actions;
3539
+ };
3540
+
3541
+ const tagActions = (project, name, spec, describe) => {
3542
+ const actions = [];
3543
+ for (const [position, raw] of listOf(spec.data.tags).entries()) {
3544
+ const tag = recordOf(raw);
3545
+ const original = textOf(tag.description) ?? "";
3546
+ const converted = original === "" ? "" : describe(original);
3547
+ const prose = project.inventory.tagFolders.find(
3548
+ (entry) =>
3549
+ entry.prose &&
3550
+ project.inventory.endpoints.some(
3551
+ (endpoint) =>
3552
+ endpoint.spec === name &&
3553
+ endpoint.tag === tag.name &&
3554
+ endpoint.folder === entry.oldUrl.split("/").pop()
3555
+ )
3556
+ )?.prose;
3557
+ if (converted !== original || prose) {
3558
+ actions.push({
3559
+ description: `tag ${tag.name}`,
3560
+ target: `$.tags[${position}]`,
3561
+ update: {
3562
+ description: [converted, prose].filter(Boolean).join("\n\n"),
3563
+ },
3564
+ });
3565
+ }
3566
+ }
3567
+ return actions;
3568
+ };
3569
+
3570
+ const writeOverlays = (project) => {
3571
+ for (const [name, spec] of [...project.specs.entries()].toSorted()) {
3572
+ if (spec.yaml) {
3573
+ todo(
3574
+ `reference/${name} is YAML: the overlay, redirect, and order steps read JSON only; convert it (npx @redocly/cli bundle reference/${name} --output reference/${name.replace(/\.ya?ml$/iu, ".json")}) and rerun`
3575
+ );
3576
+ }
3577
+ if (!spec.data) {
3578
+ continue;
3579
+ }
3580
+ const specFile = `reference/${name}`;
3581
+ const describe = (value) =>
3582
+ convertDescription(value, specFile, project.context);
3583
+ const actions = [
3584
+ ...operationActions(project, name, spec, describe),
3585
+ ...tagActions(project, name, spec, describe),
3586
+ ];
3587
+ const info = textOf(recordOf(spec.data.info).description);
3588
+ if (info !== undefined && describe(info) !== info) {
3589
+ actions.push({
3590
+ description: "info",
3591
+ target: "$.info",
3592
+ update: { description: describe(info) },
3593
+ });
3594
+ }
3595
+ if (actions.length === 0) {
3596
+ continue;
3597
+ }
3598
+ const overlay = `reference/overlays/${name.replace(/\.json$/iu, "")}.overlay.json`;
3599
+ project.writes.set(
3600
+ path.join(project.root, overlay),
3601
+ `${JSON.stringify({ actions, info: { title: `ReadMe migration (${name})`, version: "1.0.0" }, overlay: "1.0.0" }, null, 2)}\n`
3602
+ );
3603
+ count("overlay actions", actions.length);
3604
+ todo(`add the overlay to that spec's source: overlays: ["./${overlay}"]`);
3605
+ }
3606
+ };
3607
+
3608
+ const reportProject = (project) => {
3609
+ const specsByCategory = new Map();
3610
+ for (const endpoint of project.inventory.endpoints) {
3611
+ const set = specsByCategory.get(endpoint.category) ?? new Set();
3612
+ set.add(endpoint.spec);
3613
+ specsByCategory.set(endpoint.category, set);
3614
+ }
3615
+ for (const [category, set] of specsByCategory) {
3616
+ if (set.size > 1) {
3617
+ todo(
3618
+ `reference category "${category}" held ${set.size} specs (${[...set].toSorted().join(", ")}): give each its own label or route; they can't share one`
3619
+ );
3620
+ }
3621
+ }
3622
+ if (project.context.variables.size > 0) {
3623
+ todo(
3624
+ `define these in blume.config.ts variables (defaults from the hub): ${[...project.context.variables].toSorted().join(", ")}`
3625
+ );
3626
+ }
3627
+ };
3628
+
3629
+ const sortedRedirects = (redirects) =>
3630
+ [...redirects.values()].toSorted((a, b) => a.from.localeCompare(b.from));
3631
+
3632
+ const convertProject = (root, options) => {
3633
+ const project = createProject(root, options);
3634
+ recordUrls(project);
3635
+ classifyReference(project);
3636
+ removeEmptyParents(project);
3637
+ for (const page of project.pages.filter(
3638
+ (entry) => !project.handled.has(entry.file)
3639
+ )) {
3640
+ convertPage(project, page);
3641
+ }
3642
+ convertSnippetFiles(project);
3643
+ if (project.syncLayout) {
3644
+ for (const entry of orderFiles(project)) {
3645
+ writeMeta(project, entry);
3646
+ }
3647
+ }
3648
+ writeOverlays(project);
3649
+ addSectionRedirects(project);
3650
+ reportProject(project);
3651
+ if (project.syncLayout) {
3652
+ project.writes.set(
3653
+ path.join(root, INVENTORY),
3654
+ `${JSON.stringify(project.inventory, null, 2)}\n`
3655
+ );
3656
+ project.writes.set(
3657
+ path.join(root, REDIRECTS),
3658
+ `${JSON.stringify(sortedRedirects(project.redirects), null, 2)}\n`
3659
+ );
3660
+ } else {
3661
+ todo(
3662
+ "not ReadMe's Git sync layout (no _order.yaml): only page content was converted; rebuild the tree from frontmatter as the reference describes"
3663
+ );
3664
+ }
3665
+ return { deletes: project.deletes, writes: project.writes };
3666
+ };
3667
+
3668
+ // --- Pass 2: routes -------------------------------------------------------------------
3669
+
3670
+ const resolveRoute = (state, endpoint) => {
3671
+ const reference = state.bySpec.get(endpoint.spec);
3672
+ const byId =
3673
+ state.references[reference]?.operationIds?.[endpoint.operationId];
3674
+ if (byId) {
3675
+ return byId;
3676
+ }
3677
+ if (!(endpoint.method && endpoint.path)) {
3678
+ return;
3679
+ }
3680
+ const key = `${endpoint.method} ${endpoint.path}`;
3681
+ const candidates = Object.entries(state.references)
3682
+ .filter(([route]) => !reference || route === reference)
3683
+ .map(([, entry]) => entry.endpoints?.[key])
3684
+ .filter(Boolean);
3685
+ if (candidates.length > 1) {
3686
+ todo(
3687
+ `${endpoint.oldUrl}: ${key} is in several references; pass --spec to pick one`
3688
+ );
3689
+ }
3690
+ return candidates.length === 1 ? candidates[0] : undefined;
3691
+ };
3692
+
3693
+ const rankOf = (inventory, endpoint) => [
3694
+ Math.max(inventory.referenceOrder.indexOf(endpoint.category), 0),
3695
+ Math.max(
3696
+ inventory.tagFolders.find(
3697
+ (tag) => tag.oldUrl.split("/").pop() === endpoint.folder
3698
+ )?.position ?? 0,
3699
+ 0
3700
+ ),
3701
+ endpoint.position < 0 ? Number.MAX_SAFE_INTEGER : endpoint.position,
3702
+ ];
3703
+
3704
+ const compareRanks = (inventory) => (a, b) => {
3705
+ const left = rankOf(inventory, a);
3706
+ const right = rankOf(inventory, b);
3707
+ const index = left.findIndex((value, position) => value !== right[position]);
3708
+ return index === -1 ? 0 : left[index] - right[index];
3709
+ };
3710
+
3711
+ const redirectEndpoints = (state) => {
3712
+ for (const endpoint of state.inventory.endpoints) {
3713
+ const route = resolveRoute(state, endpoint);
3714
+ if (!route) {
3715
+ todo(
3716
+ `${endpoint.oldUrl} (${endpoint.spec} ${endpoint.operationId}) has no generated page in the routes file`
3717
+ );
3718
+ continue;
3719
+ }
3720
+ endpoint.route = route;
3721
+ state.moved.set(endpoint.oldUrl, route);
3722
+ if (
3723
+ state.taken.has(endpoint.oldUrl) ||
3724
+ state.pageUrls.has(endpoint.oldUrl)
3725
+ ) {
3726
+ todo(
3727
+ `${endpoint.oldUrl} is now ${state.taken.has(endpoint.oldUrl) ? "a reference overview or operation" : "a page"}: no redirect (it would hide that page)`
3728
+ );
3729
+ } else if (!state.redirects.has(endpoint.oldUrl)) {
3730
+ state.redirects.set(endpoint.oldUrl, {
3731
+ from: endpoint.oldUrl,
3732
+ to: route,
3733
+ });
3734
+ }
3735
+ }
3736
+ };
3737
+
3738
+ const redirectTags = (state) => {
3739
+ const routed = state.inventory.endpoints
3740
+ .filter((endpoint) => endpoint.route)
3741
+ .toSorted(state.compare);
3742
+ for (const tag of state.inventory.tagFolders) {
3743
+ const folder = tag.oldUrl.split("/").pop();
3744
+ const first = routed.find((endpoint) => endpoint.folder === folder);
3745
+ if (!first) {
3746
+ continue;
3747
+ }
3748
+ state.moved.set(tag.oldUrl, first.route);
3749
+ const free = !(
3750
+ state.taken.has(tag.oldUrl) || state.pageUrls.has(tag.oldUrl)
3751
+ );
3752
+ if (free && !state.redirects.has(tag.oldUrl)) {
3753
+ state.redirects.set(tag.oldUrl, {
3754
+ from: tag.oldUrl,
3755
+ status: 302,
3756
+ to: first.route,
3757
+ });
3758
+ }
3759
+ }
3760
+ const [first] = routed;
3761
+ if (
3762
+ !state.redirects.has("/reference") &&
3763
+ !state.taken.has("/reference") &&
3764
+ first &&
3765
+ state.inventory.referenceOrder[0] === first.category
3766
+ ) {
3767
+ state.redirects.set("/reference", {
3768
+ from: "/reference",
3769
+ status: 302,
3770
+ to: first.route,
3771
+ });
3772
+ }
3773
+ };
3774
+
3775
+ // A redirect from a URL that is now a reference overview or operation page
3776
+ // would hide that page (pass 1 sends /reference to the first page, but a
3777
+ // single spec's overview lives there).
3778
+ const dropShadowingRedirects = (state) => {
3779
+ for (const from of state.redirects.keys()) {
3780
+ if (state.taken.has(from)) {
3781
+ state.redirects.delete(from);
3782
+ todo(
3783
+ `${from} is now a reference page: its redirect was removed (it would hide the page)`
3784
+ );
3785
+ }
3786
+ }
3787
+ };
3788
+
3789
+ const groupByDirectory = (endpoints) => {
3790
+ const byDirectory = new Map();
3791
+ for (const endpoint of endpoints) {
3792
+ const directory = endpoint.route.split("/").slice(0, -1).join("/");
3793
+ byDirectory.set(directory, [
3794
+ ...(byDirectory.get(directory) ?? []),
3795
+ endpoint,
3796
+ ]);
3797
+ }
3798
+ const bySource = new Map();
3799
+ for (const [directory, list] of byDirectory) {
3800
+ const source = directory.split("/").slice(0, -1).join("/");
3801
+ bySource.set(source, [...(bySource.get(source) ?? []), [directory, list]]);
3802
+ }
3803
+ return bySource;
3804
+ };
3805
+
3806
+ // Tag-level meta.ts: ReadMe's endpoint order inside each tag, where it can
3807
+ // differ from the spec's order, which the generated sidebar follows.
3808
+ const writeTagMetas = (state) => {
3809
+ const routed = state.inventory.endpoints.filter((endpoint) => endpoint.route);
3810
+ for (const [source, directories] of sortedEntries(groupByDirectory(routed))) {
3811
+ const sorted = directories
3812
+ .map(([directory, list]) => [directory, list.toSorted(state.compare)])
3813
+ .toSorted((a, b) => state.compare(a[1][0], b[1][0]));
3814
+ for (const [position, [directory, endpoints]] of sorted.entries()) {
3815
+ const [{ tag }] = endpoints;
3816
+ if (!tag) {
3817
+ todo(
3818
+ `${directory.slice(1)}: tag name unknown (YAML spec?); write its meta.ts by hand`
3819
+ );
3820
+ continue;
3821
+ }
3822
+ const target = path.join(state.root, directory.slice(1), "meta.ts");
3823
+ const content = metaModule({
3824
+ order: position,
3825
+ pages: endpoints.map((endpoint) => endpoint.route.split("/").pop()),
3826
+ title: tag,
3827
+ });
3828
+ if (existsSync(target) && readFileSync(target, "utf-8") !== content) {
3829
+ note(
3830
+ path.relative(state.root, target),
3831
+ "exists with other content; left as is"
3832
+ );
3833
+ } else {
3834
+ state.writes.set(target, content);
3835
+ count("tag meta.ts written (ReadMe's endpoint order)");
3836
+ }
3837
+ }
3838
+ const [[, [{ category }]]] = sorted;
3839
+ const position = state.inventory.referenceOrder.indexOf(category);
3840
+ todo(
3841
+ `${source.slice(1)}/meta.ts: defineMeta({ order: ${position === -1 ? "<n>" : position} }) puts it where ReadMe listed "${category}" (the source's label stays its title)`
3842
+ );
3843
+ }
3844
+ };
3845
+
3846
+ const rewriteMovedLinks = (state) => {
3847
+ if (state.moved.size === 0) {
3848
+ return;
3849
+ }
3850
+ const urls = [...state.moved.keys()]
3851
+ .toSorted((a, b) => b.length - a.length)
3852
+ .map(escapeRegExp);
3853
+ const pattern = new RegExp(
3854
+ String.raw`(?<=[\s("'\x60])(?<url>${urls.join("|")})(?<hash>#[^\s)"'\x60]*)?(?=[\s)"'\x60]|$)`,
3855
+ "gmu"
3856
+ );
3857
+ for (const dir of [
3858
+ "docs",
3859
+ "reference",
3860
+ "recipes",
3861
+ "page",
3862
+ "changelog",
3863
+ "_snippets",
3864
+ ]) {
3865
+ for (const file of walk(path.join(state.root, dir)).filter((entry) =>
3866
+ entry.endsWith(".mdx")
3867
+ )) {
3868
+ const before = readFileSync(file, "utf-8");
3869
+ // Fenced code samples keep the URLs they show.
3870
+ const after = mapProse(before, (text) =>
3871
+ text.replaceAll(
3872
+ pattern,
3873
+ byGroups(({ hash, url }) => {
3874
+ count("link to an old endpoint or tag URL rewritten");
3875
+ if (hash) {
3876
+ note(
3877
+ path.relative(state.root, file),
3878
+ `anchor ${hash} dropped from a link to ${url}`
3879
+ );
3880
+ }
3881
+ return state.moved.get(url);
3882
+ })
3883
+ )
3884
+ );
3885
+ if (after !== before) {
3886
+ state.writes.set(file, after);
3887
+ }
3888
+ }
3889
+ }
3890
+ };
3891
+
3892
+ const applyRoutes = (root, options) => {
3893
+ const inventory = readJson(path.join(root, INVENTORY), null);
3894
+ if (!inventory) {
3895
+ throw new Error(`${INVENTORY} not found: run the convert pass first`);
3896
+ }
3897
+ const routes = readJson(options.routes, null);
3898
+ if (!routes?.references) {
3899
+ throw new Error(`${options.routes} isn't operation-routes.mjs output`);
3900
+ }
3901
+ const references = recordOf(routes.references);
3902
+ const state = {
3903
+ bySpec: new Map(
3904
+ options.specs.map(({ file, route }) => [path.basename(file), route])
3905
+ ),
3906
+ compare: compareRanks(inventory),
3907
+ inventory,
3908
+ moved: new Map(),
3909
+ pageUrls: new Set(
3910
+ inventory.urls
3911
+ .filter((entry) => entry.kind === "page")
3912
+ .map((entry) => entry.url)
3913
+ ),
3914
+ redirects: new Map(
3915
+ readJson(path.join(root, REDIRECTS), []).map((entry) => [
3916
+ entry.from,
3917
+ entry,
3918
+ ])
3919
+ ),
3920
+ references,
3921
+ root,
3922
+ taken: new Set([
3923
+ ...Object.keys(references),
3924
+ ...Object.values(references).flatMap((entry) =>
3925
+ Object.values(recordOf(entry.endpoints))
3926
+ ),
3927
+ ]),
3928
+ writes: new Map(),
3929
+ };
3930
+ dropShadowingRedirects(state);
3931
+ redirectEndpoints(state);
3932
+ redirectTags(state);
3933
+ writeTagMetas(state);
3934
+ rewriteMovedLinks(state);
3935
+ state.writes.set(
3936
+ path.join(root, INVENTORY),
3937
+ `${JSON.stringify(inventory, null, 2)}\n`
3938
+ );
3939
+ state.writes.set(
3940
+ path.join(root, REDIRECTS),
3941
+ `${JSON.stringify(sortedRedirects(state.redirects), null, 2)}\n`
3942
+ );
3943
+ return { deletes: new Set(), writes: state.writes };
3944
+ };
3945
+
3946
+ // --- Driver ---------------------------------------------------------------------------
3947
+
3948
+ const HELP = `readme-codemod: the mechanical pass of a ReadMe → Blume migration
3949
+
3950
+ node readme-codemod.mjs [--write] [--json] [--site <origin>]... [--glossary <file>] <project-root>
3951
+ node readme-codemod.mjs --routes <routes.json> [--spec <route>=<file>]... [--write] [--json] <project-root>
3952
+
3953
+ Without --write it only reports. See the header of this file for what each pass does.`;
3954
+
3955
+ const OPTION_HANDLERS = {
3956
+ "--glossary": (options, value) => {
3957
+ options.glossary = path.resolve(value);
3958
+ },
3959
+ "--routes": (options, value) => {
3960
+ options.routes = path.resolve(value);
3961
+ },
3962
+ "--site": (options, value) => {
3963
+ options.sites.push(value.replace(/\/+$/u, ""));
3964
+ },
3965
+ "--spec": (options, value) => {
3966
+ const split = value.indexOf("=");
3967
+ if (split <= 0) {
3968
+ throw new Error(`--spec takes <route>=<file>, got ${value}`);
3969
+ }
3970
+ const route = value.slice(0, split).replaceAll(/^\/+|\/+$/gu, "");
3971
+ options.specs.push({ file: value.slice(split + 1), route: `/${route}` });
3972
+ },
3973
+ };
3974
+ const FLAGS = {
3975
+ "--help": "help",
3976
+ "--json": "json",
3977
+ "--write": "write",
3978
+ "-h": "help",
3979
+ };
3980
+
3981
+ const parseArgs = (argv) => {
3982
+ const options = {
3983
+ glossary: null,
3984
+ help: false,
3985
+ json: false,
3986
+ root: null,
3987
+ routes: null,
3988
+ sites: [],
3989
+ specs: [],
3990
+ write: false,
3991
+ };
3992
+ for (let index = 0; index < argv.length; index += 1) {
3993
+ const arg = argv[index];
3994
+ if (Object.hasOwn(FLAGS, arg)) {
3995
+ options[FLAGS[arg]] = true;
3996
+ } else if (Object.hasOwn(OPTION_HANDLERS, arg)) {
3997
+ index += 1;
3998
+ if (argv[index] === undefined) {
3999
+ throw new Error(`${arg} needs a value`);
4000
+ }
4001
+ OPTION_HANDLERS[arg](options, argv[index]);
4002
+ } else if (arg.startsWith("-")) {
4003
+ throw new Error(`unknown option ${arg}`);
4004
+ } else if (options.root === null) {
4005
+ options.root = path.resolve(arg);
4006
+ } else {
4007
+ throw new Error("one project root only");
4008
+ }
4009
+ }
4010
+ return options;
4011
+ };
4012
+
4013
+ const removeEmptyDirs = (dir) => {
4014
+ if (!(existsSync(dir) && statSync(dir).isDirectory())) {
4015
+ return;
4016
+ }
4017
+ for (const name of readdirSync(dir)) {
4018
+ removeEmptyDirs(path.join(dir, name));
4019
+ }
4020
+ if (readdirSync(dir).length === 0) {
4021
+ rmdirSync(dir);
4022
+ }
4023
+ };
4024
+
4025
+ const apply = (root, writes, deletes) => {
4026
+ for (const [file, content] of [...writes.entries()].toSorted()) {
4027
+ mkdirSync(path.dirname(file), { recursive: true });
4028
+ writeFileSync(file, content, "utf-8");
4029
+ }
4030
+ for (const file of [...deletes].toSorted()) {
4031
+ if (!writes.has(file) && existsSync(file)) {
4032
+ rmSync(file);
4033
+ }
4034
+ }
4035
+ for (const dir of [
4036
+ "docs",
4037
+ "reference",
4038
+ "recipes",
4039
+ "custom_pages",
4040
+ "page",
4041
+ "changelogs",
4042
+ "changelog",
4043
+ ]) {
4044
+ removeEmptyDirs(path.join(root, dir));
4045
+ }
4046
+ };
4047
+
4048
+ const summarize = (root, writes, deletes, write) => {
4049
+ const relative = (file) => path.relative(root, file);
4050
+ return {
4051
+ counts: Object.fromEntries(
4052
+ sortedEntries(report.counts).filter(([, value]) => value !== 0)
4053
+ ),
4054
+ deleted: [...deletes]
4055
+ .filter((file) => existsSync(file) || write)
4056
+ .map(relative)
4057
+ .toSorted(),
4058
+ files: Object.fromEntries(
4059
+ sortedEntries(report.files).map(([file, messages]) => [
4060
+ file,
4061
+ [...new Set(messages)],
4062
+ ])
4063
+ ),
4064
+ todo: [...new Set(report.todo)],
4065
+ written: [...writes.keys()].map(relative).toSorted(),
4066
+ wrote: write,
4067
+ };
4068
+ };
4069
+
4070
+ const printReport = (summary) => {
4071
+ const out = [];
4072
+ if (summary.written.length === 0 && summary.deleted.length === 0) {
4073
+ out.push("Nothing to change.");
4074
+ } else {
4075
+ out.push(
4076
+ summary.wrote ? "Applied:" : "Would apply (dry run; --write to apply):"
4077
+ );
4078
+ for (const [kind, value] of Object.entries(summary.counts)) {
4079
+ out.push(` ${value}× ${kind}`);
4080
+ }
4081
+ out.push(
4082
+ ` ${summary.written.length} file(s) written, ${summary.deleted.length} deleted`
4083
+ );
4084
+ }
4085
+ const files = Object.entries(summary.files);
4086
+ if (files.length > 0) {
4087
+ out.push("", "Review, per file:");
4088
+ for (const [file, messages] of files) {
4089
+ out.push(` ${file}`, ...messages.map((message) => ` - ${message}`));
4090
+ }
4091
+ }
4092
+ if (summary.todo.length > 0) {
4093
+ out.push("", "Left for you:", ...summary.todo.map((item) => ` - ${item}`));
4094
+ }
4095
+ process.stdout.write(`${out.join("\n")}\n`);
4096
+ };
4097
+
4098
+ const run = (options) => {
4099
+ const result = options.routes
4100
+ ? applyRoutes(options.root, options)
4101
+ : convertProject(options.root, options);
4102
+ // Skip writes that wouldn't change a byte, so a rerun reports nothing.
4103
+ const writes = new Map(
4104
+ [...result.writes].filter(
4105
+ ([file, content]) =>
4106
+ !(existsSync(file) && readFileSync(file, "utf-8") === content)
4107
+ )
4108
+ );
4109
+ const deletes = new Set(
4110
+ [...result.deletes].filter((file) => existsSync(file))
4111
+ );
4112
+ if (options.write) {
4113
+ apply(options.root, writes, deletes);
4114
+ }
4115
+ return summarize(options.root, writes, deletes, options.write);
4116
+ };
4117
+
4118
+ const main = () => {
4119
+ let options;
4120
+ let summary;
4121
+ try {
4122
+ options = parseArgs(process.argv.slice(2));
4123
+ if (options.help || options.root === null) {
4124
+ process.stdout.write(`${HELP}\n`);
4125
+ return;
4126
+ }
4127
+ summary = run(options);
4128
+ } catch (error) {
4129
+ process.stderr.write(`readme-codemod: ${error.message}\n`);
4130
+ process.exit(1);
4131
+ }
4132
+ if (options.json) {
4133
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
4134
+ } else {
4135
+ printReport(summary);
4136
+ }
4137
+ };
4138
+
4139
+ main();