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,1049 @@
1
+ #!/usr/bin/env node
2
+ // pin-heading-ids.mjs — keep a migrated site's old heading anchors working.
3
+ //
4
+ // Every docs tool slugs headings its own way. GitBook writes
5
+ // `AppConfig:OCSP:Enabled` as `#appconfig-ocsp-enabled` where Blume writes
6
+ // `#appconfigocspenabled`; VitePress, MkDocs, and others differ in their own
7
+ // cases. After a migration, every `#anchor` link to such a heading and every
8
+ // deep link from outside lands at the top of the page instead. This script
9
+ // finds those headings and pins the old id on each:
10
+ //
11
+ // 1. It reads the ids the old site actually published — its live pages, or
12
+ // a local directory of its built HTML — rather than deriving them, since
13
+ // every tool has edge cases (GitBook truncates long ids, for one).
14
+ // 2. It reads the ids Blume actually rendered from the migrated build
15
+ // (`dist/`), and pairs the two pages' headings by their text, in order.
16
+ // Only letters and digits are compared, after dropping each old heading's
17
+ // permalink (MkDocs' `¶`, VitePress's and Docusaurus's anchors), so smart
18
+ // dashes, markup, and emoji don't break a pair.
19
+ // 3. For each pair whose ids differ, it finds the heading's source line — in
20
+ // the page itself or in an `<include>`d partial — and appends the old id:
21
+ // `[#id]` in `.mdx`, `{#id}` in `.md`, and `\{#id\}` in a partial (that
22
+ // spelling pins the same anchor in both formats). An existing pin that
23
+ // disagrees is replaced.
24
+ //
25
+ // Usage, from the migrated project after `blume build`:
26
+ //
27
+ // node pin-heading-ids.mjs --old https://docs.example.com # dry run
28
+ // node pin-heading-ids.mjs --old https://docs.example.com --write # apply
29
+ // node pin-heading-ids.mjs --old ../old-site/build --map dist/blume-redirects.json
30
+ //
31
+ // Then rebuild and run it again until it reports 0: a pin can change the `-1`
32
+ // suffix Blume gives a later heading with the same text. Run `blume validate`
33
+ // after: it checks every `#anchor` link against the pinned ids.
34
+ //
35
+ // Options:
36
+ // --old <url|dir> The old site: its base URL (pages are fetched one at a
37
+ // time) or a directory of its built HTML. Required.
38
+ // --dist <dir> The migrated build (default: dist). A server build
39
+ // (vercel(), node(), cloudflare()) keeps its pages in
40
+ // dist/client, which is read when dist has a client/
41
+ // folder and no index.html of its own. Vercel's build
42
+ // also copies them to .vercel/output/static.
43
+ // --manifest <file> Blume's route manifest, which maps each route to its
44
+ // source file (default: <runtime dir>/blume.manifest.json,
45
+ // the runtime dir being $BLUME_RUNTIME_DIR or .blume).
46
+ // Without one, routes are derived from the file paths
47
+ // under --docs (ordering prefixes, (group) folders,
48
+ // index, and a frontmatter slug; not i18n or versions).
49
+ // --docs <dir> The content root (default: the manifest's, else docs).
50
+ // --map <file> Old URL → new route, for pages that moved: a JSON object
51
+ // { "/old": "/new" }, or an array of { from, to } entries:
52
+ // Blume's dist/blume-redirects.json works as it is, but a
53
+ // build writes it only when no host adapter is set (with
54
+ // one, save the config's `redirects` array as JSON).
55
+ // Patterns and .md/.mdx mirrors are skipped. A page it
56
+ // doesn't name is looked up at its own route.
57
+ // --old-scope <tag> The element holding the old page's content (default:
58
+ // main; the whole page when there's none).
59
+ // --new-scope <tag> The same for the build (default: article).
60
+ // --only-linked Pin only ids that content links to: a #fragment in a
61
+ // Markdown link or an href. For sites where hundreds of
62
+ // ids change and only the linked ones matter.
63
+ // --links <file> More ids to keep, one per line: `id`, `#id`,
64
+ // `/route#id`, or a full URL. Implies --only-linked.
65
+ // --cache <dir> Where fetched pages are kept, so reruns don't refetch
66
+ // (default: .pin-heading-ids-cache; delete it when done).
67
+ // --delay <ms> Pause between fetches (default: 500).
68
+ // --write Apply the pins. Without it, report only.
69
+ // --json A machine-readable report.
70
+ //
71
+ // The old pages must be server-rendered HTML with ids on their headings. A
72
+ // client-rendered site (Docsify) has none in its HTML: pass the directory
73
+ // `docsify-codemod.mjs --snapshot` wrote as `--old`. A heading whose text the source
74
+ // doesn't spell out (one a component renders, or a reworded one) is reported
75
+ // as a NOTE with no source line: pin it by hand.
76
+ //
77
+ // Zero dependencies: runs on a bare Node.js 22 with nothing installed.
78
+
79
+ import {
80
+ existsSync,
81
+ mkdirSync,
82
+ readdirSync,
83
+ readFileSync,
84
+ realpathSync,
85
+ statSync,
86
+ writeFileSync,
87
+ } from "node:fs";
88
+ import path from "node:path";
89
+ import { setTimeout as sleep } from "node:timers/promises";
90
+
91
+ const USAGE = [
92
+ "Usage: node pin-heading-ids.mjs --old <url|dir> [options]",
93
+ "",
94
+ " --old <url|dir> the old site's base URL, or a directory of its built HTML",
95
+ " --dist <dir> the migrated build (default: dist; a server build's",
96
+ " dist/client is read when dist has no index.html)",
97
+ " --manifest <file> Blume's route manifest (default: .blume/blume.manifest.json)",
98
+ " --docs <dir> the content root (default: the manifest's, else docs)",
99
+ " --map <file> old URL → new route, as JSON (dist/blume-redirects.json, written with no host adapter, works)",
100
+ " --old-scope <tag> the old page's content element (default: main)",
101
+ " --new-scope <tag> the build's content element (default: article)",
102
+ " --only-linked pin only ids that content links to",
103
+ " --links <file> more ids to keep, one per line (implies --only-linked)",
104
+ " --cache <dir> fetched-page cache (default: .pin-heading-ids-cache)",
105
+ " --delay <ms> pause between fetches (default: 500)",
106
+ " --write apply the pins (default: report only)",
107
+ " --json machine-readable report",
108
+ ].join("\n");
109
+
110
+ // --- Arguments ---------------------------------------------------------------
111
+
112
+ const FLAGS = new Set(["--help", "--json", "--only-linked", "--write", "-h"]);
113
+
114
+ const parseArgs = (argv) => {
115
+ const options = {};
116
+ for (let i = 0; i < argv.length; i += 1) {
117
+ const arg = argv[i];
118
+ if (FLAGS.has(arg)) {
119
+ options[arg.replace(/^-+/u, "")] = true;
120
+ } else if (arg.startsWith("--")) {
121
+ const value = argv[i + 1];
122
+ if (value === undefined || value.startsWith("--")) {
123
+ throw new Error(`${arg} needs a value`);
124
+ }
125
+ options[arg.slice(2)] = value;
126
+ i += 1;
127
+ } else {
128
+ throw new Error(`unexpected argument: ${arg}`);
129
+ }
130
+ }
131
+ return options;
132
+ };
133
+
134
+ // --- HTML --------------------------------------------------------------------
135
+
136
+ // The named entities docs sites write in headings: punctuation, arrows, and
137
+ // the pilcrow MkDocs and Sphinx put in their permalinks.
138
+ const NAMED_ENTITIES = {
139
+ amp: "&",
140
+ apos: "'",
141
+ bull: "•",
142
+ copy: "©",
143
+ darr: "↓",
144
+ emsp: " ",
145
+ ensp: " ",
146
+ gt: ">",
147
+ harr: "↔",
148
+ hellip: "…",
149
+ laquo: "«",
150
+ larr: "←",
151
+ ldquo: "“",
152
+ lsquo: "‘",
153
+ lt: "<",
154
+ mdash: "—",
155
+ middot: "·",
156
+ nbsp: " ",
157
+ ndash: "–",
158
+ para: "¶",
159
+ quot: '"',
160
+ raquo: "»",
161
+ rarr: "→",
162
+ rdquo: "”",
163
+ reg: "®",
164
+ rsquo: "’",
165
+ sect: "§",
166
+ shy: "",
167
+ thinsp: " ",
168
+ times: "×",
169
+ trade: "™",
170
+ uarr: "↑",
171
+ zwj: "",
172
+ zwnj: "",
173
+ };
174
+
175
+ const ENTITY = /&(?<entity>#x[0-9a-f]+|#\d+|[a-z]+);/giu;
176
+
177
+ /**
178
+ * `text` with its character references decoded. A named one outside the table
179
+ * is kept as written, or replaced by `unknown(match)`: in rendered HTML, where
180
+ * a literal `&` is always `&amp;`, it can only be a character, never letters.
181
+ */
182
+ const decodeEntities = (text, unknown = (match) => match) =>
183
+ text.replace(ENTITY, (match, entity) => {
184
+ if (!entity.startsWith("#")) {
185
+ return NAMED_ENTITIES[entity.toLowerCase()] ?? unknown(match);
186
+ }
187
+ const hex = entity[1] === "x" || entity[1] === "X";
188
+ const code = hex
189
+ ? Number.parseInt(entity.slice(2), 16)
190
+ : Number(entity.slice(1));
191
+ return Number.isInteger(code) ? String.fromCodePoint(code) : match;
192
+ });
193
+
194
+ /** `text` matched literally inside a `RegExp`. */
195
+ const escapeRegExp = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
196
+
197
+ /** The markup between the first `<tag …>` and the last `</tag>`, else all of it. */
198
+ const scope = (html, tag) => {
199
+ const open = new RegExp(`<${escapeRegExp(tag)}[\\s>]`, "iu").exec(html);
200
+ const close = html.toLowerCase().lastIndexOf(`</${tag.toLowerCase()}>`);
201
+ return open && close > open.index ? html.slice(open.index, close) : html;
202
+ };
203
+
204
+ const HIDDEN_MARKUP = /<(?<tag>script|style|svg)\b[\s\S]*?<\/\k<tag>>/giu;
205
+
206
+ /** `html` without its script, style, and svg elements, removed until none is left. */
207
+ const withoutHiddenMarkup = (html) => {
208
+ let out = html;
209
+ let previous;
210
+ do {
211
+ previous = out;
212
+ out = out.replaceAll(HIDDEN_MARKUP, "");
213
+ } while (out !== previous);
214
+ return out;
215
+ };
216
+
217
+ const ANCHOR = /<a\b(?<attributes>[^>]*)>(?<content>[\s\S]*?)<\/a\s*>/giu;
218
+ // Permalinks that sit beside a heading's text, never around it: MkDocs and
219
+ // Sphinx (`headerlink`), Docusaurus (`hash-link`), and Starlight.
220
+ const PERMALINK_CLASS =
221
+ /\sclass\s*=\s*["'](?:[^"']*\s)?(?:headerlink|hash-link|sl-anchor-link)(?:\s[^"']*)?["']/iu;
222
+ const HEADING =
223
+ /<h(?<level>[1-6])(?<attributes>\s[^>]*)?>(?<inner>[\s\S]*?)<\/h\k<level>\s*>/giu;
224
+ const ID_ATTRIBUTE =
225
+ /\sid\s*=\s*(?:"(?<double>[^"]*)"|'(?<single>[^']*)'|(?<bare>[^\s"'>]+))/iu;
226
+
227
+ const dropEntity = () => "";
228
+
229
+ /** Markup as the text a reader sees: tags gone, entities decoded. */
230
+ const visibleText = (markup) =>
231
+ decodeEntities(markup.replaceAll(/<[^>]*>/gu, " "), dropEntity);
232
+
233
+ /**
234
+ * A heading's markup without its permalink: one by a known class, or a link
235
+ * with no letters or digits in it (`¶`, `#`, a zero-width space, an icon).
236
+ * A link around the heading's text, as Blume and VuePress 2 write it, stays.
237
+ */
238
+ const withoutPermalinks = (inner) =>
239
+ inner.replaceAll(ANCHOR, (match, attributes, content) =>
240
+ PERMALINK_CLASS.test(` ${attributes}`) ||
241
+ !/[\p{L}\p{N}]/u.test(visibleText(content))
242
+ ? " "
243
+ : match
244
+ );
245
+
246
+ /** Every heading with an id, in document order: `{ id, level, text }`. */
247
+ const htmlHeadings = (html) => {
248
+ const out = [];
249
+ for (const match of withoutHiddenMarkup(html).matchAll(HEADING)) {
250
+ const { attributes = "", inner, level } = match.groups;
251
+ const id = ID_ATTRIBUTE.exec(attributes)?.groups;
252
+ const value = decodeEntities(id?.double ?? id?.single ?? id?.bare ?? "");
253
+ if (value) {
254
+ const text = visibleText(withoutPermalinks(inner))
255
+ .replaceAll(/\s+/gu, " ")
256
+ .trim();
257
+ out.push({ id: value, level: Number(level), text });
258
+ }
259
+ }
260
+ return out;
261
+ };
262
+
263
+ /** Letters and digits only, so markup, punctuation, and emoji can't break a pair. */
264
+ const normalize = (text) =>
265
+ text
266
+ .normalize("NFKC")
267
+ .toLowerCase()
268
+ .replaceAll(/[^\p{L}\p{N}]+/gu, "");
269
+
270
+ // --- Routes and source files --------------------------------------------------
271
+
272
+ /** A path as reports show it: relative to `from`, with `/` on every platform. */
273
+ const shown = (from, file) =>
274
+ path.relative(from, file).split(path.sep).join("/");
275
+
276
+ /** A path with symlinks resolved (macOS's /var is /private/var), so reports stay relative. */
277
+ const real = (file) => {
278
+ try {
279
+ return realpathSync(file);
280
+ } catch {
281
+ return file;
282
+ }
283
+ };
284
+
285
+ const ORDERING_PREFIX = /^\d+[-_.]/u;
286
+ const NOT_AN_ORDER = /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u;
287
+ const GROUP = /^\(.*\)$/u;
288
+
289
+ const stripOrder = (name) =>
290
+ NOT_AN_ORDER.test(name) ? name : name.replace(ORDERING_PREFIX, "");
291
+
292
+ /** A route without a trailing slash (`/` stays `/`), decoded. */
293
+ const toRoute = (value) => {
294
+ let route = value.replace(/[?#].*$/u, "");
295
+ try {
296
+ route = decodeURI(route);
297
+ } catch {
298
+ // Keep a malformed escape as written.
299
+ }
300
+ route = route.startsWith("/") ? route : `/${route}`;
301
+ return route.length > 1 ? route.replace(/\/+$/u, "") : route;
302
+ };
303
+
304
+ const FRONTMATTER = /^---\r?\n(?<yaml>[\s\S]*?)\r?\n---/u;
305
+ const SLUG = /^slug:\s*["']?(?<slug>[^"'\n]+?)["']?\s*$/mu;
306
+
307
+ const frontmatterSlug = (text) => {
308
+ const yaml = FRONTMATTER.exec(text)?.groups?.yaml ?? "";
309
+ return SLUG.exec(yaml)?.groups?.slug.trim();
310
+ };
311
+
312
+ const walkContent = (dir, files = []) => {
313
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
314
+ const full = path.join(dir, entry.name);
315
+ const skipped = entry.name.startsWith(".") || entry.name === "node_modules";
316
+ if (!skipped && entry.isDirectory()) {
317
+ walkContent(full, files);
318
+ } else if (!skipped && /\.mdx?$/u.test(entry.name)) {
319
+ files.push(full);
320
+ }
321
+ }
322
+ return files;
323
+ };
324
+
325
+ /** The route Blume gives a content file, for projects without a manifest. */
326
+ const deriveRoute = (contentRoot, file) => {
327
+ const slug = frontmatterSlug(readFileSync(file, "utf-8"));
328
+ if (slug) {
329
+ return toRoute(slug);
330
+ }
331
+ const segments = [];
332
+ const relative = path.relative(contentRoot, file).replace(/\.mdx?$/u, "");
333
+ for (const part of relative.split(path.sep)) {
334
+ const name = stripOrder(part);
335
+ if (!(GROUP.test(part) || GROUP.test(name) || name === "index")) {
336
+ segments.push(name.replaceAll(/[:#?%]/gu, ""));
337
+ }
338
+ }
339
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
340
+ };
341
+
342
+ const isPublished = (contentRoot, file) =>
343
+ path
344
+ .relative(contentRoot, file)
345
+ .split(path.sep)
346
+ .every((part) => !part.startsWith("_"));
347
+
348
+ const pagesFromManifest = (manifestPath, options, cwd) => {
349
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
350
+ const pages = [];
351
+ for (const entry of manifest.routes ?? []) {
352
+ const route = String(entry.path ?? "");
353
+ const file = String(entry.sourcePath ?? "");
354
+ if (route && file && existsSync(file)) {
355
+ pages.push({ file: real(file), route });
356
+ }
357
+ }
358
+ const contentRoot = real(
359
+ options.docs
360
+ ? path.resolve(cwd, options.docs)
361
+ : String(manifest.contentRoot ?? path.resolve(cwd, "docs"))
362
+ );
363
+ return { contentRoot, pages };
364
+ };
365
+
366
+ /** `{ contentRoot, pages: [{ route, file }] }` from the manifest or the files. */
367
+ const loadPages = (options, cwd) => {
368
+ const runtime = process.env.BLUME_RUNTIME_DIR ?? ".blume";
369
+ const manifestPath = path.resolve(
370
+ cwd,
371
+ options.manifest ?? path.join(runtime, "blume.manifest.json")
372
+ );
373
+ if (existsSync(manifestPath)) {
374
+ return pagesFromManifest(manifestPath, options, cwd);
375
+ }
376
+ if (options.manifest) {
377
+ throw new Error(`no manifest at ${manifestPath}`);
378
+ }
379
+ const contentRoot = real(path.resolve(cwd, options.docs ?? "docs"));
380
+ if (!existsSync(contentRoot)) {
381
+ throw new Error(
382
+ `no Blume manifest (run \`blume build\` first) and no content root at ${contentRoot}`
383
+ );
384
+ }
385
+ const pages = walkContent(contentRoot)
386
+ .filter((file) => isPublished(contentRoot, file))
387
+ .map((file) => ({ file, route: deriveRoute(contentRoot, file) }));
388
+ return { contentRoot, pages };
389
+ };
390
+
391
+ /** New route → the old URLs to try for it, from --map. */
392
+ const loadMap = (file) => {
393
+ if (!existsSync(file)) {
394
+ throw new Error(
395
+ `no redirect map at ${file}: a build writes dist/blume-redirects.json only when no host adapter is set, so save the config's \`redirects\` as JSON instead`
396
+ );
397
+ }
398
+ const data = JSON.parse(readFileSync(file, "utf-8"));
399
+ const entries = Array.isArray(data)
400
+ ? data.map((entry) => [entry.from, entry.to])
401
+ : Object.entries(data);
402
+ const oldFor = new Map();
403
+ for (const [rawFrom, rawTo] of entries) {
404
+ const from = String(rawFrom ?? "");
405
+ const to = String(rawTo ?? "");
406
+ const skip =
407
+ !(from && to) ||
408
+ /[:*]/u.test(from) ||
409
+ /\.mdx?$/u.test(from) ||
410
+ /^[a-z]+:\/\//iu.test(to);
411
+ if (!skip) {
412
+ const route = toRoute(to);
413
+ oldFor.set(route, [...(oldFor.get(route) ?? []), toRoute(from)]);
414
+ }
415
+ }
416
+ return oldFor;
417
+ };
418
+
419
+ // --- Old pages ------------------------------------------------------------------
420
+
421
+ const readOldFile = (dir, route) => {
422
+ const base = path.join(dir, ...route.split("/").filter(Boolean));
423
+ const candidates =
424
+ route === "/"
425
+ ? [path.join(dir, "index.html")]
426
+ : [path.join(base, "index.html"), `${base}.html`, base];
427
+ const file = candidates.find(
428
+ (candidate) =>
429
+ candidate.endsWith(".html") &&
430
+ existsSync(candidate) &&
431
+ statSync(candidate).isFile()
432
+ );
433
+ return file ? readFileSync(file, "utf-8") : null;
434
+ };
435
+
436
+ const fetchPage = async (url) => {
437
+ try {
438
+ const response = await fetch(url, {
439
+ headers: { "user-agent": "blume-pin-heading-ids" },
440
+ signal: AbortSignal.timeout(30_000),
441
+ });
442
+ return response.ok ? await response.text() : null;
443
+ } catch {
444
+ return null;
445
+ }
446
+ };
447
+
448
+ const createOldSite = (options, cwd) => {
449
+ if (!/^https?:\/\//iu.test(options.old)) {
450
+ const dir = path.resolve(cwd, options.old);
451
+ if (!existsSync(dir)) {
452
+ throw new Error(`no old site at ${dir}`);
453
+ }
454
+ return { read: (route) => Promise.resolve(readOldFile(dir, route)) };
455
+ }
456
+ const base = options.old.replace(/\/+$/u, "");
457
+ const cache = path.resolve(cwd, options.cache ?? ".pin-heading-ids-cache");
458
+ const delay = Number(options.delay ?? 500);
459
+ let lastFetch = 0;
460
+ const read = async (route) => {
461
+ mkdirSync(cache, { recursive: true });
462
+ const key = path.join(cache, `${encodeURIComponent(route)}.html`);
463
+ if (existsSync(key)) {
464
+ return readFileSync(key, "utf-8");
465
+ }
466
+ // Be gentle with the old host: one request at a time, spaced out.
467
+ const wait = lastFetch + delay - Date.now();
468
+ if (wait > 0) {
469
+ await sleep(wait);
470
+ }
471
+ lastFetch = Date.now();
472
+ const html = await fetchPage(`${base}${encodeURI(route)}`);
473
+ if (html !== null) {
474
+ writeFileSync(key, html);
475
+ }
476
+ return html;
477
+ };
478
+ return { read };
479
+ };
480
+
481
+ // --- Source headings -------------------------------------------------------------
482
+
483
+ // Trailing heading markers Blume reads: `[#id]`, `{#id}`, `\{#id\}`, `[toc]`, `[!toc]`.
484
+ const MARKER = /\s*(?:\\?\{#[^\s}\\]+\\?\}|\[#[^\s\]]+\]|\[!?toc\])\s*$/u;
485
+ const ID_MARKER =
486
+ /^\s*(?:\\?\{#(?<curly>[^\s}\\]+)\\?\}|\[#(?<bracket>[^\s\]]+)\])\s*$/u;
487
+ const VARIABLE = /\{\{\s*[\w-]+\s*\}\}/gu;
488
+ const INCLUDE =
489
+ /^\s*<include\b(?<attributes>[^>]*)>\s*(?<target>[^<]+?)\s*<\/include>/u;
490
+ const FENCE = /^\s*(?<fence>`{3,}|~{3,})/u;
491
+
492
+ /** Split a heading line into its body and its trailing markers. */
493
+ const splitMarkers = (line) => {
494
+ let body = line;
495
+ const markers = [];
496
+ for (let match = MARKER.exec(body); match; match = MARKER.exec(body)) {
497
+ markers.unshift(match[0].trim());
498
+ body = body.slice(0, match.index);
499
+ }
500
+ return { body, markers };
501
+ };
502
+
503
+ /** The id a heading's markers already pin, if any. */
504
+ const pinnedId = (markers) => {
505
+ const groups = markers
506
+ .map((marker) => ID_MARKER.exec(marker)?.groups)
507
+ .find(Boolean);
508
+ return groups?.curly ?? groups?.bracket;
509
+ };
510
+
511
+ const ESCAPED_PUNCTUATION = /\\[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/gu;
512
+ // Not opened by an escaped backtick; closed by any run of the same length, as
513
+ // escapes don't work inside.
514
+ const CODE_SPAN = /(?<!\\)(?<ticks>`+)(?<code>[\s\S]*?)\k<ticks>/gu;
515
+ const HELD_CODE = /\uE000(?<index>\d+)\uE000/gu;
516
+
517
+ /** A heading's visible text, with `{{variables}}` kept as placeholders. */
518
+ const plainText = (body) => {
519
+ const spans = [];
520
+ const prose = body
521
+ .replace(/^\s*#{1,6}\s+/u, "")
522
+ .replace(/\s+#+\s*$/u, "")
523
+ // Code spans are literal: no escapes, tags, links, or entities inside.
524
+ .replaceAll(CODE_SPAN, (_match, _ticks, code) => {
525
+ spans.push(code);
526
+ return `\uE000${spans.length - 1}\uE000`;
527
+ })
528
+ // So is an escaped character, and only letters and digits are compared,
529
+ // so it reads as a space: `Promise\<App>` keeps its `App`.
530
+ .replaceAll(ESCAPED_PUNCTUATION, " ");
531
+ return decodeEntities(
532
+ prose
533
+ .replaceAll(/!\[[^\]]*\]\([^)]*\)/gu, "")
534
+ .replaceAll(/\[(?<label>[^\]]*)\]\([^)]*\)/gu, "$<label>")
535
+ .replaceAll(/<[^>]*>/gu, " ")
536
+ ).replaceAll(HELD_CODE, (_match, index) => spans[Number(index)]);
537
+ };
538
+
539
+ /** A matcher for a source heading against a rendered heading's text. */
540
+ const textMatcher = (text) => {
541
+ const parts = text
542
+ .split(VARIABLE)
543
+ .map((part) => normalize(part).replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"));
544
+ return new RegExp(`^${parts.join(".*")}$`, "u");
545
+ };
546
+
547
+ /** Index of the line after the frontmatter block, or 0. */
548
+ const bodyStart = (lines) => {
549
+ if (lines[0]?.trim() !== "---") {
550
+ return 0;
551
+ }
552
+ return (
553
+ lines.findIndex((line, index) => index > 0 && line.trim() === "---") + 1
554
+ );
555
+ };
556
+
557
+ /** An `<include>` statement at `i`, joined when a formatter wrapped it. */
558
+ const includeAt = (lines, i) => {
559
+ const line = lines[i];
560
+ if (!/^\s*<include\b/u.test(line)) {
561
+ return null;
562
+ }
563
+ if (line.includes("</include>")) {
564
+ return { end: i, groups: INCLUDE.exec(line)?.groups };
565
+ }
566
+ const end = lines.findIndex(
567
+ (next, index) => index > i && next.includes("</include>")
568
+ );
569
+ if (end === -1 || end - i > 5) {
570
+ return null;
571
+ }
572
+ const statement = lines.slice(i, end + 1).join(" ");
573
+ return { end, groups: INCLUDE.exec(statement)?.groups };
574
+ };
575
+
576
+ const resolveInclude = (file, target, context) =>
577
+ target.startsWith("/")
578
+ ? path.join(context.contentRoot, target)
579
+ : path.resolve(path.dirname(file), target);
580
+
581
+ /** Every heading in a file and the partials it includes, in order. */
582
+ const sourceHeadings = (file, context, seen = new Set()) => {
583
+ if (seen.has(file)) {
584
+ return [];
585
+ }
586
+ const included = seen.size > 0;
587
+ const nextSeen = new Set([...seen, file]);
588
+ const lines = readFileSync(file, "utf-8").split("\n");
589
+ const heading = file.endsWith(".mdx")
590
+ ? /^\s*#{1,6}\s+\S/u
591
+ : /^ {0,3}#{1,6}\s+\S/u;
592
+ const out = [];
593
+ let fence = null;
594
+ for (let i = bodyStart(lines); i < lines.length; i += 1) {
595
+ const line = lines[i];
596
+ const opener = FENCE.exec(line)?.groups?.fence;
597
+ const include = fence || opener ? null : includeAt(lines, i);
598
+ if (opener && !fence) {
599
+ fence = opener;
600
+ } else if (fence) {
601
+ fence = line.trim().startsWith(fence) ? null : fence;
602
+ } else if (include?.groups) {
603
+ i = include.end;
604
+ const { target } = include.groups;
605
+ const resolved = resolveInclude(file, target, context);
606
+ if (existsSync(resolved)) {
607
+ out.push(...sourceHeadings(resolved, context, nextSeen));
608
+ } else {
609
+ context.notes.push(
610
+ `${shown(context.cwd, file)}: include not found: ${target}`
611
+ );
612
+ }
613
+ } else if (heading.test(line)) {
614
+ const { body, markers } = splitMarkers(line);
615
+ out.push({
616
+ file,
617
+ included,
618
+ index: i,
619
+ match: textMatcher(plainText(body)),
620
+ pinned: pinnedId(markers),
621
+ });
622
+ }
623
+ }
624
+ return out;
625
+ };
626
+
627
+ /** The marker that pins `id` in `entry`'s file. */
628
+ const markerFor = (entry, id) => {
629
+ if (entry.included) {
630
+ return `\\{#${id}\\}`;
631
+ }
632
+ return entry.file.endsWith(".mdx") ? `[#${id}]` : `{#${id}}`;
633
+ };
634
+
635
+ /** `line` with its id marker replaced by, or extended with, `marker`. */
636
+ const applyMarker = (line, marker) => {
637
+ const { body, markers } = splitMarkers(line);
638
+ const kept = markers.filter((existing) => !ID_MARKER.test(existing));
639
+ const text = body.trimEnd().replace(/\s+#+$/u, "");
640
+ return [text, ...kept, marker].join(" ");
641
+ };
642
+
643
+ // --- Linked ids ---------------------------------------------------------------------
644
+
645
+ const fragmentOf = (target) => {
646
+ const hash = target.lastIndexOf("#");
647
+ if (hash === -1) {
648
+ return;
649
+ }
650
+ const fragment = target.slice(hash + 1).trim();
651
+ try {
652
+ return decodeURIComponent(fragment);
653
+ } catch {
654
+ return fragment;
655
+ }
656
+ };
657
+
658
+ const LINK_TARGET = /\]\(\s*<?(?<target>[^)\s>]*#[^)\s>]+)/gu;
659
+ const HREF_TARGET = /\bhref=\{?["'](?<target>[^"']*#[^"']+)["']/gu;
660
+
661
+ const linkedIds = (options, contentRoot, cwd) => {
662
+ const ids = new Set();
663
+ if (options.links) {
664
+ const listed = readFileSync(path.resolve(cwd, options.links), "utf-8");
665
+ for (const line of listed.split("\n")) {
666
+ const value = line.trim();
667
+ if (value) {
668
+ ids.add(fragmentOf(value) ?? value);
669
+ }
670
+ }
671
+ }
672
+ for (const file of walkContent(contentRoot)) {
673
+ const text = readFileSync(file, "utf-8");
674
+ for (const match of [
675
+ ...text.matchAll(LINK_TARGET),
676
+ ...text.matchAll(HREF_TARGET),
677
+ ]) {
678
+ ids.add(fragmentOf(match.groups.target));
679
+ }
680
+ }
681
+ ids.delete();
682
+ ids.delete("");
683
+ return ids;
684
+ };
685
+
686
+ // --- Pairing -------------------------------------------------------------------------
687
+
688
+ const isDirectory = (dir) => existsSync(dir) && statSync(dir).isDirectory();
689
+
690
+ /**
691
+ * The directory holding the pages: a server build's `client/` half, else
692
+ * `dist`. node() and cloudflare() write `client/` beside `server/`, vercel()
693
+ * writes only `client/`, and none of them puts an index.html in `dist`.
694
+ */
695
+ const pagesRoot = (dist) => {
696
+ const client = path.join(dist, "client");
697
+ if (isDirectory(client) && !existsSync(path.join(dist, "index.html"))) {
698
+ process.stderr.write(
699
+ `pin-heading-ids: reading ${client}, a server build's pages.\n`
700
+ );
701
+ return client;
702
+ }
703
+ return dist;
704
+ };
705
+
706
+ const distPage = (dist, route) => {
707
+ const base = path.join(dist, ...route.split("/").filter(Boolean));
708
+ const candidates =
709
+ route === "/"
710
+ ? [path.join(dist, "index.html")]
711
+ : [path.join(base, "index.html"), `${base}.html`];
712
+ return candidates.find((candidate) => existsSync(candidate));
713
+ };
714
+
715
+ /** The next unused source line, from the cursor on, whose text matches `key`. */
716
+ const takeSource = (lines, state, key) => {
717
+ let s = state.sourceCursor;
718
+ while (s < lines.length && (lines[s].used || !lines[s].match.test(key))) {
719
+ s += 1;
720
+ }
721
+ const entry = lines[s];
722
+ if (entry) {
723
+ entry.used = true;
724
+ state.sourceCursor = s + 1;
725
+ }
726
+ return entry;
727
+ };
728
+
729
+ const TITLE = /<h1(?<attributes>\s[^>]*)?>(?<inner>[\s\S]*?)<\/h1\s*>/iu;
730
+
731
+ /**
732
+ * The index of the old heading that became the page title: the first old h1
733
+ * with the text of the build's title, an h1 Blume renders without an id.
734
+ * Else -1.
735
+ */
736
+ const oldTitleIndex = (before, builtHtml) => {
737
+ const title = TITLE.exec(withoutHiddenMarkup(builtHtml))?.groups;
738
+ if (!title || ID_ATTRIBUTE.test(title.attributes ?? "")) {
739
+ return -1;
740
+ }
741
+ const key = normalize(visibleText(title.inner));
742
+ return before.findIndex(
743
+ (heading) => heading.level === 1 && normalize(heading.text) === key
744
+ );
745
+ };
746
+
747
+ /**
748
+ * The next old heading, from the cursor on, with the same text as `key`.
749
+ * A section heading passes over the one the page title took: a section that
750
+ * repeats the title's text (`# Install`, then `## Install`, which the old site
751
+ * gave `#install-1`) pairs with its own old heading. A built h1 is the source's
752
+ * own `# Install`, kept beside the title, so it pairs with the old title. A
753
+ * later old h1 (a section the migration turned into an h2) still pairs.
754
+ */
755
+ const takeOld = (before, state, heading, title) => {
756
+ const key = normalize(heading.text);
757
+ const skip = heading.level > 1 ? title : -1;
758
+ let k = state.oldCursor;
759
+ while (
760
+ k < before.length &&
761
+ (k === skip || normalize(before[k].text) !== key)
762
+ ) {
763
+ k += 1;
764
+ }
765
+ if (k === before.length) {
766
+ return -1;
767
+ }
768
+ state.oldCursor = k + 1;
769
+ return k;
770
+ };
771
+
772
+ /** Record a pin for one paired heading whose ids differ, or note why not. */
773
+ const pinHeading = (result, page, heading, oldId, entry) => {
774
+ const { claims, cwd, edits, notes, pins } = result;
775
+ if (!entry) {
776
+ notes.push(
777
+ `${page.route}: no source line for "${heading.text}" (#${heading.id})`
778
+ );
779
+ return;
780
+ }
781
+ if (/[\s\]}\\]/u.test(oldId)) {
782
+ notes.push(
783
+ `${page.route}: #${oldId} can't be written as a pin; link to #${heading.id} instead`
784
+ );
785
+ return;
786
+ }
787
+ // One source line can render on several pages (an included partial), and
788
+ // it can pin only one id: the first page to claim it wins.
789
+ const claim = `${entry.file}\n${entry.index}`;
790
+ const claimed = claims.get(claim);
791
+ if (claimed && claimed !== oldId) {
792
+ notes.push(
793
+ `${shown(cwd, entry.file)}:${entry.index + 1}: renders with two old ids (#${claimed}, #${oldId}), and a line pins one; kept #${claimed}`
794
+ );
795
+ return;
796
+ }
797
+ claims.set(claim, oldId);
798
+ const fileEdits = edits.get(entry.file) ?? new Map();
799
+ if (entry.pinned === oldId) {
800
+ result.awaitingBuild += 1;
801
+ } else if (!fileEdits.has(entry.index)) {
802
+ fileEdits.set(entry.index, markerFor(entry, oldId));
803
+ edits.set(entry.file, fileEdits);
804
+ pins.push({
805
+ file: shown(cwd, entry.file),
806
+ from: heading.id,
807
+ line: entry.index + 1,
808
+ replaces: entry.pinned,
809
+ route: page.route,
810
+ text: heading.text,
811
+ to: oldId,
812
+ });
813
+ }
814
+ };
815
+
816
+ const reportLost = (result, page, before, pairedOld) => {
817
+ // An old h1 is the page title: a link to it lands at the top either way.
818
+ for (const [k, heading] of before.entries()) {
819
+ const reportable =
820
+ !pairedOld.has(k) &&
821
+ heading.level > 1 &&
822
+ (!result.linked || result.linked.has(heading.id));
823
+ if (reportable) {
824
+ result.notes.push(
825
+ `${page.route}: old heading #${heading.id} ("${heading.text}") has no counterpart; its anchor is lost`
826
+ );
827
+ }
828
+ }
829
+ };
830
+
831
+ /** Pair one page's old and new headings, recording pins and notes. */
832
+ const pairPage = (result, page, oldHtml, builtHtml) => {
833
+ const { linked, options } = result;
834
+ const before = htmlHeadings(scope(oldHtml, options["old-scope"] ?? "main"));
835
+ const built = scope(builtHtml, options["new-scope"] ?? "article");
836
+ const after = htmlHeadings(built);
837
+ const title = oldTitleIndex(before, built);
838
+ const lines = sourceHeadings(page.file, result);
839
+ const pairedOld = new Set();
840
+ const state = { oldCursor: 0, sourceCursor: 0 };
841
+ result.compared += 1;
842
+ result.oldSections += before.filter((heading) => heading.level > 1).length;
843
+ for (const heading of after) {
844
+ const key = normalize(heading.text);
845
+ // Source lines and rendered headings share an order, so both scan forward.
846
+ const entry = takeSource(lines, state, key);
847
+ const k = takeOld(before, state, heading, title);
848
+ const oldId = k === -1 ? heading.id : before[k].id;
849
+ if (k !== -1) {
850
+ pairedOld.add(k);
851
+ result.paired += 1;
852
+ }
853
+ if (oldId !== heading.id && linked && !linked.has(oldId)) {
854
+ result.unlinked += 1;
855
+ } else if (oldId !== heading.id) {
856
+ pinHeading(result, page, heading, oldId, entry);
857
+ }
858
+ }
859
+ reportLost(result, page, before, pairedOld);
860
+ };
861
+
862
+ /** The first old URL for a page that the old site has, as HTML. */
863
+ const findOldPage = async (oldSite, candidates) => {
864
+ const [first, ...rest] = candidates;
865
+ if (first === undefined) {
866
+ return null;
867
+ }
868
+ return (await oldSite.read(first)) ?? findOldPage(oldSite, rest);
869
+ };
870
+
871
+ const writeEdits = (edits) => {
872
+ for (const [file, fileEdits] of edits) {
873
+ const lines = readFileSync(file, "utf-8").split("\n");
874
+ for (const [index, marker] of fileEdits) {
875
+ lines[index] = applyMarker(lines[index], marker);
876
+ }
877
+ writeFileSync(file, lines.join("\n"));
878
+ }
879
+ };
880
+
881
+ /** Visit each page in route order, one at a time, so the old host never sees parallel requests. */
882
+ const visitPages = async (pages, visit) => {
883
+ const [first, ...rest] = pages;
884
+ if (first === undefined) {
885
+ return;
886
+ }
887
+ await visit(first);
888
+ await visitPages(rest, visit);
889
+ };
890
+
891
+ const run = async (options) => {
892
+ const cwd = real(process.cwd());
893
+ const { contentRoot, pages } = loadPages(options, cwd);
894
+ const given = path.resolve(cwd, options.dist ?? "dist");
895
+ if (!existsSync(given)) {
896
+ throw new Error(`no build at ${given}: run \`blume build\` first`);
897
+ }
898
+ const dist = pagesRoot(given);
899
+ const oldFor = options.map
900
+ ? loadMap(path.resolve(cwd, options.map))
901
+ : new Map();
902
+ const oldSite = createOldSite(options, cwd);
903
+ const onlyLinked = Boolean(options["only-linked"] || options.links);
904
+ const result = {
905
+ awaitingBuild: 0,
906
+ claims: new Map(),
907
+ compared: 0,
908
+ contentRoot,
909
+ cwd,
910
+ dist,
911
+ edits: new Map(),
912
+ linked: onlyLinked ? linkedIds(options, contentRoot, cwd) : undefined,
913
+ notes: [],
914
+ oldSections: 0,
915
+ options,
916
+ paired: 0,
917
+ pins: [],
918
+ unlinked: 0,
919
+ };
920
+ const ordered = pages.toSorted((a, b) => a.route.localeCompare(b.route));
921
+ await visitPages(ordered, async (page) => {
922
+ const built = distPage(dist, page.route);
923
+ if (!built) {
924
+ result.notes.push(`${page.route}: not in the build`);
925
+ return;
926
+ }
927
+ const candidates = [...(oldFor.get(page.route) ?? []), page.route];
928
+ const oldHtml = await findOldPage(oldSite, candidates);
929
+ if (oldHtml) {
930
+ pairPage(result, page, oldHtml, readFileSync(built, "utf-8"));
931
+ } else {
932
+ result.notes.push(`${page.route}: not on the old site`);
933
+ }
934
+ });
935
+ if (options.write) {
936
+ writeEdits(result.edits);
937
+ }
938
+ return result;
939
+ };
940
+
941
+ // --- Report --------------------------------------------------------------------------
942
+
943
+ const summary = (result, write) => {
944
+ const { awaitingBuild, notes, paired, pins, unlinked } = result;
945
+ const files = new Set(pins.map((pin) => pin.file)).size;
946
+ const parts = [
947
+ `${paired} heading(s) paired`,
948
+ `${pins.length} heading line(s) ${write ? "pinned" : "need a pin"} in ${files} file(s)`,
949
+ ];
950
+ if (awaitingBuild > 0) {
951
+ parts.push(`${awaitingBuild} already pinned, waiting for a rebuild`);
952
+ }
953
+ if (unlinked > 0) {
954
+ parts.push(
955
+ `${unlinked} changed id(s) left alone because nothing links to them`
956
+ );
957
+ }
958
+ parts.push(`${notes.length} note(s)`);
959
+ return parts.join(" · ");
960
+ };
961
+
962
+ const report = (result, write) => {
963
+ const out = result.pins.map((pin) => {
964
+ const replaced = pin.replaces ? ` replaces #${pin.replaces}` : "";
965
+ return `${pin.route} "${pin.text}" #${pin.from} → #${pin.to} (${pin.file}:${pin.line})${replaced}`;
966
+ });
967
+ out.push(
968
+ ...result.notes.map((note) => `NOTE ${note}`),
969
+ summary(result, write)
970
+ );
971
+ if (result.pins.length > 0 && write) {
972
+ out.push(
973
+ "Rebuild and run again until it reports 0: a pin can change the -1 suffix of a later heading with the same text."
974
+ );
975
+ } else if (result.pins.length > 0) {
976
+ out.push("Dry run: pass --write to apply.");
977
+ }
978
+ return out.join("\n");
979
+ };
980
+
981
+ /**
982
+ * A warning when nothing paired for a reason other than an old site with no
983
+ * section headings: no page found on both sides, usually the wrong --dist or
984
+ * --old, or headings that never matched.
985
+ */
986
+ const noPairsWarning = (result) => {
987
+ if (result.paired > 0 || (result.compared > 0 && result.oldSections === 0)) {
988
+ return;
989
+ }
990
+ const built = shown(result.cwd, result.dist) || ".";
991
+ const where = `the build in ${built} and the old site at ${result.options.old}`;
992
+ const what =
993
+ result.compared === 0
994
+ ? `no page was found in both ${where}`
995
+ : `the old pages have ${result.oldSections} section heading(s), but none matched a heading in ${where}`;
996
+ return [
997
+ `WARNING: 0 headings paired: ${what}.`,
998
+ "A server build keeps its pages in dist/client (read when dist has no index.html of its own); pass another folder with --dist. Check that --old points at the old site's pages, too.",
999
+ ].join("\n");
1000
+ };
1001
+
1002
+ const jsonReport = (result, write) =>
1003
+ JSON.stringify(
1004
+ {
1005
+ awaitingBuild: result.awaitingBuild,
1006
+ notes: result.notes,
1007
+ paired: result.paired,
1008
+ pins: result.pins,
1009
+ unlinked: result.unlinked,
1010
+ write,
1011
+ },
1012
+ null,
1013
+ 2
1014
+ );
1015
+
1016
+ const main = async () => {
1017
+ let options = {};
1018
+ try {
1019
+ options = parseArgs(process.argv.slice(2));
1020
+ } catch (error) {
1021
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`);
1022
+ process.exit(2);
1023
+ }
1024
+ if (options.help || options.h || process.argv.length <= 2) {
1025
+ process.stdout.write(`${USAGE}\n`);
1026
+ return;
1027
+ }
1028
+ if (!options.old) {
1029
+ process.stderr.write(`--old is required\n\n${USAGE}\n`);
1030
+ process.exit(2);
1031
+ }
1032
+ try {
1033
+ const result = await run(options);
1034
+ const write = Boolean(options.write);
1035
+ const output = options.json
1036
+ ? jsonReport(result, write)
1037
+ : report(result, write);
1038
+ process.stdout.write(`${output}\n`);
1039
+ const warning = noPairsWarning(result);
1040
+ if (warning) {
1041
+ process.stderr.write(`${warning}\n`);
1042
+ }
1043
+ } catch (error) {
1044
+ process.stderr.write(`${error.message}\n`);
1045
+ process.exit(1);
1046
+ }
1047
+ };
1048
+
1049
+ await main();