blume 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +126 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-74wfhv79.js → chunk-05f21r8h.js} +1 -1
  5. package/dist/cli/{chunk-9qzgg6mj.js → chunk-1cm0h819.js} +3 -3
  6. package/dist/cli/{chunk-nbyvgnnm.js → chunk-1rkxzg3n.js} +104 -47
  7. package/dist/cli/chunk-1rkxzg3n.js.map +36 -0
  8. package/dist/cli/{chunk-desvm72z.js → chunk-2nr29y95.js} +22 -26
  9. package/dist/cli/chunk-2nr29y95.js.map +13 -0
  10. package/dist/cli/{chunk-32khwcb6.js → chunk-49r370mb.js} +42 -69
  11. package/dist/cli/{chunk-32khwcb6.js.map → chunk-49r370mb.js.map} +9 -10
  12. package/dist/cli/{chunk-ms9z66nz.js → chunk-4rftmttv.js} +2 -2
  13. package/dist/cli/{chunk-s0bdnc7v.js → chunk-79bzzkx4.js} +5 -4
  14. package/dist/cli/chunk-79bzzkx4.js.map +10 -0
  15. package/dist/cli/{chunk-hvcxng5f.js → chunk-7tekknev.js} +6 -6
  16. package/dist/cli/{chunk-dv2z0rzf.js → chunk-8b489v1b.js} +38 -13
  17. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-8b489v1b.js.map} +5 -4
  18. package/dist/cli/{chunk-0pmb4tr4.js → chunk-8f8xw4x2.js} +1195 -222
  19. package/dist/cli/chunk-8f8xw4x2.js.map +44 -0
  20. package/dist/cli/chunk-96snx8yy.js +329 -0
  21. package/dist/cli/chunk-96snx8yy.js.map +13 -0
  22. package/dist/cli/{chunk-ckrcm08h.js → chunk-a3ydrqny.js} +48 -62
  23. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-a3ydrqny.js.map} +4 -5
  24. package/dist/cli/{chunk-bxwhtvw8.js → chunk-ahc5gc9h.js} +5 -5
  25. package/dist/cli/{chunk-dpy8qdjf.js → chunk-as09pafp.js} +73 -182
  26. package/dist/cli/chunk-as09pafp.js.map +18 -0
  27. package/dist/cli/{chunk-tfycddbt.js → chunk-bjk964fp.js} +2 -2
  28. package/dist/cli/{chunk-gfc5fqvf.js → chunk-br0n8n3e.js} +18 -18
  29. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-br0n8n3e.js.map} +1 -1
  30. package/dist/cli/{chunk-297ajdcv.js → chunk-c69tw29j.js} +6 -6
  31. package/dist/cli/{chunk-297ajdcv.js.map → chunk-c69tw29j.js.map} +3 -3
  32. package/dist/cli/chunk-cn3dy3hz.js +146 -0
  33. package/dist/cli/chunk-cn3dy3hz.js.map +11 -0
  34. package/dist/cli/{chunk-124fvmzq.js → chunk-dm4mg9bj.js} +11064 -8742
  35. package/dist/cli/{chunk-124fvmzq.js.map → chunk-dm4mg9bj.js.map} +78 -64
  36. package/dist/cli/{chunk-dqhg54mr.js → chunk-dmx6cezx.js} +1 -1
  37. package/dist/cli/{chunk-60hz265r.js → chunk-dpcahcrb.js} +24 -11
  38. package/dist/cli/chunk-dpcahcrb.js.map +10 -0
  39. package/dist/cli/{chunk-a57yh0h9.js → chunk-dyk3r7jw.js} +7 -7
  40. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-dyk3r7jw.js.map} +1 -1
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-enk76qd5.js} +14 -10
  42. package/dist/cli/chunk-enk76qd5.js.map +10 -0
  43. package/dist/cli/{chunk-k3gr1nm9.js → chunk-fqy4n3q2.js} +13 -16
  44. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-fqy4n3q2.js.map} +3 -3
  45. package/dist/cli/{chunk-sc93apfs.js → chunk-frk2wdq1.js} +17 -17
  46. package/dist/cli/{chunk-sc93apfs.js.map → chunk-frk2wdq1.js.map} +2 -2
  47. package/dist/cli/{chunk-kdpzw38a.js → chunk-gprwej7h.js} +95 -7
  48. package/dist/cli/chunk-gprwej7h.js.map +11 -0
  49. package/dist/cli/chunk-kkrteb15.js +157 -0
  50. package/dist/cli/chunk-kkrteb15.js.map +10 -0
  51. package/dist/cli/{chunk-ave1wcbr.js → chunk-m9bp2hc1.js} +1 -1
  52. package/dist/cli/{chunk-58bt3hag.js → chunk-mrvh4c88.js} +4 -4
  53. package/dist/cli/{chunk-5cmts6dv.js → chunk-p8zg3a2h.js} +7 -7
  54. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-p8zg3a2h.js.map} +1 -1
  55. package/dist/cli/{chunk-0w6hs479.js → chunk-rkqae7kh.js} +30 -18
  56. package/dist/cli/chunk-rkqae7kh.js.map +10 -0
  57. package/dist/cli/{chunk-bqxaqn5p.js → chunk-t5xx74rd.js} +8 -8
  58. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-t5xx74rd.js.map} +1 -1
  59. package/dist/cli/{chunk-1f882900.js → chunk-th0pgaj5.js} +2 -2
  60. package/dist/cli/{chunk-dhwtqnnd.js → chunk-vnbvhgmd.js} +2 -2
  61. package/dist/cli/{chunk-gxtg6ed2.js → chunk-vzy9rh6y.js} +21 -21
  62. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-vzy9rh6y.js.map} +3 -3
  63. package/dist/cli/{chunk-j1x92mx9.js → chunk-w6e455pz.js} +1 -1
  64. package/dist/cli/chunk-wehv26ry.js +155 -0
  65. package/dist/cli/chunk-wehv26ry.js.map +11 -0
  66. package/dist/cli/{chunk-y4msrwm9.js → chunk-xgq4eytj.js} +15 -15
  67. package/dist/cli/{chunk-36bxs9c7.js → chunk-xt0qvz1t.js} +27 -23
  68. package/dist/cli/chunk-xt0qvz1t.js.map +10 -0
  69. package/dist/cli/index.js +78 -42
  70. package/dist/cli/index.js.map +7 -6
  71. package/dist/types/ai/changelog-components.d.ts +18 -0
  72. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  73. package/dist/types/ai/openapi-components.d.ts +7 -7
  74. package/dist/types/ai/operation-details.d.ts +39 -0
  75. package/dist/types/ai/relative-links.d.ts +3 -1
  76. package/dist/types/ai/serializers.d.ts +6 -4
  77. package/dist/types/analytics/head.d.ts +4 -2
  78. package/dist/types/analytics/script.d.ts +8 -2
  79. package/dist/types/components/content/youtube.d.ts +20 -0
  80. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  81. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  82. package/dist/types/components/openapi/helpers.d.ts +53 -11
  83. package/dist/types/components/openapi/snippets.d.ts +7 -1
  84. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  85. package/dist/types/core/builtin-tags.d.ts +16 -0
  86. package/dist/types/core/changelog-list.d.ts +48 -0
  87. package/dist/types/core/closest-match.d.ts +6 -0
  88. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  89. package/dist/types/core/code-fences.d.ts +8 -1
  90. package/dist/types/core/config-input.d.ts +1 -1
  91. package/dist/types/core/content-assets.d.ts +16 -18
  92. package/dist/types/core/diagnostics.d.ts +14 -0
  93. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  94. package/dist/types/core/former-routes.d.ts +14 -0
  95. package/dist/types/core/github.d.ts +5 -4
  96. package/dist/types/core/heading-markers.d.ts +65 -2
  97. package/dist/types/core/hidden-pages.d.ts +3 -0
  98. package/dist/types/core/includes.d.ts +23 -0
  99. package/dist/types/core/last-modified.d.ts +18 -6
  100. package/dist/types/core/links.d.ts +42 -2
  101. package/dist/types/core/load-module.d.ts +19 -4
  102. package/dist/types/core/manifest.d.ts +14 -1
  103. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  104. package/dist/types/core/mdx-elements.d.ts +32 -0
  105. package/dist/types/core/mdx-syntax.d.ts +100 -0
  106. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  107. package/dist/types/core/meta.d.ts +32 -5
  108. package/dist/types/core/nav-diagnostics.d.ts +9 -2
  109. package/dist/types/core/navigation.d.ts +13 -0
  110. package/dist/types/core/ordering-prefix.d.ts +6 -0
  111. package/dist/types/core/pinned-ids.d.ts +23 -0
  112. package/dist/types/core/project-graph.d.ts +9 -1
  113. package/dist/types/core/redirect-patterns.d.ts +18 -0
  114. package/dist/types/core/relative-files.d.ts +31 -0
  115. package/dist/types/core/schema.d.ts +267 -7
  116. package/dist/types/core/sources/normalize.d.ts +101 -9
  117. package/dist/types/core/sources/types.d.ts +16 -0
  118. package/dist/types/core/static-files.d.ts +10 -0
  119. package/dist/types/core/static-host.d.ts +42 -0
  120. package/dist/types/core/svg-images.d.ts +26 -0
  121. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  122. package/dist/types/core/types.d.ts +42 -1
  123. package/dist/types/deploy/generated-files.d.ts +41 -0
  124. package/dist/types/deploy/redirects.d.ts +21 -7
  125. package/dist/types/markdown/code-title.d.ts +9 -5
  126. package/dist/types/markdown/directives.d.ts +8 -0
  127. package/dist/types/markdown/fence-language.d.ts +51 -0
  128. package/dist/types/markdown/fence-meta.d.ts +5 -0
  129. package/dist/types/markdown/github-alerts.d.ts +35 -0
  130. package/dist/types/openapi/asyncapi.d.ts +6 -5
  131. package/dist/types/openapi/checks.d.ts +10 -5
  132. package/dist/types/openapi/model.d.ts +55 -8
  133. package/dist/types/openapi/parse.d.ts +4 -1
  134. package/dist/types/sources/filesystem.d.ts +14 -4
  135. package/dist/types/sources/mdx-remote.d.ts +6 -3
  136. package/dist/types/sources/registry.d.ts +3 -3
  137. package/dist/types/theme/color.d.ts +33 -0
  138. package/dist/types/theme/palette.d.ts +122 -0
  139. package/docs/01-quickstart.mdx +1 -1
  140. package/docs/02-deployment.mdx +23 -8
  141. package/docs/04-migrating.mdx +16 -4
  142. package/docs/advanced/changelog.mdx +27 -1
  143. package/docs/advanced/skills.mdx +1 -1
  144. package/docs/cli/audit.mdx +1 -1
  145. package/docs/cli/doctor.mdx +2 -2
  146. package/docs/cli/evals.mdx +1 -1
  147. package/docs/cli/index.mdx +4 -2
  148. package/docs/cli/validate.mdx +10 -7
  149. package/docs/configuration/analytics.mdx +2 -0
  150. package/docs/configuration/customization.mdx +3 -1
  151. package/docs/configuration/index.mdx +2 -2
  152. package/docs/configuration/theming.mdx +5 -1
  153. package/docs/content/components.mdx +26 -6
  154. package/docs/content/frontmatter.mdx +1 -1
  155. package/docs/content/includes.mdx +6 -2
  156. package/docs/content/index.mdx +11 -1
  157. package/docs/content/islands.mdx +2 -0
  158. package/docs/content/meta.mdx +9 -3
  159. package/docs/content/navigation.mdx +14 -6
  160. package/docs/content/sources/obsidian.mdx +1 -1
  161. package/docs/content/sources/remote-mdx.mdx +3 -1
  162. package/docs/content/syntax.mdx +76 -8
  163. package/docs/content/variables.mdx +3 -1
  164. package/docs/discoverability/agent-discovery.mdx +1 -1
  165. package/docs/discoverability/json-api.mdx +1 -1
  166. package/docs/discoverability/llms-txt.mdx +1 -1
  167. package/docs/discoverability/markdown.mdx +2 -2
  168. package/docs/discoverability/open-graph.mdx +1 -1
  169. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  170. package/docs/references/graphql.mdx +2 -0
  171. package/docs/references/openapi.mdx +45 -7
  172. package/package.json +4 -4
  173. package/skills/blume/SKILL.md +2 -2
  174. package/skills/blume-migrate/SKILL.md +49 -27
  175. package/skills/blume-migrate/references/docsify.md +262 -0
  176. package/skills/blume-migrate/references/docus.md +298 -0
  177. package/skills/blume-migrate/references/docusaurus.md +3 -3
  178. package/skills/blume-migrate/references/fern.md +327 -0
  179. package/skills/blume-migrate/references/fumadocs.md +1 -1
  180. package/skills/blume-migrate/references/gitbook.md +267 -0
  181. package/skills/blume-migrate/references/github-wiki.md +259 -0
  182. package/skills/blume-migrate/references/jekyll.md +253 -0
  183. package/skills/blume-migrate/references/mdbook.md +256 -0
  184. package/skills/blume-migrate/references/mintlify.md +5 -5
  185. package/skills/blume-migrate/references/mkdocs.md +236 -0
  186. package/skills/blume-migrate/references/monorepo.md +20 -8
  187. package/skills/blume-migrate/references/nextra.md +2 -2
  188. package/skills/blume-migrate/references/readme.md +257 -0
  189. package/skills/blume-migrate/references/redocly.md +198 -0
  190. package/skills/blume-migrate/references/starlight.md +3 -3
  191. package/skills/blume-migrate/references/vitepress.md +204 -0
  192. package/skills/blume-migrate/references/vuepress.md +271 -0
  193. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  194. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  195. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  196. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  197. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  198. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  199. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  200. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  201. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  202. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  203. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  204. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  205. package/src/ai/changelog-components.ts +24 -0
  206. package/src/ai/changelog-markdown.ts +45 -41
  207. package/src/ai/component-markdown.ts +22 -9
  208. package/src/ai/llms.ts +36 -5
  209. package/src/ai/markdown.ts +2 -2
  210. package/src/ai/openapi-components.ts +25 -9
  211. package/src/ai/operation-details.ts +208 -0
  212. package/src/ai/relative-links.ts +48 -8
  213. package/src/ai/serializers.ts +7 -6
  214. package/src/ai/site-skill.ts +6 -2
  215. package/src/analytics/head.ts +11 -5
  216. package/src/analytics/script.ts +13 -3
  217. package/src/astro/generate.ts +32 -6
  218. package/src/astro/index.ts +1 -0
  219. package/src/astro/integration.ts +27 -1
  220. package/src/astro/mdx-source-errors.ts +46 -0
  221. package/src/astro/templates.ts +89 -185
  222. package/src/audit/checks/redirects.ts +5 -1
  223. package/src/audit/redirects.ts +36 -19
  224. package/src/audit/report.ts +22 -9
  225. package/src/audit/run.ts +12 -28
  226. package/src/audit/terms.ts +23 -0
  227. package/src/audit/url.ts +2 -2
  228. package/src/cli/build-failure.ts +256 -7
  229. package/src/cli/command-meta.ts +1 -1
  230. package/src/cli/commands/audit.ts +1 -1
  231. package/src/cli/commands/build.ts +12 -3
  232. package/src/cli/commands/check.ts +13 -2
  233. package/src/cli/commands/dev.ts +1 -1
  234. package/src/cli/commands/doctor.ts +4 -2
  235. package/src/cli/commands/init.ts +3 -3
  236. package/src/cli/commands/preview.ts +17 -1
  237. package/src/cli/commands/validate.ts +22 -10
  238. package/src/cli/prepare.ts +1 -1
  239. package/src/cli/preview-pages.ts +161 -0
  240. package/src/cli/unknown-flags.ts +1 -42
  241. package/src/components/code-copy.ts +51 -0
  242. package/src/components/code-language.ts +75 -0
  243. package/src/components/content/AccordionItem.astro +16 -1
  244. package/src/components/content/Callout.astro +11 -1
  245. package/src/components/content/Changelog.astro +189 -0
  246. package/src/components/content/CodeBlock.astro +8 -11
  247. package/src/components/content/Tabs.astro +16 -2
  248. package/src/components/content/YouTube.astro +6 -4
  249. package/src/components/content/code-block.ts +33 -0
  250. package/src/components/content/content-strings.ts +5 -1
  251. package/src/components/content/inline-markdown.ts +35 -8
  252. package/src/components/content/youtube.ts +49 -7
  253. package/src/components/layout/Analytics.astro +5 -1
  254. package/src/components/layout/NavTree.astro +8 -3
  255. package/src/components/layout/RootLayout.astro +18 -31
  256. package/src/components/layout/nav-utils.ts +14 -0
  257. package/src/components/layout/search/pagefind.ts +2 -1
  258. package/src/components/openapi/Operation.astro +4 -0
  259. package/src/components/openapi/PanelTabs.astro +9 -3
  260. package/src/components/openapi/RequestBody.astro +42 -8
  261. package/src/components/openapi/RequestPanel.astro +34 -53
  262. package/src/components/openapi/example-panels.ts +110 -0
  263. package/src/components/openapi/helpers.ts +195 -20
  264. package/src/components/openapi/operation-model.ts +63 -26
  265. package/src/components/openapi/request.ts +13 -0
  266. package/src/components/openapi/snippets.ts +14 -2
  267. package/src/core/alert-diagnostics.ts +89 -0
  268. package/src/core/builtin-tags.ts +76 -0
  269. package/src/core/changelog-list.ts +87 -0
  270. package/src/core/closest-match.ts +41 -0
  271. package/src/core/code-fence-diagnostics.ts +267 -0
  272. package/src/core/code-fences.ts +43 -7
  273. package/src/core/component-diagnostics.ts +65 -12
  274. package/src/core/config-input.ts +1 -1
  275. package/src/core/config.ts +46 -1
  276. package/src/core/content-assets.ts +64 -144
  277. package/src/core/diagnostics.ts +36 -3
  278. package/src/core/directive-diagnostics.ts +265 -47
  279. package/src/core/former-routes.ts +46 -0
  280. package/src/core/git-ignored.ts +53 -0
  281. package/src/core/github.ts +5 -4
  282. package/src/core/heading-markers.ts +156 -2
  283. package/src/core/hidden-pages.ts +64 -0
  284. package/src/core/includes.ts +181 -41
  285. package/src/core/last-modified.ts +47 -10
  286. package/src/core/links.ts +438 -83
  287. package/src/core/load-module.ts +98 -9
  288. package/src/core/manifest.ts +12 -9
  289. package/src/core/markdown-mirrors.ts +52 -0
  290. package/src/core/mdx-elements.ts +153 -0
  291. package/src/core/mdx-syntax.ts +377 -0
  292. package/src/core/meta-diagnostics.ts +134 -0
  293. package/src/core/meta.ts +154 -55
  294. package/src/core/nav-diagnostics.ts +16 -4
  295. package/src/core/navigation.ts +70 -10
  296. package/src/core/ordering-prefix.ts +22 -2
  297. package/src/core/pinned-ids.ts +141 -0
  298. package/src/core/project-graph.ts +221 -29
  299. package/src/core/redirect-patterns.ts +41 -0
  300. package/src/core/relative-files.ts +145 -0
  301. package/src/core/schema.ts +17 -3
  302. package/src/core/sources/mdx-remote.ts +75 -14
  303. package/src/core/sources/normalize.ts +885 -179
  304. package/src/core/sources/notion.ts +5 -4
  305. package/src/core/sources/obsidian.ts +2 -5
  306. package/src/core/sources/types.ts +17 -0
  307. package/src/core/static-files.ts +40 -0
  308. package/src/core/static-host.ts +125 -0
  309. package/src/core/svg-images.ts +98 -0
  310. package/src/core/syntax-diagnostics.ts +491 -0
  311. package/src/core/types.ts +43 -1
  312. package/src/deploy/artifacts.ts +2 -28
  313. package/src/deploy/generated-files.ts +128 -0
  314. package/src/deploy/platforms/vercel.ts +25 -1
  315. package/src/deploy/redirects.ts +43 -16
  316. package/src/deploy/sitemap.ts +2 -1
  317. package/src/markdown/base-links.ts +87 -6
  318. package/src/markdown/code-title.ts +29 -13
  319. package/src/markdown/content-assets.ts +210 -0
  320. package/src/markdown/directives.ts +69 -5
  321. package/src/markdown/fence-language.ts +106 -0
  322. package/src/markdown/fence-meta.ts +42 -0
  323. package/src/markdown/github-alerts.ts +172 -0
  324. package/src/markdown/heading-anchors.ts +265 -56
  325. package/src/markdown/include.ts +30 -3
  326. package/src/markdown/index.ts +145 -40
  327. package/src/markdown/prompt-text.ts +95 -0
  328. package/src/markdown/relative-links.ts +37 -2
  329. package/src/markdown/route-snapshot.ts +22 -12
  330. package/src/markdown/svg-fallback.ts +51 -0
  331. package/src/markdown/title-heading.ts +134 -0
  332. package/src/migrate/migrate.ts +127 -2
  333. package/src/og/card.ts +14 -4
  334. package/src/openapi/asyncapi.ts +14 -6
  335. package/src/openapi/checks.ts +160 -15
  336. package/src/openapi/model.ts +172 -33
  337. package/src/openapi/parse.ts +13 -4
  338. package/src/openapi/render-mdx.ts +55 -9
  339. package/src/openapi/source.ts +87 -6
  340. package/src/registry/eject.ts +0 -1
  341. package/src/search/documents.ts +10 -2
  342. package/src/sources/filesystem.ts +28 -3
  343. package/src/sources/mdx-remote.ts +7 -15
  344. package/src/theme/color.ts +14 -0
  345. package/src/theme/entry.ts +22 -1
  346. package/src/theme/palette.ts +74 -1
  347. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  348. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  349. package/dist/cli/chunk-150jk7km.js +0 -52
  350. package/dist/cli/chunk-150jk7km.js.map +0 -10
  351. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  352. package/dist/cli/chunk-53jqw8z0.js +0 -89
  353. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  354. package/dist/cli/chunk-60hz265r.js.map +0 -10
  355. package/dist/cli/chunk-desvm72z.js.map +0 -14
  356. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  357. package/dist/cli/chunk-es8h0v3j.js +0 -441
  358. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  359. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  360. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  361. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  362. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  363. package/dist/cli/chunk-ydp3065p.js +0 -64
  364. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  365. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-05f21r8h.js.map} +0 -0
  366. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-1cm0h819.js.map} +0 -0
  367. /package/dist/cli/{chunk-ms9z66nz.js.map → chunk-4rftmttv.js.map} +0 -0
  368. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-7tekknev.js.map} +0 -0
  369. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-ahc5gc9h.js.map} +0 -0
  370. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-bjk964fp.js.map} +0 -0
  371. /package/dist/cli/{chunk-dqhg54mr.js.map → chunk-dmx6cezx.js.map} +0 -0
  372. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-m9bp2hc1.js.map} +0 -0
  373. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-mrvh4c88.js.map} +0 -0
  374. /package/dist/cli/{chunk-1f882900.js.map → chunk-th0pgaj5.js.map} +0 -0
  375. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-vnbvhgmd.js.map} +0 -0
  376. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-w6e455pz.js.map} +0 -0
  377. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-xgq4eytj.js.map} +0 -0
