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