blume 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +133 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-74wfhv79.js → chunk-05f21r8h.js} +1 -1
  5. package/dist/cli/{chunk-9qzgg6mj.js → chunk-1cm0h819.js} +3 -3
  6. package/dist/cli/{chunk-nbyvgnnm.js → chunk-1rkxzg3n.js} +104 -47
  7. package/dist/cli/chunk-1rkxzg3n.js.map +36 -0
  8. package/dist/cli/{chunk-desvm72z.js → chunk-2nr29y95.js} +22 -26
  9. package/dist/cli/chunk-2nr29y95.js.map +13 -0
  10. package/dist/cli/{chunk-32khwcb6.js → chunk-49r370mb.js} +42 -69
  11. package/dist/cli/{chunk-32khwcb6.js.map → chunk-49r370mb.js.map} +9 -10
  12. package/dist/cli/{chunk-ms9z66nz.js → chunk-4rftmttv.js} +2 -2
  13. package/dist/cli/{chunk-s0bdnc7v.js → chunk-79bzzkx4.js} +5 -4
  14. package/dist/cli/chunk-79bzzkx4.js.map +10 -0
  15. package/dist/cli/{chunk-hvcxng5f.js → chunk-7tekknev.js} +6 -6
  16. package/dist/cli/{chunk-dv2z0rzf.js → chunk-8b489v1b.js} +38 -13
  17. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-8b489v1b.js.map} +5 -4
  18. package/dist/cli/{chunk-0pmb4tr4.js → chunk-8f8xw4x2.js} +1195 -222
  19. package/dist/cli/chunk-8f8xw4x2.js.map +44 -0
  20. package/dist/cli/chunk-96snx8yy.js +329 -0
  21. package/dist/cli/chunk-96snx8yy.js.map +13 -0
  22. package/dist/cli/{chunk-ckrcm08h.js → chunk-a3ydrqny.js} +48 -62
  23. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-a3ydrqny.js.map} +4 -5
  24. package/dist/cli/{chunk-bxwhtvw8.js → chunk-ahc5gc9h.js} +5 -5
  25. package/dist/cli/{chunk-dpy8qdjf.js → chunk-as09pafp.js} +73 -182
  26. package/dist/cli/chunk-as09pafp.js.map +18 -0
  27. package/dist/cli/{chunk-tfycddbt.js → chunk-bjk964fp.js} +2 -2
  28. package/dist/cli/{chunk-gfc5fqvf.js → chunk-br0n8n3e.js} +18 -18
  29. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-br0n8n3e.js.map} +1 -1
  30. package/dist/cli/{chunk-297ajdcv.js → chunk-c69tw29j.js} +6 -6
  31. package/dist/cli/{chunk-297ajdcv.js.map → chunk-c69tw29j.js.map} +3 -3
  32. package/dist/cli/chunk-cn3dy3hz.js +146 -0
  33. package/dist/cli/chunk-cn3dy3hz.js.map +11 -0
  34. package/dist/cli/{chunk-124fvmzq.js → chunk-dm4mg9bj.js} +11064 -8742
  35. package/dist/cli/{chunk-124fvmzq.js.map → chunk-dm4mg9bj.js.map} +78 -64
  36. package/dist/cli/{chunk-dqhg54mr.js → chunk-dmx6cezx.js} +1 -1
  37. package/dist/cli/{chunk-60hz265r.js → chunk-dpcahcrb.js} +24 -11
  38. package/dist/cli/chunk-dpcahcrb.js.map +10 -0
  39. package/dist/cli/{chunk-a57yh0h9.js → chunk-dyk3r7jw.js} +7 -7
  40. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-dyk3r7jw.js.map} +1 -1
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-enk76qd5.js} +14 -10
  42. package/dist/cli/chunk-enk76qd5.js.map +10 -0
  43. package/dist/cli/{chunk-k3gr1nm9.js → chunk-fqy4n3q2.js} +13 -16
  44. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-fqy4n3q2.js.map} +3 -3
  45. package/dist/cli/{chunk-sc93apfs.js → chunk-frk2wdq1.js} +17 -17
  46. package/dist/cli/{chunk-sc93apfs.js.map → chunk-frk2wdq1.js.map} +2 -2
  47. package/dist/cli/{chunk-kdpzw38a.js → chunk-gprwej7h.js} +95 -7
  48. package/dist/cli/chunk-gprwej7h.js.map +11 -0
  49. package/dist/cli/chunk-kkrteb15.js +157 -0
  50. package/dist/cli/chunk-kkrteb15.js.map +10 -0
  51. package/dist/cli/{chunk-ave1wcbr.js → chunk-m9bp2hc1.js} +1 -1
  52. package/dist/cli/{chunk-58bt3hag.js → chunk-mrvh4c88.js} +4 -4
  53. package/dist/cli/{chunk-5cmts6dv.js → chunk-p8zg3a2h.js} +7 -7
  54. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-p8zg3a2h.js.map} +1 -1
  55. package/dist/cli/{chunk-0w6hs479.js → chunk-rkqae7kh.js} +30 -18
  56. package/dist/cli/chunk-rkqae7kh.js.map +10 -0
  57. package/dist/cli/{chunk-bqxaqn5p.js → chunk-t5xx74rd.js} +8 -8
  58. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-t5xx74rd.js.map} +1 -1
  59. package/dist/cli/{chunk-1f882900.js → chunk-th0pgaj5.js} +2 -2
  60. package/dist/cli/{chunk-dhwtqnnd.js → chunk-vnbvhgmd.js} +2 -2
  61. package/dist/cli/{chunk-gxtg6ed2.js → chunk-vzy9rh6y.js} +21 -21
  62. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-vzy9rh6y.js.map} +3 -3
  63. package/dist/cli/{chunk-j1x92mx9.js → chunk-w6e455pz.js} +1 -1
  64. package/dist/cli/chunk-wehv26ry.js +155 -0
  65. package/dist/cli/chunk-wehv26ry.js.map +11 -0
  66. package/dist/cli/{chunk-y4msrwm9.js → chunk-xgq4eytj.js} +15 -15
  67. package/dist/cli/{chunk-36bxs9c7.js → chunk-xt0qvz1t.js} +27 -23
  68. package/dist/cli/chunk-xt0qvz1t.js.map +10 -0
  69. package/dist/cli/index.js +78 -42
  70. package/dist/cli/index.js.map +7 -6
  71. package/dist/types/ai/changelog-components.d.ts +18 -0
  72. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  73. package/dist/types/ai/openapi-components.d.ts +7 -7
  74. package/dist/types/ai/operation-details.d.ts +39 -0
  75. package/dist/types/ai/relative-links.d.ts +3 -1
  76. package/dist/types/ai/serializers.d.ts +6 -4
  77. package/dist/types/analytics/head.d.ts +4 -2
  78. package/dist/types/analytics/script.d.ts +8 -2
  79. package/dist/types/components/content/youtube.d.ts +20 -0
  80. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  81. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  82. package/dist/types/components/openapi/helpers.d.ts +53 -11
  83. package/dist/types/components/openapi/snippets.d.ts +7 -1
  84. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  85. package/dist/types/core/builtin-tags.d.ts +16 -0
  86. package/dist/types/core/changelog-list.d.ts +48 -0
  87. package/dist/types/core/closest-match.d.ts +6 -0
  88. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  89. package/dist/types/core/code-fences.d.ts +8 -1
  90. package/dist/types/core/config-input.d.ts +1 -1
  91. package/dist/types/core/content-assets.d.ts +16 -18
  92. package/dist/types/core/diagnostics.d.ts +14 -0
  93. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  94. package/dist/types/core/former-routes.d.ts +14 -0
  95. package/dist/types/core/github.d.ts +5 -4
  96. package/dist/types/core/heading-markers.d.ts +65 -2
  97. package/dist/types/core/hidden-pages.d.ts +3 -0
  98. package/dist/types/core/includes.d.ts +23 -0
  99. package/dist/types/core/last-modified.d.ts +18 -6
  100. package/dist/types/core/links.d.ts +42 -2
  101. package/dist/types/core/load-module.d.ts +19 -4
  102. package/dist/types/core/manifest.d.ts +14 -1
  103. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  104. package/dist/types/core/mdx-elements.d.ts +32 -0
  105. package/dist/types/core/mdx-syntax.d.ts +100 -0
  106. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  107. package/dist/types/core/meta.d.ts +32 -5
  108. package/dist/types/core/nav-diagnostics.d.ts +9 -2
  109. package/dist/types/core/navigation.d.ts +13 -0
  110. package/dist/types/core/ordering-prefix.d.ts +6 -0
  111. package/dist/types/core/pinned-ids.d.ts +23 -0
  112. package/dist/types/core/project-graph.d.ts +9 -1
  113. package/dist/types/core/redirect-patterns.d.ts +18 -0
  114. package/dist/types/core/relative-files.d.ts +31 -0
  115. package/dist/types/core/schema.d.ts +267 -7
  116. package/dist/types/core/sources/normalize.d.ts +101 -9
  117. package/dist/types/core/sources/types.d.ts +16 -0
  118. package/dist/types/core/static-files.d.ts +10 -0
  119. package/dist/types/core/static-host.d.ts +42 -0
  120. package/dist/types/core/svg-images.d.ts +26 -0
  121. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  122. package/dist/types/core/types.d.ts +42 -1
  123. package/dist/types/deploy/generated-files.d.ts +41 -0
  124. package/dist/types/deploy/redirects.d.ts +21 -7
  125. package/dist/types/markdown/code-title.d.ts +9 -5
  126. package/dist/types/markdown/directives.d.ts +8 -0
  127. package/dist/types/markdown/fence-language.d.ts +51 -0
  128. package/dist/types/markdown/fence-meta.d.ts +5 -0
  129. package/dist/types/markdown/github-alerts.d.ts +35 -0
  130. package/dist/types/openapi/asyncapi.d.ts +6 -5
  131. package/dist/types/openapi/checks.d.ts +10 -5
  132. package/dist/types/openapi/model.d.ts +55 -8
  133. package/dist/types/openapi/parse.d.ts +4 -1
  134. package/dist/types/sources/filesystem.d.ts +14 -4
  135. package/dist/types/sources/mdx-remote.d.ts +6 -3
  136. package/dist/types/sources/registry.d.ts +3 -3
  137. package/dist/types/theme/color.d.ts +33 -0
  138. package/dist/types/theme/palette.d.ts +122 -0
  139. package/docs/01-quickstart.mdx +1 -1
  140. package/docs/02-deployment.mdx +23 -8
  141. package/docs/04-migrating.mdx +16 -4
  142. package/docs/advanced/changelog.mdx +27 -1
  143. package/docs/advanced/skills.mdx +1 -1
  144. package/docs/cli/audit.mdx +1 -1
  145. package/docs/cli/doctor.mdx +2 -2
  146. package/docs/cli/evals.mdx +1 -1
  147. package/docs/cli/index.mdx +4 -2
  148. package/docs/cli/validate.mdx +10 -7
  149. package/docs/configuration/analytics.mdx +2 -0
  150. package/docs/configuration/customization.mdx +3 -1
  151. package/docs/configuration/index.mdx +2 -2
  152. package/docs/configuration/theming.mdx +5 -1
  153. package/docs/content/components.mdx +26 -6
  154. package/docs/content/frontmatter.mdx +1 -1
  155. package/docs/content/includes.mdx +6 -2
  156. package/docs/content/index.mdx +11 -1
  157. package/docs/content/islands.mdx +2 -0
  158. package/docs/content/meta.mdx +9 -3
  159. package/docs/content/navigation.mdx +14 -6
  160. package/docs/content/sources/obsidian.mdx +1 -1
  161. package/docs/content/sources/remote-mdx.mdx +3 -1
  162. package/docs/content/syntax.mdx +76 -8
  163. package/docs/content/variables.mdx +3 -1
  164. package/docs/discoverability/agent-discovery.mdx +1 -1
  165. package/docs/discoverability/json-api.mdx +1 -1
  166. package/docs/discoverability/llms-txt.mdx +1 -1
  167. package/docs/discoverability/markdown.mdx +2 -2
  168. package/docs/discoverability/open-graph.mdx +1 -1
  169. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  170. package/docs/references/graphql.mdx +2 -0
  171. package/docs/references/openapi.mdx +45 -7
  172. package/package.json +10 -9
  173. package/skills/blume/SKILL.md +2 -2
  174. package/skills/blume-migrate/SKILL.md +49 -27
  175. package/skills/blume-migrate/references/docsify.md +262 -0
  176. package/skills/blume-migrate/references/docus.md +298 -0
  177. package/skills/blume-migrate/references/docusaurus.md +3 -3
  178. package/skills/blume-migrate/references/fern.md +327 -0
  179. package/skills/blume-migrate/references/fumadocs.md +1 -1
  180. package/skills/blume-migrate/references/gitbook.md +267 -0
  181. package/skills/blume-migrate/references/github-wiki.md +259 -0
  182. package/skills/blume-migrate/references/jekyll.md +253 -0
  183. package/skills/blume-migrate/references/mdbook.md +256 -0
  184. package/skills/blume-migrate/references/mintlify.md +5 -5
  185. package/skills/blume-migrate/references/mkdocs.md +236 -0
  186. package/skills/blume-migrate/references/monorepo.md +20 -8
  187. package/skills/blume-migrate/references/nextra.md +2 -2
  188. package/skills/blume-migrate/references/readme.md +257 -0
  189. package/skills/blume-migrate/references/redocly.md +198 -0
  190. package/skills/blume-migrate/references/starlight.md +3 -3
  191. package/skills/blume-migrate/references/vitepress.md +204 -0
  192. package/skills/blume-migrate/references/vuepress.md +271 -0
  193. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  194. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  195. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  196. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  197. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  198. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  199. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  200. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  201. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  202. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  203. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  204. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  205. package/src/ai/changelog-components.ts +24 -0
  206. package/src/ai/changelog-markdown.ts +45 -41
  207. package/src/ai/component-markdown.ts +22 -9
  208. package/src/ai/llms.ts +36 -5
  209. package/src/ai/markdown.ts +2 -2
  210. package/src/ai/openapi-components.ts +25 -9
  211. package/src/ai/operation-details.ts +208 -0
  212. package/src/ai/relative-links.ts +48 -8
  213. package/src/ai/serializers.ts +7 -6
  214. package/src/ai/site-skill.ts +6 -2
  215. package/src/analytics/head.ts +11 -5
  216. package/src/analytics/script.ts +13 -3
  217. package/src/astro/generate.ts +32 -6
  218. package/src/astro/index.ts +1 -0
  219. package/src/astro/integration.ts +27 -1
  220. package/src/astro/mdx-source-errors.ts +46 -0
  221. package/src/astro/templates.ts +89 -185
  222. package/src/audit/checks/redirects.ts +5 -1
  223. package/src/audit/redirects.ts +36 -19
  224. package/src/audit/report.ts +22 -9
  225. package/src/audit/run.ts +12 -28
  226. package/src/audit/terms.ts +23 -0
  227. package/src/audit/url.ts +2 -2
  228. package/src/cli/build-failure.ts +256 -7
  229. package/src/cli/command-meta.ts +1 -1
  230. package/src/cli/commands/audit.ts +1 -1
  231. package/src/cli/commands/build.ts +12 -3
  232. package/src/cli/commands/check.ts +13 -2
  233. package/src/cli/commands/dev.ts +1 -1
  234. package/src/cli/commands/doctor.ts +4 -2
  235. package/src/cli/commands/init.ts +3 -3
  236. package/src/cli/commands/preview.ts +17 -1
  237. package/src/cli/commands/validate.ts +22 -10
  238. package/src/cli/prepare.ts +1 -1
  239. package/src/cli/preview-pages.ts +161 -0
  240. package/src/cli/unknown-flags.ts +1 -42
  241. package/src/components/code-copy.ts +51 -0
  242. package/src/components/code-language.ts +75 -0
  243. package/src/components/content/AccordionItem.astro +16 -1
  244. package/src/components/content/Callout.astro +11 -1
  245. package/src/components/content/Changelog.astro +189 -0
  246. package/src/components/content/CodeBlock.astro +8 -11
  247. package/src/components/content/Tabs.astro +16 -2
  248. package/src/components/content/YouTube.astro +6 -4
  249. package/src/components/content/code-block.ts +33 -0
  250. package/src/components/content/content-strings.ts +5 -1
  251. package/src/components/content/inline-markdown.ts +35 -8
  252. package/src/components/content/youtube.ts +49 -7
  253. package/src/components/layout/Analytics.astro +5 -1
  254. package/src/components/layout/NavTree.astro +8 -3
  255. package/src/components/layout/RootLayout.astro +18 -31
  256. package/src/components/layout/nav-utils.ts +14 -0
  257. package/src/components/layout/search/pagefind.ts +2 -1
  258. package/src/components/openapi/Operation.astro +4 -0
  259. package/src/components/openapi/PanelTabs.astro +9 -3
  260. package/src/components/openapi/RequestBody.astro +42 -8
  261. package/src/components/openapi/RequestPanel.astro +34 -53
  262. package/src/components/openapi/example-panels.ts +110 -0
  263. package/src/components/openapi/helpers.ts +195 -20
  264. package/src/components/openapi/operation-model.ts +63 -26
  265. package/src/components/openapi/request.ts +13 -0
  266. package/src/components/openapi/snippets.ts +14 -2
  267. package/src/core/alert-diagnostics.ts +89 -0
  268. package/src/core/builtin-tags.ts +76 -0
  269. package/src/core/changelog-list.ts +87 -0
  270. package/src/core/closest-match.ts +41 -0
  271. package/src/core/code-fence-diagnostics.ts +267 -0
  272. package/src/core/code-fences.ts +43 -7
  273. package/src/core/component-diagnostics.ts +65 -12
  274. package/src/core/config-input.ts +1 -1
  275. package/src/core/config.ts +46 -1
  276. package/src/core/content-assets.ts +64 -144
  277. package/src/core/diagnostics.ts +36 -3
  278. package/src/core/directive-diagnostics.ts +265 -47
  279. package/src/core/former-routes.ts +46 -0
  280. package/src/core/git-ignored.ts +53 -0
  281. package/src/core/github.ts +5 -4
  282. package/src/core/heading-markers.ts +156 -2
  283. package/src/core/hidden-pages.ts +64 -0
  284. package/src/core/includes.ts +181 -41
  285. package/src/core/last-modified.ts +47 -10
  286. package/src/core/links.ts +438 -83
  287. package/src/core/load-module.ts +98 -9
  288. package/src/core/manifest.ts +12 -9
  289. package/src/core/markdown-mirrors.ts +52 -0
  290. package/src/core/mdx-elements.ts +153 -0
  291. package/src/core/mdx-syntax.ts +377 -0
  292. package/src/core/meta-diagnostics.ts +134 -0
  293. package/src/core/meta.ts +154 -55
  294. package/src/core/nav-diagnostics.ts +16 -4
  295. package/src/core/navigation.ts +70 -10
  296. package/src/core/ordering-prefix.ts +22 -2
  297. package/src/core/pinned-ids.ts +141 -0
  298. package/src/core/project-graph.ts +221 -29
  299. package/src/core/redirect-patterns.ts +41 -0
  300. package/src/core/relative-files.ts +145 -0
  301. package/src/core/schema.ts +17 -3
  302. package/src/core/sources/mdx-remote.ts +75 -14
  303. package/src/core/sources/normalize.ts +885 -179
  304. package/src/core/sources/notion.ts +5 -4
  305. package/src/core/sources/obsidian.ts +2 -5
  306. package/src/core/sources/types.ts +17 -0
  307. package/src/core/static-files.ts +40 -0
  308. package/src/core/static-host.ts +125 -0
  309. package/src/core/svg-images.ts +98 -0
  310. package/src/core/syntax-diagnostics.ts +491 -0
  311. package/src/core/types.ts +43 -1
  312. package/src/deploy/artifacts.ts +2 -28
  313. package/src/deploy/generated-files.ts +128 -0
  314. package/src/deploy/platforms/vercel.ts +25 -1
  315. package/src/deploy/redirects.ts +43 -16
  316. package/src/deploy/sitemap.ts +2 -1
  317. package/src/markdown/base-links.ts +87 -6
  318. package/src/markdown/code-title.ts +29 -13
  319. package/src/markdown/content-assets.ts +210 -0
  320. package/src/markdown/directives.ts +69 -5
  321. package/src/markdown/fence-language.ts +106 -0
  322. package/src/markdown/fence-meta.ts +42 -0
  323. package/src/markdown/github-alerts.ts +172 -0
  324. package/src/markdown/heading-anchors.ts +265 -56
  325. package/src/markdown/include.ts +30 -3
  326. package/src/markdown/index.ts +145 -40
  327. package/src/markdown/prompt-text.ts +95 -0
  328. package/src/markdown/relative-links.ts +37 -2
  329. package/src/markdown/route-snapshot.ts +22 -12
  330. package/src/markdown/svg-fallback.ts +51 -0
  331. package/src/markdown/title-heading.ts +134 -0
  332. package/src/migrate/migrate.ts +127 -2
  333. package/src/og/card.ts +14 -4
  334. package/src/openapi/asyncapi.ts +14 -6
  335. package/src/openapi/checks.ts +160 -15
  336. package/src/openapi/model.ts +172 -33
  337. package/src/openapi/parse.ts +13 -4
  338. package/src/openapi/render-mdx.ts +55 -9
  339. package/src/openapi/source.ts +87 -6
  340. package/src/registry/eject.ts +0 -1
  341. package/src/search/documents.ts +10 -2
  342. package/src/sources/filesystem.ts +28 -3
  343. package/src/sources/mdx-remote.ts +7 -15
  344. package/src/theme/color.ts +14 -0
  345. package/src/theme/entry.ts +22 -1
  346. package/src/theme/palette.ts +74 -1
  347. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  348. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  349. package/dist/cli/chunk-150jk7km.js +0 -52
  350. package/dist/cli/chunk-150jk7km.js.map +0 -10
  351. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  352. package/dist/cli/chunk-53jqw8z0.js +0 -89
  353. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  354. package/dist/cli/chunk-60hz265r.js.map +0 -10
  355. package/dist/cli/chunk-desvm72z.js.map +0 -14
  356. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  357. package/dist/cli/chunk-es8h0v3j.js +0 -441
  358. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  359. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  360. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  361. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  362. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  363. package/dist/cli/chunk-ydp3065p.js +0 -64
  364. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  365. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-05f21r8h.js.map} +0 -0
  366. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-1cm0h819.js.map} +0 -0
  367. /package/dist/cli/{chunk-ms9z66nz.js.map → chunk-4rftmttv.js.map} +0 -0
  368. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-7tekknev.js.map} +0 -0
  369. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-ahc5gc9h.js.map} +0 -0
  370. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-bjk964fp.js.map} +0 -0
  371. /package/dist/cli/{chunk-dqhg54mr.js.map → chunk-dmx6cezx.js.map} +0 -0
  372. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-m9bp2hc1.js.map} +0 -0
  373. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-mrvh4c88.js.map} +0 -0
  374. /package/dist/cli/{chunk-1f882900.js.map → chunk-th0pgaj5.js.map} +0 -0
  375. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-vnbvhgmd.js.map} +0 -0
  376. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-w6e455pz.js.map} +0 -0
  377. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-xgq4eytj.js.map} +0 -0