@@ -0,0 +1,2633 @@
1
+ #!/usr/bin/env node
2
+ // fern-codemod.mjs: the mechanical half of a Fern Docs → Blume migration.
3
+ // Run it from the folder that holds `fern/` (where blume.config.ts goes).
4
+ //
5
+ // plan (default) Reads fern/docs.yml, computes every URL the Fern site
6
+ // served (tabs, sections, slugs, skip-slug, frontmatter
7
+ // slugs, hidden pages, api summaries, changelog dates), and
8
+ // moves each page to a path in the content root that
9
+ // reproduces it. Writes a meta.ts per folder (docs.yml
10
+ // order, section titles and icons, collapse), maps
11
+ // frontmatter to Blume's strict schema, converts the Fern
12
+ // components a migration always meets (callouts, code
13
+ // groups, cards, accordions, icons, param fields,
14
+ // snippets, embeds),
15
+ // moves the assets pages use into public/, and saves
16
+ // fern-migration.json for the next step.
17
+ // endpoints After `blume build` and operation-routes.mjs: joins
18
+ // Fern's old API Reference URLs to the routes Blume built,
19
+ // translates docs.yml redirects, adds group and changelog
20
+ // redirects, rewrites links to old URLs, checks every old
21
+ // URL is served or redirected, and writes
22
+ // fern-redirects.json for blume.config.ts to import.
23
+ //
24
+ // It reports, never guesses, the judgment calls: changelog titles taken from
25
+ // file names, API embeds (<EndpointRequestSnippet> …), links already broken
26
+ // on Fern, unpublished drafts, CI and contributor docs. Versions, products,
27
+ // tab variants, and `folder:` entries stay with the agent (the reference
28
+ // covers them). It never touches generators.yml, the Fern Definition, or a
29
+ // spec. Zero dependencies, deterministic, idempotent (a moved page is
30
+ // skipped, and its URL comes from fern-migration.json). Dry run unless --write.
31
+
32
+ // oxlint-disable max-classes-per-file -- the YAML reader, the docs.yml walker, and the
33
+ // placer are separate state machines; one file keeps the script runnable with a bare `node`.
34
+
35
+ import {
36
+ copyFileSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ readdirSync,
40
+ readFileSync,
41
+ renameSync,
42
+ statSync,
43
+ writeFileSync,
44
+ } from "node:fs";
45
+ import path from "node:path";
46
+
47
+ // --- YAML: the subset Fern config uses ---------------------------------------
48
+
49
+ const DASH = /^-(?:\s|$)/u;
50
+ const SKIP = /^\s*(?:#.*)?$|^(?:---|\.\.\.)\s*$/u;
51
+ const ESCAPES = {
52
+ "/": "/",
53
+ 0: "\0",
54
+ b: "\b",
55
+ f: "\f",
56
+ n: "\n",
57
+ r: "\r",
58
+ t: "\t",
59
+ };
60
+ const DOUBLE_BODY = /^(?:[^"\\]|\\[\s\S])*/u;
61
+ const SINGLE_BODY = /^(?:[^']|'')*/u;
62
+
63
+ const unescapeDouble = (text) =>
64
+ text.replaceAll(
65
+ /\\(?:x(?<hex>[\dA-Fa-f]{2})|u(?<uni>[\dA-Fa-f]{4})|(?<char>[\s\S]))/gu,
66
+ (...match) => {
67
+ const { char, hex, uni } = match.at(-1);
68
+ if (hex || uni) {
69
+ return String.fromCodePoint(Number.parseInt(hex ?? uni, 16));
70
+ }
71
+ return ESCAPES[char] ?? char;
72
+ }
73
+ );
74
+
75
+ /** Text before a ` # comment` that sits outside quotes. */
76
+ const stripComment = (text) => {
77
+ let quote = "";
78
+ for (let i = 0; i < text.length; i += 1) {
79
+ const char = text[i];
80
+ const prev = i === 0 ? " " : text[i - 1];
81
+ if (quote && char === "\\" && quote === '"') {
82
+ i += 1;
83
+ } else if (quote && char === quote) {
84
+ quote = "";
85
+ } else if (
86
+ !quote &&
87
+ (char === '"' || char === "'") &&
88
+ /[\s,:[{]/u.test(prev)
89
+ ) {
90
+ quote = char;
91
+ } else if (!quote && char === "#" && /\s/u.test(prev)) {
92
+ return text.slice(0, i).trimEnd();
93
+ }
94
+ }
95
+ return text.trimEnd();
96
+ };
97
+
98
+ const scalar = (raw) => {
99
+ const value = raw.trim();
100
+ if (/^(?:|~|null|Null|NULL)$/u.test(value)) {
101
+ return null;
102
+ }
103
+ if (/^(?:true|True|TRUE|false|False|FALSE)$/u.test(value)) {
104
+ return value.toLowerCase() === "true";
105
+ }
106
+ if (/^[-+]?(?:0|[1-9]\d*)(?:\.\d+)?$/u.test(value)) {
107
+ return Number(value);
108
+ }
109
+ return value;
110
+ };
111
+
112
+ /** Where a mapping key ends (`key: value`), or -1. */
113
+ const keyColon = (text) => {
114
+ if (/^[[{]/u.test(text)) {
115
+ return -1;
116
+ }
117
+ if (text[0] === '"' || text[0] === "'") {
118
+ const close = text.indexOf(text[0], 1);
119
+ return close !== -1 && /^:(?:\s|$)/u.test(text.slice(close + 1))
120
+ ? close + 1
121
+ : -1;
122
+ }
123
+ const match = /:(?:\s|$)/u.exec(text);
124
+ return match && !/\s#/u.test(text.slice(0, match.index)) ? match.index : -1;
125
+ };
126
+
127
+ const keyText = (raw) => {
128
+ const key = raw.trim();
129
+ if (key[0] === '"') {
130
+ return unescapeDouble(key.slice(1, -1));
131
+ }
132
+ return key[0] === "'" ? key.slice(1, -1).replaceAll("''", "'") : key;
133
+ };
134
+
135
+ /** A one-line flow collection: `[a, "b"]`, `{ name: x, env: Y }`. */
136
+ const parseFlow = (text) => {
137
+ let at = 0;
138
+ const space = () => {
139
+ while (/\s/u.test(text[at] ?? "")) {
140
+ at += 1;
141
+ }
142
+ };
143
+ const quoted = () => {
144
+ const quote = text[at];
145
+ const end = text.indexOf(quote, at + 1);
146
+ const inner = text.slice(at + 1, end);
147
+ at = end + 1;
148
+ return quote === '"' ? unescapeDouble(inner) : inner;
149
+ };
150
+ const value = (stops) => {
151
+ space();
152
+ if (text[at] === "[" || text[at] === "{") {
153
+ const close = text[at] === "[" ? "]" : "}";
154
+ const entries = [];
155
+ at += 1;
156
+ for (space(); text[at] !== close && at < text.length; space()) {
157
+ const key = value(close === "}" ? /[,:}]/u : /[,\]]/u);
158
+ space();
159
+ if (close === "}" && text[at] === ":") {
160
+ at += 1;
161
+ entries.push([String(key), value(/[,}]/u)]);
162
+ } else {
163
+ entries.push(close === "}" ? [String(key), null] : key);
164
+ }
165
+ space();
166
+ at += text[at] === "," ? 1 : 0;
167
+ }
168
+ at += 1;
169
+ return close === "]" ? entries : Object.fromEntries(entries);
170
+ }
171
+ if (text[at] === '"' || text[at] === "'") {
172
+ return quoted();
173
+ }
174
+ const start = at;
175
+ while (at < text.length && !stops.test(text[at])) {
176
+ at += 1;
177
+ }
178
+ return scalar(text.slice(start, at));
179
+ };
180
+ return value(/[,\]}]/u);
181
+ };
182
+
183
+ /** Fold continuation lines the way a YAML flow scalar does. */
184
+ const fold = (parts) => {
185
+ let out = parts[0].trimEnd();
186
+ let breaks = 0;
187
+ for (const part of parts.slice(1)) {
188
+ if (part.trim() === "") {
189
+ breaks += 1;
190
+ } else {
191
+ out += `${breaks > 0 ? "\n".repeat(breaks) : " "}${part.trim()}`;
192
+ breaks = 0;
193
+ }
194
+ }
195
+ return out;
196
+ };
197
+
198
+ class YamlReader {
199
+ constructor(source, file) {
200
+ this.lines = source.replaceAll(/\r\n?/gu, "\n").split("\n");
201
+ this.at = 0;
202
+ this.file = file;
203
+ }
204
+
205
+ fail(message, line = this.at) {
206
+ throw new Error(`${this.file}:${line + 1}: ${message}`);
207
+ }
208
+
209
+ indentOf(line) {
210
+ const [lead] = /^[\t ]*/u.exec(line);
211
+ if (lead.includes("\t")) {
212
+ this.fail("tabs in indentation aren't supported");
213
+ }
214
+ return lead.length;
215
+ }
216
+
217
+ peek() {
218
+ let i = this.at;
219
+ while (i < this.lines.length && SKIP.test(this.lines[i])) {
220
+ i += 1;
221
+ }
222
+ return i;
223
+ }
224
+
225
+ read() {
226
+ const start = this.peek();
227
+ if (start >= this.lines.length) {
228
+ return null;
229
+ }
230
+ const result = this.block(this.indentOf(this.lines[start]));
231
+ const rest = this.peek();
232
+ if (rest < this.lines.length) {
233
+ this.fail(`unexpected content: ${this.lines[rest].trim()}`, rest);
234
+ }
235
+ return result;
236
+ }
237
+
238
+ block(indent) {
239
+ this.at = this.peek();
240
+ const line = this.lines[this.at].slice(indent);
241
+ if (DASH.test(line)) {
242
+ return this.list(indent);
243
+ }
244
+ if (keyColon(line) !== -1) {
245
+ return this.map(indent);
246
+ }
247
+ return this.value(line, indent - 1, false);
248
+ }
249
+
250
+ map(indent) {
251
+ const map = {};
252
+ for (let i = this.peek(); i < this.lines.length; i = this.peek()) {
253
+ const depth = this.indentOf(this.lines[i]);
254
+ const line = this.lines[i].slice(depth);
255
+ if (depth < indent || DASH.test(line)) {
256
+ break;
257
+ }
258
+ const colon = keyColon(line);
259
+ if (depth > indent || colon === -1) {
260
+ this.fail(`expected "key: value" at this indentation: ${line}`, i);
261
+ }
262
+ this.at = i;
263
+ map[keyText(line.slice(0, colon))] = this.value(
264
+ line.slice(colon + 1),
265
+ depth,
266
+ true
267
+ );
268
+ }
269
+ this.at = this.peek();
270
+ return map;
271
+ }
272
+
273
+ list(indent) {
274
+ const items = [];
275
+ for (let i = this.peek(); i < this.lines.length; i = this.peek()) {
276
+ const depth = this.indentOf(this.lines[i]);
277
+ const line = this.lines[i].slice(depth);
278
+ if (depth !== indent || !DASH.test(line)) {
279
+ break;
280
+ }
281
+ this.at = i;
282
+ const content = line.slice(1).trimStart();
283
+ const column = depth + line.length - content.length;
284
+ const nested = DASH.test(content) || keyColon(content) !== -1;
285
+ if (content && !content.startsWith("#") && nested) {
286
+ // A map or list starting on the dash line: read it at its own column.
287
+ this.lines[i] = `${" ".repeat(column)}${content}`;
288
+ items.push(this.block(column));
289
+ } else {
290
+ items.push(this.value(content, depth, false));
291
+ }
292
+ }
293
+ this.at = this.peek();
294
+ return items;
295
+ }
296
+
297
+ value(rest, parent, compactList) {
298
+ const text = stripComment(rest).trim();
299
+ if (text === "") {
300
+ this.at += 1;
301
+ const next = this.peek();
302
+ const depth =
303
+ next < this.lines.length ? this.indentOf(this.lines[next]) : -1;
304
+ const sameLevelList =
305
+ compactList &&
306
+ depth === parent &&
307
+ DASH.test(this.lines[next].slice(depth));
308
+ return depth > parent || sameLevelList ? this.block(depth) : null;
309
+ }
310
+ if (/^[!&*]/u.test(text)) {
311
+ return this.fail("anchors, aliases, and tags aren't supported");
312
+ }
313
+ if (/^[>|]/u.test(text)) {
314
+ return this.literal(text, parent);
315
+ }
316
+ if (text[0] === "[" || text[0] === "{") {
317
+ this.at += 1;
318
+ return parseFlow(text);
319
+ }
320
+ if (text[0] === '"' || text[0] === "'") {
321
+ return this.quoted(rest.trim());
322
+ }
323
+ return this.plain(text, parent);
324
+ }
325
+
326
+ plain(first, parent) {
327
+ const parts = [first];
328
+ for (this.at += 1; this.at < this.lines.length; this.at += 1) {
329
+ const line = this.lines[this.at];
330
+ if (line.trim() === "") {
331
+ parts.push("");
332
+ } else if (SKIP.test(line) || this.indentOf(line) <= parent) {
333
+ break;
334
+ } else {
335
+ parts.push(stripComment(line));
336
+ }
337
+ }
338
+ while (parts.at(-1) === "") {
339
+ parts.pop();
340
+ this.at -= 1;
341
+ }
342
+ return parts.length === 1 ? scalar(first) : fold(parts);
343
+ }
344
+
345
+ quoted(first) {
346
+ const double = first[0] === '"';
347
+ const [inner] = (double ? DOUBLE_BODY : SINGLE_BODY).exec(first.slice(1));
348
+ if (inner.length >= first.length - 1) {
349
+ this.fail("a quoted string that spans lines isn't supported");
350
+ }
351
+ this.at += 1;
352
+ return double ? unescapeDouble(inner) : inner.replaceAll("''", "'");
353
+ }
354
+
355
+ literal(header, parent) {
356
+ const { chomp, style } = /^(?<style>[>|])(?<chomp>[+-]?)/u.exec(
357
+ header
358
+ ).groups;
359
+ const body = [];
360
+ let indent = -1;
361
+ for (this.at += 1; this.at < this.lines.length; this.at += 1) {
362
+ const line = this.lines[this.at];
363
+ const blank = line.trim() === "";
364
+ const depth = blank ? indent : this.indentOf(line);
365
+ indent = indent === -1 ? depth : indent;
366
+ if (!blank && (depth <= parent || depth < indent)) {
367
+ break;
368
+ }
369
+ body.push(blank ? "" : line.slice(indent));
370
+ }
371
+ let trailing = 0;
372
+ while (body.at(-1) === "") {
373
+ body.pop();
374
+ trailing += 1;
375
+ }
376
+ let text = body.join("\n");
377
+ if (style === ">") {
378
+ // Folded: a line break between two plain lines becomes a space.
379
+ text = body
380
+ .map((line, i) =>
381
+ i > 0 && line && body[i - 1] && !/^\s/u.test(line)
382
+ ? ` ${line}`
383
+ : `${i > 0 ? "\n" : ""}${line}`
384
+ )
385
+ .join("");
386
+ }
387
+ if (body.length === 0 || chomp === "-") {
388
+ return text;
389
+ }
390
+ return chomp === "+" ? `${text}\n${"\n".repeat(trailing)}` : `${text}\n`;
391
+ }
392
+ }
393
+
394
+ const parseYaml = (source, file) => new YamlReader(source, file).read();
395
+
396
+ // --- Slugs -----------------------------------------------------------------------
397
+
398
+ // lodash `kebabCase`, which Fern uses for every generated slug: apostrophes
399
+ // dropped, accents folded, words split at case changes and letter/digit
400
+ // boundaries (`v3 (Latest)` → `v-3-latest`, `AgentMail` → `agent-mail`).
401
+ const WORD =
402
+ /[A-Z]?[a-z]+(?=[^\dA-Za-z]|[A-Z]|$)|[A-Z]+(?=[^\dA-Za-z]|[A-Z][a-z]|$)|[A-Z]?[a-z]+|[A-Z]+|\d*(?:1ST|2ND|3RD|(?![123])\dTH)(?=\b|[_a-z])|\d*(?:1st|2nd|3rd|(?![123])\dth)(?=\b|[A-Z_])|\d+/gu;
403
+ const kebab = (value) =>
404
+ (
405
+ String(value ?? "")
406
+ .normalize("NFD")
407
+ .replaceAll(/[̀-ͯ'’]/gu, "")
408
+ .match(WORD) ?? []
409
+ )
410
+ .map((word) => word.toLowerCase())
411
+ .join("-");
412
+
413
+ /** Blume's ordering-prefix rule (core/ordering-prefix.ts). */
414
+ const stripOrder = (name) =>
415
+ /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u.test(name)
416
+ ? name
417
+ : name.replace(/^\d+[-_.]/u, "");
418
+ const childSlug = (name) =>
419
+ stripOrder(name.replace(/^\((?<inner>.*)\)$/u, "$<inner>"));
420
+ const joinUrl = (...parts) =>
421
+ `/${parts
422
+ .flat()
423
+ .filter((part) => part !== undefined && part !== null && part !== "")
424
+ .flatMap((part) => String(part).split("/"))
425
+ .filter(Boolean)
426
+ .join("/")}`;
427
+ const normUrl = (url) => (url.length > 1 ? url.replace(/\/+$/u, "") : url);
428
+ const folderRoute = (folder) =>
429
+ joinUrl(
430
+ folder
431
+ .split("/")
432
+ .filter((segment) => segment && !/^\(.*\)$/u.test(segment))
433
+ .map(stripOrder)
434
+ );
435
+
436
+ // --- Icons: Font Awesome → Lucide ---------------------------------------------------
437
+
438
+ // `name` maps to itself, `name:other` to another Lucide name, and `name:` to
439
+ // nothing (dropped and reported). Every target is in the set Blume bundles.
440
+ const ICON_TABLE = `
441
+ address-book:book-user angle-right:chevron-right arrow-right arrow-right-from-bracket:log-out
442
+ arrow-up-right-from-square:external-link at:at-sign aws: ban bars:menu bell bolt:zap
443
+ bolt-lightning:zap book book-open bookmark box-open:package-open boxes brain briefcase bug building
444
+ bullhorn:megaphone calendar certificate:badge-check chart-line chart-simple:chart-column check
445
+ circle-check circle-exclamation:circle-alert circle-info:info circle-question:circle-help
446
+ circle-xmark:circle-x clipboard clock clone:copy cloud cloud-arrow-up:cloud-upload code
447
+ code-branch:git-branch code-merge:git-merge cog:settings comment:message-square
448
+ comments:messages-square compass copy credit-card cube:box cubes:boxes database
449
+ diagram-project:workflow discord: docker: dollar-sign download ellipsis ellipsis-h:ellipsis
450
+ envelope:mail envelope-circle-check:mail-check envelope-open:mail-open envelope-open-text:mail-open
451
+ eye face-frown:frown face-smile:smile facebook file file-code file-invoice:file-text
452
+ file-lines:file-text filter fire:flame flag flask:flask-conical folder folder-open gauge
453
+ gauge-high:gauge gear:settings gears:settings gift github gitlab globe google: graduation-cap hand
454
+ hand-holding:hand-helping handshake headset heart home:house house image inbox info instagram key
455
+ keyboard laptop-code:laptop layer-group:layers leaf life-ring:life-buoy lightbulb link
456
+ link-slash:unlink linkedin list list-check:list-checks location-dot:map-pin lock magic:sparkles
457
+ magnifying-glass:search map map-marker:map-pin medium: message:message-square microchip:cpu
458
+ microphone:mic money-bill:banknote money-bill-wave:banknote moon n: network-wired:network newspaper
459
+ node-js: npm: paper-plane:send paperclip pen pen-to-square:square-pen pencil people-group:users
460
+ phone play plug plus puzzle puzzle-piece:puzzle python: question:circle-help repeat
461
+ right-from-bracket:log-out right-left:arrow-left-right right-to-bracket:log-in robot:bot rocket
462
+ rotate-left:rotate-ccw rotate-right:rotate-cw route rss scissors screwdriver-wrench:wrench
463
+ search server shield shield-alt:shield shield-check shield-halved:shield shield-virus:shield-alert
464
+ sitemap:network slack sliders:sliders-horizontal sparkles star table tag tags
465
+ temperature-half:thermometer terminal times:x toolbox:wrench trash trash-can:trash-2
466
+ triangle-exclamation:triangle-alert twitter unlock upload user user-check user-gear:user-cog
467
+ user-plus user-shield:shield-user users video walkie-talkie:radio wand-magic-sparkles:sparkles
468
+ warning:triangle-alert webhook wrench x x-twitter: xmark:x youtube zap
469
+ `;
470
+ const ICONS = new Map(
471
+ ICON_TABLE.trim()
472
+ .split(/\s+/u)
473
+ .map((pair) => {
474
+ const [name, target = name] = pair.split(":");
475
+ return [name, target || null];
476
+ })
477
+ );
478
+ const ICON_STYLE =
479
+ /^(?:fa-)?(?:brands|duotone|light|regular|sharp|solid|thin)\s+/u;
480
+
481
+ /** A Font Awesome spelling as Fern accepts it → { lucide } | { asset } | { dropped } | { unknown }. */
482
+ const mapIcon = (value) => {
483
+ const raw = String(value ?? "").trim();
484
+ if (raw === "" || raw.startsWith("<svg")) {
485
+ return raw ? { lucide: raw } : {};
486
+ }
487
+ if (/^(?:\.{0,2}\/|https?:)|\.(?:gif|jpe?g|png|svg|webp)$/iu.test(raw)) {
488
+ return { asset: raw };
489
+ }
490
+ let name = raw;
491
+ while (ICON_STYLE.test(name)) {
492
+ name = name.replace(ICON_STYLE, "");
493
+ }
494
+ name = name.replace(/^fa-/u, "");
495
+ if (!ICONS.has(name)) {
496
+ return { unknown: raw };
497
+ }
498
+ return ICONS.get(name) ? { lucide: ICONS.get(name) } : { dropped: raw };
499
+ };
500
+
501
+ // --- Files and reports ----------------------------------------------------------------
502
+
503
+ const toPosix = (file) => file.split(path.sep).join("/");
504
+ const readText = (file) => readFileSync(file, "utf-8").replaceAll("\r\n", "\n");
505
+ const byName = (a, b) => (a < b ? -1 : Number(a > b));
506
+
507
+ const listFiles = (dir, rel = "") => {
508
+ if (!existsSync(dir)) {
509
+ return [];
510
+ }
511
+ return readdirSync(path.join(dir, rel))
512
+ .toSorted(byName)
513
+ .filter((name) => name !== "node_modules" && !name.startsWith("."))
514
+ .flatMap((name) => {
515
+ const child = rel ? `${rel}/${name}` : name;
516
+ return statSync(path.join(dir, child)).isDirectory()
517
+ ? listFiles(dir, child)
518
+ : [child];
519
+ });
520
+ };
521
+
522
+ const splitFrontmatter = (text) => {
523
+ const match = /^---\n(?<yaml>[\s\S]*?)\n---[\t ]*(?:\n|$)/u.exec(text);
524
+ return match
525
+ ? { body: text.slice(match[0].length), raw: match.groups.yaml }
526
+ : { body: text, raw: null };
527
+ };
528
+
529
+ const moveFile = (from, to, text) => {
530
+ mkdirSync(path.dirname(to), { recursive: true });
531
+ renameSync(from, to);
532
+ if (text !== undefined) {
533
+ writeFileSync(to, text);
534
+ }
535
+ };
536
+
537
+ const makeReport = () => {
538
+ const entries = [];
539
+ const add = (kind) => (where, detail) => {
540
+ entries.push({ detail, kind, where });
541
+ };
542
+ /** Report an icon that didn't map to a Lucide name (a no-op when it did). */
543
+ const icon = (where, found) => {
544
+ let detail;
545
+ if (found.dropped) {
546
+ detail = `icon ${found.dropped} has no Lucide equivalent: dropped`;
547
+ } else if (found.unknown) {
548
+ detail = `icon ${found.unknown} isn't in the table: set a Lucide name by hand`;
549
+ } else if (found.asset) {
550
+ detail = `icon ${found.asset} is an image: move it into public/ and set its root-absolute path by hand`;
551
+ }
552
+ if (detail) {
553
+ entries.push({ detail, kind: "icon", where });
554
+ }
555
+ };
556
+ return {
557
+ drop: add("dropped"),
558
+ entries,
559
+ icon,
560
+ note: add("note"),
561
+ todo: add("todo"),
562
+ };
563
+ };
564
+
565
+ // --- docs.yml → a tree of pages with their old URLs ------------------------------------
566
+
567
+ const CHANGELOG_FILE =
568
+ /^(?:(?<y>\d{4})-(?<m>\d{1,2})-(?<d>\d{1,2})|(?<m2>\d{1,2})-(?<d2>\d{1,2})-(?<y2>\d{4}|\d{2}))(?:-(?<suffix>.+))?\.mdx?$/u;
569
+ const MONTHS =
570
+ "January February March April May June July August September October November December".split(
571
+ " "
572
+ );
573
+
574
+ const changelogDate = (name) => {
575
+ const groups = CHANGELOG_FILE.exec(name)?.groups;
576
+ if (!groups) {
577
+ return null;
578
+ }
579
+ const shortYear = groups.y2?.length === 2 ? `20${groups.y2}` : groups.y2;
580
+ const year = groups.y ?? shortYear;
581
+ const month = Number(groups.m ?? groups.m2);
582
+ const day = Number(groups.d ?? groups.d2);
583
+ const iso = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
584
+ return { day, iso, month, suffix: groups.suffix, year };
585
+ };
586
+
587
+ const pageUrl = (slugs, own, fm) =>
588
+ fm.slug ? joinUrl(String(fm.slug)) : joinUrl(slugs, own);
589
+
590
+ class Navigation {
591
+ constructor(fernDir, report, previous) {
592
+ this.fernDir = fernDir;
593
+ this.report = report;
594
+ this.previous = previous;
595
+ this.referenced = new Set();
596
+ this.apis = [];
597
+ this.seen = new Map();
598
+ }
599
+
600
+ rel(file) {
601
+ return toPosix(path.relative(this.fernDir, file));
602
+ }
603
+
604
+ frontmatter(file) {
605
+ const raw = existsSync(file) ? splitFrontmatter(readText(file)).raw : null;
606
+ try {
607
+ return (raw && parseYaml(raw, this.rel(file))) || {};
608
+ } catch (error) {
609
+ this.report.todo(
610
+ this.rel(file),
611
+ `frontmatter not parsed: ${error.message}`
612
+ );
613
+ return {};
614
+ }
615
+ }
616
+
617
+ /** A page an earlier --write already moved keeps the URL recorded then. */
618
+ moved(file) {
619
+ const rel = this.rel(file);
620
+ // A file docs.yml lists twice has one recorded URL per listing, in order.
621
+ const nth = this.seen.get(rel) ?? 0;
622
+ this.seen.set(rel, nth + 1);
623
+ return existsSync(file) ? undefined : this.previous.get(rel)?.[nth];
624
+ }
625
+
626
+ read(file, extra) {
627
+ this.referenced.add(file);
628
+ const fm = this.frontmatter(file);
629
+ return { file: this.rel(file), fm, ...extra(fm) };
630
+ }
631
+
632
+ page(item, ctx) {
633
+ const file = path.resolve(ctx.dir, String(item.path ?? ""));
634
+ return this.read(file, (fm) => ({
635
+ hidden: Boolean(item.hidden || ctx.hidden),
636
+ icon: item.icon,
637
+ kind: "page",
638
+ name: String(item.page),
639
+ noindex: Boolean(item.noindex),
640
+ url:
641
+ this.moved(file) ??
642
+ pageUrl(ctx.slugs, item.slug ?? kebab(item.page), fm),
643
+ }));
644
+ }
645
+
646
+ section(item, ctx) {
647
+ const slug = item["skip-slug"]
648
+ ? undefined
649
+ : (item.slug ?? kebab(item.section));
650
+ const hidden = Boolean(ctx.hidden || item.hidden);
651
+ const sub = { ...ctx, hidden, slugs: [...ctx.slugs, slug] };
652
+ const children = this.items(item.contents, sub);
653
+ if (item.path) {
654
+ const file = path.resolve(ctx.dir, String(item.path));
655
+ children.unshift(
656
+ this.read(file, (fm) => ({
657
+ hidden,
658
+ kind: "overview",
659
+ name: String(item.section),
660
+ url: this.moved(file) ?? pageUrl(sub.slugs, undefined, fm),
661
+ }))
662
+ );
663
+ }
664
+ const collapsible = item.collapsible ? "open-by-default" : undefined;
665
+ const byDefault = item["collapsed-by-default"] ? true : collapsible;
666
+ return {
667
+ children,
668
+ collapsed: item.collapsed ?? byDefault,
669
+ icon: item.icon,
670
+ kind: "group",
671
+ name: String(item.section),
672
+ };
673
+ }
674
+
675
+ api(item, ctx) {
676
+ const own = item["skip-slug"] ? undefined : (item.slug ?? kebab(item.api));
677
+ let base = joinUrl(ctx.slugs, own);
678
+ const children = [];
679
+ if (item.summary) {
680
+ const file = path.resolve(ctx.dir, String(item.summary));
681
+ const summary = this.read(file, (fm) => ({
682
+ kind: "apiSummary",
683
+ name: String(item.api),
684
+ url: this.moved(file) ?? (fm.slug ? joinUrl(String(fm.slug)) : base),
685
+ }));
686
+ base = summary.url;
687
+ children.push(summary);
688
+ }
689
+ const name = item["api-name"] ? String(item["api-name"]) : undefined;
690
+ const definition = [
691
+ name && path.join(this.fernDir, "apis", name, "definition"),
692
+ path.join(this.fernDir, "definition"),
693
+ ].find((dir) => dir && existsSync(dir));
694
+ const fromDefinition = Boolean(definition && !item.specs);
695
+ const api = {
696
+ base,
697
+ definition: fromDefinition ? this.rel(definition) : undefined,
698
+ kind: fromDefinition ? "definition" : "openapi",
699
+ name,
700
+ };
701
+ this.apis.push(api);
702
+ return { api, children, kind: "api" };
703
+ }
704
+
705
+ changelog(dir, ctx, ownSlug) {
706
+ // Fern reads the changelog index's text from any of these.
707
+ const overview = ["overview", "index", "summary"]
708
+ .flatMap((name) => [`${name}.mdx`, `${name}.md`])
709
+ .map((name) => path.join(dir, name))
710
+ .find((file) => existsSync(file));
711
+ const overviewSlug = overview ? this.frontmatter(overview).slug : undefined;
712
+ const base = overviewSlug
713
+ ? joinUrl(String(overviewSlug))
714
+ : joinUrl(ctx.slugs, ownSlug);
715
+ const dateUrl = (date) =>
716
+ joinUrl(base, String(date.year), String(date.month), String(date.day));
717
+ const entries = [];
718
+ const others = [];
719
+ const names = existsSync(dir) ? readdirSync(dir).toSorted(byName) : [];
720
+ for (const name of names) {
721
+ const file = path.join(dir, name);
722
+ const date = changelogDate(name);
723
+ if (date) {
724
+ entries.push(
725
+ this.read(file, (fm) => ({
726
+ date,
727
+ kind: "changelogEntry",
728
+ url: fm.slug ? joinUrl(String(fm.slug)) : dateUrl(date),
729
+ }))
730
+ );
731
+ } else if (/\.mdx?$/u.test(name) && file !== overview) {
732
+ others.push(this.rel(file));
733
+ }
734
+ }
735
+ const prefix = `${this.rel(dir)}/`;
736
+ for (const [source, [url]] of this.previous) {
737
+ const date = changelogDate(path.posix.basename(source));
738
+ const gone = !existsSync(path.join(this.fernDir, source));
739
+ if (date && gone && source.startsWith(prefix)) {
740
+ entries.push({
741
+ date,
742
+ file: source,
743
+ fm: {},
744
+ kind: "changelogEntry",
745
+ url,
746
+ });
747
+ }
748
+ }
749
+ entries.sort((a, b) => byName(a.file, b.file));
750
+ if (overview) {
751
+ this.referenced.add(overview);
752
+ }
753
+ const overviewRel = overview ? this.rel(overview) : undefined;
754
+ return { base, entries, kind: "changelog", others, overview: overviewRel };
755
+ }
756
+
757
+ items(list, ctx) {
758
+ const out = [];
759
+ for (const item of list ?? []) {
760
+ if (item?.page !== undefined) {
761
+ out.push(this.page(item, ctx));
762
+ } else if (item?.section !== undefined) {
763
+ out.push(this.section(item, ctx));
764
+ } else if (item?.api !== undefined) {
765
+ out.push(this.api(item, ctx));
766
+ } else if (item?.changelog !== undefined) {
767
+ const dir = path.resolve(ctx.dir, String(item.changelog));
768
+ out.push(
769
+ this.changelog(
770
+ dir,
771
+ ctx,
772
+ item.slug ?? kebab(item.title ?? "Changelog")
773
+ )
774
+ );
775
+ } else if (item?.link === undefined) {
776
+ // `- folder:`, `- library:`, `- blog:`, or anything newer: never drop
777
+ // a listing silently.
778
+ const [key] = Object.keys(item ?? {});
779
+ this.report.todo(
780
+ "docs.yml",
781
+ `"${key}: ${item?.[key]}" entry: not handled here; place its pages by hand`
782
+ );
783
+ } else {
784
+ this.report.todo(
785
+ "docs.yml",
786
+ `sidebar link "${item.link}" → ${item.href}: add it to navigation.featured`
787
+ );
788
+ }
789
+ }
790
+ return out;
791
+ }
792
+
793
+ tab(entry, docs) {
794
+ const root = { dir: this.fernDir, hidden: false, slugs: [] };
795
+ const key = String(entry.tab);
796
+ const tab = docs.tabs?.[key] ?? {};
797
+ const label = String(tab["display-name"] ?? key);
798
+ const slug = tab["skip-slug"] ? undefined : (tab.slug ?? kebab(label));
799
+ const info = {
800
+ hidden: Boolean(tab.hidden),
801
+ href: tab.href,
802
+ icon: tab.icon,
803
+ key,
804
+ label,
805
+ slug,
806
+ };
807
+ const changelog = tab.changelog ?? tab.blog;
808
+ if (tab.href) {
809
+ return { ...info, children: [] };
810
+ }
811
+ if (changelog) {
812
+ const dir = path.resolve(this.fernDir, String(changelog));
813
+ return {
814
+ ...info,
815
+ // Fern ignores skip-slug on a changelog tab.
816
+ children: [this.changelog(dir, root, tab.slug ?? kebab(label))],
817
+ };
818
+ }
819
+ if (entry.variants) {
820
+ this.report.todo(
821
+ "docs.yml",
822
+ `tab "${label}" has variants: not handled here (see the reference)`
823
+ );
824
+ return { ...info, children: [] };
825
+ }
826
+ const ctx = { ...root, hidden: info.hidden, slugs: slug ? [slug] : [] };
827
+ return { ...info, children: this.items(entry.layout, ctx) };
828
+ }
829
+
830
+ tabs(docs) {
831
+ const navigation = [docs.navigation ?? []].flat();
832
+ if (!navigation.some((entry) => entry?.tab !== undefined)) {
833
+ const root = { dir: this.fernDir, hidden: false, slugs: [] };
834
+ return [{ children: this.items(navigation, root), key: null }];
835
+ }
836
+ return navigation.map((entry) => this.tab(entry, docs));
837
+ }
838
+ }
839
+
840
+ // --- Placement: a file path per page that reproduces its URL ------------------------
841
+
842
+ const pagesIn = (nodes) =>
843
+ nodes.flatMap((node) => {
844
+ if (node.kind === "group") {
845
+ return pagesIn(node.children);
846
+ }
847
+ return node.kind === "page" || node.kind === "overview" ? [node] : [];
848
+ });
849
+
850
+ /** A section's folder: a real one when its pages share a URL segment, else a `(group)`. */
851
+ const groupFolderName = (node, prefix) => {
852
+ const base = prefix === "/" ? "" : prefix;
853
+ const overview = node.children.find((child) => child.kind === "overview");
854
+ const own = overview?.url.slice(base.length + 1);
855
+ if (overview?.url.startsWith(`${base}/`) && !own.includes("/")) {
856
+ return own;
857
+ }
858
+ const direct = node.children
859
+ .filter(
860
+ (child) => child.kind === "page" && child.url.startsWith(`${base}/`)
861
+ )
862
+ .map((child) => child.url.slice(base.length + 1).split("/"));
863
+ const counts = new Map();
864
+ for (const rest of direct.filter((parts) => parts.length <= 2)) {
865
+ counts.set(rest[0], (counts.get(rest[0]) ?? 0) + 1);
866
+ }
867
+ const ranked = [...counts].toSorted(
868
+ (a, b) => b[1] - a[1] || byName(a[0], b[0])
869
+ );
870
+ const [best, count = 0] = ranked[0] ?? [];
871
+ const pages = node.children.filter((child) => child.kind === "page").length;
872
+ return count >= 2 && count >= Math.ceil(pages / 2)
873
+ ? best
874
+ : `(${kebab(node.name) || "group"})`;
875
+ };
876
+
877
+ class Placer {
878
+ constructor(report) {
879
+ this.report = report;
880
+ this.placements = [];
881
+ this.metas = new Map();
882
+ this.taken = new Set();
883
+ this.changelogs = [];
884
+ this.tabs = [];
885
+ }
886
+
887
+ meta(folder) {
888
+ if (!this.metas.has(folder)) {
889
+ this.metas.set(folder, { pages: [] });
890
+ }
891
+ return this.metas.get(folder);
892
+ }
893
+
894
+ claim(file, page) {
895
+ let target = file;
896
+ for (let n = 2; this.taken.has(target); n += 1) {
897
+ target = file.replace(/\.mdx$/u, `-${n}.mdx`);
898
+ }
899
+ if (target !== file) {
900
+ this.report.todo(
901
+ page.file,
902
+ `another page already sits at ${file}; placed at ${target}`
903
+ );
904
+ }
905
+ this.taken.add(target);
906
+ return target;
907
+ }
908
+
909
+ page(page, folder) {
910
+ const first = this.placements.find(
911
+ (p) => p.page.file === page.file && !p.alias
912
+ );
913
+ if (first) {
914
+ // Fern served the file at every listing; Blume publishes it once.
915
+ const route = first.slug ? `/${first.slug}` : first.route;
916
+ this.placements.push({ alias: true, file: first.file, page, route });
917
+ this.report.todo(
918
+ page.file,
919
+ `docs.yml lists it twice: published once, at ${route}; ${page.url} redirects there`
920
+ );
921
+ return;
922
+ }
923
+ const prefix = folderRoute(folder);
924
+ const leaf = page.url === prefix ? "index" : page.url.split("/").pop();
925
+ const file = this.claim(`${folder ? `${folder}/` : ""}${leaf}.mdx`, page);
926
+ const stem = path.posix.basename(file, ".mdx");
927
+ const derived =
928
+ stem === "index" ? prefix : joinUrl(prefix, stripOrder(stem));
929
+ const slug = derived === page.url ? undefined : page.url.slice(1);
930
+ this.placements.push({ file, page, route: page.url, slug });
931
+ if (stem !== "index") {
932
+ this.meta(folder).pages.push(childSlug(stem));
933
+ }
934
+ }
935
+
936
+ group(node, folder) {
937
+ const name = groupFolderName(node, folderRoute(folder));
938
+ const sub = `${folder ? `${folder}/` : ""}${name}`;
939
+ const meta = this.meta(sub);
940
+ if (meta.title !== undefined) {
941
+ this.report.todo(
942
+ `section "${node.name}"`,
943
+ `shares the folder ${sub} with "${meta.title}", so the sidebar merges them: split them by hand`
944
+ );
945
+ }
946
+ meta.title = node.name;
947
+ const icon = mapIcon(node.icon);
948
+ meta.icon = icon.lucide;
949
+ this.report.icon(`section "${node.name}"`, icon);
950
+ if (node.collapsed === true || node.collapsed === "open-by-default") {
951
+ meta.display = "group";
952
+ meta.collapsed = node.collapsed === true;
953
+ }
954
+ this.meta(folder).pages.push(childSlug(name));
955
+ this.nodes(node.children, sub);
956
+ }
957
+
958
+ api(node) {
959
+ for (const page of node.children) {
960
+ const intro = joinUrl(node.api.base, "introduction").slice(1);
961
+ const file = this.claim(`${intro}.mdx`, page);
962
+ const route = `/${file.replace(/\.mdx$/u, "")}`;
963
+ this.placements.push({ file, page, route, summary: true });
964
+ }
965
+ }
966
+
967
+ changelog(node) {
968
+ this.changelogs.push(node);
969
+ for (const entry of node.entries) {
970
+ const suffix = entry.date.suffix ? `-${entry.date.suffix}` : "";
971
+ const name = `${entry.date.iso}${suffix}`;
972
+ const file = this.claim(`changelog/${name}.mdx`, entry);
973
+ this.placements.push({
974
+ changelog: true,
975
+ file,
976
+ page: entry,
977
+ route: `/${file.replace(/\.mdx$/u, "")}`,
978
+ });
979
+ }
980
+ }
981
+
982
+ nodes(nodes, folder) {
983
+ for (const node of nodes) {
984
+ if (node.kind === "page" || node.kind === "overview") {
985
+ this.page(node, folder);
986
+ } else if (node.kind === "group") {
987
+ this.group(node, folder);
988
+ } else if (node.kind === "api") {
989
+ this.api(node);
990
+ } else if (node.kind === "changelog") {
991
+ this.changelog(node);
992
+ }
993
+ }
994
+ }
995
+
996
+ tab(tab) {
997
+ if (tab.href) {
998
+ this.tabs.push({ href: tab.href, label: tab.label });
999
+ return;
1000
+ }
1001
+ const pages = pagesIn(tab.children);
1002
+ const slugUrl = joinUrl(tab.slug);
1003
+ const underSlug = (page) =>
1004
+ page.url === slugUrl || page.url.startsWith(`${slugUrl}/`);
1005
+ const shared = Boolean(
1006
+ tab.key && tab.slug && pages.length > 0 && pages.every(underSlug)
1007
+ );
1008
+ const folder = shared ? tab.slug : "";
1009
+ if (shared) {
1010
+ this.meta(folder).title = tab.label;
1011
+ }
1012
+ this.nodes(tab.children, folder);
1013
+ if (tab.key) {
1014
+ this.tabs.push(this.tabEntry(tab, shared ? slugUrl : "/", pages));
1015
+ }
1016
+ }
1017
+
1018
+ tabEntry(tab, ownPath, pages) {
1019
+ const [first] = tab.children;
1020
+ let tabPath = ownPath;
1021
+ if (tab.children.length === 1 && first.kind === "changelog") {
1022
+ tabPath = "/changelog";
1023
+ } else if (first?.kind === "api" && pages.length === 0) {
1024
+ tabPath = first.api.base;
1025
+ }
1026
+ const icon = mapIcon(tab.icon);
1027
+ this.report.icon(`tab "${tab.label}"`, icon);
1028
+ return {
1029
+ hidden: tab.hidden,
1030
+ icon: icon.lucide,
1031
+ label: tab.label,
1032
+ path: tabPath,
1033
+ };
1034
+ }
1035
+ }
1036
+
1037
+ // --- Frontmatter ---------------------------------------------------------------------
1038
+
1039
+ const KNOWN_KEYS = new Set([
1040
+ "description",
1041
+ "headline",
1042
+ "noindex",
1043
+ "sidebar-title",
1044
+ "slug",
1045
+ "subtitle",
1046
+ "title",
1047
+ ]);
1048
+
1049
+ const sentenceCase = (suffix) =>
1050
+ suffix
1051
+ .split(/[-_]+/u)
1052
+ .filter(Boolean)
1053
+ .map((word, i) =>
1054
+ i === 0 ? `${word[0].toUpperCase()}${word.slice(1)}` : word
1055
+ )
1056
+ .join(" ");
1057
+
1058
+ const changelogFrontmatter = (page, report) => {
1059
+ const { date, fm } = page;
1060
+ let title = `${MONTHS[date.month - 1]} ${date.day}, ${date.year}`;
1061
+ if (fm.title) {
1062
+ title = String(fm.title);
1063
+ } else if (date.suffix) {
1064
+ title = sentenceCase(date.suffix);
1065
+ report.todo(
1066
+ page.file,
1067
+ `changelog title "${title}" came from the file name: review it`
1068
+ );
1069
+ }
1070
+ const data = Object.fromEntries([
1071
+ ["title", title],
1072
+ ["type", "changelog"],
1073
+ ["date", date.iso],
1074
+ ]);
1075
+ const tags = [fm.tags ?? []].flat().map(String);
1076
+ if (tags.length > 0) {
1077
+ data.changelog = { category: tags[0] };
1078
+ data.search = { tags };
1079
+ }
1080
+ for (const key of Object.keys(fm).filter(
1081
+ (k) => k !== "tags" && k !== "title"
1082
+ )) {
1083
+ report.drop(page.file, `frontmatter ${key}`);
1084
+ }
1085
+ return data;
1086
+ };
1087
+
1088
+ const seoFields = (page) => {
1089
+ const { fm } = page;
1090
+ const seo = {};
1091
+ if (fm.headline) {
1092
+ seo.title = String(fm.headline);
1093
+ }
1094
+ if (fm.subtitle && fm.description && fm.description !== fm.subtitle) {
1095
+ seo.description = String(fm.description);
1096
+ }
1097
+ if (page.hidden || page.noindex || fm.noindex === true) {
1098
+ seo.noindex = true;
1099
+ }
1100
+ return Object.keys(seo).length > 0 ? seo : undefined;
1101
+ };
1102
+
1103
+ const pageFrontmatter = (placement, title, report) => {
1104
+ const { page } = placement;
1105
+ const { fm } = page;
1106
+ const icon = mapIcon(page.icon);
1107
+ report.icon(page.file, icon);
1108
+ const named =
1109
+ page.kind === "page" && page.name !== title ? page.name : undefined;
1110
+ const label = fm["sidebar-title"] ?? named;
1111
+ const description = fm.subtitle ?? fm.description;
1112
+ // Entries, not an object literal: they keep the order the page is read in.
1113
+ const data = Object.fromEntries([
1114
+ ["title", title],
1115
+ ["description", description ? String(description) : undefined],
1116
+ ["slug", placement.slug],
1117
+ ["icon", icon.lucide],
1118
+ ["hidden", page.hidden || undefined],
1119
+ ["sidebar", label ? { label: String(label) } : undefined],
1120
+ ["seo", seoFields(page)],
1121
+ ["search", page.hidden ? { exclude: true } : undefined],
1122
+ ]);
1123
+ for (const key of Object.keys(fm).filter((k) => !KNOWN_KEYS.has(k))) {
1124
+ report.drop(
1125
+ page.file,
1126
+ `frontmatter ${key}: map it by hand if it matters (the reference's table)`
1127
+ );
1128
+ }
1129
+ return data;
1130
+ };
1131
+
1132
+ const yamlScalar = (value) => {
1133
+ if (value === true || value === false || /^\d{4}-\d{2}-\d{2}$/u.test(value)) {
1134
+ return String(value);
1135
+ }
1136
+ const unsafe =
1137
+ value === "" ||
1138
+ value.trim() !== value ||
1139
+ /^[!"#%&'*,:>?@[\]`{|}-]|: |\s#|:$|[\n\t]/u.test(value) ||
1140
+ /^(?:true|false|yes|no|on|off|null|~|[-+]?[\d.]+)$/iu.test(value);
1141
+ return unsafe ? JSON.stringify(value) : value;
1142
+ };
1143
+
1144
+ const yamlLines = (data, indent = "") =>
1145
+ Object.entries(data)
1146
+ .filter(([, value]) => value !== undefined)
1147
+ .flatMap(([key, value]) => {
1148
+ if (Array.isArray(value)) {
1149
+ return [
1150
+ `${indent}${key}: [${value.map((item) => JSON.stringify(item)).join(", ")}]`,
1151
+ ];
1152
+ }
1153
+ if (value instanceof Object) {
1154
+ return [`${indent}${key}:`, ...yamlLines(value, `${indent} `)];
1155
+ }
1156
+ return [`${indent}${key}: ${yamlScalar(value)}`];
1157
+ });
1158
+
1159
+ // --- Body: Fern components → Blume ---------------------------------------------------
1160
+
1161
+ // Placeholders for masked code: private-use characters no page contains.
1162
+ const FENCE = String.fromCodePoint(0xe0_00);
1163
+ const CODE = String.fromCodePoint(0xe0_01);
1164
+ /** `"a:b c:d"` → Map { a → b, c → d }: compact tables that stay one line each. */
1165
+ const table = (text) => new Map(text.split(" ").map((pair) => pair.split(":")));
1166
+ const CALLOUTS = table(
1167
+ "Check:success Error:danger Info:info Launch:tip Note:note Success:success Tip:tip Warning:warning"
1168
+ );
1169
+ const INTENTS = table(
1170
+ "check:success danger:danger error:danger info:info launch:tip note:note success:success tip:tip warn:warning warning:warning"
1171
+ );
1172
+ const LANGUAGES = table(
1173
+ "bash:Bash csharp:C# curl:cURL go:Go java:Java javascript:JavaScript js:JavaScript json:JSON php:PHP py:Python python:Python ruby:Ruby rust:Rust sh:Shell shell:Shell swift:Swift ts:TypeScript typescript:TypeScript yaml:YAML"
1174
+ );
1175
+ const RENAMES = table(
1176
+ "AccordionGroup:Accordion Cards:CardGroup CodeBlocks:CodeGroup"
1177
+ );
1178
+ const REPORTED = new Set(
1179
+ "Aside Availability Badge Button ChangelogTags Code Copy Download EndpointRequestSnippet EndpointResponseSnippet EndpointSchemaSnippet Feature Files If Indent PaginatedSearchableTable PaginatedTable Prompt RunnableEndpoint Schema SchemaSnippet ScrollWalkthrough SearchableTable StickySearchableTable StickyTable Template Versions WebhookPayloadSnippet".split(
1180
+ " "
1181
+ )
1182
+ );
1183
+ const CARD_PROPS =
1184
+ "className darkModeColor iconPosition iconSize imageHeight imageWidth lightModeColor".split(
1185
+ " "
1186
+ );
1187
+ const TAG = new RegExp(
1188
+ `<(?<closing>/?)(?<name>[A-Z][\\w.]*)(?<attrs>(?:\\s+(?:[^>"'{}/]|/(?!>)|"[^"]*"|'[^']*'|\\{(?:[^{}]|\\{(?:[^{}]|\\{[^{}]*\\})*\\})*\\})*)?)\\s*(?<self>/?)>|${FENCE}(?<fence>\\d+)${FENCE}`,
1189
+ "gu"
1190
+ );
1191
+
1192
+ const attrPattern = (name) =>
1193
+ new RegExp(
1194
+ `(?<lead>^|\\s)${name}\\s*=\\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|\\{(?<ex>(?:[^{}]|\\{[^{}]*\\})*)\\})`,
1195
+ "u"
1196
+ );
1197
+ const attr = (attrs, name) => {
1198
+ const groups = attrPattern(name).exec(attrs)?.groups;
1199
+ if (!groups) {
1200
+ return;
1201
+ }
1202
+ return (
1203
+ groups.dq ??
1204
+ groups.sq ??
1205
+ groups.ex.trim().replace(/^["'`](?<inner>.*)["'`]$/u, "$<inner>")
1206
+ );
1207
+ };
1208
+ const dropAttr = (attrs, name) => attrs.replace(attrPattern(name), "");
1209
+ const setAttr = (attrs, name, value) =>
1210
+ attrs.replace(
1211
+ attrPattern(name),
1212
+ (...match) => `${match.at(-1).lead}${name}="${value}"`
1213
+ );
1214
+ /** A masked-source position's line in the original file (fences count in full). */
1215
+ const lineAt = (src, pos, ctx) => {
1216
+ const masked = src.slice(0, pos).split("\n").length - 1;
1217
+ return ctx.lines[masked] ?? masked + 1;
1218
+ };
1219
+ const lineStart = (src, pos) => src.lastIndexOf("\n", pos - 1) + 1;
1220
+ const ownLine = (src, pos) =>
1221
+ /^[\t ]*$/u.test(src.slice(lineStart(src, pos), pos));
1222
+ /** Nothing but whitespace from `pos` to the end of its line. */
1223
+ const endsLine = (src, pos) => /^[\t ]*(?:\n|$)/u.test(src.slice(pos));
1224
+ const edit = (range, text) => ({ end: range.end, start: range.start, text });
1225
+ const wrapEdits = (open, opening, closing) =>
1226
+ open.close
1227
+ ? [edit(open, opening), edit(open.close, closing)]
1228
+ : [edit(open, opening)];
1229
+ const tagText = (open, name, attrs) =>
1230
+ `<${name}${attrs}${open.self ? " /" : ""}>`;
1231
+
1232
+ const dedent = (text) => {
1233
+ const lines = text
1234
+ .replace(/^[\t ]*\n/u, "")
1235
+ .replace(/\n[\t ]*$/u, "")
1236
+ .split("\n");
1237
+ const widths = lines
1238
+ .filter((line) => line.trim())
1239
+ .map((line) => /^ */u.exec(line)[0].length);
1240
+ const width = widths.length > 0 ? Math.min(...widths) : 0;
1241
+ return lines
1242
+ .map((line) => line.slice(Math.min(width, /^ */u.exec(line)[0].length)))
1243
+ .join("\n")
1244
+ .trim();
1245
+ };
1246
+
1247
+ const splice = (text, offset, edits) => {
1248
+ let out = text;
1249
+ const inRange = edits.filter(
1250
+ (change) => change.start >= offset && change.end <= offset + text.length
1251
+ );
1252
+ for (const change of inRange.toSorted((a, b) => b.start - a.start)) {
1253
+ out = `${out.slice(0, change.start - offset)}${change.text}${out.slice(change.end - offset)}`;
1254
+ }
1255
+ return out;
1256
+ };
1257
+
1258
+ const dropAll = (attrs, names) => {
1259
+ let out = attrs;
1260
+ for (const name of names) {
1261
+ out = dropAttr(out, name);
1262
+ }
1263
+ return out;
1264
+ };
1265
+
1266
+ /** Fenced code out of the way: placeholders, so tag rewriting never sees it. */
1267
+ const maskFences = (input, ctx) => {
1268
+ const fences = [];
1269
+ const lines = input.split("\n");
1270
+ const out = [];
1271
+ const offset = ctx.offset ?? 0;
1272
+ ctx.lines = [];
1273
+ for (let i = 0; i < lines.length; i += 1) {
1274
+ const open =
1275
+ /^(?<indent>\s*)(?<marker>`{3,}|~{3,})(?<lang>[^\s`]*)(?<meta>.*)$/u.exec(
1276
+ lines[i]
1277
+ )?.groups;
1278
+ if (open) {
1279
+ const closer = new RegExp(
1280
+ `^\\s*[${open.marker[0]}]{${open.marker.length},}\\s*$`,
1281
+ "u"
1282
+ );
1283
+ let j = i + 1;
1284
+ while (j < lines.length && !closer.test(lines[j])) {
1285
+ j += 1;
1286
+ }
1287
+ if (j >= lines.length) {
1288
+ ctx.report.todo(
1289
+ ctx.where,
1290
+ `unclosed code fence at line ${i + 1 + offset} (Fern rendered an empty block): closed at the end; delete the stray fence`
1291
+ );
1292
+ }
1293
+ const close = lines[j] ?? `${open.indent}${open.marker}`;
1294
+ fences.push({ ...open, body: lines.slice(i + 1, j), close });
1295
+ out.push(`${open.indent}${FENCE}${fences.length - 1}${FENCE}`);
1296
+ ctx.lines.push(i + 1 + offset);
1297
+ i = j;
1298
+ } else {
1299
+ out.push(lines[i]);
1300
+ ctx.lines.push(i + 1 + offset);
1301
+ }
1302
+ }
1303
+ return { fences, src: out.join("\n") };
1304
+ };
1305
+
1306
+ const iframe = (whole, attrs) => {
1307
+ const src = attr(attrs, "src") ?? "";
1308
+ const title = attr(attrs, "title");
1309
+ const titled = title ? ` title="${title}"` : "";
1310
+ // A playlist embed names no video (`videoseries`): <YouTube url> embeds the list.
1311
+ if (/youtube(?:-nocookie)?\.com\/embed\/videoseries\?/u.test(src)) {
1312
+ return `<YouTube url="${src}"${titled} />`;
1313
+ }
1314
+ const id =
1315
+ /(?:youtube(?:-nocookie)?\.com\/embed\/|youtu\.be\/)(?<id>[\w-]{6,})/u.exec(
1316
+ src
1317
+ )?.groups.id;
1318
+ if (id) {
1319
+ return `<YouTube id="${id}"${titled} />`;
1320
+ }
1321
+ return whole
1322
+ .replace(
1323
+ /\ballowfullscreen(?:=(?:""|"true"|\{true\}))?/u,
1324
+ "allowFullScreen"
1325
+ )
1326
+ .replace(/\bframeborder=/u, "frameBorder=");
1327
+ };
1328
+
1329
+ /** Prose-level rewrites: HTML comments, iframes, custom-component imports. */
1330
+ const rewriteProse = (src, ctx) =>
1331
+ src
1332
+ .replaceAll(
1333
+ /<!--(?<comment>[\s\S]*?)-->/gu,
1334
+ (...match) => `{/*${match.at(-1).comment.replaceAll("*/", "* /")}*/}`
1335
+ )
1336
+ .replaceAll(/<iframe\b(?<attrs>[^>]*?)\/?>(?:\s*<\/iframe>)?/gu, iframe)
1337
+ .replaceAll(/^\s*import\s.+\sfrom\s+["'][^"']+["'];?\s*$/gmu, (line) => {
1338
+ ctx.report.todo(
1339
+ ctx.where,
1340
+ `import (a custom component): ${line.trim()}; make it an island or a components.ts override`
1341
+ );
1342
+ return line;
1343
+ });
1344
+
1345
+ /** Every component tag, with its parents and its closing tag. */
1346
+ const scanTags = (src, fences, ctx) => {
1347
+ const stack = [];
1348
+ const opens = [];
1349
+ for (const match of src.matchAll(TAG)) {
1350
+ const { attrs = "", closing, fence, name, self } = match.groups;
1351
+ const end = match.index + match[0].length;
1352
+ if (fence !== undefined) {
1353
+ const wrapper = stack.findLast((open) => open.name === "CodeBlock");
1354
+ const target = fences[Number(fence)];
1355
+ target.group = stack.some(
1356
+ (open) => open.name === "CodeBlocks" || open.name === "CodeGroup"
1357
+ );
1358
+ target.title =
1359
+ wrapper &&
1360
+ (attr(wrapper.attrs, "title") ?? attr(wrapper.attrs, "filename"));
1361
+ } else if (closing) {
1362
+ const at = stack.map((open) => open.name).lastIndexOf(name);
1363
+ if (at === -1) {
1364
+ ctx.report.todo(
1365
+ ctx.where,
1366
+ `unmatched </${name}> (line ${lineAt(src, match.index, ctx)})`
1367
+ );
1368
+ } else {
1369
+ stack[at].close = { end, start: match.index };
1370
+ stack.length = at;
1371
+ }
1372
+ } else {
1373
+ const parents = stack.map((open) => open.name);
1374
+ const open = {
1375
+ attrs,
1376
+ end,
1377
+ name,
1378
+ parents,
1379
+ self: Boolean(self),
1380
+ start: match.index,
1381
+ };
1382
+ opens.push(open);
1383
+ if (!open.self) {
1384
+ stack.push(open);
1385
+ }
1386
+ }
1387
+ }
1388
+ for (const open of stack) {
1389
+ ctx.report.todo(
1390
+ ctx.where,
1391
+ `unclosed <${open.name}> (line ${lineAt(src, open.start, ctx)})`
1392
+ );
1393
+ }
1394
+ return opens;
1395
+ };
1396
+
1397
+ const iconProp = (attrs, ctx, label) => {
1398
+ const value = attr(attrs, "icon");
1399
+ if (value === undefined) {
1400
+ return attrs;
1401
+ }
1402
+ const img = /<img[^>]*\ssrc=["'](?<src>[^"']+)["']/u.exec(value)?.groups.src;
1403
+ const icon = img ? { asset: img } : mapIcon(value);
1404
+ if (icon.lucide || icon.asset) {
1405
+ return setAttr(attrs, "icon", icon.lucide ?? ctx.asset(icon.asset));
1406
+ }
1407
+ ctx.report.icon(`${ctx.where} ${label}`, icon);
1408
+ return dropAttr(attrs, "icon");
1409
+ };
1410
+
1411
+ /** Drop Fern-only props from a tag Blume ships under the same name. */
1412
+ const dropProps = (open, props) => {
1413
+ const attrs = dropAll(open.attrs, props);
1414
+ return attrs === open.attrs
1415
+ ? []
1416
+ : [edit(open, tagText(open, open.name, attrs))];
1417
+ };
1418
+
1419
+ const TAG_HANDLERS = {
1420
+ Accordion: (open) => {
1421
+ if (attr(open.attrs, "title") === undefined) {
1422
+ return [];
1423
+ }
1424
+ const attrs = dropAttr(dropAttr(open.attrs, "id"), "toc");
1425
+ return open.parents.at(-1) === "AccordionGroup"
1426
+ ? wrapEdits(open, `<AccordionItem${attrs}>`, "</AccordionItem>")
1427
+ : wrapEdits(
1428
+ open,
1429
+ `<Accordion>\n<AccordionItem${attrs}>`,
1430
+ "</AccordionItem>\n</Accordion>"
1431
+ );
1432
+ },
1433
+ Card: (open, src, ctx) => {
1434
+ const label = `<Card title="${attr(open.attrs, "title") ?? ""}">`;
1435
+ const attrs = dropAll(iconProp(open.attrs, ctx, label), CARD_PROPS);
1436
+ return attrs === open.attrs
1437
+ ? []
1438
+ : [edit(open, tagText(open, "Card", attrs))];
1439
+ },
1440
+ // Blume's <CodeBlock> renders a wrapped fence but not its `title`, so the
1441
+ // title moves onto the fence (scanTags) and the wrapper goes.
1442
+ CodeBlock: (open) =>
1443
+ attr(open.attrs, "code") === undefined ? wrapEdits(open, "", "") : [],
1444
+ Frame: (open) => dropProps(open, ["background"]),
1445
+ Icon: (open, src, ctx) => {
1446
+ const icon = mapIcon(attr(open.attrs, "icon"));
1447
+ if (!(icon.lucide || icon.asset)) {
1448
+ ctx.report.icon(`${ctx.where} <Icon>`, icon);
1449
+ return wrapEdits(open, "", "");
1450
+ }
1451
+ const size = attr(open.attrs, "size") ?? "";
1452
+ const color = attr(open.attrs, "color");
1453
+ const props = [
1454
+ `icon="${icon.lucide ?? ctx.asset(icon.asset)}"`,
1455
+ /^\d+$/u.test(size) && `size={${Number(size) * 4}}`,
1456
+ color && `color="${color}"`,
1457
+ ].filter(Boolean);
1458
+ return wrapEdits(open, `<Icon ${props.join(" ")} />`, "");
1459
+ },
1460
+ Markdown: (open, src, ctx) => {
1461
+ const params = [
1462
+ ...open.attrs.matchAll(
1463
+ /\s(?<key>[A-Z_a-z][\w-]*)\s*=\s*"(?<value>[^"]*)"/gu
1464
+ ),
1465
+ ]
1466
+ .filter((param) => param.groups.key !== "src")
1467
+ .map((param) => ` ${param.groups.key}="${param.groups.value}"`);
1468
+ const target = ctx.snippet(attr(open.attrs, "src") ?? "");
1469
+ const include = `<include${params.join("")}>${target}</include>`;
1470
+ // Blume reads an <include> only on a line of its own.
1471
+ const alone = ownLine(src, open.start) && endsLine(src, open.end);
1472
+ return [edit(open, alone ? include : `\n${include}\n`)];
1473
+ },
1474
+ // Fern's `path` is the field's name; Blume's (Mintlify's) is a path
1475
+ // parameter's location, which would badge every field "path".
1476
+ ParamField: (open) => {
1477
+ const attrs = dropAttr(open.attrs, "toc").replace(
1478
+ attrPattern("path"),
1479
+ (match) => match.replace("path", "name")
1480
+ );
1481
+ return attrs === open.attrs
1482
+ ? []
1483
+ : [edit(open, tagText(open, "ParamField", attrs.trimEnd()))];
1484
+ },
1485
+ Tab: (open) => dropProps(open, ["language"]),
1486
+ };
1487
+
1488
+ /**
1489
+ * A top-level callout becomes a `:::` directive; a nested one, or one whose
1490
+ * icon maps (a directive can't set an icon), a <Callout>.
1491
+ */
1492
+ const calloutEdits = (open, src, ctx) => {
1493
+ const intent = String(
1494
+ attr(open.attrs, "intent") ?? attr(open.attrs, "type") ?? "info"
1495
+ );
1496
+ const kind =
1497
+ CALLOUTS.get(open.name) ?? INTENTS.get(intent.toLowerCase()) ?? "info";
1498
+ const title = attr(open.attrs, "title");
1499
+ const mapped = mapIcon(attr(open.attrs, "icon"));
1500
+ const iconValue = mapped.lucide ?? (mapped.asset && ctx.asset(mapped.asset));
1501
+ if (!iconValue) {
1502
+ ctx.report.icon(`${ctx.where} <${open.name}>`, mapped);
1503
+ }
1504
+ const alone = lineStart(src, open.start) === open.start;
1505
+ if (
1506
+ !iconValue &&
1507
+ open.parents.length === 0 &&
1508
+ open.close &&
1509
+ alone &&
1510
+ ownLine(src, open.close.start) &&
1511
+ endsLine(src, open.close.end)
1512
+ ) {
1513
+ const directive = `:::${kind}${title ? `[${title}]` : ""}`;
1514
+ return [
1515
+ {
1516
+ bodyEnd: open.close.start,
1517
+ bodyStart: open.end,
1518
+ directive,
1519
+ end: open.close.end,
1520
+ start: open.start,
1521
+ },
1522
+ ];
1523
+ }
1524
+ const titleAttr = title ? ` title="${title.replaceAll('"', "&quot;")}"` : "";
1525
+ const iconAttr = iconValue ? ` icon="${iconValue}"` : "";
1526
+ return wrapEdits(
1527
+ open,
1528
+ tagText(open, "Callout", ` type="${kind}"${titleAttr}${iconAttr}`),
1529
+ "</Callout>"
1530
+ );
1531
+ };
1532
+
1533
+ /** The edits one component tag needs; `src` is the masked page. */
1534
+ const tagEdits = (open, src, ctx) => {
1535
+ if (RENAMES.get(open.name)) {
1536
+ const to = RENAMES.get(open.name);
1537
+ return wrapEdits(
1538
+ open,
1539
+ tagText(open, to, open.name === "Cards" ? open.attrs : ""),
1540
+ `</${to}>`
1541
+ );
1542
+ }
1543
+ if (CALLOUTS.get(open.name) || open.name === "Callout") {
1544
+ return calloutEdits(open, src, ctx);
1545
+ }
1546
+ if (REPORTED.has(open.name)) {
1547
+ ctx.report.todo(
1548
+ ctx.where,
1549
+ `<${open.name}> (line ${lineAt(src, open.start, ctx)}): convert it by hand (the reference's component table)`
1550
+ );
1551
+ }
1552
+ return TAG_HANDLERS[open.name]?.(open, src, ctx) ?? [];
1553
+ };
1554
+
1555
+ /** A fence's info string in Blume's grammar. */
1556
+ const fenceInfo = (fence) => {
1557
+ const lang = fence.lang === "env" ? "dotenv" : fence.lang;
1558
+ let meta = fence.meta
1559
+ .trim()
1560
+ .replace(/\bwordWrap\b(?:=\{?true\}?)?/u, "wrap")
1561
+ .replace(/\bfilename=/u, "title=");
1562
+ const bare = meta
1563
+ .replaceAll(
1564
+ /\b\w+=(?:"[^"]*"|'[^']*'|\S+)|\{[^}]*\}|\b(?:wrap|lineNumbers|expandable)\b/gu,
1565
+ ""
1566
+ )
1567
+ .trim();
1568
+ if (/\s/u.test(bare)) {
1569
+ // Fern and Blume both read every word after the language as the title;
1570
+ // the quotes only make it explicit.
1571
+ meta = `title="${bare}" ${meta.replace(bare, "").trim()}`.trim();
1572
+ }
1573
+ // Blume labels an untitled grouped block by its language too; the title
1574
+ // keeps Fern's label where the two differ (`curl` → cURL).
1575
+ const groupTitle =
1576
+ fence.group && lang ? (LANGUAGES.get(lang) ?? lang) : undefined;
1577
+ const title = fence.title ?? groupTitle;
1578
+ if (title && !/\btitle=/u.test(meta) && bare === "") {
1579
+ meta = `title="${title}"${meta ? ` ${meta}` : ""}`;
1580
+ }
1581
+ return `${lang}${meta ? ` ${meta}` : ""}`;
1582
+ };
1583
+
1584
+ const unmask = (text, fences, inline) =>
1585
+ text
1586
+ .replaceAll(
1587
+ new RegExp(`${CODE}(?<i>\\d+)${CODE}`, "gu"),
1588
+ (...match) => inline[Number(match.at(-1).i)]
1589
+ )
1590
+ .replaceAll(
1591
+ new RegExp(`^(?<lead>[\\t ]*)${FENCE}(?<i>\\d+)${FENCE}`, "gmu"),
1592
+ (...match) => {
1593
+ const { i, lead } = match.at(-1);
1594
+ const fence = fences[Number(i)];
1595
+ const cut = fence.indent.length - lead.length;
1596
+ const fix = (line) =>
1597
+ cut > 0
1598
+ ? line.slice(Math.min(cut, /^ */u.exec(line)[0].length))
1599
+ : line;
1600
+ return [
1601
+ `${lead}${fence.marker}${fenceInfo(fence)}`,
1602
+ ...fence.body.map(fix),
1603
+ fix(fence.close),
1604
+ ].join("\n");
1605
+ }
1606
+ );
1607
+
1608
+ const rewriteUrls = (text, ctx) =>
1609
+ text
1610
+ .replaceAll(
1611
+ /(?<bang>!?)\[(?<label>[^\n\]]*)\]\((?<url>[^\s)]+)(?<title>\s+"[^"]*")?\)/gu,
1612
+ (...match) => {
1613
+ const { bang, label, title = "", url } = match.at(-1);
1614
+ return `${bang}[${label}](${bang ? ctx.asset(url) : ctx.link(url)}${title})`;
1615
+ }
1616
+ )
1617
+ .replaceAll(
1618
+ /(?<lead>\s(?:href|poster|src)=)(?<quote>["'])(?<url>[^"']+)\k<quote>/gu,
1619
+ (...match) => {
1620
+ const { lead, quote, url } = match.at(-1);
1621
+ return `${lead}${quote}${ctx.link(url)}${quote}`;
1622
+ }
1623
+ );
1624
+
1625
+ /** Convert one page or snippet body. `ctx` resolves links, assets, and snippets. */
1626
+ const convertBody = (input, ctx) => {
1627
+ const { fences, src: masked } = maskFences(input, ctx);
1628
+ const inline = [];
1629
+ const hidden = masked.replaceAll(
1630
+ /(?<ticks>`+)[^\n`](?:[^\n]*?[^\n`])?\k<ticks>(?!`)/gu,
1631
+ (code) => {
1632
+ inline.push(code);
1633
+ return `${CODE}${inline.length - 1}${CODE}`;
1634
+ }
1635
+ );
1636
+ const src = rewriteProse(hidden, ctx);
1637
+ const edits = scanTags(src, fences, ctx).flatMap((open) =>
1638
+ tagEdits(open, src, ctx)
1639
+ );
1640
+ const directives = edits.filter((change) => change.directive);
1641
+ const nested = edits.filter((change) => !change.directive);
1642
+ const inside = (change) =>
1643
+ directives.some((d) => change.start >= d.start && change.end <= d.end);
1644
+ const final = [
1645
+ ...directives.map((d) => {
1646
+ const body = dedent(
1647
+ splice(src.slice(d.bodyStart, d.bodyEnd), d.bodyStart, nested)
1648
+ );
1649
+ return {
1650
+ end: d.end,
1651
+ start: d.start,
1652
+ text: `${d.directive}\n${body}\n:::`,
1653
+ };
1654
+ }),
1655
+ ...nested.filter((change) => !inside(change)),
1656
+ ];
1657
+ const spliced = splice(src, 0, final).replaceAll(/^[\t ]+$/gmu, "");
1658
+ return unmask(rewriteUrls(spliced, ctx), fences, inline);
1659
+ };
1660
+
1661
+ // --- plan ----------------------------------------------------------------------------------
1662
+
1663
+ const instanceHosts = (docs) =>
1664
+ (docs.instances ?? []).flatMap((instance) =>
1665
+ [instance.url, ...[instance["custom-domain"]].flat()]
1666
+ .filter(Boolean)
1667
+ .map((domain) => {
1668
+ const clean = String(domain)
1669
+ .replace(/^https?:\/\//u, "")
1670
+ .replace(/\/+$/u, "");
1671
+ const slash = clean.indexOf("/");
1672
+ return slash === -1
1673
+ ? { host: clean, path: "" }
1674
+ : { host: clean.slice(0, slash), path: clean.slice(slash) };
1675
+ })
1676
+ );
1677
+
1678
+ const docsRedirects = (docs, fernDir, report) => {
1679
+ const entries = [docs.redirects ?? []].flat();
1680
+ const files = entries.filter((entry) => !(entry instanceof Object));
1681
+ const fromFiles = files.flatMap((file) => {
1682
+ try {
1683
+ return (
1684
+ parseYaml(readText(path.join(fernDir, String(file))), String(file))
1685
+ ?.redirects ?? []
1686
+ );
1687
+ } catch (error) {
1688
+ report.todo(String(file), `redirects file not parsed: ${error.message}`);
1689
+ return [];
1690
+ }
1691
+ });
1692
+ return [...entries.filter((entry) => entry instanceof Object), ...fromFiles];
1693
+ };
1694
+
1695
+ const loadPrevious = (statePath) => {
1696
+ if (!existsSync(statePath)) {
1697
+ return new Map();
1698
+ }
1699
+ const { pages = [] } = JSON.parse(readText(statePath));
1700
+ const previous = new Map();
1701
+ for (const page of pages.filter((p) => p.source)) {
1702
+ previous.set(page.source, [
1703
+ ...(previous.get(page.source) ?? []),
1704
+ page.from,
1705
+ ]);
1706
+ }
1707
+ return previous;
1708
+ };
1709
+
1710
+ /** The fern-relative path of a local file a page references, or null. */
1711
+ const localFile = (env, url, fromFile) => {
1712
+ const [clean] = url.split(/[#?]/u);
1713
+ if (!clean || /^(?:[a-z][\d+.a-z-]*:|\/\/)/iu.test(url)) {
1714
+ return null;
1715
+ }
1716
+ const abs = clean.startsWith("/")
1717
+ ? path.join(env.fernDir, clean)
1718
+ : path.resolve(path.dirname(path.join(env.fernDir, fromFile)), clean);
1719
+ const usable =
1720
+ abs.startsWith(env.fernDir + path.sep) &&
1721
+ existsSync(abs) &&
1722
+ statSync(abs).isFile();
1723
+ return usable ? toPosix(path.relative(env.fernDir, abs)) : null;
1724
+ };
1725
+
1726
+ /** A link to the docs' own host, made root-relative. */
1727
+ const siteRelative = (env, url) => {
1728
+ const bare = url.replace(/^https?:\/\//u, "");
1729
+ const host = /^https?:\/\//u.test(url)
1730
+ ? env.hosts.find(
1731
+ (h) =>
1732
+ bare.startsWith(`${h.host}${h.path}`) &&
1733
+ /^(?:[#/?]|$)/u.test(bare.slice(h.host.length + h.path.length))
1734
+ )
1735
+ : undefined;
1736
+ return host ? bare.slice(host.host.length + host.path.length) || "/" : url;
1737
+ };
1738
+
1739
+ /** Links, assets, and snippets for one page, resolved against its Fern location. */
1740
+ const pageContext = (env, fromFile, offset = 0) => {
1741
+ const asset = (url) => {
1742
+ const rel = localFile(env, url, fromFile);
1743
+ if (!rel || /\.mdx?$/u.test(rel)) {
1744
+ return url;
1745
+ }
1746
+ env.assets.add(rel);
1747
+ return `/${rel}`;
1748
+ };
1749
+ const link = (url) => {
1750
+ const rel = localFile(env, url, fromFile);
1751
+ if (rel && !/\.mdx?$/u.test(rel)) {
1752
+ return asset(url);
1753
+ }
1754
+ const target = siteRelative(env, url);
1755
+ const groups = /^(?<p>\/[^#?]*)(?<rest>.*)$/u.exec(target)?.groups;
1756
+ const mapped = groups && env.urlMap.get(normUrl(groups.p));
1757
+ return mapped ? `${mapped}${groups.rest}` : target;
1758
+ };
1759
+ const snippet = (src) => {
1760
+ const rel = localFile(env, src, fromFile);
1761
+ if (!rel) {
1762
+ env.report.todo(
1763
+ fromFile,
1764
+ `<Markdown src="${src}">: snippet file not found`
1765
+ );
1766
+ return src;
1767
+ }
1768
+ const at = rel.lastIndexOf("snippets/");
1769
+ const name =
1770
+ at === -1 ? path.posix.basename(rel) : rel.slice(at + "snippets/".length);
1771
+ env.snippets.set(rel, `_snippets/${name}`);
1772
+ return `/_snippets/${name}`;
1773
+ };
1774
+ return { asset, link, offset, report: env.report, snippet, where: fromFile };
1775
+ };
1776
+
1777
+ const metaSource = (meta) => {
1778
+ const fields = [
1779
+ meta.title && ` title: ${JSON.stringify(meta.title)},`,
1780
+ meta.icon && ` icon: ${JSON.stringify(meta.icon)},`,
1781
+ meta.display &&
1782
+ ` display: "${meta.display}",\n collapsed: ${meta.collapsed},`,
1783
+ meta.pages.length > 0 &&
1784
+ ` pages: [${[...new Set(meta.pages)].map((slug) => JSON.stringify(slug)).join(", ")}],`,
1785
+ ].filter(Boolean);
1786
+ return fields.length > 0
1787
+ ? `import { defineMeta } from "blume";\n\nexport default defineMeta({\n${fields.join("\n")}\n});\n`
1788
+ : null;
1789
+ };
1790
+
1791
+ const changelogRedirects = (placer, report) =>
1792
+ placer.changelogs.flatMap((changelog) => {
1793
+ if (changelog.overview) {
1794
+ report.todo(
1795
+ changelog.overview,
1796
+ "changelog overview: move its text to `changelog: { title, description }` in blume.config.ts"
1797
+ );
1798
+ }
1799
+ for (const other of changelog.others) {
1800
+ report.todo(
1801
+ other,
1802
+ "not a dated entry (Fern never published it): move it out or prefix it with _"
1803
+ );
1804
+ }
1805
+ const index =
1806
+ changelog.base === "/changelog"
1807
+ ? []
1808
+ : [{ from: changelog.base, status: 308, to: "/changelog" }];
1809
+ const feeds = ["rss", "atom", "json"].map((ext) => ({
1810
+ from: `${changelog.base}.${ext}`,
1811
+ status: 308,
1812
+ to: "/changelog/rss.xml",
1813
+ }));
1814
+ return [...index, ...feeds];
1815
+ });
1816
+
1817
+ /** Old URL → new route, and the redirects content moves need. */
1818
+ const contentRedirects = (placer, report) => {
1819
+ const urlMap = new Map();
1820
+ for (const placement of placer.placements.filter((p) => !p.summary)) {
1821
+ const route = placement.slug ? `/${placement.slug}` : placement.route;
1822
+ // Two changelog entries on one day share a Fern URL: prefer the unsuffixed one.
1823
+ const unsuffixed = placement.changelog && !placement.page.date.suffix;
1824
+ if (!urlMap.has(placement.page.url) || unsuffixed) {
1825
+ urlMap.set(placement.page.url, route);
1826
+ }
1827
+ }
1828
+ const moved = [...urlMap]
1829
+ .filter(([from, to]) => from !== to)
1830
+ .map(([from, to]) => ({ from, status: 308, to }));
1831
+ const redirects = [...moved, ...changelogRedirects(placer, report)];
1832
+ const first = placer.placements.find((p) => !(p.changelog || p.summary));
1833
+ if (first && ![...urlMap.values()].includes("/")) {
1834
+ const to = first.slug ? `/${first.slug}` : first.route;
1835
+ redirects.unshift({ from: "/", status: 307, to });
1836
+ report.note(
1837
+ "blume.config.ts",
1838
+ `no page at "/": Fern sent it to the first page, so it redirects to ${to}; set logo.href to ${to}`
1839
+ );
1840
+ }
1841
+ return { redirects, urlMap };
1842
+ };
1843
+
1844
+ const pageWrite = (placement, env, from, to) => {
1845
+ const { page } = placement;
1846
+ const source = readText(from);
1847
+ const { body } = splitFrontmatter(source);
1848
+ // Only an H1 that opens the page: a `# comment` further down can sit in a fence.
1849
+ const h1 = /^(?:[\t ]*\n)*# (?<heading>.+)\n+/u.exec(body);
1850
+ const useH1 = page.kind !== "changelogEntry" && !page.fm.title && h1;
1851
+ let title = page.name;
1852
+ if (page.fm.title) {
1853
+ title = String(page.fm.title);
1854
+ } else if (useH1) {
1855
+ title = h1.groups.heading.trim();
1856
+ }
1857
+ const data =
1858
+ page.kind === "changelogEntry"
1859
+ ? changelogFrontmatter(page, env.report)
1860
+ : pageFrontmatter(placement, title, env.report);
1861
+ // Findings cite file lines: count the frontmatter (and a dropped H1) back in.
1862
+ const dropped = useH1 ? h1[0].split("\n").length - 1 : 0;
1863
+ const offset = source.split("\n").length - body.split("\n").length + dropped;
1864
+ const text = convertBody(
1865
+ useH1 ? body.replace(h1[0], "") : body,
1866
+ pageContext(env, page.file, offset)
1867
+ );
1868
+ return {
1869
+ from,
1870
+ text: `---\n${yamlLines(data).join("\n")}\n---\n\n${text.replace(/^\n+/u, "")}`,
1871
+ to,
1872
+ };
1873
+ };
1874
+
1875
+ const convertPages = (placer, env, outDir) => {
1876
+ const writes = [];
1877
+ let skipped = 0;
1878
+ for (const placement of placer.placements.filter((p) => !p.alias)) {
1879
+ const from = path.join(env.fernDir, placement.page.file);
1880
+ const to = path.join(outDir, placement.file);
1881
+ if (existsSync(from) && !existsSync(to)) {
1882
+ writes.push(pageWrite(placement, env, from, to));
1883
+ } else if (existsSync(from)) {
1884
+ env.report.todo(
1885
+ placement.file,
1886
+ `target exists; left ${placement.page.file} in place`
1887
+ );
1888
+ } else if (existsSync(to)) {
1889
+ skipped += 1;
1890
+ } else {
1891
+ env.report.todo(
1892
+ placement.page.file,
1893
+ "docs.yml references it, but it doesn't exist"
1894
+ );
1895
+ }
1896
+ }
1897
+ return { skipped, writes };
1898
+ };
1899
+
1900
+ const snippetWrites = (env, outDir) =>
1901
+ [...env.snippets]
1902
+ .filter(
1903
+ ([rel, target]) =>
1904
+ existsSync(path.join(env.fernDir, rel)) &&
1905
+ !existsSync(path.join(outDir, target))
1906
+ )
1907
+ .map(([rel, target]) => {
1908
+ // Fern's {{params}} are Blume's include props as written.
1909
+ const text = convertBody(
1910
+ readText(path.join(env.fernDir, rel)),
1911
+ pageContext(env, rel)
1912
+ );
1913
+ return {
1914
+ from: path.join(env.fernDir, rel),
1915
+ text,
1916
+ to: path.join(outDir, target),
1917
+ };
1918
+ });
1919
+
1920
+ const diffSitemap = (file, urlMap, placer, nav) => {
1921
+ const sitemap = new Set(
1922
+ readText(file)
1923
+ .split("\n")
1924
+ .map((line) => normUrl(line.trim()))
1925
+ .filter(Boolean)
1926
+ );
1927
+ const computed = new Set([
1928
+ ...urlMap.keys(),
1929
+ ...nav.apis.map((api) => api.base),
1930
+ ...placer.changelogs.map((c) => c.base),
1931
+ ]);
1932
+ const hidden = new Set(
1933
+ placer.placements.filter((p) => p.page.hidden).map((p) => p.page.url)
1934
+ );
1935
+ const underApi = (url) =>
1936
+ nav.apis.some((api) => url.startsWith(`${api.base}/`));
1937
+ return {
1938
+ computedOnly: [...computed].filter(
1939
+ (url) => !(sitemap.has(url) || hidden.has(url))
1940
+ ),
1941
+ hiddenPages: [...hidden].filter((url) => !sitemap.has(url)),
1942
+ sitemapOnly: [...sitemap]
1943
+ .filter((url) => !(computed.has(url) || underApi(url)))
1944
+ .toSorted(byName),
1945
+ };
1946
+ };
1947
+
1948
+ /** A file under fern/ that SDK generation can read: YAML, JSON, or the API folders. */
1949
+ const sdkFile = (rel) =>
1950
+ /^(?:apis|definition)\//u.test(rel) || /\.(?:json|ya?ml)$/iu.test(rel);
1951
+
1952
+ const applyPlan = (work, opts) => {
1953
+ for (const write of work.writes) {
1954
+ moveFile(write.from, write.to, write.text);
1955
+ }
1956
+ for (const rel of work.env.assets) {
1957
+ const from = path.join(work.env.fernDir, rel);
1958
+ const to = path.resolve(
1959
+ opts.public,
1960
+ rel === work.env.favicon ? `favicon${path.extname(rel)}` : rel
1961
+ );
1962
+ if (existsSync(from) && !existsSync(to)) {
1963
+ mkdirSync(path.dirname(to), { recursive: true });
1964
+ // SDK generation may read it (a spec, the definition): copy, never move.
1965
+ (sdkFile(rel) ? copyFileSync : renameSync)(from, to);
1966
+ }
1967
+ }
1968
+ for (const meta of work.metas.filter((m) => !existsSync(m.to))) {
1969
+ mkdirSync(path.dirname(meta.to), { recursive: true });
1970
+ writeFileSync(meta.to, meta.text);
1971
+ }
1972
+ writeFileSync(
1973
+ path.resolve(opts.state),
1974
+ `${JSON.stringify(work.state, null, 2)}\n`
1975
+ );
1976
+ };
1977
+
1978
+ const readFern = (opts, report) => {
1979
+ const fernDir = path.resolve(opts.fern);
1980
+ if (!existsSync(path.join(fernDir, "docs.yml"))) {
1981
+ throw new Error(
1982
+ `${opts.fern}/docs.yml not found: run from the folder that holds fern/, or pass --fern`
1983
+ );
1984
+ }
1985
+ const docs =
1986
+ parseYaml(readText(path.join(fernDir, "docs.yml")), "docs.yml") ?? {};
1987
+ if (docs.versions || docs.products) {
1988
+ throw new Error(
1989
+ "docs.yml declares versions or products, which this codemod doesn't handle: follow the reference by hand"
1990
+ );
1991
+ }
1992
+ const nav = new Navigation(
1993
+ fernDir,
1994
+ report,
1995
+ loadPrevious(path.resolve(opts.state))
1996
+ );
1997
+ const placer = new Placer(report);
1998
+ for (const tab of nav.tabs(docs)) {
1999
+ placer.tab(tab);
2000
+ }
2001
+ const roots = placer.tabs.filter((tab) => tab.path === "/" && !tab.hidden);
2002
+ if (roots.length > 1) {
2003
+ report.todo(
2004
+ "blume.config.ts",
2005
+ `tabs ${roots.map((t) => `"${t.label}"`).join(", ")} share no URL prefix; only one tab can sit at "/"`
2006
+ );
2007
+ }
2008
+ return { docs, fernDir, nav, placer };
2009
+ };
2010
+
2011
+ const plan = (opts) => {
2012
+ const report = makeReport();
2013
+ const { docs, fernDir, nav, placer } = readFern(opts, report);
2014
+ const { redirects, urlMap } = contentRedirects(placer, report);
2015
+ const hosts = instanceHosts(docs);
2016
+ const paths = [...new Set(hosts.map((host) => host.path))];
2017
+ if (paths.some(Boolean)) {
2018
+ report.note(
2019
+ "docs.yml",
2020
+ `an instance serves under ${paths.filter(Boolean).join(", ")}: links and redirects written with that path need it stripped (Blume adds deployment.base itself)`
2021
+ );
2022
+ }
2023
+ const env = {
2024
+ assets: new Set(),
2025
+ fernDir,
2026
+ hosts,
2027
+ report,
2028
+ snippets: new Map(),
2029
+ urlMap,
2030
+ };
2031
+ const outDir = path.resolve(opts.out);
2032
+ const { skipped, writes } = convertPages(placer, env, outDir);
2033
+ writes.push(...snippetWrites(env, outDir));
2034
+ const config = pageContext(env, "docs.yml");
2035
+ for (const logo of [docs.logo?.light, docs.logo?.dark].filter(Boolean)) {
2036
+ config.asset(String(logo));
2037
+ }
2038
+ env.favicon = docs.favicon
2039
+ ? config.asset(String(docs.favicon)).replace(/^\//u, "")
2040
+ : undefined;
2041
+ for (const rel of [...env.assets].filter(sdkFile)) {
2042
+ report.note(
2043
+ rel,
2044
+ "a page links it: copied to public/, not moved (SDK generation may read it); the copy goes stale when it changes"
2045
+ );
2046
+ }
2047
+ const isDraft = (rel) =>
2048
+ /\.mdx?$/u.test(rel) &&
2049
+ !nav.referenced.has(path.join(fernDir, rel)) &&
2050
+ !env.snippets.has(rel) &&
2051
+ !placer.changelogs.some(
2052
+ (c) => c.overview === rel || c.others.includes(rel)
2053
+ );
2054
+ const drafts = listFiles(fernDir).filter(isDraft);
2055
+ for (const rel of drafts) {
2056
+ report.todo(
2057
+ rel,
2058
+ "unpublished draft (docs.yml never references it): decide where it goes"
2059
+ );
2060
+ }
2061
+ const metas = [...placer.metas]
2062
+ .toSorted(([a], [b]) => byName(a, b))
2063
+ .map(([folder, meta]) => ({
2064
+ text: metaSource(meta),
2065
+ to: path.join(outDir, folder, "meta.ts"),
2066
+ }))
2067
+ .filter((meta) => meta.text);
2068
+ const pages = placer.placements.map((p) => ({
2069
+ file: p.file,
2070
+ from: p.page.url,
2071
+ source: p.page.file,
2072
+ to: p.slug ? `/${p.slug}` : p.route,
2073
+ }));
2074
+ const oldUrls = [
2075
+ ...urlMap.keys(),
2076
+ ...nav.apis.map((api) => api.base),
2077
+ ...placer.changelogs.map((c) => c.base),
2078
+ ];
2079
+ const state = {
2080
+ apis: nav.apis,
2081
+ docsRedirects: docsRedirects(docs, fernDir, report),
2082
+ generated: placer.changelogs.length > 0 ? ["/changelog"] : [],
2083
+ oldUrls: [...new Set(oldUrls)].toSorted(byName),
2084
+ out: toPosix(path.relative(process.cwd(), outDir)),
2085
+ pages,
2086
+ redirects,
2087
+ urlMap: Object.fromEntries(urlMap),
2088
+ };
2089
+ if (opts.write) {
2090
+ applyPlan({ env, metas, state, writes }, opts);
2091
+ }
2092
+ return {
2093
+ apis: nav.apis,
2094
+ assets: [...env.assets],
2095
+ drafts,
2096
+ findings: report.entries,
2097
+ metas: metas.length,
2098
+ moved: redirects.length,
2099
+ pages: placer.placements.filter((p) => !p.alias).length,
2100
+ sitemapDiff: opts.sitemap
2101
+ ? diffSitemap(opts.sitemap, urlMap, placer, nav)
2102
+ : undefined,
2103
+ skipped,
2104
+ tabs: placer.tabs,
2105
+ wrote: opts.write,
2106
+ };
2107
+ };
2108
+
2109
+ // --- endpoints -----------------------------------------------------------------------------
2110
+
2111
+ const METHODS = [
2112
+ "get",
2113
+ "put",
2114
+ "post",
2115
+ "delete",
2116
+ "options",
2117
+ "head",
2118
+ "patch",
2119
+ "trace",
2120
+ ];
2121
+ const PATTERN = /[(*:]/u;
2122
+ const SPREAD = /\/:\w+\*$/u;
2123
+
2124
+ /** Fern's URL for a spec operation (the reference's "API URLs"). */
2125
+ const fernEndpointUrl = (api, op, method, specPath) => {
2126
+ if (api.kind === "definition") {
2127
+ return op.operationId
2128
+ ? joinUrl(api.base, String(op.operationId).split("_").map(kebab))
2129
+ : null;
2130
+ }
2131
+ // Fern's OpenAPI parser: x-fern-sdk-method-name names the endpoint and
2132
+ // x-fern-sdk-group-name its group; otherwise the first tag is the group and
2133
+ // the operationId minus the tag's leading words (else the summary) the name.
2134
+ const sdkMethod = op["x-fern-sdk-method-name"];
2135
+ const tag = op.tags?.[0];
2136
+ const groups = [sdkMethod ? op["x-fern-sdk-group-name"] : tag]
2137
+ .flat()
2138
+ .filter(Boolean)
2139
+ .map(kebab);
2140
+ const tagWords = kebab(tag).split("-").filter(Boolean);
2141
+ const idWords = kebab(op.operationId).split("-").filter(Boolean);
2142
+ const leading =
2143
+ tagWords.length > 0 &&
2144
+ tagWords.length < idWords.length &&
2145
+ tagWords.every((word, i) => idWords[i] === word);
2146
+ const id = idWords.slice(leading ? tagWords.length : 0).join("-");
2147
+ const name = sdkMethod ?? (id || op.summary || `${method} ${specPath}`);
2148
+ return joinUrl(api.base, groups, kebab(name));
2149
+ };
2150
+
2151
+ /** One API's Fern endpoint and webhook URLs → Blume routes. */
2152
+ const joinApi = (api, reference, spec, oldUrls, report) => {
2153
+ const joined = [];
2154
+ for (const [specPath, item] of Object.entries(spec.paths ?? {})) {
2155
+ for (const method of METHODS.filter((m) => item?.[m])) {
2156
+ const endpoint = `${method.toUpperCase()} ${specPath}`;
2157
+ const from = fernEndpointUrl(api, item[method], method, specPath);
2158
+ const route = reference.endpoints?.[endpoint];
2159
+ if (from && route) {
2160
+ joined.push([from, route]);
2161
+ } else {
2162
+ report.todo(
2163
+ api.base,
2164
+ `${endpoint}: ${from ? "no page in the build" : "no operationId, so its Fern URL is unknown"}`
2165
+ );
2166
+ }
2167
+ }
2168
+ }
2169
+ // Webhooks: fern export drops them, so their pages only exist when an
2170
+ // overlay restored them; match each to the old URL ending in its kebab name.
2171
+ const taken = new Set(joined.map(([from]) => from));
2172
+ for (const [endpoint, route] of Object.entries(reference.endpoints ?? {})) {
2173
+ const [, name] = endpoint.split(" ");
2174
+ const from = name.startsWith("/")
2175
+ ? undefined
2176
+ : oldUrls.find(
2177
+ (url) =>
2178
+ url.startsWith(`${api.base}/`) &&
2179
+ !taken.has(url) &&
2180
+ url.endsWith(`/${kebab(name)}`)
2181
+ );
2182
+ if (from) {
2183
+ joined.push([from, route]);
2184
+ }
2185
+ }
2186
+ return joined;
2187
+ };
2188
+
2189
+ /** Every Fern endpoint URL → its route, and each group URL → its overview anchor. */
2190
+ const joinEndpoints = (state, routes, specs, oldUrls, report) => {
2191
+ const endpointMap = new Map();
2192
+ const groupMap = new Map();
2193
+ for (const api of state.apis) {
2194
+ const reference = routes.references?.[api.base];
2195
+ const specFile = specs.get(api.base);
2196
+ if (reference && specFile) {
2197
+ for (const [from, route] of joinApi(
2198
+ api,
2199
+ reference,
2200
+ JSON.parse(readText(specFile)),
2201
+ oldUrls,
2202
+ report
2203
+ )) {
2204
+ endpointMap.set(from, route);
2205
+ const group = from.slice(0, from.lastIndexOf("/"));
2206
+ if (group !== api.base && !groupMap.has(group)) {
2207
+ groupMap.set(
2208
+ group,
2209
+ `${api.base}#${route.slice(api.base.length + 1).split("/")[0]}`
2210
+ );
2211
+ }
2212
+ }
2213
+ } else {
2214
+ report.todo(
2215
+ api.base,
2216
+ reference
2217
+ ? `pass --spec ${api.base}=<spec.json>`
2218
+ : `no reference at ${api.base} in the routes file`
2219
+ );
2220
+ }
2221
+ }
2222
+ return { endpointMap, groupMap };
2223
+ };
2224
+
2225
+ /** Follow a docs.yml destination through Fern's own redirects to its final route. */
2226
+ const followDestination = (dest, exact, maps) => {
2227
+ let current = dest;
2228
+ for (
2229
+ let hop = 0;
2230
+ hop < 10 && !maps.finalMap.has(current) && exact.has(current);
2231
+ hop += 1
2232
+ ) {
2233
+ current = exact.get(current);
2234
+ }
2235
+ const [url, hash] = current.split("#");
2236
+ const mapped = maps.finalMap.get(url) ?? maps.groupMap.get(url);
2237
+ if (!mapped) {
2238
+ return current;
2239
+ }
2240
+ return hash && !mapped.includes("#") ? `${mapped}#${hash}` : mapped;
2241
+ };
2242
+
2243
+ /** One docs.yml redirect in Blume's shape (a reference pattern expands per operation). */
2244
+ const translateRedirect = (redirect, exact, maps, report) => {
2245
+ const { endpointMap, finalMap, groupMap } = maps;
2246
+ const from = normUrl(String(redirect.source));
2247
+ const external = /^https?:/u.test(String(redirect.destination));
2248
+ const dest = external
2249
+ ? String(redirect.destination)
2250
+ : normUrl(String(redirect.destination));
2251
+ const status = redirect.permanent === false ? 307 : 308;
2252
+ if (/\(|:\w+\+/u.test(from)) {
2253
+ report.todo(
2254
+ from,
2255
+ "regex or one-or-more pattern: Blume reads :name and :name* only; expand it or use host rules"
2256
+ );
2257
+ return [];
2258
+ }
2259
+ const destBase = dest.replace(SPREAD, "");
2260
+ const covered =
2261
+ PATTERN.test(from) && SPREAD.test(dest)
2262
+ ? [...endpointMap].filter(([fern]) => fern.startsWith(`${destBase}/`))
2263
+ : [];
2264
+ if (covered.length > 0) {
2265
+ const fromBase = from.replace(SPREAD, "");
2266
+ return covered.map(([fern, route]) => ({
2267
+ from: `${fromBase}${fern.slice(destBase.length)}`,
2268
+ status,
2269
+ to: route,
2270
+ }));
2271
+ }
2272
+ const to =
2273
+ external || PATTERN.test(dest)
2274
+ ? dest
2275
+ : followDestination(dest, exact, maps);
2276
+ const landed =
2277
+ external ||
2278
+ PATTERN.test(to) ||
2279
+ [...finalMap.values()].includes(to.split("#")[0]) ||
2280
+ groupMap.has(dest);
2281
+ if (!landed) {
2282
+ report.todo(
2283
+ from,
2284
+ `destination ${to} isn't a page here: it probably 404ed on Fern too; point it at the intended page`
2285
+ );
2286
+ }
2287
+ return [{ from, status, to }];
2288
+ };
2289
+
2290
+ const coverage = (state, redirects, oldUrls) => {
2291
+ const served = new Set([
2292
+ ...state.pages.map((p) => p.to),
2293
+ ...state.generated,
2294
+ ...state.apis.map((a) => a.base),
2295
+ ]);
2296
+ const exact = new Set(redirects.map((r) => r.from));
2297
+ const patterns = redirects
2298
+ .filter((r) => PATTERN.test(r.from))
2299
+ .map(
2300
+ (r) =>
2301
+ new RegExp(
2302
+ `^${r.from.replace(SPREAD, "(?:/.*)?").replaceAll(/:\w+/gu, "[^/]+")}$`,
2303
+ "u"
2304
+ )
2305
+ );
2306
+ return oldUrls
2307
+ .filter(
2308
+ (url) =>
2309
+ !(
2310
+ served.has(url) ||
2311
+ exact.has(url) ||
2312
+ patterns.some((p) => p.test(url))
2313
+ )
2314
+ )
2315
+ .toSorted(byName);
2316
+ };
2317
+
2318
+ const fixLink = (url, targets, apiRoutes) => {
2319
+ const api = /^api:(?:[\w-]+:)?(?<method>[A-Z]+)(?<route>\/\S*)$/u.exec(
2320
+ url
2321
+ )?.groups;
2322
+ if (api) {
2323
+ return apiRoutes.get(`${api.method} ${api.route}`) ?? url;
2324
+ }
2325
+ const groups = /^(?<p>\/[^#?]*)(?<rest>.*)$/u.exec(url)?.groups;
2326
+ const target = groups && targets.get(normUrl(groups.p));
2327
+ if (!target) {
2328
+ return url;
2329
+ }
2330
+ return target.includes("#") ? target : `${target}${groups.rest}`;
2331
+ };
2332
+
2333
+ const rewriteLinks = (state, targets, apiRoutes, write) => {
2334
+ const outDir = path.resolve(state.out);
2335
+ const rewritten = [];
2336
+ const code =
2337
+ /(?<code>^[\t ]*(?:`{3,}|~{3,})[\s\S]*?^[\t ]*(?:`{3,}|~{3,})[\t ]*$|`[^\n`]+`)/mu;
2338
+ for (const rel of listFiles(outDir).filter((file) => /\.mdx?$/u.test(file))) {
2339
+ const file = path.join(outDir, rel);
2340
+ const before = readText(file);
2341
+ const after = before
2342
+ .split(code)
2343
+ .map((part, i) =>
2344
+ i % 2 === 1
2345
+ ? part
2346
+ : part.replaceAll(
2347
+ /(?<lead>\]\(|\shref=["'])(?<url>[^\s"')]+)/gu,
2348
+ (...match) => {
2349
+ const { lead, url } = match.at(-1);
2350
+ return `${lead}${fixLink(url, targets, apiRoutes)}`;
2351
+ }
2352
+ )
2353
+ )
2354
+ .join("");
2355
+ if (after !== before) {
2356
+ rewritten.push(`${state.out}/${rel}`);
2357
+ if (write) {
2358
+ writeFileSync(file, after);
2359
+ }
2360
+ }
2361
+ }
2362
+ return rewritten;
2363
+ };
2364
+
2365
+ const endpoints = (opts) => {
2366
+ const state = JSON.parse(readText(path.resolve(opts.state)));
2367
+ const routes = JSON.parse(readText(path.resolve(opts.routes)));
2368
+ const report = makeReport();
2369
+ const specs = new Map(
2370
+ opts.specs.map((spec) => [normUrl(spec.route), spec.file])
2371
+ );
2372
+ const yamlSpec = [...specs.values()].find((file) => /\.ya?ml$/iu.test(file));
2373
+ if (yamlSpec) {
2374
+ throw new Error(
2375
+ `${yamlSpec}: pass a JSON spec (fern export writes JSON to a .json path)`
2376
+ );
2377
+ }
2378
+ const fromSitemap = opts.sitemap
2379
+ ? readText(opts.sitemap)
2380
+ .split("\n")
2381
+ .map((line) => normUrl(line.trim()))
2382
+ : [];
2383
+ const oldUrls = [
2384
+ ...new Set([...state.oldUrls, ...fromSitemap.filter(Boolean)]),
2385
+ ];
2386
+ const { endpointMap, groupMap } = joinEndpoints(
2387
+ state,
2388
+ routes,
2389
+ specs,
2390
+ oldUrls,
2391
+ report
2392
+ );
2393
+ const finalMap = new Map([...Object.entries(state.urlMap), ...endpointMap]);
2394
+ const maps = { endpointMap, finalMap, groupMap };
2395
+ const exact = new Map(
2396
+ state.docsRedirects
2397
+ .filter((r) => !PATTERN.test(String(r.source)))
2398
+ .map((r) => [normUrl(String(r.source)), normUrl(String(r.destination))])
2399
+ );
2400
+ const all = [
2401
+ ...state.redirects,
2402
+ ...state.docsRedirects.flatMap((redirect) =>
2403
+ translateRedirect(redirect, exact, maps, report)
2404
+ ),
2405
+ ...[...endpointMap].map(([from, to]) => ({ from, status: 308, to })),
2406
+ ...[...groupMap]
2407
+ .filter(([from]) => !finalMap.has(from))
2408
+ .map(([from, to]) => ({ from, status: 307, to })),
2409
+ ];
2410
+ const seen = new Set();
2411
+ const redirects = all.filter(
2412
+ (r) => r.from !== r.to && !seen.has(r.from) && seen.add(r.from)
2413
+ );
2414
+ const missing = coverage(state, redirects, oldUrls);
2415
+ for (const url of missing) {
2416
+ report.todo(url, "old URL neither served nor redirected");
2417
+ }
2418
+ const apiRoutes = new Map(
2419
+ Object.values(routes.references ?? {}).flatMap((reference) =>
2420
+ Object.entries(reference.endpoints ?? {})
2421
+ )
2422
+ );
2423
+ const targets = new Map(
2424
+ redirects
2425
+ .filter((r) => !(PATTERN.test(r.from) || PATTERN.test(r.to)))
2426
+ .map((r) => [r.from, r.to])
2427
+ );
2428
+ const rewritten = rewriteLinks(state, targets, apiRoutes, opts.write);
2429
+ if (opts.write) {
2430
+ writeFileSync(
2431
+ path.resolve(opts.redirectsOut),
2432
+ `${JSON.stringify(redirects, null, 2)}\n`
2433
+ );
2434
+ }
2435
+ return {
2436
+ endpoints: endpointMap.size,
2437
+ findings: report.entries,
2438
+ groups: groupMap.size,
2439
+ missing,
2440
+ redirects: redirects.length,
2441
+ rewritten,
2442
+ wrote: opts.write ? opts.redirectsOut : false,
2443
+ };
2444
+ };
2445
+
2446
+ // --- CLI -------------------------------------------------------------------------------------
2447
+
2448
+ const HELP = `fern-codemod: the mechanical half of a Fern Docs → Blume migration
2449
+
2450
+ node fern-codemod.mjs [plan] [--write] [--fern fern] [--out docs] [--public public]
2451
+ [--sitemap old-urls.txt] [--json]
2452
+ Place every page at a path that reproduces its Fern URL, convert
2453
+ frontmatter and components, write meta.ts files, move assets to
2454
+ public/. Dry run unless --write, which also saves fern-migration.json.
2455
+
2456
+ node fern-codemod.mjs endpoints --routes routes.json --spec <route>=<spec.json>
2457
+ [--sitemap old-urls.txt] [--write] [--json]
2458
+ After blume build and operation-routes.mjs: one redirect per old
2459
+ endpoint and group URL, docs.yml redirects translated, links rewritten,
2460
+ every old URL checked. --write saves fern-redirects.json.
2461
+ `;
2462
+
2463
+ const VALUE_FLAGS = new Set([
2464
+ "--fern",
2465
+ "--out",
2466
+ "--public",
2467
+ "--routes",
2468
+ "--sitemap",
2469
+ "--spec",
2470
+ ]);
2471
+
2472
+ const parseArgs = (argv) => {
2473
+ const opts = {
2474
+ command: "plan",
2475
+ fern: "fern",
2476
+ json: false,
2477
+ out: "docs",
2478
+ public: "public",
2479
+ redirectsOut: "fern-redirects.json",
2480
+ specs: [],
2481
+ state: "fern-migration.json",
2482
+ write: false,
2483
+ };
2484
+ const args = [...argv];
2485
+ if (args[0] && !args[0].startsWith("-")) {
2486
+ opts.command = args.shift();
2487
+ }
2488
+ for (let i = 0; i < args.length; i += 1) {
2489
+ const flag = args[i];
2490
+ const value = args[i + 1];
2491
+ if (VALUE_FLAGS.has(flag) && value === undefined) {
2492
+ throw new Error(`${flag} needs a value`);
2493
+ }
2494
+ if (flag === "--spec") {
2495
+ const [route, file] = value.split(/[=](?<file>.*)/u);
2496
+ opts.specs.push({ file, route });
2497
+ } else if (VALUE_FLAGS.has(flag)) {
2498
+ opts[flag.slice(2)] = value;
2499
+ } else if (flag === "--write" || flag === "--json") {
2500
+ opts[flag.slice(2)] = true;
2501
+ } else if (flag === "--help" || flag === "-h") {
2502
+ opts.command = "help";
2503
+ } else {
2504
+ throw new Error(`unknown argument ${flag} (see --help)`);
2505
+ }
2506
+ i += VALUE_FLAGS.has(flag) ? 1 : 0;
2507
+ }
2508
+ return opts;
2509
+ };
2510
+
2511
+ const print = (line = "") => process.stdout.write(`${line}\n`);
2512
+
2513
+ const printFindings = (findings) => {
2514
+ const labels = [
2515
+ ["todo", "TODO (judgment)"],
2516
+ ["icon", "icons"],
2517
+ ["dropped", "dropped"],
2518
+ ["note", "notes"],
2519
+ ];
2520
+ for (const [kind, label] of labels) {
2521
+ const matching = findings.filter((finding) => finding.kind === kind);
2522
+ if (matching.length > 0) {
2523
+ print(`\n${label}:`);
2524
+ for (const finding of matching) {
2525
+ print(` ${finding.where}: ${finding.detail}`);
2526
+ }
2527
+ }
2528
+ }
2529
+ };
2530
+
2531
+ const tabLine = (tab) => {
2532
+ if (tab.href) {
2533
+ return ` ${tab.label} links to ${tab.href}: put it in navigation.actions`;
2534
+ }
2535
+ const icon = tab.icon ? `, icon: ${JSON.stringify(tab.icon)}` : "";
2536
+ return ` { label: ${JSON.stringify(tab.label)}, path: ${JSON.stringify(tab.path)}${icon} }${tab.hidden ? " (hidden on Fern)" : ""}`;
2537
+ };
2538
+
2539
+ const printPlan = (result) => {
2540
+ print(
2541
+ `${result.pages} page(s) placed (${result.skipped} already migrated); ${result.moved} content redirect(s); ${result.metas} meta.ts; ${result.assets.length} asset(s) for public/`
2542
+ );
2543
+ const diff = result.sitemapDiff;
2544
+ if (diff) {
2545
+ print(
2546
+ `sitemap: ${diff.computedOnly.length} computed URL(s) not in it, ${diff.sitemapOnly.length} of its URLs not computed (API pages aside), ${diff.hiddenPages.length} hidden page(s) it omits`
2547
+ );
2548
+ for (const url of diff.computedOnly) {
2549
+ print(` computed only: ${url}`);
2550
+ }
2551
+ for (const url of diff.sitemapOnly) {
2552
+ print(` sitemap only: ${url}`);
2553
+ }
2554
+ }
2555
+ print("\nnavigation.tabs:");
2556
+ for (const tab of result.tabs) {
2557
+ print(tabLine(tab));
2558
+ }
2559
+ for (const api of result.apis) {
2560
+ const source =
2561
+ api.kind === "definition"
2562
+ ? `Fern Definition in ${api.definition}: fern export it to OpenAPI`
2563
+ : "OpenAPI";
2564
+ print(`API at ${api.base}: ${source}`);
2565
+ }
2566
+ printFindings(result.findings);
2567
+ print(
2568
+ result.wrote
2569
+ ? "\nWrote the changes and fern-migration.json."
2570
+ : "\nDry run. Re-run with --write to apply."
2571
+ );
2572
+ };
2573
+
2574
+ const printEndpoints = (result) => {
2575
+ print(
2576
+ `${result.endpoints} endpoint URL(s) joined, ${result.groups} group URL(s), ${result.redirects} redirect(s) in all`
2577
+ );
2578
+ print(
2579
+ `${result.rewritten.length} file(s) with links to old URLs${result.wrote ? " rewritten" : ""}`
2580
+ );
2581
+ print(
2582
+ result.missing.length === 0
2583
+ ? "every old URL is served or redirected"
2584
+ : `${result.missing.length} old URL(s) not covered`
2585
+ );
2586
+ printFindings(result.findings);
2587
+ print(
2588
+ result.wrote
2589
+ ? `\nWrote ${result.wrote}: import it into redirects in blume.config.ts.`
2590
+ : "\nDry run. Re-run with --write to save the redirects and rewrite links."
2591
+ );
2592
+ };
2593
+
2594
+ const COMMANDS = {
2595
+ endpoints: (opts) => {
2596
+ if (!opts.routes) {
2597
+ throw new Error("endpoints needs --routes <operation-routes.mjs output>");
2598
+ }
2599
+ return [endpoints(opts), printEndpoints];
2600
+ },
2601
+ plan: (opts) => [plan(opts), printPlan],
2602
+ };
2603
+
2604
+ const run = (opts) => {
2605
+ if (opts.command === "help") {
2606
+ process.stdout.write(HELP);
2607
+ return;
2608
+ }
2609
+ const command = COMMANDS[opts.command];
2610
+ if (!command) {
2611
+ throw new Error(`unknown command ${opts.command} (see --help)`);
2612
+ }
2613
+ const [result, printer] = command(opts);
2614
+ if (opts.json) {
2615
+ print(JSON.stringify(result, null, 2));
2616
+ } else {
2617
+ printer(result);
2618
+ }
2619
+ };
2620
+
2621
+ process.stdout.on("error", (error) => {
2622
+ // A reader that closes the pipe early (`| head`) isn't a failure.
2623
+ if (error.code !== "EPIPE") {
2624
+ throw error;
2625
+ }
2626
+ });
2627
+
2628
+ try {
2629
+ run(parseArgs(process.argv.slice(2)));
2630
+ } catch (error) {
2631
+ process.stderr.write(`fern-codemod: ${error.message}\n`);
2632
+ process.exitCode = 1;
2633
+ }