blume 2.1.3 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (397) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +134 -0
  3. package/README.md +4 -4
  4. package/dist/cli/{chunk-y4msrwm9.js → chunk-080mfch6.js} +15 -15
  5. package/dist/cli/{chunk-gfc5fqvf.js → chunk-1ch3ct3r.js} +18 -18
  6. package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-1ch3ct3r.js.map} +1 -1
  7. package/dist/cli/{chunk-32khwcb6.js → chunk-2ssh60qh.js} +42 -69
  8. package/dist/cli/{chunk-32khwcb6.js.map → chunk-2ssh60qh.js.map} +9 -10
  9. package/dist/cli/{chunk-9qzgg6mj.js → chunk-34wap3ed.js} +3 -3
  10. package/dist/cli/{chunk-k3gr1nm9.js → chunk-40vk8kgf.js} +13 -16
  11. package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-40vk8kgf.js.map} +3 -3
  12. package/dist/cli/chunk-487b95qy.js +329 -0
  13. package/dist/cli/chunk-487b95qy.js.map +13 -0
  14. package/dist/cli/chunk-4zxh25hc.js +157 -0
  15. package/dist/cli/chunk-4zxh25hc.js.map +10 -0
  16. package/dist/cli/chunk-69rjf786.js +146 -0
  17. package/dist/cli/chunk-69rjf786.js.map +11 -0
  18. package/dist/cli/{chunk-dqhg54mr.js → chunk-9h8mh2tk.js} +3 -3
  19. package/dist/cli/{chunk-dqhg54mr.js.map → chunk-9h8mh2tk.js.map} +2 -2
  20. package/dist/cli/{chunk-bqxaqn5p.js → chunk-9jqmq5hv.js} +8 -8
  21. package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-9jqmq5hv.js.map} +1 -1
  22. package/dist/cli/{chunk-dv2z0rzf.js → chunk-aafhwx74.js} +38 -13
  23. package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-aafhwx74.js.map} +5 -4
  24. package/dist/cli/{chunk-desvm72z.js → chunk-b680sv30.js} +22 -26
  25. package/dist/cli/chunk-b680sv30.js.map +13 -0
  26. package/dist/cli/{chunk-0w6hs479.js → chunk-beb0z983.js} +30 -18
  27. package/dist/cli/chunk-beb0z983.js.map +10 -0
  28. package/dist/cli/{chunk-1f882900.js → chunk-c5bjxq8v.js} +2 -2
  29. package/dist/cli/chunk-e1n41w9m.js +155 -0
  30. package/dist/cli/chunk-e1n41w9m.js.map +11 -0
  31. package/dist/cli/{chunk-sc93apfs.js → chunk-etckvbrh.js} +17 -17
  32. package/dist/cli/{chunk-sc93apfs.js.map → chunk-etckvbrh.js.map} +2 -2
  33. package/dist/cli/{chunk-bxwhtvw8.js → chunk-f260jdrn.js} +5 -5
  34. package/dist/cli/{chunk-74wfhv79.js → chunk-fnxgtjeq.js} +1 -1
  35. package/dist/cli/{chunk-ms9z66nz.js → chunk-gwy55cx3.js} +2 -2
  36. package/dist/cli/{chunk-ms9z66nz.js.map → chunk-gwy55cx3.js.map} +2 -2
  37. package/dist/cli/{chunk-dhwtqnnd.js → chunk-gxhcf2hs.js} +2 -2
  38. package/dist/cli/{chunk-tfycddbt.js → chunk-h0qwdbem.js} +2 -2
  39. package/dist/cli/{chunk-nbyvgnnm.js → chunk-hktq4j5r.js} +109 -49
  40. package/dist/cli/chunk-hktq4j5r.js.map +36 -0
  41. package/dist/cli/{chunk-twy7psdz.js → chunk-hn3g2b89.js} +14 -10
  42. package/dist/cli/chunk-hn3g2b89.js.map +10 -0
  43. package/dist/cli/{chunk-j1x92mx9.js → chunk-j4nfgn2j.js} +1 -1
  44. package/dist/cli/{chunk-dpy8qdjf.js → chunk-k45sq16q.js} +74 -183
  45. package/dist/cli/chunk-k45sq16q.js.map +18 -0
  46. package/dist/cli/{chunk-5cmts6dv.js → chunk-kmswjxj9.js} +7 -7
  47. package/dist/cli/{chunk-5cmts6dv.js.map → chunk-kmswjxj9.js.map} +1 -1
  48. package/dist/cli/{chunk-36bxs9c7.js → chunk-nqna8dzp.js} +27 -23
  49. package/dist/cli/chunk-nqna8dzp.js.map +10 -0
  50. package/dist/cli/{chunk-a57yh0h9.js → chunk-p834rkcn.js} +7 -7
  51. package/dist/cli/{chunk-a57yh0h9.js.map → chunk-p834rkcn.js.map} +1 -1
  52. package/dist/cli/{chunk-60hz265r.js → chunk-ppa6mg4d.js} +24 -11
  53. package/dist/cli/chunk-ppa6mg4d.js.map +10 -0
  54. package/dist/cli/{chunk-s0bdnc7v.js → chunk-q1wwrxzw.js} +5 -4
  55. package/dist/cli/chunk-q1wwrxzw.js.map +10 -0
  56. package/dist/cli/{chunk-ave1wcbr.js → chunk-qcyxkj44.js} +1 -1
  57. package/dist/cli/{chunk-124fvmzq.js → chunk-r5nm9w0c.js} +11183 -8806
  58. package/dist/cli/{chunk-124fvmzq.js.map → chunk-r5nm9w0c.js.map} +82 -66
  59. package/dist/cli/{chunk-ckrcm08h.js → chunk-rtvgmg2j.js} +48 -62
  60. package/dist/cli/{chunk-ckrcm08h.js.map → chunk-rtvgmg2j.js.map} +4 -5
  61. package/dist/cli/{chunk-297ajdcv.js → chunk-t2h8pwy1.js} +6 -6
  62. package/dist/cli/{chunk-297ajdcv.js.map → chunk-t2h8pwy1.js.map} +3 -3
  63. package/dist/cli/{chunk-gxtg6ed2.js → chunk-x60e15st.js} +21 -21
  64. package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-x60e15st.js.map} +3 -3
  65. package/dist/cli/{chunk-58bt3hag.js → chunk-x7bh6d9j.js} +4 -4
  66. package/dist/cli/{chunk-hvcxng5f.js → chunk-ydqzkhjn.js} +6 -6
  67. package/dist/cli/{chunk-kdpzw38a.js → chunk-yg8ckc3g.js} +95 -7
  68. package/dist/cli/chunk-yg8ckc3g.js.map +11 -0
  69. package/dist/cli/{chunk-0pmb4tr4.js → chunk-zxd5jrgx.js} +1205 -222
  70. package/dist/cli/chunk-zxd5jrgx.js.map +44 -0
  71. package/dist/cli/index.js +78 -42
  72. package/dist/cli/index.js.map +7 -6
  73. package/dist/types/ai/changelog-components.d.ts +18 -0
  74. package/dist/types/ai/changelog-markdown.d.ts +12 -0
  75. package/dist/types/ai/openapi-components.d.ts +7 -7
  76. package/dist/types/ai/operation-details.d.ts +39 -0
  77. package/dist/types/ai/relative-links.d.ts +3 -1
  78. package/dist/types/ai/serializers.d.ts +6 -4
  79. package/dist/types/analytics/head.d.ts +4 -2
  80. package/dist/types/analytics/script.d.ts +8 -2
  81. package/dist/types/components/content/youtube.d.ts +20 -0
  82. package/dist/types/components/layout/nav-utils.d.ts +8 -0
  83. package/dist/types/components/openapi/example-panels.d.ts +33 -0
  84. package/dist/types/components/openapi/helpers.d.ts +53 -11
  85. package/dist/types/components/openapi/snippets.d.ts +7 -1
  86. package/dist/types/core/alert-diagnostics.d.ts +10 -0
  87. package/dist/types/core/builtin-tags.d.ts +16 -0
  88. package/dist/types/core/changelog-list.d.ts +48 -0
  89. package/dist/types/core/closest-match.d.ts +6 -0
  90. package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
  91. package/dist/types/core/code-fences.d.ts +8 -1
  92. package/dist/types/core/config-input.d.ts +38 -6
  93. package/dist/types/core/content-assets.d.ts +16 -18
  94. package/dist/types/core/data.d.ts +6 -2
  95. package/dist/types/core/diagnostics.d.ts +14 -0
  96. package/dist/types/core/directive-diagnostics.d.ts +22 -8
  97. package/dist/types/core/former-routes.d.ts +14 -0
  98. package/dist/types/core/github.d.ts +5 -4
  99. package/dist/types/core/heading-markers.d.ts +65 -2
  100. package/dist/types/core/hidden-pages.d.ts +3 -0
  101. package/dist/types/core/includes.d.ts +23 -0
  102. package/dist/types/core/last-modified.d.ts +18 -6
  103. package/dist/types/core/links.d.ts +42 -2
  104. package/dist/types/core/load-module.d.ts +19 -4
  105. package/dist/types/core/manifest.d.ts +14 -1
  106. package/dist/types/core/markdown-mirrors.d.ts +22 -0
  107. package/dist/types/core/mcp-clients.d.ts +47 -0
  108. package/dist/types/core/mdx-elements.d.ts +32 -0
  109. package/dist/types/core/mdx-syntax.d.ts +100 -0
  110. package/dist/types/core/meta-diagnostics.d.ts +7 -0
  111. package/dist/types/core/meta.d.ts +32 -5
  112. package/dist/types/core/nav-diagnostics.d.ts +16 -2
  113. package/dist/types/core/navigation.d.ts +13 -0
  114. package/dist/types/core/ordering-prefix.d.ts +6 -0
  115. package/dist/types/core/pinned-ids.d.ts +23 -0
  116. package/dist/types/core/project-graph.d.ts +9 -1
  117. package/dist/types/core/redirect-patterns.d.ts +18 -0
  118. package/dist/types/core/relative-files.d.ts +31 -0
  119. package/dist/types/core/schema.d.ts +322 -11
  120. package/dist/types/core/sources/normalize.d.ts +101 -9
  121. package/dist/types/core/sources/types.d.ts +16 -0
  122. package/dist/types/core/static-files.d.ts +10 -0
  123. package/dist/types/core/static-host.d.ts +42 -0
  124. package/dist/types/core/svg-images.d.ts +26 -0
  125. package/dist/types/core/syntax-diagnostics.d.ts +14 -0
  126. package/dist/types/core/types.d.ts +42 -1
  127. package/dist/types/deploy/generated-files.d.ts +41 -0
  128. package/dist/types/deploy/redirects.d.ts +21 -7
  129. package/dist/types/markdown/code-title.d.ts +9 -5
  130. package/dist/types/markdown/directives.d.ts +8 -0
  131. package/dist/types/markdown/fence-language.d.ts +51 -0
  132. package/dist/types/markdown/fence-meta.d.ts +5 -0
  133. package/dist/types/markdown/github-alerts.d.ts +35 -0
  134. package/dist/types/openapi/asyncapi.d.ts +6 -5
  135. package/dist/types/openapi/checks.d.ts +10 -5
  136. package/dist/types/openapi/model.d.ts +55 -8
  137. package/dist/types/openapi/parse.d.ts +4 -1
  138. package/dist/types/ratelimit/index.d.ts +2 -0
  139. package/dist/types/ratelimit/memory.d.ts +1 -1
  140. package/dist/types/ratelimit/schema.d.ts +40 -0
  141. package/dist/types/ratelimit/unkey.d.ts +48 -0
  142. package/dist/types/sources/filesystem.d.ts +14 -4
  143. package/dist/types/sources/mdx-remote.d.ts +6 -3
  144. package/dist/types/sources/registry.d.ts +3 -3
  145. package/dist/types/theme/color.d.ts +33 -0
  146. package/dist/types/theme/palette.d.ts +122 -0
  147. package/docs/01-quickstart.mdx +1 -1
  148. package/docs/02-deployment.mdx +24 -8
  149. package/docs/04-migrating.mdx +16 -4
  150. package/docs/advanced/changelog.mdx +27 -1
  151. package/docs/advanced/skills.mdx +1 -1
  152. package/docs/cli/audit.mdx +1 -1
  153. package/docs/cli/doctor.mdx +2 -2
  154. package/docs/cli/evals.mdx +1 -1
  155. package/docs/cli/index.mdx +4 -2
  156. package/docs/cli/validate.mdx +10 -7
  157. package/docs/configuration/analytics.mdx +2 -0
  158. package/docs/configuration/customization.mdx +3 -1
  159. package/docs/configuration/index.mdx +7 -6
  160. package/docs/configuration/rate-limiting.mdx +20 -1
  161. package/docs/configuration/theming.mdx +5 -1
  162. package/docs/content/components.mdx +26 -6
  163. package/docs/content/frontmatter.mdx +1 -1
  164. package/docs/content/i18n.mdx +1 -1
  165. package/docs/content/includes.mdx +6 -2
  166. package/docs/content/index.mdx +11 -1
  167. package/docs/content/islands.mdx +2 -0
  168. package/docs/content/meta.mdx +9 -3
  169. package/docs/content/navigation.mdx +14 -6
  170. package/docs/content/sources/obsidian.mdx +1 -1
  171. package/docs/content/sources/remote-mdx.mdx +3 -1
  172. package/docs/content/syntax.mdx +76 -8
  173. package/docs/content/variables.mdx +3 -1
  174. package/docs/discoverability/agent-discovery.mdx +1 -1
  175. package/docs/discoverability/json-api.mdx +1 -1
  176. package/docs/discoverability/llms-txt.mdx +1 -1
  177. package/docs/discoverability/markdown.mdx +2 -2
  178. package/docs/discoverability/mcp.mdx +40 -8
  179. package/docs/discoverability/open-graph.mdx +1 -1
  180. package/docs/discoverability/sitemap-and-robots.mdx +1 -1
  181. package/docs/references/graphql.mdx +2 -0
  182. package/docs/references/openapi.mdx +45 -7
  183. package/package.json +4 -4
  184. package/skills/blume/SKILL.md +2 -2
  185. package/skills/blume-migrate/SKILL.md +51 -29
  186. package/skills/blume-migrate/references/docsify.md +262 -0
  187. package/skills/blume-migrate/references/docus.md +298 -0
  188. package/skills/blume-migrate/references/docusaurus.md +5 -5
  189. package/skills/blume-migrate/references/fern.md +327 -0
  190. package/skills/blume-migrate/references/fumadocs.md +1 -1
  191. package/skills/blume-migrate/references/gitbook.md +267 -0
  192. package/skills/blume-migrate/references/github-wiki.md +259 -0
  193. package/skills/blume-migrate/references/jekyll.md +253 -0
  194. package/skills/blume-migrate/references/mdbook.md +256 -0
  195. package/skills/blume-migrate/references/mintlify.md +5 -5
  196. package/skills/blume-migrate/references/mkdocs.md +236 -0
  197. package/skills/blume-migrate/references/monorepo.md +20 -8
  198. package/skills/blume-migrate/references/nextra.md +5 -5
  199. package/skills/blume-migrate/references/readme.md +257 -0
  200. package/skills/blume-migrate/references/redocly.md +198 -0
  201. package/skills/blume-migrate/references/starlight.md +3 -3
  202. package/skills/blume-migrate/references/vitepress.md +204 -0
  203. package/skills/blume-migrate/references/vuepress.md +271 -0
  204. package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
  205. package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
  206. package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
  207. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
  208. package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
  209. package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
  210. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
  211. package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
  212. package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
  213. package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
  214. package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
  215. package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
  216. package/src/ai/changelog-components.ts +24 -0
  217. package/src/ai/changelog-markdown.ts +45 -41
  218. package/src/ai/component-markdown.ts +22 -9
  219. package/src/ai/llms.ts +36 -5
  220. package/src/ai/markdown.ts +2 -2
  221. package/src/ai/openapi-components.ts +25 -9
  222. package/src/ai/operation-details.ts +208 -0
  223. package/src/ai/relative-links.ts +48 -8
  224. package/src/ai/serializers.ts +7 -6
  225. package/src/ai/site-skill.ts +6 -2
  226. package/src/analytics/head.ts +11 -5
  227. package/src/analytics/script.ts +13 -3
  228. package/src/astro/generate.ts +47 -11
  229. package/src/astro/index.ts +1 -0
  230. package/src/astro/integration.ts +27 -1
  231. package/src/astro/mdx-source-errors.ts +46 -0
  232. package/src/astro/templates.ts +93 -189
  233. package/src/audit/checks/redirects.ts +5 -1
  234. package/src/audit/redirects.ts +36 -19
  235. package/src/audit/report.ts +22 -9
  236. package/src/audit/run.ts +12 -28
  237. package/src/audit/terms.ts +23 -0
  238. package/src/audit/url.ts +2 -2
  239. package/src/cli/build-failure.ts +256 -7
  240. package/src/cli/command-meta.ts +1 -1
  241. package/src/cli/commands/audit.ts +1 -1
  242. package/src/cli/commands/build.ts +12 -3
  243. package/src/cli/commands/check.ts +13 -2
  244. package/src/cli/commands/dev.ts +1 -1
  245. package/src/cli/commands/doctor.ts +4 -2
  246. package/src/cli/commands/init.ts +3 -3
  247. package/src/cli/commands/preview.ts +17 -1
  248. package/src/cli/commands/validate.ts +22 -10
  249. package/src/cli/prepare.ts +1 -1
  250. package/src/cli/preview-pages.ts +161 -0
  251. package/src/cli/required-secrets.ts +0 -1
  252. package/src/cli/unknown-flags.ts +1 -42
  253. package/src/components/code-copy.ts +51 -0
  254. package/src/components/code-language.ts +75 -0
  255. package/src/components/content/AccordionItem.astro +16 -1
  256. package/src/components/content/Callout.astro +11 -1
  257. package/src/components/content/Changelog.astro +189 -0
  258. package/src/components/content/CodeBlock.astro +8 -11
  259. package/src/components/content/Tabs.astro +16 -2
  260. package/src/components/content/YouTube.astro +6 -4
  261. package/src/components/content/code-block.ts +33 -0
  262. package/src/components/content/content-strings.ts +5 -1
  263. package/src/components/content/inline-markdown.ts +35 -8
  264. package/src/components/content/youtube.ts +49 -7
  265. package/src/components/layout/Analytics.astro +5 -1
  266. package/src/components/layout/NavTree.astro +8 -3
  267. package/src/components/layout/PageActions.astro +81 -23
  268. package/src/components/layout/RootLayout.astro +35 -32
  269. package/src/components/layout/SiteFooter.astro +31 -8
  270. package/src/components/layout/nav-utils.ts +14 -0
  271. package/src/components/layout/search/pagefind.ts +2 -1
  272. package/src/components/openapi/Operation.astro +4 -0
  273. package/src/components/openapi/PanelTabs.astro +9 -3
  274. package/src/components/openapi/RequestBody.astro +42 -8
  275. package/src/components/openapi/RequestPanel.astro +34 -53
  276. package/src/components/openapi/example-panels.ts +110 -0
  277. package/src/components/openapi/helpers.ts +195 -20
  278. package/src/components/openapi/operation-model.ts +63 -26
  279. package/src/components/openapi/request.ts +13 -0
  280. package/src/components/openapi/snippets.ts +14 -2
  281. package/src/core/alert-diagnostics.ts +89 -0
  282. package/src/core/builtin-tags.ts +76 -0
  283. package/src/core/changelog-list.ts +87 -0
  284. package/src/core/closest-match.ts +41 -0
  285. package/src/core/code-fence-diagnostics.ts +267 -0
  286. package/src/core/code-fences.ts +43 -7
  287. package/src/core/component-diagnostics.ts +65 -12
  288. package/src/core/config-input.ts +39 -5
  289. package/src/core/config.ts +46 -1
  290. package/src/core/content-assets.ts +64 -144
  291. package/src/core/data.ts +10 -3
  292. package/src/core/diagnostics.ts +36 -3
  293. package/src/core/directive-diagnostics.ts +265 -47
  294. package/src/core/former-routes.ts +46 -0
  295. package/src/core/git-ignored.ts +53 -0
  296. package/src/core/github.ts +5 -4
  297. package/src/core/heading-markers.ts +156 -2
  298. package/src/core/hidden-pages.ts +64 -0
  299. package/src/core/includes.ts +181 -41
  300. package/src/core/last-modified.ts +47 -10
  301. package/src/core/links.ts +438 -83
  302. package/src/core/load-module.ts +98 -9
  303. package/src/core/manifest.ts +12 -9
  304. package/src/core/markdown-mirrors.ts +52 -0
  305. package/src/core/mcp-clients.ts +87 -0
  306. package/src/core/mdx-elements.ts +153 -0
  307. package/src/core/mdx-syntax.ts +377 -0
  308. package/src/core/meta-diagnostics.ts +134 -0
  309. package/src/core/meta.ts +154 -55
  310. package/src/core/nav-diagnostics.ts +41 -4
  311. package/src/core/navigation.ts +70 -10
  312. package/src/core/ordering-prefix.ts +22 -2
  313. package/src/core/pinned-ids.ts +141 -0
  314. package/src/core/project-graph.ts +221 -29
  315. package/src/core/redirect-patterns.ts +41 -0
  316. package/src/core/relative-files.ts +145 -0
  317. package/src/core/schema.ts +110 -6
  318. package/src/core/sources/mdx-remote.ts +75 -14
  319. package/src/core/sources/normalize.ts +885 -179
  320. package/src/core/sources/notion.ts +5 -4
  321. package/src/core/sources/obsidian.ts +2 -5
  322. package/src/core/sources/types.ts +17 -0
  323. package/src/core/static-files.ts +40 -0
  324. package/src/core/static-host.ts +125 -0
  325. package/src/core/svg-images.ts +98 -0
  326. package/src/core/syntax-diagnostics.ts +491 -0
  327. package/src/core/types.ts +43 -1
  328. package/src/deploy/artifacts.ts +2 -28
  329. package/src/deploy/generated-files.ts +128 -0
  330. package/src/deploy/platforms/vercel.ts +25 -1
  331. package/src/deploy/redirects.ts +43 -16
  332. package/src/deploy/sitemap.ts +2 -1
  333. package/src/markdown/base-links.ts +87 -6
  334. package/src/markdown/code-title.ts +29 -13
  335. package/src/markdown/content-assets.ts +210 -0
  336. package/src/markdown/directives.ts +69 -5
  337. package/src/markdown/fence-language.ts +106 -0
  338. package/src/markdown/fence-meta.ts +42 -0
  339. package/src/markdown/github-alerts.ts +172 -0
  340. package/src/markdown/heading-anchors.ts +265 -56
  341. package/src/markdown/include.ts +30 -3
  342. package/src/markdown/index.ts +145 -40
  343. package/src/markdown/prompt-text.ts +95 -0
  344. package/src/markdown/relative-links.ts +37 -2
  345. package/src/markdown/route-snapshot.ts +22 -12
  346. package/src/markdown/svg-fallback.ts +51 -0
  347. package/src/markdown/title-heading.ts +134 -0
  348. package/src/migrate/migrate.ts +127 -2
  349. package/src/og/card.ts +14 -4
  350. package/src/openapi/asyncapi.ts +14 -6
  351. package/src/openapi/checks.ts +160 -15
  352. package/src/openapi/model.ts +172 -33
  353. package/src/openapi/parse.ts +13 -4
  354. package/src/openapi/render-mdx.ts +55 -9
  355. package/src/openapi/source.ts +87 -6
  356. package/src/ratelimit/index.ts +2 -0
  357. package/src/ratelimit/memory.ts +1 -1
  358. package/src/ratelimit/runtime.ts +81 -0
  359. package/src/ratelimit/schema.ts +2 -0
  360. package/src/ratelimit/unkey.ts +61 -0
  361. package/src/registry/eject.ts +0 -1
  362. package/src/registry/registry.ts +1 -1
  363. package/src/search/documents.ts +10 -2
  364. package/src/sources/filesystem.ts +28 -3
  365. package/src/sources/mdx-remote.ts +7 -15
  366. package/src/theme/color.ts +14 -0
  367. package/src/theme/entry.ts +22 -1
  368. package/src/theme/palette.ts +74 -1
  369. package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
  370. package/dist/cli/chunk-0w6hs479.js.map +0 -10
  371. package/dist/cli/chunk-150jk7km.js +0 -52
  372. package/dist/cli/chunk-150jk7km.js.map +0 -10
  373. package/dist/cli/chunk-36bxs9c7.js.map +0 -10
  374. package/dist/cli/chunk-53jqw8z0.js +0 -89
  375. package/dist/cli/chunk-53jqw8z0.js.map +0 -10
  376. package/dist/cli/chunk-60hz265r.js.map +0 -10
  377. package/dist/cli/chunk-desvm72z.js.map +0 -14
  378. package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
  379. package/dist/cli/chunk-es8h0v3j.js +0 -441
  380. package/dist/cli/chunk-es8h0v3j.js.map +0 -11
  381. package/dist/cli/chunk-kdpzw38a.js.map +0 -11
  382. package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
  383. package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
  384. package/dist/cli/chunk-twy7psdz.js.map +0 -10
  385. package/dist/cli/chunk-ydp3065p.js +0 -64
  386. package/dist/cli/chunk-ydp3065p.js.map +0 -10
  387. /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-080mfch6.js.map} +0 -0
  388. /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-34wap3ed.js.map} +0 -0
  389. /package/dist/cli/{chunk-1f882900.js.map → chunk-c5bjxq8v.js.map} +0 -0
  390. /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-f260jdrn.js.map} +0 -0
  391. /package/dist/cli/{chunk-74wfhv79.js.map → chunk-fnxgtjeq.js.map} +0 -0
  392. /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-gxhcf2hs.js.map} +0 -0
  393. /package/dist/cli/{chunk-tfycddbt.js.map → chunk-h0qwdbem.js.map} +0 -0
  394. /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-j4nfgn2j.js.map} +0 -0
  395. /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-qcyxkj44.js.map} +0 -0
  396. /package/dist/cli/{chunk-58bt3hag.js.map → chunk-x7bh6d9j.js.map} +0 -0
  397. /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-ydqzkhjn.js.map} +0 -0