@@ -0,0 +1,3649 @@
1
+ #!/usr/bin/env node
2
+ // jekyll-codemod.mjs — the mechanical part of a Jekyll (Just the Docs) →
3
+ // Blume migration. Run it from the Jekyll source directory (the folder that
4
+ // holds `_config.yml`, which becomes Blume's `content.root: "."`), after
5
+ // building the old site. It rewrites pages and Markdown partials only where
6
+ // the mapping is exact, and reports everything it leaves:
7
+ //
8
+ // - Kramdown block IALs: a callout name declared under `callouts:` in
9
+ // `_config.yml` (`{: .note }` before or after a paragraph or blockquote,
10
+ // `{: .note-title }` with its title paragraph, nested ones) → `:::type`
11
+ // directives. A name `callouts:` doesn't declare, or a malformed IAL,
12
+ // rendered a plain paragraph, so the IAL is dropped. `.label` →
13
+ // `<Badge>`, `.no_toc` on a heading → `[!toc]`, `{#id}`/`{: #id }` on a
14
+ // heading → `[#id]`, and Just the Docs' utility and button classes go.
15
+ // - The in-page TOC (`1. TOC` + `{:toc}`, the `<details>` around it, its
16
+ // "Table of contents" heading, an include of a partial that holds only a
17
+ // TOC) is deleted, and so are `{::comment}` blocks and ALDs.
18
+ // - The body `# H1` → front matter `title` (a different old `title` becomes
19
+ // `sidebar.label` and `seo.title`); Just the Docs' navigation keys,
20
+ // `layout`, `permalink`, and the redirect keys are mapped or removed.
21
+ // - Liquid: `{% include x.md k="v" %}` (and `include_relative`,
22
+ // `include_cached`) → `<include k="v">/_includes/x.md</include>`, with
23
+ // `{{ include.k }}` → `{{k}}` in the partial; `{{ site.x }}` (a plain
24
+ // value in `_config.yml`) → the `{{x}}` variable; `{{ site.baseurl }}`,
25
+ // `relative_url`, `absolute_url`, and `{% link %}` → root-relative routes;
26
+ // `{% highlight %}` → a fence; `{% comment %}` removed; `{% raw %}`
27
+ // unwrapped, its content left exactly as written.
28
+ // - Links: relative links resolved against the page's old URL (a `pretty`
29
+ // page lived at `/a/b/`), `.md` and `.html` links, trailing slashes, and
30
+ // links to the site's own origin → the target's route; relative Markdown
31
+ // images → a path from the page's new location, and a relative raw HTML
32
+ // `src` → a root path (Jekyll resolved it against the page's old URL).
33
+ // - With `--old <built site>`: the sidebar the old build rendered becomes
34
+ // folders and `meta.ts` files. A parent page that's a leaf file moves into
35
+ // a folder of its own (a `(name)` group folder when its children sit
36
+ // beside it), a child elsewhere moves under its parent, and a `slug` pins
37
+ // every URL a move would change. Each section's duplicate index row is
38
+ // hidden when the titles allow it, pages outside the nav are hidden (a
39
+ // hidden home page still stays in search, the sitemap, and llms.txt), a
40
+ // parent that showed Just the Docs' child list gets
41
+ // `directory: "accordion"`, and a folder the nav never showed gets
42
+ // `collapsed: false`: Blume opens a group that's alone at the top of the
43
+ // sidebar by itself, but not one beside the home page's row or a section.
44
+ // - A page that now needs MDX (a directive, a component, `$$` math, an
45
+ // indented include, or a partial that does) is renamed `.mdx`, with void
46
+ // tags self-closed, HTML comments as `{/* */}`, autolinks as links, and
47
+ // page scripts and styles removed, inline code left as written. Partials
48
+ // such a page includes get the same treatment.
49
+ // - `redirect_from`, `redirect_to`, `.html` URLs, and the variables go to
50
+ // `jekyll-migration.json`, for `blume.config.ts` to import.
51
+ //
52
+ // It never changes the content of `{% raw %}` blocks or fenced code (except
53
+ // the Liquid Jekyll rendered there too), never moves a folder, and
54
+ // reports HTML includes, Liquid logic, unknown front matter, MDX hazards,
55
+ // custom CSS that hides or adds content, and theme hook files.
56
+ //
57
+ // Design constraints (shared with the other blume-migrate codemods):
58
+ // - ZERO dependencies: runs with a bare `node`.
59
+ // - Deterministic: files are processed in sorted order.
60
+ // - Runs once: `--write` always writes `jekyll-migration.json` and refuses a
61
+ // folder that has one, since a second pass would take a page's next H1
62
+ // as its title and convert what `{% raw %}` protected the first time.
63
+ // - Reports every change and every finding, so nothing is silent.
64
+ //
65
+ // Usage, from the Jekyll source directory:
66
+ // node jekyll-codemod.mjs --old <built site> . # dry run
67
+ // node jekyll-codemod.mjs --old <built site> --write . # apply
68
+ // node jekyll-codemod.mjs --old <built site> --json . # JSON report
69
+ //
70
+ // `--old` is the Jekyll build (`bundle exec jekyll build -d <dir>`). Without
71
+ // it, URLs come from the permalink rules alone and the navigation is left
72
+ // for you. `--callouts note,warning` names the callout classes of a site whose
73
+ // `_config.yml` has no `callouts:` (it styled them in `_sass/custom`).
74
+
75
+ import {
76
+ existsSync,
77
+ mkdirSync,
78
+ readdirSync,
79
+ readFileSync,
80
+ rmSync,
81
+ statSync,
82
+ writeFileSync,
83
+ } from "node:fs";
84
+ import path from "node:path";
85
+
86
+ // --- Tables -------------------------------------------------------------------
87
+
88
+ // Just the Docs callout names → the Blume directive to write. Blume reads
89
+ // `caution`, `error`, `important`, and `warn` as aliases, so they stay.
90
+ const CALLOUT_NAMES = new Map([
91
+ ["attention", "warning"],
92
+ ["caution", "caution"],
93
+ ["danger", "danger"],
94
+ ["error", "error"],
95
+ ["highlight", "info"],
96
+ ["hint", "tip"],
97
+ ["important", "important"],
98
+ ["info", "info"],
99
+ ["new", "success"],
100
+ ["note", "note"],
101
+ ["success", "success"],
102
+ ["tip", "tip"],
103
+ ["warn", "warn"],
104
+ ["warning", "warning"],
105
+ ]);
106
+
107
+ // A callout name Blume doesn't know maps by the color `_config.yml` gave it.
108
+ const CALLOUT_COLORS = new Map([
109
+ ["blue", "info"],
110
+ ["green", "success"],
111
+ ["grey-dk", "note"],
112
+ ["grey-lt", "note"],
113
+ ["purple", "note"],
114
+ ["red", "danger"],
115
+ ["yellow", "warning"],
116
+ ]);
117
+
118
+ // `.label-<color>` → `<Badge color>`; a bare `.label` is blue.
119
+ const LABEL_COLORS = new Set(["blue", "green", "purple", "red", "yellow"]);
120
+
121
+ // Just the Docs' layout and typography utilities: dropping them keeps the
122
+ // content.
123
+ const UTILITY_CLASS =
124
+ /^(?:text-delta|fs-\d+|fw-\d+|lh-[\w-]+|d-[\w-]+|text-[\w-]+|m[trblxy]?-[\w-]+|p[trblxy]?-[\w-]+|float-[\w-]+|v-align-[\w-]+|bg-[\w-]+|flex-[\w-]+|opaque|lead|no_toc)$/u;
125
+
126
+ // Front matter keys Just the Docs reads for its navigation.
127
+ const NAV_KEYS = new Set([
128
+ "ancestor",
129
+ "child_nav_order",
130
+ "grand_parent",
131
+ "has_children",
132
+ "has_toc",
133
+ "nav_exclude",
134
+ "nav_fold",
135
+ "nav_order",
136
+ "parent",
137
+ ]);
138
+
139
+ // Layouts that only frame a page: a page using one needs no `mode`.
140
+ const PLAIN_LAYOUTS = new Set(["about", "default", "home", "page", "post"]);
141
+
142
+ // Jekyll keys this codemod reads and maps (or drops) itself.
143
+ const HANDLED_KEYS = new Set([
144
+ ...NAV_KEYS,
145
+ "canonical_url",
146
+ "description",
147
+ "image",
148
+ "last_modified_date",
149
+ "layout",
150
+ "nav_enabled",
151
+ "permalink",
152
+ "published",
153
+ "redirect_from",
154
+ "redirect_to",
155
+ "search",
156
+ "search_exclude",
157
+ "seo",
158
+ "sidebar",
159
+ "summary",
160
+ "title",
161
+ ]);
162
+
163
+ // Blume's page front matter keys (its schema is strict).
164
+ const BLUME_KEYS = new Set([
165
+ "ai",
166
+ "api",
167
+ "authMethod",
168
+ "authors",
169
+ "changelog",
170
+ "date",
171
+ "deprecated",
172
+ "description",
173
+ "draft",
174
+ "hidden",
175
+ "icon",
176
+ "lastModified",
177
+ "mode",
178
+ "narration",
179
+ "noindex",
180
+ "pagination",
181
+ "playground",
182
+ "related",
183
+ "search",
184
+ "seo",
185
+ "sidebar",
186
+ "slug",
187
+ "title",
188
+ "type",
189
+ ]);
190
+
191
+ const FRONTMATTER_ORDER = [
192
+ "title",
193
+ "description",
194
+ "slug",
195
+ "sidebar",
196
+ "seo",
197
+ "search",
198
+ "lastModified",
199
+ "draft",
200
+ "mode",
201
+ ];
202
+
203
+ const VOID_TAGS =
204
+ "area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr";
205
+
206
+ // Words a folder name spells in lowercase that Blume capitalizes its own way
207
+ // when it labels a group (core/navigation.ts): a `meta.ts` title is written
208
+ // only when the label differs from Blume's.
209
+ const WORD_FORMS = new Map([
210
+ ["ai", "AI"],
211
+ ["api", "API"],
212
+ ["apis", "APIs"],
213
+ ["asyncapi", "AsyncAPI"],
214
+ ["cli", "CLI"],
215
+ ["css", "CSS"],
216
+ ["faq", "FAQ"],
217
+ ["faqs", "FAQs"],
218
+ ["graphql", "GraphQL"],
219
+ ["html", "HTML"],
220
+ ["http", "HTTP"],
221
+ ["https", "HTTPS"],
222
+ ["id", "ID"],
223
+ ["ids", "IDs"],
224
+ ["ios", "iOS"],
225
+ ["js", "JS"],
226
+ ["json", "JSON"],
227
+ ["jwt", "JWT"],
228
+ ["llm", "LLM"],
229
+ ["llms", "LLMs"],
230
+ ["macos", "macOS"],
231
+ ["mcp", "MCP"],
232
+ ["oauth", "OAuth"],
233
+ ["openapi", "OpenAPI"],
234
+ ["rss", "RSS"],
235
+ ["sdk", "SDK"],
236
+ ["sdks", "SDKs"],
237
+ ["seo", "SEO"],
238
+ ["sql", "SQL"],
239
+ ["sso", "SSO"],
240
+ ["ts", "TS"],
241
+ ["ui", "UI"],
242
+ ["url", "URL"],
243
+ ["urls", "URLs"],
244
+ ["xml", "XML"],
245
+ ["yaml", "YAML"],
246
+ ]);
247
+
248
+ const META_HEADER =
249
+ "// Generated by jekyll-codemod.mjs from the old site's sidebar.";
250
+
251
+ const BOM = String.fromCodePoint(0xfe_ff);
252
+
253
+ // `{` `}` `%` inside `{% raw %}` blocks are swapped for private-use characters
254
+ // until the end, so no pass reads them as Liquid or as an IAL.
255
+ const RAW_CHARS = new Map([
256
+ ["%", String.fromCodePoint(0xe0_02)],
257
+ ["{", String.fromCodePoint(0xe0_00)],
258
+ ["}", String.fromCodePoint(0xe0_01)],
259
+ ]);
260
+ const RAW_PLACEHOLDERS = [...RAW_CHARS.values()];
261
+ const hasRawPlaceholder = (text) =>
262
+ RAW_PLACEHOLDERS.some((char) => text.includes(char));
263
+
264
+ // --- Values -------------------------------------------------------------------
265
+
266
+ // Values come from the YAML reader below: strings, numbers, booleans, null,
267
+ // arrays, and plain objects.
268
+ const isRecord = (value) =>
269
+ value !== null &&
270
+ value !== undefined &&
271
+ !Array.isArray(value) &&
272
+ Object.getPrototypeOf(value) === Object.prototype;
273
+ const textOf = (value) => {
274
+ if (
275
+ value === null ||
276
+ value === undefined ||
277
+ value === true ||
278
+ value === false ||
279
+ Array.isArray(value) ||
280
+ isRecord(value)
281
+ ) {
282
+ return;
283
+ }
284
+ const text = String(value).trim();
285
+ return text === "" ? undefined : text;
286
+ };
287
+ const recordOf = (value) => (isRecord(value) ? value : {});
288
+ const listOf = (value) => {
289
+ if (Array.isArray(value)) {
290
+ return value;
291
+ }
292
+ return value === null || value === undefined ? [] : [value];
293
+ };
294
+
295
+ // A replace() callback that reads named groups.
296
+ const byGroups =
297
+ (read) =>
298
+ (...args) =>
299
+ read(args.at(-1), args[0]);
300
+
301
+ /** A file's text without a BOM, with LF line endings. */
302
+ const normalizeText = (text) =>
303
+ (text.startsWith(BOM) ? text.slice(1) : text).replaceAll("\r\n", "\n");
304
+
305
+ const parentOf = (rel) => {
306
+ const dir = path.posix.dirname(rel);
307
+ return dir === "." ? "" : dir;
308
+ };
309
+
310
+ const safeDecode = (value) => {
311
+ try {
312
+ return decodeURI(value);
313
+ } catch {
314
+ return value;
315
+ }
316
+ };
317
+
318
+ // --- Minimal YAML (the subset `_config.yml` and front matter use) -------------
319
+
320
+ class YamlError extends Error {
321
+ constructor(message) {
322
+ super(message);
323
+ this.name = "YamlError";
324
+ }
325
+ }
326
+
327
+ const indentOf = (line) => line.match(/^ */u)[0].length;
328
+ const isSkippable = (line) => /^\s*(?:#.*)?$/u.test(line);
329
+
330
+ const ESCAPES = new Map([
331
+ [" ", " "],
332
+ ['"', '"'],
333
+ ["/", "/"],
334
+ ["0", String.fromCodePoint(0)],
335
+ ["L", String.fromCodePoint(0x20_28)],
336
+ ["N", String.fromCodePoint(0x85)],
337
+ ["P", String.fromCodePoint(0x20_29)],
338
+ ["\\", "\\"],
339
+ ["_", String.fromCodePoint(0xa0)],
340
+ ["a", String.fromCodePoint(7)],
341
+ ["b", "\b"],
342
+ ["e", String.fromCodePoint(0x1b)],
343
+ ["f", "\f"],
344
+ ["n", "\n"],
345
+ ["r", "\r"],
346
+ ["t", "\t"],
347
+ ["v", "\v"],
348
+ ]);
349
+
350
+ const unescapeDouble = (body) =>
351
+ body.replaceAll(
352
+ /\\(?:x(?<x>[0-9a-fA-F]{2})|u(?<u>[0-9a-fA-F]{4})|U(?<big>[0-9a-fA-F]{8})|(?<char>.))/gu,
353
+ byGroups(({ big, char, u, x }) => {
354
+ const hex = x ?? u ?? big;
355
+ if (hex) {
356
+ return String.fromCodePoint(Number.parseInt(hex, 16));
357
+ }
358
+ if (!ESCAPES.has(char)) {
359
+ throw new YamlError(`unknown escape \\${char}`);
360
+ }
361
+ return ESCAPES.get(char);
362
+ })
363
+ );
364
+
365
+ const splitFlow = (inner) => {
366
+ const items = [];
367
+ let current = "";
368
+ let quote = null;
369
+ for (const char of inner) {
370
+ if (quote) {
371
+ current += char;
372
+ if (char === quote) {
373
+ quote = null;
374
+ }
375
+ } else if (char === '"' || char === "'") {
376
+ quote = char;
377
+ current += char;
378
+ } else if (char === "[" || char === "{") {
379
+ throw new YamlError("nested flow collections");
380
+ } else if (char === ",") {
381
+ items.push(current.trim());
382
+ current = "";
383
+ } else {
384
+ current += char;
385
+ }
386
+ }
387
+ if (current.trim() !== "") {
388
+ items.push(current.trim());
389
+ }
390
+ return items;
391
+ };
392
+
393
+ const parseQuoted = (text) => {
394
+ if (text.startsWith('"')) {
395
+ const match = /^"(?<inner>(?:[^"\\]|\\.)*)"\s*(?:#.*)?$/u.exec(text);
396
+ if (!match) {
397
+ throw new YamlError("unterminated double-quoted string");
398
+ }
399
+ return unescapeDouble(match.groups.inner);
400
+ }
401
+ const match = /^'(?<inner>(?:[^']|'')*)'\s*(?:#.*)?$/u.exec(text);
402
+ if (!match) {
403
+ throw new YamlError("unterminated single-quoted string");
404
+ }
405
+ return match.groups.inner.replaceAll("''", "'");
406
+ };
407
+
408
+ const parsePlain = (text) => {
409
+ const plain = text.replace(/\s+#.*$/u, "");
410
+ if (/^(?:true|True|TRUE)$/u.test(plain)) {
411
+ return true;
412
+ }
413
+ if (/^(?:false|False|FALSE)$/u.test(plain)) {
414
+ return false;
415
+ }
416
+ if (
417
+ /^[-+]?(?:0|[1-9]\d*)$/u.test(plain) ||
418
+ /^[-+]?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$/u.test(plain)
419
+ ) {
420
+ return Number(plain);
421
+ }
422
+ return plain;
423
+ };
424
+
425
+ const parseScalar = (raw) => {
426
+ const text = raw.trim();
427
+ if (text === "" || text === "~" || /^null$/iu.test(text)) {
428
+ return null;
429
+ }
430
+ if (/^[&*!|>]/u.test(text)) {
431
+ throw new YamlError(`unsupported value ${text}`);
432
+ }
433
+ if (text.startsWith('"') || text.startsWith("'")) {
434
+ return parseQuoted(text);
435
+ }
436
+ if (text.startsWith("[")) {
437
+ if (!text.endsWith("]")) {
438
+ throw new YamlError("unterminated flow sequence");
439
+ }
440
+ return splitFlow(text.slice(1, -1)).map(parseScalar);
441
+ }
442
+ if (text.startsWith("{")) {
443
+ if (/^\{\s*\}$/u.test(text)) {
444
+ return {};
445
+ }
446
+ throw new YamlError("flow mappings");
447
+ }
448
+ return parsePlain(text);
449
+ };
450
+
451
+ const foldLines = (content) => {
452
+ let value = "";
453
+ let previous = null;
454
+ for (const line of content) {
455
+ if (line === "") {
456
+ value += "\n";
457
+ } else if (previous === null || previous === "") {
458
+ value += line;
459
+ } else if (line.startsWith(" ") || previous.startsWith(" ")) {
460
+ value += `\n${line}`;
461
+ } else {
462
+ value += ` ${line}`;
463
+ }
464
+ previous = line;
465
+ }
466
+ return value;
467
+ };
468
+
469
+ const chomp = (value, indicator) => {
470
+ const body = value.replace(/\n+$/u, "");
471
+ if (indicator === "-") {
472
+ return body;
473
+ }
474
+ if (indicator === "+") {
475
+ return `${body}${value.slice(body.length)}\n`;
476
+ }
477
+ return body === "" ? "" : `${body}\n`;
478
+ };
479
+
480
+ const parseBlockScalar = (lines, start, header, parentIndent) => {
481
+ const match = /^(?<style>[|>])(?<indicator>[-+]?)$/u.exec(header);
482
+ if (!match) {
483
+ throw new YamlError(`unsupported block scalar header ${header}`);
484
+ }
485
+ let end = start;
486
+ while (
487
+ end < lines.length &&
488
+ (lines[end].trim() === "" || indentOf(lines[end]) > parentIndent)
489
+ ) {
490
+ end += 1;
491
+ }
492
+ const block = lines.slice(start, end);
493
+ const first = block.find((line) => line.trim() !== "");
494
+ const indent = first === undefined ? 0 : indentOf(first);
495
+ const content = block.map((line) =>
496
+ line.trim() === "" ? "" : line.slice(indent)
497
+ );
498
+ const value =
499
+ match.groups.style === "|" ? content.join("\n") : foldLines(content);
500
+ return [chomp(value, match.groups.indicator), end];
501
+ };
502
+
503
+ const KEY_LINE =
504
+ /^(?:"(?<dq>(?:[^"\\]|\\.)*)"|'(?<sq>(?:[^']|'')*)'|(?<plain>[^\s"'#\-?:,[\]{}][^#]*?|-[^\s#][^#]*?))\s*:(?:\s+(?<rest>.*))?$/u;
505
+
506
+ const nextContent = (lines, index) => {
507
+ let cursor = index;
508
+ while (cursor < lines.length && isSkippable(lines[cursor])) {
509
+ cursor += 1;
510
+ }
511
+ return cursor;
512
+ };
513
+
514
+ const isSequenceLine = (line, indent) =>
515
+ indentOf(line) === indent && /^-(?:\s|$)/u.test(line.slice(indent));
516
+
517
+ const keyOf = ({ dq, plain, sq }) => {
518
+ if (dq !== undefined) {
519
+ return unescapeDouble(dq);
520
+ }
521
+ if (sq !== undefined) {
522
+ return sq.replaceAll("''", "'");
523
+ }
524
+ return plain;
525
+ };
526
+
527
+ // One `key: value` line; `parseBlock` reads a nested block.
528
+ const parseEntry = (lines, index, indent, parseBlock) => {
529
+ const content = lines[index].slice(indent);
530
+ const match = KEY_LINE.exec(content);
531
+ if (!match) {
532
+ throw new YamlError(`can't read line ${index + 1}: ${content}`);
533
+ }
534
+ const key = keyOf(match.groups);
535
+ const value = (match.groups.rest ?? "").trim();
536
+ if (value === "" || value.startsWith("#")) {
537
+ const next = nextContent(lines, index + 1);
538
+ const nested =
539
+ next < lines.length &&
540
+ (indentOf(lines[next]) > indent || isSequenceLine(lines[next], indent));
541
+ return nested ? [key, ...parseBlock(next, indent)] : [key, null, index + 1];
542
+ }
543
+ if (/^[|>][-+]?(?:\s+#.*)?$/u.test(value)) {
544
+ const header = value.replace(/\s+#.*$/u, "");
545
+ return [key, ...parseBlockScalar(lines, index + 1, header, indent)];
546
+ }
547
+ const next = nextContent(lines, index + 1);
548
+ if (next < lines.length && indentOf(lines[next]) > indent) {
549
+ throw new YamlError(`multi-line value on line ${index + 1}`);
550
+ }
551
+ return [key, parseScalar(value), index + 1];
552
+ };
553
+
554
+ const parseMapping = (lines, start, indent, parseBlock) => {
555
+ const object = {};
556
+ let index = start;
557
+ for (;;) {
558
+ index = nextContent(lines, index);
559
+ if (index >= lines.length || indentOf(lines[index]) < indent) {
560
+ return [object, index];
561
+ }
562
+ if (indentOf(lines[index]) > indent) {
563
+ throw new YamlError(`unexpected indentation on line ${index + 1}`);
564
+ }
565
+ if (/^-(?:\s|$)/u.test(lines[index].slice(indent))) {
566
+ return [object, index];
567
+ }
568
+ const [key, value, next] = parseEntry(lines, index, indent, parseBlock);
569
+ object[key] = value;
570
+ index = next;
571
+ }
572
+ };
573
+
574
+ const parseSequence = (lines, start, indent, parseBlock) => {
575
+ const array = [];
576
+ let index = start;
577
+ for (;;) {
578
+ index = nextContent(lines, index);
579
+ if (index >= lines.length || !isSequenceLine(lines[index], indent)) {
580
+ return [array, index];
581
+ }
582
+ const after = lines[index].slice(indent + 1);
583
+ const rest = after.trimStart();
584
+ let value;
585
+ if (rest === "" || rest.startsWith("#")) {
586
+ [value, index] = parseBlock(index + 1, indent + 1);
587
+ } else if (KEY_LINE.test(rest) && !/^["'[{]/u.test(rest)) {
588
+ // `- key: value` starts a mapping at the key's column.
589
+ const itemIndent = indent + 1 + (after.length - rest.length);
590
+ lines[index] = `${" ".repeat(itemIndent)}${rest}`;
591
+ [value, index] = parseMapping(lines, index, itemIndent, parseBlock);
592
+ } else {
593
+ value = parseScalar(rest);
594
+ index += 1;
595
+ }
596
+ array.push(value);
597
+ }
598
+ };
599
+
600
+ const parseYaml = (text) => {
601
+ if (/^\t|\n\t/u.test(text)) {
602
+ throw new YamlError("tab indentation");
603
+ }
604
+ const lines = text.split("\n");
605
+ const parseBlock = (start, minIndent) => {
606
+ const index = nextContent(lines, start);
607
+ if (index >= lines.length || indentOf(lines[index]) < minIndent) {
608
+ return [null, index];
609
+ }
610
+ const indent = indentOf(lines[index]);
611
+ return isSequenceLine(lines[index], indent)
612
+ ? parseSequence(lines, index, indent, parseBlock)
613
+ : parseMapping(lines, index, indent, parseBlock);
614
+ };
615
+ const [value, end] = parseBlock(0, 0);
616
+ if (nextContent(lines, end) < lines.length) {
617
+ throw new YamlError(`can't read line ${end + 1}`);
618
+ }
619
+ return value ?? {};
620
+ };
621
+
622
+ const plainSafe = (text) =>
623
+ text !== "" &&
624
+ !/^\s|\s$/u.test(text) &&
625
+ !/^[-?:,[\]{}#&*!|>'"%@`]/u.test(text) &&
626
+ !/:(?:\s|$)|\s#|[\n\r\t\\]/u.test(text) &&
627
+ !/^(?:true|false|null|~|yes|no|on|off|y|n)$/iu.test(text) &&
628
+ !/^[-+]?(?:\d|\.\d)/u.test(text);
629
+
630
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/u;
631
+
632
+ const yamlKey = (key) =>
633
+ /^[A-Za-z_][\w-]*$/u.test(key) ? key : JSON.stringify(key);
634
+
635
+ const yamlScalar = (value, key) => {
636
+ if (value === null) {
637
+ return "null";
638
+ }
639
+ if (value === true || value === false || Number.isFinite(value)) {
640
+ return String(value);
641
+ }
642
+ const text = String(value);
643
+ if ((key === "date" || key === "lastModified") && DATE_ONLY.test(text)) {
644
+ return text;
645
+ }
646
+ return plainSafe(text) ? text : JSON.stringify(text);
647
+ };
648
+
649
+ const emitYaml = (value, indent = 0) => {
650
+ const pad = " ".repeat(indent);
651
+ const lines = [];
652
+ for (const [key, entry] of Object.entries(value)) {
653
+ if (entry === undefined) {
654
+ continue;
655
+ }
656
+ if (Array.isArray(entry) && entry.length === 0) {
657
+ lines.push(`${pad}${yamlKey(key)}: []`);
658
+ } else if (Array.isArray(entry)) {
659
+ lines.push(`${pad}${yamlKey(key)}:`);
660
+ for (const item of entry) {
661
+ if (isRecord(item)) {
662
+ const [first, ...more] = emitYaml(item, indent + 4).split("\n");
663
+ lines.push(`${pad} - ${first.trimStart()}`, ...more);
664
+ } else {
665
+ lines.push(`${pad} - ${yamlScalar(item)}`);
666
+ }
667
+ }
668
+ } else if (isRecord(entry) && Object.keys(entry).length === 0) {
669
+ lines.push(`${pad}${yamlKey(key)}: {}`);
670
+ } else if (isRecord(entry)) {
671
+ lines.push(`${pad}${yamlKey(key)}:`, emitYaml(entry, indent + 2));
672
+ } else {
673
+ lines.push(`${pad}${yamlKey(key)}: ${yamlScalar(entry, key)}`);
674
+ }
675
+ }
676
+ return lines.join("\n");
677
+ };
678
+
679
+ // --- Report ---------------------------------------------------------------------
680
+
681
+ const newReport = () => ({ edits: new Map(), notes: [] });
682
+ const edit = (report, kind, count = 1) => {
683
+ report.edits.set(kind, (report.edits.get(kind) ?? 0) + count);
684
+ };
685
+ const note = (report, message) => {
686
+ if (!report.notes.includes(message)) {
687
+ report.notes.push(message);
688
+ }
689
+ };
690
+
691
+ // --- Config ---------------------------------------------------------------------
692
+
693
+ /** The lines of one top-level key's block, for a config the reader rejects. */
694
+ const topLevelBlock = (text, key) => {
695
+ const lines = text.split("\n");
696
+ const start = lines.findIndex((line) => line.startsWith(`${key}:`));
697
+ if (start === -1) {
698
+ return null;
699
+ }
700
+ let end = start + 1;
701
+ while (end < lines.length && !/^[^\s#]/u.test(lines[end])) {
702
+ end += 1;
703
+ }
704
+ return lines.slice(start, end).join("\n");
705
+ };
706
+
707
+ const CONFIG_KEYS = [
708
+ "baseurl",
709
+ "callouts",
710
+ "collections",
711
+ "exclude",
712
+ "includes_dir",
713
+ "permalink",
714
+ "url",
715
+ ];
716
+
717
+ const readConfig = (root, notes) => {
718
+ const file = path.join(root, "_config.yml");
719
+ if (!existsSync(file)) {
720
+ notes.push(
721
+ "No _config.yml here: run the codemod from the Jekyll source directory."
722
+ );
723
+ return {};
724
+ }
725
+ const text = normalizeText(readFileSync(file, "utf-8"));
726
+ try {
727
+ return recordOf(parseYaml(text));
728
+ } catch (error) {
729
+ notes.push(
730
+ `_config.yml: ${error.message}; only ${CONFIG_KEYS.join(", ")} were read, so {{ site.x }} stays for you.`
731
+ );
732
+ }
733
+ const config = {};
734
+ for (const key of CONFIG_KEYS) {
735
+ const block = topLevelBlock(text, key);
736
+ if (block) {
737
+ try {
738
+ Object.assign(config, recordOf(parseYaml(block)));
739
+ } catch {
740
+ notes.push(`_config.yml: couldn't read \`${key}\`.`);
741
+ }
742
+ }
743
+ }
744
+ config.unreadable = true;
745
+ return config;
746
+ };
747
+
748
+ /** `name → { color, title }` from `callouts:`, or null when it's unset. */
749
+ const calloutConfig = (config) => {
750
+ if (!isRecord(config.callouts)) {
751
+ return null;
752
+ }
753
+ const out = new Map();
754
+ for (const [name, value] of Object.entries(config.callouts)) {
755
+ const entry = recordOf(value);
756
+ out.set(name, { color: textOf(entry.color), title: textOf(entry.title) });
757
+ }
758
+ return out;
759
+ };
760
+
761
+ /** The directive (and title) a configured callout name becomes. */
762
+ const calloutDirective = (name, callouts) => {
763
+ const entry = callouts.get(name);
764
+ const type =
765
+ CALLOUT_NAMES.get(name) ?? CALLOUT_COLORS.get(entry?.color) ?? "note";
766
+ const title =
767
+ entry?.title && entry.title.toLowerCase() !== type
768
+ ? entry.title
769
+ : undefined;
770
+ return { title, type };
771
+ };
772
+
773
+ /** The URL suffix Jekyll gives a non-index page under a permalink style. */
774
+ const permalinkSuffix = (style) => {
775
+ if (style === undefined || ["date", "none", "ordinal"].includes(style)) {
776
+ return ".html";
777
+ }
778
+ if (style === "pretty" || style.endsWith("/")) {
779
+ return "/";
780
+ }
781
+ return style.endsWith(":output_ext") ? ".html" : "";
782
+ };
783
+
784
+ // --- Paths and routes -------------------------------------------------------------
785
+
786
+ const ORDERING_PREFIX = /^(?<order>\d+)[-_.]/u;
787
+ const NOT_AN_ORDER = /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u;
788
+ const GROUP_FOLDER = /^\((?<label>.+)\)$/u;
789
+ const DOC_EXT = /\.(?:md|mdx|markdown)$/u;
790
+
791
+ const stripOrderingPrefix = (name) =>
792
+ NOT_AN_ORDER.test(name) ? name : name.replace(ORDERING_PREFIX, "");
793
+
794
+ const groupLabelOf = (segment) =>
795
+ segment.match(GROUP_FOLDER)?.groups?.label ??
796
+ stripOrderingPrefix(segment).match(GROUP_FOLDER)?.groups?.label;
797
+
798
+ /** The label Blume gives a folder group without a `meta.ts` title. */
799
+ const humanize = (segment) =>
800
+ stripOrderingPrefix(groupLabelOf(segment) ?? segment)
801
+ .split(/[-_]/u)
802
+ .filter(Boolean)
803
+ .map(
804
+ (word) =>
805
+ WORD_FORMS.get(word) ?? word.charAt(0).toUpperCase() + word.slice(1)
806
+ )
807
+ .join(" ");
808
+
809
+ const stripExt = (rel) => rel.replace(DOC_EXT, "");
810
+
811
+ /** The slug a `meta.ts` `pages` array names a child by. */
812
+ const entrySlug = (name) =>
813
+ stripOrderingPrefix(groupLabelOf(name) ?? stripExt(name));
814
+
815
+ /** The route Blume serves a content file at (no `slug`). */
816
+ const blumeRoute = (rel) => {
817
+ const segments = stripExt(rel)
818
+ .split("/")
819
+ .filter((segment) => !groupLabelOf(segment))
820
+ .map(stripOrderingPrefix);
821
+ if (segments.at(-1) === "index") {
822
+ segments.pop();
823
+ }
824
+ return `/${segments.join("/")}`;
825
+ };
826
+
827
+ /** An old URL or path → the slashless route it names. */
828
+ const normalizeRoute = (url) => {
829
+ const route = safeDecode(url.replace(/[?#].*$/u, ""))
830
+ .replace(/\/index\.html?$/u, "/")
831
+ .replace(/\.html?$/u, "")
832
+ .replace(/\/+$/u, "");
833
+ return route === "" ? "/" : route;
834
+ };
835
+
836
+ const encodePath = (value) => encodeURI(value).replaceAll("%25", "%");
837
+
838
+ const isWithin = (dir, rel) => dir === "" || rel.startsWith(`${dir}/`);
839
+
840
+ // --- Files ----------------------------------------------------------------------
841
+
842
+ const ALWAYS_SKIP = new Set([
843
+ "_site",
844
+ "dist",
845
+ "node_modules",
846
+ "public",
847
+ "vendor",
848
+ ]);
849
+
850
+ const globToRegExp = (glob) =>
851
+ new RegExp(
852
+ `^${glob
853
+ .replaceAll(/[.+^${}()|[\]\\]/gu, "\\$&")
854
+ .replaceAll("**", "<GLOBSTAR>")
855
+ .replaceAll("*", "[^/]*")
856
+ .replaceAll("<GLOBSTAR>", ".*")
857
+ .replaceAll("?", "[^/]")}$`,
858
+ "u"
859
+ );
860
+
861
+ const excludedBy = (patterns) => {
862
+ const tests = patterns.map((raw) => {
863
+ const pattern = String(raw).replace(/^\.\//u, "").replace(/\/$/u, "");
864
+ const regex = globToRegExp(pattern);
865
+ return (rel) =>
866
+ rel === pattern || rel.startsWith(`${pattern}/`) || regex.test(rel);
867
+ });
868
+ return (rel) => tests.some((test) => test(rel));
869
+ };
870
+
871
+ const walk = (root, dir, skip) => {
872
+ const out = [];
873
+ for (const name of readdirSync(path.join(root, dir)).toSorted()) {
874
+ const rel = dir ? `${dir}/${name}` : name;
875
+ if (skip(rel, name)) {
876
+ continue;
877
+ }
878
+ if (statSync(path.join(root, rel)).isDirectory()) {
879
+ out.push(...walk(root, rel, skip));
880
+ } else {
881
+ out.push(rel);
882
+ }
883
+ }
884
+ return out;
885
+ };
886
+
887
+ const splitFrontmatter = (text) => {
888
+ const match = /^---[ \t]*\n(?<raw>[\s\S]*?)\n?---[ \t]*(?:\n|$)/u.exec(text);
889
+ return match
890
+ ? { body: text.slice(match[0].length), raw: match.groups.raw }
891
+ : { body: text, raw: null };
892
+ };
893
+
894
+ // --- Old build -----------------------------------------------------------------
895
+
896
+ /**
897
+ * `text` without `pattern`'s matches, removed again until none is left;
898
+ * `removed`, if given, runs once per match.
899
+ */
900
+ const removeAll = (text, pattern, removed) => {
901
+ let out = text;
902
+ let previous;
903
+ do {
904
+ previous = out;
905
+ out = out.replaceAll(pattern, () => {
906
+ removed?.();
907
+ return "";
908
+ });
909
+ } while (out !== previous);
910
+ return out;
911
+ };
912
+
913
+ const decodeEntities = (text) =>
914
+ text
915
+ .replaceAll(
916
+ /&#(?<num>\d+);/gu,
917
+ byGroups(({ num }) => String.fromCodePoint(Number(num)))
918
+ )
919
+ .replaceAll(
920
+ /&#x(?<hex>[0-9a-f]+);/giu,
921
+ byGroups(({ hex }) => String.fromCodePoint(Number.parseInt(hex, 16)))
922
+ )
923
+ .replaceAll("&quot;", '"')
924
+ .replaceAll("&lt;", "<")
925
+ .replaceAll("&gt;", ">")
926
+ .replaceAll("&nbsp;", " ")
927
+ .replaceAll("&amp;", "&");
928
+
929
+ /** The built HTML file that served `url`, if the old build has one. */
930
+ const oldFileFor = (oldDir, url) => {
931
+ const decoded = safeDecode(url.replace(/[?#].*$/u, ""));
932
+ const base = path.join(oldDir, decoded);
933
+ const candidates = decoded.endsWith("/")
934
+ ? [path.join(base, "index.html")]
935
+ : [base, `${base}.html`, path.join(base, "index.html")];
936
+ return candidates.find(
937
+ (candidate) => existsSync(candidate) && statSync(candidate).isFile()
938
+ );
939
+ };
940
+
941
+ /** The URL the old build served a route at: slashed, `.html`, or bare. */
942
+ const builtUrl = (oldDir, route) => {
943
+ const forms = route === "/" ? ["/"] : [`${route}/`, `${route}.html`, route];
944
+ return forms.find((form) => {
945
+ const base = path.join(oldDir, safeDecode(form));
946
+ const file = form.endsWith("/") ? path.join(base, "index.html") : base;
947
+ return existsSync(file) && statSync(file).isFile();
948
+ });
949
+ };
950
+
951
+ /** Just the Docs' sidebar, as the old build rendered it: { title, href, children }. */
952
+ const readOldNav = (oldDir) => {
953
+ for (const name of ["index.html", "404.html"]) {
954
+ const file = path.join(oldDir, name);
955
+ const html = existsSync(file) ? readFileSync(file, "utf-8") : "";
956
+ const start = html.search(/<nav[^>]*id="site-nav"/u);
957
+ if (start !== -1) {
958
+ const nav = html.slice(start, html.indexOf("</nav>", start));
959
+ const root = { children: [] };
960
+ const stack = [];
961
+ let last = root;
962
+ for (const token of nav.matchAll(
963
+ /<ul[^>]*>|<\/ul>|<a href="(?<href>[^"]*)" class="nav-list-link"[^>]*>(?<label>[\s\S]*?)<\/a>/gu
964
+ )) {
965
+ if (token[0].startsWith("<ul")) {
966
+ stack.push(last);
967
+ } else if (token[0] === "</ul>") {
968
+ stack.pop();
969
+ } else {
970
+ last = {
971
+ children: [],
972
+ href: decodeEntities(token.groups.href),
973
+ title: decodeEntities(
974
+ removeAll(token.groups.label, /<[^>]+>/gu)
975
+ ).trim(),
976
+ };
977
+ (stack.at(-1) ?? root).children.push(last);
978
+ }
979
+ }
980
+ return root.children;
981
+ }
982
+ }
983
+ return null;
984
+ };
985
+
986
+ /** The first link of the child list Just the Docs put at the end of a parent page. */
987
+ const oldChildList = (html) => {
988
+ const main =
989
+ html.match(/<main[^>]*>(?<main>[\s\S]*?)<\/main>/u)?.groups?.main ?? html;
990
+ const match =
991
+ /<hr\s*\/?>\s*<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>\s*<ul>\s*<li>\s*<a href="(?<href>[^"]+)"/u.exec(
992
+ main
993
+ );
994
+ return match ? decodeEntities(match.groups.href) : null;
995
+ };
996
+
997
+ // --- Text helpers -----------------------------------------------------------------
998
+
999
+ const isBlank = (line) => line === undefined || line.trim() === "";
1000
+ const FENCE = /^\s*(?<fence>`{3,}|~{3,})/u;
1001
+ const HEADING = /^ {0,3}#{1,6}(?:\s|$)/u;
1002
+ const IAL_LINE = /^ {0,3}\{:(?![:\w-]+:)(?<spec>[^}]*)\}\s*$/u;
1003
+ const ALD_LINE = /^ {0,3}\{:[\w-]+:[^}]*\}\s*$/u;
1004
+ const TOC_IAL = /^\s*\{:\s*toc\s*\}\s*$/u;
1005
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/u;
1006
+ const DIRECTIVE_LINE = /^\s*:{3,}/u;
1007
+
1008
+ /** Which lines are inside fenced code (fences included). */
1009
+ const fenceMask = (lines) => {
1010
+ const mask = [];
1011
+ let open = null;
1012
+ for (const line of lines) {
1013
+ const fence = line.match(FENCE)?.groups?.fence;
1014
+ if (open) {
1015
+ mask.push(true);
1016
+ if (
1017
+ fence &&
1018
+ fence[0] === open[0] &&
1019
+ fence.length >= open.length &&
1020
+ line.trim() === fence
1021
+ ) {
1022
+ open = null;
1023
+ }
1024
+ } else if (fence) {
1025
+ open = fence;
1026
+ mask.push(true);
1027
+ } else {
1028
+ mask.push(false);
1029
+ }
1030
+ }
1031
+ return mask;
1032
+ };
1033
+
1034
+ /** Apply `transform` to the runs of lines outside fenced code. */
1035
+ const mapProse = (text, transform) => {
1036
+ const lines = text.split("\n");
1037
+ const mask = fenceMask(lines);
1038
+ const out = [];
1039
+ let run = [];
1040
+ let [runIsCode] = mask;
1041
+ for (const [index, line] of lines.entries()) {
1042
+ if (mask[index] !== runIsCode) {
1043
+ out.push(runIsCode ? run.join("\n") : transform(run.join("\n")));
1044
+ run = [];
1045
+ runIsCode = mask[index];
1046
+ }
1047
+ run.push(line);
1048
+ }
1049
+ out.push(runIsCode ? run.join("\n") : transform(run.join("\n")));
1050
+ return out.join("\n");
1051
+ };
1052
+
1053
+ /** Apply `transform` to the parts of a line outside inline code spans. */
1054
+ const mapOutsideCode = (line, transform) => {
1055
+ const parts = [];
1056
+ let last = 0;
1057
+ for (const span of line.matchAll(/(?<ticks>`+)[\s\S]*?\k<ticks>/gu)) {
1058
+ parts.push(transform(line.slice(last, span.index)), span[0]);
1059
+ last = span.index + span[0].length;
1060
+ }
1061
+ parts.push(transform(line.slice(last)));
1062
+ return parts.join("");
1063
+ };
1064
+
1065
+ const SPAN_OPEN = String.fromCodePoint(0xe0_10);
1066
+ const SPAN_CLOSE = String.fromCodePoint(0xe0_11);
1067
+ const SPAN_TOKEN = new RegExp(`${SPAN_OPEN}(?<index>\\d+)${SPAN_CLOSE}`, "gu");
1068
+
1069
+ /** Run `transform` with the inline code spans in `prose` held out of its reach. */
1070
+ const outsideCodeSpans = (prose, transform) => {
1071
+ const spans = [];
1072
+ const masked = prose
1073
+ .split("\n")
1074
+ .map((line) =>
1075
+ line.replaceAll(/(?<ticks>`+)[\s\S]*?\k<ticks>/gu, (span) => {
1076
+ spans.push(span);
1077
+ return `${SPAN_OPEN}${spans.length - 1}${SPAN_CLOSE}`;
1078
+ })
1079
+ )
1080
+ .join("\n");
1081
+ return transform(masked).replaceAll(
1082
+ SPAN_TOKEN,
1083
+ byGroups(({ index }) => spans[Number(index)])
1084
+ );
1085
+ };
1086
+
1087
+ /** Unwrap `{% raw %}` blocks, protecting what's inside from every later pass. */
1088
+ const protectRaw = (text, report) =>
1089
+ text.replaceAll(
1090
+ /(?:^[ \t]*\{%-?\s*raw\s*-?%\}[ \t]*\n|\{%-?\s*raw\s*-?%\})(?<inner>[\s\S]*?)(?:^[ \t]*\{%-?\s*endraw\s*-?%\}[ \t]*(?:\n|$)|\{%-?\s*endraw\s*-?%\})/gmu,
1091
+ byGroups(({ inner }) => {
1092
+ edit(report, "{% raw %} unwrapped");
1093
+ return [...inner].map((char) => RAW_CHARS.get(char) ?? char).join("");
1094
+ })
1095
+ );
1096
+
1097
+ const restoreRaw = (text) => {
1098
+ let out = text;
1099
+ for (const [char, placeholder] of RAW_CHARS) {
1100
+ out = out.replaceAll(placeholder, char);
1101
+ }
1102
+ return out;
1103
+ };
1104
+
1105
+ /** A heading's Markdown as plain text, for a front matter `title`. */
1106
+ const plainText = (markdown) =>
1107
+ decodeEntities(
1108
+ removeAll(markdown, /<[^>]+>/gu)
1109
+ .replaceAll(
1110
+ /!?\[(?<label>[^\]]*)\]\([^)]*\)/gu,
1111
+ byGroups(({ label }) => label)
1112
+ )
1113
+ .replaceAll(
1114
+ /`(?<code>[^`]*)`/gu,
1115
+ byGroups(({ code }) => code)
1116
+ )
1117
+ .replaceAll(
1118
+ /(?<mark>\*\*|__)(?<inner>\S(?:.*?\S)?)\k<mark>/gu,
1119
+ byGroups(({ inner }) => inner)
1120
+ )
1121
+ .replaceAll(
1122
+ /(?<before>^|[^\w*])\*(?<inner>\S(?:.*?\S)?)\*(?=[^\w*]|$)/gu,
1123
+ byGroups(({ before, inner }) => `${before}${inner}`)
1124
+ )
1125
+ .replaceAll(
1126
+ /(?<before>^|\W)_(?<inner>\S(?:.*?\S)?)_(?=\W|$)/gu,
1127
+ byGroups(({ before, inner }) => `${before}${inner}`)
1128
+ )
1129
+ .replaceAll(
1130
+ /\\(?<char>[\\`*_{}[\]()#+\-.!|])/gu,
1131
+ byGroups(({ char }) => char)
1132
+ )
1133
+ )
1134
+ .replaceAll(/\s+/gu, " ")
1135
+ .trim();
1136
+
1137
+ /** Escape a directive title for its `[…]` label. */
1138
+ const labelText = (text) => text.replaceAll(/[[\]]/gu, (char) => `\\${char}`);
1139
+
1140
+ // --- Liquid -------------------------------------------------------------------------
1141
+
1142
+ const INCLUDE_TAG =
1143
+ /^(?<indent>[ \t]*)\{%-?\s*(?<tag>include|include_relative|include_cached)\s+(?<rest>.*?)\s*-?%\}[ \t]*$/u;
1144
+ const INCLUDE_PARAM =
1145
+ /(?<key>[\w-]+)\s*=\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|(?<bare>[^\s"']+))/gu;
1146
+
1147
+ /** An include tag's file and parameters, or why it can't be converted. */
1148
+ const parseInclude = (rest) => {
1149
+ const file = rest.match(/^\S+/u)?.[0] ?? "";
1150
+ if (file.includes("{{")) {
1151
+ return { problem: "its file name is a Liquid expression" };
1152
+ }
1153
+ const params = [];
1154
+ const leftover = rest.slice(file.length).replaceAll(
1155
+ INCLUDE_PARAM,
1156
+ byGroups(({ bare, dq, key, sq }) => {
1157
+ // A bare `true`, `false`, or number is a Liquid literal, not a variable.
1158
+ const literal = /^(?:true|false|-?\d+(?:\.\d+)?)$/u.test(bare ?? "");
1159
+ params.push({
1160
+ bare: literal ? undefined : bare,
1161
+ key,
1162
+ value: dq ?? sq ?? (literal ? bare : undefined),
1163
+ });
1164
+ return "";
1165
+ })
1166
+ );
1167
+ if (leftover.trim() !== "") {
1168
+ return {
1169
+ problem: `it has parameters the codemod can't read (${leftover.trim()})`,
1170
+ };
1171
+ }
1172
+ const variable = params.find(({ bare }) => bare !== undefined);
1173
+ if (variable) {
1174
+ return {
1175
+ problem: `${variable.key}=${variable.bare} passes a variable: pass its value as quoted text`,
1176
+ };
1177
+ }
1178
+ const upper = params.find(({ key }) => key !== key.toLowerCase());
1179
+ if (upper) {
1180
+ return {
1181
+ problem: `parameter ${upper.key} isn't lowercase, which <include> props need`,
1182
+ };
1183
+ }
1184
+ return { file: file.replace(/^\/+/u, ""), params };
1185
+ };
1186
+
1187
+ const attrValue = (value) =>
1188
+ value.includes('"') ? `'${value}'` : `"${value}"`;
1189
+
1190
+ const convertHighlight = (text, report) =>
1191
+ text.replaceAll(
1192
+ /^(?<indent>[ \t]*)\{%-?\s*highlight\s+(?<lang>[^\s%]+)(?<opts>[^%]*?)-?%\}[ \t]*\n?(?<code>[\s\S]*?)\n?[ \t]*\{%-?\s*endhighlight\s*-?%\}/gmu,
1193
+ byGroups(({ code, indent, lang, opts }) => {
1194
+ edit(report, "{% highlight %} → fence");
1195
+ const meta = [];
1196
+ const marks = opts.match(/mark_lines="(?<lines>[^"]*)"/u)?.groups?.lines;
1197
+ if (marks) {
1198
+ meta.push(`{${marks.trim().split(/\s+/u).join(",")}}`);
1199
+ }
1200
+ if (/\blinenos\b/u.test(opts)) {
1201
+ meta.push("lineNumbers");
1202
+ }
1203
+ const info = meta.length > 0 ? ` ${meta.join(" ")}` : "";
1204
+ return `${indent}\`\`\`${lang}${info}\n${code}\n${indent}\`\`\``;
1205
+ })
1206
+ );
1207
+
1208
+ /** `{{ site.x }}` → `{{x}}` where `_config.yml` holds a plain value. */
1209
+ const convertSiteVariables = (text, file, ctx) =>
1210
+ text.replaceAll(
1211
+ /\{\{-?\s*site\.(?<key>[\w-]+)\s*-?\}\}/gu,
1212
+ byGroups(({ key }, whole) => {
1213
+ const value = textOf(ctx.config[key]);
1214
+ if (value === undefined || ctx.config.unreadable) {
1215
+ note(
1216
+ file.report,
1217
+ `${whole} isn't a plain value in _config.yml: inline it`
1218
+ );
1219
+ return whole;
1220
+ }
1221
+ ctx.variables.set(key, value);
1222
+ file.siteKeys.add(key);
1223
+ edit(file.report, "{{ site.x }} → {{x}} variable");
1224
+ return `{{${key}}}`;
1225
+ })
1226
+ );
1227
+
1228
+ /** `{{ site.baseurl }}`, `relative_url`, `absolute_url`, `{% link %}`. */
1229
+ const convertUrls = (text, file, ctx) => {
1230
+ const { report } = file;
1231
+ return text
1232
+ .replaceAll(
1233
+ /\{\{-?\s*site\.url\s*-?\}\}\s*\{\{-?\s*site\.baseurl\s*-?\}\}|\{\{-?\s*site\.baseurl\s*-?\}\}/gu,
1234
+ () => {
1235
+ edit(report, "{{ site.baseurl }} removed");
1236
+ return "";
1237
+ }
1238
+ )
1239
+ .replaceAll(
1240
+ /(?<lead>\]\(|\b(?:href|src)=["'])\{\{-?\s*site\.url\s*-?\}\}(?=\/)/gu,
1241
+ byGroups(({ lead }) => {
1242
+ edit(report, "{{ site.url }} link → root-relative");
1243
+ return lead;
1244
+ })
1245
+ )
1246
+ .replaceAll(
1247
+ /\{\{-?\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')\s*\|\s*(?:relative_url|absolute_url)\s*-?\}\}/gu,
1248
+ byGroups(({ dq, sq }) => {
1249
+ edit(report, "relative_url/absolute_url → root path");
1250
+ const value = dq ?? sq;
1251
+ return value.startsWith("/") ? value : `/${value}`;
1252
+ })
1253
+ )
1254
+ .replaceAll(
1255
+ /\{%-?\s*link\s+(?<target>[^%\s]+)\s*-?%\}/gu,
1256
+ byGroups(({ target }, whole) => {
1257
+ const rel = target.replace(/^\/+/u, "");
1258
+ const page = ctx.pagesBySource.get(rel);
1259
+ if (page) {
1260
+ edit(report, "{% link %} → route");
1261
+ return page.oldRoute;
1262
+ }
1263
+ if (existsSync(path.join(ctx.root, rel))) {
1264
+ edit(report, "{% link %} → file path");
1265
+ return `/${encodePath(rel)}`;
1266
+ }
1267
+ note(report, `${whole} names no page or file: fix it by hand`);
1268
+ return whole;
1269
+ })
1270
+ );
1271
+ };
1272
+
1273
+ const removeComments = (text, report) =>
1274
+ text
1275
+ .replaceAll(
1276
+ /^[ \t]*\{%-?\s*comment\s*-?%\}[\s\S]*?\{%-?\s*endcomment\s*-?%\}[ \t]*\n?/gmu,
1277
+ () => {
1278
+ edit(report, "{% comment %} removed");
1279
+ return "";
1280
+ }
1281
+ )
1282
+ .replaceAll(
1283
+ /\{%-?\s*comment\s*-?%\}[\s\S]*?\{%-?\s*endcomment\s*-?%\}/gu,
1284
+ () => {
1285
+ edit(report, "{% comment %} removed");
1286
+ return "";
1287
+ }
1288
+ );
1289
+
1290
+ const INCLUDE_VAR = /\{\{-?\s*include\.(?<key>[\w-]+)\s*-?\}\}/gu;
1291
+
1292
+ /** The `{{ include.x }}` parameters a partial reads, outside `{% raw %}`. */
1293
+ const propsRead = (partial) => {
1294
+ partial.reads ??= new Set(
1295
+ [...protectRaw(partial.body, newReport()).matchAll(INCLUDE_VAR)].map(
1296
+ (match) => match.groups.key
1297
+ )
1298
+ );
1299
+ return partial.reads;
1300
+ };
1301
+
1302
+ /** Include tags, one per line, outside fenced code → `<include>`. */
1303
+ const convertIncludes = (text, file, ctx) => {
1304
+ const { report } = file;
1305
+ const lines = text.split("\n");
1306
+ const mask = fenceMask(lines);
1307
+ const kept = [];
1308
+ for (const [index, line] of lines.entries()) {
1309
+ const match = mask[index] ? null : line.match(INCLUDE_TAG);
1310
+ if (!match) {
1311
+ kept.push(line);
1312
+ continue;
1313
+ }
1314
+ const { indent, rest, tag } = match.groups;
1315
+ const parsed = parseInclude(rest);
1316
+ if (parsed.problem) {
1317
+ file.includeProblems.set(line.trim(), `left, because ${parsed.problem}`);
1318
+ kept.push(line);
1319
+ continue;
1320
+ }
1321
+ const target =
1322
+ tag === "include_relative"
1323
+ ? path.posix.normalize(path.posix.join(parentOf(file.rel), parsed.file))
1324
+ : `${ctx.includesDir}/${parsed.file}`;
1325
+ const partial = ctx.partials.get(target) ?? ctx.pagesBySource.get(target);
1326
+ if (!partial) {
1327
+ const reason = existsSync(path.join(ctx.root, target))
1328
+ ? `${target} isn't Markdown (an HTML partial is embedded as code, not spliced)`
1329
+ : `${target} isn't in this site (a theme include?)`;
1330
+ file.includeProblems.set(
1331
+ line.trim(),
1332
+ `left, because ${reason}: convert each use by hand`
1333
+ );
1334
+ kept.push(line);
1335
+ continue;
1336
+ }
1337
+ partial.includers?.add(file);
1338
+ file.includes.add(partial);
1339
+ if (partial.tocOnly) {
1340
+ edit(report, "include of a TOC-only partial removed");
1341
+ continue;
1342
+ }
1343
+ // A parameter the partial reads but this tag doesn't pass printed
1344
+ // nothing in Jekyll; unpassed, Blume would print `{{x}}` or a variable.
1345
+ const passed = new Set(parsed.params.map(({ key }) => key));
1346
+ const unpassed = [...propsRead(partial)].filter(
1347
+ (key) => !passed.has(key) && key === key.toLowerCase()
1348
+ );
1349
+ if (unpassed.length > 0) {
1350
+ edit(report, 'parameter the include left out → key=""');
1351
+ }
1352
+ const attrs = [
1353
+ ...parsed.params,
1354
+ ...unpassed.map((key) => ({ key, value: "" })),
1355
+ ]
1356
+ .map(({ key, value }) => ` ${key}=${attrValue(value)}`)
1357
+ .join("");
1358
+ // Root-relative, so a page the navigation moves still finds it.
1359
+ kept.push(`${indent}<include${attrs}>/${target}</include>`);
1360
+ edit(report, `{% ${tag} %} → <include>`);
1361
+ }
1362
+ return kept.join("\n");
1363
+ };
1364
+
1365
+ /** Liquid that maps one-to-one, outside `{% raw %}`. */
1366
+ const convertLiquid = (text, file, ctx) => {
1367
+ let out = convertHighlight(text, file.report);
1368
+ out = removeComments(out, file.report);
1369
+ out = convertUrls(out, file, ctx);
1370
+ out = convertSiteVariables(out, file, ctx);
1371
+ if (file.partial) {
1372
+ out = out.replaceAll(
1373
+ INCLUDE_VAR,
1374
+ byGroups(({ key }) => {
1375
+ edit(file.report, "{{ include.x }} → {{x}} prop");
1376
+ file.props.add(key);
1377
+ return `{{${key}}}`;
1378
+ })
1379
+ );
1380
+ }
1381
+ return convertIncludes(out, file, ctx);
1382
+ };
1383
+
1384
+ // --- Kramdown -------------------------------------------------------------------------
1385
+
1386
+ const parseSpec = (spec) => {
1387
+ const out = { attrs: [], classes: [], id: undefined, words: [] };
1388
+ for (const token of spec.matchAll(
1389
+ /(?<cls>\.[\w-]+)|(?<id>#[\w-]+)|(?<attr>[\w-]+=(?:"[^"]*"|'[^']*'|\S+))|(?<word>\S+)/gu
1390
+ )) {
1391
+ const { attr, cls, id, word } = token.groups;
1392
+ if (cls) {
1393
+ out.classes.push(cls.slice(1));
1394
+ } else if (id) {
1395
+ out.id = id.slice(1);
1396
+ } else if (attr) {
1397
+ out.attrs.push(attr);
1398
+ } else {
1399
+ out.words.push(word);
1400
+ }
1401
+ }
1402
+ return out;
1403
+ };
1404
+
1405
+ const blockKind = (lines) => {
1406
+ const [first] = lines;
1407
+ if (HEADING.test(first)) {
1408
+ return "heading";
1409
+ }
1410
+ if (/^ {0,3}>/u.test(first)) {
1411
+ return "blockquote";
1412
+ }
1413
+ if (LIST_ITEM.test(first)) {
1414
+ return "list";
1415
+ }
1416
+ if (/^ {0,3}\|/u.test(first)) {
1417
+ return "table";
1418
+ }
1419
+ if (/^ {0,3}</u.test(first)) {
1420
+ return "html";
1421
+ }
1422
+ return "paragraph";
1423
+ };
1424
+
1425
+ /** The longest directive fence in `lines`, to nest a callout around them. */
1426
+ const maxFence = (lines) =>
1427
+ Math.max(
1428
+ 0,
1429
+ ...lines.map(
1430
+ (line) => line.match(/^\s*(?<colons>:{3,})/u)?.groups?.colons.length ?? 0
1431
+ )
1432
+ );
1433
+
1434
+ /** A heading line with markers appended (`[!toc]`, `[#id]`). */
1435
+ const markHeading = (line, markers) => {
1436
+ const trimmed = line.replace(/\s+$/u, "");
1437
+ const extra = markers.filter((marker) => !trimmed.includes(marker));
1438
+ return extra.length > 0 ? `${trimmed} ${extra.join(" ")}` : trimmed;
1439
+ };
1440
+
1441
+ const leadingSpace = (line) => line.match(/^\s*/u)[0];
1442
+
1443
+ /** A configured callout around a paragraph or blockquote → a directive. */
1444
+ const calloutBlock = (block, callout, nest, context) => {
1445
+ const { classes, ctx, report } = context;
1446
+ const name = callout.replace(/-title$/u, "");
1447
+ const directive = calloutDirective(name, ctx.callouts);
1448
+ const indent = leadingSpace(block.lines[0]);
1449
+ let body = block.lines;
1450
+ if (block.kind === "blockquote") {
1451
+ body = nest(
1452
+ block.lines.map((line) =>
1453
+ line.replace(
1454
+ /^(?<lead>\s*)> ?/u,
1455
+ byGroups(({ lead }) => lead)
1456
+ )
1457
+ )
1458
+ );
1459
+ }
1460
+ let { title } = directive;
1461
+ if (callout.endsWith("-title") && block.kind === "blockquote") {
1462
+ const split = body.findIndex((line) => isBlank(line));
1463
+ const heading = split === -1 ? body : body.slice(0, split);
1464
+ title = heading.map((line) => line.trim()).join(" ");
1465
+ body = split === -1 ? [] : body.slice(split + 1);
1466
+ while (body.length > 0 && isBlank(body[0])) {
1467
+ body = body.slice(1);
1468
+ }
1469
+ }
1470
+ const colons = ":".repeat(Math.max(3, maxFence(body) + 1));
1471
+ const label = title ? `[${labelText(title)}]` : "";
1472
+ edit(report, `{: .${name} } → :::${directive.type}`);
1473
+ const others = classes.filter(
1474
+ (cls) => cls !== callout && !UTILITY_CLASS.test(cls)
1475
+ );
1476
+ if (others.length > 0) {
1477
+ note(report, `classes beside .${callout} dropped: .${others.join(" .")}`);
1478
+ }
1479
+ return [
1480
+ `${indent}${colons}${directive.type}${label}`,
1481
+ ...body,
1482
+ `${indent}${colons}`,
1483
+ ];
1484
+ };
1485
+
1486
+ /** A heading's IAL → `[!toc]` and `[#id]` markers. */
1487
+ const headingBlock = (block, context) => {
1488
+ const { classes, id, report } = context;
1489
+ const markers = [];
1490
+ if (classes.includes("no_toc")) {
1491
+ markers.push("[!toc]");
1492
+ edit(report, ".no_toc heading → [!toc]");
1493
+ }
1494
+ if (id) {
1495
+ markers.push(`[#${id}]`);
1496
+ edit(report, "heading IAL id → [#id]");
1497
+ }
1498
+ const other = classes.filter(
1499
+ (cls) => cls !== "no_toc" && !UTILITY_CLASS.test(cls)
1500
+ );
1501
+ if (other.length > 0) {
1502
+ note(report, `heading classes .${other.join(" .")} dropped`);
1503
+ }
1504
+ return [markHeading(block.lines[0], markers), ...block.lines.slice(1)];
1505
+ };
1506
+
1507
+ /** A paragraph `.label` → `<Badge>`. */
1508
+ const labelBlock = (block, context) => {
1509
+ const { classes, report } = context;
1510
+ const picked =
1511
+ classes
1512
+ .filter((cls) => cls.startsWith("label-"))
1513
+ .map((cls) => cls.slice("label-".length))
1514
+ .find((color) => LABEL_COLORS.has(color)) ?? "blue";
1515
+ if (block.lines.length > 1) {
1516
+ note(
1517
+ report,
1518
+ "a .label on a multi-line paragraph was dropped: make it a <Badge> by hand"
1519
+ );
1520
+ return block.lines;
1521
+ }
1522
+ edit(report, ".label → <Badge>");
1523
+ const [line] = block.lines;
1524
+ return [
1525
+ `${leadingSpace(line)}<Badge color="${picked}">${line.trim()}</Badge>`,
1526
+ ];
1527
+ };
1528
+
1529
+ /** Classes that only styled the block: drop them, report the unknown ones. */
1530
+ const dropClasses = (block, context) => {
1531
+ const { classes, id, report } = context;
1532
+ if (classes.some((cls) => cls === "btn" || cls.startsWith("btn-"))) {
1533
+ edit(report, ".btn IAL dropped (plain link)");
1534
+ }
1535
+ if (id) {
1536
+ edit(report, "block IAL id dropped");
1537
+ note(
1538
+ report,
1539
+ `id #${id} on a ${block.kind} dropped: link to a heading instead`
1540
+ );
1541
+ }
1542
+ const unknown = classes.filter(
1543
+ (cls) =>
1544
+ !UTILITY_CLASS.test(cls) &&
1545
+ cls !== "btn" &&
1546
+ !cls.startsWith("btn-") &&
1547
+ !cls.startsWith("label")
1548
+ );
1549
+ if (unknown.length === 0) {
1550
+ edit(report, "utility class IAL dropped");
1551
+ return block.lines;
1552
+ }
1553
+ edit(report, "unconfigured class IAL dropped (it rendered a plain block)");
1554
+ const where = `isn't a callout under callouts: in _config.yml, so it rendered a plain ${block.kind} (check _sass for a rule that styled it)`;
1555
+ note(report, `.${unknown.join(" .")} ${where}`);
1556
+ return block.lines;
1557
+ };
1558
+
1559
+ /** Apply one IAL group to its block; returns the block's new lines. */
1560
+ const applyIal = (block, specs, nest, ctx, report) => {
1561
+ const context = {
1562
+ classes: specs.flatMap((spec) => spec.classes),
1563
+ ctx,
1564
+ id: specs.map((spec) => spec.id).findLast(Boolean),
1565
+ report,
1566
+ };
1567
+ const words = specs.flatMap((spec) => spec.words);
1568
+ const attrs = specs.flatMap((spec) => spec.attrs);
1569
+ if (words.length > 0 && context.classes.length === 0 && !context.id) {
1570
+ edit(report, "malformed IAL dropped (Kramdown applied nothing)");
1571
+ note(
1572
+ report,
1573
+ `{: ${words.join(" ")} } is malformed or names an undefined ALD: Kramdown applied nothing, so it was dropped`
1574
+ );
1575
+ return block.lines;
1576
+ }
1577
+ if (attrs.length > 0) {
1578
+ edit(report, "IAL attribute dropped");
1579
+ note(report, `IAL attributes ${attrs.join(" ")} dropped`);
1580
+ }
1581
+ if (block.kind === "heading") {
1582
+ return headingBlock(block, context);
1583
+ }
1584
+ const callout = ctx.callouts
1585
+ ? context.classes.find((cls) =>
1586
+ ctx.callouts.has(cls.replace(/-title$/u, ""))
1587
+ )
1588
+ : undefined;
1589
+ if (callout && ["blockquote", "paragraph"].includes(block.kind)) {
1590
+ return calloutBlock(block, callout, nest, context);
1591
+ }
1592
+ if (callout && block.inItem) {
1593
+ edit(report, "callout IAL inside a list item dropped");
1594
+ note(
1595
+ report,
1596
+ `.${callout} sat inside a list item: where the old page shows <p class="${callout}"> in that item, Just the Docs boxed it, so write the callout inside the item by hand`
1597
+ );
1598
+ return block.lines;
1599
+ }
1600
+ if (callout) {
1601
+ edit(report, "callout IAL on a non-paragraph dropped");
1602
+ note(
1603
+ report,
1604
+ `.${callout} sat on a ${block.kind}: Just the Docs styles only paragraphs and blockquotes as callouts, so it rendered without a box; dropped`
1605
+ );
1606
+ return block.lines;
1607
+ }
1608
+ if (
1609
+ block.kind === "paragraph" &&
1610
+ context.classes.some((cls) => cls === "label" || cls.startsWith("label-"))
1611
+ ) {
1612
+ return labelBlock(block, context);
1613
+ }
1614
+ return dropClasses(block, context);
1615
+ };
1616
+
1617
+ // A list or blockquote line ends a paragraph in Kramdown without a blank line.
1618
+ const INTERRUPTS_PARAGRAPH = /^ {0,3}(?:(?:[-*+]|\d+[.)])\s|>)/u;
1619
+
1620
+ /** The end of the block a prefix IAL applies to, starting at `start`. */
1621
+ const blockEnd = (lines, mask, start) => {
1622
+ if (mask[start]) {
1623
+ let end = start;
1624
+ while (end + 1 < lines.length && mask[end + 1]) {
1625
+ end += 1;
1626
+ if (FENCE.test(lines[end])) {
1627
+ break;
1628
+ }
1629
+ }
1630
+ return end;
1631
+ }
1632
+ if (HEADING.test(lines[start])) {
1633
+ return start;
1634
+ }
1635
+ // A paragraph ends at a list or blockquote line; those run to a blank.
1636
+ const stop = INTERRUPTS_PARAGRAPH.test(lines[start])
1637
+ ? () => false
1638
+ : (line) => INTERRUPTS_PARAGRAPH.test(line);
1639
+ let end = start;
1640
+ while (
1641
+ end + 1 < lines.length &&
1642
+ !isBlank(lines[end + 1]) &&
1643
+ !mask[end + 1] &&
1644
+ !IAL_LINE.test(lines[end + 1]) &&
1645
+ !HEADING.test(lines[end + 1]) &&
1646
+ !stop(lines[end + 1])
1647
+ ) {
1648
+ end += 1;
1649
+ }
1650
+ return end;
1651
+ };
1652
+
1653
+ /** Where the block that ends at the tail of `out` starts (for a postfix IAL). */
1654
+ const blockStart = (out, outMask) => {
1655
+ let start = out.length - 1;
1656
+ if (outMask[start]) {
1657
+ while (start > 0 && outMask[start - 1] && !FENCE.test(out[start])) {
1658
+ start -= 1;
1659
+ }
1660
+ return start;
1661
+ }
1662
+ if (HEADING.test(out[start])) {
1663
+ return start;
1664
+ }
1665
+ while (
1666
+ start > 0 &&
1667
+ !isBlank(out[start - 1]) &&
1668
+ !outMask[start - 1] &&
1669
+ !HEADING.test(out[start - 1]) &&
1670
+ !DIRECTIVE_LINE.test(out[start - 1])
1671
+ ) {
1672
+ start -= 1;
1673
+ }
1674
+ // A paragraph followed by a list or blockquote: the IAL is on the last one.
1675
+ if (!INTERRUPTS_PARAGRAPH.test(out[start])) {
1676
+ const interrupt = out.findIndex(
1677
+ (line, at) => at > start && INTERRUPTS_PARAGRAPH.test(line)
1678
+ );
1679
+ return interrupt === -1 ? start : interrupt;
1680
+ }
1681
+ return start;
1682
+ };
1683
+
1684
+ const readSpecs = (lines, mask, from) => {
1685
+ const specs = [];
1686
+ let end = from;
1687
+ while (end < lines.length && !mask[end] && IAL_LINE.test(lines[end])) {
1688
+ specs.push(parseSpec(lines[end].match(IAL_LINE).groups.spec));
1689
+ end += 1;
1690
+ }
1691
+ return { end, specs };
1692
+ };
1693
+
1694
+ /** Kramdown block IALs and ALDs, outside fenced code. */
1695
+ /** Classes that may be callouts on a site whose `_config.yml` names none. */
1696
+ const undeclaredCallout = (specs, ctx) =>
1697
+ !ctx.callouts &&
1698
+ specs.some((spec) =>
1699
+ spec.classes.some(
1700
+ (cls) =>
1701
+ !UTILITY_CLASS.test(cls) &&
1702
+ cls !== "btn" &&
1703
+ !cls.startsWith("btn-") &&
1704
+ !cls.startsWith("label")
1705
+ )
1706
+ );
1707
+
1708
+ const convertBlocks = (input, ctx, report) => {
1709
+ const lines = [...input];
1710
+ const mask = fenceMask(lines);
1711
+ const nest = (inner) => convertBlocks(inner, ctx, report);
1712
+ const out = [];
1713
+ const outMask = [];
1714
+ const push = (line, code) => {
1715
+ out.push(line);
1716
+ outMask.push(code);
1717
+ };
1718
+ const pushBlock = (result, code, next) => {
1719
+ if (
1720
+ DIRECTIVE_LINE.test(result[0]) &&
1721
+ out.length > 0 &&
1722
+ !isBlank(out.at(-1))
1723
+ ) {
1724
+ push("", false);
1725
+ }
1726
+ for (const line of result) {
1727
+ push(line, code);
1728
+ }
1729
+ if (DIRECTIVE_LINE.test(result.at(-1)) && !isBlank(next)) {
1730
+ push("", false);
1731
+ }
1732
+ };
1733
+ let index = 0;
1734
+ while (index < lines.length) {
1735
+ const line = lines[index];
1736
+ if (mask[index] || (!IAL_LINE.test(line) && !ALD_LINE.test(line))) {
1737
+ push(line, mask[index]);
1738
+ index += 1;
1739
+ continue;
1740
+ }
1741
+ if (ALD_LINE.test(line)) {
1742
+ edit(report, "Kramdown ALD removed");
1743
+ index += 1;
1744
+ continue;
1745
+ }
1746
+ const { end, specs } = readSpecs(lines, mask, index);
1747
+ if (undeclaredCallout(specs, ctx)) {
1748
+ // No `callouts:` to say what these classes were: leave them for review.
1749
+ for (const kept of lines.slice(index, end)) {
1750
+ push(kept, false);
1751
+ }
1752
+ index = end;
1753
+ } else if (out.length > 0 && !isBlank(out.at(-1))) {
1754
+ // Postfix: the IAL applies to the block right above it.
1755
+ const start = blockStart(out, outMask);
1756
+ const code = outMask[start];
1757
+ const blockLines = out.splice(start);
1758
+ outMask.splice(start);
1759
+ const kind = code ? "code" : blockKind(blockLines);
1760
+ // Indented under a list item, the IAL sits inside that item.
1761
+ const inItem = kind === "list" && /^\s/u.test(line);
1762
+ const result = applyIal(
1763
+ { inItem, kind, lines: blockLines },
1764
+ specs,
1765
+ nest,
1766
+ ctx,
1767
+ report
1768
+ );
1769
+ pushBlock(result, code, lines[end]);
1770
+ index = end;
1771
+ } else {
1772
+ // Prefix: the next block, past any blank lines (Kramdown applies an IAL
1773
+ // that stands alone to the block after it), plus any IAL right after it.
1774
+ let first = end;
1775
+ while (first < lines.length && isBlank(lines[first])) {
1776
+ first += 1;
1777
+ }
1778
+ if (
1779
+ first >= lines.length ||
1780
+ IAL_LINE.test(lines[first]) ||
1781
+ ALD_LINE.test(lines[first])
1782
+ ) {
1783
+ edit(report, "orphan IAL dropped (Kramdown ignored it)");
1784
+ index = end;
1785
+ continue;
1786
+ }
1787
+ for (const blank of lines.slice(end, first)) {
1788
+ push(blank, false);
1789
+ }
1790
+ const last = blockEnd(lines, mask, first);
1791
+ const after = readSpecs(lines, mask, last + 1);
1792
+ const code = mask[first];
1793
+ const blockLines = lines.slice(first, last + 1);
1794
+ const kind = code ? "code" : blockKind(blockLines);
1795
+ const result = applyIal(
1796
+ { kind, lines: blockLines },
1797
+ [...specs, ...after.specs],
1798
+ nest,
1799
+ ctx,
1800
+ report
1801
+ );
1802
+ pushBlock(result, code, lines[after.end]);
1803
+ index = after.end;
1804
+ }
1805
+ }
1806
+ return out;
1807
+ };
1808
+
1809
+ /** Kramdown's in-page TOC: the list, its `<details>` wrapper, its heading. */
1810
+ const removeToc = (text, report) => {
1811
+ const lines = text.split("\n");
1812
+ const mask = fenceMask(lines);
1813
+ const drop = new Set();
1814
+ for (const [index, line] of lines.entries()) {
1815
+ if (mask[index] || drop.has(index)) {
1816
+ continue;
1817
+ }
1818
+ if (/^\s*<details\b/iu.test(line)) {
1819
+ const close = lines.findIndex(
1820
+ (other, at) => at >= index && /<\/details>/iu.test(other)
1821
+ );
1822
+ const inside = close === -1 ? [] : lines.slice(index, close);
1823
+ if (inside.some((other) => TOC_IAL.test(other))) {
1824
+ for (let at = index; at <= close; at += 1) {
1825
+ drop.add(at);
1826
+ }
1827
+ edit(report, "TOC <details> block removed");
1828
+ }
1829
+ } else if (
1830
+ /^\s*(?:[-*+]|\d+\.)\s+TOC\s*$/u.test(line) &&
1831
+ TOC_IAL.test(lines[index + 1] ?? "")
1832
+ ) {
1833
+ drop.add(index);
1834
+ drop.add(index + 1);
1835
+ edit(report, "TOC list removed");
1836
+ // Its "Table of contents" heading, marked .no_toc. An H1 there is the
1837
+ // page's own heading (kramdown's `# Title {: .no_toc } * TOC {:toc}`
1838
+ // pattern), which becomes the title, so it stays.
1839
+ let back = index - 1;
1840
+ while (back >= 0 && isBlank(lines[back])) {
1841
+ back -= 1;
1842
+ }
1843
+ if (
1844
+ back > 0 &&
1845
+ /\.no_toc/u.test(lines[back]) &&
1846
+ IAL_LINE.test(lines[back]) &&
1847
+ HEADING.test(lines[back - 1]) &&
1848
+ !/^ {0,3}#(?:\s|$)/u.test(lines[back - 1])
1849
+ ) {
1850
+ drop.add(back);
1851
+ drop.add(back - 1);
1852
+ }
1853
+ }
1854
+ }
1855
+ const kept = lines.filter((_, index) => !drop.has(index));
1856
+ const keptMask = fenceMask(kept);
1857
+ return kept
1858
+ .filter((line, index) => {
1859
+ if (!keptMask[index] && TOC_IAL.test(line)) {
1860
+ edit(report, "stray {:toc} removed");
1861
+ return false;
1862
+ }
1863
+ return true;
1864
+ })
1865
+ .join("\n");
1866
+ };
1867
+
1868
+ /** Kramdown extensions: `{::comment}`, `{::options}`, `{::nomarkdown}`. */
1869
+ const removeExtensions = (text, report) =>
1870
+ mapProse(text, (whole) =>
1871
+ outsideCodeSpans(whole, (prose) =>
1872
+ prose
1873
+ .replaceAll(/\{::comment\}[\s\S]*?\{:\/(?:comment)?\}\n?/gu, () => {
1874
+ edit(report, "{::comment} removed");
1875
+ return "";
1876
+ })
1877
+ .replaceAll(/\{::options[^}]*\/\}\n?/gu, () => {
1878
+ edit(report, "{::options} removed");
1879
+ return "";
1880
+ })
1881
+ .replaceAll(
1882
+ /\{::nomarkdown\}(?<inner>[\s\S]*?)\{:\/(?:nomarkdown)?\}/gu,
1883
+ byGroups(({ inner }) => {
1884
+ edit(report, "{::nomarkdown} unwrapped");
1885
+ return inner;
1886
+ })
1887
+ )
1888
+ )
1889
+ );
1890
+
1891
+ /** Which lines sit inside an HTML comment or a `<pre>`, where no heading renders. */
1892
+ const htmlRawMask = (lines) => {
1893
+ const mask = [];
1894
+ let open = null;
1895
+ for (const line of lines) {
1896
+ let inside = open !== null;
1897
+ if (open === null && /<!--(?![\s\S]*-->)/u.test(line)) {
1898
+ open = "-->";
1899
+ inside = true;
1900
+ } else if (open === null && /^\s*<pre\b(?![\s\S]*<\/pre>)/iu.test(line)) {
1901
+ open = "</pre>";
1902
+ inside = true;
1903
+ } else if (open !== null && line.toLowerCase().includes(open)) {
1904
+ open = null;
1905
+ }
1906
+ mask.push(inside);
1907
+ }
1908
+ return mask;
1909
+ };
1910
+
1911
+ /** The first H1 outside fences, comments, and `<pre>` (and its IALs) → `title`. */
1912
+ const takeH1 = (text) => {
1913
+ const lines = text.split("\n");
1914
+ const mask = fenceMask(lines);
1915
+ const raw = htmlRawMask(lines);
1916
+ const index = lines.findIndex(
1917
+ (line, at) => !(mask[at] || raw[at]) && /^#(?:\s|$)/u.test(line)
1918
+ );
1919
+ if (index === -1) {
1920
+ return { text, title: undefined };
1921
+ }
1922
+ const heading = lines[index]
1923
+ .replace(/^#\s*/u, "")
1924
+ .replace(/\s+#+\s*$/u, "")
1925
+ .replace(/\s*\{#[\w-]+\}\s*$/u, "")
1926
+ .replace(/\s*\[(?:!toc|toc|#[\w-]+)\]\s*$/u, "");
1927
+ let end = index + 1;
1928
+ while (end < lines.length && IAL_LINE.test(lines[end])) {
1929
+ end += 1;
1930
+ }
1931
+ return {
1932
+ text: [...lines.slice(0, index), ...lines.slice(end)].join("\n"),
1933
+ title: plainText(heading),
1934
+ };
1935
+ };
1936
+
1937
+ /** Span IALs: `[x](url){: .btn }`, `<span>New</span>{: .label .label-green }`. */
1938
+ const convertSpanIals = (text, report) =>
1939
+ mapProse(text, (prose) =>
1940
+ prose
1941
+ .split("\n")
1942
+ .map((line) =>
1943
+ mapOutsideCode(line, (part) =>
1944
+ part
1945
+ .replaceAll(
1946
+ /<span>(?<inner>[^<]*)<\/span>\{:\s*(?<spec>[^}]*)\}/gu,
1947
+ byGroups(({ inner, spec }) => {
1948
+ const { classes } = parseSpec(spec);
1949
+ if (classes.some((cls) => cls.startsWith("label"))) {
1950
+ const color =
1951
+ classes
1952
+ .map((cls) => cls.slice("label-".length))
1953
+ .find((value) => LABEL_COLORS.has(value)) ?? "blue";
1954
+ edit(report, ".label span → <Badge>");
1955
+ return `<Badge color="${color}">${inner}</Badge>`;
1956
+ }
1957
+ edit(report, "span IAL dropped");
1958
+ return `<span>${inner}</span>`;
1959
+ })
1960
+ )
1961
+ // Kramdown reads a span IAL only after a span element (a link,
1962
+ // emphasis, code, or HTML); after plain text it printed it.
1963
+ .replaceAll(
1964
+ /(?<=[)*_`\]>])\{:\s*(?<spec>[^}]*)\}/gu,
1965
+ byGroups(({ spec }) => {
1966
+ edit(report, "span IAL dropped");
1967
+ const { attrs, id } = parseSpec(spec);
1968
+ if (attrs.length > 0 || id) {
1969
+ note(
1970
+ report,
1971
+ `span IAL {: ${spec.trim()} } dropped: it sized or named the element, so check the old page`
1972
+ );
1973
+ }
1974
+ return "";
1975
+ })
1976
+ )
1977
+ )
1978
+ )
1979
+ .join("\n")
1980
+ );
1981
+
1982
+ /** Heading `{#id}` → `[#id]`, which pins the anchor in `.md` and `.mdx` alike. */
1983
+ const convertHeadingIds = (text, report) =>
1984
+ mapProse(text, (prose) =>
1985
+ prose.replaceAll(
1986
+ /^(?<heading> {0,3}#{1,6}\s.*?)\s*\{#(?<id>[\w-]+)\}[ \t]*$/gmu,
1987
+ byGroups(({ heading, id }) => {
1988
+ edit(report, "heading {#id} → [#id]");
1989
+ return `${heading} [#${id}]`;
1990
+ })
1991
+ )
1992
+ );
1993
+
1994
+ const MARKDOWN_ATTR = /\s+markdown=(?<q>["']?)(?:1|block|span|0)\k<q>/giu;
1995
+
1996
+ /** The line closing the element opened on `start`, if it closes alone. */
1997
+ const closingLine = (lines, start, tag) => {
1998
+ let depth = 0;
1999
+ for (let at = start; at < lines.length; at += 1) {
2000
+ depth += (lines[at].match(new RegExp(`<${tag}\\b`, "giu")) ?? []).length;
2001
+ depth -= (lines[at].match(new RegExp(`</${tag}\\s*>`, "giu")) ?? []).length;
2002
+ if (depth <= 0) {
2003
+ return at > start &&
2004
+ new RegExp(`^\\s*</${tag}\\s*>\\s*$`, "iu").test(lines[at])
2005
+ ? at
2006
+ : -1;
2007
+ }
2008
+ }
2009
+ return -1;
2010
+ };
2011
+
2012
+ /** `markdown="1"` attributes go; the element gets blank lines so `.md` parses inside. */
2013
+ const convertMarkdownAttr = (text, report) => {
2014
+ const lines = text.split("\n");
2015
+ const mask = fenceMask(lines);
2016
+ const out = [];
2017
+ const closers = new Set();
2018
+ for (const [index, line] of lines.entries()) {
2019
+ if (mask[index]) {
2020
+ out.push(line);
2021
+ continue;
2022
+ }
2023
+ if (closers.has(index) && !isBlank(out.at(-1))) {
2024
+ out.push("");
2025
+ }
2026
+ const opener =
2027
+ /^\s*<(?<tag>[a-z][\w-]*)\b[^>]*\smarkdown=["']?(?:1|block|span|0)["']?[^>]*>\s*$/iu.exec(
2028
+ line
2029
+ );
2030
+ out.push(
2031
+ mapOutsideCode(line, (part) =>
2032
+ part.replaceAll(MARKDOWN_ATTR, () => {
2033
+ edit(report, 'markdown="1" attribute removed');
2034
+ return "";
2035
+ })
2036
+ )
2037
+ );
2038
+ if (opener) {
2039
+ if (!isBlank(lines[index + 1])) {
2040
+ out.push("");
2041
+ }
2042
+ const close = closingLine(lines, index, opener.groups.tag);
2043
+ if (close !== -1) {
2044
+ closers.add(close);
2045
+ }
2046
+ }
2047
+ }
2048
+ return out.join("\n");
2049
+ };
2050
+
2051
+ // --- Links ----------------------------------------------------------------------------
2052
+
2053
+ const SKIP_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|\{\{|\{%|<)/iu;
2054
+
2055
+ /** A link to the site's own origin → a root path; anything else unchanged. */
2056
+ const stripOrigin = (value, ctx, report) => {
2057
+ if (!(ctx.origin && value.startsWith(ctx.origin))) {
2058
+ return value;
2059
+ }
2060
+ const rest = value.slice(ctx.origin.length);
2061
+ if (rest !== "" && !/^[/?#]/u.test(rest)) {
2062
+ return value;
2063
+ }
2064
+ edit(report, "own-origin link → root-relative");
2065
+ return rest === "" ? "/" : rest;
2066
+ };
2067
+
2068
+ /** A file link (`page.md`) → its page, relative to the linking file. */
2069
+ const fileLinkPage = (pathPart, link, ctx) =>
2070
+ ctx.pagesBySource.get(
2071
+ path.posix.normalize(
2072
+ path.posix.join(parentOf(link.sourceRel), safeDecode(pathPart))
2073
+ )
2074
+ );
2075
+
2076
+ /** The site path a link reached on the old site, or null if unknown. */
2077
+ const oldPath = (pathPart, link, ctx) => {
2078
+ if (!pathPart.startsWith("/")) {
2079
+ return link.baseUrl
2080
+ ? new URL(pathPart, `https://jekyll.invalid${link.baseUrl}`).pathname
2081
+ : null;
2082
+ }
2083
+ const { baseurl } = ctx;
2084
+ if (baseurl && (pathPart === baseurl || pathPart.startsWith(`${baseurl}/`))) {
2085
+ return pathPart.slice(baseurl.length) || "/";
2086
+ }
2087
+ return pathPart;
2088
+ };
2089
+
2090
+ /** A relative link to a file the site serves: images rebase, others go root. */
2091
+ const assetHref = (asset, parts, link, report) => {
2092
+ const { href, pathPart, suffix } = parts;
2093
+ if (link.image) {
2094
+ const relative = path.posix.relative(parentOf(link.finalRel), asset);
2095
+ const out = encodePath(
2096
+ relative.startsWith(".") ? relative : `./${relative}`
2097
+ );
2098
+ if (out === pathPart) {
2099
+ return href;
2100
+ }
2101
+ edit(report, "relative image rebased");
2102
+ return `${out}${suffix}`;
2103
+ }
2104
+ edit(report, "relative file link → root path");
2105
+ note(report, `${href} → /${asset}: serve that file from public/${asset}`);
2106
+ return `/${encodePath(asset)}${suffix}`;
2107
+ };
2108
+
2109
+ const isFile = (file) => existsSync(file) && statSync(file).isFile();
2110
+
2111
+ /** Rewrite one link target, or return it unchanged. */
2112
+ /** A link target's path, and its `?query#fragment`. */
2113
+ const splitHref = (value) => {
2114
+ const cut = value.search(/[?#]/u);
2115
+ return cut === -1
2116
+ ? { pathPart: value, suffix: "" }
2117
+ : { pathPart: value.slice(0, cut), suffix: value.slice(cut) };
2118
+ };
2119
+
2120
+ /** A `.md` file link → its page's route. */
2121
+ const mdLinkHref = (href, parts, link, ctx, report) => {
2122
+ const page = fileLinkPage(parts.pathPart, link, ctx);
2123
+ if (!page) {
2124
+ note(report, `link ${href} names no page`);
2125
+ return href;
2126
+ }
2127
+ edit(report, ".md link → route");
2128
+ return `${page.oldRoute}${parts.suffix}`;
2129
+ };
2130
+
2131
+ /** Rewrite one link target, or return it unchanged. */
2132
+ const rewriteHref = (href, link, file, ctx) => {
2133
+ const { report } = file;
2134
+ const inner = /^<(?<inner>.*)>$/u.exec(href)?.groups?.inner ?? href;
2135
+ const value = stripOrigin(inner, ctx, report);
2136
+ // Unchanged so far: hand back the target exactly as written.
2137
+ const kept = value === inner ? href : value;
2138
+ const parts = splitHref(value);
2139
+ const { pathPart, suffix } = parts;
2140
+ if (SKIP_HREF.test(value) || hasRawPlaceholder(value) || pathPart === "") {
2141
+ return kept;
2142
+ }
2143
+ const relative = !pathPart.startsWith("/");
2144
+ if (relative && /\.(?:md|markdown)$/iu.test(pathPart)) {
2145
+ return mdLinkHref(href, parts, link, ctx, report);
2146
+ }
2147
+ const absolute = oldPath(pathPart, link, ctx);
2148
+ if (absolute === null) {
2149
+ note(
2150
+ report,
2151
+ `relative link ${href} in a partial more than one page includes: make it root-relative by hand`
2152
+ );
2153
+ return href;
2154
+ }
2155
+ const route = normalizeRoute(absolute);
2156
+ if (ctx.pagesByRoute.has(route) || ctx.redirectFroms.has(route)) {
2157
+ const out = `${route}${suffix}`;
2158
+ if (out !== value) {
2159
+ edit(report, relative ? "relative link → route" : "link → route");
2160
+ }
2161
+ return out;
2162
+ }
2163
+ const asset = safeDecode(absolute).replace(/^\/+/u, "");
2164
+ if (asset !== "" && isFile(path.join(ctx.root, asset))) {
2165
+ return relative ? assetHref(asset, { href, ...parts }, link, report) : kept;
2166
+ }
2167
+ if (relative) {
2168
+ note(
2169
+ report,
2170
+ `relative link ${href} reached ${absolute} on the old site, which is no page or file here`
2171
+ );
2172
+ }
2173
+ return kept;
2174
+ };
2175
+
2176
+ const INLINE_LINK =
2177
+ /(?<bang>!?)\[(?<label>(?:[^\][]|\[[^\]]*\])*)\]\((?<href><[^>]*>|[^\s)]+)(?<title>\s+(?:"[^"]*"|'[^']*'))?\)/gu;
2178
+ const REF_DEFINITION = /^(?<lead>\s{0,3}\[[^\]]+\]:\s*)(?<href><[^>]*>|\S+)/gu;
2179
+ const HTML_URL_ATTR =
2180
+ /(?<attr>\b(?:href|src))=(?<quote>["'])(?<value>[^"']*)\k<quote>/gu;
2181
+
2182
+ const convertLinks = (text, link, file, ctx) => {
2183
+ const rewrite = (href, image) =>
2184
+ rewriteHref(href, { ...link, image }, file, ctx);
2185
+ return mapProse(text, (prose) =>
2186
+ prose
2187
+ .split("\n")
2188
+ .map((line) =>
2189
+ mapOutsideCode(line, (part) =>
2190
+ part
2191
+ .replaceAll(
2192
+ INLINE_LINK,
2193
+ byGroups(({ bang, href, label, title }) => {
2194
+ const out = rewrite(href, bang === "!");
2195
+ return `${bang}[${label}](${out}${title ?? ""})`;
2196
+ })
2197
+ )
2198
+ .replaceAll(
2199
+ REF_DEFINITION,
2200
+ byGroups(({ href, lead }) => `${lead}${rewrite(href, false)}`)
2201
+ )
2202
+ .replaceAll(
2203
+ HTML_URL_ATTR,
2204
+ byGroups(({ attr, quote, value }) => {
2205
+ // A relative `src` was read against the page's old URL, which
2206
+ // the new folders don't mirror: a root path, not a rebase.
2207
+ // Blume adds deployment.base to it.
2208
+ const out = rewrite(value, false);
2209
+ return `${attr}=${quote}${out}${quote}`;
2210
+ })
2211
+ )
2212
+ )
2213
+ )
2214
+ .join("\n")
2215
+ );
2216
+ };
2217
+
2218
+ // --- MDX ------------------------------------------------------------------------------
2219
+
2220
+ const MDX_FEATURE =
2221
+ /^\s*:{3,}[a-z]|<[A-Z][\w.]*[\s/>]|\$\$|^[ \t]+<include[\s>]/mu;
2222
+ const MERMAID_FENCE = /^\s*(?:`{3,}|~{3,})mermaid\b/mu;
2223
+
2224
+ const needsMdxText = (text) => {
2225
+ let found = MERMAID_FENCE.test(text);
2226
+ mapProse(text, (prose) => {
2227
+ found ||= MDX_FEATURE.test(prose);
2228
+ return prose;
2229
+ });
2230
+ return found;
2231
+ };
2232
+
2233
+ /** Prose (inline code masked) made MDX-safe where the fix is exact. */
2234
+ const mdxSafeProse = (prose, report) => {
2235
+ const withoutScripts = removeAll(
2236
+ prose,
2237
+ /<script\b[\s\S]*?<\/script\b[^>]*>[ \t]*\n?/giu,
2238
+ () => {
2239
+ edit(report, "<script> removed (MDX)");
2240
+ note(
2241
+ report,
2242
+ "a <script> was removed: rebuild what it did as an island, a PageFooter slot, or static content"
2243
+ );
2244
+ }
2245
+ );
2246
+ const withoutStyles = removeAll(
2247
+ withoutScripts,
2248
+ /<style\b[\s\S]*?<\/style\b[^>]*>[ \t]*\n?/giu,
2249
+ () => {
2250
+ edit(report, "<style> removed (MDX)");
2251
+ note(
2252
+ report,
2253
+ "a <style> block was removed: move the rules that still matter to theme.css"
2254
+ );
2255
+ }
2256
+ );
2257
+ return withoutStyles
2258
+ .replaceAll(/<link\b[^>]*rel=["']stylesheet["'][^>]*>[ \t]*\n?/giu, () => {
2259
+ edit(report, "stylesheet <link> removed (MDX)");
2260
+ return "";
2261
+ })
2262
+ .replaceAll(
2263
+ /<!--(?<body>[\s\S]*?)-->/gu,
2264
+ byGroups(({ body }) => {
2265
+ edit(report, "HTML comment → {/* */}");
2266
+ return `{/* ${body.replaceAll("*/", "* /").replaceAll(/\s+/gu, " ").trim()} */}`;
2267
+ })
2268
+ )
2269
+ .replaceAll(
2270
+ new RegExp(
2271
+ `<(?<tag>${VOID_TAGS})\\b(?<attrs>(?:[^<>"']|"[^"]*"|'[^']*')*?)\\s*(?<!/)>`,
2272
+ "giu"
2273
+ ),
2274
+ byGroups(({ attrs, tag }) => {
2275
+ edit(report, `<${tag.toLowerCase()}> self-closed`);
2276
+ return `<${tag}${attrs} />`;
2277
+ })
2278
+ )
2279
+ .replaceAll(
2280
+ /<(?<url>https?:\/\/[^\s<>]+)>/gu,
2281
+ byGroups(({ url }) => {
2282
+ edit(report, "autolink → link");
2283
+ return `[${url}](${url})`;
2284
+ })
2285
+ )
2286
+ .replaceAll(/<(?![A-Za-z/!])/gu, () => {
2287
+ edit(report, "bare < → &lt;");
2288
+ return "&lt;";
2289
+ });
2290
+ };
2291
+
2292
+ /** Make prose MDX-safe where the fix is exact; inline code stays as written. */
2293
+ const makeMdxSafe = (text, report) =>
2294
+ mapProse(text, (whole) =>
2295
+ outsideCodeSpans(whole, (prose) => mdxSafeProse(prose, report))
2296
+ );
2297
+
2298
+ /** What MDX would choke on that the codemod can't fix exactly. */
2299
+ const mdxHazards = (text, start) => {
2300
+ const found = [];
2301
+ const lines = text.split("\n");
2302
+ const mask = fenceMask(lines);
2303
+ for (const [index, line] of lines.entries()) {
2304
+ if (
2305
+ index < start ||
2306
+ mask[index] ||
2307
+ /\{%|\{\{(?!\s*[\w-]+\s*\}\})/u.test(line)
2308
+ ) {
2309
+ continue;
2310
+ }
2311
+ const prose = mapOutsideCode(line, (part) =>
2312
+ removeAll(
2313
+ part
2314
+ .replaceAll(/\{\/\*.*?\*\/\}/gu, "")
2315
+ .replaceAll(/\$\$.*?\$\$/gu, "")
2316
+ .replaceAll(/\{\{\s*[\w-]+\s*\}\}/gu, ""),
2317
+ /<[A-Za-z][^<>]*>/gu
2318
+ ).replaceAll(/\\[{}<]/gu, "")
2319
+ );
2320
+ if (/[{}]/u.test(prose)) {
2321
+ found.push({
2322
+ line: index + 1,
2323
+ message: "a brace in prose: escape it (\\{) or put it in backticks",
2324
+ });
2325
+ } else if (/<(?:[\s\d=<]|$)/u.test(prose)) {
2326
+ found.push({
2327
+ line: index + 1,
2328
+ message: "a bare < in prose: write &lt; or put it in backticks",
2329
+ });
2330
+ }
2331
+ if (
2332
+ /^(?: {4}|\t)\S/u.test(line) &&
2333
+ isBlank(lines[index - 1]) &&
2334
+ !LIST_ITEM.test(lines[index - 2] ?? "") &&
2335
+ !/^\s/u.test(lines[index - 2] ?? "")
2336
+ ) {
2337
+ found.push({
2338
+ line: index + 1,
2339
+ message:
2340
+ "an indented code block: MDX reads it as a paragraph, so fence it",
2341
+ });
2342
+ }
2343
+ }
2344
+ return found;
2345
+ };
2346
+
2347
+ /** Liquid and Kramdown left over, for the review list. */
2348
+ const leftovers = (text, start, problems) => {
2349
+ const found = [];
2350
+ const lines = text.split("\n");
2351
+ const mask = fenceMask(lines);
2352
+ const at = (index, message) => found.push({ line: index + 1, message });
2353
+ for (const [index, line] of lines.entries()) {
2354
+ const liquid = /\{%|\{\{(?!\s*[\w-]+\s*\}\})/u.test(line);
2355
+ if (index < start) {
2356
+ continue;
2357
+ }
2358
+ if (!mask[index] && problems.has(line.trim())) {
2359
+ at(index, `${line.trim().slice(0, 100)}: ${problems.get(line.trim())}`);
2360
+ continue;
2361
+ }
2362
+ if (mask[index]) {
2363
+ if (liquid) {
2364
+ at(
2365
+ index,
2366
+ "Liquid in a code block: Jekyll ran it (it wasn't in {% raw %}), so write what the old build shows"
2367
+ );
2368
+ }
2369
+ continue;
2370
+ }
2371
+ if (liquid) {
2372
+ at(index, `Liquid left: ${line.trim().slice(0, 100)}`);
2373
+ }
2374
+ if (/\{:/u.test(line)) {
2375
+ at(index, `Kramdown attribute list left: ${line.trim().slice(0, 100)}`);
2376
+ }
2377
+ if (/^#\s/u.test(line)) {
2378
+ at(
2379
+ index,
2380
+ "an H1: Blume renders the title as the page's H1, so demote this one"
2381
+ );
2382
+ }
2383
+ if (!isBlank(line) && /^=+\s*$/u.test(lines[index + 1] ?? "")) {
2384
+ at(index, "a setext heading: rewrite it with #");
2385
+ }
2386
+ if (/^:\s/u.test(line)) {
2387
+ at(
2388
+ index,
2389
+ "a definition list item: Blume shows it as text, so use a list or <dl>"
2390
+ );
2391
+ }
2392
+ if (/^\*\[[^\]]+\]:/u.test(line)) {
2393
+ at(
2394
+ index,
2395
+ "an abbreviation definition: Blume shows it as text, so remove it"
2396
+ );
2397
+ }
2398
+ if (/<i class="fa/u.test(line)) {
2399
+ at(
2400
+ index,
2401
+ "a Font Awesome icon: use <Icon icon> with a Lucide name, or drop it"
2402
+ );
2403
+ }
2404
+ if (/^\s*<details\b/iu.test(line)) {
2405
+ at(index, "a <details> block: in .mdx, use <Expandable title>");
2406
+ }
2407
+ }
2408
+ return found;
2409
+ };
2410
+
2411
+ // --- Front matter -----------------------------------------------------------------------
2412
+
2413
+ const isoDate = (value) => {
2414
+ const text = textOf(value);
2415
+ if (text === undefined) {
2416
+ return;
2417
+ }
2418
+ return /^\d{4}-\d{2}-\d{2}/u.test(text) ? text.slice(0, 10) : text;
2419
+ };
2420
+
2421
+ const seoOf = (data, report) => {
2422
+ const seo = {};
2423
+ for (const [key, value] of Object.entries(recordOf(data.seo))) {
2424
+ if (
2425
+ ["canonical", "description", "image", "noindex", "title", "x"].includes(
2426
+ key
2427
+ )
2428
+ ) {
2429
+ seo[key] = value;
2430
+ } else {
2431
+ edit(report, `jekyll-seo-tag seo.${key} removed`);
2432
+ }
2433
+ }
2434
+ const image = textOf(data.image) ?? textOf(recordOf(data.image).path);
2435
+ if (image !== undefined) {
2436
+ seo.image ??= image;
2437
+ edit(report, "image → seo.image");
2438
+ }
2439
+ const canonical = textOf(data.canonical_url);
2440
+ if (canonical !== undefined) {
2441
+ seo.canonical ??= canonical;
2442
+ edit(report, "canonical_url → seo.canonical");
2443
+ }
2444
+ return seo;
2445
+ };
2446
+
2447
+ /** `title`, `description`, and the sidebar label a different old title becomes. */
2448
+ const titleFields = (page, out, extra) => {
2449
+ const { data, report } = page;
2450
+ const oldTitle = textOf(data.title);
2451
+ const title = page.h1 ?? oldTitle;
2452
+ if (title !== undefined) {
2453
+ out.title = title;
2454
+ }
2455
+ if (page.h1 && oldTitle && oldTitle !== page.h1) {
2456
+ extra.sidebar.label ??= oldTitle;
2457
+ extra.seo.title ??= oldTitle;
2458
+ edit(report, "old title → sidebar.label + seo.title");
2459
+ }
2460
+ const description = textOf(data.description) ?? textOf(data.summary);
2461
+ if (description !== undefined) {
2462
+ out.description = description;
2463
+ }
2464
+ if ([oldTitle, description].some((value) => /\{\{|\{%/u.test(value ?? ""))) {
2465
+ note(
2466
+ report,
2467
+ "front matter title or description holds Liquid, which Jekyll printed as written there (and Blume shows the description under the title): check the old page"
2468
+ );
2469
+ }
2470
+ if (data.description === undefined && textOf(data.summary) !== undefined) {
2471
+ edit(report, "summary → description");
2472
+ }
2473
+ };
2474
+
2475
+ /** Dates, drafts, search, and the layout → their Blume keys. */
2476
+ const pageFields = (page, out, extra) => {
2477
+ const { data, report } = page;
2478
+ if (page.hidden) {
2479
+ extra.sidebar.hidden = true;
2480
+ }
2481
+ if (data.search_exclude === true) {
2482
+ extra.search.exclude = true;
2483
+ edit(report, "search_exclude → search.exclude");
2484
+ }
2485
+ out.lastModified =
2486
+ isoDate(data.last_modified_date) ?? isoDate(data.lastModified);
2487
+ if (data.published === false) {
2488
+ out.draft = true;
2489
+ edit(report, "published: false → draft: true");
2490
+ }
2491
+ const layout = textOf(data.layout);
2492
+ if (layout === "minimal" || data.nav_enabled === false) {
2493
+ out.mode = "center";
2494
+ edit(report, "no sidebar (minimal layout) → mode: center");
2495
+ } else if (layout !== undefined && !PLAIN_LAYOUTS.has(layout)) {
2496
+ note(
2497
+ report,
2498
+ `layout: ${layout} is a custom layout: read _layouts/${layout}.html and pick a mode or a custom page`
2499
+ );
2500
+ }
2501
+ };
2502
+
2503
+ /** Keys the codemod doesn't map: kept, and reported unless Blume reads them. */
2504
+ const otherFields = (page, out) => {
2505
+ const { data, report } = page;
2506
+ for (const [key, value] of Object.entries(data)) {
2507
+ if (HANDLED_KEYS.has(key) || Object.hasOwn(out, key)) {
2508
+ if (
2509
+ NAV_KEYS.has(key) ||
2510
+ ["layout", "nav_enabled", "permalink"].includes(key)
2511
+ ) {
2512
+ edit(report, `front matter ${key} removed`);
2513
+ }
2514
+ continue;
2515
+ }
2516
+ out[key] = value;
2517
+ if (!BLUME_KEYS.has(key)) {
2518
+ note(
2519
+ report,
2520
+ `front matter \`${key}\` isn't a Blume key (the schema is strict): map it or remove it`
2521
+ );
2522
+ }
2523
+ }
2524
+ };
2525
+
2526
+ /** Jekyll and Just the Docs front matter → Blume's, in a stable key order. */
2527
+ const convertFrontmatter = (page) => {
2528
+ const out = {};
2529
+ const extra = {
2530
+ search: { ...recordOf(page.data.search) },
2531
+ seo: seoOf(page.data, page.report),
2532
+ sidebar: { ...recordOf(page.data.sidebar) },
2533
+ };
2534
+ titleFields(page, out, extra);
2535
+ if (page.slug !== undefined) {
2536
+ out.slug = page.slug;
2537
+ }
2538
+ pageFields(page, out, extra);
2539
+ for (const [key, value] of Object.entries(extra)) {
2540
+ if (Object.keys(value).length > 0) {
2541
+ out[key] = value;
2542
+ }
2543
+ }
2544
+ otherFields(page, out);
2545
+ const ordered = {};
2546
+ for (const key of FRONTMATTER_ORDER) {
2547
+ if (out[key] !== undefined) {
2548
+ ordered[key] = out[key];
2549
+ }
2550
+ }
2551
+ for (const [key, value] of Object.entries(out)) {
2552
+ if (value !== undefined && !Object.hasOwn(ordered, key)) {
2553
+ ordered[key] = value;
2554
+ }
2555
+ }
2556
+ return ordered;
2557
+ };
2558
+
2559
+ // --- Navigation ---------------------------------------------------------------------------
2560
+
2561
+ /** Plan a move of the page now planned at `from`. */
2562
+ const planMove = (pages, from, to) => {
2563
+ for (const page of pages) {
2564
+ if (page.finalRel === from) {
2565
+ page.finalRel = to;
2566
+ }
2567
+ }
2568
+ };
2569
+
2570
+ /** A nav link's page, through the site's origin and base. */
2571
+ const navPage = (href, ctx) => {
2572
+ let value = href;
2573
+ if (ctx.origin && value.startsWith(ctx.origin)) {
2574
+ value = value.slice(ctx.origin.length) || "/";
2575
+ }
2576
+ if (/^[a-z][a-z0-9+.-]*:/iu.test(value)) {
2577
+ return null;
2578
+ }
2579
+ let route = normalizeRoute(value);
2580
+ if (
2581
+ ctx.baseurl &&
2582
+ (route === ctx.baseurl || route.startsWith(`${ctx.baseurl}/`))
2583
+ ) {
2584
+ route = route.slice(ctx.baseurl.length) || "/";
2585
+ }
2586
+ return ctx.pagesByRoute.get(route) ?? null;
2587
+ };
2588
+
2589
+ const unitPath = (unit) => unit.folder ?? unit.page.finalRel;
2590
+
2591
+ /** Settle one nav section: its folder, and its children under it. */
2592
+ const placeSection = (unit, pages, notes) => {
2593
+ const { children, page } = unit;
2594
+ const dir = parentOf(page.finalRel);
2595
+ const name = path.posix.basename(stripExt(page.finalRel));
2596
+ const ext = path.posix.extname(page.finalRel);
2597
+ if (name === "index") {
2598
+ unit.folder = dir;
2599
+ } else {
2600
+ const real = dir ? `${dir}/${name}` : name;
2601
+ const inside = children.every((child) => isWithin(real, unitPath(child)));
2602
+ const taken = pages.some(
2603
+ (other) => other !== page && stripExt(other.finalRel) === `${real}/index`
2604
+ );
2605
+ const group = dir ? `${dir}/(${name})` : `(${name})`;
2606
+ unit.folder = inside && !taken ? real : group;
2607
+ planMove(pages, page.finalRel, `${unit.folder}/index${ext}`);
2608
+ }
2609
+ for (const child of children) {
2610
+ const where = unitPath(child);
2611
+ if (isWithin(unit.folder, where)) {
2612
+ continue;
2613
+ }
2614
+ if (child.folder !== null) {
2615
+ notes.push(
2616
+ `section "${child.node.title}" (${child.folder}/) sits outside its parent's folder ${unit.folder || "(the root)"}: move it by hand`
2617
+ );
2618
+ continue;
2619
+ }
2620
+ const target = `${unit.folder}/${path.posix.basename(where)}`;
2621
+ if (pages.some((other) => other.finalRel === target)) {
2622
+ notes.push(
2623
+ `couldn't move ${where} under ${unit.folder}/: ${target} exists`
2624
+ );
2625
+ continue;
2626
+ }
2627
+ planMove(pages, where, target);
2628
+ }
2629
+ };
2630
+
2631
+ /** Each folder's children, in nav order, by their first segment under it. */
2632
+ const entriesUnder = (folder, units) => {
2633
+ const names = [];
2634
+ for (const child of units) {
2635
+ const where = unitPath(child);
2636
+ // A section left outside the folder is named as it will be once moved in.
2637
+ const inside = folder ? where.slice(folder.length + 1) : where;
2638
+ const [first] = (
2639
+ isWithin(folder, where) ? inside : path.posix.basename(where)
2640
+ ).split("/");
2641
+ const slug = entrySlug(first);
2642
+ if (slug !== "" && slug !== "index" && !names.includes(slug)) {
2643
+ names.push(slug);
2644
+ }
2645
+ }
2646
+ return names;
2647
+ };
2648
+
2649
+ /** Folders between `folder` and its children that no nav section owns. */
2650
+ const collectWrappers = (folder, unit, sections, wrappers) => {
2651
+ for (const child of unit.children) {
2652
+ let dir = child.folder ?? parentOf(child.page.finalRel);
2653
+ while (dir !== folder && dir !== "" && isWithin(folder, dir)) {
2654
+ if (dir !== child.folder && !sections.has(dir)) {
2655
+ const wrapper = wrappers.get(dir) ?? { children: [], folder: dir };
2656
+ wrapper.children.push(child);
2657
+ wrappers.set(dir, wrapper);
2658
+ }
2659
+ dir = parentOf(dir);
2660
+ }
2661
+ }
2662
+ };
2663
+
2664
+ /** Whether the old page listed this section's children (`has_toc`). */
2665
+ const showedChildList = (unit, ctx) => {
2666
+ const oldFile = oldFileFor(ctx.oldDir, unit.page.oldUrl);
2667
+ const first = oldFile ? oldChildList(readFileSync(oldFile, "utf-8")) : null;
2668
+ const page = first ? navPage(first, ctx) : null;
2669
+ return Boolean(page) && unit.children.some((child) => child.page === page);
2670
+ };
2671
+
2672
+ /** `directory` inherits: stop it in a section whose page showed no list. */
2673
+ const settleDirectories = (meta, sections) => {
2674
+ const byDepth = [...meta.keys()].toSorted(
2675
+ (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
2676
+ );
2677
+ const effective = new Map();
2678
+ for (const folder of byDepth) {
2679
+ const entry = meta.get(folder);
2680
+ let parent = folder === "" ? null : parentOf(folder);
2681
+ while (parent !== null && !effective.has(parent)) {
2682
+ parent = parent === "" ? null : parentOf(parent);
2683
+ }
2684
+ const inherited = parent === null ? undefined : effective.get(parent);
2685
+ if (!entry.directory && inherited === "accordion" && sections.has(folder)) {
2686
+ entry.directory = "none";
2687
+ }
2688
+ effective.set(folder, entry.directory ?? inherited);
2689
+ }
2690
+ };
2691
+
2692
+ /** How many of the sidebar's own links match a page, and how many don't. */
2693
+ const countMatches = (nodes, ctx) => {
2694
+ const total = { matched: 0, missed: 0 };
2695
+ for (const node of nodes) {
2696
+ const inner = countMatches(node.children, ctx);
2697
+ total.matched += inner.matched;
2698
+ total.missed += inner.missed;
2699
+ if (!/^[a-z][a-z0-9+.-]*:/iu.test(node.href)) {
2700
+ if (navPage(node.href, ctx) === null) {
2701
+ total.missed += 1;
2702
+ } else {
2703
+ total.matched += 1;
2704
+ }
2705
+ }
2706
+ }
2707
+ return total;
2708
+ };
2709
+
2710
+ /** Folders and meta.ts files from the old sidebar. */
2711
+ const planNavigation = (pages, ctx, nav) => {
2712
+ const { matched, missed } = countMatches(nav, ctx);
2713
+ if (missed > matched) {
2714
+ return {
2715
+ meta: new Map(),
2716
+ notes: [
2717
+ `${missed} of the old sidebar's ${matched + missed} links match no page (is baseurl in _config.yml right?), so the navigation is left for you`,
2718
+ ],
2719
+ };
2720
+ }
2721
+ const notes = [];
2722
+ const inNav = new Set();
2723
+ const sections = new Map();
2724
+ // Post-order: a parent moves its children only once their own units settle.
2725
+ const visit = (node) => {
2726
+ const page = navPage(node.href, ctx);
2727
+ if (!page) {
2728
+ notes.push(
2729
+ /^[a-z][a-z0-9+.-]*:/iu.test(node.href)
2730
+ ? `nav link "${node.title}" (${node.href}) is external: put it in navigation.featured`
2731
+ : `nav entry "${node.title}" (${node.href}) matches no page`
2732
+ );
2733
+ return null;
2734
+ }
2735
+ if (inNav.has(page)) {
2736
+ // Just the Docs matched a `parent` to every page with that title.
2737
+ notes.push(
2738
+ `${page.rel} ("${node.title}") is listed again under another section: its parent's title names more than one page (grand_parent or ancestor picks one). It stays under the first, since Blume lists a page once`
2739
+ );
2740
+ return null;
2741
+ }
2742
+ inNav.add(page);
2743
+ const children = node.children.map(visit).filter(Boolean);
2744
+ const unit = { children, folder: null, node, page };
2745
+ if (children.length > 0) {
2746
+ placeSection(unit, pages, notes);
2747
+ sections.set(unit.folder, unit);
2748
+ }
2749
+ return unit;
2750
+ };
2751
+ const top = nav.map(visit).filter(Boolean);
2752
+ const meta = new Map();
2753
+ const wrappers = new Map();
2754
+ const root = { children: top, folder: "" };
2755
+ meta.set("", { folder: "", pages: entriesUnder("", top) });
2756
+ // Just the Docs lets the home page head a section; Blume's content root
2757
+ // index heads no group, so its children list in their own folders.
2758
+ const home = sections.get("");
2759
+ if (home) {
2760
+ sections.delete("");
2761
+ collectWrappers("", home, sections, wrappers);
2762
+ notes.push(
2763
+ `${home.page.rel} (the home page) heads "${home.node.title}" in the old sidebar, but the content root's index heads no group in Blume: its pages stay where they are and list in their folders`
2764
+ );
2765
+ }
2766
+ collectWrappers("", root, sections, wrappers);
2767
+ for (const [folder, unit] of sections) {
2768
+ const entry = { folder, pages: entriesUnder(folder, unit.children) };
2769
+ meta.set(folder, entry);
2770
+ collectWrappers(folder, unit, sections, wrappers);
2771
+ const label = unit.node.title;
2772
+ const humanized = humanize(folder.split("/").at(-1));
2773
+ const oldTitle = textOf(unit.page.data.title);
2774
+ const pageTitle = unit.page.h1 ?? oldTitle;
2775
+ // The sidebar.label titleFields writes: the old title, where the H1 differs.
2776
+ const sidebarLabel =
2777
+ textOf(recordOf(unit.page.data.sidebar).label) ??
2778
+ (unit.page.h1 && oldTitle !== unit.page.h1 ? oldTitle : undefined);
2779
+ if (label !== humanized) {
2780
+ entry.title = label;
2781
+ }
2782
+ // Hide the duplicate index row where Blume won't flag the titles: the
2783
+ // index check passes when the title or sidebar.label is the folder title.
2784
+ if (pageTitle === label || label === humanized || sidebarLabel === label) {
2785
+ unit.page.hidden = true;
2786
+ edit(
2787
+ unit.page.report,
2788
+ "section index row hidden (the group row links it)"
2789
+ );
2790
+ } else {
2791
+ notes.push(
2792
+ `${unit.page.finalRel}: its title "${pageTitle}" differs from the section label "${label}", so its row stays visible (hidden, it would raise BLUME_NAV_INDEX_TITLE_MISMATCH): to hide it, set its sidebar.label to "${label}" too`
2793
+ );
2794
+ }
2795
+ if (showedChildList(unit, ctx)) {
2796
+ entry.directory = "accordion";
2797
+ }
2798
+ }
2799
+ // Just the Docs listed a wrapper's pages at the top of its nav, open
2800
+ // everywhere; Blume's group display opens one only on its own pages, or
2801
+ // when it's the sidebar's only top-level row.
2802
+ for (const [folder, wrapper] of wrappers) {
2803
+ if (!meta.has(folder)) {
2804
+ meta.set(folder, {
2805
+ collapsed: false,
2806
+ folder,
2807
+ pages: entriesUnder(folder, wrapper.children),
2808
+ });
2809
+ notes.push(
2810
+ `${folder}/ isn't a section in the old sidebar: it becomes one group, kept open (collapsed: false)`
2811
+ );
2812
+ }
2813
+ }
2814
+ settleDirectories(meta, sections);
2815
+ // A hidden page leaves search, the sitemap, and llms.txt too, unless a
2816
+ // group row links it or it's the home page, which the root URL serves.
2817
+ const outside = pages.filter(
2818
+ (page) => !(inNav.has(page) || page.redirectOnly)
2819
+ );
2820
+ for (const page of outside) {
2821
+ page.hidden = true;
2822
+ edit(page.report, "not in the old sidebar → sidebar.hidden");
2823
+ }
2824
+ const unlisted = outside.filter((page) => page.oldRoute !== "/");
2825
+ if (unlisted.length > 0) {
2826
+ notes.push(
2827
+ `${unlisted.length} page(s) outside the old sidebar are hidden, which also leaves them out of search, the sitemap, and llms.txt, where Just the Docs still searched them: set search: { indexing: { includeHiddenPages: true } } to keep them searchable`
2828
+ );
2829
+ }
2830
+ return { meta, notes };
2831
+ };
2832
+
2833
+ const renderMeta = (entry) => {
2834
+ const lines = [
2835
+ META_HEADER,
2836
+ 'import { defineMeta } from "blume";',
2837
+ "",
2838
+ "export default defineMeta({",
2839
+ ];
2840
+ if (entry.title) {
2841
+ lines.push(` title: ${JSON.stringify(entry.title)},`);
2842
+ }
2843
+ if (entry.collapsed === false) {
2844
+ lines.push(" collapsed: false,");
2845
+ }
2846
+ if (entry.directory) {
2847
+ lines.push(` directory: ${JSON.stringify(entry.directory)},`);
2848
+ }
2849
+ if (entry.pages.length > 1) {
2850
+ lines.push(
2851
+ " pages: [",
2852
+ ...entry.pages.map((slug) => ` ${JSON.stringify(slug)},`),
2853
+ " ],"
2854
+ );
2855
+ }
2856
+ lines.push("});", "");
2857
+ return lines.join("\n");
2858
+ };
2859
+
2860
+ const worthWriting = (entry) =>
2861
+ Boolean(entry.title) ||
2862
+ Boolean(entry.directory) ||
2863
+ entry.collapsed === false ||
2864
+ entry.pages.length > 1;
2865
+
2866
+ // --- Run ------------------------------------------------------------------------------------
2867
+
2868
+ /** A page's old URL from Jekyll's rules: its permalink, or its path. */
2869
+ const ruleUrl = (rel, data, style) => {
2870
+ const dir = parentOf(rel)
2871
+ .split("/")
2872
+ .filter((segment) => segment && !groupLabelOf(segment))
2873
+ .join("/");
2874
+ const name = path.posix.basename(stripExt(rel));
2875
+ const slug = textOf(data.slug);
2876
+ const permalink = textOf(data.permalink);
2877
+ if (slug !== undefined) {
2878
+ return `/${slug.replace(/^\/+/u, "")}`;
2879
+ }
2880
+ if (permalink !== undefined) {
2881
+ const expanded = permalink
2882
+ .replaceAll(":path", dir)
2883
+ .replaceAll(":basename", name)
2884
+ .replaceAll(":title", name)
2885
+ .replaceAll(":output_ext", ".html");
2886
+ return `/${expanded}`.replaceAll(/\/{2,}/gu, "/");
2887
+ }
2888
+ if (name === "index") {
2889
+ return dir ? `/${dir}/` : "/";
2890
+ }
2891
+ return `/${dir ? `${dir}/` : ""}${name}${permalinkSuffix(style)}`;
2892
+ };
2893
+
2894
+ /** The permalink `defaults:` gives a page: the most specific scope wins. */
2895
+ const defaultPermalink = (rel, defaults) => {
2896
+ let best;
2897
+ for (const entry of listOf(defaults)) {
2898
+ const scope = recordOf(recordOf(entry).scope);
2899
+ const permalink = textOf(recordOf(recordOf(entry).values).permalink);
2900
+ const type = textOf(scope.type);
2901
+ const where = (textOf(scope.path) ?? "").replaceAll(/^\.?\/+|\/+$/gu, "");
2902
+ if (
2903
+ permalink !== undefined &&
2904
+ (type === undefined || type === "pages") &&
2905
+ (where === "" || rel === where || rel.startsWith(`${where}/`)) &&
2906
+ (best === undefined || where.length >= best.where.length)
2907
+ ) {
2908
+ best = { permalink, where };
2909
+ }
2910
+ }
2911
+ return best?.permalink;
2912
+ };
2913
+
2914
+ const readFile = (root, rel, ctx) => {
2915
+ const original = readFileSync(path.join(root, rel), "utf-8");
2916
+ const text = normalizeText(original);
2917
+ const { body, raw } = splitFrontmatter(text);
2918
+ let data = {};
2919
+ let fmError = null;
2920
+ if (raw !== null) {
2921
+ try {
2922
+ data = recordOf(parseYaml(raw));
2923
+ } catch (error) {
2924
+ fmError = error.message;
2925
+ }
2926
+ }
2927
+ const permalink =
2928
+ textOf(data.permalink) ?? defaultPermalink(rel, ctx.config.defaults);
2929
+ const fromRules = ruleUrl(
2930
+ rel,
2931
+ { ...data, permalink },
2932
+ textOf(ctx.config.permalink)
2933
+ );
2934
+ const route = normalizeRoute(fromRules);
2935
+ const oldUrl = (ctx.oldDir && builtUrl(ctx.oldDir, route)) || fromRules;
2936
+ return {
2937
+ body,
2938
+ data,
2939
+ finalRel: rel.replace(/\.markdown$/u, ".md"),
2940
+ fmError,
2941
+ hasFrontmatter: raw !== null,
2942
+ includeProblems: new Map(),
2943
+ includers: new Set(),
2944
+ includes: new Set(),
2945
+ oldRoute: route,
2946
+ oldUrl,
2947
+ partial: false,
2948
+ props: new Set(),
2949
+ raw,
2950
+ redirectOnly: textOf(data.redirect_to) !== undefined,
2951
+ rel,
2952
+ report: newReport(),
2953
+ siteKeys: new Set(),
2954
+ text: original,
2955
+ };
2956
+ };
2957
+
2958
+ const convertFile = (file, ctx) => {
2959
+ const { report } = file;
2960
+ let text = protectRaw(file.body, report);
2961
+ text = convertLiquid(text, file, ctx);
2962
+ text = removeToc(text, report);
2963
+ if (!file.partial && !file.fmError) {
2964
+ const taken = takeH1(text);
2965
+ if (taken.title !== undefined) {
2966
+ // Blume leaves front matter as written, so a variable the H1 showed
2967
+ // goes into the title as its value.
2968
+ file.h1 = taken.title.replaceAll(
2969
+ /\{\{(?<key>[\w-]+)\}\}/gu,
2970
+ byGroups(({ key }, whole) => ctx.variables.get(key) ?? whole)
2971
+ );
2972
+ ({ text } = taken);
2973
+ edit(report, "body H1 → title");
2974
+ if (/\{\{|\{%/u.test(file.h1)) {
2975
+ note(
2976
+ report,
2977
+ `the H1 "${file.h1}" holds Liquid, which Blume shows as written in a title: write what the old page showed`
2978
+ );
2979
+ }
2980
+ }
2981
+ }
2982
+ text = removeExtensions(text, report);
2983
+ text = convertBlocks(text.split("\n"), ctx, report).join("\n");
2984
+ text = convertSpanIals(text, report);
2985
+ text = convertHeadingIds(text, report);
2986
+ file.converted = convertMarkdownAttr(text, report);
2987
+ };
2988
+
2989
+ /** The one page a partial ends up in, through nested includes, or null. */
2990
+ const onlyPage = (partial) => {
2991
+ const found = new Set();
2992
+ const seen = new Set();
2993
+ const climb = (file) => {
2994
+ if (seen.has(file)) {
2995
+ return;
2996
+ }
2997
+ seen.add(file);
2998
+ if (file.partial) {
2999
+ for (const includer of file.includers) {
3000
+ climb(includer);
3001
+ }
3002
+ } else {
3003
+ found.add(file);
3004
+ }
3005
+ };
3006
+ climb(partial);
3007
+ return found.size === 1 ? [...found][0] : null;
3008
+ };
3009
+
3010
+ const finishLinks = (file, ctx) => {
3011
+ const page = file.partial ? onlyPage(file) : file;
3012
+ file.converted = convertLinks(
3013
+ file.converted,
3014
+ {
3015
+ baseUrl: page?.oldUrl ?? null,
3016
+ finalRel: file.partial ? file.rel : file.finalRel,
3017
+ sourceRel: file.rel,
3018
+ },
3019
+ file,
3020
+ ctx
3021
+ );
3022
+ };
3023
+
3024
+ const tidy = (text) =>
3025
+ `${mapProse(text, (prose) => prose.replaceAll(/\n{3,}/gu, "\n\n"))
3026
+ .replace(/^\n+/u, "")
3027
+ .replace(/\s*$/u, "")}\n`;
3028
+
3029
+ /** Redirects a page's front matter and old URL call for, and its slug. */
3030
+ const settleUrls = (page, redirects) => {
3031
+ const { report } = page;
3032
+ if (blumeRoute(page.finalRel) !== page.oldRoute) {
3033
+ if (page.oldRoute === "/") {
3034
+ note(
3035
+ report,
3036
+ "this page served / but isn't the content root's index: move it to index.md"
3037
+ );
3038
+ } else {
3039
+ page.slug = page.oldRoute.slice(1);
3040
+ edit(report, "slug pins the old URL");
3041
+ }
3042
+ }
3043
+ for (const from of listOf(page.data.redirect_from)) {
3044
+ const text = textOf(from);
3045
+ const source = text?.startsWith("/") ? text : `/${text}`;
3046
+ if (text === undefined || normalizeRoute(source) === page.oldRoute) {
3047
+ note(
3048
+ report,
3049
+ `redirect_from ${source} is empty or the page's own URL: skipped`
3050
+ );
3051
+ continue;
3052
+ }
3053
+ redirects.push({ from: source, to: page.oldRoute });
3054
+ edit(report, "redirect_from → redirects");
3055
+ }
3056
+ const target = textOf(page.data.redirect_to);
3057
+ if (target !== undefined) {
3058
+ const to = /^[a-z][a-z0-9+.-]*:/iu.test(target)
3059
+ ? target
3060
+ : normalizeRoute(target.startsWith("/") ? target : `/${target}`);
3061
+ redirects.push({ from: page.oldRoute, to });
3062
+ edit(report, "redirect_to → redirects");
3063
+ }
3064
+ if (
3065
+ page.oldUrl.endsWith(".html") &&
3066
+ !page.unbuilt &&
3067
+ !/(?:^|\/)(?:index|404)\.html$/u.test(page.oldUrl)
3068
+ ) {
3069
+ redirects.push({ from: page.oldUrl, to: page.oldRoute });
3070
+ edit(report, ".html URL → redirect");
3071
+ }
3072
+ if (page.oldRoute === "/404") {
3073
+ note(
3074
+ report,
3075
+ "a 404 page: delete it (Blume ships one; pages/404.astro replaces it)"
3076
+ );
3077
+ }
3078
+ };
3079
+
3080
+ /** Which pages and partials need MDX: their own syntax, or a partial's. */
3081
+ const settleMdx = (pages) => {
3082
+ const needs = new Map();
3083
+ const needsMdx = (file, seen) => {
3084
+ if (needs.has(file)) {
3085
+ return needs.get(file);
3086
+ }
3087
+ if (seen.has(file)) {
3088
+ return false;
3089
+ }
3090
+ seen.add(file);
3091
+ const result =
3092
+ needsMdxText(file.converted) ||
3093
+ file.rel.endsWith(".mdx") ||
3094
+ [...file.includes].some((partial) => needsMdx(partial, seen));
3095
+ needs.set(file, result);
3096
+ return result;
3097
+ };
3098
+ const mdxPartials = new Set();
3099
+ const markPartials = (file) => {
3100
+ for (const partial of file.includes) {
3101
+ if (!mdxPartials.has(partial)) {
3102
+ mdxPartials.add(partial);
3103
+ markPartials(partial);
3104
+ }
3105
+ }
3106
+ };
3107
+ for (const page of pages) {
3108
+ page.mdx = needsMdx(page, new Set());
3109
+ if (page.mdx) {
3110
+ page.finalRel = page.finalRel.replace(/\.(?:md|markdown)$/u, ".mdx");
3111
+ markPartials(page);
3112
+ }
3113
+ }
3114
+ return mdxPartials;
3115
+ };
3116
+
3117
+ const RAW_REFERENCE = new RegExp(
3118
+ `${RAW_CHARS.get("{")}{2}\\s*(?<name>[\\w-]+)\\s*${RAW_CHARS.get("}")}{2}`,
3119
+ "gu"
3120
+ );
3121
+
3122
+ /** `{{name}}` references Blume would fill in where Jekyll printed them as written. */
3123
+ const variableClashes = (file, protectedText, ctx) => {
3124
+ for (const match of protectedText.matchAll(RAW_REFERENCE)) {
3125
+ const { name } = match.groups;
3126
+ if (ctx.variables.has(name) || file.props.has(name)) {
3127
+ note(
3128
+ file.report,
3129
+ `{{ ${name} }} sat in {% raw %}, so the old page printed it as written, but Blume fills in ${name} wherever {{${name}}} appears, code included: rename the ${ctx.variables.has(name) ? "variable (in jekyll-migration.json and the pages using it)" : "prop"}`
3130
+ );
3131
+ }
3132
+ }
3133
+ for (const key of file.siteKeys) {
3134
+ if (file.props.has(key)) {
3135
+ note(
3136
+ file.report,
3137
+ `{{${key}}} now reads both {{ site.${key} }} and {{ include.${key} }}, and a passed ${key} wins for both: rename one`
3138
+ );
3139
+ }
3140
+ }
3141
+ };
3142
+
3143
+ /** The final text of a page or partial, and what's left to review. */
3144
+ const finalText = (file, mdx, ctx) => {
3145
+ let body = file.converted;
3146
+ if (mdx) {
3147
+ body = makeMdxSafe(body, file.report);
3148
+ }
3149
+ let head = "";
3150
+ if (file.partial || file.fmError) {
3151
+ if (file.fmError) {
3152
+ note(file.report, `front matter: ${file.fmError}; left as written`);
3153
+ }
3154
+ head = file.raw === null ? "" : `---\n${file.raw}\n---\n\n`;
3155
+ } else {
3156
+ const yaml = emitYaml(convertFrontmatter(file));
3157
+ head = yaml ? `---\n${yaml}\n---\n\n` : "";
3158
+ if (file.h1 === undefined && textOf(file.data.title) === undefined) {
3159
+ note(file.report, "no title and no H1: give the page a title");
3160
+ }
3161
+ }
3162
+ // Reviews count lines in the file as written, front matter included.
3163
+ const protectedText = `${head}${tidy(body)}`;
3164
+ variableClashes(file, protectedText, ctx);
3165
+ const start = head.split("\n").length - 1;
3166
+ const reviews = leftovers(protectedText, start, file.includeProblems);
3167
+ const text = restoreRaw(protectedText);
3168
+ if (mdx) {
3169
+ reviews.push(...mdxHazards(text, start));
3170
+ }
3171
+ return { reviews, text };
3172
+ };
3173
+
3174
+ const writeFile = (root, file, text, remove) => {
3175
+ if (remove) {
3176
+ rmSync(path.join(root, file.rel));
3177
+ return;
3178
+ }
3179
+ const target = path.join(root, file.finalRel);
3180
+ mkdirSync(path.dirname(target), { recursive: true });
3181
+ writeFileSync(target, text);
3182
+ if (file.finalRel !== file.rel) {
3183
+ rmSync(path.join(root, file.rel));
3184
+ }
3185
+ };
3186
+
3187
+ /** Notes for theme files and settings the codemod leaves. */
3188
+ const themeNotes = (files, ctx) => {
3189
+ const notes = [];
3190
+ for (const rel of files.filter((file) =>
3191
+ /^(?:_sass|assets\/css)\/.*\.s?css$/u.test(file)
3192
+ )) {
3193
+ const lines = readFileSync(path.join(ctx.root, rel), "utf-8").split("\n");
3194
+ for (const [index, line] of lines.entries()) {
3195
+ if (/display:\s*none|\bcontent:\s*["']/u.test(line)) {
3196
+ const rule =
3197
+ line.trim().length > 120
3198
+ ? `${line.trim().slice(0, 120)}…`
3199
+ : line.trim();
3200
+ notes.push(
3201
+ `${rel}:${index + 1}: \`${rule}\` hides or adds content: find what it targets in the pages and decide`
3202
+ );
3203
+ }
3204
+ }
3205
+ }
3206
+ const hookName = new RegExp(
3207
+ `^${ctx.includesDir}/(?:head_custom|header_custom|footer_custom|nav_footer_custom|title|search_placeholder_custom|toc_heading_custom)\\.html$|^_layouts/`,
3208
+ "u"
3209
+ );
3210
+ const hooks = files.filter((rel) => hookName.test(rel));
3211
+ if (hooks.length > 0) {
3212
+ notes.push(`Theme files to port or drop by hand: ${hooks.join(", ")}`);
3213
+ }
3214
+ for (const name of Object.keys(recordOf(ctx.config.collections))) {
3215
+ notes.push(
3216
+ `collection _${name}/ isn't converted: rename it ${name}/ (for a /:collection/:path/ permalink) and run again`
3217
+ );
3218
+ }
3219
+ return notes;
3220
+ };
3221
+
3222
+ const writeMeta = (root, meta, write, notes) => {
3223
+ const written = [];
3224
+ for (const [folder, entry] of [...meta].toSorted(([a], [b]) =>
3225
+ a.localeCompare(b)
3226
+ )) {
3227
+ const rel = folder ? `${folder}/meta.ts` : "meta.ts";
3228
+ const target = path.join(root, rel);
3229
+ const content = renderMeta(entry);
3230
+ const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
3231
+ if (!worthWriting(entry) || current === content) {
3232
+ continue;
3233
+ }
3234
+ if (current !== null && !current.startsWith(META_HEADER)) {
3235
+ notes.push(
3236
+ `${rel} exists and wasn't written by this codemod: merge this by hand:\n${content}`
3237
+ );
3238
+ continue;
3239
+ }
3240
+ written.push(rel);
3241
+ if (write) {
3242
+ mkdirSync(path.dirname(target), { recursive: true });
3243
+ writeFileSync(target, content);
3244
+ }
3245
+ }
3246
+ return written;
3247
+ };
3248
+
3249
+ const collectFiles = (root, ctx) => {
3250
+ const exclude = excludedBy(listOf(ctx.config.exclude).map(String));
3251
+ const skip = (rel, name) =>
3252
+ ALWAYS_SKIP.has(rel) ||
3253
+ name.startsWith(".") ||
3254
+ (name.startsWith("_") &&
3255
+ rel !== ctx.includesDir &&
3256
+ !rel.startsWith("_sass") &&
3257
+ rel !== "_layouts") ||
3258
+ exclude(rel);
3259
+ return walk(root, "", skip);
3260
+ };
3261
+
3262
+ const buildContext = (root, options, notes) => {
3263
+ const config = readConfig(root, notes);
3264
+ const url = textOf(config.url)?.replace(/\/+$/u, "");
3265
+ const baseurl = textOf(config.baseurl)?.replace(/\/+$/u, "") ?? "";
3266
+ return {
3267
+ baseurl,
3268
+ callouts: options.callouts
3269
+ ? new Map(options.callouts.map((name) => [name, {}]))
3270
+ : calloutConfig(config),
3271
+ config,
3272
+ includesDir: (textOf(config.includes_dir) ?? "_includes").replaceAll(
3273
+ /^\.?\/+|\/+$/gu,
3274
+ ""
3275
+ ),
3276
+ oldDir: options.old ? path.resolve(options.old) : null,
3277
+ origin: url ? `${url}${baseurl}` : null,
3278
+ pagesByRoute: new Map(),
3279
+ pagesBySource: new Map(),
3280
+ partials: new Map(),
3281
+ redirectFroms: new Set(),
3282
+ root,
3283
+ variables: new Map(),
3284
+ };
3285
+ };
3286
+
3287
+ const addPage = (file, pages, ctx, notes) => {
3288
+ pages.push(file);
3289
+ ctx.pagesBySource.set(file.rel, file);
3290
+ const other = ctx.pagesByRoute.get(file.oldRoute);
3291
+ if (other) {
3292
+ notes.push(
3293
+ `${file.rel} and ${other.rel} both served ${file.oldUrl}: keep one`
3294
+ );
3295
+ }
3296
+ ctx.pagesByRoute.set(file.oldRoute, file);
3297
+ for (const from of listOf(file.data.redirect_from)) {
3298
+ const text = textOf(from);
3299
+ if (text !== undefined) {
3300
+ ctx.redirectFroms.add(
3301
+ normalizeRoute(text.startsWith("/") ? text : `/${text}`)
3302
+ );
3303
+ }
3304
+ }
3305
+ };
3306
+
3307
+ /** The files a page or partial names in `{% include_relative %}` tags. */
3308
+ const relativeTargets = (file) =>
3309
+ file.body.split("\n").flatMap((line) => {
3310
+ const match = line.match(INCLUDE_TAG);
3311
+ const parsed =
3312
+ match?.groups.tag === "include_relative"
3313
+ ? parseInclude(match.groups.rest)
3314
+ : {};
3315
+ return parsed.file
3316
+ ? [path.posix.normalize(path.posix.join(parentOf(file.rel), parsed.file))]
3317
+ : [];
3318
+ });
3319
+
3320
+ /** Pages and Markdown partials, read; files that aren't pages, noted. */
3321
+ const readSite = (root, files, ctx, notes) => {
3322
+ const pages = [];
3323
+ const skipped = [];
3324
+ for (const rel of files.filter((file) => DOC_EXT.test(file))) {
3325
+ const file = readFile(root, rel, ctx);
3326
+ const built =
3327
+ file.hasFrontmatter ||
3328
+ Boolean(ctx.oldDir && oldFileFor(ctx.oldDir, file.oldUrl));
3329
+ if (rel.startsWith(`${ctx.includesDir}/`)) {
3330
+ file.partial = true;
3331
+ file.finalRel = rel;
3332
+ ctx.partials.set(rel, file);
3333
+ } else if (!rel.startsWith("_") && built) {
3334
+ addPage(file, pages, ctx, notes);
3335
+ } else if (!rel.startsWith("_")) {
3336
+ skipped.push(rel);
3337
+ }
3338
+ }
3339
+ // Markdown a page pulls in with include_relative is a partial too, even
3340
+ // without front matter or under an `_` folder.
3341
+ const queue = [...pages, ...ctx.partials.values()];
3342
+ for (const file of queue) {
3343
+ for (const target of relativeTargets(file)) {
3344
+ if (
3345
+ DOC_EXT.test(target) &&
3346
+ !ctx.pagesBySource.has(target) &&
3347
+ !ctx.partials.has(target) &&
3348
+ isFile(path.join(root, target))
3349
+ ) {
3350
+ const partial = readFile(root, target, ctx);
3351
+ partial.partial = true;
3352
+ partial.finalRel = target;
3353
+ ctx.partials.set(target, partial);
3354
+ queue.push(partial);
3355
+ if (!target.split("/").some((segment) => segment.startsWith("_"))) {
3356
+ notes.push(
3357
+ `${target} is included with include_relative: Blume would publish it as a page, so rename it with a leading underscore (and its <include> paths) or leave it out of content.include`
3358
+ );
3359
+ }
3360
+ }
3361
+ }
3362
+ }
3363
+ const unread = skipped.filter((rel) => !ctx.partials.has(rel));
3364
+ if (unread.length > 0) {
3365
+ notes.push(
3366
+ `Not pages (no front matter, so Jekyll copied them as files): ${unread.join(", ")}`
3367
+ );
3368
+ }
3369
+ // A partial that holds only a TOC is dropped where it's included.
3370
+ for (const partial of ctx.partials.values()) {
3371
+ partial.tocOnly =
3372
+ partial.body.split("\n").some((line) => TOC_IAL.test(line)) &&
3373
+ removeToc(partial.body, newReport()).trim() === "";
3374
+ }
3375
+ return pages;
3376
+ };
3377
+
3378
+ /** The old sidebar → folders and meta.ts, and pages the old build lacks. */
3379
+ const readNavigation = (pages, ctx, options, notes) => {
3380
+ if (!ctx.oldDir) {
3381
+ notes.push(
3382
+ "No --old build: URLs come from the permalink rules, and the navigation is left for you"
3383
+ );
3384
+ return { meta: new Map(), notes: [] };
3385
+ }
3386
+ for (const page of pages.filter((one) => !one.redirectOnly)) {
3387
+ if (!oldFileFor(ctx.oldDir, page.oldUrl)) {
3388
+ page.unbuilt = true;
3389
+ note(
3390
+ page.report,
3391
+ `${page.oldUrl} isn't in the old build: was it published? (check permalink, defaults, and published: false)`
3392
+ );
3393
+ }
3394
+ }
3395
+ const nav = readOldNav(ctx.oldDir);
3396
+ if (!nav) {
3397
+ notes.push(
3398
+ `${options.old} has no Just the Docs sidebar (nav#site-nav) in index.html, so the navigation is left for you`
3399
+ );
3400
+ return { meta: new Map(), notes: [] };
3401
+ }
3402
+ return planNavigation(pages, ctx, nav);
3403
+ };
3404
+
3405
+ /** A page whose heading lives in the one partial it includes. */
3406
+ const noteHeadingInPartial = (page) => {
3407
+ if (page.h1 !== undefined) {
3408
+ return;
3409
+ }
3410
+ const partial = [...page.includes].find(
3411
+ (one) => one.partial && takeH1(one.converted).title !== undefined
3412
+ );
3413
+ if (partial && onlyPage(partial) === page) {
3414
+ note(
3415
+ page.report,
3416
+ `its heading is the H1 in ${partial.rel}: move it into this page's title`
3417
+ );
3418
+ }
3419
+ };
3420
+
3421
+ /** Write (or plan) one file; its entry in the report, or null. */
3422
+ const finishFile = (file, mdx, ctx, write) => {
3423
+ const { root } = ctx;
3424
+ const { reviews, text } = finalText(file, mdx, ctx);
3425
+ const remove = file.redirectOnly && file.converted.trim() === "";
3426
+ if (file.redirectOnly && !remove) {
3427
+ note(
3428
+ file.report,
3429
+ "redirect_to on a page with content: the redirect replaces it, so delete the page if that's right"
3430
+ );
3431
+ }
3432
+ const changed = remove || text !== file.text || file.finalRel !== file.rel;
3433
+ for (const message of file.report.notes) {
3434
+ reviews.push({ line: 0, message });
3435
+ }
3436
+ if (write && changed) {
3437
+ writeFile(root, file, text, remove);
3438
+ }
3439
+ if (!(changed || reviews.length > 0)) {
3440
+ return null;
3441
+ }
3442
+ let moved;
3443
+ if (remove) {
3444
+ moved = "(removed)";
3445
+ } else if (file.finalRel !== file.rel) {
3446
+ moved = file.finalRel;
3447
+ }
3448
+ return {
3449
+ changed,
3450
+ edits: Object.fromEntries(
3451
+ [...file.report.edits].toSorted(([a], [b]) => a.localeCompare(b))
3452
+ ),
3453
+ file: file.rel,
3454
+ moved,
3455
+ reviews: reviews.toSorted((a, b) => a.line - b.line),
3456
+ };
3457
+ };
3458
+
3459
+ /** Redirects and variables, for blume.config.ts to import. */
3460
+ const writeMigration = (root, redirects, ctx, write) => {
3461
+ const migration = {
3462
+ redirects: redirects.toSorted((a, b) => a.from.localeCompare(b.from)),
3463
+ variables: Object.fromEntries(
3464
+ [...ctx.variables].toSorted(([a], [b]) => a.localeCompare(b))
3465
+ ),
3466
+ };
3467
+ // Written even when empty: it marks the folder as converted.
3468
+ const file = path.join(root, "jekyll-migration.json");
3469
+ const text = `${JSON.stringify(migration, null, 2)}\n`;
3470
+ const current = existsSync(file) ? readFileSync(file, "utf-8") : null;
3471
+ if (write && current !== text) {
3472
+ writeFileSync(file, text);
3473
+ }
3474
+ return migration;
3475
+ };
3476
+
3477
+ const run = (root, options) => {
3478
+ const notes = [];
3479
+ const ctx = buildContext(root, options, notes);
3480
+ if (!ctx.callouts) {
3481
+ notes.push(
3482
+ "_config.yml declares no callouts:, so IALs with classes like .note were left: if _sass/custom styled them as callouts, pass --callouts note,warning on the write run (else delete them)"
3483
+ );
3484
+ }
3485
+ const files = collectFiles(root, ctx);
3486
+ const pages = readSite(root, files, ctx, notes);
3487
+ const everything = [...pages, ...ctx.partials.values()];
3488
+ for (const file of everything) {
3489
+ convertFile(file, ctx);
3490
+ }
3491
+ const navigation = readNavigation(pages, ctx, options, notes);
3492
+ const redirects = [];
3493
+ for (const page of pages) {
3494
+ settleUrls(page, redirects);
3495
+ noteHeadingInPartial(page);
3496
+ }
3497
+ for (const file of everything) {
3498
+ finishLinks(file, ctx);
3499
+ }
3500
+ const mdxPartials = settleMdx(pages);
3501
+ const results = everything
3502
+ .map((file) =>
3503
+ finishFile(
3504
+ file,
3505
+ file.partial ? mdxPartials.has(file) : file.mdx,
3506
+ ctx,
3507
+ options.write
3508
+ )
3509
+ )
3510
+ .filter(Boolean);
3511
+ return {
3512
+ files: results,
3513
+ meta: writeMeta(root, navigation.meta, options.write, navigation.notes),
3514
+ migration: writeMigration(root, redirects, ctx, options.write),
3515
+ notes: [...notes, ...navigation.notes, ...themeNotes(files, ctx)],
3516
+ };
3517
+ };
3518
+
3519
+ const HELP = [
3520
+ "jekyll-codemod — the mechanical part of a Jekyll (Just the Docs) → Blume migration",
3521
+ "",
3522
+ " node jekyll-codemod.mjs --old <built site> <jekyll dir> dry run (report only)",
3523
+ " node jekyll-codemod.mjs --old <built site> --write <jekyll dir> apply in place",
3524
+ " node jekyll-codemod.mjs --old <built site> --json <jekyll dir> JSON report",
3525
+ "",
3526
+ " --callouts a,b the callout classes of a site whose _config.yml has no callouts:",
3527
+ "",
3528
+ ].join("\n");
3529
+
3530
+ const parseArgs = (argv) => {
3531
+ const options = {
3532
+ callouts: null,
3533
+ dir: null,
3534
+ help: false,
3535
+ json: false,
3536
+ old: null,
3537
+ write: false,
3538
+ };
3539
+ let pending = null;
3540
+ for (const arg of argv) {
3541
+ if (pending === "--old") {
3542
+ options.old = arg;
3543
+ pending = null;
3544
+ } else if (pending === "--callouts") {
3545
+ options.callouts = arg
3546
+ .split(",")
3547
+ .map((name) => name.trim())
3548
+ .filter(Boolean);
3549
+ pending = null;
3550
+ } else if (arg === "--old" || arg === "--callouts") {
3551
+ pending = arg;
3552
+ } else if (arg === "--write") {
3553
+ options.write = true;
3554
+ } else if (arg === "--json") {
3555
+ options.json = true;
3556
+ } else if (arg === "--help" || arg === "-h") {
3557
+ options.help = true;
3558
+ } else {
3559
+ options.dir = arg;
3560
+ }
3561
+ }
3562
+ return options;
3563
+ };
3564
+
3565
+ const printReport = (result, write) => {
3566
+ const totals = new Map();
3567
+ let reviewCount = 0;
3568
+ for (const file of result.files) {
3569
+ process.stdout.write(
3570
+ `\n${file.moved ? `${file.file} → ${file.moved}` : file.file}\n`
3571
+ );
3572
+ for (const [kind, count] of Object.entries(file.edits)) {
3573
+ process.stdout.write(` ${count} × ${kind}\n`);
3574
+ totals.set(kind, (totals.get(kind) ?? 0) + count);
3575
+ }
3576
+ for (const { line, message } of file.reviews) {
3577
+ process.stdout.write(
3578
+ ` REVIEW${line ? ` line ${line}` : ""}: ${message}\n`
3579
+ );
3580
+ }
3581
+ reviewCount += file.reviews.length;
3582
+ }
3583
+ if (result.meta.length > 0) {
3584
+ process.stdout.write(`\nmeta.ts: ${result.meta.join(", ")}\n`);
3585
+ }
3586
+ if (result.migration) {
3587
+ const { redirects, variables } = result.migration;
3588
+ process.stdout.write(
3589
+ [
3590
+ "",
3591
+ `jekyll-migration.json: ${redirects.length} redirect(s), ${Object.keys(variables).length} variable(s). In blume.config.ts:`,
3592
+ ' import migration from "./jekyll-migration.json" with { type: "json" };',
3593
+ " redirects: migration.redirects, variables: migration.variables,",
3594
+ "",
3595
+ ].join("\n")
3596
+ );
3597
+ }
3598
+ if (result.notes.length > 0) {
3599
+ process.stdout.write("\nNotes\n");
3600
+ for (const message of result.notes) {
3601
+ process.stdout.write(` ${message}\n`);
3602
+ }
3603
+ }
3604
+ process.stdout.write("\nTotals\n");
3605
+ for (const [kind, count] of [...totals].toSorted(([a], [b]) =>
3606
+ a.localeCompare(b)
3607
+ )) {
3608
+ process.stdout.write(` ${count} × ${kind}\n`);
3609
+ }
3610
+ const changed = result.files.filter((file) => file.changed).length;
3611
+ const verb = write ? "changed" : "would change";
3612
+ const hint = write ? "" : " Re-run with --write to apply.";
3613
+ process.stdout.write(
3614
+ `\n${result.files.length} file(s) with findings, ${changed} ${verb}, ${reviewCount} item(s) to review.${hint}\n`
3615
+ );
3616
+ };
3617
+
3618
+ const main = () => {
3619
+ const options = parseArgs(process.argv.slice(2));
3620
+ if (options.help || !options.dir) {
3621
+ process.stdout.write(`${HELP}\n`);
3622
+ return;
3623
+ }
3624
+ const root = path.resolve(options.dir);
3625
+ if (!existsSync(path.join(root, "_config.yml"))) {
3626
+ process.stderr.write(
3627
+ `No _config.yml in ${root}: run the codemod from the Jekyll source directory, before deleting the Jekyll files.\n`
3628
+ );
3629
+ process.exitCode = 1;
3630
+ return;
3631
+ }
3632
+ if (options.write && existsSync(path.join(root, "jekyll-migration.json"))) {
3633
+ process.stderr.write(
3634
+ `${root} is already converted (it has jekyll-migration.json): a second --write would take each page's next H1 as its title and convert what {% raw %} protected. Run it once, on a fresh copy of the Jekyll source.\n`
3635
+ );
3636
+ process.exitCode = 1;
3637
+ return;
3638
+ }
3639
+ const result = run(root, options);
3640
+ if (options.json) {
3641
+ process.stdout.write(
3642
+ `${JSON.stringify({ ...result, wrote: options.write }, null, 2)}\n`
3643
+ );
3644
+ return;
3645
+ }
3646
+ printReport(result, options.write);
3647
+ };
3648
+
3649
+ main();