@@ -0,0 +1,2387 @@
1
+ #!/usr/bin/env node
2
+ // docsify-codemod.mjs — the mechanical part of a Docsify → Blume migration.
3
+ // Point it at the folder that holds Docsify's index.html (it becomes Blume's
4
+ // `content.root`) and run it once, before any hand edits. It leaves fenced and
5
+ // indented code alone, except that MDX reads an indented block as prose, so
6
+ // a `.mdx` page gets the MDX-safe fixes there too. Per page it:
7
+ //
8
+ // - converts `!>` / `?>` paragraphs and `> [!TYPE]` alerts (GitHub style and
9
+ // flexible-alerts, `|label:…` included) to `:::` directives;
10
+ // - converts docsify-tabs sets (`<!-- tabs:start -->`, bold-heading or
11
+ // `<!-- tab:… -->` labels) to <Tabs>/<Tab>;
12
+ // - turns `[x](path ':include …')` lines into <include>: Markdown, code with
13
+ // its language, and mermaid, resolved from the page's folder as Docsify
14
+ // did (a leading `/` included); a `:fragment=` is copied into its own file
15
+ // beside the target where Docsify cut one (code includes, and Markdown
16
+ // ones in v5); a missing target is reported (Docsify 4 rendered the whole
17
+ // page blank when an include failed);
18
+ // - rewrites `:id=` to a pinned `[#id]`, `{docsify-ignore}` to `[!toc]`, and
19
+ // setext headings to ATX;
20
+ // - rewrites page links the way Docsify resolved them (from the docs root
21
+ // unless `relativePath: true`), including `#/route?id=x` hash links, `?id=`
22
+ // anchors, and the old site's own absolute URLs (`--site`), to links
23
+ // relative to the linking file, dropping an anchor that named the target's
24
+ // title H1; rebases images (Docsify read a leading-slash one from the
25
+ // page's folder too); strips `':target=…'`-style attribute strings from
26
+ // links and images; makes relative raw-HTML `href`/`src` and `':ignore'`
27
+ // links root paths (hash routing resolved them from `/`), except that an
28
+ // `':ignore'` link to a page's own file, which opened the raw Markdown,
29
+ // becomes a raw `<a href>` to the page's Markdown copy;
30
+ // - converts emoji shortcodes to Unicode when given an emoji map (`--emoji`,
31
+ // e.g. GitHub's `https://api.github.com/emojis` saved to a file);
32
+ // - writes frontmatter: `title` from a leading H1 (deleted from the body,
33
+ // later H1 sections demoted) or else from the sidebar label (every heading
34
+ // demoted), `sidebar.label`, `seo.title` from a sidebar link title, and
35
+ // `hidden: true` for pages the sidebar doesn't list;
36
+ // - renames `README.md` to `index.md`, and a page that now needs MDX to
37
+ // `.mdx` (HTML comments, `<br>`, void tags, and autolinks made MDX-safe);
38
+ // - with `--group-folders`, moves each page into a folder per `_sidebar.md`
39
+ // group, writes each folder's meta.ts and a root one that keeps the
40
+ // groups in sidebar order, and writes the old → new route table
41
+ // (`docsify-routes.json`) the hash redirect script reads. A
42
+ // `homepage: "intro.md"` page also answered at `/intro`, so the table
43
+ // maps that route too.
44
+ //
45
+ // `--snapshot <dir>` writes what the old site served: one HTML file per old
46
+ // route holding its headings with the ids Docsify gave them (v4 or v5 rules,
47
+ // checked against Docsify 4.13.1 and 5.0.0 in a browser), plus
48
+ // `old-routes.txt`. Feed the directory to pin-heading-ids.mjs as `--old`
49
+ // after the first build. Take the snapshot on the first run: it reads the
50
+ // pages as they are.
51
+ //
52
+ // It reports — and never fakes — what needs judgment: titles that may be
53
+ // section headings, media and remote includes, links it can't resolve, files
54
+ // that must move to `public/`, emoji it can't map, and MDX hazards left in a
55
+ // renamed page (indented code, table cells sharing a line with text, block
56
+ // elements in <p>, repeated attributes, braces in prose, <script>).
57
+ //
58
+ // Design constraints (as mintlify-codemod.mjs): zero dependencies, sorted and
59
+ // deterministic, idempotent (a second run changes nothing), dry run by default.
60
+ //
61
+ // Usage, from the directory that will hold blume.config.ts:
62
+ // node docsify-codemod.mjs docs # dry run
63
+ // node docsify-codemod.mjs --write --snapshot .docsify-old docs # apply
64
+ // node docsify-codemod.mjs --write --group-folders docs # also restructure
65
+ // node docsify-codemod.mjs --json docs # JSON report
66
+ //
67
+ // Options:
68
+ // --write Apply the changes. Without it, report only.
69
+ // --snapshot <dir> Write the old site's heading ids and old-routes.txt.
70
+ // --group-folders Move pages into a folder per sidebar group, write
71
+ // meta.ts files and the route table.
72
+ // --routes-out <file> Where the route table goes (default:
73
+ // docsify-routes.json in the current directory).
74
+ // --emoji <file> A JSON or JS file mapping shortcode names to emoji
75
+ // image URLs or paths with `unicode/<hex>` in them.
76
+ // --site <url> The old site's address, to rewrite its own absolute
77
+ // `https://site/#/…` links.
78
+ // --version 4|5 Docsify's major version (default: read index.html).
79
+ // --relative-path true|false
80
+ // Docsify's `relativePath` (default: read index.html).
81
+ // --sidebar <file> The sidebar file (default: index.html's
82
+ // `loadSidebar`, else _sidebar.md).
83
+ // --json A machine-readable report.
84
+
85
+ import {
86
+ existsSync,
87
+ mkdirSync,
88
+ readdirSync,
89
+ readFileSync,
90
+ rmSync,
91
+ statSync,
92
+ writeFileSync,
93
+ } from "node:fs";
94
+ import path from "node:path";
95
+
96
+ const { posix } = path;
97
+
98
+ // --- Tables -------------------------------------------------------------------
99
+
100
+ // Alert and flexible-alerts types → Blume callouts. Docsify 5 and
101
+ // flexible-alerts both draw caution/attention/danger red.
102
+ const ALERTS = {
103
+ attention: "danger",
104
+ caution: "danger",
105
+ danger: "danger",
106
+ important: "note",
107
+ info: "note",
108
+ note: "note",
109
+ tip: "tip",
110
+ warning: "warning",
111
+ };
112
+
113
+ // Top-level frontmatter keys Blume accepts; anything else fails the build.
114
+ const BLUME_KEYS = new Set([
115
+ "ai",
116
+ "api",
117
+ "authMethod",
118
+ "authors",
119
+ "changelog",
120
+ "date",
121
+ "deprecated",
122
+ "description",
123
+ "draft",
124
+ "hidden",
125
+ "icon",
126
+ "lastModified",
127
+ "mode",
128
+ "narration",
129
+ "noindex",
130
+ "pagination",
131
+ "playground",
132
+ "related",
133
+ "search",
134
+ "seo",
135
+ "sidebar",
136
+ "slug",
137
+ "title",
138
+ "type",
139
+ ]);
140
+
141
+ // A line the run deleted (a title H1, a setext underline).
142
+ const DELETED = "\u{0}";
143
+
144
+ // --- Patterns -----------------------------------------------------------------
145
+
146
+ const FENCE = /^\s*(?<marker>`{3,}|~{3,})/u;
147
+ const ATX = /^ {0,3}(?<hashes>#{1,6})(?:[ \t]+(?<text>.*?))?[ \t]*#*[ \t]*$/u;
148
+ const SETEXT = /^ {0,3}(?<rule>=+|-+)[ \t]*$/u;
149
+ const LIST_OR_QUOTE = /^\s*(?:[-*+]\s|\d+[.)]\s|>)/u;
150
+ const TABS_START = /^\s*<!-+\s+tabs:\s*start\s+-+>\s*$/u;
151
+ const TABS_END = /^\s*<!-+\s+tabs:\s*end\s+-+>\s*$/u;
152
+ const TAB_COMMENT = /^\s*<!-+\s+tab:\s*(?<label>.*?)\s+-+>\s*$/u;
153
+ const TAB_HEADING =
154
+ /^\s*#{1,6}\s*(?:\*\*|__)\s*(?<label>.*?\S)\s*(?:\*\*|__)\s*$/u;
155
+ const LEADING_SPACE = /^\s*/u;
156
+ const LEGACY_CALLOUT = /^(?<indent>\s*)(?<mark>[!?])>\s?(?<body>.*)$/u;
157
+ const ALERT =
158
+ /^(?<indent>\s*)>\s*\[!(?<kind>\w+)(?<options>(?:\|[^\]]*)?)\]\s*(?<body>.*)$/u;
159
+ const QUOTE_LINE = /^(?<indent>\s*)>\s?(?<body>.*)$/u;
160
+ const INCLUDE_LINE =
161
+ /^(?<indent>\s*)\[[^\]]*\]\(\s*<?(?<target>[^\s)>]+)>?\s+(?<quote>["'])(?<options>(?:(?!\k<quote>).)*:include(?:(?!\k<quote>).)*)\k<quote>\s*\)\s*$/u;
162
+ const INLINE_INCLUDE = /\]\([^)]*["'][^"')]*:include/u;
163
+ const CONFIG_TOKEN = /(?:^|\s):(?<key>[\w-]+:?)=?(?<value>[\w%-]+)?/gu;
164
+ const HAS_CONFIG = /(?:^|\s):[\w-]+/u;
165
+ const IGNORE_COMMENT = /\s*<!--\s*\{docsify-ignore(?:-all)?\}\s*-->/u;
166
+ const IGNORE_BARE = /\s*\{docsify-ignore(?:-all)?\}/u;
167
+ const IGNORE_ALL = /\{docsify-ignore-all\}/u;
168
+ const HEADING_ID = /(?:^|\s):id=(?<id>[\w%-]+)/u;
169
+ const TOC_MARKER = /\s\[!toc\]/u;
170
+ const PIN_MARKER = /\s\[#[^\]\s]+\]|\s\{#[^}\s]+\}/u;
171
+ const LINK =
172
+ /(?<!!)\[(?<text>(?:[^[\]]|\[[^\]]*\])*)\]\(\s*<?(?<href>[^\s)>]*)>?(?<title>(?:\s+(?:"[^"]*"|'[^']*'))?)\s*\)/gu;
173
+ const IMAGE =
174
+ /!\[(?<text>[^\]]*)\]\(\s*<?(?<href>[^\s)>]*)>?(?<title>(?:\s+(?:"[^"]*"|'[^']*'))?)\s*\)/gu;
175
+ const RAW_URL_ATTR =
176
+ /(?<start><(?:a|img|source|video|audio|iframe)\b[^>]*?\s(?:href|src)=)(?<quote>["'])(?<url>[^"']*)\k<quote>/giu;
177
+ const SHORTCODE = /:(?<name>[a-z0-9_+-]*[a-z0-9][a-z0-9_+-]*):/gu;
178
+ const URL_BEFORE = /(?:https?|ftp):\/?\/?\S*$/u;
179
+ const EMOJI_ENTRY =
180
+ /["']?(?<name>[\w+-]+)["']?\s*:\s*["'][^"']*unicode\/(?<hex>[0-9a-f-]+)/giu;
181
+ const CODE_SPAN = /(?<ticks>`+)(?<code>[\s\S]*?[^`])\k<ticks>(?!`)/gu;
182
+ const HTML_TAG = /(?<tag><\/?[A-Za-z][^>]*>)/u;
183
+ const ANY_TAG = /<[^>]+>/gu;
184
+ const AUTOLINK = /<(?<url>(?:https?|ftp|mailto):[^\s>]+)>/gu;
185
+ const EXTERNAL = /:|\/\//u;
186
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
187
+ const MD_EXT = /\.(?:md|markdown)$/iu;
188
+ const PAGE_EXT = /\.mdx?$/u;
189
+ const README = /(?:^|\/)README\.md$/iu;
190
+ const README_ROUTE = /(?<lead>^|\/)README$/iu;
191
+ const TRAILING_SLASHES = /(?<first>.)\/+$/u;
192
+ const ORDERING_PREFIX = /^\d+[-_.]/u;
193
+ const NOT_AN_ORDER = /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u;
194
+ const HTML_DOCUMENT = /^\s*(?:<!doctype html|<html)/iu;
195
+ const PUNCT =
196
+ /[\u{2000}-\u{206F}\u{2E00}-\u{2E7F}\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/gu;
197
+ const EMOJI_CHARS = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
198
+ const COMBINING = /[\u{0300}-\u{036F}]/gu;
199
+ const ENTITY_ESCAPE = /[<>"']|&(?!#?\w+;)/gu;
200
+ const ENTITIES = {
201
+ '"': "&quot;",
202
+ "&": "&amp;",
203
+ "'": "&#39;",
204
+ "<": "&lt;",
205
+ ">": "&gt;",
206
+ };
207
+ const NAMED_ENTITY = /&(?<name>amp|lt|gt|quot);/gu;
208
+ const DECODE = { amp: "&", gt: ">", lt: "<", quot: '"' };
209
+ const UPPER = /[A-Z]+/gu;
210
+ const WHITESPACE = /\s/gu;
211
+ const WHITESPACE_RUN = /\s+/gu;
212
+ const DASH_RUN = /-+/gu;
213
+ const LEADING_DIGIT = /^(?<digit>\d)/u;
214
+ const MD_LINK_TEXT = /\[(?<text>[^\]]+)\]\([^)]+\)/gu;
215
+ const MD_IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
216
+ const MD_LINK_ANY = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
217
+ const MD_REF_LINK = /\[(?<text>[^\]]+)\]\[[^\]]*\]/gu;
218
+ const STRONG = /(?<mark>\*\*\*|\*\*|\*)(?=\S)(?<inner>[\s\S]*?\S)\k<mark>/gu;
219
+ const UNDERSCORE =
220
+ /(?<lead>^|[^\p{L}\p{N}_])(?<mark>___|__|_)(?=\S)(?<inner>[\s\S]*?\S)\k<mark>(?![\p{L}\p{N}_])/gu;
221
+ const STRIKE = /~~(?=\S)(?<inner>[\s\S]*?\S)~~/gu;
222
+ const BACKSLASH = /\\(?<char>[\\`*_{}[\]()#+\-.!])/gu;
223
+ const WORD = /\p{L}{3,}/gu;
224
+ const NON_ALNUM = /[^\p{L}\p{N}]+/gu;
225
+ const EDGE_DASHES = /^-+|-+$/gu;
226
+ const SEPARATORS = /[-_]+/gu;
227
+ const FIRST_LETTER = /^\w/u;
228
+ const MDX_ONLY =
229
+ /^\s*:::|<Tabs\b|^\s*(?:`{3,}|~{3,})\s*mermaid\b|<include lang="mermaid"/u;
230
+ const COMMENT_OPEN = /<!-+/gu;
231
+ const COMMENT_CLOSE = /-+->/gu;
232
+ const MDX_COMMENT = /\{\/\*.*?\*\/\}/gu;
233
+ const VOID_TAG =
234
+ /<(?<tag>br|hr|img|input|source|wbr)\b(?<rest>[^>]*?)\s*\/?>/giu;
235
+ const CLOSE_BR = /<\/br\s*>/giu;
236
+ const INDENTED = /^(?: {4,}|\t)\S/u;
237
+ const CELL_WITH_TEXT =
238
+ /<\/?(?:td|th|tr)\b[^>]*>\s*\S.*$|^\s*\S.*<(?:td|th|tr)\b/iu;
239
+ const P_WITH_BLOCK =
240
+ /<p\b[^>]*>(?:(?!<\/p>)[\s\S])*?<(?:h[1-6]|div|ul|ol|table|p|pre|blockquote)\b/giu;
241
+ const TAG_ATTRIBUTES = /<[A-Za-z][\w-]*\s(?<attributes>[^<>]*)>/gu;
242
+ const ATTRIBUTE_NAME = /(?<name>[\w:-]+)\s*=/gu;
243
+ const BRACE = /[{}]/u;
244
+ const SCRIPT = /<script\b/iu;
245
+ const UPDATED = /\s*\{docsify-updated\}/u;
246
+ const VERSION_5 =
247
+ /\/docsify@5|\/docsify(?:@[\w.-]+)?\/dist\/docsify(?:\.module)?(?:\.min)?\.js|new Docsify\s*\(/u;
248
+ const SIDEBAR_OPTION = /loadSidebar\s*:\s*["'](?<file>[^"']+)["']/u;
249
+ const NAVBAR_OPTION = /loadNavbar\s*:\s*["'](?<file>[^"']+)["']/u;
250
+ const COVER_OPTION = /coverpage\s*:\s*["'](?<file>[^"']+)["']/u;
251
+ const HOMEPAGE_OPTION = /homepage\s*:\s*["'](?<file>[^"']+)["']/u;
252
+ const HISTORY_OPTION = /routerMode\s*:\s*["']history["']/u;
253
+ const NO_EMOJI_OPTION = /noEmoji\s*:\s*true/u;
254
+ const RELATIVE_OPTION = /relativePath\s*:\s*true/u;
255
+ const HTML_COMMENTS = /<!--[\s\S]*?-->/gu;
256
+ const BLOCK_COMMENTS = /\/\*[\s\S]*?\*\//gu;
257
+ const LINE_COMMENTS = /^\s*\/\/.*$/gmu;
258
+ const SIDEBAR_ITEM = /^(?<indent>\s*)[-*+]\s+(?<item>.*)$/u;
259
+ const SIDEBAR_LINK =
260
+ /^\[(?<label>[^\]]*)\]\(\s*<?(?<href>[^\s)>]+)>?(?:\s+(?<quote>["'])(?<title>.*?)\k<quote>)?\s*\)/u;
261
+ const HASH_PREFIX = /^#(?=\/)/u;
262
+ const NAV_LINK = /\]\(\s*<?(?<href>[^\s)>"']+)/gu;
263
+ const QUERY = /\?.*$/u;
264
+ const LEADING_SLASHES = /^\/+/u;
265
+ const SITE_SLASHES = /\/+$/u;
266
+ const FRONT_KEY = /^(?<key>[\w-]+):/u;
267
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/gu;
268
+ const ROUTE_EXT = /\.(?:md|html)$/iu;
269
+ const MMD = /\.mmd$/iu;
270
+ const HTML_EXT = /\.html?$/iu;
271
+ const VIDEO = /\.(?:mp4|ogg)$/iu;
272
+ const AUDIO = /\.mp3$/iu;
273
+ const INDEX_ROUTE = /(?<lead>^|\/)index$/u;
274
+ const QUOTES = /^["']|["']$/gu;
275
+ const LEADING_HASH = /^(?<indent>\s*)#/u;
276
+ const INDEX_STEM = /^(?:readme|index)$/iu;
277
+ const PARENTHESES = /^\((?<name>.*)\)$/u;
278
+
279
+ // --- Small helpers ------------------------------------------------------------
280
+
281
+ const yamlString = (value) => JSON.stringify(value);
282
+
283
+ const stripOrder = (name) =>
284
+ NOT_AN_ORDER.test(name) ? name : name.replace(ORDERING_PREFIX, "");
285
+
286
+ const slugifyFolder = (label) =>
287
+ label
288
+ .normalize("NFKD")
289
+ .toLowerCase()
290
+ .replaceAll(COMBINING, "")
291
+ .replaceAll(NON_ALNUM, "-")
292
+ .replaceAll(EDGE_DASHES, "") || "group";
293
+
294
+ /**
295
+ * A path relative to a page's folder, from the docs root. Docsify fetched
296
+ * `../x` from a root-level page as `/../x`, which the browser reads as `/x`,
297
+ * so a path can't climb out of the root.
298
+ */
299
+ const docsPath = (fromDir, target) => {
300
+ let joined = posix.normalize(posix.join(fromDir, target));
301
+ while (joined.startsWith("../")) {
302
+ joined = joined.slice(3);
303
+ }
304
+ return joined === ".." ? "." : joined;
305
+ };
306
+
307
+ /** A path from one docs-root file to another, written `./x` or `../x`. */
308
+ const relativeFile = (fromFile, toFile) => {
309
+ const relative = posix.relative(posix.dirname(fromFile), toFile);
310
+ return relative.startsWith(".") ? relative : `./${relative}`;
311
+ };
312
+
313
+ /** The new route a page file publishes at. */
314
+ const routeOfPage = (file) =>
315
+ `/${file.replace(PAGE_EXT, "").split("/").map(stripOrder).join("/")}`
316
+ .replace(INDEX_ROUTE, "$<lead>")
317
+ .replace(TRAILING_SLASHES, "$<first>") || "/";
318
+
319
+ const humanize = (stem) =>
320
+ stem
321
+ .replaceAll(SEPARATORS, " ")
322
+ .replace(FIRST_LETTER, (first) => first.toUpperCase())
323
+ .trim();
324
+
325
+ const stemOf = (file) => posix.basename(file, posix.extname(file));
326
+
327
+ /** A sidebar slug for a path segment: numeric prefix and parentheses gone. */
328
+ const childSlug = (segment) =>
329
+ stripOrder(segment.replace(PAGE_EXT, "")).replace(PARENTHESES, "$<name>");
330
+
331
+ /** Four columns of indentation (a tab counts as four) is an indented code block. */
332
+ const isCodeIndent = (indent) => indent.replaceAll("\t", " ").length >= 4;
333
+
334
+ /** The sidebar, navbar, cover, and 404 files Docsify loads around a page. */
335
+ const isNavFile = (name, settings) =>
336
+ name === settings.sidebar ||
337
+ name === settings.navbar ||
338
+ name === settings.cover ||
339
+ name === "_404.md";
340
+
341
+ // `match` gives null for no words, and `new Set(null)` is empty.
342
+ const words = (value) => new Set(value.toLowerCase().match(WORD));
343
+
344
+ /** Toggle the comment state across one line's `<!--` and `-->` markers. */
345
+ const commentAfter = (line, inComment) => {
346
+ let comment = inComment;
347
+ let cursor = 0;
348
+ for (;;) {
349
+ const marker = comment ? "-->" : "<!--";
350
+ const at = line.indexOf(marker, cursor);
351
+ if (at === -1) {
352
+ return comment;
353
+ }
354
+ comment = !comment;
355
+ cursor = at + marker.length;
356
+ }
357
+ };
358
+
359
+ const closesFence = (line, fence) => {
360
+ const marker = FENCE.exec(line)?.groups.marker;
361
+ return (
362
+ marker !== undefined &&
363
+ marker[0] === fence[0] &&
364
+ marker.length >= fence.length &&
365
+ line.trim() === marker
366
+ );
367
+ };
368
+
369
+ /**
370
+ * The block a line sits in, from the one before it: an indented code block
371
+ * starts after a blank line or a heading, outside a list, and runs on
372
+ * through indented and blank lines.
373
+ */
374
+ const nextBlock = (line, block, comment) => {
375
+ const blank = line.trim() === "";
376
+ const [indent] = LEADING_SPACE.exec(line);
377
+ const starts = block.indented || (block.opens && !block.list);
378
+ const indented = !(blank || comment) && isCodeIndent(indent) && starts;
379
+ const list =
380
+ blank || indented
381
+ ? block.list
382
+ : LIST_OR_QUOTE.test(line) || (block.list && indent !== "");
383
+ return { indented, list, opens: blank || ATX.test(line) };
384
+ };
385
+
386
+ /** Line states: in a fenced code block, an indented one, or an HTML comment? */
387
+ const scanLines = (lines) => {
388
+ const states = [];
389
+ let fence = null;
390
+ let comment = false;
391
+ let block = { indented: false, list: false, opens: true };
392
+ for (const line of lines) {
393
+ if (fence) {
394
+ states.push({ comment: false, fence: true, indented: false });
395
+ fence = closesFence(line, fence) ? null : fence;
396
+ block.indented = false;
397
+ block.opens = fence === null;
398
+ continue;
399
+ }
400
+ block = nextBlock(line, block, comment);
401
+ const open = block.indented ? null : FENCE.exec(line);
402
+ if (open && !comment) {
403
+ fence = open.groups.marker;
404
+ states.push({ comment: false, fence: true, indented: false });
405
+ block.opens = false;
406
+ continue;
407
+ }
408
+ const { indented } = block;
409
+ const opensComment =
410
+ !indented && line.trimStart().startsWith("<!--") && !line.includes("-->");
411
+ states.push({ comment: comment || opensComment, fence: false, indented });
412
+ comment = indented ? comment : commentAfter(line, comment);
413
+ }
414
+ return states;
415
+ };
416
+
417
+ /** A line Docsify showed as code: fenced or indented. */
418
+ const inCode = (state) => state.fence || state.indented;
419
+
420
+ /** Mask inline code so link and emoji rewrites skip it. */
421
+ const maskCode = (line) =>
422
+ line.replaceAll(CODE_SPAN, (span) => DELETED.repeat(span.length));
423
+
424
+ /** Docsify's getAndRemoveConfig: `:key=value` tokens out of a title string. */
425
+ const parseConfig = (raw) => {
426
+ const config = new Map();
427
+ const rest = raw
428
+ .replaceAll(QUOTES, "")
429
+ .replaceAll(CONFIG_TOKEN, (token, key, value) => {
430
+ if (key.includes(":")) {
431
+ return token;
432
+ }
433
+ config.set(key, value ?? "");
434
+ return "";
435
+ })
436
+ .trim();
437
+ return { config, rest };
438
+ };
439
+
440
+ /** A route or href split into its path and its anchor (`?id=x` or `#x`). */
441
+ const splitAnchor = (value) => {
442
+ let routePath = value;
443
+ let anchor = "";
444
+ const query = routePath.indexOf("?");
445
+ if (query !== -1) {
446
+ anchor = new URLSearchParams(routePath.slice(query + 1)).get("id") ?? "";
447
+ routePath = routePath.slice(0, query);
448
+ }
449
+ const hash = routePath.indexOf("#");
450
+ if (hash !== -1) {
451
+ anchor = routePath.slice(hash + 1);
452
+ routePath = routePath.slice(0, hash);
453
+ }
454
+ return { anchor, path: routePath };
455
+ };
456
+
457
+ // --- Docsify heading ids --------------------------------------------------------
458
+
459
+ /** Strip Markdown emphasis the way marked does: `_` only at word boundaries. */
460
+ const stripEmphasis = (text) =>
461
+ text
462
+ .replaceAll(STRONG, "$<inner>")
463
+ .replaceAll(UNDERSCORE, "$<lead>$<inner>")
464
+ .replaceAll(STRIKE, "$<inner>");
465
+
466
+ const escapeText = (text) =>
467
+ text.replaceAll(ENTITY_ESCAPE, (character) => ENTITIES[character]);
468
+
469
+ /** marked's inline rendering of plain text (not code, not a tag). */
470
+ const inlineText = (text) => {
471
+ const plain = stripEmphasis(
472
+ text
473
+ .replaceAll(MD_IMAGE, "")
474
+ .replaceAll(MD_LINK_ANY, "$<text>")
475
+ .replaceAll(MD_REF_LINK, "$<text>")
476
+ ).replaceAll(BACKSLASH, "$<char>");
477
+ return escapeText(plain);
478
+ };
479
+
480
+ /** Text with raw HTML tags kept and everything between them rendered. */
481
+ const renderInlineChunk = (chunk) =>
482
+ chunk
483
+ .split(HTML_TAG)
484
+ .map((piece, index) => (index % 2 === 1 ? piece : inlineText(piece)))
485
+ .join("");
486
+
487
+ /** marked 1.x's inline HTML for a heading: what Docsify 4 slugs. */
488
+ const markedInline = (source) => {
489
+ let html = "";
490
+ let cursor = 0;
491
+ const text = source.replaceAll(AUTOLINK, "$<url>");
492
+ for (const span of text.matchAll(CODE_SPAN)) {
493
+ html += renderInlineChunk(text.slice(cursor, span.index));
494
+ html += `<code>${escapeText(span.groups.code.trim())}</code>`;
495
+ cursor = span.index + span[0].length;
496
+ }
497
+ return html + renderInlineChunk(text.slice(cursor));
498
+ };
499
+
500
+ /** `text` without `pattern`'s matches, removed again until none is left. */
501
+ const removeAll = (text, pattern) => {
502
+ let out = text;
503
+ let previous;
504
+ do {
505
+ previous = out;
506
+ out = out.replaceAll(pattern, "");
507
+ } while (out !== previous);
508
+ return out;
509
+ };
510
+
511
+ /** Docsify 4.13's slugify, on marked's inline HTML. */
512
+ const slugV4 = (html) =>
513
+ removeAll(html.trim(), ANY_TAG)
514
+ .replaceAll(UPPER, (upper) => upper.toLowerCase())
515
+ .replaceAll(PUNCT, "")
516
+ .replaceAll(WHITESPACE, "-")
517
+ .replaceAll(DASH_RUN, "-")
518
+ .replace(LEADING_DIGIT, "_$<digit>");
519
+
520
+ /** Docsify 5's slugify, on the heading's raw text. */
521
+ const slugV5 = (text) =>
522
+ removeAll(
523
+ text
524
+ .trim()
525
+ .normalize("NFC")
526
+ .replaceAll(MD_LINK_TEXT, "$<text>")
527
+ .replaceAll("\u{FE0F}", "")
528
+ .replaceAll(EMOJI_CHARS, ""),
529
+ ANY_TAG
530
+ )
531
+ .replaceAll(UPPER, (upper) => upper.toLowerCase())
532
+ .replaceAll(PUNCT, "")
533
+ .replaceAll(WHITESPACE, "-")
534
+ .replace(LEADING_DIGIT, "_$<digit>");
535
+
536
+ /** A heading's text with its Markdown and HTML markup gone. */
537
+ const plainText = (source) =>
538
+ removeAll(markedInline(source), ANY_TAG)
539
+ .replaceAll(NAMED_ENTITY, (_, name) => DECODE[name])
540
+ .replaceAll("&#39;", "'")
541
+ .replaceAll(WHITESPACE_RUN, " ")
542
+ .trim();
543
+
544
+ /** The text a reader saw in the heading (emoji images had none). */
545
+ const visibleText = (source) =>
546
+ plainText(source)
547
+ .replaceAll(SHORTCODE, "")
548
+ .replaceAll(WHITESPACE_RUN, " ")
549
+ .trim();
550
+
551
+ const withoutConfig = (text) =>
552
+ text
553
+ .replace(IGNORE_COMMENT, "")
554
+ .replace(IGNORE_BARE, "")
555
+ .replaceAll(CONFIG_TOKEN, (token, key) => (key.includes(":") ? token : ""));
556
+
557
+ /** The id Docsify gave one heading, before its page's duplicate counter. */
558
+ const docsifyBaseId = (rawText, version) => {
559
+ const custom = HEADING_ID.exec(rawText)?.groups.id;
560
+ if (custom) {
561
+ return slugV4(custom);
562
+ }
563
+ return version === 5
564
+ ? slugV5(rawText)
565
+ : slugV4(markedInline(withoutConfig(rawText)));
566
+ };
567
+
568
+ // --- Site discovery -------------------------------------------------------------
569
+
570
+ /** index.html with commented-out code removed (`// routerMode: 'history'`). */
571
+ const readIndexHtml = (root) => {
572
+ const index = path.join(root, "index.html");
573
+ return existsSync(index)
574
+ ? removeAll(readFileSync(index, "utf-8"), HTML_COMMENTS)
575
+ .replaceAll(BLOCK_COMMENTS, "")
576
+ .replaceAll(LINE_COMMENTS, "")
577
+ : "";
578
+ };
579
+
580
+ const readSettings = (root, options) => {
581
+ const html = readIndexHtml(root);
582
+ const homepage = HOMEPAGE_OPTION.exec(html)?.groups.file;
583
+ const relativeOption = options.get("relative-path");
584
+ const version = options.get("version");
585
+ const detected = VERSION_5.test(html) ? 5 : 4;
586
+ return {
587
+ cover: COVER_OPTION.exec(html)?.groups.file ?? "_coverpage.md",
588
+ history: HISTORY_OPTION.test(html),
589
+ homepage: homepage && !EXTERNAL.test(homepage) ? homepage : "README.md",
590
+ navbar: NAVBAR_OPTION.exec(html)?.groups.file ?? "_navbar.md",
591
+ noEmoji: NO_EMOJI_OPTION.test(html),
592
+ relativePath:
593
+ relativeOption === undefined
594
+ ? RELATIVE_OPTION.test(html)
595
+ : relativeOption === "true",
596
+ sidebar:
597
+ options.get("sidebar") ??
598
+ SIDEBAR_OPTION.exec(html)?.groups.file ??
599
+ "_sidebar.md",
600
+ version: version ? Number(version) : detected,
601
+ };
602
+ };
603
+
604
+ const listFiles = (root, dir = "") => {
605
+ const out = [];
606
+ for (const name of readdirSync(path.join(root, dir)).toSorted()) {
607
+ const rel = dir ? `${dir}/${name}` : name;
608
+ const stat = statSync(path.join(root, rel), { throwIfNoEntry: false });
609
+ if (name === "node_modules" || name.startsWith(".") || !stat) {
610
+ continue;
611
+ }
612
+ out.push(...(stat.isDirectory() ? listFiles(root, rel) : [rel]));
613
+ }
614
+ return out;
615
+ };
616
+
617
+ const isPartialPath = (rel) =>
618
+ rel.split("/").some((part) => part.startsWith("_"));
619
+
620
+ /** A Docsify route for a page file: no extension, README as its folder. */
621
+ const routeOfFile = (rel, settings) => {
622
+ if (rel === settings.homepage) {
623
+ return "/";
624
+ }
625
+ const route = `/${rel.replace(MD_EXT, "")}`;
626
+ return (
627
+ route
628
+ .replace(README_ROUTE, "$<lead>")
629
+ .replace(TRAILING_SLASHES, "$<first>") || "/"
630
+ );
631
+ };
632
+
633
+ /** The file a Docsify route (after `#`) loads, relative to the docs root. */
634
+ const fileOfRoute = (route, settings) => {
635
+ let file = route.replace(LEADING_SLASHES, "");
636
+ if (file === "" || file === "README" || file === "README.md") {
637
+ return settings.homepage;
638
+ }
639
+ if (file.endsWith("/")) {
640
+ file += "README.md";
641
+ } else if (!ROUTE_EXT.test(file)) {
642
+ file += ".md";
643
+ }
644
+ return posix.normalize(file);
645
+ };
646
+
647
+ /** A page file as it exists now: a rerun finds `x.mdx` and `index.md`. */
648
+ const existingPage = (root, file) => {
649
+ const candidates = [file, file.replace(MD_EXT, ".mdx")];
650
+ if (README.test(file)) {
651
+ const dir = posix.dirname(file);
652
+ candidates.push(posix.join(dir, "index.md"), posix.join(dir, "index.mdx"));
653
+ }
654
+ return candidates.find((candidate) => existsSync(path.join(root, candidate)));
655
+ };
656
+
657
+ const sidebarItem = (text, stack) => {
658
+ // An icon inside the label (`[![x](x.svg)Name](url)`) isn't the link.
659
+ const link = SIDEBAR_LINK.exec(text.replaceAll(MD_IMAGE, ""));
660
+ if (!link) {
661
+ return null;
662
+ }
663
+ const { href, label, title } = link.groups;
664
+ return {
665
+ groups: stack.map((entry) => entry.label),
666
+ href,
667
+ label: label.trim(),
668
+ title: title ? parseConfig(title).rest || undefined : undefined,
669
+ };
670
+ };
671
+
672
+ /** Parse a sidebar into items with their group path. */
673
+ const parseSidebar = (text) => {
674
+ const items = [];
675
+ const stack = [];
676
+ for (const line of text.split("\n")) {
677
+ const match = SIDEBAR_ITEM.exec(line);
678
+ if (!match) {
679
+ continue;
680
+ }
681
+ const depth = match.groups.indent.replaceAll("\t", " ").length;
682
+ while (stack.length > 0 && stack.at(-1).depth >= depth) {
683
+ stack.pop();
684
+ }
685
+ const item = sidebarItem(match.groups.item.trim(), stack);
686
+ if (item) {
687
+ items.push(item);
688
+ }
689
+ stack.push({ depth, label: item?.label ?? match.groups.item.trim() });
690
+ }
691
+ return items;
692
+ };
693
+
694
+ const loadEmoji = (file) => {
695
+ const map = new Map();
696
+ if (!file) {
697
+ return map;
698
+ }
699
+ for (const match of readFileSync(file, "utf-8").matchAll(EMOJI_ENTRY)) {
700
+ const points = match.groups.hex
701
+ .split("-")
702
+ .map((hex) => Number.parseInt(hex, 16));
703
+ map.set(match.groups.name, String.fromCodePoint(...points));
704
+ }
705
+ return map;
706
+ };
707
+
708
+ // --- Findings -----------------------------------------------------------------
709
+
710
+ /** A finding for a line, by its index in `page.lines` at the time. */
711
+ const review = (page, index, message) => {
712
+ page.reviews.push({ index, message });
713
+ };
714
+
715
+ /** After a step rebuilt `page.lines`, move each finding to its line's new index. */
716
+ const remapReviews = (page, remap) => {
717
+ for (const finding of page.reviews) {
718
+ finding.index = remap[finding.index] ?? finding.index;
719
+ }
720
+ };
721
+
722
+ const count = (page, kind) => {
723
+ page.edits[kind] = (page.edits[kind] ?? 0) + 1;
724
+ };
725
+
726
+ // --- Page conversion steps --------------------------------------------------------
727
+
728
+ const isSetextUnderline = (lines, states, index) => {
729
+ const previous = lines[index - 1];
730
+ return (
731
+ SETEXT.test(lines[index]) &&
732
+ !inCode(states[index]) &&
733
+ !inCode(states[index - 1]) &&
734
+ !states[index].comment &&
735
+ previous.trim() !== "" &&
736
+ !LIST_OR_QUOTE.test(previous) &&
737
+ !ATX.test(previous) &&
738
+ !previous.trimStart().startsWith("<") &&
739
+ (index < 2 || lines[index - 2].trim() === "")
740
+ );
741
+ };
742
+
743
+ const setextToAtx = (page) => {
744
+ const { lines } = page;
745
+ const states = scanLines(lines);
746
+ for (let index = 1; index < lines.length; index += 1) {
747
+ if (isSetextUnderline(lines, states, index)) {
748
+ const { rule } = SETEXT.exec(lines[index]).groups;
749
+ const level = rule.startsWith("=") ? "#" : "##";
750
+ lines[index - 1] = `${level} ${lines[index - 1].trim()}`;
751
+ lines[index] = DELETED;
752
+ count(page, "setext heading → ATX");
753
+ }
754
+ }
755
+ };
756
+
757
+ const tabLabel = (line) =>
758
+ (TAB_COMMENT.exec(line) ?? TAB_HEADING.exec(line))?.groups.label.trim();
759
+
760
+ /** docsify-tabs sets → <Tabs>/<Tab>, with one blank line inside each tab. */
761
+ const convertTabs = (page) => {
762
+ const states = scanLines(page.lines);
763
+ const out = [];
764
+ const remap = [];
765
+ let indent = null;
766
+ let tabOpen = false;
767
+ const blankLast = () => out.length > 0 && out.at(-1).trim() === "";
768
+ const closeTab = () => {
769
+ if (tabOpen && !blankLast()) {
770
+ out.push("");
771
+ }
772
+ if (tabOpen) {
773
+ out.push(`${indent}</Tab>`);
774
+ }
775
+ tabOpen = false;
776
+ };
777
+ for (const [index, line] of page.lines.entries()) {
778
+ remap[index] = out.length;
779
+ const code = inCode(states[index]);
780
+ const label = indent === null || code ? undefined : tabLabel(line);
781
+ if (!code && TABS_START.test(line)) {
782
+ [indent] = LEADING_SPACE.exec(line);
783
+ out.push(`${indent}<Tabs>`);
784
+ count(page, "tab set → <Tabs>");
785
+ } else if (indent !== null && !code && TABS_END.test(line)) {
786
+ closeTab();
787
+ out.push(`${indent}</Tabs>`);
788
+ indent = null;
789
+ } else if (label !== undefined) {
790
+ closeTab();
791
+ out.push(`${indent}<Tab title=${yamlString(label)}>`, "");
792
+ tabOpen = true;
793
+ } else if (!(indent !== null && line.trim() === "" && blankLast())) {
794
+ out.push(line);
795
+ }
796
+ }
797
+ if (indent !== null) {
798
+ review(
799
+ page,
800
+ out.length - 1,
801
+ "a tab set has no `<!-- tabs:end -->`; closed it at the end of the page"
802
+ );
803
+ closeTab();
804
+ out.push(`${indent}</Tabs>`);
805
+ }
806
+ remapReviews(page, remap);
807
+ page.lines = out;
808
+ };
809
+
810
+ /** The last line of the paragraph a `!>` or `?>` callout starts. */
811
+ const paragraphEnd = (lines, states, start) => {
812
+ let end = start;
813
+ while (
814
+ end + 1 < lines.length &&
815
+ lines[end + 1].trim() !== "" &&
816
+ !inCode(states[end + 1]) &&
817
+ !ATX.test(lines[end + 1])
818
+ ) {
819
+ end += 1;
820
+ }
821
+ return end;
822
+ };
823
+
824
+ const legacyCallout = (page, lines, states, index) => {
825
+ const match = LEGACY_CALLOUT.exec(lines[index]);
826
+ // `!>` starts a callout only where a paragraph starts.
827
+ const startsParagraph =
828
+ index === 0 ||
829
+ lines[index - 1].trim() === "" ||
830
+ ATX.test(lines[index - 1]) ||
831
+ states[index - 1].fence;
832
+ // Four columns in, it's an indented code block, which Docsify showed as code.
833
+ if (!(match && startsParagraph) || isCodeIndent(match.groups.indent)) {
834
+ return null;
835
+ }
836
+ const { body, indent, mark } = match.groups;
837
+ const end = paragraphEnd(lines, states, index);
838
+ const type = mark === "!" ? "warning" : "tip";
839
+ count(page, `${mark}> → :::${type}`);
840
+ return {
841
+ end,
842
+ lines: [
843
+ `${indent}:::${type}`,
844
+ `${indent}${body}`,
845
+ ...lines.slice(index + 1, end + 1),
846
+ `${indent}:::`,
847
+ ],
848
+ };
849
+ };
850
+
851
+ const alertCallout = (page, lines, states, index) => {
852
+ const match = ALERT.exec(lines[index]);
853
+ if (!match || isCodeIndent(match.groups.indent)) {
854
+ return null;
855
+ }
856
+ const { body, indent, kind, options } = match.groups;
857
+ const type = ALERTS[kind.toLowerCase()] ?? "note";
858
+ if (!ALERTS[kind.toLowerCase()]) {
859
+ review(
860
+ page,
861
+ index,
862
+ `custom alert type [!${kind}] became :::note; pick the closest callout`
863
+ );
864
+ }
865
+ const settings = options.slice(1).split("|").filter(Boolean);
866
+ const label = settings
867
+ .find((option) => option.startsWith("label:"))
868
+ ?.slice(6)
869
+ .trim();
870
+ if (settings.some((option) => !option.startsWith("label:"))) {
871
+ count(page, "alert options dropped (style, icon, …)");
872
+ }
873
+ const content = body ? [`${indent}${body}`] : [];
874
+ let end = index;
875
+ while (
876
+ end + 1 < lines.length &&
877
+ QUOTE_LINE.test(lines[end + 1]) &&
878
+ !inCode(states[end + 1])
879
+ ) {
880
+ end += 1;
881
+ const quoted = QUOTE_LINE.exec(lines[end]).groups;
882
+ content.push(`${quoted.indent}${quoted.body}`);
883
+ }
884
+ count(page, `[!${kind.toUpperCase()}] → :::${type}`);
885
+ const opener = `${indent}:::${type}${label ? `[${label}]` : ""}`;
886
+ return { end, lines: [opener, ...content, `${indent}:::`] };
887
+ };
888
+
889
+ const convertCallouts = (page) => {
890
+ const { lines } = page;
891
+ const states = scanLines(lines);
892
+ const out = [];
893
+ const remap = [];
894
+ for (let index = 0; index < lines.length; index += 1) {
895
+ remap[index] = out.length;
896
+ const skip = inCode(states[index]) || states[index].comment;
897
+ const callout = skip
898
+ ? null
899
+ : (legacyCallout(page, lines, states, index) ??
900
+ alertCallout(page, lines, states, index));
901
+ if (callout) {
902
+ out.push(...callout.lines);
903
+ index = callout.end;
904
+ } else {
905
+ out.push(lines[index]);
906
+ }
907
+ }
908
+ remapReviews(page, remap);
909
+ page.lines = out;
910
+ };
911
+
912
+ /** The content between `### [name]` / `/// [name]` markers, dedented. */
913
+ const extractFragment = (text, name, version) => {
914
+ const escaped = name.replaceAll(REGEX_SPECIAL, "\\$&");
915
+ const marker = `(?:###|\\/\\/\\/)\\s*\\[${escaped}\\]`;
916
+ const body = version === 5 ? "(?<body>[\\s\\S]*?)" : "(?<body>[\\s\\S]*)";
917
+ const found = new RegExp(`${marker}${body}${marker}`, "u").exec(text);
918
+ if (!found) {
919
+ return null;
920
+ }
921
+ const lines = found.groups.body.split("\n");
922
+ const indents = lines
923
+ .filter((line) => line.trim() !== "")
924
+ .map((line) => LEADING_SPACE.exec(line)[0].length);
925
+ const indent = indents.length > 0 ? Math.min(...indents) : 0;
926
+ return lines
927
+ .map((line) => line.slice(indent))
928
+ .join("\n")
929
+ .trim();
930
+ };
931
+
932
+ /** Docsify's embed type: an explicit `:type=`, else by extension. */
933
+ const includeType = (config, target) => {
934
+ const explicit = config.get("type");
935
+ if (explicit) {
936
+ return explicit;
937
+ }
938
+ if (MD_EXT.test(target)) {
939
+ return "markdown";
940
+ }
941
+ if (MMD.test(target)) {
942
+ return "mermaid";
943
+ }
944
+ if (HTML_EXT.test(target)) {
945
+ return "iframe";
946
+ }
947
+ if (VIDEO.test(target)) {
948
+ return "video";
949
+ }
950
+ return AUDIO.test(target) ? "audio" : "code";
951
+ };
952
+
953
+ /** A `:fragment=`'s lines, copied to a file beside its target. */
954
+ const fragmentFile = (page, index, ctx, { config, file, target }) => {
955
+ const name = config.get("fragment");
956
+ const fragment = extractFragment(
957
+ readFileSync(path.join(ctx.root, file), "utf-8"),
958
+ name,
959
+ ctx.settings.version
960
+ );
961
+ if (fragment === null) {
962
+ review(page, index, `fragment [${name}] not found in ${target}`);
963
+ return null;
964
+ }
965
+ const ext = posix.extname(file);
966
+ const prefix = MD_EXT.test(ext) && !isPartialPath(file) ? "_" : "";
967
+ const copy = posix.join(
968
+ posix.dirname(file),
969
+ `${prefix}${stemOf(file)}.${name}${ext}`
970
+ );
971
+ ctx.newFiles.set(copy, `${fragment}\n`);
972
+ review(
973
+ page,
974
+ index,
975
+ `fragment [${name}] copied to ${copy}; if ${target} is generated, have its generator write that file`
976
+ );
977
+ count(page, "fragment include → copied file");
978
+ return copy;
979
+ };
980
+
981
+ const includeLanguage = (page, index, { file, rest, target, type }) => {
982
+ if (type === "mermaid") {
983
+ return "mermaid";
984
+ }
985
+ if (type === "code") {
986
+ const [lang] = rest.split(WHITESPACE_RUN);
987
+ return lang || (MD_EXT.test(file) ? "markdown" : "");
988
+ }
989
+ if (!MD_EXT.test(file)) {
990
+ review(
991
+ page,
992
+ index,
993
+ `${target} is included as Markdown, but Blume splices only .md and .mdx as content: give it a .md name (a symlink works)`
994
+ );
995
+ }
996
+ return "";
997
+ };
998
+
999
+ /** Report an include the run leaves as it is; true when it does. */
1000
+ const reviewUnconvertedInclude = (page, index, include, ctx) => {
1001
+ const { file, target, type } = include;
1002
+ if (EXTERNAL.test(target)) {
1003
+ review(
1004
+ page,
1005
+ index,
1006
+ `remote include ${target}: vendor the file into the content root, or fetch the page with mdxRemote()`
1007
+ );
1008
+ return true;
1009
+ }
1010
+ if (["audio", "iframe", "video"].includes(type)) {
1011
+ review(
1012
+ page,
1013
+ index,
1014
+ `${type} embed of ${target}: write a raw <${type}> (the file goes in public/), or <YouTube>`
1015
+ );
1016
+ return true;
1017
+ }
1018
+ if (!existsSync(path.join(ctx.root, file))) {
1019
+ review(
1020
+ page,
1021
+ index,
1022
+ `include target ${target} doesn't exist; Docsify 4 rendered this whole page blank. Repoint it or drop it`
1023
+ );
1024
+ return true;
1025
+ }
1026
+ return false;
1027
+ };
1028
+
1029
+ /**
1030
+ * Docsify 4 cuts a `:fragment=` out of code includes only; it showed a
1031
+ * Markdown or mermaid include whole. Docsify 5 cuts Markdown ones too.
1032
+ */
1033
+ const takesFragment = (type, version) =>
1034
+ type === "code" || (version === 5 && type === "markdown");
1035
+
1036
+ const convertInclude = (page, index, ctx) => {
1037
+ const match = INCLUDE_LINE.exec(page.lines[index]);
1038
+ if (!match) {
1039
+ if (INLINE_INCLUDE.test(page.lines[index])) {
1040
+ review(
1041
+ page,
1042
+ index,
1043
+ "an include inside other text: put it on its own line, then convert it"
1044
+ );
1045
+ }
1046
+ return;
1047
+ }
1048
+ const { indent, options, target } = match.groups;
1049
+ if (isCodeIndent(indent)) {
1050
+ return;
1051
+ }
1052
+ const { config, rest } = parseConfig(options);
1053
+ // Docsify joins the page's folder and the target even when the target
1054
+ // starts with `/`, so `/x` from `guide/page.md` loaded `guide/x`.
1055
+ const file = docsPath(posix.dirname(page.rel), target);
1056
+ const type = includeType(config, target);
1057
+ const include = { config, file, rest, target, type };
1058
+ if (reviewUnconvertedInclude(page, index, include, ctx)) {
1059
+ return;
1060
+ }
1061
+ if (config.has("fragment") && takesFragment(type, ctx.settings.version)) {
1062
+ include.file = fragmentFile(page, index, ctx, include);
1063
+ } else if (config.has("fragment")) {
1064
+ review(
1065
+ page,
1066
+ index,
1067
+ `Docsify ${ctx.settings.version} ignored :fragment= on a ${type} include and showed all of ${target}, so it's included whole`
1068
+ );
1069
+ }
1070
+ if (include.file === null) {
1071
+ return;
1072
+ }
1073
+ const lang = includeLanguage(page, index, include);
1074
+ page.includes.push({ file: include.file, lang });
1075
+ const into = ctx.moves.get(page.rel) ?? page.rel;
1076
+ const relative = relativeFile(
1077
+ into,
1078
+ ctx.moves.get(include.file) ?? include.file
1079
+ );
1080
+ const attribute = lang ? ` lang="${lang}"` : "";
1081
+ page.lines[index] = `${indent}<include${attribute}>${relative}</include>`;
1082
+ count(
1083
+ page,
1084
+ type === "code"
1085
+ ? "code include → <include lang>"
1086
+ : `${type} include → <include>`
1087
+ );
1088
+ };
1089
+
1090
+ const convertIncludes = (page, ctx) => {
1091
+ const states = scanLines(page.lines);
1092
+ for (const index of page.lines.keys()) {
1093
+ if (!(inCode(states[index]) || states[index].comment)) {
1094
+ convertInclude(page, index, ctx);
1095
+ }
1096
+ }
1097
+ };
1098
+
1099
+ /** One heading's `:id=` and docsify-ignore, as Blume markers. */
1100
+ const headingMarkers = (page, text, ignoreAll) => {
1101
+ let rest = text;
1102
+ let toc = ignoreAll;
1103
+ for (const pattern of [IGNORE_COMMENT, IGNORE_BARE]) {
1104
+ if (pattern.test(rest)) {
1105
+ rest = rest.replace(pattern, "");
1106
+ toc = true;
1107
+ }
1108
+ }
1109
+ const custom = HEADING_ID.exec(rest)?.groups.id;
1110
+ const pin = custom ? ` [#${slugV4(custom)}]` : "";
1111
+ if (custom) {
1112
+ rest = rest.replace(HEADING_ID, "");
1113
+ count(page, ":id= → [#id]");
1114
+ }
1115
+ const marker = toc && !TOC_MARKER.test(` ${rest}`) ? " [!toc]" : "";
1116
+ if (marker) {
1117
+ count(page, "docsify-ignore → [!toc]");
1118
+ }
1119
+ return `${rest.trim()}${marker}${pin}`;
1120
+ };
1121
+
1122
+ const convertHeadingAttributes = (page) => {
1123
+ const states = scanLines(page.lines);
1124
+ const headings = page.lines
1125
+ .map((line, index) => ({ index, match: ATX.exec(line) }))
1126
+ .filter(
1127
+ ({ index, match }) =>
1128
+ match?.groups.text !== undefined &&
1129
+ !inCode(states[index]) &&
1130
+ !states[index].comment
1131
+ );
1132
+ const ignoreAll = headings.some(({ match }) =>
1133
+ IGNORE_ALL.test(match.groups.text)
1134
+ );
1135
+ for (const { index, match } of headings) {
1136
+ const text = headingMarkers(page, match.groups.text, ignoreAll);
1137
+ page.lines[index] = `${match.groups.hashes} ${text}`;
1138
+ }
1139
+ };
1140
+
1141
+ /** The route an absolute link to the old site's own hash URL names, if any. */
1142
+ const siteRoute = (href, site) => {
1143
+ const rest = href.slice(site.length) || "/";
1144
+ if (rest.startsWith("/#/")) {
1145
+ return rest.slice(2);
1146
+ }
1147
+ return rest.startsWith("#/") ? rest.slice(1) : null;
1148
+ };
1149
+
1150
+ /** Docsify's link target for an href on a page: a route and an anchor. */
1151
+ const resolveDocsifyHref = (href, page, ctx) => {
1152
+ if (ctx.site && href.toLowerCase().startsWith(ctx.site)) {
1153
+ const route = siteRoute(href, ctx.site);
1154
+ return route === null ? null : { ...splitAnchor(route), hash: true };
1155
+ }
1156
+ if (SCHEME.test(href) || href.startsWith("//") || href === "") {
1157
+ return null;
1158
+ }
1159
+ if (href.startsWith("#/")) {
1160
+ return { ...splitAnchor(href.slice(1)), hash: true };
1161
+ }
1162
+ if (href.startsWith("#")) {
1163
+ return null;
1164
+ }
1165
+ const parts = splitAnchor(href);
1166
+ const relative = ctx.settings.relativePath && !parts.path.startsWith("/");
1167
+ const base = relative ? posix.dirname(`/${page.rel}`) : "/";
1168
+ const trailing = parts.path.endsWith("/") ? "/" : "";
1169
+ return {
1170
+ anchor: parts.anchor,
1171
+ hash: false,
1172
+ path: `${posix.normalize(posix.join(base, parts.path))}${trailing}`,
1173
+ };
1174
+ };
1175
+
1176
+ /** Strip a Docsify attribute title; returns the config and the title to keep. */
1177
+ const stripAttributes = (page, title, kind) => {
1178
+ const trimmed = title.trim();
1179
+ const inner = trimmed.slice(1, -1);
1180
+ if (!(trimmed && HAS_CONFIG.test(inner))) {
1181
+ return { config: new Map(), title };
1182
+ }
1183
+ const { config, rest } = parseConfig(inner);
1184
+ for (const key of config.keys()) {
1185
+ count(page, `${kind} attribute :${key} dropped`);
1186
+ }
1187
+ const [quote] = trimmed;
1188
+ return { config, title: rest ? ` ${quote}${rest}${quote}` : "" };
1189
+ };
1190
+
1191
+ /** A link to a non-page file under the content root: it has to move. */
1192
+ const reviewFileLink = (page, index, file, ctx) => {
1193
+ if (existsSync(path.join(ctx.root, file))) {
1194
+ ctx.publicFiles.add(`/${file}`);
1195
+ review(
1196
+ page,
1197
+ index,
1198
+ `links ${file}, a file in the content root: serve it from public/ and link /${file}`
1199
+ );
1200
+ }
1201
+ };
1202
+
1203
+ /** A page link's new href: a file link relative to the linking page. */
1204
+ const linkFor = (href, page, index, ctx) => {
1205
+ const target = resolveDocsifyHref(href, page, ctx);
1206
+ if (!target) {
1207
+ return href;
1208
+ }
1209
+ const routed = fileOfRoute(target.path, ctx.settings);
1210
+ if (!MD_EXT.test(routed)) {
1211
+ reviewFileLink(page, index, routed, ctx);
1212
+ return href;
1213
+ }
1214
+ const file = ctx.pageFiles.has(routed)
1215
+ ? routed
1216
+ : existingPage(ctx.root, routed);
1217
+ if (!ctx.pageFiles.has(file)) {
1218
+ review(
1219
+ page,
1220
+ index,
1221
+ `link ${href} → ${routed}, which isn't a page here (an alias, or dead on the old site too)`
1222
+ );
1223
+ return href;
1224
+ }
1225
+ const into = ctx.moves.get(page.rel) ?? page.rel;
1226
+ // The title H1 leaves the body, and its anchor with it: link the page.
1227
+ const title = target.anchor && target.anchor === ctx.titleIds.get(file);
1228
+ if (title) {
1229
+ count(page, "link to a title H1 → link to its page");
1230
+ }
1231
+ const anchor = target.anchor && !title ? `#${target.anchor}` : "";
1232
+ const next = relativeFile(into, ctx.moves.get(file) ?? file) + anchor;
1233
+ if (next !== href && !title) {
1234
+ count(page, target.hash ? "hash link → file link" : "link → file link");
1235
+ }
1236
+ return next;
1237
+ };
1238
+
1239
+ /** A relative URL the browser read from the docs root, as a root path. */
1240
+ const rootedUrl = (url, ctx) => {
1241
+ const rooted = `/${docsPath(".", url)}`;
1242
+ ctx.publicFiles.add(splitAnchor(rooted).path);
1243
+ return rooted;
1244
+ };
1245
+
1246
+ /**
1247
+ * A root path to a file in the content root: list it for public/. A page's
1248
+ * own file needs nothing: Blume serves its Markdown copy at that path.
1249
+ */
1250
+ const noteRootFile = (url, ctx) => {
1251
+ const file = splitAnchor(url).path;
1252
+ const stat = url.startsWith("//")
1253
+ ? undefined
1254
+ : statSync(path.join(ctx.root, file), { throwIfNoEntry: false });
1255
+ if (stat?.isFile() && !ctx.pageFiles.has(file.slice(1))) {
1256
+ ctx.publicFiles.add(file);
1257
+ }
1258
+ };
1259
+
1260
+ /**
1261
+ * An image's path after the page's move, clamped to the docs root. Docsify
1262
+ * read a leading-slash image from the page's folder too (`/x.png` in
1263
+ * `guide/page.md` is `guide/x.png`), where Blume reads it from `public/`.
1264
+ */
1265
+ const assetFor = (href, page, index, ctx) => {
1266
+ const { anchor, path: target } = splitAnchor(href);
1267
+ const dir = posix.dirname(page.rel);
1268
+ const file = docsPath(dir, target);
1269
+ const rooted = target.startsWith("/");
1270
+ if (!existsSync(path.join(ctx.root, file))) {
1271
+ const root = rooted && existsSync(path.join(ctx.root, target));
1272
+ const hint = root
1273
+ ? `; Docsify read a leading-slash path from the page's folder, so it looked for ${file}. If ${target.slice(1)} is the image meant, point the link at it`
1274
+ : "";
1275
+ review(
1276
+ page,
1277
+ index,
1278
+ `image ${href} doesn't exist (it was broken on the old site too)${hint}`
1279
+ );
1280
+ }
1281
+ const climbs = posix.normalize(posix.join(dir, target)) !== file;
1282
+ if (!(ctx.moves.has(page.rel) || climbs || rooted)) {
1283
+ return href;
1284
+ }
1285
+ const into = ctx.moves.get(page.rel) ?? page.rel;
1286
+ const next = relativeFile(into, file) + (anchor ? `#${anchor}` : "");
1287
+ if (posix.normalize(next) !== posix.normalize(href)) {
1288
+ let kind = "relative path rebased for the move";
1289
+ if (rooted) {
1290
+ kind = "leading-slash image → path from the page's folder";
1291
+ } else if (climbs) {
1292
+ kind = "path above the docs root clamped";
1293
+ }
1294
+ count(page, kind);
1295
+ }
1296
+ return next;
1297
+ };
1298
+
1299
+ /**
1300
+ * A `':ignore'` link: Docsify left the href alone for the browser, which read
1301
+ * a relative one from the docs root (from the page's folder in history mode).
1302
+ * One naming a page's `.md` file opened that raw file, which Blume serves as
1303
+ * the page's Markdown copy, at its new route plus `.md`. A Markdown link to a
1304
+ * page's file lands on the page itself, so that one becomes a raw `<a href>`,
1305
+ * which keeps its path: `{ href, raw: true }`.
1306
+ */
1307
+ const ignoredLinkFor = (href, page, ctx) => {
1308
+ const local = !(EXTERNAL.test(href) || href.startsWith("#") || href === "");
1309
+ if (!local) {
1310
+ return { href };
1311
+ }
1312
+ const { anchor, path: target } = splitAnchor(href);
1313
+ const rooted = target.startsWith("/");
1314
+ const dir = ctx.settings.history && !rooted ? posix.dirname(page.rel) : ".";
1315
+ const file = docsPath(dir, target);
1316
+ if (ctx.pageFiles.has(file)) {
1317
+ count(
1318
+ page,
1319
+ "':ignore' link to a page's file → <a href> to its Markdown copy"
1320
+ );
1321
+ const route = routeOfPage(ctx.moves.get(file) ?? file);
1322
+ const copy = route === "/" ? "/index.md" : `${route}.md`;
1323
+ return { href: copy + (anchor ? `#${anchor}` : ""), raw: true };
1324
+ }
1325
+ if (rooted) {
1326
+ noteRootFile(href, ctx);
1327
+ }
1328
+ if (rooted || ctx.settings.history) {
1329
+ return { href };
1330
+ }
1331
+ count(page, "':ignore' link → root path");
1332
+ return { href: rootedUrl(href, ctx) };
1333
+ };
1334
+
1335
+ const escapeAttribute = (value) =>
1336
+ value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
1337
+
1338
+ /** A raw `<a href>`, its title from a link's ` "title"` (as stripped). */
1339
+ const rawLink = (label, href, title) => {
1340
+ const text = title.trim().slice(1, -1);
1341
+ const titled = text ? ` title="${escapeAttribute(text)}"` : "";
1342
+ return `<a href="${escapeAttribute(href)}"${titled}>${label}</a>`;
1343
+ };
1344
+
1345
+ const rewriteMatch = (page, index, ctx, { kind, line, match }) => {
1346
+ const { href, text, title } = match.groups;
1347
+ const original = line.slice(match.index, match.index + match[0].length);
1348
+ if (title.includes(":include")) {
1349
+ return original;
1350
+ }
1351
+ const stripped = stripAttributes(page, title, kind);
1352
+ const local = !(EXTERNAL.test(href) || href.startsWith("#") || href === "");
1353
+ const textAt = match.index + (kind === "image" ? 2 : 1);
1354
+ const label = line.slice(textAt, textAt + text.length);
1355
+ let target = href;
1356
+ if (kind === "link" && stripped.config.has("ignore")) {
1357
+ const ignored = ignoredLinkFor(href, page, ctx);
1358
+ if (ignored.raw) {
1359
+ return rawLink(label, ignored.href, stripped.title);
1360
+ }
1361
+ target = ignored.href;
1362
+ } else if (kind === "link") {
1363
+ target = linkFor(href, page, index, ctx);
1364
+ } else if (local) {
1365
+ target = assetFor(href, page, index, ctx);
1366
+ }
1367
+ const bang = kind === "image" ? "!" : "";
1368
+ return `${bang}[${label}](${target}${stripped.title})`;
1369
+ };
1370
+
1371
+ const rewriteLinks = (page, index, line, ctx) => {
1372
+ const masked = maskCode(line);
1373
+ const matches = [
1374
+ ...[...masked.matchAll(LINK)].map((match) => ({
1375
+ kind: "link",
1376
+ line,
1377
+ match,
1378
+ })),
1379
+ ...[...masked.matchAll(IMAGE)].map((match) => ({
1380
+ kind: "image",
1381
+ line,
1382
+ match,
1383
+ })),
1384
+ ].toSorted((a, b) => a.match.index - b.match.index);
1385
+ let out = "";
1386
+ let cursor = 0;
1387
+ for (const entry of matches) {
1388
+ if (entry.match.index >= cursor) {
1389
+ out +=
1390
+ line.slice(cursor, entry.match.index) +
1391
+ rewriteMatch(page, index, ctx, entry);
1392
+ cursor = entry.match.index + entry.match[0].length;
1393
+ }
1394
+ }
1395
+ return out + line.slice(cursor);
1396
+ };
1397
+
1398
+ /** Raw HTML URLs: hash routing resolved a relative one from `/`. */
1399
+ const rewriteRawUrls = (page, line, ctx) =>
1400
+ line.replaceAll(RAW_URL_ATTR, (whole, start, quote, url) => {
1401
+ if (url.startsWith("/")) {
1402
+ noteRootFile(url, ctx);
1403
+ }
1404
+ const absolute =
1405
+ url === "" ||
1406
+ EXTERNAL.test(url) ||
1407
+ url.startsWith("/") ||
1408
+ url.startsWith("#");
1409
+ if (absolute) {
1410
+ return whole;
1411
+ }
1412
+ count(page, "raw HTML relative URL → root path");
1413
+ return `${start}${quote}${rootedUrl(url, ctx)}${quote}`;
1414
+ });
1415
+
1416
+ const rewriteEmoji = (page, line, ctx) => {
1417
+ const masked = maskCode(line);
1418
+ let out = "";
1419
+ let cursor = 0;
1420
+ for (const match of masked.matchAll(SHORTCODE)) {
1421
+ const before = masked.slice(Math.max(0, match.index - 8), match.index);
1422
+ const inUrl = URL_BEFORE.test(before);
1423
+ const emoji = inUrl ? undefined : ctx.emoji.get(match.groups.name);
1424
+ if (emoji) {
1425
+ out += line.slice(cursor, match.index) + emoji;
1426
+ cursor = match.index + match[0].length;
1427
+ count(page, "emoji shortcode → Unicode");
1428
+ } else if (!inUrl) {
1429
+ page.shortcodes.add(match[0]);
1430
+ }
1431
+ }
1432
+ return out + line.slice(cursor);
1433
+ };
1434
+
1435
+ /** Links, images, raw HTML URLs, emoji, and page-level syntax on one line. */
1436
+ const rewriteLine = (page, index, ctx) => {
1437
+ let line = page.lines[index];
1438
+ if (SCRIPT.test(line)) {
1439
+ review(
1440
+ page,
1441
+ index,
1442
+ "a <script> in the page: Docsify 4 ran it only with executeScript or Vue; move it to an island or drop it"
1443
+ );
1444
+ }
1445
+ if (UPDATED.test(line)) {
1446
+ line = line.replace(UPDATED, "");
1447
+ review(page, index, '{docsify-updated} removed: set lastModified: "git"');
1448
+ }
1449
+ line = rewriteLinks(page, index, line, ctx);
1450
+ if (!ctx.settings.history) {
1451
+ line = rewriteRawUrls(page, line, ctx);
1452
+ }
1453
+ if (!ctx.settings.noEmoji) {
1454
+ line = rewriteEmoji(page, line, ctx);
1455
+ }
1456
+ page.lines[index] = line;
1457
+ };
1458
+
1459
+ // --- Titles and frontmatter -------------------------------------------------------
1460
+
1461
+ const splitFrontmatter = (lines) => {
1462
+ const end = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
1463
+ return end === -1
1464
+ ? { body: lines, front: null }
1465
+ : { body: lines.slice(end + 1), front: lines.slice(1, end) };
1466
+ };
1467
+
1468
+ /** The page's headings and the kind of its first block. */
1469
+ const collectHeadings = (page) => {
1470
+ const states = scanLines(page.lines);
1471
+ const headings = [];
1472
+ let firstBlock = null;
1473
+ for (const [index, line] of page.lines.entries()) {
1474
+ const blank =
1475
+ line.trim() === "" || line === DELETED || states[index].comment;
1476
+ const match = blank || inCode(states[index]) ? null : ATX.exec(line);
1477
+ if (match?.groups.text === undefined) {
1478
+ firstBlock ??= blank ? null : "text";
1479
+ continue;
1480
+ }
1481
+ const level = match.groups.hashes.length;
1482
+ headings.push({ index, level, text: match.groups.text });
1483
+ firstBlock ??= level === 1 ? "h1" : "heading";
1484
+ }
1485
+ return { firstBlock, headings };
1486
+ };
1487
+
1488
+ /** The id Docsify gave the leading H1 that becomes a page's title, if any. */
1489
+ const titleId = (page, version) => {
1490
+ const probe = { edits: {}, lines: [...page.original] };
1491
+ setextToAtx(probe);
1492
+ const { firstBlock, headings } = collectHeadings(probe);
1493
+ return firstBlock === "h1"
1494
+ ? docsifyBaseId(headings[0].text, version)
1495
+ : undefined;
1496
+ };
1497
+
1498
+ const reviewTitle = (page, { h1s, label, title }) => {
1499
+ const labelWords = words(label);
1500
+ const fileWords = words(page.rel);
1501
+ const shared = [...words(title)].some(
1502
+ (word) => labelWords.has(word) || fileWords.has(word)
1503
+ );
1504
+ if (!shared || (h1s.length > 1 && title !== label)) {
1505
+ review(
1506
+ page,
1507
+ h1s[0].index,
1508
+ `title "${title}" (sidebar: "${label}", ${h1s.length} H1s): if this H1 is a section, not the page's title, take the label as the title and demote every heading instead`
1509
+ );
1510
+ }
1511
+ };
1512
+
1513
+ const demoteHeadings = (page, headings, from) => {
1514
+ for (const heading of headings) {
1515
+ if (heading.index < from || page.lines[heading.index] === DELETED) {
1516
+ continue;
1517
+ }
1518
+ if (heading.level === 6) {
1519
+ review(
1520
+ page,
1521
+ heading.index,
1522
+ "an h6 can't move down a level; flatten this section by hand"
1523
+ );
1524
+ continue;
1525
+ }
1526
+ page.lines[heading.index] = page.lines[heading.index].replace(
1527
+ LEADING_HASH,
1528
+ "$<indent>##"
1529
+ );
1530
+ count(page, "heading demoted");
1531
+ }
1532
+ };
1533
+
1534
+ /** The page's title, taking the leading H1 out of the body, and demotions. */
1535
+ const titleAndDemote = (page, ctx) => {
1536
+ const { firstBlock, headings } = collectHeadings(page);
1537
+ const h1s = headings.filter((heading) => heading.level === 1);
1538
+ // A folder's README is named for its folder, not "README".
1539
+ const stem = stemOf(page.rel);
1540
+ const dir = posix.dirname(page.rel);
1541
+ const name =
1542
+ INDEX_STEM.test(stem) && dir !== "." ? posix.basename(dir) : stem;
1543
+ const label = ctx.sidebarByFile.get(page.rel)?.label ?? humanize(name);
1544
+ if (firstBlock !== "h1") {
1545
+ if (h1s.length > 0) {
1546
+ demoteHeadings(page, headings, 0);
1547
+ review(
1548
+ page,
1549
+ h1s[0].index,
1550
+ `content comes before the first H1, so the title is "${label}" and every heading moved down a level`
1551
+ );
1552
+ }
1553
+ return label;
1554
+ }
1555
+ const [first, second] = h1s;
1556
+ const title = plainText(
1557
+ first.text.replace(PIN_MARKER, "").replace(TOC_MARKER, "")
1558
+ );
1559
+ // Only a sidebar label is worth comparing; the home page's is "Home".
1560
+ if (ctx.sidebarByFile.has(page.rel) && page.route !== "/") {
1561
+ reviewTitle(page, { h1s, label, title });
1562
+ }
1563
+ page.lines[first.index] = DELETED;
1564
+ count(page, "H1 → frontmatter title");
1565
+ demoteHeadings(page, headings, second?.index ?? Number.POSITIVE_INFINITY);
1566
+ return title;
1567
+ };
1568
+
1569
+ const buildFrontmatter = (page, title, ctx) => {
1570
+ const sidebar = ctx.sidebarByFile.get(page.rel);
1571
+ const entries = [`title: ${yamlString(title)}`];
1572
+ if (sidebar?.label && sidebar.label !== title) {
1573
+ entries.push("sidebar:", ` label: ${yamlString(sidebar.label)}`);
1574
+ }
1575
+ if (sidebar?.title) {
1576
+ entries.push("seo:", ` title: ${yamlString(sidebar.title)}`);
1577
+ }
1578
+ if (ctx.hasSidebar && !sidebar && page.route !== "/") {
1579
+ entries.push("hidden: true");
1580
+ count(page, "unlisted page → hidden: true");
1581
+ }
1582
+ const existing = page.front ?? [];
1583
+ for (const line of existing) {
1584
+ const key = FRONT_KEY.exec(line)?.groups.key;
1585
+ if (key && !BLUME_KEYS.has(key)) {
1586
+ review(
1587
+ page,
1588
+ 0,
1589
+ `frontmatter key "${key}" isn't in Blume's schema: map it or remove it`
1590
+ );
1591
+ }
1592
+ }
1593
+ return [...entries, ...existing.filter((line) => !line.startsWith("title:"))];
1594
+ };
1595
+
1596
+ // --- MDX ----------------------------------------------------------------------
1597
+
1598
+ const mdxSafeLine = (line, inComment) => {
1599
+ const comment = inComment || line.includes("<!--") || line.includes("-->");
1600
+ const uncommented = comment
1601
+ ? line.replaceAll(COMMENT_OPEN, "{/*").replaceAll(COMMENT_CLOSE, "*/}")
1602
+ : line;
1603
+ return uncommented
1604
+ .replaceAll(CLOSE_BR, "<br />")
1605
+ .replaceAll(VOID_TAG, "<$<tag>$<rest> />")
1606
+ .replaceAll(AUTOLINK, "[$<url>]($<url>)");
1607
+ };
1608
+
1609
+ const makeMdxSafe = (page) => {
1610
+ const states = scanLines(page.lines);
1611
+ for (const [index, line] of page.lines.entries()) {
1612
+ const next = states[index].fence
1613
+ ? line
1614
+ : mdxSafeLine(line, states[index].comment);
1615
+ if (next !== line) {
1616
+ page.lines[index] = next;
1617
+ count(page, "MDX-safe HTML (comment, void tag, autolink)");
1618
+ }
1619
+ }
1620
+ };
1621
+
1622
+ /** A <p> holding a block element can span lines: search the whole page. */
1623
+ const reviewParagraphBlocks = (page, states) => {
1624
+ const starts = [];
1625
+ let offset = 0;
1626
+ for (const line of page.lines) {
1627
+ starts.push(offset);
1628
+ offset += line.length + 1;
1629
+ }
1630
+ const joined = page.lines
1631
+ .map((line, index) => (states[index].fence ? "" : line))
1632
+ .join("\n");
1633
+ for (const match of joined.matchAll(P_WITH_BLOCK)) {
1634
+ const index = starts.findLastIndex((start) => start <= match.index);
1635
+ review(
1636
+ page,
1637
+ index,
1638
+ "a block element inside <p>: MDX rejects it, so make the <p> a <div>"
1639
+ );
1640
+ }
1641
+ };
1642
+
1643
+ const reviewAttributes = (page, index, masked) => {
1644
+ for (const tag of masked.matchAll(TAG_ATTRIBUTES)) {
1645
+ const names = [...tag.groups.attributes.matchAll(ATTRIBUTE_NAME)].map(
1646
+ (name) => name.groups.name.toLowerCase()
1647
+ );
1648
+ if (new Set(names).size !== names.length) {
1649
+ review(
1650
+ page,
1651
+ index,
1652
+ "an HTML tag repeats an attribute: MDX rejects it, so keep one"
1653
+ );
1654
+ }
1655
+ }
1656
+ };
1657
+
1658
+ const reviewLine = (page, index, { line, startsBlock }) => {
1659
+ const masked = maskCode(line);
1660
+ const html = masked.trimStart().startsWith("<");
1661
+ if (startsBlock && INDENTED.test(line) && !html) {
1662
+ review(page, index, "an indented code block: MDX has none, so fence it");
1663
+ }
1664
+ if (CELL_WITH_TEXT.test(masked)) {
1665
+ review(
1666
+ page,
1667
+ index,
1668
+ "an HTML table tag shares its line with text: give each structural tag its own line"
1669
+ );
1670
+ }
1671
+ reviewAttributes(page, index, masked);
1672
+ if (BRACE.test(masked.replaceAll(MDX_COMMENT, "")) && !html) {
1673
+ review(
1674
+ page,
1675
+ index,
1676
+ "a brace in prose is a JSX expression in MDX: escape it as \\{ or put it in code"
1677
+ );
1678
+ }
1679
+ };
1680
+
1681
+ /** What MDX still rejects in a renamed page. */
1682
+ const reviewMdxHazards = (page) => {
1683
+ const states = scanLines(page.lines);
1684
+ reviewParagraphBlocks(page, states);
1685
+ let previousBlank = true;
1686
+ let inList = false;
1687
+ for (const [index, line] of page.lines.entries()) {
1688
+ if (line === DELETED) {
1689
+ continue;
1690
+ }
1691
+ if (!states[index].fence) {
1692
+ reviewLine(page, index, { line, startsBlock: previousBlank && !inList });
1693
+ }
1694
+ if (line.trim() !== "") {
1695
+ inList = LIST_OR_QUOTE.test(line) || (inList && line.startsWith(" "));
1696
+ }
1697
+ previousBlank = line.trim() === "";
1698
+ }
1699
+ };
1700
+
1701
+ // --- The snapshot ---------------------------------------------------------------
1702
+
1703
+ /**
1704
+ * The Markdown a line includes: `{ file, fragment }`, `{ file: "" }` for
1705
+ * another kind of include, or null for no include.
1706
+ */
1707
+ const includedMarkdown = (line, page, ctx) => {
1708
+ const include = INCLUDE_LINE.exec(line);
1709
+ if (!include || isCodeIndent(include.groups.indent)) {
1710
+ return null;
1711
+ }
1712
+ const { options, target } = include.groups;
1713
+ if (EXTERNAL.test(target)) {
1714
+ return { file: "" };
1715
+ }
1716
+ const file = docsPath(posix.dirname(page.rel), target);
1717
+ const { config } = parseConfig(options);
1718
+ const type = includeType(config, file);
1719
+ if (type !== "markdown" || !existsSync(path.join(ctx.root, file))) {
1720
+ return { file: "" };
1721
+ }
1722
+ const cut =
1723
+ config.has("fragment") && takesFragment(type, ctx.settings.version);
1724
+ return { file, fragment: cut ? config.get("fragment") : undefined };
1725
+ };
1726
+
1727
+ const setextHeading = (lines, index) => {
1728
+ const line = lines[index];
1729
+ const under = index + 1 < lines.length ? SETEXT.exec(lines[index + 1]) : null;
1730
+ const plain =
1731
+ under &&
1732
+ line.trim() !== "" &&
1733
+ !LIST_OR_QUOTE.test(line) &&
1734
+ !ATX.test(line) &&
1735
+ !line.trimStart().startsWith("<") &&
1736
+ (index === 0 || lines[index - 1].trim() === "");
1737
+ if (!plain) {
1738
+ return null;
1739
+ }
1740
+ return {
1741
+ level: under.groups.rule.startsWith("=") ? 1 : 2,
1742
+ text: line.trim(),
1743
+ };
1744
+ };
1745
+
1746
+ const snapshotHeading = (lines, index) => {
1747
+ const atx = ATX.exec(lines[index])?.groups;
1748
+ if (atx) {
1749
+ return { level: atx.hashes.length, setext: false, text: atx.text ?? "" };
1750
+ }
1751
+ const setext = setextHeading(lines, index);
1752
+ return setext ? { ...setext, setext: true } : null;
1753
+ };
1754
+
1755
+ /** The headings Docsify rendered for a page, includes and setext ones too. */
1756
+ const snapshotHeadings = (page, ctx, lines, seen) => {
1757
+ const states = scanLines(lines);
1758
+ const out = [];
1759
+ let inTabs = false;
1760
+ for (let index = 0; index < lines.length; index += 1) {
1761
+ const line = lines[index];
1762
+ inTabs = (inTabs || TABS_START.test(line)) && !TABS_END.test(line);
1763
+ const included = includedMarkdown(line, page, ctx);
1764
+ const tab = inTabs && tabLabel(line) !== undefined;
1765
+ const skip =
1766
+ inCode(states[index]) ||
1767
+ states[index].comment ||
1768
+ tab ||
1769
+ included !== null;
1770
+ const heading = skip ? null : snapshotHeading(lines, index);
1771
+ if (included?.file && !seen.has(included.file)) {
1772
+ const { file, fragment } = included;
1773
+ const raw = readFileSync(path.join(ctx.root, file), "utf-8");
1774
+ const text = fragment
1775
+ ? (extractFragment(raw, fragment, ctx.settings.version) ?? "")
1776
+ : raw;
1777
+ const { body } = splitFrontmatter(text.split("\n"));
1778
+ const nested = new Set([...seen, file]);
1779
+ out.push(...snapshotHeadings({ rel: file }, ctx, body, nested));
1780
+ } else if (heading) {
1781
+ out.push(heading);
1782
+ index += heading.setext ? 1 : 0;
1783
+ }
1784
+ }
1785
+ return out;
1786
+ };
1787
+
1788
+ const snapshotHtml = (headings, version) => {
1789
+ const counts = new Map();
1790
+ const html = [];
1791
+ for (const { level, text } of headings) {
1792
+ // Docsify counts an empty id too: the second empty heading is `-1`.
1793
+ const base = docsifyBaseId(text, version);
1794
+ const seen = counts.has(base) ? counts.get(base) + 1 : 0;
1795
+ counts.set(base, seen);
1796
+ const id = seen ? `${base}-${seen}` : base;
1797
+ if (id !== "") {
1798
+ const visible = visibleText(withoutConfig(text))
1799
+ .replaceAll("&", "&amp;")
1800
+ .replaceAll("<", "&lt;");
1801
+ const attribute = id.replaceAll('"', "&quot;");
1802
+ html.push(`<h${level} id="${attribute}">${visible}</h${level}>`);
1803
+ }
1804
+ }
1805
+ return html;
1806
+ };
1807
+
1808
+ const writeSnapshot = (pages, ctx, dir) => {
1809
+ mkdirSync(dir, { recursive: true });
1810
+ const routes = new Set(ctx.oldRoutes);
1811
+ for (const page of pages) {
1812
+ const headings = snapshotHeadings(page, ctx, page.original, new Set());
1813
+ const html = snapshotHtml(headings, ctx.settings.version);
1814
+ const file =
1815
+ page.route === "/" ? "index.html" : `${page.route.slice(1)}/index.html`;
1816
+ mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
1817
+ writeFileSync(
1818
+ path.join(dir, file),
1819
+ `<!doctype html>\n<main>\n${html.join("\n")}\n</main>\n`
1820
+ );
1821
+ routes.add(page.route);
1822
+ }
1823
+ const list = [...routes].toSorted().join("\n");
1824
+ writeFileSync(path.join(dir, "old-routes.txt"), `${list}\n`);
1825
+ };
1826
+
1827
+ const navRoute = (href) => {
1828
+ const bare = href
1829
+ .replace(HASH_PREFIX, "")
1830
+ .replace(QUERY, "")
1831
+ .replace(MD_EXT, "")
1832
+ .replace(LEADING_SLASHES, "");
1833
+ const route = `/${bare}`.replace(README_ROUTE, "$<lead>");
1834
+ return route.length > 1 ? route.replace(TRAILING_SLASHES, "$<first>") : route;
1835
+ };
1836
+
1837
+ /** Old routes the sidebar, navbar, and cover link (aliases included). */
1838
+ const collectOldRoutes = (ctx, files) => {
1839
+ const routes = new Set();
1840
+ const navFiles = files.filter((rel) =>
1841
+ isNavFile(posix.basename(rel), ctx.settings)
1842
+ );
1843
+ for (const file of navFiles) {
1844
+ const text = readFileSync(path.join(ctx.root, file), "utf-8").replaceAll(
1845
+ MD_IMAGE,
1846
+ ""
1847
+ );
1848
+ for (const match of text.matchAll(NAV_LINK)) {
1849
+ const { href } = match.groups;
1850
+ const anchorOnly = href.startsWith("#") && !href.startsWith("#/");
1851
+ if (!(SCHEME.test(href) || anchorOnly)) {
1852
+ routes.add(navRoute(href));
1853
+ }
1854
+ }
1855
+ }
1856
+ return routes;
1857
+ };
1858
+
1859
+ // --- The run ------------------------------------------------------------------
1860
+
1861
+ const sidebarFile = (item, folder, ctx) => {
1862
+ const href = item.href.replace(HASH_PREFIX, "");
1863
+ const relative =
1864
+ ctx.settings.relativePath && !href.startsWith("/") && folder !== ".";
1865
+ const base = relative ? `/${folder}` : "/";
1866
+ const resolved = posix.normalize(posix.join(base, splitAnchor(href).path));
1867
+ const trailing = href.endsWith("/") ? "/" : "";
1868
+ const routed = fileOfRoute(`${resolved}${trailing}`, ctx.settings);
1869
+ return MD_EXT.test(routed) ? existingPage(ctx.root, routed) : undefined;
1870
+ };
1871
+
1872
+ /** The sidebar(s): the root one names groups; a folder's own one is a tab. */
1873
+ const loadSidebars = (ctx, files, notes) => {
1874
+ const sidebars = files.filter(
1875
+ (rel) => posix.basename(rel) === ctx.settings.sidebar
1876
+ );
1877
+ for (const file of sidebars) {
1878
+ ctx.hasSidebar = true;
1879
+ const folder = posix.dirname(file);
1880
+ if (folder !== ".") {
1881
+ notes.push(
1882
+ `${file}: a per-folder sidebar; make /${folder} a navigation.tabs entry`
1883
+ );
1884
+ }
1885
+ const text = readFileSync(path.join(ctx.root, file), "utf-8");
1886
+ for (const item of parseSidebar(text)) {
1887
+ const external = SCHEME.test(item.href) || item.href.startsWith("//");
1888
+ const page = external ? undefined : sidebarFile(item, folder, ctx);
1889
+ if (!page) {
1890
+ notes.push(
1891
+ `${file}: "${item.label}" → ${item.href} isn't a page here (external, an alias, a static file, or a dead link)`
1892
+ );
1893
+ } else if (!ctx.sidebarByFile.has(page)) {
1894
+ ctx.sidebarByFile.set(page, item);
1895
+ // The home page stays the content root's index, in no group.
1896
+ const home = page === ctx.settings.homepage || page === "index.md";
1897
+ ctx.groupsByFile.set(page, folder === "." && !home ? item.groups : []);
1898
+ }
1899
+ }
1900
+ }
1901
+ };
1902
+
1903
+ const newPage = (rel, text, settings) => {
1904
+ const { body, front } = splitFrontmatter(text.split("\n"));
1905
+ const partial = isPartialPath(rel);
1906
+ return {
1907
+ converted: front?.some((line) => line.startsWith("title:")) ?? false,
1908
+ edits: {},
1909
+ front,
1910
+ includes: [],
1911
+ lines: [...body],
1912
+ original: body,
1913
+ partial,
1914
+ rel,
1915
+ reviews: [],
1916
+ route: partial ? null : routeOfFile(rel, settings),
1917
+ shortcodes: new Set(),
1918
+ };
1919
+ };
1920
+
1921
+ /** Every Markdown file Docsify could route to; partials convert too. */
1922
+ const loadPages = (ctx, files, notes) => {
1923
+ const pages = [];
1924
+ for (const rel of files) {
1925
+ const nav = isNavFile(posix.basename(rel), ctx.settings);
1926
+ const text =
1927
+ MD_EXT.test(rel) && !nav
1928
+ ? readFileSync(path.join(ctx.root, rel), "utf-8")
1929
+ : null;
1930
+ if (text !== null && HTML_DOCUMENT.test(text)) {
1931
+ notes.push(
1932
+ `${rel}: an HTML document saved as Markdown; publish the HTML from public/ instead`
1933
+ );
1934
+ } else if (text !== null) {
1935
+ pages.push(newPage(rel, text, ctx.settings));
1936
+ }
1937
+ }
1938
+ return pages;
1939
+ };
1940
+
1941
+ /** README → index, and a folder per sidebar group; `.mdx` comes later. */
1942
+ const nextPath = (page, ctx, options) => {
1943
+ const dir = posix.dirname(page.rel);
1944
+ let next = page.rel;
1945
+ if (page.rel === ctx.settings.homepage && page.rel !== "index.md") {
1946
+ next = "index.md";
1947
+ } else if (
1948
+ README.test(page.rel) &&
1949
+ !existingPage(ctx.root, posix.join(dir, "index.md"))
1950
+ ) {
1951
+ next = posix.join(dir, "index.md");
1952
+ }
1953
+ const groups = ctx.groupsByFile.get(page.rel) ?? [];
1954
+ const folder = groups.map(slugifyFolder).join("/");
1955
+ const regroup = options.has("group-folders") && folder !== "";
1956
+ if (regroup && posix.dirname(next) !== folder) {
1957
+ next = posix.join(folder, posix.basename(next));
1958
+ }
1959
+ return next;
1960
+ };
1961
+
1962
+ const planMoves = (pages, ctx, options) => {
1963
+ const targets = new Set();
1964
+ for (const page of pages.filter(
1965
+ (entry) => !(entry.partial || entry.converted)
1966
+ )) {
1967
+ const next = nextPath(page, ctx, options);
1968
+ const taken = targets.has(next) || existsSync(path.join(ctx.root, next));
1969
+ if (next !== page.rel && taken) {
1970
+ review(page, 0, `can't move to ${next}: a file is already there`);
1971
+ } else if (next !== page.rel) {
1972
+ ctx.moves.set(page.rel, next);
1973
+ targets.add(next);
1974
+ }
1975
+ }
1976
+ };
1977
+
1978
+ const needsMdx = (page) => page.lines.some((line) => MDX_ONLY.test(line));
1979
+
1980
+ /** Pages that need MDX: their own syntax, or a partial they include. */
1981
+ const planMdx = (pages, ctx) => {
1982
+ const byFile = new Map(pages.map((page) => [page.rel, page]));
1983
+ for (const page of pages.filter((entry) => !entry.partial)) {
1984
+ const viaPartial = page.includes.some(
1985
+ ({ file, lang }) =>
1986
+ !lang && byFile.has(file) && needsMdx(byFile.get(file))
1987
+ );
1988
+ if (needsMdx(page) || viaPartial) {
1989
+ const next = (ctx.moves.get(page.rel) ?? page.rel).replace(
1990
+ MD_EXT,
1991
+ ".mdx"
1992
+ );
1993
+ ctx.moves.set(page.rel, next);
1994
+ }
1995
+ }
1996
+ };
1997
+
1998
+ const convertPage = (page, ctx) => {
1999
+ setextToAtx(page);
2000
+ convertTabs(page);
2001
+ convertCallouts(page);
2002
+ convertIncludes(page, ctx);
2003
+ convertHeadingAttributes(page);
2004
+ const states = scanLines(page.lines);
2005
+ for (const index of page.lines.keys()) {
2006
+ const skip =
2007
+ inCode(states[index]) ||
2008
+ states[index].comment ||
2009
+ page.lines[index] === DELETED;
2010
+ if (!skip) {
2011
+ rewriteLine(page, index, ctx);
2012
+ }
2013
+ }
2014
+ };
2015
+
2016
+ const finishPage = (page, ctx) => {
2017
+ if (!(page.partial || page.converted)) {
2018
+ const title = titleAndDemote(page, ctx);
2019
+ page.front = buildFrontmatter(page, title, ctx);
2020
+ }
2021
+ if ((ctx.moves.get(page.rel) ?? "").endsWith(".mdx")) {
2022
+ makeMdxSafe(page);
2023
+ reviewMdxHazards(page);
2024
+ }
2025
+ const hint = ctx.emoji.size === 0 ? " (pass --emoji)" : "";
2026
+ for (const code of [...page.shortcodes].toSorted()) {
2027
+ review(
2028
+ page,
2029
+ 0,
2030
+ `emoji shortcode ${code} renders as text in Blume: replace it with the emoji${hint}`
2031
+ );
2032
+ }
2033
+ };
2034
+
2035
+ /** Old route → new route, for every page whose route changed. */
2036
+ const routeTable = (pages, ctx) => {
2037
+ const table = {};
2038
+ for (const page of pages.filter(
2039
+ (entry) => !(entry.partial || entry.converted)
2040
+ )) {
2041
+ const next = routeOfPage(ctx.moves.get(page.rel) ?? page.rel);
2042
+ if (next !== page.route) {
2043
+ table[page.route] = next;
2044
+ }
2045
+ // A `homepage: "intro.md"` page also answered at its own route.
2046
+ const own = `/${page.rel.replace(MD_EXT, "")}`;
2047
+ if (page.route === "/" && !README.test(page.rel) && own !== next) {
2048
+ table[own] = next;
2049
+ }
2050
+ }
2051
+ return table;
2052
+ };
2053
+
2054
+ /**
2055
+ * meta.ts per group folder, in sidebar order, plus one at the content root
2056
+ * that keeps the groups in sidebar order (Blume would sort them by name).
2057
+ */
2058
+ const groupMetas = (ctx) => {
2059
+ const metas = new Map();
2060
+ const top = [];
2061
+ for (const [file, groups] of ctx.groupsByFile) {
2062
+ const [first, ...rest] = (ctx.moves.get(file) ?? file).split("/");
2063
+ const topChild = childSlug(rest.length > 0 ? first : stemOf(first));
2064
+ if (topChild !== "index" && !top.includes(topChild)) {
2065
+ top.push(topChild);
2066
+ }
2067
+ for (const [depth, group] of groups.entries()) {
2068
+ const folder = groups
2069
+ .slice(0, depth + 1)
2070
+ .map(slugifyFolder)
2071
+ .join("/");
2072
+ const meta = metas.get(folder) ?? { pages: [], title: group };
2073
+ const last = depth === groups.length - 1;
2074
+ const child = last
2075
+ ? stripOrder(stemOf(ctx.moves.get(file) ?? file))
2076
+ : slugifyFolder(groups[depth + 1]);
2077
+ if (!meta.pages.includes(child)) {
2078
+ meta.pages.push(child);
2079
+ }
2080
+ metas.set(folder, meta);
2081
+ }
2082
+ }
2083
+ if (metas.size > 0 && top.length > 1) {
2084
+ metas.set(".", { pages: top });
2085
+ }
2086
+ return metas;
2087
+ };
2088
+
2089
+ /** The page's output text, with each finding's line number in it. */
2090
+ const renderPage = (page) => {
2091
+ const positions = [];
2092
+ const body = [];
2093
+ for (const line of page.lines) {
2094
+ positions.push(body.length);
2095
+ if (line !== DELETED) {
2096
+ body.push(line);
2097
+ }
2098
+ }
2099
+ const leading = Math.max(
2100
+ 0,
2101
+ body.findIndex((line) => line.trim() !== "")
2102
+ );
2103
+ const front = page.front ? ["---", ...page.front, "---", ""] : [];
2104
+ const reviews = page.reviews
2105
+ .map(({ index, message }) => {
2106
+ const position = Math.max(0, (positions[index] ?? body.length) - leading);
2107
+ return { line: front.length + position + 1, message };
2108
+ })
2109
+ .toSorted((a, b) => a.line - b.line);
2110
+ return { reviews, text: [...front, ...body.slice(leading)].join("\n") };
2111
+ };
2112
+
2113
+ const writeMetas = (ctx, metas, notes) => {
2114
+ for (const [folder, meta] of metas) {
2115
+ const file = path.join(ctx.root, folder, "meta.ts");
2116
+ if (existsSync(file)) {
2117
+ notes.push(`${folder}/meta.ts exists; left it alone`);
2118
+ continue;
2119
+ }
2120
+ mkdirSync(path.dirname(file), { recursive: true });
2121
+ const pages = meta.pages.map(yamlString).join(", ");
2122
+ const title = meta.title ? ` title: ${yamlString(meta.title)},\n` : "";
2123
+ writeFileSync(
2124
+ file,
2125
+ `import { defineMeta } from "blume";\n\nexport default defineMeta({\n${title} pages: [${pages}],\n});\n`
2126
+ );
2127
+ }
2128
+ };
2129
+
2130
+ const writeRouteTable = (table, file) => {
2131
+ const previous = existsSync(file)
2132
+ ? JSON.parse(readFileSync(file, "utf-8"))
2133
+ : {};
2134
+ const merged = Object.fromEntries(
2135
+ Object.entries({ ...previous, ...table }).toSorted(([a], [b]) =>
2136
+ a.localeCompare(b)
2137
+ )
2138
+ );
2139
+ writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`);
2140
+ };
2141
+
2142
+ const writeResults = (ctx, { metas, notes, options, results, table }) => {
2143
+ for (const result of results.filter((entry) => entry.changed)) {
2144
+ const target = path.join(ctx.root, result.renamed ?? result.file);
2145
+ mkdirSync(path.dirname(target), { recursive: true });
2146
+ writeFileSync(target, result.text);
2147
+ if (result.renamed) {
2148
+ rmSync(path.join(ctx.root, result.file));
2149
+ }
2150
+ }
2151
+ for (const [file, text] of ctx.newFiles) {
2152
+ writeFileSync(path.join(ctx.root, file), text);
2153
+ }
2154
+ writeMetas(ctx, metas, notes);
2155
+ if (Object.keys(table).length > 0) {
2156
+ const out = options.get("routes-out") ?? "docsify-routes.json";
2157
+ writeRouteTable(table, path.resolve(out));
2158
+ }
2159
+ };
2160
+
2161
+ const siteNotes = (files, settings, notes) => {
2162
+ if (files.includes(settings.cover)) {
2163
+ notes.push(
2164
+ `${settings.cover}: rebuild the cover as the top of index.md (title, tagline, links); drop its background`
2165
+ );
2166
+ }
2167
+ if (files.includes(settings.navbar)) {
2168
+ notes.push(
2169
+ `${settings.navbar}: map its links to navigation.tabs (doc sections) or navigation.actions (the rest)`
2170
+ );
2171
+ }
2172
+ };
2173
+
2174
+ const takeSnapshot = (pages, ctx, options, notes) => {
2175
+ const dir = options.get("snapshot");
2176
+ if (!dir) {
2177
+ return;
2178
+ }
2179
+ const converted = pages.filter((page) => page.converted).length;
2180
+ if (converted > 0) {
2181
+ notes.push(
2182
+ `snapshot skipped: ${converted} page(s) already have a title, so this isn't the first run`
2183
+ );
2184
+ return;
2185
+ }
2186
+ const routed = pages.filter((page) => !page.partial);
2187
+ writeSnapshot(routed, ctx, path.resolve(dir));
2188
+ };
2189
+
2190
+ const pageResult = (page, ctx) => {
2191
+ const next = ctx.moves.get(page.rel) ?? page.rel;
2192
+ const { reviews, text } = renderPage(page);
2193
+ const before = readFileSync(path.join(ctx.root, page.rel), "utf-8");
2194
+ return {
2195
+ changed: text !== before || next !== page.rel,
2196
+ edits: page.edits,
2197
+ file: page.rel,
2198
+ renamed: next === page.rel ? undefined : next,
2199
+ reviews,
2200
+ text,
2201
+ };
2202
+ };
2203
+
2204
+ const createContext = (root, options) => ({
2205
+ emoji: loadEmoji(options.get("emoji")),
2206
+ groupsByFile: new Map(),
2207
+ hasSidebar: false,
2208
+ moves: new Map(),
2209
+ newFiles: new Map(),
2210
+ oldRoutes: new Set(),
2211
+ pageFiles: new Set(),
2212
+ publicFiles: new Set(),
2213
+ root,
2214
+ settings: readSettings(root, options),
2215
+ sidebarByFile: new Map(),
2216
+ site: options.get("site")?.replace(SITE_SLASHES, "").toLowerCase() ?? "",
2217
+ titleIds: new Map(),
2218
+ });
2219
+
2220
+ const run = (root, options) => {
2221
+ const ctx = createContext(root, options);
2222
+ const files = listFiles(root);
2223
+ const notes = [];
2224
+ loadSidebars(ctx, files, notes);
2225
+ ctx.oldRoutes = collectOldRoutes(ctx, files);
2226
+ siteNotes(files, ctx.settings, notes);
2227
+ const pages = loadPages(ctx, files, notes);
2228
+ ctx.pageFiles = new Set(
2229
+ pages.filter((page) => !page.partial).map((page) => page.rel)
2230
+ );
2231
+ for (const page of pages.filter((entry) => !entry.partial)) {
2232
+ ctx.titleIds.set(page.rel, titleId(page, ctx.settings.version));
2233
+ }
2234
+ takeSnapshot(pages, ctx, options, notes);
2235
+ planMoves(pages, ctx, options);
2236
+ const fresh = pages.filter((page) => !page.converted);
2237
+ for (const page of fresh) {
2238
+ convertPage(page, ctx);
2239
+ }
2240
+ planMdx(fresh, ctx);
2241
+ for (const page of pages) {
2242
+ finishPage(page, ctx);
2243
+ }
2244
+ const table = routeTable(pages, ctx);
2245
+ const metas = options.has("group-folders") ? groupMetas(ctx) : new Map();
2246
+ const results = pages
2247
+ .map((page) => pageResult(page, ctx))
2248
+ .filter((result) => result.changed || result.reviews.length > 0);
2249
+ if (options.has("write")) {
2250
+ writeResults(ctx, { metas, notes, options, results, table });
2251
+ }
2252
+ return {
2253
+ files: results.map(({ text: _text, ...rest }) => rest),
2254
+ metas: [...metas.keys()].toSorted(),
2255
+ notes: notes.toSorted(),
2256
+ publicFiles: [...ctx.publicFiles].toSorted(),
2257
+ routes: table,
2258
+ settings: ctx.settings,
2259
+ };
2260
+ };
2261
+
2262
+ // --- CLI ----------------------------------------------------------------------
2263
+
2264
+ const HELP = [
2265
+ "Usage:",
2266
+ " node docsify-codemod.mjs [options] <docsify-dir>",
2267
+ "",
2268
+ " --write apply (default: dry run)",
2269
+ " --snapshot <dir> write old heading ids + old-routes.txt for pin-heading-ids.mjs",
2270
+ " --group-folders move pages into a folder per sidebar group, write meta.ts",
2271
+ " --routes-out <file> the route table (default: docsify-routes.json)",
2272
+ " --emoji <file> shortcode → emoji map (GitHub's /emojis JSON works)",
2273
+ " --site <url> the old site's address, for its own absolute links",
2274
+ " --version 4|5 Docsify's major version (default: from index.html)",
2275
+ " --relative-path true|false Docsify's relativePath (default: from index.html)",
2276
+ " --sidebar <file> the sidebar file name (default: from index.html)",
2277
+ " --json JSON report",
2278
+ ].join("\n");
2279
+
2280
+ const VALUE_OPTIONS = new Set([
2281
+ "emoji",
2282
+ "relative-path",
2283
+ "routes-out",
2284
+ "sidebar",
2285
+ "site",
2286
+ "snapshot",
2287
+ "version",
2288
+ ]);
2289
+
2290
+ const parseArgs = (argv) => {
2291
+ const options = new Map();
2292
+ const positional = [];
2293
+ for (let index = 0; index < argv.length; index += 1) {
2294
+ const arg = argv[index];
2295
+ const [key, inline] = arg.startsWith("--")
2296
+ ? arg.slice(2).split("=", 2)
2297
+ : [];
2298
+ if (key === undefined) {
2299
+ positional.push(arg);
2300
+ } else if (VALUE_OPTIONS.has(key) && inline === undefined) {
2301
+ index += 1;
2302
+ options.set(key, argv[index]);
2303
+ } else {
2304
+ options.set(key, inline ?? "");
2305
+ }
2306
+ }
2307
+ return { options, positional };
2308
+ };
2309
+
2310
+ const printFiles = (files, totals) => {
2311
+ let reviewCount = 0;
2312
+ for (const file of files) {
2313
+ const name = file.renamed ? `${file.file} → ${file.renamed}` : file.file;
2314
+ process.stdout.write(`\n${name}\n`);
2315
+ for (const [kind, n] of Object.entries(file.edits).toSorted()) {
2316
+ process.stdout.write(` ${n} × ${kind}\n`);
2317
+ totals.set(kind, (totals.get(kind) ?? 0) + n);
2318
+ }
2319
+ for (const { line, message } of file.reviews) {
2320
+ process.stdout.write(` REVIEW line ${line}: ${message}\n`);
2321
+ }
2322
+ reviewCount += file.reviews.length;
2323
+ }
2324
+ return reviewCount;
2325
+ };
2326
+
2327
+ const printList = (heading, items) => {
2328
+ if (items.length === 0) {
2329
+ return;
2330
+ }
2331
+ process.stdout.write(`\n${heading}\n`);
2332
+ for (const item of items) {
2333
+ process.stdout.write(` ${item}\n`);
2334
+ }
2335
+ };
2336
+
2337
+ const printReport = (result, options) => {
2338
+ const { settings } = result;
2339
+ const write = options.has("write");
2340
+ const routing = settings.history ? "history" : "hash";
2341
+ process.stdout.write(
2342
+ `Docsify ${settings.version}, relativePath: ${settings.relativePath}, ${routing} routing, sidebar: ${settings.sidebar}\n`
2343
+ );
2344
+ const totals = new Map();
2345
+ const reviewCount = printFiles(result.files, totals);
2346
+ printList(
2347
+ "Site",
2348
+ result.notes.map((note) => `REVIEW ${note}`)
2349
+ );
2350
+ printList(
2351
+ "Files the pages link that must be served from public/",
2352
+ result.publicFiles
2353
+ );
2354
+ const moved = Object.keys(result.routes).length;
2355
+ const out = options.get("routes-out") ?? "docsify-routes.json";
2356
+ const where = write ? `, written to ${out}` : "";
2357
+ printList("Routes", moved > 0 ? [`${moved} route(s) changed${where}`] : []);
2358
+ printList(
2359
+ "Totals",
2360
+ [...totals].toSorted().map(([kind, n]) => `${n} × ${kind}`)
2361
+ );
2362
+ const changed = result.files.filter((file) => file.changed).length;
2363
+ const verb = write ? "changed" : "would change";
2364
+ const hint = write ? "" : " Re-run with --write to apply.";
2365
+ const reviews = reviewCount + result.notes.length;
2366
+ process.stdout.write(
2367
+ `\n${result.files.length} file(s) with findings, ${changed} ${verb}, ${reviews} item(s) to review.${hint}\n`
2368
+ );
2369
+ };
2370
+
2371
+ const main = () => {
2372
+ const { options, positional } = parseArgs(process.argv.slice(2));
2373
+ const [dir] = positional;
2374
+ if (options.has("help") || !dir) {
2375
+ process.stdout.write(`${HELP}\n`);
2376
+ return;
2377
+ }
2378
+ const result = run(path.resolve(dir), options);
2379
+ if (options.has("json")) {
2380
+ const json = { ...result, wrote: options.has("write") };
2381
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
2382
+ return;
2383
+ }
2384
+ printReport(result, options);
2385
+ };
2386
+
2387
+ main();