blume 2.1.3 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +134 -0
- package/README.md +4 -4
- package/dist/cli/{chunk-y4msrwm9.js → chunk-080mfch6.js} +15 -15
- package/dist/cli/{chunk-gfc5fqvf.js → chunk-1ch3ct3r.js} +18 -18
- package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-1ch3ct3r.js.map} +1 -1
- package/dist/cli/{chunk-32khwcb6.js → chunk-2ssh60qh.js} +42 -69
- package/dist/cli/{chunk-32khwcb6.js.map → chunk-2ssh60qh.js.map} +9 -10
- package/dist/cli/{chunk-9qzgg6mj.js → chunk-34wap3ed.js} +3 -3
- package/dist/cli/{chunk-k3gr1nm9.js → chunk-40vk8kgf.js} +13 -16
- package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-40vk8kgf.js.map} +3 -3
- package/dist/cli/chunk-487b95qy.js +329 -0
- package/dist/cli/chunk-487b95qy.js.map +13 -0
- package/dist/cli/chunk-4zxh25hc.js +157 -0
- package/dist/cli/chunk-4zxh25hc.js.map +10 -0
- package/dist/cli/chunk-69rjf786.js +146 -0
- package/dist/cli/chunk-69rjf786.js.map +11 -0
- package/dist/cli/{chunk-dqhg54mr.js → chunk-9h8mh2tk.js} +3 -3
- package/dist/cli/{chunk-dqhg54mr.js.map → chunk-9h8mh2tk.js.map} +2 -2
- package/dist/cli/{chunk-bqxaqn5p.js → chunk-9jqmq5hv.js} +8 -8
- package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-9jqmq5hv.js.map} +1 -1
- package/dist/cli/{chunk-dv2z0rzf.js → chunk-aafhwx74.js} +38 -13
- package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-aafhwx74.js.map} +5 -4
- package/dist/cli/{chunk-desvm72z.js → chunk-b680sv30.js} +22 -26
- package/dist/cli/chunk-b680sv30.js.map +13 -0
- package/dist/cli/{chunk-0w6hs479.js → chunk-beb0z983.js} +30 -18
- package/dist/cli/chunk-beb0z983.js.map +10 -0
- package/dist/cli/{chunk-1f882900.js → chunk-c5bjxq8v.js} +2 -2
- package/dist/cli/chunk-e1n41w9m.js +155 -0
- package/dist/cli/chunk-e1n41w9m.js.map +11 -0
- package/dist/cli/{chunk-sc93apfs.js → chunk-etckvbrh.js} +17 -17
- package/dist/cli/{chunk-sc93apfs.js.map → chunk-etckvbrh.js.map} +2 -2
- package/dist/cli/{chunk-bxwhtvw8.js → chunk-f260jdrn.js} +5 -5
- package/dist/cli/{chunk-74wfhv79.js → chunk-fnxgtjeq.js} +1 -1
- package/dist/cli/{chunk-ms9z66nz.js → chunk-gwy55cx3.js} +2 -2
- package/dist/cli/{chunk-ms9z66nz.js.map → chunk-gwy55cx3.js.map} +2 -2
- package/dist/cli/{chunk-dhwtqnnd.js → chunk-gxhcf2hs.js} +2 -2
- package/dist/cli/{chunk-tfycddbt.js → chunk-h0qwdbem.js} +2 -2
- package/dist/cli/{chunk-nbyvgnnm.js → chunk-hktq4j5r.js} +109 -49
- package/dist/cli/chunk-hktq4j5r.js.map +36 -0
- package/dist/cli/{chunk-twy7psdz.js → chunk-hn3g2b89.js} +14 -10
- package/dist/cli/chunk-hn3g2b89.js.map +10 -0
- package/dist/cli/{chunk-j1x92mx9.js → chunk-j4nfgn2j.js} +1 -1
- package/dist/cli/{chunk-dpy8qdjf.js → chunk-k45sq16q.js} +74 -183
- package/dist/cli/chunk-k45sq16q.js.map +18 -0
- package/dist/cli/{chunk-5cmts6dv.js → chunk-kmswjxj9.js} +7 -7
- package/dist/cli/{chunk-5cmts6dv.js.map → chunk-kmswjxj9.js.map} +1 -1
- package/dist/cli/{chunk-36bxs9c7.js → chunk-nqna8dzp.js} +27 -23
- package/dist/cli/chunk-nqna8dzp.js.map +10 -0
- package/dist/cli/{chunk-a57yh0h9.js → chunk-p834rkcn.js} +7 -7
- package/dist/cli/{chunk-a57yh0h9.js.map → chunk-p834rkcn.js.map} +1 -1
- package/dist/cli/{chunk-60hz265r.js → chunk-ppa6mg4d.js} +24 -11
- package/dist/cli/chunk-ppa6mg4d.js.map +10 -0
- package/dist/cli/{chunk-s0bdnc7v.js → chunk-q1wwrxzw.js} +5 -4
- package/dist/cli/chunk-q1wwrxzw.js.map +10 -0
- package/dist/cli/{chunk-ave1wcbr.js → chunk-qcyxkj44.js} +1 -1
- package/dist/cli/{chunk-124fvmzq.js → chunk-r5nm9w0c.js} +11183 -8806
- package/dist/cli/{chunk-124fvmzq.js.map → chunk-r5nm9w0c.js.map} +82 -66
- package/dist/cli/{chunk-ckrcm08h.js → chunk-rtvgmg2j.js} +48 -62
- package/dist/cli/{chunk-ckrcm08h.js.map → chunk-rtvgmg2j.js.map} +4 -5
- package/dist/cli/{chunk-297ajdcv.js → chunk-t2h8pwy1.js} +6 -6
- package/dist/cli/{chunk-297ajdcv.js.map → chunk-t2h8pwy1.js.map} +3 -3
- package/dist/cli/{chunk-gxtg6ed2.js → chunk-x60e15st.js} +21 -21
- package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-x60e15st.js.map} +3 -3
- package/dist/cli/{chunk-58bt3hag.js → chunk-x7bh6d9j.js} +4 -4
- package/dist/cli/{chunk-hvcxng5f.js → chunk-ydqzkhjn.js} +6 -6
- package/dist/cli/{chunk-kdpzw38a.js → chunk-yg8ckc3g.js} +95 -7
- package/dist/cli/chunk-yg8ckc3g.js.map +11 -0
- package/dist/cli/{chunk-0pmb4tr4.js → chunk-zxd5jrgx.js} +1205 -222
- package/dist/cli/chunk-zxd5jrgx.js.map +44 -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 +38 -6
- package/dist/types/core/content-assets.d.ts +16 -18
- package/dist/types/core/data.d.ts +6 -2
- 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/mcp-clients.d.ts +47 -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 +16 -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 +322 -11
- 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/ratelimit/index.d.ts +2 -0
- package/dist/types/ratelimit/memory.d.ts +1 -1
- package/dist/types/ratelimit/schema.d.ts +40 -0
- package/dist/types/ratelimit/unkey.d.ts +48 -0
- 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 +24 -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 +7 -6
- package/docs/configuration/rate-limiting.mdx +20 -1
- package/docs/configuration/theming.mdx +5 -1
- package/docs/content/components.mdx +26 -6
- package/docs/content/frontmatter.mdx +1 -1
- package/docs/content/i18n.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/mcp.mdx +40 -8
- 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 +51 -29
- 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 +5 -5
- 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 +5 -5
- 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 +47 -11
- 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 +93 -189
- 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/required-secrets.ts +0 -1
- 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/PageActions.astro +81 -23
- package/src/components/layout/RootLayout.astro +35 -32
- package/src/components/layout/SiteFooter.astro +31 -8
- 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 +39 -5
- package/src/core/config.ts +46 -1
- package/src/core/content-assets.ts +64 -144
- package/src/core/data.ts +10 -3
- 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/mcp-clients.ts +87 -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 +41 -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 +110 -6
- 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/ratelimit/index.ts +2 -0
- package/src/ratelimit/memory.ts +1 -1
- package/src/ratelimit/runtime.ts +81 -0
- package/src/ratelimit/schema.ts +2 -0
- package/src/ratelimit/unkey.ts +61 -0
- package/src/registry/eject.ts +0 -1
- package/src/registry/registry.ts +1 -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-y4msrwm9.js.map → chunk-080mfch6.js.map} +0 -0
- /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-34wap3ed.js.map} +0 -0
- /package/dist/cli/{chunk-1f882900.js.map → chunk-c5bjxq8v.js.map} +0 -0
- /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-f260jdrn.js.map} +0 -0
- /package/dist/cli/{chunk-74wfhv79.js.map → chunk-fnxgtjeq.js.map} +0 -0
- /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-gxhcf2hs.js.map} +0 -0
- /package/dist/cli/{chunk-tfycddbt.js.map → chunk-h0qwdbem.js.map} +0 -0
- /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-j4nfgn2j.js.map} +0 -0
- /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-qcyxkj44.js.map} +0 -0
- /package/dist/cli/{chunk-58bt3hag.js.map → chunk-x7bh6d9j.js.map} +0 -0
- /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-ydqzkhjn.js.map} +0 -0
|
@@ -0,0 +1,4139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// readme-codemod.mjs: the mechanical part of a ReadMe → Blume migration, for a
|
|
3
|
+
// repository in ReadMe's bi-directional Git sync layout (`docs/`, `reference/`,
|
|
4
|
+
// `recipes/`, `custom_pages/`, `custom_blocks/`, `_order.yaml` files, and the
|
|
5
|
+
// changelog from the `main` branch). Two passes:
|
|
6
|
+
//
|
|
7
|
+
// 1. Convert (default). Moves the content into the layout the ReadMe
|
|
8
|
+
// reference describes, rewrites every page, and writes what the later
|
|
9
|
+
// steps need:
|
|
10
|
+
// - `custom_pages/` → `page/`, `changelogs/` → `changelog/`, category
|
|
11
|
+
// folders → `(Category)` group folders, `.md` → `.mdx`;
|
|
12
|
+
// - frontmatter mapped to Blume's schema, with a `slug` pin wherever
|
|
13
|
+
// Blume's route would differ from ReadMe's flat URL;
|
|
14
|
+
// - callouts (emoji blockquotes, `<Callout>`, `[block:callout]`), links
|
|
15
|
+
// (`doc:`, `ref:`, `page:`, `changelog:`, `blog:`, and absolute links
|
|
16
|
+
// to `--site`), `<Image>`, `<Embed>`, `<Table>`, `<Cards>`,
|
|
17
|
+
// `<Columns>`, `<Accordion>`, `<HTMLBlock>`, `<Anchor>`, `<Recipe>`,
|
|
18
|
+
// `<Glossary>` (with `--glossary`), variables, reusable content, the
|
|
19
|
+
// other magic blocks, Font Awesome icons, emoji shortcodes, code-fence
|
|
20
|
+
// titles and languages, adjacent fences → `<CodeGroup>`, and body
|
|
21
|
+
// headings;
|
|
22
|
+
// - `_order.yaml` → `meta.ts`;
|
|
23
|
+
// - endpoint pages, frontmatter-only tag pages, and empty parent pages
|
|
24
|
+
// deleted, and recorded in `readme-migration.json`;
|
|
25
|
+
// - `reference/overlays/<spec>.overlay.json`, carrying endpoint-page prose
|
|
26
|
+
// and the ReadMe callouts in the specs' own descriptions (JSON specs);
|
|
27
|
+
// - `readme-redirects.json`, with the redirects it can already compute.
|
|
28
|
+
// 2. Routes (`--routes <file>`), after `blume build` and
|
|
29
|
+
// `operation-routes.mjs`: redirects every old endpoint and tag-page URL
|
|
30
|
+
// to its generated operation page, rewrites links to them, and writes the
|
|
31
|
+
// tag-level `meta.ts` files that restore ReadMe's endpoint order.
|
|
32
|
+
//
|
|
33
|
+
// It reports what it changed and what it left for a person: custom
|
|
34
|
+
// components, inline components, `<style>`/`<script>`, event handlers,
|
|
35
|
+
// unknown components, links to pages that don't exist, and so on.
|
|
36
|
+
//
|
|
37
|
+
// Zero dependencies, deterministic (sorted traversal, no clock or randomness),
|
|
38
|
+
// idempotent (a second run changes nothing), and a dry run by default.
|
|
39
|
+
//
|
|
40
|
+
// Usage:
|
|
41
|
+
// node readme-codemod.mjs [options] <project-root>
|
|
42
|
+
// --write apply the changes (default: report only)
|
|
43
|
+
// --json print the report as JSON
|
|
44
|
+
// --site <origin> the hub's origin; absolute links to it become
|
|
45
|
+
// root-relative (repeatable)
|
|
46
|
+
// --glossary <file> glossary terms: JSON ({ "Term": "Definition" }
|
|
47
|
+
// or [{ "term", "definition" }]), or a saved page
|
|
48
|
+
// of the hub, whose settings carry glossaryTerms
|
|
49
|
+
// node readme-codemod.mjs --routes <routes.json> [--spec <route>=<file>]... [--write] <project-root>
|
|
50
|
+
// <routes.json> is operation-routes.mjs output; pass it the same --spec
|
|
51
|
+
// pairs so operationIds resolve.
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
existsSync,
|
|
55
|
+
mkdirSync,
|
|
56
|
+
readdirSync,
|
|
57
|
+
readFileSync,
|
|
58
|
+
rmdirSync,
|
|
59
|
+
rmSync,
|
|
60
|
+
statSync,
|
|
61
|
+
writeFileSync,
|
|
62
|
+
} from "node:fs";
|
|
63
|
+
import path from "node:path";
|
|
64
|
+
|
|
65
|
+
// --- Data ---------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
// Font Awesome names (style prefixes and `fa-` stripped) → Lucide. `null`
|
|
68
|
+
// means no Lucide counterpart: the icon is dropped and reported.
|
|
69
|
+
const FONT_AWESOME = {
|
|
70
|
+
"arrow-left": "arrow-left",
|
|
71
|
+
"arrow-right": "arrow-right",
|
|
72
|
+
"arrow-right-from-bracket": "log-out",
|
|
73
|
+
"arrow-up-right-from-square": "external-link",
|
|
74
|
+
"arrows-rotate": "refresh-cw",
|
|
75
|
+
"bag-shopping": "shopping-bag",
|
|
76
|
+
ban: "ban",
|
|
77
|
+
bell: "bell",
|
|
78
|
+
bolt: "zap",
|
|
79
|
+
book: "book",
|
|
80
|
+
"book-open": "book-open",
|
|
81
|
+
bookmark: "bookmark",
|
|
82
|
+
box: "box",
|
|
83
|
+
boxes: "boxes",
|
|
84
|
+
brain: "brain",
|
|
85
|
+
briefcase: "briefcase",
|
|
86
|
+
brush: "brush",
|
|
87
|
+
bug: "bug",
|
|
88
|
+
building: "building",
|
|
89
|
+
"building-columns": "landmark",
|
|
90
|
+
bullhorn: "megaphone",
|
|
91
|
+
calculator: "calculator",
|
|
92
|
+
calendar: "calendar",
|
|
93
|
+
"cart-shopping": "shopping-cart",
|
|
94
|
+
"chart-bar": "chart-column",
|
|
95
|
+
"chart-column": "chart-column",
|
|
96
|
+
"chart-line": "chart-line",
|
|
97
|
+
"chart-pie": "chart-pie",
|
|
98
|
+
"chart-simple": "chart-column",
|
|
99
|
+
check: "check",
|
|
100
|
+
"check-circle": "circle-check",
|
|
101
|
+
"circle-check": "circle-check",
|
|
102
|
+
"circle-exclamation": "circle-alert",
|
|
103
|
+
"circle-info": "info",
|
|
104
|
+
"circle-play": "circle-play",
|
|
105
|
+
"circle-question": "circle-help",
|
|
106
|
+
"circle-xmark": "circle-x",
|
|
107
|
+
clipboard: "clipboard",
|
|
108
|
+
clock: "clock",
|
|
109
|
+
cloud: "cloud",
|
|
110
|
+
code: "code",
|
|
111
|
+
"code-branch": "git-branch",
|
|
112
|
+
"code-simple": "code",
|
|
113
|
+
cog: "settings",
|
|
114
|
+
comment: "message-square",
|
|
115
|
+
"comment-question": "message-circle-question",
|
|
116
|
+
comments: "messages-square",
|
|
117
|
+
compass: "compass",
|
|
118
|
+
computer: "computer",
|
|
119
|
+
copy: "copy",
|
|
120
|
+
"credit-card": "credit-card",
|
|
121
|
+
cube: "box",
|
|
122
|
+
cubes: "boxes",
|
|
123
|
+
database: "database",
|
|
124
|
+
desktop: "monitor",
|
|
125
|
+
"diagram-project": "workflow",
|
|
126
|
+
display: "monitor",
|
|
127
|
+
"dollar-sign": "dollar-sign",
|
|
128
|
+
download: "download",
|
|
129
|
+
envelope: "mail",
|
|
130
|
+
"exchange-alt": "arrow-left-right",
|
|
131
|
+
"exclamation-circle": "circle-alert",
|
|
132
|
+
"exclamation-triangle": "triangle-alert",
|
|
133
|
+
"external-link": "external-link",
|
|
134
|
+
eye: "eye",
|
|
135
|
+
"eye-slash": "eye-off",
|
|
136
|
+
file: "file",
|
|
137
|
+
"file-chart-column": "file-chart-column",
|
|
138
|
+
"file-lines": "file-text",
|
|
139
|
+
"file-signature": "signature",
|
|
140
|
+
filter: "filter",
|
|
141
|
+
filters: "funnel",
|
|
142
|
+
fingerprint: "fingerprint",
|
|
143
|
+
flag: "flag",
|
|
144
|
+
flask: "flask-conical",
|
|
145
|
+
folder: "folder",
|
|
146
|
+
"folder-open": "folder-open",
|
|
147
|
+
gauge: "gauge",
|
|
148
|
+
"gauge-high": "gauge",
|
|
149
|
+
gavel: "gavel",
|
|
150
|
+
gear: "settings",
|
|
151
|
+
gears: "settings",
|
|
152
|
+
gift: "gift",
|
|
153
|
+
github: "github",
|
|
154
|
+
globe: "globe",
|
|
155
|
+
"graduation-cap": "graduation-cap",
|
|
156
|
+
"hand-wave": "hand",
|
|
157
|
+
handshake: "handshake",
|
|
158
|
+
heart: "heart",
|
|
159
|
+
"help-circle": "circle-help",
|
|
160
|
+
home: "house",
|
|
161
|
+
hourglass: "hourglass",
|
|
162
|
+
"hourglass-half": "hourglass",
|
|
163
|
+
house: "house",
|
|
164
|
+
"id-card": "id-card",
|
|
165
|
+
image: "image",
|
|
166
|
+
inbox: "inbox",
|
|
167
|
+
info: "info",
|
|
168
|
+
"info-circle": "info",
|
|
169
|
+
key: "key",
|
|
170
|
+
laptop: "laptop",
|
|
171
|
+
"laptop-code": "laptop",
|
|
172
|
+
"layer-group": "layers",
|
|
173
|
+
layers: "layers",
|
|
174
|
+
"life-ring": "life-buoy",
|
|
175
|
+
lightbulb: "lightbulb",
|
|
176
|
+
link: "link",
|
|
177
|
+
list: "list",
|
|
178
|
+
"location-dot": "map-pin",
|
|
179
|
+
lock: "lock",
|
|
180
|
+
magic: "sparkles",
|
|
181
|
+
"magnifying-glass": "search",
|
|
182
|
+
"magnifying-glass-chart": "search",
|
|
183
|
+
map: "map",
|
|
184
|
+
"map-marker": "map-pin",
|
|
185
|
+
microchip: "cpu",
|
|
186
|
+
minus: "minus",
|
|
187
|
+
mobile: "smartphone",
|
|
188
|
+
"money-bill": "banknote",
|
|
189
|
+
"money-bill-wave": "banknote",
|
|
190
|
+
newspaper: "newspaper",
|
|
191
|
+
"paint-brush": "paintbrush",
|
|
192
|
+
palette: "palette",
|
|
193
|
+
"paper-plane": "send",
|
|
194
|
+
pause: "pause",
|
|
195
|
+
pen: "pencil",
|
|
196
|
+
"pen-to-square": "square-pen",
|
|
197
|
+
pencil: "pencil",
|
|
198
|
+
percent: "percent",
|
|
199
|
+
"person-snowboarding": null,
|
|
200
|
+
phone: "phone",
|
|
201
|
+
play: "play",
|
|
202
|
+
plug: "plug",
|
|
203
|
+
plus: "plus",
|
|
204
|
+
"plus-circle": "circle-plus",
|
|
205
|
+
"puzzle-piece": "puzzle",
|
|
206
|
+
question: "circle-help",
|
|
207
|
+
"question-circle": "circle-help",
|
|
208
|
+
receipt: "receipt",
|
|
209
|
+
"right-to-bracket": "log-in",
|
|
210
|
+
robot: "bot",
|
|
211
|
+
rocket: "rocket",
|
|
212
|
+
"rocket-launch": "rocket",
|
|
213
|
+
rotate: "refresh-cw",
|
|
214
|
+
rss: "rss",
|
|
215
|
+
"rss-square": "rss",
|
|
216
|
+
"satellite-dish": "satellite-dish",
|
|
217
|
+
"scale-balanced": "scale",
|
|
218
|
+
"screwdriver-wrench": "wrench",
|
|
219
|
+
search: "search",
|
|
220
|
+
server: "server",
|
|
221
|
+
shield: "shield",
|
|
222
|
+
"shield-alt": "shield",
|
|
223
|
+
"shield-halved": "shield",
|
|
224
|
+
signal: "signal",
|
|
225
|
+
sitemap: "network",
|
|
226
|
+
slack: "slack",
|
|
227
|
+
smile: "smile",
|
|
228
|
+
sparkles: "sparkles",
|
|
229
|
+
"square-rss": "rss",
|
|
230
|
+
star: "star",
|
|
231
|
+
store: "store",
|
|
232
|
+
sync: "refresh-cw",
|
|
233
|
+
table: "table",
|
|
234
|
+
tag: "tag",
|
|
235
|
+
tags: "tags",
|
|
236
|
+
terminal: "terminal",
|
|
237
|
+
"thumbs-down": "thumbs-down",
|
|
238
|
+
"thumbs-up": "thumbs-up",
|
|
239
|
+
times: "x",
|
|
240
|
+
"times-circle": "circle-x",
|
|
241
|
+
toolbox: "wrench",
|
|
242
|
+
trash: "trash",
|
|
243
|
+
"trash-alt": "trash-2",
|
|
244
|
+
"trash-can": "trash-2",
|
|
245
|
+
"triangle-exclamation": "triangle-alert",
|
|
246
|
+
truck: "truck",
|
|
247
|
+
unlock: "lock-open",
|
|
248
|
+
upload: "upload",
|
|
249
|
+
user: "user",
|
|
250
|
+
"user-check": "user-check",
|
|
251
|
+
users: "users",
|
|
252
|
+
video: "video",
|
|
253
|
+
wallet: "wallet",
|
|
254
|
+
"wand-magic-sparkles": "sparkles",
|
|
255
|
+
webhook: "webhook",
|
|
256
|
+
wifi: "wifi",
|
|
257
|
+
wrench: "wrench",
|
|
258
|
+
"x-twitter": null,
|
|
259
|
+
xmark: "x",
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Style prefixes in a Font Awesome class string (`fad fa-hand-wave`,
|
|
263
|
+
// `fa-duotone fa-solid fa-rocket-launch`).
|
|
264
|
+
const FA_STYLE =
|
|
265
|
+
/^(?:fa|fas|far|fad|fal|fat|fab|fast|fasl|fasr|fass|fa-solid|fa-regular|fa-light|fa-thin|fa-duotone|fa-brands|fa-sharp|fa-sharp-duotone|fa-fw|fa-lg|fa-xl|fa-2x)$/u;
|
|
266
|
+
|
|
267
|
+
// GitHub-style emoji shortcodes (the gemoji names ReadMe renders), a common
|
|
268
|
+
// subset as "name emoji" pairs; anything else is reported.
|
|
269
|
+
const SHORTCODE_PAIRS = `100 💯 1234 🔢 +1 👍 -1 👎 1st_place_medal 🥇 abc 🔤 airplane ✈️
|
|
270
|
+
alarm_clock ⏰ anchor ⚓ arrow_down ⬇️ arrow_forward ▶️ arrow_left ⬅️
|
|
271
|
+
arrow_lower_right ↘️ arrow_right ➡️ arrow_up ⬆️ arrow_upper_right ↗️
|
|
272
|
+
arrows_counterclockwise 🔄 art 🎨 balloon 🎈 ballot_box_with_check ☑️
|
|
273
|
+
bangbang ‼️ bank 🏦 bar_chart 📊 battery 🔋 bee 🐝 beer 🍺 bell 🔔 bike 🚲
|
|
274
|
+
birthday 🎂 black_circle ⚫ blue_heart 💙 blush 😊 bomb 💣 book 📖
|
|
275
|
+
bookmark 🔖 bookmark_tabs 📑 books 📚 boom 💥 brain 🧠 briefcase 💼
|
|
276
|
+
broken_heart 💔 bug 🐛 building_construction 🏗️ bulb 💡 cake 🍰
|
|
277
|
+
calendar 📆 calling 📲 camera 📷 car 🚗 card_index 📇 cat 🐱 chains ⛓️
|
|
278
|
+
chart 💹 chart_with_downwards_trend 📉 chart_with_upwards_trend 📈
|
|
279
|
+
checkered_flag 🏁 christmas_tree 🎄 clap 👏 clipboard 📋 clock1 🕐
|
|
280
|
+
closed_book 📕 closed_lock_with_key 🔐 cloud ☁️ coffee ☕ compass 🧭
|
|
281
|
+
computer 💻 confetti_ball 🎊 construction 🚧 convenience_store 🏪 cool 🆒
|
|
282
|
+
credit_card 💳 crown 👑 cry 😢 dart 🎯 date 📅 department_store 🏬
|
|
283
|
+
desktop_computer 🖥️ dna 🧬 dog 🐶 dollar 💵 e-mail 📧 earth_africa 🌍
|
|
284
|
+
earth_americas 🌎 earth_asia 🌏 electric_plug 🔌 email 📧 envelope ✉️
|
|
285
|
+
envelope_with_arrow 📩 exclamation ❗ eyes 👀 factory 🏭 fallen_leaf 🍂
|
|
286
|
+
file_folder 📁 fire 🔥 fireworks 🎆 flushed 😳 free 🆓 gear ⚙️ gem 💎
|
|
287
|
+
ghost 👻 gift 🎁 gift_heart 💝 globe_with_meridians 🌐 green_circle 🟢
|
|
288
|
+
green_heart 💚 grey_exclamation ❕ grey_question ❔ grin 😁 grinning 😀
|
|
289
|
+
hammer 🔨 hammer_and_pick ⚒️ hammer_and_wrench 🛠️ hand ✋ handshake 🤝
|
|
290
|
+
hatching_chick 🐣 headphones 🎧 heart ❤️ heart_decoration 💟
|
|
291
|
+
heart_eyes 😍 heavy_check_mark ✔️ heavy_division_sign ➗
|
|
292
|
+
heavy_dollar_sign 💲 heavy_exclamation_mark ❗ heavy_minus_sign ➖
|
|
293
|
+
heavy_multiplication_x ✖️ heavy_plus_sign ➕ hospital 🏥 hourglass ⌛
|
|
294
|
+
hourglass_flowing_sand ⏳ house 🏠 hugs 🤗 inbox_tray 📥
|
|
295
|
+
incoming_envelope 📨 information_desk_person 💁 information_source ℹ️
|
|
296
|
+
interrobang ⁉️ iphone 📱 jack_o_lantern 🎃 joy 😂 key 🔑 keyboard ⌨️
|
|
297
|
+
label 🏷️ large_blue_circle 🔵 laughing 😆 leaves 🍃 ledger 📒
|
|
298
|
+
left_speech_bubble 🗨️ link 🔗 lock 🔒 lock_with_ink_pen 🔏 loud_sound 🔊
|
|
299
|
+
loudspeaker 📢 mag 🔍 mag_right 🔎 magnet 🧲 mailbox 📫
|
|
300
|
+
mailbox_with_mail 📬 maple_leaf 🍁 mask 😷 medal_military 🎖️
|
|
301
|
+
medal_sports 🏅 mega 📣 memo 📝 microscope 🔬 money_with_wings 💸
|
|
302
|
+
moneybag 💰 mortar_board 🎓 movie_camera 🎥 muscle 💪 musical_note 🎵
|
|
303
|
+
mute 🔇 negative_squared_cross_mark ❎ nerd_face 🤓 new 🆕 no_bell 🔕
|
|
304
|
+
no_entry ⛔ no_entry_sign 🚫 notebook 📓 nut_and_bolt 🔩 office 🏢
|
|
305
|
+
ok 🆗 ok_hand 👌 old_key 🗝️ open_file_folder 📂 orange_circle 🟠
|
|
306
|
+
orange_heart 🧡 outbox_tray 📤 owl 🦉 package 📦 page_facing_up 📄
|
|
307
|
+
page_with_curl 📃 paperclip 📎 partly_sunny ⛅ partying_face 🥳
|
|
308
|
+
pencil 📝 pencil2 ✏️ penguin 🐧 performing_arts 🎭 phone ☎️ pick ⛏️
|
|
309
|
+
pizza 🍕 point_down 👇 point_left 👈 point_right 👉 point_up ☝️
|
|
310
|
+
point_up_2 👆 pray 🙏 purple_circle 🟣 purple_heart 💜 pushpin 📌
|
|
311
|
+
question ❓ rabbit 🐰 radio_button 🔘 rage 😡 rainbow 🌈 raised_hand ✋
|
|
312
|
+
raised_hands 🙌 receipt 🧾 recycle ♻️ red_circle 🔴 relaxed ☺️
|
|
313
|
+
relieved 😌 repeat 🔁 ribbon 🎀 robot 🤖 rocket 🚀 rofl 🤣
|
|
314
|
+
rotating_light 🚨 round_pushpin 📍 satellite 📡 school 🏫 scissors ✂️
|
|
315
|
+
scream 😱 scroll 📜 see_no_evil 🙈 seedling 🌱 shield 🛡️ ship 🚢
|
|
316
|
+
shushing_face 🤫 skull 💀 slightly_smiling_face 🙂 smile 😄 smiley 😃
|
|
317
|
+
smirk 😏 snowflake ❄️ sob 😭 sos 🆘 sound 🔉 sparkler 🎇 sparkles ✨
|
|
318
|
+
sparkling_heart 💖 speaker 🔈 speech_balloon 💬 spiral_calendar 🗓️
|
|
319
|
+
star ⭐ star2 🌟 star_struck 🤩 stop_sign 🛑 stopwatch ⏱️
|
|
320
|
+
straight_ruler 📏 sunglasses 😎 sunny ☀️ sunrise 🌅 sweat 😓
|
|
321
|
+
sweat_smile 😅 tada 🎉 telephone_receiver 📞 telescope 🔭 test_tube 🧪
|
|
322
|
+
thinking 🤔 thought_balloon 💭 thumbsdown 👎 thumbsup 👍 ticket 🎫
|
|
323
|
+
timer_clock ⏲️ toolbox 🧰 train 🚋 triangular_flag_on_post 🚩
|
|
324
|
+
triangular_ruler 📐 trophy 🏆 turtle 🐢 umbrella ☔ unicorn 🦄 unlock 🔓
|
|
325
|
+
up 🆙 upside_down_face 🙃 video_game 🎮 warning ⚠️ watch ⌚ wave 👋
|
|
326
|
+
white_check_mark ✅ white_circle ⚪ wink 😉 world_map 🗺️ wrench 🔧
|
|
327
|
+
writing_hand ✍️ x ❌ yellow_circle 🟡 yellow_heart 💛 zap ⚡
|
|
328
|
+
zipper_mouth_face 🤐`;
|
|
329
|
+
|
|
330
|
+
const readPairs = (text) => {
|
|
331
|
+
const tokens = text.split(/\s+/u);
|
|
332
|
+
const pairs = new Map();
|
|
333
|
+
for (let index = 0; index + 1 < tokens.length; index += 2) {
|
|
334
|
+
pairs.set(tokens[index], tokens[index + 1]);
|
|
335
|
+
}
|
|
336
|
+
return pairs;
|
|
337
|
+
};
|
|
338
|
+
const SHORTCODES = readPairs(SHORTCODE_PAIRS);
|
|
339
|
+
|
|
340
|
+
// Emoji → callout type, as ReadMe's renderer themes them (components/Callout).
|
|
341
|
+
const CALLOUT_EMOJI = [
|
|
342
|
+
// A bare ℹ (no variation selector) isn't in ReadMe's list: default theme.
|
|
343
|
+
[/^(?:📘|ℹ️)/u, "info"],
|
|
344
|
+
[/^(?:👍|✅)/u, "success"],
|
|
345
|
+
[/^(?:🚧|⚠️|⚠)/u, "warning"],
|
|
346
|
+
[/^(?:❗️|❗|🛑|‼️|⁉️)/u, "danger"],
|
|
347
|
+
];
|
|
348
|
+
const CALLOUT_THEME = {
|
|
349
|
+
default: "note",
|
|
350
|
+
error: "danger",
|
|
351
|
+
info: "info",
|
|
352
|
+
okay: "success",
|
|
353
|
+
warn: "warning",
|
|
354
|
+
};
|
|
355
|
+
const MAGIC_CALLOUT = {
|
|
356
|
+
danger: "danger",
|
|
357
|
+
error: "danger",
|
|
358
|
+
info: "info",
|
|
359
|
+
success: "success",
|
|
360
|
+
warning: "warning",
|
|
361
|
+
};
|
|
362
|
+
// A leading emoji, with its variation selectors and ZWJ sequence, a flag, or a
|
|
363
|
+
// keycap: what ReadMe's emoji-regex matches.
|
|
364
|
+
const LEADING_EMOJI =
|
|
365
|
+
/^(?:\p{Extended_Pictographic}(?:\uFE0F|\u200D\p{Extended_Pictographic}|\p{Emoji_Modifier})*\uFE0F?|\p{Regional_Indicator}{2}|[#*0-9]\uFE0F?\u20E3)/u;
|
|
366
|
+
|
|
367
|
+
// Fence languages Shiki doesn't know, and ReadMe's tab labels for untitled
|
|
368
|
+
// fences (its renderer shows the language).
|
|
369
|
+
const FENCE_LANGUAGE = {
|
|
370
|
+
cplusplus: "cpp",
|
|
371
|
+
curl: "bash",
|
|
372
|
+
node: "js",
|
|
373
|
+
objectivec: "objc",
|
|
374
|
+
};
|
|
375
|
+
const LANGUAGE_LABEL = {
|
|
376
|
+
bash: "Bash",
|
|
377
|
+
c: "C",
|
|
378
|
+
cpp: "C++",
|
|
379
|
+
csharp: "C#",
|
|
380
|
+
curl: "cURL",
|
|
381
|
+
go: "Go",
|
|
382
|
+
html: "HTML",
|
|
383
|
+
http: "HTTP",
|
|
384
|
+
java: "Java",
|
|
385
|
+
javascript: "JavaScript",
|
|
386
|
+
js: "JavaScript",
|
|
387
|
+
json: "JSON",
|
|
388
|
+
kotlin: "Kotlin",
|
|
389
|
+
node: "Node",
|
|
390
|
+
php: "PHP",
|
|
391
|
+
python: "Python",
|
|
392
|
+
ruby: "Ruby",
|
|
393
|
+
sh: "Shell",
|
|
394
|
+
shell: "Shell",
|
|
395
|
+
swift: "Swift",
|
|
396
|
+
text: "Text",
|
|
397
|
+
ts: "TypeScript",
|
|
398
|
+
typescript: "TypeScript",
|
|
399
|
+
xml: "XML",
|
|
400
|
+
yaml: "YAML",
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Components Blume renders in MDX; anything else capitalized is reported.
|
|
404
|
+
const BLUME_COMPONENTS = new Set([
|
|
405
|
+
"Accordion",
|
|
406
|
+
"AccordionItem",
|
|
407
|
+
"AutoTypeTable",
|
|
408
|
+
"Badge",
|
|
409
|
+
"Callout",
|
|
410
|
+
"Card",
|
|
411
|
+
"CardGroup",
|
|
412
|
+
"CodeBlock",
|
|
413
|
+
"CodeGroup",
|
|
414
|
+
"Color",
|
|
415
|
+
"Column",
|
|
416
|
+
"Columns",
|
|
417
|
+
"Component",
|
|
418
|
+
"Diff",
|
|
419
|
+
"Expandable",
|
|
420
|
+
"FileTree",
|
|
421
|
+
"Frame",
|
|
422
|
+
"GithubInfo",
|
|
423
|
+
"Icon",
|
|
424
|
+
"Math",
|
|
425
|
+
"Panel",
|
|
426
|
+
"ParamField",
|
|
427
|
+
"Prompt",
|
|
428
|
+
"RequestExample",
|
|
429
|
+
"ResponseExample",
|
|
430
|
+
"ResponseField",
|
|
431
|
+
"Step",
|
|
432
|
+
"Steps",
|
|
433
|
+
"Tab",
|
|
434
|
+
"Tabs",
|
|
435
|
+
"Tile",
|
|
436
|
+
"Tooltip",
|
|
437
|
+
"Tree",
|
|
438
|
+
"TypeTable",
|
|
439
|
+
"View",
|
|
440
|
+
"Visibility",
|
|
441
|
+
"YouTube",
|
|
442
|
+
]);
|
|
443
|
+
|
|
444
|
+
// JSX attributes: quoted strings, or `{…}` with one level of nested braces.
|
|
445
|
+
const ATTRS = String.raw`(?:[^>"'{}]|"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\})*?`;
|
|
446
|
+
const VOID_TAGS = new RegExp(
|
|
447
|
+
String.raw`<(?<tag>br|hr|img|input|source|wbr|col|area|embed|track|param)\b(?<rest>${ATTRS})\s*(?<!\/)>`,
|
|
448
|
+
"gu"
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const SECTIONS = {
|
|
452
|
+
changelog: "changelog",
|
|
453
|
+
changelogs: "changelog",
|
|
454
|
+
custom_pages: "page",
|
|
455
|
+
docs: "docs",
|
|
456
|
+
page: "page",
|
|
457
|
+
recipes: "recipes",
|
|
458
|
+
reference: "reference",
|
|
459
|
+
};
|
|
460
|
+
const URL_PREFIX = {
|
|
461
|
+
changelog: "/changelog/",
|
|
462
|
+
docs: "/docs/",
|
|
463
|
+
page: "/page/",
|
|
464
|
+
recipes: "/recipes/",
|
|
465
|
+
reference: "/reference/",
|
|
466
|
+
};
|
|
467
|
+
const SCHEME_PREFIX = {
|
|
468
|
+
blog: "/changelog/",
|
|
469
|
+
changelog: "/changelog/",
|
|
470
|
+
doc: "/docs/",
|
|
471
|
+
page: "/page/",
|
|
472
|
+
ref: "/reference/",
|
|
473
|
+
};
|
|
474
|
+
const NEXT_PREFIX = {
|
|
475
|
+
basic: "/docs/",
|
|
476
|
+
changelog: "/changelog/",
|
|
477
|
+
custom_page: "/page/",
|
|
478
|
+
endpoint: "/reference/",
|
|
479
|
+
};
|
|
480
|
+
const MAGIC_TYPES = new Set([
|
|
481
|
+
"api-header",
|
|
482
|
+
"callout",
|
|
483
|
+
"code",
|
|
484
|
+
"embed",
|
|
485
|
+
"html",
|
|
486
|
+
"image",
|
|
487
|
+
"parameters",
|
|
488
|
+
"recipe",
|
|
489
|
+
"table",
|
|
490
|
+
"tutorial-tile",
|
|
491
|
+
]);
|
|
492
|
+
const CHANGELOG_TYPES = new Set([
|
|
493
|
+
"added",
|
|
494
|
+
"deprecated",
|
|
495
|
+
"fixed",
|
|
496
|
+
"improved",
|
|
497
|
+
"none",
|
|
498
|
+
"removed",
|
|
499
|
+
]);
|
|
500
|
+
const NAV_KEYS = [
|
|
501
|
+
"category",
|
|
502
|
+
"categorySlug",
|
|
503
|
+
"order",
|
|
504
|
+
"parent",
|
|
505
|
+
"parentDoc",
|
|
506
|
+
"parentDocSlug",
|
|
507
|
+
"position",
|
|
508
|
+
];
|
|
509
|
+
// Keys a Blume page accepts, so a second run passes them through quietly.
|
|
510
|
+
const BLUME_KEYS = new Set([
|
|
511
|
+
"ai",
|
|
512
|
+
"api",
|
|
513
|
+
"authMethod",
|
|
514
|
+
"authors",
|
|
515
|
+
"changelog",
|
|
516
|
+
"date",
|
|
517
|
+
"deprecated",
|
|
518
|
+
"description",
|
|
519
|
+
"draft",
|
|
520
|
+
"hidden",
|
|
521
|
+
"icon",
|
|
522
|
+
"lastModified",
|
|
523
|
+
"mode",
|
|
524
|
+
"narration",
|
|
525
|
+
"noindex",
|
|
526
|
+
"pagination",
|
|
527
|
+
"playground",
|
|
528
|
+
"related",
|
|
529
|
+
"search",
|
|
530
|
+
"seo",
|
|
531
|
+
"sidebar",
|
|
532
|
+
"slug",
|
|
533
|
+
"title",
|
|
534
|
+
"type",
|
|
535
|
+
]);
|
|
536
|
+
const KEY_ORDER = [
|
|
537
|
+
"title",
|
|
538
|
+
"description",
|
|
539
|
+
"type",
|
|
540
|
+
"date",
|
|
541
|
+
"authors",
|
|
542
|
+
"changelog",
|
|
543
|
+
"slug",
|
|
544
|
+
"icon",
|
|
545
|
+
"mode",
|
|
546
|
+
"hidden",
|
|
547
|
+
"draft",
|
|
548
|
+
"deprecated",
|
|
549
|
+
"noindex",
|
|
550
|
+
"sidebar",
|
|
551
|
+
"seo",
|
|
552
|
+
"search",
|
|
553
|
+
"related",
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
const INVENTORY = "readme-migration.json";
|
|
557
|
+
const REDIRECTS = "readme-redirects.json";
|
|
558
|
+
// Private-use placeholders for code and inline code while prose is rewritten.
|
|
559
|
+
const CODE_MARK = "\uE000";
|
|
560
|
+
const SPAN_MARK = "\uE001";
|
|
561
|
+
const CODE_PLACEHOLDER = /\uE000(?<index>\d+)\uE000/gu;
|
|
562
|
+
const SPAN_PLACEHOLDER = /\uE001(?<index>\d+)\uE001/gu;
|
|
563
|
+
|
|
564
|
+
// --- Report -------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
const report = { counts: new Map(), files: new Map(), todo: [] };
|
|
567
|
+
const count = (kind, amount = 1) => {
|
|
568
|
+
report.counts.set(kind, (report.counts.get(kind) ?? 0) + amount);
|
|
569
|
+
};
|
|
570
|
+
const note = (file, message) => {
|
|
571
|
+
const list = report.files.get(file) ?? [];
|
|
572
|
+
list.push(message);
|
|
573
|
+
report.files.set(file, list);
|
|
574
|
+
};
|
|
575
|
+
const todo = (message) => {
|
|
576
|
+
report.todo.push(message);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// --- Values -------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
// Frontmatter comes from the YAML reader below and specs from JSON.parse:
|
|
582
|
+
// strings, numbers, booleans, null, arrays, and plain objects. These read the
|
|
583
|
+
// values back as the types the conversion needs.
|
|
584
|
+
const isRecord = (value) =>
|
|
585
|
+
value !== null &&
|
|
586
|
+
value !== undefined &&
|
|
587
|
+
!Array.isArray(value) &&
|
|
588
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
589
|
+
const textOf = (value) => {
|
|
590
|
+
if (
|
|
591
|
+
value === null ||
|
|
592
|
+
value === undefined ||
|
|
593
|
+
value === true ||
|
|
594
|
+
value === false ||
|
|
595
|
+
Array.isArray(value) ||
|
|
596
|
+
isRecord(value)
|
|
597
|
+
) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const text = String(value).trim();
|
|
601
|
+
return text === "" ? undefined : text;
|
|
602
|
+
};
|
|
603
|
+
const recordOf = (value) => (isRecord(value) ? value : {});
|
|
604
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
605
|
+
|
|
606
|
+
// A replace() callback that reads named groups.
|
|
607
|
+
const byGroups =
|
|
608
|
+
(read) =>
|
|
609
|
+
(...args) =>
|
|
610
|
+
read(args.at(-1), args[0]);
|
|
611
|
+
|
|
612
|
+
const sortedEntries = (map) =>
|
|
613
|
+
[...map.entries()].toSorted(([a], [b]) => a.localeCompare(b));
|
|
614
|
+
|
|
615
|
+
// --- Minimal YAML (the frontmatter subset ReadMe writes) ----------------------
|
|
616
|
+
|
|
617
|
+
class YamlError extends Error {
|
|
618
|
+
constructor(message) {
|
|
619
|
+
super(message);
|
|
620
|
+
this.name = "YamlError";
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const indentOf = (line) => line.match(/^ */u)[0].length;
|
|
625
|
+
const isSkippable = (line) => /^\s*(?:#.*)?$/u.test(line);
|
|
626
|
+
|
|
627
|
+
const ESCAPES = {
|
|
628
|
+
" ": " ",
|
|
629
|
+
'"': '"',
|
|
630
|
+
"/": "/",
|
|
631
|
+
0: "\0",
|
|
632
|
+
L: "\u2028",
|
|
633
|
+
N: "\u0085",
|
|
634
|
+
P: "\u2029",
|
|
635
|
+
"\\": "\\",
|
|
636
|
+
_: "\u00A0",
|
|
637
|
+
a: "\u0007",
|
|
638
|
+
b: "\b",
|
|
639
|
+
e: "\u001B",
|
|
640
|
+
f: "\f",
|
|
641
|
+
n: "\n",
|
|
642
|
+
r: "\r",
|
|
643
|
+
t: "\t",
|
|
644
|
+
v: "\v",
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const unescapeDouble = (body) =>
|
|
648
|
+
body.replaceAll(
|
|
649
|
+
/\\(?:x(?<x>[0-9a-fA-F]{2})|u(?<u>[0-9a-fA-F]{4})|U(?<big>[0-9a-fA-F]{8})|(?<char>.))/gu,
|
|
650
|
+
byGroups(({ big, char, u, x }) => {
|
|
651
|
+
const hex = x ?? u ?? big;
|
|
652
|
+
if (hex) {
|
|
653
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
654
|
+
}
|
|
655
|
+
if (!Object.hasOwn(ESCAPES, char)) {
|
|
656
|
+
throw new YamlError(`unknown escape \\${char}`);
|
|
657
|
+
}
|
|
658
|
+
return ESCAPES[char];
|
|
659
|
+
})
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
const splitFlow = (inner) => {
|
|
663
|
+
const items = [];
|
|
664
|
+
let current = "";
|
|
665
|
+
let quote = null;
|
|
666
|
+
for (const char of inner) {
|
|
667
|
+
if (quote) {
|
|
668
|
+
current += char;
|
|
669
|
+
if (char === quote) {
|
|
670
|
+
quote = null;
|
|
671
|
+
}
|
|
672
|
+
} else if (char === '"' || char === "'") {
|
|
673
|
+
quote = char;
|
|
674
|
+
current += char;
|
|
675
|
+
} else if (char === "[" || char === "{") {
|
|
676
|
+
throw new YamlError("nested flow collections");
|
|
677
|
+
} else if (char === ",") {
|
|
678
|
+
items.push(current.trim());
|
|
679
|
+
current = "";
|
|
680
|
+
} else {
|
|
681
|
+
current += char;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (current.trim() !== "") {
|
|
685
|
+
items.push(current.trim());
|
|
686
|
+
}
|
|
687
|
+
return items;
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const parseQuoted = (text) => {
|
|
691
|
+
if (text.startsWith('"')) {
|
|
692
|
+
const match = /^"(?<inner>(?:[^"\\]|\\.)*)"\s*(?:#.*)?$/u.exec(text);
|
|
693
|
+
if (!match) {
|
|
694
|
+
throw new YamlError("unterminated double-quoted string");
|
|
695
|
+
}
|
|
696
|
+
return unescapeDouble(match.groups.inner);
|
|
697
|
+
}
|
|
698
|
+
const match = /^'(?<inner>(?:[^']|'')*)'\s*(?:#.*)?$/u.exec(text);
|
|
699
|
+
if (!match) {
|
|
700
|
+
throw new YamlError("unterminated single-quoted string");
|
|
701
|
+
}
|
|
702
|
+
return match.groups.inner.replaceAll("''", "'");
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const parsePlain = (text) => {
|
|
706
|
+
const plain = text.replace(/\s+#.*$/u, "");
|
|
707
|
+
if (/^(?:true|True|TRUE)$/u.test(plain)) {
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
if (/^(?:false|False|FALSE)$/u.test(plain)) {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
if (
|
|
714
|
+
/^[-+]?(?:0|[1-9]\d*)$/u.test(plain) ||
|
|
715
|
+
/^[-+]?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$/u.test(plain)
|
|
716
|
+
) {
|
|
717
|
+
return Number(plain);
|
|
718
|
+
}
|
|
719
|
+
return plain;
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const parseScalar = (raw) => {
|
|
723
|
+
const text = raw.trim();
|
|
724
|
+
if (text === "" || text === "~" || /^null$/iu.test(text)) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
if (/^[&*!|>]/u.test(text)) {
|
|
728
|
+
throw new YamlError(`unsupported value ${text}`);
|
|
729
|
+
}
|
|
730
|
+
if (text.startsWith('"') || text.startsWith("'")) {
|
|
731
|
+
return parseQuoted(text);
|
|
732
|
+
}
|
|
733
|
+
if (text.startsWith("[")) {
|
|
734
|
+
if (!text.endsWith("]")) {
|
|
735
|
+
throw new YamlError("unterminated flow sequence");
|
|
736
|
+
}
|
|
737
|
+
return splitFlow(text.slice(1, -1)).map(parseScalar);
|
|
738
|
+
}
|
|
739
|
+
if (text.startsWith("{")) {
|
|
740
|
+
if (/^\{\s*\}$/u.test(text)) {
|
|
741
|
+
return {};
|
|
742
|
+
}
|
|
743
|
+
throw new YamlError("flow mappings");
|
|
744
|
+
}
|
|
745
|
+
return parsePlain(text);
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const foldLines = (content) => {
|
|
749
|
+
let value = "";
|
|
750
|
+
let previous = null;
|
|
751
|
+
for (const line of content) {
|
|
752
|
+
if (line === "") {
|
|
753
|
+
value += "\n";
|
|
754
|
+
} else if (previous === null || previous === "") {
|
|
755
|
+
value += line;
|
|
756
|
+
} else if (line.startsWith(" ") || previous.startsWith(" ")) {
|
|
757
|
+
value += `\n${line}`;
|
|
758
|
+
} else {
|
|
759
|
+
value += ` ${line}`;
|
|
760
|
+
}
|
|
761
|
+
previous = line;
|
|
762
|
+
}
|
|
763
|
+
return value;
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const chomp = (value, indicator) => {
|
|
767
|
+
const body = value.replace(/\n+$/u, "");
|
|
768
|
+
if (indicator === "-") {
|
|
769
|
+
return body;
|
|
770
|
+
}
|
|
771
|
+
if (indicator === "+") {
|
|
772
|
+
return `${body}${value.slice(body.length)}\n`;
|
|
773
|
+
}
|
|
774
|
+
return body === "" ? "" : `${body}\n`;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const parseBlockScalar = (lines, start, header, parentIndent) => {
|
|
778
|
+
const match = /^(?<style>[|>])(?<indicator>[-+]?)$/u.exec(header);
|
|
779
|
+
if (!match) {
|
|
780
|
+
throw new YamlError(`unsupported block scalar header ${header}`);
|
|
781
|
+
}
|
|
782
|
+
let end = start;
|
|
783
|
+
while (
|
|
784
|
+
end < lines.length &&
|
|
785
|
+
(lines[end].trim() === "" || indentOf(lines[end]) > parentIndent)
|
|
786
|
+
) {
|
|
787
|
+
end += 1;
|
|
788
|
+
}
|
|
789
|
+
const block = lines.slice(start, end);
|
|
790
|
+
const first = block.find((line) => line.trim() !== "");
|
|
791
|
+
const indent = first === undefined ? 0 : indentOf(first);
|
|
792
|
+
const content = block.map((line) =>
|
|
793
|
+
line.trim() === "" ? "" : line.slice(indent)
|
|
794
|
+
);
|
|
795
|
+
const value =
|
|
796
|
+
match.groups.style === "|" ? content.join("\n") : foldLines(content);
|
|
797
|
+
return [chomp(value, match.groups.indicator), end];
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
const KEY_LINE =
|
|
801
|
+
/^(?:"(?<dq>(?:[^"\\]|\\.)*)"|'(?<sq>(?:[^']|'')*)'|(?<plain>[^\s"'#\-?:,[\]{}][^#]*?|-[^\s#][^#]*?))\s*:(?:\s+(?<rest>.*))?$/u;
|
|
802
|
+
|
|
803
|
+
const nextContent = (lines, index) => {
|
|
804
|
+
let cursor = index;
|
|
805
|
+
while (cursor < lines.length && isSkippable(lines[cursor])) {
|
|
806
|
+
cursor += 1;
|
|
807
|
+
}
|
|
808
|
+
return cursor;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const isSequenceLine = (line, indent) =>
|
|
812
|
+
indentOf(line) === indent && /^-(?:\s|$)/u.test(line.slice(indent));
|
|
813
|
+
|
|
814
|
+
const keyOf = ({ dq, plain, sq }) => {
|
|
815
|
+
if (dq !== undefined) {
|
|
816
|
+
return unescapeDouble(dq);
|
|
817
|
+
}
|
|
818
|
+
if (sq !== undefined) {
|
|
819
|
+
return sq.replaceAll("''", "'");
|
|
820
|
+
}
|
|
821
|
+
return plain;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// One `key: value` line; `parseBlock` reads a nested block.
|
|
825
|
+
const parseEntry = (lines, index, indent, parseBlock) => {
|
|
826
|
+
const content = lines[index].slice(indent);
|
|
827
|
+
const match = KEY_LINE.exec(content);
|
|
828
|
+
if (!match) {
|
|
829
|
+
throw new YamlError(`can't read line ${index + 1}: ${content}`);
|
|
830
|
+
}
|
|
831
|
+
const key = keyOf(match.groups);
|
|
832
|
+
const value = (match.groups.rest ?? "").trim();
|
|
833
|
+
if (value === "" || value.startsWith("#")) {
|
|
834
|
+
const next = nextContent(lines, index + 1);
|
|
835
|
+
const nested =
|
|
836
|
+
next < lines.length &&
|
|
837
|
+
(indentOf(lines[next]) > indent || isSequenceLine(lines[next], indent));
|
|
838
|
+
return nested ? [key, ...parseBlock(next, indent)] : [key, null, index + 1];
|
|
839
|
+
}
|
|
840
|
+
if (/^[|>][-+]?(?:\s+#.*)?$/u.test(value)) {
|
|
841
|
+
const header = value.replace(/\s+#.*$/u, "");
|
|
842
|
+
return [key, ...parseBlockScalar(lines, index + 1, header, indent)];
|
|
843
|
+
}
|
|
844
|
+
const next = nextContent(lines, index + 1);
|
|
845
|
+
if (next < lines.length && indentOf(lines[next]) > indent) {
|
|
846
|
+
throw new YamlError(`multi-line value on line ${index + 1}`);
|
|
847
|
+
}
|
|
848
|
+
return [key, parseScalar(value), index + 1];
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
const parseMapping = (lines, start, indent, parseBlock) => {
|
|
852
|
+
const object = {};
|
|
853
|
+
let index = start;
|
|
854
|
+
for (;;) {
|
|
855
|
+
index = nextContent(lines, index);
|
|
856
|
+
if (index >= lines.length || indentOf(lines[index]) < indent) {
|
|
857
|
+
return [object, index];
|
|
858
|
+
}
|
|
859
|
+
if (indentOf(lines[index]) > indent) {
|
|
860
|
+
throw new YamlError(`unexpected indentation on line ${index + 1}`);
|
|
861
|
+
}
|
|
862
|
+
if (/^-(?:\s|$)/u.test(lines[index].slice(indent))) {
|
|
863
|
+
return [object, index];
|
|
864
|
+
}
|
|
865
|
+
const [key, value, next] = parseEntry(lines, index, indent, parseBlock);
|
|
866
|
+
object[key] = value;
|
|
867
|
+
index = next;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const parseSequence = (lines, start, indent, parseBlock) => {
|
|
872
|
+
const array = [];
|
|
873
|
+
let index = start;
|
|
874
|
+
for (;;) {
|
|
875
|
+
index = nextContent(lines, index);
|
|
876
|
+
if (index >= lines.length || !isSequenceLine(lines[index], indent)) {
|
|
877
|
+
return [array, index];
|
|
878
|
+
}
|
|
879
|
+
const after = lines[index].slice(indent + 1);
|
|
880
|
+
const rest = after.trimStart();
|
|
881
|
+
let value;
|
|
882
|
+
if (rest === "" || rest.startsWith("#")) {
|
|
883
|
+
[value, index] = parseBlock(index + 1, indent + 1);
|
|
884
|
+
} else if (KEY_LINE.test(rest) && !/^["'[{]/u.test(rest)) {
|
|
885
|
+
// `- key: value` starts a mapping at the key's column.
|
|
886
|
+
const itemIndent = indent + 1 + (after.length - rest.length);
|
|
887
|
+
lines[index] = `${" ".repeat(itemIndent)}${rest}`;
|
|
888
|
+
[value, index] = parseMapping(lines, index, itemIndent, parseBlock);
|
|
889
|
+
} else {
|
|
890
|
+
value = parseScalar(rest);
|
|
891
|
+
index += 1;
|
|
892
|
+
}
|
|
893
|
+
array.push(value);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const parseYaml = (text) => {
|
|
898
|
+
if (/^\t|\n\t/u.test(text)) {
|
|
899
|
+
throw new YamlError("tab indentation");
|
|
900
|
+
}
|
|
901
|
+
const lines = text.split("\n");
|
|
902
|
+
const parseBlock = (start, minIndent) => {
|
|
903
|
+
const index = nextContent(lines, start);
|
|
904
|
+
if (index >= lines.length || indentOf(lines[index]) < minIndent) {
|
|
905
|
+
return [null, index];
|
|
906
|
+
}
|
|
907
|
+
const indent = indentOf(lines[index]);
|
|
908
|
+
return isSequenceLine(lines[index], indent)
|
|
909
|
+
? parseSequence(lines, index, indent, parseBlock)
|
|
910
|
+
: parseMapping(lines, index, indent, parseBlock);
|
|
911
|
+
};
|
|
912
|
+
const [value, end] = parseBlock(0, 0);
|
|
913
|
+
if (nextContent(lines, end) < lines.length) {
|
|
914
|
+
throw new YamlError(`can't read line ${end + 1}`);
|
|
915
|
+
}
|
|
916
|
+
return value ?? {};
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const plainSafe = (text) =>
|
|
920
|
+
text !== "" &&
|
|
921
|
+
!/^\s|\s$/u.test(text) &&
|
|
922
|
+
!/^[-?:,[\]{}#&*!|>'"%@`]/u.test(text) &&
|
|
923
|
+
!/:(?:\s|$)|\s#|[\n\r\t\\]/u.test(text) &&
|
|
924
|
+
!/^(?:true|false|null|~|yes|no|on|off|y|n)$/iu.test(text) &&
|
|
925
|
+
!/^[-+]?(?:\d|\.\d)/u.test(text);
|
|
926
|
+
|
|
927
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/u;
|
|
928
|
+
|
|
929
|
+
const yamlKey = (key) =>
|
|
930
|
+
/^[A-Za-z_][\w-]*$/u.test(key) ? key : JSON.stringify(key);
|
|
931
|
+
|
|
932
|
+
const yamlScalar = (value, key) => {
|
|
933
|
+
if (value === null) {
|
|
934
|
+
return "null";
|
|
935
|
+
}
|
|
936
|
+
if (value === true || value === false || Number.isFinite(value)) {
|
|
937
|
+
return String(value);
|
|
938
|
+
}
|
|
939
|
+
const text = String(value);
|
|
940
|
+
if ((key === "date" || key === "lastModified") && DATE_ONLY.test(text)) {
|
|
941
|
+
return text;
|
|
942
|
+
}
|
|
943
|
+
return plainSafe(text) ? text : JSON.stringify(text);
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const emitYaml = (value, indent = 0) => {
|
|
947
|
+
const pad = " ".repeat(indent);
|
|
948
|
+
const lines = [];
|
|
949
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
950
|
+
if (entry === undefined) {
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (Array.isArray(entry) && entry.length === 0) {
|
|
954
|
+
lines.push(`${pad}${yamlKey(key)}: []`);
|
|
955
|
+
} else if (Array.isArray(entry)) {
|
|
956
|
+
lines.push(`${pad}${yamlKey(key)}:`);
|
|
957
|
+
for (const item of entry) {
|
|
958
|
+
if (isRecord(item)) {
|
|
959
|
+
const [first, ...more] = emitYaml(item, indent + 4).split("\n");
|
|
960
|
+
lines.push(`${pad} - ${first.trimStart()}`, ...more);
|
|
961
|
+
} else {
|
|
962
|
+
lines.push(`${pad} - ${yamlScalar(item)}`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
} else if (isRecord(entry) && Object.keys(entry).length === 0) {
|
|
966
|
+
lines.push(`${pad}${yamlKey(key)}: {}`);
|
|
967
|
+
} else if (isRecord(entry)) {
|
|
968
|
+
lines.push(`${pad}${yamlKey(key)}:`, emitYaml(entry, indent + 2));
|
|
969
|
+
} else {
|
|
970
|
+
lines.push(`${pad}${yamlKey(key)}: ${yamlScalar(entry, key)}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return lines.join("\n");
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// --- Files --------------------------------------------------------------------
|
|
977
|
+
|
|
978
|
+
const walk = (dir) => {
|
|
979
|
+
const out = [];
|
|
980
|
+
if (!existsSync(dir)) {
|
|
981
|
+
return out;
|
|
982
|
+
}
|
|
983
|
+
for (const name of readdirSync(dir).toSorted()) {
|
|
984
|
+
if (name === "node_modules" || name.startsWith(".")) {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const full = path.join(dir, name);
|
|
988
|
+
if (statSync(full).isDirectory()) {
|
|
989
|
+
out.push(...walk(full));
|
|
990
|
+
} else {
|
|
991
|
+
out.push(full);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return out;
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const splitFrontmatter = (text) => {
|
|
998
|
+
const match = /^---\n(?<raw>[\s\S]*?)\n---[ \t]*(?:\n|$)/u.exec(text);
|
|
999
|
+
return match
|
|
1000
|
+
? { body: text.slice(match[0].length), raw: match.groups.raw }
|
|
1001
|
+
: { body: text, raw: null };
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
const readOrder = (file) => {
|
|
1005
|
+
if (!existsSync(file)) {
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
const value = parseYaml(
|
|
1009
|
+
readFileSync(file, "utf-8")
|
|
1010
|
+
.replace(/^\uFEFF/u, "")
|
|
1011
|
+
.replaceAll("\r\n", "\n")
|
|
1012
|
+
);
|
|
1013
|
+
return [
|
|
1014
|
+
...new Set(
|
|
1015
|
+
listOf(value)
|
|
1016
|
+
.map((entry) => textOf(entry))
|
|
1017
|
+
.filter(Boolean)
|
|
1018
|
+
),
|
|
1019
|
+
];
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const isGroup = (segment) => /^\(.+\)$/u.test(segment);
|
|
1023
|
+
|
|
1024
|
+
const readJson = (file, fallback) =>
|
|
1025
|
+
existsSync(file) ? JSON.parse(readFileSync(file, "utf-8")) : fallback;
|
|
1026
|
+
|
|
1027
|
+
// meta.ts fields in the order people write them.
|
|
1028
|
+
const META_ORDER = ["title", "display", "order", "pages"];
|
|
1029
|
+
|
|
1030
|
+
const metaModule = (fields) => {
|
|
1031
|
+
const lines = META_ORDER.filter((key) => fields[key] !== undefined).map(
|
|
1032
|
+
(key) => ` ${key}: ${JSON.stringify(fields[key])},`
|
|
1033
|
+
);
|
|
1034
|
+
return `import { defineMeta } from "blume";\n\nexport default defineMeta({\n${lines.join("\n")}\n});\n`;
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const stripSpacers = (text) =>
|
|
1038
|
+
text
|
|
1039
|
+
.replaceAll(/^[ \t]*<br\s*\/?>[ \t]*$/gmu, "")
|
|
1040
|
+
.replaceAll(/\n{3,}/gu, "\n\n")
|
|
1041
|
+
.trim();
|
|
1042
|
+
|
|
1043
|
+
// --- Links --------------------------------------------------------------------
|
|
1044
|
+
|
|
1045
|
+
const escapeRegExp = (text) =>
|
|
1046
|
+
text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
1047
|
+
|
|
1048
|
+
const fromScheme = (url) => {
|
|
1049
|
+
const match =
|
|
1050
|
+
/^(?<kind>doc|ref|page|changelog|blog):(?<slug>[^#?\s]*)(?<rest>.*)$/u.exec(
|
|
1051
|
+
url
|
|
1052
|
+
);
|
|
1053
|
+
if (!match) {
|
|
1054
|
+
return url;
|
|
1055
|
+
}
|
|
1056
|
+
const { kind, rest, slug } = match.groups;
|
|
1057
|
+
count(`link ${kind}: rewritten`);
|
|
1058
|
+
return `${SCHEME_PREFIX[kind]}${slug.toLowerCase()}${rest}`;
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
const fromSite = (url, sites) => {
|
|
1062
|
+
for (const site of sites) {
|
|
1063
|
+
if (url === site || url.startsWith(`${site}/`)) {
|
|
1064
|
+
count("link to the hub's own origin made root-relative");
|
|
1065
|
+
return url.slice(site.length) || "/";
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return url;
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const checkTarget = (target, url, file, knownUrls) => {
|
|
1072
|
+
if (/^\/v\d[^/]*\//u.test(target)) {
|
|
1073
|
+
note(file, `link through a version prefix, check it: ${url}`);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const [, section] = target.split("/");
|
|
1077
|
+
const listed =
|
|
1078
|
+
!knownUrls ||
|
|
1079
|
+
knownUrls.has(target) ||
|
|
1080
|
+
/^\/(?:docs|reference|recipes|changelog|page)?$/u.test(target);
|
|
1081
|
+
if (!listed && ["docs", "page", "recipes", "changelog"].includes(section)) {
|
|
1082
|
+
note(
|
|
1083
|
+
file,
|
|
1084
|
+
`link to ${target}, which no file in this repo serves (dead on the hub too, or in another version): point it at the closest page, or unlink it`
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
const createLinkRewriter = (context) => (url, file) => {
|
|
1090
|
+
let next = fromSite(fromScheme(url), context.sites);
|
|
1091
|
+
if (next.startsWith("/") && !next.startsWith("//")) {
|
|
1092
|
+
// ReadMe's hash-router leftovers and trailing slashes.
|
|
1093
|
+
next = next.replace(/#\/?$/u, "").replace(/(?<=.)\/(?=[#?]|$)/u, "");
|
|
1094
|
+
checkTarget(next.split(/[#?]/u)[0], url, file, context.knownUrls);
|
|
1095
|
+
} else if (/^https?:\/\/(?!files\.|cdn\.)[^/]+\.readme\.io\b/u.test(next)) {
|
|
1096
|
+
note(file, `link to a readme.io hub, pass its origin with --site: ${url}`);
|
|
1097
|
+
}
|
|
1098
|
+
return next;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
// Rewrite link targets in Markdown links, reference definitions, and href=.
|
|
1102
|
+
const rewriteLinks = (text, file, rewrite) =>
|
|
1103
|
+
text
|
|
1104
|
+
.replaceAll(
|
|
1105
|
+
/\]\((?<url><[^>]*>|[^)\s]+)(?<title>(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)\)/gu,
|
|
1106
|
+
byGroups(({ title, url }, all) => {
|
|
1107
|
+
const bare = url.startsWith("<") ? url.slice(1, -1) : url;
|
|
1108
|
+
const next = rewrite(bare, file);
|
|
1109
|
+
return next === bare ? all : `](${next}${title})`;
|
|
1110
|
+
})
|
|
1111
|
+
)
|
|
1112
|
+
.replaceAll(
|
|
1113
|
+
/^(?<lead>\s*\[[^\]]+\]:\s+)(?<url><[^>]*>|\S+)/gmu,
|
|
1114
|
+
byGroups(({ lead, url }, all) => {
|
|
1115
|
+
const angled = url.startsWith("<");
|
|
1116
|
+
const bare = angled ? url.slice(1, -1) : url;
|
|
1117
|
+
const next = rewrite(bare, file);
|
|
1118
|
+
if (next === bare) {
|
|
1119
|
+
return all;
|
|
1120
|
+
}
|
|
1121
|
+
return angled ? `${lead}<${next}>` : `${lead}${next}`;
|
|
1122
|
+
})
|
|
1123
|
+
)
|
|
1124
|
+
.replaceAll(
|
|
1125
|
+
/\bhref=(?<quote>["'])(?<url>[^"']+)\k<quote>/gu,
|
|
1126
|
+
byGroups(({ quote, url }, all) => {
|
|
1127
|
+
const next = rewrite(url, file);
|
|
1128
|
+
return next === url ? all : `href=${quote}${next}${quote}`;
|
|
1129
|
+
})
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
// --- Icons --------------------------------------------------------------------
|
|
1133
|
+
|
|
1134
|
+
const lucideFor = (raw, file) => {
|
|
1135
|
+
const value = textOf(raw);
|
|
1136
|
+
if (value === undefined) {
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const parts = value.split(/\s+/u).filter((part) => !FA_STYLE.test(part));
|
|
1140
|
+
const [first] = parts;
|
|
1141
|
+
if (
|
|
1142
|
+
parts.length === 1 &&
|
|
1143
|
+
!first.startsWith("fa-") &&
|
|
1144
|
+
/^[a-z0-9-]+$/u.test(first)
|
|
1145
|
+
) {
|
|
1146
|
+
// Already a bare name (a second run, or a Lucide name).
|
|
1147
|
+
return first;
|
|
1148
|
+
}
|
|
1149
|
+
const name = parts.find((part) => part.startsWith("fa-"))?.slice(3);
|
|
1150
|
+
let reason;
|
|
1151
|
+
if (name === undefined) {
|
|
1152
|
+
reason = `emoji or not Font Awesome: ${value}`;
|
|
1153
|
+
} else if (!Object.hasOwn(FONT_AWESOME, name)) {
|
|
1154
|
+
reason = `no mapping for fa-${name}; pick a Lucide name`;
|
|
1155
|
+
} else if (FONT_AWESOME[name] === null) {
|
|
1156
|
+
reason = `no Lucide equivalent: fa-${name}`;
|
|
1157
|
+
}
|
|
1158
|
+
if (reason) {
|
|
1159
|
+
note(file, `icon dropped (${reason})`);
|
|
1160
|
+
count("icon dropped");
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
count("icon Font Awesome → Lucide");
|
|
1164
|
+
return FONT_AWESOME[name];
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
// --- Code and prose -----------------------------------------------------------
|
|
1168
|
+
|
|
1169
|
+
const FENCE = /^(?<indent>[ \t]*)(?<ticks>`{3,}|~{3,})(?<info>.*)$/u;
|
|
1170
|
+
|
|
1171
|
+
// A fence line's parts, or undefined. A backtick fence's info string can't
|
|
1172
|
+
// hold a backtick (CommonMark), so a line opening with ```inline``` code is a
|
|
1173
|
+
// paragraph, not a fence.
|
|
1174
|
+
const fenceOf = (line) => {
|
|
1175
|
+
const groups = FENCE.exec(line)?.groups;
|
|
1176
|
+
return groups && !(groups.ticks[0] === "`" && groups.info.includes("`"))
|
|
1177
|
+
? groups
|
|
1178
|
+
: undefined;
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const segmentBody = (body) => {
|
|
1182
|
+
const segments = [];
|
|
1183
|
+
let current = { code: false, lines: [] };
|
|
1184
|
+
let fence = null;
|
|
1185
|
+
for (const line of body.split("\n")) {
|
|
1186
|
+
const match = fenceOf(line);
|
|
1187
|
+
if (fence === null && match) {
|
|
1188
|
+
if (current.lines.length > 0) {
|
|
1189
|
+
segments.push(current);
|
|
1190
|
+
}
|
|
1191
|
+
current = { code: true, lines: [line] };
|
|
1192
|
+
fence = match.ticks;
|
|
1193
|
+
} else if (fence === null) {
|
|
1194
|
+
current.lines.push(line);
|
|
1195
|
+
} else {
|
|
1196
|
+
current.lines.push(line);
|
|
1197
|
+
const closes =
|
|
1198
|
+
match &&
|
|
1199
|
+
match.ticks[0] === fence[0] &&
|
|
1200
|
+
match.ticks.length >= fence.length &&
|
|
1201
|
+
match.info.trim() === "";
|
|
1202
|
+
if (closes) {
|
|
1203
|
+
segments.push(current);
|
|
1204
|
+
current = { code: false, lines: [] };
|
|
1205
|
+
fence = null;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (current.lines.length > 0) {
|
|
1210
|
+
segments.push(current);
|
|
1211
|
+
}
|
|
1212
|
+
return segments;
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const stashSpans = (text, spans) =>
|
|
1216
|
+
text.replaceAll(
|
|
1217
|
+
/(?<!`)(?<ticks>`+)(?!`)[\s\S]*?[^`]\k<ticks>(?!`)/gu,
|
|
1218
|
+
(span) =>
|
|
1219
|
+
span.includes("\n\n")
|
|
1220
|
+
? span
|
|
1221
|
+
: `${SPAN_MARK}${spans.push(span) - 1}${SPAN_MARK}`
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
const restore = (text, pattern, stash) =>
|
|
1225
|
+
text.replaceAll(
|
|
1226
|
+
pattern,
|
|
1227
|
+
byGroups(({ index }) => stash[Number(index)])
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
// `convert` applied to the prose between fenced code blocks only.
|
|
1231
|
+
const mapProse = (text, convert) =>
|
|
1232
|
+
segmentBody(text)
|
|
1233
|
+
.map((segment) =>
|
|
1234
|
+
segment.code
|
|
1235
|
+
? segment.lines.join("\n")
|
|
1236
|
+
: convert(segment.lines.join("\n"))
|
|
1237
|
+
)
|
|
1238
|
+
.join("\n");
|
|
1239
|
+
|
|
1240
|
+
// --- Attributes ---------------------------------------------------------------
|
|
1241
|
+
|
|
1242
|
+
const attributeValue = (groups) => {
|
|
1243
|
+
const quoted =
|
|
1244
|
+
groups.dq ?? groups.sq ?? groups.edq ?? groups.esq ?? groups.tpl;
|
|
1245
|
+
if (quoted !== undefined) {
|
|
1246
|
+
return quoted;
|
|
1247
|
+
}
|
|
1248
|
+
if (groups.expression === undefined) {
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
const trimmed = groups.expression.trim();
|
|
1252
|
+
if (trimmed === "true" || trimmed === "false") {
|
|
1253
|
+
return trimmed === "true";
|
|
1254
|
+
}
|
|
1255
|
+
return trimmed;
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
const readAttributes = (source) => {
|
|
1259
|
+
const attributes = {};
|
|
1260
|
+
const pattern =
|
|
1261
|
+
/(?<name>[\w:-]+)(?:\s*=\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|\{\s*(?:"(?<edq>[^"]*)"|'(?<esq>[^']*)'|`(?<tpl>[^`]*)`)\s*\}|\{(?<expression>[^}]*)\}))?/gu;
|
|
1262
|
+
for (const match of source.matchAll(pattern)) {
|
|
1263
|
+
attributes[match.groups.name] = attributeValue(match.groups);
|
|
1264
|
+
}
|
|
1265
|
+
return attributes;
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
const attribute = (value) =>
|
|
1269
|
+
`"${String(value).replaceAll("&", "&").replaceAll('"', """)}"`;
|
|
1270
|
+
|
|
1271
|
+
// Text moved from a prop or JSON into MDX children: braces would start an
|
|
1272
|
+
// expression, and `<` a tag.
|
|
1273
|
+
const escapeMdxText = (text) =>
|
|
1274
|
+
text
|
|
1275
|
+
.replaceAll("{", String.raw`\{`)
|
|
1276
|
+
.replaceAll("}", String.raw`\}`)
|
|
1277
|
+
.replaceAll("<", "<");
|
|
1278
|
+
|
|
1279
|
+
// Markdown moved from JSON into MDX children: escaped like prop text, except
|
|
1280
|
+
// inline code and fenced code, which show their text as written.
|
|
1281
|
+
const escapeMdxMarkdown = (text) =>
|
|
1282
|
+
mapProse(text, (prose) => {
|
|
1283
|
+
const spans = [];
|
|
1284
|
+
return restore(
|
|
1285
|
+
escapeMdxText(stashSpans(prose, spans)),
|
|
1286
|
+
SPAN_PLACEHOLDER,
|
|
1287
|
+
spans
|
|
1288
|
+
);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
const altFromFile = (src) => {
|
|
1292
|
+
const name = String(src).split(/[?#]/u)[0].split("/").pop() ?? "";
|
|
1293
|
+
const base = decodeURIComponent(name)
|
|
1294
|
+
.replace(/\.[a-z0-9]+$/iu, "")
|
|
1295
|
+
.replace(/^(?:[0-9a-f]{7,}-)+/iu, "")
|
|
1296
|
+
.replaceAll(/[-_]+/gu, " ")
|
|
1297
|
+
.replace(/\s*\(\d+\)$/u, "")
|
|
1298
|
+
.trim();
|
|
1299
|
+
const generic =
|
|
1300
|
+
/^(?:screen ?shot|image|img|group|untitled)\b/iu.test(base) ||
|
|
1301
|
+
/^[\d\s]*$/u.test(base) ||
|
|
1302
|
+
base.length < 3;
|
|
1303
|
+
return generic ? "Screenshot" : base;
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
const usableAlt = (value) => {
|
|
1307
|
+
const text = textOf(value);
|
|
1308
|
+
return (
|
|
1309
|
+
text !== undefined &&
|
|
1310
|
+
!/^\{?\s*\d+\s*\}?$/u.test(text) &&
|
|
1311
|
+
!/\.(?:png|jpe?g|gif|svg|webp)$/iu.test(text)
|
|
1312
|
+
);
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
// --- Callouts -----------------------------------------------------------------
|
|
1316
|
+
|
|
1317
|
+
const calloutType = (emoji) =>
|
|
1318
|
+
CALLOUT_EMOJI.find(([pattern]) => pattern.test(emoji))?.[1] ?? "note";
|
|
1319
|
+
|
|
1320
|
+
const directive = (type, title, body, indent = "") => {
|
|
1321
|
+
const label = title ? `[${title}]` : "";
|
|
1322
|
+
const lines = body === "" ? [] : body.split("\n");
|
|
1323
|
+
return [
|
|
1324
|
+
`${indent}:::${type}${label}`,
|
|
1325
|
+
...lines.map((line) => (line === "" ? "" : `${indent}${line}`)),
|
|
1326
|
+
`${indent}:::`,
|
|
1327
|
+
].join("\n");
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
const QUOTE_LINE = /^(?<indent>[ \t]*)>[ \t]?(?<content>.*)$/u;
|
|
1331
|
+
// Lines that start a block of their own, so they can't continue a quoted
|
|
1332
|
+
// paragraph lazily (without their `>`).
|
|
1333
|
+
const BLOCK_START =
|
|
1334
|
+
/^[ \t]*(?:>|[-*+][ \t]|\d+[.)][ \t]|#{1,6}(?:[ \t]|$)|`{3}|~{3}|<|:::|\||(?:[-*_][ \t]*){3,}$)/u;
|
|
1335
|
+
const NOT_PARAGRAPH = /^(?:#{1,6}(?:[ \t]|$)|(?:[-*_][ \t]*){3,}$)/u;
|
|
1336
|
+
|
|
1337
|
+
// The emoji opening a callout: ReadMe wants whitespace (or nothing) after it.
|
|
1338
|
+
const calloutEmoji = (content) => {
|
|
1339
|
+
const text = content.trimStart();
|
|
1340
|
+
const emoji = LEADING_EMOJI.exec(text)?.[0];
|
|
1341
|
+
return emoji && /^(?:\s|$)/u.test(text.slice(emoji.length))
|
|
1342
|
+
? emoji
|
|
1343
|
+
: undefined;
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
// One emoji blockquote from `start`: its title line, the quoted lines after
|
|
1347
|
+
// it (fenced code inside them included), and lazy continuation lines, which
|
|
1348
|
+
// belong to the quote's paragraph as in CommonMark. Returns the body and the
|
|
1349
|
+
// index past the quote.
|
|
1350
|
+
const readQuote = (lines, start, indent) => {
|
|
1351
|
+
const body = [];
|
|
1352
|
+
let fence = null;
|
|
1353
|
+
let paragraph = true;
|
|
1354
|
+
let index = start;
|
|
1355
|
+
for (; index < lines.length; index += 1) {
|
|
1356
|
+
const line = lines[index];
|
|
1357
|
+
if (line.startsWith(`${indent}>`)) {
|
|
1358
|
+
const content = line.slice(indent.length + 1).replace(/^[ \t]/u, "");
|
|
1359
|
+
const fenceLine = fenceOf(content);
|
|
1360
|
+
if (fenceLine && fence === null) {
|
|
1361
|
+
fence = fenceLine.ticks;
|
|
1362
|
+
} else if (
|
|
1363
|
+
fenceLine &&
|
|
1364
|
+
fenceLine.ticks[0] === fence[0] &&
|
|
1365
|
+
fenceLine.ticks.length >= fence.length &&
|
|
1366
|
+
fenceLine.info.trim() === ""
|
|
1367
|
+
) {
|
|
1368
|
+
fence = null;
|
|
1369
|
+
}
|
|
1370
|
+
paragraph =
|
|
1371
|
+
fence === null &&
|
|
1372
|
+
!fenceLine &&
|
|
1373
|
+
content.trim() !== "" &&
|
|
1374
|
+
!NOT_PARAGRAPH.test(content.trim());
|
|
1375
|
+
body.push(content);
|
|
1376
|
+
} else if (
|
|
1377
|
+
paragraph &&
|
|
1378
|
+
line.trim() !== "" &&
|
|
1379
|
+
line.startsWith(indent) &&
|
|
1380
|
+
!BLOCK_START.test(line)
|
|
1381
|
+
) {
|
|
1382
|
+
body.push(line.slice(indent.length).trimStart());
|
|
1383
|
+
} else {
|
|
1384
|
+
break;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return [body.join("\n").replaceAll(/^\n+|\n+$/gu, ""), index];
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
const convertBlockquoteCallouts = (text, file) => {
|
|
1391
|
+
const lines = text.split("\n");
|
|
1392
|
+
const out = [];
|
|
1393
|
+
for (let index = 0; index < lines.length;) {
|
|
1394
|
+
const match = QUOTE_LINE.exec(lines[index]);
|
|
1395
|
+
const emoji = match ? calloutEmoji(match.groups.content) : undefined;
|
|
1396
|
+
if (!emoji) {
|
|
1397
|
+
out.push(lines[index]);
|
|
1398
|
+
index += 1;
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
const { content, indent } = match.groups;
|
|
1402
|
+
const type = calloutType(emoji);
|
|
1403
|
+
const title = content
|
|
1404
|
+
.trimStart()
|
|
1405
|
+
.slice(emoji.length)
|
|
1406
|
+
.trim()
|
|
1407
|
+
.replace(/^#{1,6}\s*/u, "");
|
|
1408
|
+
const [body, next] = readQuote(lines, index + 1, indent);
|
|
1409
|
+
if (/[[\]]/u.test(title)) {
|
|
1410
|
+
note(file, `callout title holds brackets, check it renders: ${title}`);
|
|
1411
|
+
}
|
|
1412
|
+
count(`emoji blockquote → :::${type}`);
|
|
1413
|
+
out.push(directive(type, title, body, indent));
|
|
1414
|
+
index = next;
|
|
1415
|
+
}
|
|
1416
|
+
return out.join("\n");
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
// Dedent `text` (a JSX child block) to its least-indented line. A stashed
|
|
1420
|
+
// code block's own lines move with its placeholder line, less `keep` (the
|
|
1421
|
+
// indentation the caller puts back on every line it writes).
|
|
1422
|
+
const dedent = (text, code = [], keep = 0) => {
|
|
1423
|
+
const lines = text.replaceAll(/^\n+|\s+$/gu, "").split("\n");
|
|
1424
|
+
const indents = lines
|
|
1425
|
+
.filter((line) => line.trim() !== "")
|
|
1426
|
+
.map((line) => line.match(/^[ \t]*/u)[0].length);
|
|
1427
|
+
const cut = indents.length > 0 ? Math.min(...indents) : 0;
|
|
1428
|
+
const strip = new RegExp(`^[ \\t]{0,${Math.max(cut - keep, 0)}}`, "u");
|
|
1429
|
+
return lines
|
|
1430
|
+
.map((line) => {
|
|
1431
|
+
for (const { groups } of line.matchAll(CODE_PLACEHOLDER)) {
|
|
1432
|
+
const index = Number(groups.index);
|
|
1433
|
+
code[index] = code[index]
|
|
1434
|
+
.split("\n")
|
|
1435
|
+
.map((codeLine, position) =>
|
|
1436
|
+
position === 0 ? codeLine : codeLine.replace(strip, "")
|
|
1437
|
+
)
|
|
1438
|
+
.join("\n");
|
|
1439
|
+
}
|
|
1440
|
+
return line.slice(cut);
|
|
1441
|
+
})
|
|
1442
|
+
.join("\n");
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
const convertCalloutComponents = (text, code) =>
|
|
1446
|
+
text.replaceAll(
|
|
1447
|
+
/^(?<indent>[ \t]*)<Callout\b(?<source>[^>]*)>(?<inner>[\s\S]*?)<\/Callout>/gmu,
|
|
1448
|
+
byGroups(({ indent, inner, source }, all) => {
|
|
1449
|
+
const attributes = readAttributes(source);
|
|
1450
|
+
// Blume's own <Callout type> stays.
|
|
1451
|
+
if (attributes.type !== undefined && attributes.theme === undefined) {
|
|
1452
|
+
return all;
|
|
1453
|
+
}
|
|
1454
|
+
const type =
|
|
1455
|
+
CALLOUT_THEME[attributes.theme] ??
|
|
1456
|
+
calloutType(textOf(attributes.icon) ?? "");
|
|
1457
|
+
const lines = dedent(inner, code, indent.length).split("\n");
|
|
1458
|
+
let title = "";
|
|
1459
|
+
if (/^#{1,6}\s/u.test(lines[0] ?? "")) {
|
|
1460
|
+
title = lines
|
|
1461
|
+
.shift()
|
|
1462
|
+
.replace(/^#{1,6}\s*/u, "")
|
|
1463
|
+
.trim();
|
|
1464
|
+
}
|
|
1465
|
+
count(`<Callout> → :::${type}`);
|
|
1466
|
+
const body = lines.join("\n").replace(/^\n+/u, "");
|
|
1467
|
+
return directive(type, title, body, indent);
|
|
1468
|
+
})
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
// --- Components ---------------------------------------------------------------
|
|
1472
|
+
|
|
1473
|
+
const tableCells = (inner) =>
|
|
1474
|
+
[...inner.matchAll(/<tr\b[^>]*>(?<row>[\s\S]*?)<\/tr>/gu)].map((row) =>
|
|
1475
|
+
[
|
|
1476
|
+
...row.groups.row.matchAll(
|
|
1477
|
+
/<t(?<kind>[hd])\b[^>]*>(?<cell>[\s\S]*?)<\/t\k<kind>>/gu
|
|
1478
|
+
),
|
|
1479
|
+
].map((cell) =>
|
|
1480
|
+
cell.groups.cell
|
|
1481
|
+
.split(/\n[ \t]*\n/u)
|
|
1482
|
+
.map((paragraph) =>
|
|
1483
|
+
paragraph
|
|
1484
|
+
.split("\n")
|
|
1485
|
+
.map((line) => line.trim())
|
|
1486
|
+
.join(" ")
|
|
1487
|
+
.trim()
|
|
1488
|
+
)
|
|
1489
|
+
.filter(Boolean)
|
|
1490
|
+
)
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
const hasBlocks = (rows) =>
|
|
1494
|
+
rows.some((row) =>
|
|
1495
|
+
row.some((cell) =>
|
|
1496
|
+
cell.some((paragraph) =>
|
|
1497
|
+
/^(?:[-*+] |\d+\. |:::|<(?:ul|ol|table|div|pre|Tabs|Accordion)\b)|\uE000/u.test(
|
|
1498
|
+
paragraph
|
|
1499
|
+
)
|
|
1500
|
+
)
|
|
1501
|
+
)
|
|
1502
|
+
);
|
|
1503
|
+
|
|
1504
|
+
// A cell spanning columns or rows has no Markdown form.
|
|
1505
|
+
const SPANNING_CELL =
|
|
1506
|
+
/<t[hd]\b[^>]*\b(?:colspan|rowspan)\s*=\s*\{?\s*["']?\s*(?!1\b)\d/iu;
|
|
1507
|
+
|
|
1508
|
+
const tableToMarkdown = (inner, file, spans = []) => {
|
|
1509
|
+
const rows = tableCells(inner);
|
|
1510
|
+
if (rows.length === 0) {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
if (hasBlocks(rows)) {
|
|
1514
|
+
note(file, "table with lists or blocks in its cells kept as <table>");
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
if (SPANNING_CELL.test(inner)) {
|
|
1518
|
+
note(file, "table with cells spanning columns or rows kept as <table>");
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
const width = Math.max(...rows.map((row) => row.length));
|
|
1522
|
+
// A pipe splits a Markdown table cell even inside inline code, unless
|
|
1523
|
+
// escaped (GFM drops the backslash there).
|
|
1524
|
+
const escapePipes = (cell) =>
|
|
1525
|
+
cell.replaceAll("|", String.raw`\|`).replaceAll(
|
|
1526
|
+
SPAN_PLACEHOLDER,
|
|
1527
|
+
byGroups(({ index }, all) => {
|
|
1528
|
+
spans[Number(index)] = spans[Number(index)].replaceAll(
|
|
1529
|
+
"|",
|
|
1530
|
+
String.raw`\|`
|
|
1531
|
+
);
|
|
1532
|
+
return all;
|
|
1533
|
+
})
|
|
1534
|
+
);
|
|
1535
|
+
const render = (row) =>
|
|
1536
|
+
`| ${Array.from({ length: width }, (_, index) =>
|
|
1537
|
+
escapePipes((row[index] ?? []).join("<br />"))
|
|
1538
|
+
).join(" | ")} |`;
|
|
1539
|
+
const [head, ...rest] = rows;
|
|
1540
|
+
const kept = rest.filter((row) => row.some((cell) => cell.length > 0));
|
|
1541
|
+
if (kept.length !== rest.length) {
|
|
1542
|
+
count("table rows dropped (empty)", rest.length - kept.length);
|
|
1543
|
+
}
|
|
1544
|
+
return [
|
|
1545
|
+
render(head),
|
|
1546
|
+
`| ${Array.from({ length: width }, () => "---").join(" | ")} |`,
|
|
1547
|
+
...kept.map(render),
|
|
1548
|
+
].join("\n");
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
const pickAlt = (attributes, caption) => {
|
|
1552
|
+
for (const candidate of [attributes.alt, caption, attributes.title]) {
|
|
1553
|
+
if (usableAlt(candidate)) {
|
|
1554
|
+
return textOf(candidate);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return null;
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
const imageMarkdown = (attributes, caption, file) => {
|
|
1561
|
+
const src = textOf(attributes.src);
|
|
1562
|
+
if (src === undefined) {
|
|
1563
|
+
note(file, "an <Image> without src was left as is");
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
let alt = pickAlt(attributes, caption);
|
|
1567
|
+
if (alt === null) {
|
|
1568
|
+
alt = altFromFile(src);
|
|
1569
|
+
note(file, `image alt derived from its file name ("${alt}"), review it`);
|
|
1570
|
+
count("image alt derived");
|
|
1571
|
+
}
|
|
1572
|
+
for (const key of [
|
|
1573
|
+
"align",
|
|
1574
|
+
"width",
|
|
1575
|
+
"height",
|
|
1576
|
+
"border",
|
|
1577
|
+
"wrap",
|
|
1578
|
+
"className",
|
|
1579
|
+
"lazy",
|
|
1580
|
+
]) {
|
|
1581
|
+
if (attributes[key] !== undefined) {
|
|
1582
|
+
count(`<Image> ${key} dropped`);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
const markdown = `![${alt.replaceAll(/[[\]]/gu, "")}](${src})`;
|
|
1586
|
+
if (caption) {
|
|
1587
|
+
count("<Image> caption → <Frame>");
|
|
1588
|
+
return `<Frame caption=${attribute(caption)}>\n\n${markdown}\n\n</Frame>`;
|
|
1589
|
+
}
|
|
1590
|
+
return markdown;
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
const decodeRepeatedly = (text) => {
|
|
1594
|
+
let decoded = text;
|
|
1595
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
1596
|
+
try {
|
|
1597
|
+
decoded = decodeURIComponent(decoded);
|
|
1598
|
+
} catch {
|
|
1599
|
+
return decoded;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return decoded;
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
const youTubeId = (...candidates) => {
|
|
1606
|
+
for (const candidate of candidates.map(textOf).filter(Boolean)) {
|
|
1607
|
+
const match =
|
|
1608
|
+
/(?:youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^"'\s]*&)?v=|embed\/|shorts\/)|youtu\.be\/)(?<id>[\w-]{11})/u.exec(
|
|
1609
|
+
decodeRepeatedly(candidate)
|
|
1610
|
+
);
|
|
1611
|
+
if (match) {
|
|
1612
|
+
return match.groups.id;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
const embedReplacement = (attributes, file) => {
|
|
1618
|
+
const id = youTubeId(attributes.url, attributes.href, attributes.html);
|
|
1619
|
+
if (id) {
|
|
1620
|
+
count("<Embed> → <YouTube>");
|
|
1621
|
+
const title = textOf(attributes.title);
|
|
1622
|
+
return `<YouTube id="${id}"${title ? ` title=${attribute(title)}` : ""} />`;
|
|
1623
|
+
}
|
|
1624
|
+
const url = textOf(attributes.url) ?? textOf(attributes.href);
|
|
1625
|
+
if (url === undefined) {
|
|
1626
|
+
note(file, "an <Embed> without url was left as is");
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
const name =
|
|
1630
|
+
textOf(attributes.title) ??
|
|
1631
|
+
decodeURIComponent(url.split("/").pop() ?? url).replace(/\.pdf$/iu, "");
|
|
1632
|
+
const label = attributes.typeOfEmbed === "pdf" ? `${name} (PDF)` : name;
|
|
1633
|
+
note(
|
|
1634
|
+
file,
|
|
1635
|
+
`embed of ${url} became a link; use an <iframe> if it must stay inline`
|
|
1636
|
+
);
|
|
1637
|
+
count("<Embed> → link");
|
|
1638
|
+
return `[${escapeMdxText(label)}](${url})`;
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
const convertImages = (text, file) => {
|
|
1642
|
+
let out = text.replaceAll(
|
|
1643
|
+
new RegExp(
|
|
1644
|
+
String.raw`<Image\b(?<source>${ATTRS})(?<!\/)>(?<children>[\s\S]*?)<\/Image>`,
|
|
1645
|
+
"gu"
|
|
1646
|
+
),
|
|
1647
|
+
byGroups(
|
|
1648
|
+
({ children, source }, all) =>
|
|
1649
|
+
imageMarkdown(
|
|
1650
|
+
readAttributes(source),
|
|
1651
|
+
children.trim().replaceAll(/\s+/gu, " "),
|
|
1652
|
+
file
|
|
1653
|
+
) ?? all
|
|
1654
|
+
)
|
|
1655
|
+
);
|
|
1656
|
+
out = out.replaceAll(
|
|
1657
|
+
new RegExp(String.raw`<Image\b(?<source>${ATTRS})\/>`, "gu"),
|
|
1658
|
+
byGroups(({ source }, all) => {
|
|
1659
|
+
const attributes = readAttributes(source);
|
|
1660
|
+
return (
|
|
1661
|
+
imageMarkdown(attributes, textOf(attributes.caption) ?? "", file) ?? all
|
|
1662
|
+
);
|
|
1663
|
+
})
|
|
1664
|
+
);
|
|
1665
|
+
// Legacy Markdown images whose alt is a pixel width and whose title is the
|
|
1666
|
+
// original file name: .png").
|
|
1667
|
+
return out.replaceAll(
|
|
1668
|
+
/!\[\d+\]\((?<src>[^)\s]+)(?:\s+"(?<title>[^"]*)")?\)/gu,
|
|
1669
|
+
byGroups(({ src, title }) => {
|
|
1670
|
+
const alt = usableAlt(title) ? title : altFromFile(src);
|
|
1671
|
+
note(file, `image alt was a pixel width, now "${alt}": review it`);
|
|
1672
|
+
count("image alt derived");
|
|
1673
|
+
return `![${alt.replaceAll(/[[\]]/gu, "")}](${src})`;
|
|
1674
|
+
})
|
|
1675
|
+
);
|
|
1676
|
+
};
|
|
1677
|
+
|
|
1678
|
+
const convertEmbeds = (text, file) =>
|
|
1679
|
+
text
|
|
1680
|
+
.replaceAll(
|
|
1681
|
+
new RegExp(String.raw`<Embed\b(?<source>${ATTRS})\/>`, "gu"),
|
|
1682
|
+
byGroups(
|
|
1683
|
+
({ source }, all) =>
|
|
1684
|
+
embedReplacement(readAttributes(source), file) ?? all
|
|
1685
|
+
)
|
|
1686
|
+
)
|
|
1687
|
+
.replaceAll(
|
|
1688
|
+
/\[(?<label>[^\]]*)\]\((?<url>[^)\s]+)\s+"@embed"\)/gu,
|
|
1689
|
+
byGroups(
|
|
1690
|
+
({ label, url }, all) =>
|
|
1691
|
+
embedReplacement({ title: label, url }, file) ?? all
|
|
1692
|
+
)
|
|
1693
|
+
);
|
|
1694
|
+
|
|
1695
|
+
const convertTables = (text, file, spans) =>
|
|
1696
|
+
text.replaceAll(
|
|
1697
|
+
/<Table\b[^>]*>(?<inner>[\s\S]*?)<\/Table>/gu,
|
|
1698
|
+
byGroups(({ inner }, all) => {
|
|
1699
|
+
const markdown = tableToMarkdown(inner, file, spans);
|
|
1700
|
+
if (markdown === null) {
|
|
1701
|
+
count("<Table> → <table>");
|
|
1702
|
+
return all
|
|
1703
|
+
.replace(/^<Table\b[^>]*>/u, "<table>")
|
|
1704
|
+
.replace(/<\/Table>$/u, "</table>");
|
|
1705
|
+
}
|
|
1706
|
+
count("<Table> → Markdown table");
|
|
1707
|
+
return `\n${markdown}\n`;
|
|
1708
|
+
})
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
const CSS_COLOR = /^(?:#[0-9a-f]{3,8}|[a-z]+|(?:rgb|hsl)a?\(.*\)|var\(.*\))$/iu;
|
|
1712
|
+
|
|
1713
|
+
const cardProps = (attributes, file, context) => {
|
|
1714
|
+
const props = [];
|
|
1715
|
+
if (attributes.title !== undefined) {
|
|
1716
|
+
props.push(`title=${attribute(attributes.title)}`);
|
|
1717
|
+
}
|
|
1718
|
+
const icon = lucideFor(attributes.icon, file);
|
|
1719
|
+
if (icon) {
|
|
1720
|
+
props.push(`icon="${icon}"`);
|
|
1721
|
+
}
|
|
1722
|
+
const href = textOf(attributes.href);
|
|
1723
|
+
if (href !== undefined) {
|
|
1724
|
+
props.push(`href=${attribute(context.rewrite(href, file))}`);
|
|
1725
|
+
}
|
|
1726
|
+
const color = textOf(attributes.iconColor);
|
|
1727
|
+
if (color !== undefined && CSS_COLOR.test(color)) {
|
|
1728
|
+
props.push(`color=${attribute(color)}`);
|
|
1729
|
+
} else if (color !== undefined) {
|
|
1730
|
+
note(file, `card iconColor dropped (not a CSS color): ${color}`);
|
|
1731
|
+
}
|
|
1732
|
+
if (attributes.kind !== undefined) {
|
|
1733
|
+
note(file, `card kind="${attributes.kind}" dropped`);
|
|
1734
|
+
}
|
|
1735
|
+
return props;
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
const convertCards = (text, file, context) =>
|
|
1739
|
+
text
|
|
1740
|
+
.replaceAll(
|
|
1741
|
+
/<Cards\b(?<source>[^>]*)>(?<inner>[\s\S]*?)<\/Cards>/gu,
|
|
1742
|
+
byGroups(({ inner, source }) => {
|
|
1743
|
+
const declared = Number(
|
|
1744
|
+
String(readAttributes(source).columns ?? "").replaceAll(/\D/gu, "")
|
|
1745
|
+
);
|
|
1746
|
+
const cards = (inner.match(/<Card\b/gu) ?? []).length;
|
|
1747
|
+
const cols = declared > 0 ? declared : Math.max(1, Math.min(cards, 3));
|
|
1748
|
+
count("<Cards> → <CardGroup>");
|
|
1749
|
+
return `<CardGroup cols={${cols}}>${inner}</CardGroup>`;
|
|
1750
|
+
})
|
|
1751
|
+
)
|
|
1752
|
+
.replaceAll(
|
|
1753
|
+
new RegExp(String.raw`<Card\b(?<source>${ATTRS})(?<close>\/?)>`, "gu"),
|
|
1754
|
+
byGroups(({ close, source }, all) => {
|
|
1755
|
+
const attributes = readAttributes(source);
|
|
1756
|
+
const readmeProps = ["iconColor", "badge", "target", "kind"].some(
|
|
1757
|
+
(key) => attributes[key] !== undefined
|
|
1758
|
+
);
|
|
1759
|
+
const icon = textOf(attributes.icon);
|
|
1760
|
+
if (
|
|
1761
|
+
!readmeProps &&
|
|
1762
|
+
(icon === undefined || /^[a-z0-9-]+$/u.test(icon))
|
|
1763
|
+
) {
|
|
1764
|
+
return all;
|
|
1765
|
+
}
|
|
1766
|
+
count("<Card> props mapped");
|
|
1767
|
+
const props = cardProps(attributes, file, context).join(" ");
|
|
1768
|
+
if (close === "/") {
|
|
1769
|
+
return `<Card ${props} />`;
|
|
1770
|
+
}
|
|
1771
|
+
const badge = textOf(attributes.badge);
|
|
1772
|
+
return `<Card ${props}>${badge ? `\n<Badge>${escapeMdxText(badge)}</Badge>\n` : ""}`;
|
|
1773
|
+
})
|
|
1774
|
+
);
|
|
1775
|
+
|
|
1776
|
+
const convertColumns = (text) =>
|
|
1777
|
+
text.replaceAll(
|
|
1778
|
+
/<Columns\b(?<source>[^>]*)>(?<inner>[\s\S]*?)<\/Columns>/gu,
|
|
1779
|
+
byGroups(({ inner, source }, all) => {
|
|
1780
|
+
if (readAttributes(source).cols !== undefined) {
|
|
1781
|
+
return all;
|
|
1782
|
+
}
|
|
1783
|
+
const columns = (inner.match(/<Column\b/gu) ?? []).length || 2;
|
|
1784
|
+
count("<Columns> layout → cols");
|
|
1785
|
+
return `<Columns cols={${columns}}>${inner}</Columns>`;
|
|
1786
|
+
})
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1789
|
+
const accordionItem = ({ groups }, file) => {
|
|
1790
|
+
const attributes = readAttributes(groups.source);
|
|
1791
|
+
const icon = lucideFor(attributes.icon, file);
|
|
1792
|
+
return `${groups.indent} <AccordionItem title=${attribute(attributes.title)}${icon ? ` icon="${icon}"` : ""}>${groups.inner}</AccordionItem>`;
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
const accordionRuns = (text) => {
|
|
1796
|
+
const runs = [];
|
|
1797
|
+
for (const match of text.matchAll(
|
|
1798
|
+
/^(?<indent>[ \t]*)<Accordion\b(?<source>[^>]*\btitle=[^>]*)>(?<inner>[\s\S]*?)<\/Accordion>[ \t]*$/gmu
|
|
1799
|
+
)) {
|
|
1800
|
+
const last = runs.at(-1);
|
|
1801
|
+
const end = last ? last.at(-1).index + last.at(-1)[0].length : -1;
|
|
1802
|
+
if (last && /^\s*$/u.test(text.slice(end, match.index))) {
|
|
1803
|
+
last.push(match);
|
|
1804
|
+
} else {
|
|
1805
|
+
runs.push([match]);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return runs;
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
// ReadMe's <Accordion title> is one collapsible: a run of them becomes one
|
|
1812
|
+
// Blume <Accordion> of <AccordionItem>s, a lone one an <Expandable> (which
|
|
1813
|
+
// takes no icon, so a lone one with an icon stays a one-item <Accordion>).
|
|
1814
|
+
const convertAccordions = (text, file) => {
|
|
1815
|
+
let out = text;
|
|
1816
|
+
for (const run of accordionRuns(text).toReversed()) {
|
|
1817
|
+
const start = run[0].index;
|
|
1818
|
+
const end = run.at(-1).index + run.at(-1)[0].length;
|
|
1819
|
+
if (run.some((match) => /<Accordion\b/u.test(match.groups.inner))) {
|
|
1820
|
+
note(file, "nested <Accordion> left as is");
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
const { indent, inner, source } = run[0].groups;
|
|
1824
|
+
let replacement;
|
|
1825
|
+
if (run.length === 1 && textOf(readAttributes(source).icon) === undefined) {
|
|
1826
|
+
const title = attribute(readAttributes(source).title);
|
|
1827
|
+
replacement = `${indent}<Expandable title=${title}>${inner}</Expandable>`;
|
|
1828
|
+
count("<Accordion> → <Expandable>");
|
|
1829
|
+
} else {
|
|
1830
|
+
const items = run.map((match) => accordionItem(match, file));
|
|
1831
|
+
replacement = `${indent}<Accordion>\n${items.join("\n\n")}\n${indent}</Accordion>`;
|
|
1832
|
+
count("<Accordion> run → <Accordion> of <AccordionItem>");
|
|
1833
|
+
}
|
|
1834
|
+
out = `${out.slice(0, start)}${replacement}${out.slice(end)}`;
|
|
1835
|
+
}
|
|
1836
|
+
return out;
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
const convertTabs = (text, file) =>
|
|
1840
|
+
text.replaceAll(
|
|
1841
|
+
/<Tab\b(?<source>[^>]*)>/gu,
|
|
1842
|
+
byGroups(({ source }, all) => {
|
|
1843
|
+
const attributes = readAttributes(source);
|
|
1844
|
+
const icon = textOf(attributes.icon);
|
|
1845
|
+
const bare = icon === undefined || /^[a-z0-9-]+$/u.test(icon);
|
|
1846
|
+
if (bare && attributes.iconColor === undefined) {
|
|
1847
|
+
return all;
|
|
1848
|
+
}
|
|
1849
|
+
const lucide = lucideFor(icon, file);
|
|
1850
|
+
count("<Tab> icon mapped");
|
|
1851
|
+
return `<Tab title=${attribute(attributes.title ?? "")}${lucide ? ` icon="${lucide}"` : ""}>`;
|
|
1852
|
+
})
|
|
1853
|
+
);
|
|
1854
|
+
|
|
1855
|
+
const convertGlossary = (text, file, context) => {
|
|
1856
|
+
const replace = (term, all) => {
|
|
1857
|
+
const definition = context.glossary.get(term.trim().toLowerCase());
|
|
1858
|
+
if (definition === undefined) {
|
|
1859
|
+
note(
|
|
1860
|
+
file,
|
|
1861
|
+
`glossary term "${term.trim()}" left as is: pass its definition with --glossary`
|
|
1862
|
+
);
|
|
1863
|
+
return all;
|
|
1864
|
+
}
|
|
1865
|
+
count("glossary term → <Tooltip>");
|
|
1866
|
+
return `<Tooltip tip=${attribute(definition)}>${term.trim()}</Tooltip>`;
|
|
1867
|
+
};
|
|
1868
|
+
return text
|
|
1869
|
+
.replaceAll(
|
|
1870
|
+
/<Glossary>(?<term>[^<]+)<\/Glossary>/gu,
|
|
1871
|
+
byGroups(({ term }, all) => replace(term, all))
|
|
1872
|
+
)
|
|
1873
|
+
.replaceAll(
|
|
1874
|
+
/<<glossary:(?<term>[^<>\n]+)>>/gu,
|
|
1875
|
+
byGroups(({ term }, all) => replace(term, all))
|
|
1876
|
+
);
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
const convertLinksAndRecipes = (text) =>
|
|
1880
|
+
text
|
|
1881
|
+
.replaceAll(
|
|
1882
|
+
/<Anchor\b(?<source>[^>]*)>(?<label>[\s\S]*?)<\/Anchor>/gu,
|
|
1883
|
+
byGroups(({ label, source }, all) => {
|
|
1884
|
+
const attributes = readAttributes(source);
|
|
1885
|
+
const href = textOf(attributes.href) ?? textOf(attributes.url);
|
|
1886
|
+
if (href === undefined) {
|
|
1887
|
+
return all;
|
|
1888
|
+
}
|
|
1889
|
+
count("<Anchor> → Markdown link");
|
|
1890
|
+
return `[${label.trim()}](${href})`;
|
|
1891
|
+
})
|
|
1892
|
+
)
|
|
1893
|
+
.replaceAll(
|
|
1894
|
+
/<Recipe\b(?<source>[^>]*)\/>/gu,
|
|
1895
|
+
byGroups(({ source }, all) => {
|
|
1896
|
+
const attributes = readAttributes(source);
|
|
1897
|
+
const slug = textOf(attributes.slug);
|
|
1898
|
+
if (slug === undefined) {
|
|
1899
|
+
return all;
|
|
1900
|
+
}
|
|
1901
|
+
count("<Recipe> → <Card>");
|
|
1902
|
+
return `<Card title=${attribute(attributes.title ?? slug)} href="/recipes/${slug}" />`;
|
|
1903
|
+
})
|
|
1904
|
+
);
|
|
1905
|
+
|
|
1906
|
+
const convertVariables = (text, file, context) => {
|
|
1907
|
+
const variable = (name, all) => {
|
|
1908
|
+
if (!/^[A-Za-z_][\w-]*$/u.test(name)) {
|
|
1909
|
+
note(
|
|
1910
|
+
file,
|
|
1911
|
+
`variable ${all} left as is (Blume names take letters, digits, _ and -)`
|
|
1912
|
+
);
|
|
1913
|
+
return all;
|
|
1914
|
+
}
|
|
1915
|
+
context.variables.add(name);
|
|
1916
|
+
count("variable → {{name}}");
|
|
1917
|
+
return `{{${name}}}`;
|
|
1918
|
+
};
|
|
1919
|
+
return text
|
|
1920
|
+
.replaceAll(
|
|
1921
|
+
/<Variable\b[^>]*\bname=["'](?<name>[^"']+)["'][^>]*\/>/gu,
|
|
1922
|
+
byGroups(({ name }, all) => variable(name, all))
|
|
1923
|
+
)
|
|
1924
|
+
.replaceAll(
|
|
1925
|
+
/\{\s*user\.(?<name>[\w-]+)\s*\}/gu,
|
|
1926
|
+
byGroups(({ name }, all) => variable(name, all))
|
|
1927
|
+
)
|
|
1928
|
+
.replaceAll(
|
|
1929
|
+
/\{\s*user\[["'](?<name>[^"']+)["']\]\s*\}/gu,
|
|
1930
|
+
byGroups(({ name }, all) => variable(name, all))
|
|
1931
|
+
)
|
|
1932
|
+
.replaceAll(
|
|
1933
|
+
/<<(?!glossary:)(?<name>[A-Za-z_][\w.-]*)>>/gu,
|
|
1934
|
+
byGroups(({ name }, all) => variable(name, all))
|
|
1935
|
+
);
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
const convertComponents = (text, file, context) => {
|
|
1939
|
+
let out = convertImages(text, file);
|
|
1940
|
+
out = convertEmbeds(out, file);
|
|
1941
|
+
out = convertTables(out, file, context.spans);
|
|
1942
|
+
out = convertCards(out, file, context);
|
|
1943
|
+
out = convertColumns(out);
|
|
1944
|
+
out = convertAccordions(out, file);
|
|
1945
|
+
out = convertTabs(out, file);
|
|
1946
|
+
out = convertGlossary(out, file, context);
|
|
1947
|
+
out = convertLinksAndRecipes(out);
|
|
1948
|
+
return convertVariables(out, file, context);
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
const convertShortcodes = (text, file) =>
|
|
1952
|
+
text.replaceAll(
|
|
1953
|
+
/(?<lead>^|[^\w:/\\])(?<code>:(?<name>[a-z0-9_+-]+):)(?![\w:])/gmu,
|
|
1954
|
+
byGroups(({ code, lead, name }, all) => {
|
|
1955
|
+
if (SHORTCODES.has(name)) {
|
|
1956
|
+
count(":shortcode: → emoji");
|
|
1957
|
+
return `${lead}${SHORTCODES.get(name)}`;
|
|
1958
|
+
}
|
|
1959
|
+
if (name.startsWith("fa-")) {
|
|
1960
|
+
note(file, `Font Awesome shortcode ${code} dropped`);
|
|
1961
|
+
return lead;
|
|
1962
|
+
}
|
|
1963
|
+
if (/[a-z]/u.test(name)) {
|
|
1964
|
+
note(
|
|
1965
|
+
file,
|
|
1966
|
+
`unknown emoji shortcode ${code} left as text: paste the emoji`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
return all;
|
|
1970
|
+
})
|
|
1971
|
+
);
|
|
1972
|
+
|
|
1973
|
+
// --- Magic blocks ---------------------------------------------------------------
|
|
1974
|
+
|
|
1975
|
+
const magicTable = (data) => {
|
|
1976
|
+
const cols = Number(data.cols) || 0;
|
|
1977
|
+
const rows = Number(data.rows) || 0;
|
|
1978
|
+
const cells = recordOf(data.data);
|
|
1979
|
+
const cell = (key) =>
|
|
1980
|
+
String(cells[key] ?? "")
|
|
1981
|
+
.split("\n")
|
|
1982
|
+
.map((line) => line.trim())
|
|
1983
|
+
.filter(Boolean)
|
|
1984
|
+
.join("<br />")
|
|
1985
|
+
.replaceAll("|", String.raw`\|`);
|
|
1986
|
+
const line = (prefix) =>
|
|
1987
|
+
`| ${Array.from({ length: cols }, (_, column) => cell(`${prefix}-${column}`)).join(" | ")} |`;
|
|
1988
|
+
return [
|
|
1989
|
+
line("h"),
|
|
1990
|
+
`| ${Array.from({ length: cols }, () => "---").join(" | ")} |`,
|
|
1991
|
+
...Array.from({ length: rows }, (_, row) => line(String(row))),
|
|
1992
|
+
].join("\n");
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
const magicImage = (data, file) => {
|
|
1996
|
+
const image = listOf(data.images).find((entry) =>
|
|
1997
|
+
Array.isArray(entry?.image)
|
|
1998
|
+
);
|
|
1999
|
+
if (!image) {
|
|
2000
|
+
return "";
|
|
2001
|
+
}
|
|
2002
|
+
const [src, title, alt] = image.image;
|
|
2003
|
+
return imageMarkdown({ alt, src, title }, textOf(image.caption) ?? "", file);
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
const MAGIC = {
|
|
2007
|
+
"api-header": (data) =>
|
|
2008
|
+
`${"#".repeat(Math.min(Math.max(Number(data.level) || 2, 1), 6))} ${textOf(data.title) ?? ""}`,
|
|
2009
|
+
callout: (data) =>
|
|
2010
|
+
directive(
|
|
2011
|
+
MAGIC_CALLOUT[data.type] ?? "note",
|
|
2012
|
+
textOf(data.title) ?? "",
|
|
2013
|
+
escapeMdxMarkdown(textOf(data.body) ?? "")
|
|
2014
|
+
),
|
|
2015
|
+
code: (data) =>
|
|
2016
|
+
listOf(data.codes)
|
|
2017
|
+
.map(
|
|
2018
|
+
(code) =>
|
|
2019
|
+
`\`\`\`${textOf(code.language) ?? ""}${code.name ? ` ${code.name}` : ""}\n${String(code.code ?? "").replace(/\n$/u, "")}\n\`\`\``
|
|
2020
|
+
)
|
|
2021
|
+
.join("\n"),
|
|
2022
|
+
embed: (data, file) => embedReplacement(data, file),
|
|
2023
|
+
html: (data) => {
|
|
2024
|
+
const html = textOf(data.html);
|
|
2025
|
+
return html === undefined
|
|
2026
|
+
? null
|
|
2027
|
+
: `<HTMLBlock>{\`${html.replaceAll("`", "\\`")}\`}</HTMLBlock>`;
|
|
2028
|
+
},
|
|
2029
|
+
image: magicImage,
|
|
2030
|
+
parameters: magicTable,
|
|
2031
|
+
recipe: (data) =>
|
|
2032
|
+
`<Card title=${attribute(data.title ?? data.slug ?? "")} href="/recipes/${textOf(data.slug) ?? ""}" />`,
|
|
2033
|
+
table: magicTable,
|
|
2034
|
+
"tutorial-tile": (data) =>
|
|
2035
|
+
`<Card title=${attribute(data.title ?? data.slug ?? "")} href="/recipes/${textOf(data.slug) ?? ""}" />`,
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
// A block's JSON never holds a line opening another block, so one missing its
|
|
2039
|
+
// `[/block]` can't swallow the next.
|
|
2040
|
+
const convertMagicBlocks = (text, file) => {
|
|
2041
|
+
const out = text.replaceAll(
|
|
2042
|
+
/^\[block:(?<type>[\w-]+)\]\n(?<json>(?:(?!\n\[block:)[\s\S])*?)\n\[\/block\]$/gmu,
|
|
2043
|
+
byGroups(({ json, type }, all) => {
|
|
2044
|
+
if (!MAGIC_TYPES.has(type)) {
|
|
2045
|
+
note(file, `[block:${type}] has no conversion; rewrite it by hand`);
|
|
2046
|
+
return all;
|
|
2047
|
+
}
|
|
2048
|
+
let data;
|
|
2049
|
+
try {
|
|
2050
|
+
data = recordOf(JSON.parse(json));
|
|
2051
|
+
} catch {
|
|
2052
|
+
note(file, `[block:${type}] JSON didn't parse; convert it by hand`);
|
|
2053
|
+
return all;
|
|
2054
|
+
}
|
|
2055
|
+
count(`[block:${type}] converted`);
|
|
2056
|
+
return MAGIC[type](data, file) ?? all;
|
|
2057
|
+
})
|
|
2058
|
+
);
|
|
2059
|
+
for (const { groups } of out.matchAll(
|
|
2060
|
+
/^\[block:(?<type>[\w-]+)\]$(?<rest>(?:(?!\n\[block:)[\s\S])*)/gmu
|
|
2061
|
+
)) {
|
|
2062
|
+
if (!/^\[\/block\]$/mu.test(groups.rest)) {
|
|
2063
|
+
note(file, `[block:${groups.type}] has no [/block]; convert it by hand`);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
return out;
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
// --- HTML and judgment ------------------------------------------------------------
|
|
2070
|
+
|
|
2071
|
+
/** `text` without `pattern`'s matches, removed again until none is left. */
|
|
2072
|
+
const removeAll = (text, pattern) => {
|
|
2073
|
+
let out = text;
|
|
2074
|
+
let previous;
|
|
2075
|
+
do {
|
|
2076
|
+
previous = out;
|
|
2077
|
+
out = out.replaceAll(pattern, "");
|
|
2078
|
+
} while (out !== previous);
|
|
2079
|
+
return out;
|
|
2080
|
+
};
|
|
2081
|
+
|
|
2082
|
+
const htmlIsSafe = (html) =>
|
|
2083
|
+
!/<\s*(?:style|script)\b/iu.test(html) && !/\son[a-z]+\s*=/iu.test(html);
|
|
2084
|
+
|
|
2085
|
+
const unwrapHtmlBlocks = (text, file) =>
|
|
2086
|
+
text.replaceAll(
|
|
2087
|
+
/<HTMLBlock\b[^>]*>\s*\{\s*`(?<raw>[\s\S]*?)`\s*\}\s*<\/HTMLBlock>/gu,
|
|
2088
|
+
byGroups(({ raw }, all) => {
|
|
2089
|
+
const html = raw.replaceAll("\\`", "`").replaceAll("\\${", "${");
|
|
2090
|
+
if (!htmlIsSafe(html)) {
|
|
2091
|
+
note(
|
|
2092
|
+
file,
|
|
2093
|
+
"<HTMLBlock> with <style>, <script>, or event handlers left as is: rebuild it with Blume components or an island"
|
|
2094
|
+
);
|
|
2095
|
+
count("<HTMLBlock> left for review");
|
|
2096
|
+
return all;
|
|
2097
|
+
}
|
|
2098
|
+
count("<HTMLBlock> unwrapped");
|
|
2099
|
+
const cleaned = removeAll(html, /<!--[\s\S]*?-->/gu)
|
|
2100
|
+
.replaceAll("{", "{")
|
|
2101
|
+
.replaceAll("}", "}")
|
|
2102
|
+
.replaceAll(VOID_TAGS, "<$<tag>$<rest> />")
|
|
2103
|
+
.trim();
|
|
2104
|
+
return `\n${cleaned}\n`;
|
|
2105
|
+
})
|
|
2106
|
+
);
|
|
2107
|
+
|
|
2108
|
+
const JUDGMENT = [
|
|
2109
|
+
[
|
|
2110
|
+
/\son[A-Z]\w*\s*=/u,
|
|
2111
|
+
"inline JSX event handlers (onClick, …) do nothing in Blume: rebuild that widget as an island",
|
|
2112
|
+
],
|
|
2113
|
+
[
|
|
2114
|
+
/<\s*style\b/iu,
|
|
2115
|
+
"<style> block: page styles apply to the whole page, sidebar included; replace the markup with Blume components",
|
|
2116
|
+
],
|
|
2117
|
+
[
|
|
2118
|
+
/<\s*script\b/iu,
|
|
2119
|
+
"<script> block: carry what it did over as an island or a script() adapter",
|
|
2120
|
+
],
|
|
2121
|
+
[
|
|
2122
|
+
/\bclass(?:Name)?=["'][^"']*\bfa-[a-z]/u,
|
|
2123
|
+
'Font Awesome <i> icons in HTML: replace with <Icon icon="…" /> (Lucide)',
|
|
2124
|
+
],
|
|
2125
|
+
[
|
|
2126
|
+
/\bclass(?:Name)?=["'][^"']*\b(?:p[xytblr]?|m[xytblr]?|bg|text|rounded|flex|grid|gap|w|h)-[\w[\]./-]+/u,
|
|
2127
|
+
"Tailwind utility classes in HTML: they don't style anything in Blume; restyle or use components",
|
|
2128
|
+
],
|
|
2129
|
+
];
|
|
2130
|
+
|
|
2131
|
+
const reportJudgment = (text, file, context) => {
|
|
2132
|
+
for (const [pattern, message] of JUDGMENT) {
|
|
2133
|
+
if (pattern.test(text)) {
|
|
2134
|
+
note(file, message);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
if (context.hasEsm) {
|
|
2138
|
+
note(
|
|
2139
|
+
file,
|
|
2140
|
+
"import/export in the page, left untouched: a component using React hooks must move to islands/, and one defined on several pages goes in islands/ once"
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
const unknown = new Set();
|
|
2144
|
+
for (const match of text.matchAll(/<(?<name>[A-Z][\w.]*)\b/gu)) {
|
|
2145
|
+
const { name } = match.groups;
|
|
2146
|
+
const builtIn =
|
|
2147
|
+
BLUME_COMPONENTS.has(name) ||
|
|
2148
|
+
name.startsWith("Tree.") ||
|
|
2149
|
+
name.startsWith("Color.");
|
|
2150
|
+
if (!builtIn) {
|
|
2151
|
+
unknown.add(name);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
for (const name of [...unknown].toSorted()) {
|
|
2155
|
+
note(
|
|
2156
|
+
file,
|
|
2157
|
+
context.customComponents.has(name)
|
|
2158
|
+
? `custom component <${name}> (custom_blocks/${name}.mdx): port it to islands/ or replace it with a Blume component`
|
|
2159
|
+
: `unknown component <${name}>: replace it, or the build fails`
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
|
|
2164
|
+
// --- Code fences --------------------------------------------------------------
|
|
2165
|
+
|
|
2166
|
+
const tabLabel = (language) => {
|
|
2167
|
+
if (language === "") {
|
|
2168
|
+
return "Text";
|
|
2169
|
+
}
|
|
2170
|
+
return (
|
|
2171
|
+
LANGUAGE_LABEL[language] ??
|
|
2172
|
+
`${language[0].toUpperCase()}${language.slice(1)}`
|
|
2173
|
+
);
|
|
2174
|
+
};
|
|
2175
|
+
|
|
2176
|
+
const fixFence = (line, file, label) => {
|
|
2177
|
+
const match =
|
|
2178
|
+
/^(?<indent>[ \t]*)(?<ticks>`{3,}|~{3,})(?<language>[^\s`]*)\s*(?<meta>.*)$/u.exec(
|
|
2179
|
+
line
|
|
2180
|
+
);
|
|
2181
|
+
if (!match) {
|
|
2182
|
+
return line;
|
|
2183
|
+
}
|
|
2184
|
+
const { indent, language: rawLanguage, meta: rawMeta, ticks } = match.groups;
|
|
2185
|
+
const lower = rawLanguage.toLowerCase();
|
|
2186
|
+
const language = FENCE_LANGUAGE[lower] ?? lower;
|
|
2187
|
+
if (language !== rawLanguage) {
|
|
2188
|
+
count(`fence language ${rawLanguage} → ${language}`);
|
|
2189
|
+
}
|
|
2190
|
+
let meta = rawMeta.trim();
|
|
2191
|
+
if (meta === "" && label) {
|
|
2192
|
+
meta = tabLabel(lower);
|
|
2193
|
+
count("code tab labeled with its language");
|
|
2194
|
+
}
|
|
2195
|
+
const blumeMeta =
|
|
2196
|
+
/(?:^|\s)(?:title=|lineNumbers|wrap|\{[\d,-]+\}|twoslash)/u.test(meta);
|
|
2197
|
+
if (meta !== "" && !blumeMeta && /\s/u.test(meta)) {
|
|
2198
|
+
meta = `title=${JSON.stringify(meta)}`;
|
|
2199
|
+
count('fence title → title="…"');
|
|
2200
|
+
}
|
|
2201
|
+
return `${indent}${ticks}${language}${meta ? ` ${meta}` : ""}`;
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
const withFixedFence = (segment, file, label) => ({
|
|
2205
|
+
...segment,
|
|
2206
|
+
lines: [fixFence(segment.lines[0], file, label), ...segment.lines.slice(1)],
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
const groupAdjacentFences = (segments, file) => {
|
|
2210
|
+
const out = [];
|
|
2211
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
2212
|
+
const segment = segments[index];
|
|
2213
|
+
if (!segment.code) {
|
|
2214
|
+
out.push(segment);
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
const run = [segment];
|
|
2218
|
+
while (segments[index + 1]?.code) {
|
|
2219
|
+
index += 1;
|
|
2220
|
+
run.push(segments[index]);
|
|
2221
|
+
}
|
|
2222
|
+
const before = out.at(-1)?.lines.join("\n").trimEnd() ?? "";
|
|
2223
|
+
if (run.length === 1 || /<CodeGroup\b[^>]*>$/u.test(before)) {
|
|
2224
|
+
out.push(...run.map((entry) => withFixedFence(entry, file, false)));
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
count("adjacent fences → <CodeGroup>");
|
|
2228
|
+
const [indent] = run[0].lines[0].match(/^[ \t]*/u);
|
|
2229
|
+
out.push({ code: false, lines: [`${indent}<CodeGroup>`, ""] });
|
|
2230
|
+
for (const [position, entry] of run.entries()) {
|
|
2231
|
+
out.push(withFixedFence(entry, file, true));
|
|
2232
|
+
if (position < run.length - 1) {
|
|
2233
|
+
out.push({ code: false, lines: [""] });
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
out.push({ code: false, lines: ["", `${indent}</CodeGroup>`] });
|
|
2237
|
+
}
|
|
2238
|
+
return out;
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
// --- Inline ESM ---------------------------------------------------------------
|
|
2242
|
+
|
|
2243
|
+
// A quote opens a string only after an operator or a keyword, so JSX text
|
|
2244
|
+
// like "don't" inside a component doesn't.
|
|
2245
|
+
const OPENS_STRING = /^(?:|[=(,:[{!&|?+\-*%;]|=>|return)$/u;
|
|
2246
|
+
|
|
2247
|
+
const nextToken = (previous, char) => {
|
|
2248
|
+
if (/\s/u.test(char)) {
|
|
2249
|
+
return previous;
|
|
2250
|
+
}
|
|
2251
|
+
if (/\w/u.test(char)) {
|
|
2252
|
+
return /\w$/u.test(previous) ? `${previous}${char}` : char;
|
|
2253
|
+
}
|
|
2254
|
+
return previous === "=" && char === ">" ? "=>" : char;
|
|
2255
|
+
};
|
|
2256
|
+
|
|
2257
|
+
// Skip a comment starting at `index`, or return -1 when there's none.
|
|
2258
|
+
const skipComment = (text, index) => {
|
|
2259
|
+
if (text[index] !== "/") {
|
|
2260
|
+
return -1;
|
|
2261
|
+
}
|
|
2262
|
+
if (text[index + 1] === "/") {
|
|
2263
|
+
const newline = text.indexOf("\n", index);
|
|
2264
|
+
return newline === -1 ? text.length : newline - 1;
|
|
2265
|
+
}
|
|
2266
|
+
if (text[index + 1] === "*") {
|
|
2267
|
+
const close = text.indexOf("*/", index + 2);
|
|
2268
|
+
return close === -1 ? text.length : close + 1;
|
|
2269
|
+
}
|
|
2270
|
+
return -1;
|
|
2271
|
+
};
|
|
2272
|
+
|
|
2273
|
+
// Advance through a string; returns the new index and whether it's still open.
|
|
2274
|
+
const stepString = (state, text, index) => {
|
|
2275
|
+
const char = text[index];
|
|
2276
|
+
if (char === "\\") {
|
|
2277
|
+
return index + 1;
|
|
2278
|
+
}
|
|
2279
|
+
if (state.quote === "`" && char === "$" && text[index + 1] === "{") {
|
|
2280
|
+
state.stack.push("`");
|
|
2281
|
+
state.quote = null;
|
|
2282
|
+
return index + 1;
|
|
2283
|
+
}
|
|
2284
|
+
if (char === state.quote || (char === "\n" && state.quote !== "`")) {
|
|
2285
|
+
state.quote = null;
|
|
2286
|
+
}
|
|
2287
|
+
return index;
|
|
2288
|
+
};
|
|
2289
|
+
|
|
2290
|
+
const stepCode = (state, text, index) => {
|
|
2291
|
+
const char = text[index];
|
|
2292
|
+
if (
|
|
2293
|
+
char === "`" ||
|
|
2294
|
+
((char === '"' || char === "'") && OPENS_STRING.test(state.previous))
|
|
2295
|
+
) {
|
|
2296
|
+
state.quote = char;
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
2299
|
+
if ("([{".includes(char)) {
|
|
2300
|
+
state.stack.push(char);
|
|
2301
|
+
} else if (")]}".includes(char) && state.stack.pop() === "`") {
|
|
2302
|
+
state.quote = "`";
|
|
2303
|
+
} else if (char === "\n" && state.stack.length === 0) {
|
|
2304
|
+
return true;
|
|
2305
|
+
}
|
|
2306
|
+
state.previous = nextToken(state.previous, char);
|
|
2307
|
+
return false;
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2310
|
+
// The end of the statement starting at `start` (an `import` or `export` at
|
|
2311
|
+
// column 0): the first line end where every bracket it opened has closed.
|
|
2312
|
+
const statementEnd = (text, start) => {
|
|
2313
|
+
const state = { previous: "", quote: null, stack: [] };
|
|
2314
|
+
for (let index = start; index < text.length; index += 1) {
|
|
2315
|
+
if (state.quote) {
|
|
2316
|
+
index = stepString(state, text, index);
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2319
|
+
const skipped = skipComment(text, index);
|
|
2320
|
+
if (skipped !== -1) {
|
|
2321
|
+
index = skipped;
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
if (stepCode(state, text, index)) {
|
|
2325
|
+
return index;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
return text.length;
|
|
2329
|
+
};
|
|
2330
|
+
|
|
2331
|
+
// Swap each column-0 import/export statement for a placeholder, so no
|
|
2332
|
+
// conversion touches the code: those components move to islands/ by hand.
|
|
2333
|
+
const stashEsm = (text, stash) => {
|
|
2334
|
+
let out = "";
|
|
2335
|
+
let cursor = 0;
|
|
2336
|
+
const pattern = /^(?:import|export)\s/gmu;
|
|
2337
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
2338
|
+
const end = statementEnd(text, match.index);
|
|
2339
|
+
out += text.slice(cursor, match.index);
|
|
2340
|
+
out += `${CODE_MARK}${stash.push(text.slice(match.index, end)) - 1}${CODE_MARK}`;
|
|
2341
|
+
cursor = end;
|
|
2342
|
+
pattern.lastIndex = end;
|
|
2343
|
+
}
|
|
2344
|
+
return out + text.slice(cursor);
|
|
2345
|
+
};
|
|
2346
|
+
|
|
2347
|
+
// A code block's placeholder keeps the block's indentation, so a JSX child
|
|
2348
|
+
// block around it dedents as one.
|
|
2349
|
+
const stashCode = (segments, stash) =>
|
|
2350
|
+
segments
|
|
2351
|
+
.map((segment) => {
|
|
2352
|
+
const text = segment.lines.join("\n");
|
|
2353
|
+
if (!segment.code) {
|
|
2354
|
+
return text;
|
|
2355
|
+
}
|
|
2356
|
+
const [indent] = text.match(/^[ \t]*/u);
|
|
2357
|
+
return `${indent}${CODE_MARK}${stash.push(text.slice(indent.length)) - 1}${CODE_MARK}`;
|
|
2358
|
+
})
|
|
2359
|
+
.join("\n");
|
|
2360
|
+
|
|
2361
|
+
// --- Spec descriptions -------------------------------------------------------
|
|
2362
|
+
|
|
2363
|
+
// ReadMe renders its own Markdown in spec descriptions too: callouts, emoji
|
|
2364
|
+
// shortcodes, and doc:/ref: links. Nothing else is touched.
|
|
2365
|
+
const convertDescription = (value, file, context) => {
|
|
2366
|
+
const code = [];
|
|
2367
|
+
let out = stashCode(
|
|
2368
|
+
segmentBody(
|
|
2369
|
+
mapProse(value, (text) => convertBlockquoteCallouts(text, file))
|
|
2370
|
+
),
|
|
2371
|
+
code
|
|
2372
|
+
);
|
|
2373
|
+
out = convertCalloutComponents(out, code);
|
|
2374
|
+
out = convertShortcodes(out, file);
|
|
2375
|
+
out = rewriteLinks(out, file, context.rewrite);
|
|
2376
|
+
return stripSpacers(restore(out, CODE_PLACEHOLDER, code));
|
|
2377
|
+
};
|
|
2378
|
+
|
|
2379
|
+
// --- Page body ----------------------------------------------------------------
|
|
2380
|
+
|
|
2381
|
+
const convertCustomSnippets = (text, context) =>
|
|
2382
|
+
text.replaceAll(
|
|
2383
|
+
/^(?<indent>[ \t]*)<(?<name>[A-Z]\w*)\s*\/>[ \t]*$/gmu,
|
|
2384
|
+
byGroups(({ indent, name }, all) => {
|
|
2385
|
+
const snippet = context.snippets.get(name);
|
|
2386
|
+
if (!snippet) {
|
|
2387
|
+
return all;
|
|
2388
|
+
}
|
|
2389
|
+
count("reusable content → <include>");
|
|
2390
|
+
return `${indent}<include>/_snippets/${snippet}.mdx</include>`;
|
|
2391
|
+
})
|
|
2392
|
+
);
|
|
2393
|
+
|
|
2394
|
+
// A body H1 that repeats the title goes; any other H1 means the page used
|
|
2395
|
+
// H1s for sections, so every body heading moves down a level.
|
|
2396
|
+
const fixHeadings = (text, title) => {
|
|
2397
|
+
let out = text;
|
|
2398
|
+
const first = /^\s*#\s+(?<heading>.+?)\s*#*\s*(?:\n|$)/u.exec(out);
|
|
2399
|
+
const titleText = textOf(title)?.toLowerCase();
|
|
2400
|
+
if (
|
|
2401
|
+
first &&
|
|
2402
|
+
titleText &&
|
|
2403
|
+
first.groups.heading.replaceAll(/[*_`]/gu, "").trim().toLowerCase() ===
|
|
2404
|
+
titleText
|
|
2405
|
+
) {
|
|
2406
|
+
out = out.slice(first[0].length);
|
|
2407
|
+
count("duplicate H1 removed");
|
|
2408
|
+
}
|
|
2409
|
+
if (/^[ \t]*#[ \t]/mu.test(out)) {
|
|
2410
|
+
out = out.replaceAll(
|
|
2411
|
+
/^(?<indent>[ \t]*)(?<marks>#{1,5})(?=[ \t])/gmu,
|
|
2412
|
+
"$<indent>#$<marks>"
|
|
2413
|
+
);
|
|
2414
|
+
count("body headings demoted one level (the page used H1s)");
|
|
2415
|
+
}
|
|
2416
|
+
return out;
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
const commentCount = (text) => (text.match(/<!--[\s\S]*?-->/gu) ?? []).length;
|
|
2420
|
+
|
|
2421
|
+
// A comment on lines of its own goes with its lines; one inside a line leaves
|
|
2422
|
+
// a space if it stood between words. Removing one can join the text around it
|
|
2423
|
+
// into another (`<!-<!-- x -->-`), so this repeats until none is left.
|
|
2424
|
+
const dropComments = (text) => {
|
|
2425
|
+
let out = text;
|
|
2426
|
+
let comments = commentCount(out);
|
|
2427
|
+
while (comments > 0) {
|
|
2428
|
+
count("HTML comment deleted", comments);
|
|
2429
|
+
out = out
|
|
2430
|
+
.replaceAll(/^[ \t]*<!--(?:(?!-->)[\s\S])*-->[ \t]*(?:\n|$)/gmu, "")
|
|
2431
|
+
.replaceAll(/[ \t]*<!--(?:(?!-->)[\s\S])*-->[ \t]*/gu, (comment) =>
|
|
2432
|
+
/^[ \t]|[ \t]$/u.test(comment) ? " " : ""
|
|
2433
|
+
);
|
|
2434
|
+
comments = commentCount(out);
|
|
2435
|
+
}
|
|
2436
|
+
return out;
|
|
2437
|
+
};
|
|
2438
|
+
|
|
2439
|
+
const selfCloseVoidTags = (text) =>
|
|
2440
|
+
text.replaceAll(
|
|
2441
|
+
VOID_TAGS,
|
|
2442
|
+
byGroups(({ rest, tag }) => {
|
|
2443
|
+
count(`<${tag}> self-closed`);
|
|
2444
|
+
return `<${tag}${rest} />`;
|
|
2445
|
+
})
|
|
2446
|
+
);
|
|
2447
|
+
|
|
2448
|
+
const convertBody = (body, file, context, title) => {
|
|
2449
|
+
const code = [];
|
|
2450
|
+
// Magic blocks and emoji callouts first, outside code: both can turn into
|
|
2451
|
+
// fenced code, which is then set aside with the rest.
|
|
2452
|
+
const prose = mapProse(
|
|
2453
|
+
mapProse(body, (text) => convertMagicBlocks(text, file)),
|
|
2454
|
+
(text) => convertBlockquoteCallouts(text, file)
|
|
2455
|
+
);
|
|
2456
|
+
const segments = groupAdjacentFences(segmentBody(prose), file);
|
|
2457
|
+
let text = stashCode(segments, code);
|
|
2458
|
+
const hasEsm = /^(?:import|export)\s/mu.test(text);
|
|
2459
|
+
text = unwrapHtmlBlocks(stashEsm(text, code), file);
|
|
2460
|
+
const spans = [];
|
|
2461
|
+
text = dropComments(stashSpans(text, spans));
|
|
2462
|
+
text = convertCalloutComponents(text, code);
|
|
2463
|
+
text = convertComponents(text, file, { ...context, spans });
|
|
2464
|
+
text = selfCloseVoidTags(text);
|
|
2465
|
+
text = convertShortcodes(text, file);
|
|
2466
|
+
text = rewriteLinks(text, file, context.rewrite);
|
|
2467
|
+
text = convertCustomSnippets(text, context);
|
|
2468
|
+
reportJudgment(text, file, { ...context, hasEsm });
|
|
2469
|
+
text = fixHeadings(text, title);
|
|
2470
|
+
text = restore(
|
|
2471
|
+
restore(text, SPAN_PLACEHOLDER, spans),
|
|
2472
|
+
CODE_PLACEHOLDER,
|
|
2473
|
+
code
|
|
2474
|
+
);
|
|
2475
|
+
if (code.some((block) => /<<[A-Za-z_][\w-]*>>/u.test(block))) {
|
|
2476
|
+
note(
|
|
2477
|
+
file,
|
|
2478
|
+
"a <<variable>> inside a code block was left as is; ReadMe filled it per reader"
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
return text.replaceAll(/\n{3,}/gu, "\n\n").replace(/^\n+/u, "");
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2484
|
+
// --- Frontmatter --------------------------------------------------------------
|
|
2485
|
+
|
|
2486
|
+
const ordered = (fields) => {
|
|
2487
|
+
const out = {};
|
|
2488
|
+
for (const key of KEY_ORDER) {
|
|
2489
|
+
if (fields.has(key)) {
|
|
2490
|
+
out[key] = fields.get(key);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
for (const [key, value] of fields) {
|
|
2494
|
+
if (!(key in out) && value !== undefined) {
|
|
2495
|
+
out[key] = value;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
return out;
|
|
2499
|
+
};
|
|
2500
|
+
|
|
2501
|
+
// A frontmatter map the mapping consumes key by key.
|
|
2502
|
+
const createSource = (data) => {
|
|
2503
|
+
const source = new Map(Object.entries(recordOf(data)));
|
|
2504
|
+
const take = (key) => {
|
|
2505
|
+
const value = source.get(key);
|
|
2506
|
+
source.delete(key);
|
|
2507
|
+
return value;
|
|
2508
|
+
};
|
|
2509
|
+
return { source, take };
|
|
2510
|
+
};
|
|
2511
|
+
|
|
2512
|
+
const merge = (fields, key, value) => {
|
|
2513
|
+
fields.set(key, { ...recordOf(fields.get(key)), ...value });
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
const mapChangelog = ({ take }, fields, file) => {
|
|
2517
|
+
fields.set("type", "changelog");
|
|
2518
|
+
const kind = textOf(take("type"));
|
|
2519
|
+
if (kind !== undefined && CHANGELOG_TYPES.has(kind.toLowerCase())) {
|
|
2520
|
+
if (kind.toLowerCase() !== "none") {
|
|
2521
|
+
const category = `${kind[0].toUpperCase()}${kind.slice(1).toLowerCase()}`;
|
|
2522
|
+
merge(fields, "changelog", { category });
|
|
2523
|
+
}
|
|
2524
|
+
} else if (kind !== undefined && kind !== "changelog") {
|
|
2525
|
+
note(file, `changelog type "${kind}" dropped`);
|
|
2526
|
+
}
|
|
2527
|
+
const date =
|
|
2528
|
+
textOf(take("date")) ??
|
|
2529
|
+
textOf(take("published_at")) ??
|
|
2530
|
+
textOf(take("created_at"));
|
|
2531
|
+
if (date === undefined) {
|
|
2532
|
+
note(file, "changelog entry without a date: take it from the live entry");
|
|
2533
|
+
} else {
|
|
2534
|
+
fields.set("date", date.slice(0, 10));
|
|
2535
|
+
}
|
|
2536
|
+
const author = take("author") ?? take("authors");
|
|
2537
|
+
if (Array.isArray(author) || textOf(author) !== undefined) {
|
|
2538
|
+
fields.set("authors", author);
|
|
2539
|
+
} else if (isRecord(author)) {
|
|
2540
|
+
note(file, "changelog author is an ID object; dropped");
|
|
2541
|
+
}
|
|
2542
|
+
const existing = take("changelog");
|
|
2543
|
+
if (isRecord(existing)) {
|
|
2544
|
+
merge(fields, "changelog", existing);
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
const mapVisibility = ({ take }, fields, info, data) => {
|
|
2549
|
+
const hidden = take("hidden");
|
|
2550
|
+
if (
|
|
2551
|
+
hidden !== undefined &&
|
|
2552
|
+
hidden !== null &&
|
|
2553
|
+
hidden !== true &&
|
|
2554
|
+
hidden !== false
|
|
2555
|
+
) {
|
|
2556
|
+
info.notes.push(
|
|
2557
|
+
`hidden: ${JSON.stringify(hidden)} isn't true or false: dropped; set hidden: true if the page was hidden`
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
const privacy = recordOf(take("privacy"));
|
|
2561
|
+
const unlisted = hidden === true || privacy.view === "anyone_with_link";
|
|
2562
|
+
if (info.section === "page") {
|
|
2563
|
+
// rdme@10 writes a custom page's flag as appearance.fullscreen.
|
|
2564
|
+
const fullscreen =
|
|
2565
|
+
take("fullscreen") ?? recordOf(data.appearance).fullscreen;
|
|
2566
|
+
const mode = textOf(take("mode")) ?? textOf(data.mode);
|
|
2567
|
+
fields.set("hidden", true);
|
|
2568
|
+
fields.set("mode", fullscreen === true ? "custom" : (mode ?? "center"));
|
|
2569
|
+
count("custom page → hidden + mode");
|
|
2570
|
+
} else if (unlisted) {
|
|
2571
|
+
fields.set("hidden", true);
|
|
2572
|
+
if (info.section !== "changelog") {
|
|
2573
|
+
merge(fields, "search", { exclude: true });
|
|
2574
|
+
}
|
|
2575
|
+
count("hidden → hidden (+ search.exclude)");
|
|
2576
|
+
}
|
|
2577
|
+
if (hidden === true && info.legacyRdme) {
|
|
2578
|
+
info.notes.push(
|
|
2579
|
+
"hidden: true in rdme ≤ 9 frontmatter: check the live URL, and use draft: true if it 404s"
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
const deprecated = take("deprecated");
|
|
2583
|
+
if (deprecated === true || take("state") === "deprecated") {
|
|
2584
|
+
fields.set("deprecated", true);
|
|
2585
|
+
}
|
|
2586
|
+
if (take("allow_crawlers") === "disabled") {
|
|
2587
|
+
fields.set("noindex", true);
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
|
|
2591
|
+
const mapMetadata = ({ take }, fields, info) => {
|
|
2592
|
+
const metadata = recordOf(take("metadata"));
|
|
2593
|
+
const seo = {};
|
|
2594
|
+
for (const key of ["title", "description"]) {
|
|
2595
|
+
if (textOf(metadata[key]) !== undefined) {
|
|
2596
|
+
seo[key] = textOf(metadata[key]);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
const image = textOf(metadata.image) ?? textOf(recordOf(metadata.image).url);
|
|
2600
|
+
if (image !== undefined) {
|
|
2601
|
+
seo.image = image;
|
|
2602
|
+
} else if (isRecord(metadata.image)) {
|
|
2603
|
+
// rdme@10 writes `{ uri: "/images/<id>" }`, an id rather than a URL.
|
|
2604
|
+
info.notes.push(
|
|
2605
|
+
"metadata.image has no URL (an rdme image id?): set seo.image by hand"
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
if (Object.keys(seo).length > 0) {
|
|
2609
|
+
merge(fields, "seo", seo);
|
|
2610
|
+
count("metadata → seo");
|
|
2611
|
+
}
|
|
2612
|
+
const keywords = Array.isArray(metadata.keywords)
|
|
2613
|
+
? metadata.keywords.map((keyword) => textOf(keyword)).filter(Boolean)
|
|
2614
|
+
: (textOf(metadata.keywords) ?? "")
|
|
2615
|
+
.split(",")
|
|
2616
|
+
.map((keyword) => keyword.trim())
|
|
2617
|
+
.filter(Boolean);
|
|
2618
|
+
if (keywords.length > 0) {
|
|
2619
|
+
merge(fields, "search", { keywords });
|
|
2620
|
+
count("metadata.keywords → search.keywords");
|
|
2621
|
+
}
|
|
2622
|
+
if (metadata.robots === "noindex") {
|
|
2623
|
+
fields.set("noindex", true);
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2626
|
+
|
|
2627
|
+
const relatedEntry = (entry, context, notes) => {
|
|
2628
|
+
const page = recordOf(entry);
|
|
2629
|
+
const url = textOf(page.url);
|
|
2630
|
+
if (page.type === "link" && url !== undefined) {
|
|
2631
|
+
const title = textOf(page.title);
|
|
2632
|
+
return title ? { [title]: url } : url;
|
|
2633
|
+
}
|
|
2634
|
+
const prefix = NEXT_PREFIX[page.type];
|
|
2635
|
+
const slug = textOf(page.slug);
|
|
2636
|
+
if (prefix === undefined || slug === undefined) {
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
const target = `${prefix}${slug}`;
|
|
2640
|
+
if (context.knownUrls && !context.knownUrls.has(target)) {
|
|
2641
|
+
notes.push(`next.pages entry ${target} dropped: no such page in this repo`);
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
return target;
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
const mapNext = ({ take }, fields, info, context) => {
|
|
2648
|
+
const content = recordOf(take("content"));
|
|
2649
|
+
const description = textOf(take("excerpt")) ?? textOf(take("description"));
|
|
2650
|
+
const excerpt = description ?? textOf(content.excerpt);
|
|
2651
|
+
if (excerpt !== undefined) {
|
|
2652
|
+
fields.set("description", excerpt);
|
|
2653
|
+
}
|
|
2654
|
+
const next = recordOf(take("next") ?? content.next);
|
|
2655
|
+
const related = listOf(next.pages)
|
|
2656
|
+
.map((entry) => relatedEntry(entry, context, info.notes))
|
|
2657
|
+
.filter((entry) => entry !== undefined);
|
|
2658
|
+
if (related.length > 0) {
|
|
2659
|
+
// Blume's schema takes at most 10 related pages.
|
|
2660
|
+
fields.set("related", related.slice(0, 10));
|
|
2661
|
+
count("next.pages → related");
|
|
2662
|
+
if (related.length > 10) {
|
|
2663
|
+
info.notes.push(
|
|
2664
|
+
`related keeps the first 10 of ${related.length} next.pages entries (Blume's limit)`
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
const link = recordOf(take("link") ?? content.link);
|
|
2669
|
+
info.linkUrl = textOf(link.url);
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2672
|
+
const mapRest = ({ source, take }, fields, info) => {
|
|
2673
|
+
const appearance = recordOf(take("appearance"));
|
|
2674
|
+
const icon = lucideFor(
|
|
2675
|
+
take("icon") ?? recordOf(appearance.icon).name,
|
|
2676
|
+
info.file
|
|
2677
|
+
);
|
|
2678
|
+
if (icon) {
|
|
2679
|
+
fields.set("icon", icon);
|
|
2680
|
+
}
|
|
2681
|
+
if (take("table_of_contents") === false) {
|
|
2682
|
+
if (!fields.has("mode")) {
|
|
2683
|
+
fields.set("mode", "wide");
|
|
2684
|
+
}
|
|
2685
|
+
info.notes.push("table_of_contents: false approximated with mode: wide");
|
|
2686
|
+
}
|
|
2687
|
+
const recipes = listOf(take("recipes"));
|
|
2688
|
+
if (recipes.length > 0) {
|
|
2689
|
+
info.notes.push(`related recipes dropped: ${recipes.join(", ")}`);
|
|
2690
|
+
}
|
|
2691
|
+
for (const key of ["recipe", "x-import", "x_import", "api_config", "name"]) {
|
|
2692
|
+
take(key);
|
|
2693
|
+
}
|
|
2694
|
+
if (isRecord(source.get("api")) || source.get("api") === null) {
|
|
2695
|
+
take("api");
|
|
2696
|
+
}
|
|
2697
|
+
if (info.section !== "changelog") {
|
|
2698
|
+
const kind = textOf(take("type"));
|
|
2699
|
+
if (
|
|
2700
|
+
kind !== undefined &&
|
|
2701
|
+
!["basic", "link", "error", "endpoint", "webhook", "api_config"].includes(
|
|
2702
|
+
kind
|
|
2703
|
+
)
|
|
2704
|
+
) {
|
|
2705
|
+
fields.set("type", kind);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
for (const key of NAV_KEYS) {
|
|
2709
|
+
if (source.has(key)) {
|
|
2710
|
+
info.notes.push(
|
|
2711
|
+
`${key}: rdme-upload navigation key left in place: rebuild the tree (see the reference), then remove it`
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
};
|
|
2716
|
+
|
|
2717
|
+
const passThrough = (source, fields, info) => {
|
|
2718
|
+
for (const [key, value] of source) {
|
|
2719
|
+
if (!fields.has(key)) {
|
|
2720
|
+
fields.set(key, value);
|
|
2721
|
+
if (!BLUME_KEYS.has(key) && !NAV_KEYS.includes(key)) {
|
|
2722
|
+
info.notes.push(
|
|
2723
|
+
`frontmatter key "${key}" kept as is: Blume rejects unknown keys`
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
} else if (isRecord(value) && isRecord(fields.get(key))) {
|
|
2727
|
+
fields.set(key, { ...value, ...fields.get(key) });
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
};
|
|
2731
|
+
|
|
2732
|
+
const mapFrontmatter = (data, info, context) => {
|
|
2733
|
+
const reader = createSource(data);
|
|
2734
|
+
const fields = new Map();
|
|
2735
|
+
const title = reader.take("title");
|
|
2736
|
+
if (title !== undefined) {
|
|
2737
|
+
fields.set("title", title);
|
|
2738
|
+
}
|
|
2739
|
+
if (info.section === "changelog") {
|
|
2740
|
+
mapChangelog(reader, fields, info.file);
|
|
2741
|
+
}
|
|
2742
|
+
mapNext(reader, fields, info, context);
|
|
2743
|
+
mapVisibility(reader, fields, info, data);
|
|
2744
|
+
mapMetadata(reader, fields, info);
|
|
2745
|
+
mapRest(reader, fields, info);
|
|
2746
|
+
if (info.slug) {
|
|
2747
|
+
fields.set("slug", info.slug);
|
|
2748
|
+
}
|
|
2749
|
+
passThrough(reader.source, fields, info);
|
|
2750
|
+
for (const message of info.notes) {
|
|
2751
|
+
note(info.file, message);
|
|
2752
|
+
}
|
|
2753
|
+
return ordered(fields);
|
|
2754
|
+
};
|
|
2755
|
+
|
|
2756
|
+
// --- Project: reading -----------------------------------------------------------
|
|
2757
|
+
|
|
2758
|
+
// ReadMe's flat URL for a page, from its path in the synced repo.
|
|
2759
|
+
const readmeUrl = (section, parts, stem) => {
|
|
2760
|
+
const real = parts.filter((part) => !isGroup(part));
|
|
2761
|
+
const slug = stem === "index" ? real.at(-1) : stem;
|
|
2762
|
+
return `${URL_PREFIX[section]}${slug}`;
|
|
2763
|
+
};
|
|
2764
|
+
|
|
2765
|
+
const isSyncLayout = (root) =>
|
|
2766
|
+
existsSync(path.join(root, INVENTORY)) ||
|
|
2767
|
+
["docs", "reference", "recipes", "custom_pages", "page"].some((dir) =>
|
|
2768
|
+
existsSync(path.join(root, dir, "_order.yaml"))
|
|
2769
|
+
) ||
|
|
2770
|
+
walk(path.join(root, "docs")).some((file) => file.endsWith("_order.yaml"));
|
|
2771
|
+
|
|
2772
|
+
const loadSpecs = (root) => {
|
|
2773
|
+
const specs = new Map();
|
|
2774
|
+
const dir = path.join(root, "reference");
|
|
2775
|
+
for (const file of walk(dir).filter((entry) => path.dirname(entry) === dir)) {
|
|
2776
|
+
const name = path.basename(file);
|
|
2777
|
+
if (/\.json$/iu.test(name)) {
|
|
2778
|
+
try {
|
|
2779
|
+
specs.set(name, {
|
|
2780
|
+
data: JSON.parse(readFileSync(file, "utf-8")),
|
|
2781
|
+
file,
|
|
2782
|
+
});
|
|
2783
|
+
} catch {
|
|
2784
|
+
specs.set(name, { data: null, file });
|
|
2785
|
+
}
|
|
2786
|
+
} else if (
|
|
2787
|
+
/\.ya?ml$/iu.test(name) &&
|
|
2788
|
+
/^(?:openapi|swagger):/mu.test(readFileSync(file, "utf-8"))
|
|
2789
|
+
) {
|
|
2790
|
+
specs.set(name, { data: null, file, yaml: true });
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
return specs;
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
const findOperation = (items, operationId) => {
|
|
2797
|
+
for (const [route, item] of Object.entries(recordOf(items))) {
|
|
2798
|
+
for (const [method, operation] of Object.entries(recordOf(item))) {
|
|
2799
|
+
if (recordOf(operation).operationId === operationId) {
|
|
2800
|
+
return {
|
|
2801
|
+
method: method.toUpperCase(),
|
|
2802
|
+
path: route,
|
|
2803
|
+
tag: textOf(listOf(operation.tags)[0]) ?? null,
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
|
|
2810
|
+
const operationOf = (spec, operationId) => {
|
|
2811
|
+
if (!spec?.data) {
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
return (
|
|
2815
|
+
findOperation(spec.data.paths, operationId) ??
|
|
2816
|
+
findOperation(spec.data.webhooks ?? spec.data["x-webhooks"], operationId)
|
|
2817
|
+
);
|
|
2818
|
+
};
|
|
2819
|
+
|
|
2820
|
+
// --glossary takes JSON, or a saved page of the hub, whose embedded settings
|
|
2821
|
+
// carry the glossary as "glossaryTerms": [{ "term", "definition" }].
|
|
2822
|
+
const bracketEnd = (text, from) => {
|
|
2823
|
+
let depth = 0;
|
|
2824
|
+
let quoted = false;
|
|
2825
|
+
for (let index = from; index < text.length; index += 1) {
|
|
2826
|
+
const char = text[index];
|
|
2827
|
+
if (quoted && char === "\\") {
|
|
2828
|
+
index += 1;
|
|
2829
|
+
} else if (char === '"') {
|
|
2830
|
+
quoted = !quoted;
|
|
2831
|
+
} else if (!quoted && char === "[") {
|
|
2832
|
+
depth += 1;
|
|
2833
|
+
} else if (!quoted && char === "]") {
|
|
2834
|
+
depth -= 1;
|
|
2835
|
+
if (depth === 0) {
|
|
2836
|
+
return index;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
return -1;
|
|
2841
|
+
};
|
|
2842
|
+
|
|
2843
|
+
const readGlossary = (file) => {
|
|
2844
|
+
const content = readFileSync(file, "utf-8");
|
|
2845
|
+
try {
|
|
2846
|
+
return JSON.parse(content);
|
|
2847
|
+
} catch {
|
|
2848
|
+
const start = content.indexOf('"glossaryTerms":');
|
|
2849
|
+
const from = start === -1 ? -1 : content.indexOf("[", start);
|
|
2850
|
+
const end = from === -1 ? -1 : bracketEnd(content, from);
|
|
2851
|
+
if (end === -1) {
|
|
2852
|
+
throw new Error(
|
|
2853
|
+
`${file} is neither JSON nor a hub page with glossaryTerms`
|
|
2854
|
+
);
|
|
2855
|
+
}
|
|
2856
|
+
return JSON.parse(content.slice(from, end + 1));
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
|
|
2860
|
+
const loadGlossary = (file) => {
|
|
2861
|
+
const glossary = new Map();
|
|
2862
|
+
if (!file) {
|
|
2863
|
+
return glossary;
|
|
2864
|
+
}
|
|
2865
|
+
const raw = readGlossary(file);
|
|
2866
|
+
const entries = Array.isArray(raw)
|
|
2867
|
+
? raw.map((entry) => [recordOf(entry).term, recordOf(entry).definition])
|
|
2868
|
+
: Object.entries(recordOf(raw));
|
|
2869
|
+
for (const [term, definition] of entries) {
|
|
2870
|
+
if (textOf(term) !== undefined && textOf(definition) !== undefined) {
|
|
2871
|
+
glossary.set(textOf(term).toLowerCase(), textOf(definition));
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
return glossary;
|
|
2875
|
+
};
|
|
2876
|
+
|
|
2877
|
+
const loadBlocks = (root) => {
|
|
2878
|
+
const snippets = new Map();
|
|
2879
|
+
const components = new Set();
|
|
2880
|
+
for (const file of walk(path.join(root, "custom_blocks"))) {
|
|
2881
|
+
const name = path.basename(file).replace(/\.mdx?$/u, "");
|
|
2882
|
+
if (file.endsWith(".mdx")) {
|
|
2883
|
+
components.add(name);
|
|
2884
|
+
} else if (file.endsWith(".md")) {
|
|
2885
|
+
snippets.set(
|
|
2886
|
+
name,
|
|
2887
|
+
name
|
|
2888
|
+
.replaceAll(
|
|
2889
|
+
/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu,
|
|
2890
|
+
"$<lower>-$<upper>"
|
|
2891
|
+
)
|
|
2892
|
+
.toLowerCase()
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
return { components, snippets };
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2899
|
+
const readPage = (root, dir, file) => {
|
|
2900
|
+
const relative = path.relative(path.join(root, dir), file).split(path.sep);
|
|
2901
|
+
// A byte order mark would hide the frontmatter; it goes.
|
|
2902
|
+
const raw = readFileSync(file, "utf-8").replace(/^\uFEFF/u, "");
|
|
2903
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
2904
|
+
const source = raw.replaceAll("\r\n", "\n");
|
|
2905
|
+
const { body, raw: frontmatter } = splitFrontmatter(source);
|
|
2906
|
+
const page = {
|
|
2907
|
+
body,
|
|
2908
|
+
data: {},
|
|
2909
|
+
dir,
|
|
2910
|
+
eol,
|
|
2911
|
+
file,
|
|
2912
|
+
fm: frontmatter,
|
|
2913
|
+
parseError: null,
|
|
2914
|
+
parts: relative.slice(0, -1),
|
|
2915
|
+
section: SECTIONS[dir],
|
|
2916
|
+
source,
|
|
2917
|
+
stem: path.basename(file).replace(/\.(?:mdx?|html)$/u, ""),
|
|
2918
|
+
};
|
|
2919
|
+
if (frontmatter !== null) {
|
|
2920
|
+
try {
|
|
2921
|
+
page.data = recordOf(parseYaml(frontmatter));
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
page.parseError = error.message;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
return page;
|
|
2927
|
+
};
|
|
2928
|
+
|
|
2929
|
+
const readPages = (root, sectionDirs) => {
|
|
2930
|
+
const pages = [];
|
|
2931
|
+
for (const dir of sectionDirs) {
|
|
2932
|
+
for (const file of walk(path.join(root, dir))) {
|
|
2933
|
+
if (/\.(?:mdx?|html)$/u.test(file)) {
|
|
2934
|
+
pages.push(readPage(root, dir, file));
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
return pages;
|
|
2939
|
+
};
|
|
2940
|
+
|
|
2941
|
+
const isEndpoint = (page) =>
|
|
2942
|
+
textOf(recordOf(page.data.api).operationId) !== undefined;
|
|
2943
|
+
|
|
2944
|
+
const tagFolderOf = (page) =>
|
|
2945
|
+
page.parts.length > 0 ? path.join(page.dir, ...page.parts) : null;
|
|
2946
|
+
|
|
2947
|
+
// --- Project: state ---------------------------------------------------------------
|
|
2948
|
+
|
|
2949
|
+
const createProject = (root, options) => {
|
|
2950
|
+
const syncLayout = isSyncLayout(root);
|
|
2951
|
+
const inventory = readJson(path.join(root, INVENTORY), {
|
|
2952
|
+
endpoints: [],
|
|
2953
|
+
redirects: [],
|
|
2954
|
+
referenceOrder: [],
|
|
2955
|
+
tagFolders: [],
|
|
2956
|
+
urls: [],
|
|
2957
|
+
});
|
|
2958
|
+
const redirects = new Map(
|
|
2959
|
+
readJson(path.join(root, REDIRECTS), []).map((entry) => [entry.from, entry])
|
|
2960
|
+
);
|
|
2961
|
+
const sectionDirs = Object.keys(SECTIONS).filter((dir) =>
|
|
2962
|
+
existsSync(path.join(root, dir))
|
|
2963
|
+
);
|
|
2964
|
+
const blocks = loadBlocks(root);
|
|
2965
|
+
const project = {
|
|
2966
|
+
deletes: new Set(),
|
|
2967
|
+
handled: new Set(),
|
|
2968
|
+
inventory,
|
|
2969
|
+
knownUrls: new Set(inventory.urls.map((entry) => entry.url)),
|
|
2970
|
+
options,
|
|
2971
|
+
pages: readPages(root, sectionDirs),
|
|
2972
|
+
redirects,
|
|
2973
|
+
root,
|
|
2974
|
+
sectionDirs,
|
|
2975
|
+
specs: loadSpecs(root),
|
|
2976
|
+
syncLayout,
|
|
2977
|
+
writes: new Map(),
|
|
2978
|
+
};
|
|
2979
|
+
project.context = {
|
|
2980
|
+
customComponents: blocks.components,
|
|
2981
|
+
glossary: loadGlossary(options.glossary),
|
|
2982
|
+
knownUrls: syncLayout ? project.knownUrls : null,
|
|
2983
|
+
snippets: blocks.snippets,
|
|
2984
|
+
variables: new Set(),
|
|
2985
|
+
};
|
|
2986
|
+
project.context.rewrite = createLinkRewriter({
|
|
2987
|
+
knownUrls: project.context.knownUrls,
|
|
2988
|
+
sites: options.sites,
|
|
2989
|
+
});
|
|
2990
|
+
return project;
|
|
2991
|
+
};
|
|
2992
|
+
|
|
2993
|
+
const addRedirect = (project, from, to, status) => {
|
|
2994
|
+
if (from !== to && !project.redirects.has(from)) {
|
|
2995
|
+
project.redirects.set(from, status ? { from, status, to } : { from, to });
|
|
2996
|
+
}
|
|
2997
|
+
};
|
|
2998
|
+
|
|
2999
|
+
const setKind = (project, url, kind) => {
|
|
3000
|
+
const entry = project.inventory.urls.find(
|
|
3001
|
+
(candidate) => candidate.url === url
|
|
3002
|
+
);
|
|
3003
|
+
if (entry) {
|
|
3004
|
+
entry.kind = kind;
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
|
|
3008
|
+
const drop = (project, page, kind) => {
|
|
3009
|
+
project.deletes.add(page.file);
|
|
3010
|
+
project.handled.add(page.file);
|
|
3011
|
+
if (kind) {
|
|
3012
|
+
setKind(project, page.url, kind);
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
|
|
3016
|
+
const recordUrls = (project) => {
|
|
3017
|
+
for (const page of project.pages) {
|
|
3018
|
+
if (!project.syncLayout || page.parseError) {
|
|
3019
|
+
continue;
|
|
3020
|
+
}
|
|
3021
|
+
page.url = readmeUrl(page.section, page.parts, page.stem);
|
|
3022
|
+
if (!project.knownUrls.has(page.url)) {
|
|
3023
|
+
project.knownUrls.add(page.url);
|
|
3024
|
+
project.inventory.urls.push({
|
|
3025
|
+
hidden: page.data.hidden === true || undefined,
|
|
3026
|
+
kind: isEndpoint(page) ? "endpoint" : "page",
|
|
3027
|
+
url: page.url,
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
|
|
3033
|
+
// --- Project: the API reference ---------------------------------------------------
|
|
3034
|
+
|
|
3035
|
+
const proseOf = (project, page, title) =>
|
|
3036
|
+
stripSpacers(
|
|
3037
|
+
convertBody(
|
|
3038
|
+
page.body,
|
|
3039
|
+
path.relative(project.root, page.file),
|
|
3040
|
+
project.context,
|
|
3041
|
+
title
|
|
3042
|
+
)
|
|
3043
|
+
);
|
|
3044
|
+
|
|
3045
|
+
const recordEndpoint = (project, page) => {
|
|
3046
|
+
const relative = path.relative(project.root, page.file);
|
|
3047
|
+
const api = recordOf(page.data.api);
|
|
3048
|
+
const specName = path.basename(String(api.file ?? ""));
|
|
3049
|
+
const operationId = textOf(api.operationId);
|
|
3050
|
+
const spec = project.specs.get(specName);
|
|
3051
|
+
const found = operationOf(spec, operationId);
|
|
3052
|
+
if (!spec) {
|
|
3053
|
+
note(
|
|
3054
|
+
relative,
|
|
3055
|
+
`endpoint page names ${specName}, which isn't in reference/`
|
|
3056
|
+
);
|
|
3057
|
+
} else if (spec.yaml) {
|
|
3058
|
+
note(
|
|
3059
|
+
relative,
|
|
3060
|
+
`${specName} is YAML: convert it to JSON so the redirect and order steps can read it`
|
|
3061
|
+
);
|
|
3062
|
+
} else if (!found) {
|
|
3063
|
+
note(relative, `operationId ${operationId} isn't in ${specName}`);
|
|
3064
|
+
}
|
|
3065
|
+
const order =
|
|
3066
|
+
readOrder(
|
|
3067
|
+
path.join(project.root, tagFolderOf(page) ?? "reference", "_order.yaml")
|
|
3068
|
+
) ?? [];
|
|
3069
|
+
const prose = proseOf(project, page);
|
|
3070
|
+
if (/<[A-Za-z]/u.test(prose)) {
|
|
3071
|
+
note(
|
|
3072
|
+
relative,
|
|
3073
|
+
"endpoint prose holds JSX or HTML, which the operation description escapes: rewrite it as Markdown in the overlay"
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
3076
|
+
if (page.data.hidden === true) {
|
|
3077
|
+
note(
|
|
3078
|
+
relative,
|
|
3079
|
+
"hidden endpoint: ReadMe hid this operation; remove it with an overlay (remove: true) if it should stay hidden"
|
|
3080
|
+
);
|
|
3081
|
+
}
|
|
3082
|
+
if (!project.inventory.endpoints.some((entry) => entry.oldUrl === page.url)) {
|
|
3083
|
+
project.inventory.endpoints.push({
|
|
3084
|
+
category: page.parts[0] ?? null,
|
|
3085
|
+
folder: page.parts.at(-1) ?? null,
|
|
3086
|
+
method: found?.method ?? null,
|
|
3087
|
+
oldUrl: page.url,
|
|
3088
|
+
operationId,
|
|
3089
|
+
path: found?.path ?? null,
|
|
3090
|
+
position: order.indexOf(page.stem),
|
|
3091
|
+
prose,
|
|
3092
|
+
spec: specName,
|
|
3093
|
+
tag: found?.tag ?? null,
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
drop(project, page);
|
|
3097
|
+
count("endpoint page deleted (generated from the spec)");
|
|
3098
|
+
};
|
|
3099
|
+
|
|
3100
|
+
const recordTagPage = (project, page) => {
|
|
3101
|
+
const folder = tagFolderOf(page);
|
|
3102
|
+
const prose = proseOf(project, page, page.data.title);
|
|
3103
|
+
const order = readOrder(path.join(project.root, folder, "_order.yaml")) ?? [];
|
|
3104
|
+
const categoryOrder =
|
|
3105
|
+
readOrder(path.join(project.root, path.dirname(folder), "_order.yaml")) ??
|
|
3106
|
+
[];
|
|
3107
|
+
if (
|
|
3108
|
+
!project.inventory.tagFolders.some((entry) => entry.oldUrl === page.url)
|
|
3109
|
+
) {
|
|
3110
|
+
project.inventory.tagFolders.push({
|
|
3111
|
+
category: page.parts.length > 1 ? page.parts[0] : null,
|
|
3112
|
+
endpoints: order,
|
|
3113
|
+
oldUrl: page.url,
|
|
3114
|
+
position: categoryOrder.indexOf(page.parts.at(-1)),
|
|
3115
|
+
prose,
|
|
3116
|
+
title: textOf(page.data.title) ?? null,
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
drop(project, page, "tag");
|
|
3120
|
+
count("tag page deleted");
|
|
3121
|
+
};
|
|
3122
|
+
|
|
3123
|
+
const recordConfigPage = (project, page) => {
|
|
3124
|
+
const relative = path.relative(project.root, page.file);
|
|
3125
|
+
const kind = page.data.api_config;
|
|
3126
|
+
if (proseOf(project, page, page.data.title) === "") {
|
|
3127
|
+
drop(project, page, "config");
|
|
3128
|
+
todo(
|
|
3129
|
+
`${page.url} (ReadMe's ${kind} page) had no prose and was deleted: redirect it to the closest page`
|
|
3130
|
+
);
|
|
3131
|
+
count("API config page deleted (empty)");
|
|
3132
|
+
} else {
|
|
3133
|
+
page.moveTo = path.join(project.root, "reference", `${page.stem}.mdx`);
|
|
3134
|
+
note(
|
|
3135
|
+
relative,
|
|
3136
|
+
`ReadMe's ${kind} page: kept as a plain page; its personalized API key widget has no equivalent`
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
};
|
|
3140
|
+
|
|
3141
|
+
const classifyReference = (project) => {
|
|
3142
|
+
const reference = project.pages.filter(
|
|
3143
|
+
(page) => page.dir === "reference" && !page.parseError
|
|
3144
|
+
);
|
|
3145
|
+
if (!project.syncLayout || reference.length === 0) {
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
const order = readOrder(path.join(project.root, "reference", "_order.yaml"));
|
|
3149
|
+
if (order && project.inventory.referenceOrder.length === 0) {
|
|
3150
|
+
project.inventory.referenceOrder = order;
|
|
3151
|
+
}
|
|
3152
|
+
const endpointFolders = new Set(
|
|
3153
|
+
reference.filter(isEndpoint).map(tagFolderOf)
|
|
3154
|
+
);
|
|
3155
|
+
for (const page of reference.filter(isEndpoint)) {
|
|
3156
|
+
recordEndpoint(project, page);
|
|
3157
|
+
}
|
|
3158
|
+
for (const page of reference) {
|
|
3159
|
+
if (page.stem === "index" && endpointFolders.has(tagFolderOf(page))) {
|
|
3160
|
+
recordTagPage(project, page);
|
|
3161
|
+
} else if (page.data.api_config && !project.handled.has(page.file)) {
|
|
3162
|
+
recordConfigPage(project, page);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
};
|
|
3166
|
+
|
|
3167
|
+
// --- Project: empty parents and section roots ------------------------------------
|
|
3168
|
+
|
|
3169
|
+
const firstPageIn = (project, folder) => {
|
|
3170
|
+
const order = readOrder(path.join(folder, "_order.yaml")) ?? [];
|
|
3171
|
+
const live = project.pages.filter((page) => !project.handled.has(page.file));
|
|
3172
|
+
for (const entry of order) {
|
|
3173
|
+
const page = live.find(
|
|
3174
|
+
(candidate) =>
|
|
3175
|
+
path.dirname(candidate.file) === folder && candidate.stem === entry
|
|
3176
|
+
);
|
|
3177
|
+
if (page) {
|
|
3178
|
+
return page.url;
|
|
3179
|
+
}
|
|
3180
|
+
const sub = path.join(folder, entry);
|
|
3181
|
+
if (existsSync(sub)) {
|
|
3182
|
+
const index = live.find(
|
|
3183
|
+
(candidate) =>
|
|
3184
|
+
path.dirname(candidate.file) === sub &&
|
|
3185
|
+
candidate.stem === "index" &&
|
|
3186
|
+
candidate.body.trim() !== ""
|
|
3187
|
+
);
|
|
3188
|
+
return index?.url ?? firstPageIn(project, sub);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
};
|
|
3192
|
+
|
|
3193
|
+
const isEmptyParent = (project, page) => {
|
|
3194
|
+
if (
|
|
3195
|
+
project.handled.has(page.file) ||
|
|
3196
|
+
page.stem !== "index" ||
|
|
3197
|
+
page.parseError ||
|
|
3198
|
+
page.section === "changelog" ||
|
|
3199
|
+
page.body.trim() !== "" ||
|
|
3200
|
+
page.parts.length === 0 ||
|
|
3201
|
+
isGroup(page.parts.at(-1))
|
|
3202
|
+
) {
|
|
3203
|
+
return false;
|
|
3204
|
+
}
|
|
3205
|
+
const categorized = page.section === "docs" || page.section === "reference";
|
|
3206
|
+
const folder = path.dirname(page.file);
|
|
3207
|
+
return (
|
|
3208
|
+
page.parts.length > (categorized ? 1 : 0) &&
|
|
3209
|
+
project.pages.some(
|
|
3210
|
+
(other) => other !== page && other.file.startsWith(folder + path.sep)
|
|
3211
|
+
)
|
|
3212
|
+
);
|
|
3213
|
+
};
|
|
3214
|
+
|
|
3215
|
+
// ReadMe answers an empty parent page's URL with a 302 to its first child.
|
|
3216
|
+
const removeEmptyParents = (project) => {
|
|
3217
|
+
if (!project.syncLayout) {
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
for (const page of project.pages.filter((entry) =>
|
|
3221
|
+
isEmptyParent(project, entry)
|
|
3222
|
+
)) {
|
|
3223
|
+
const target = firstPageIn(project, path.dirname(page.file));
|
|
3224
|
+
drop(project, page, "parent");
|
|
3225
|
+
page.emptyParentTitle = textOf(page.data.title);
|
|
3226
|
+
if (target) {
|
|
3227
|
+
addRedirect(project, page.url, target, 302);
|
|
3228
|
+
} else {
|
|
3229
|
+
todo(
|
|
3230
|
+
`${page.url}: empty parent page deleted, but its first child couldn't be found; add a redirect`
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
count("empty parent page deleted (302 to its first child)");
|
|
3234
|
+
}
|
|
3235
|
+
};
|
|
3236
|
+
|
|
3237
|
+
// ReadMe answers /docs and /reference with a 301 to the section's first page;
|
|
3238
|
+
// these are 302s, since that first page can change.
|
|
3239
|
+
const addSectionRedirects = (project) => {
|
|
3240
|
+
if (!project.syncLayout) {
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
const docs = path.join(project.root, "docs");
|
|
3244
|
+
const docsTarget = existsSync(docs) ? firstPageIn(project, docs) : undefined;
|
|
3245
|
+
if (docsTarget) {
|
|
3246
|
+
addRedirect(project, "/docs", docsTarget, 302);
|
|
3247
|
+
}
|
|
3248
|
+
const [firstCategory] = project.inventory.referenceOrder;
|
|
3249
|
+
const referenceTarget = firstCategory
|
|
3250
|
+
? firstPageIn(project, path.join(project.root, "reference", firstCategory))
|
|
3251
|
+
: undefined;
|
|
3252
|
+
if (referenceTarget) {
|
|
3253
|
+
addRedirect(project, "/reference", referenceTarget, 302);
|
|
3254
|
+
}
|
|
3255
|
+
if (
|
|
3256
|
+
existsSync(path.join(project.root, "changelogs")) ||
|
|
3257
|
+
existsSync(path.join(project.root, "changelog"))
|
|
3258
|
+
) {
|
|
3259
|
+
addRedirect(project, "/changelog.rss", "/changelog/rss.xml");
|
|
3260
|
+
}
|
|
3261
|
+
todo(
|
|
3262
|
+
"/ : ReadMe sent it to the landing page or the first section; add an index.mdx or a redirect"
|
|
3263
|
+
);
|
|
3264
|
+
};
|
|
3265
|
+
|
|
3266
|
+
// --- Project: pages -----------------------------------------------------------------
|
|
3267
|
+
|
|
3268
|
+
const destination = (project, absolute) => {
|
|
3269
|
+
const [top, ...rest] = path.relative(project.root, absolute).split(path.sep);
|
|
3270
|
+
const section = project.syncLayout ? (SECTIONS[top] ?? top) : top;
|
|
3271
|
+
const categorized = section === "docs" || section === "reference";
|
|
3272
|
+
if (
|
|
3273
|
+
project.syncLayout &&
|
|
3274
|
+
categorized &&
|
|
3275
|
+
rest.length > 1 &&
|
|
3276
|
+
!isGroup(rest[0])
|
|
3277
|
+
) {
|
|
3278
|
+
rest[0] = `(${rest[0]})`;
|
|
3279
|
+
}
|
|
3280
|
+
return path.join(project.root, section, ...rest);
|
|
3281
|
+
};
|
|
3282
|
+
|
|
3283
|
+
// Pin `slug` when Blume's own route would differ from ReadMe's URL: groups add
|
|
3284
|
+
// nothing, `index` is its folder, and a leading digit can be read as an
|
|
3285
|
+
// ordering prefix (`1-setup`). Blume keeps dates (`12-05-2022`, `2024-01`)
|
|
3286
|
+
// and versions whole, so pinning every digit-led segment is only cautious.
|
|
3287
|
+
const slugPin = (project, page) => {
|
|
3288
|
+
if (!project.syncLayout || !page.url || page.moveTo) {
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const categorized = page.section === "docs" || page.section === "reference";
|
|
3292
|
+
const nested = categorized ? page.parts.slice(1) : page.parts;
|
|
3293
|
+
const segments = [
|
|
3294
|
+
...nested.filter((part) => !isGroup(part)),
|
|
3295
|
+
page.stem === "index" ? null : page.stem,
|
|
3296
|
+
].filter(Boolean);
|
|
3297
|
+
const route = `/${[page.section, ...segments].join("/")}`;
|
|
3298
|
+
const digitLed = segments.some((segment) => /^\d/u.test(segment));
|
|
3299
|
+
return route !== page.url || digitLed ? page.url.slice(1) : undefined;
|
|
3300
|
+
};
|
|
3301
|
+
|
|
3302
|
+
const pageOutput = (project, page, front, relative) => {
|
|
3303
|
+
const body = convertBody(page.body, relative, project.context, front.title);
|
|
3304
|
+
const header =
|
|
3305
|
+
Object.keys(front).length > 0 ? `---\n${emitYaml(front)}\n---\n\n` : "";
|
|
3306
|
+
const output = `${header}${body.trimEnd()}\n`;
|
|
3307
|
+
return page.eol === "\r\n" ? output.replaceAll("\n", "\r\n") : output;
|
|
3308
|
+
};
|
|
3309
|
+
|
|
3310
|
+
const convertPage = (project, page) => {
|
|
3311
|
+
const relative = path.relative(project.root, page.file);
|
|
3312
|
+
if (page.file.endsWith(".html")) {
|
|
3313
|
+
note(
|
|
3314
|
+
relative,
|
|
3315
|
+
"HTML custom page: rebuild it as page/<slug>.mdx (HTML as JSX) or pages/page/<slug>.astro"
|
|
3316
|
+
);
|
|
3317
|
+
todo(`${relative}: HTML custom page left for you`);
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
if (page.parseError) {
|
|
3321
|
+
note(
|
|
3322
|
+
relative,
|
|
3323
|
+
`frontmatter not converted (${page.parseError}); the file was left as is`
|
|
3324
|
+
);
|
|
3325
|
+
todo(`${relative}: convert by hand (frontmatter didn't parse)`);
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
const info = {
|
|
3329
|
+
file: relative,
|
|
3330
|
+
legacyRdme:
|
|
3331
|
+
"parentDoc" in page.data ||
|
|
3332
|
+
/^[0-9a-f]{24}$/u.test(textOf(page.data.category) ?? ""),
|
|
3333
|
+
notes: [],
|
|
3334
|
+
section: page.section,
|
|
3335
|
+
slug: slugPin(project, page),
|
|
3336
|
+
};
|
|
3337
|
+
const front = mapFrontmatter(page.data, info, project.context);
|
|
3338
|
+
if (info.linkUrl && page.url) {
|
|
3339
|
+
addRedirect(project, page.url, info.linkUrl, 302);
|
|
3340
|
+
drop(project, page, "link");
|
|
3341
|
+
todo(
|
|
3342
|
+
`${page.url} was a link page: it now redirects to ${info.linkUrl}; add it to navigation.featured if it should stay in the sidebar`
|
|
3343
|
+
);
|
|
3344
|
+
count("link page → redirect");
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
if (info.linkUrl) {
|
|
3348
|
+
note(
|
|
3349
|
+
relative,
|
|
3350
|
+
`link page to ${info.linkUrl}: delete it and redirect its URL there`
|
|
3351
|
+
);
|
|
3352
|
+
}
|
|
3353
|
+
const target =
|
|
3354
|
+
page.moveTo ?? destination(project, page.file).replace(/\.md$/u, ".mdx");
|
|
3355
|
+
if (target !== page.file && existsSync(target)) {
|
|
3356
|
+
note(
|
|
3357
|
+
relative,
|
|
3358
|
+
`${path.relative(project.root, target)} already exists; left both in place`
|
|
3359
|
+
);
|
|
3360
|
+
return;
|
|
3361
|
+
}
|
|
3362
|
+
const output = pageOutput(project, page, front, relative);
|
|
3363
|
+
if (target !== page.file) {
|
|
3364
|
+
project.deletes.add(page.file);
|
|
3365
|
+
count(page.file.endsWith(".md") ? ".md → .mdx" : "file moved");
|
|
3366
|
+
}
|
|
3367
|
+
if (
|
|
3368
|
+
output !== page.source.replaceAll("\n", page.eol) ||
|
|
3369
|
+
target !== page.file
|
|
3370
|
+
) {
|
|
3371
|
+
project.writes.set(target, output);
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
|
|
3375
|
+
const convertSnippetFiles = (project) => {
|
|
3376
|
+
for (const file of walk(path.join(project.root, "custom_blocks"))) {
|
|
3377
|
+
if (!file.endsWith(".md")) {
|
|
3378
|
+
continue;
|
|
3379
|
+
}
|
|
3380
|
+
const name = path.basename(file, ".md");
|
|
3381
|
+
const target = path.join(
|
|
3382
|
+
project.root,
|
|
3383
|
+
"_snippets",
|
|
3384
|
+
`${project.context.snippets.get(name)}.mdx`
|
|
3385
|
+
);
|
|
3386
|
+
if (existsSync(target)) {
|
|
3387
|
+
continue;
|
|
3388
|
+
}
|
|
3389
|
+
const { body } = splitFrontmatter(
|
|
3390
|
+
readFileSync(file, "utf-8").replaceAll("\r\n", "\n")
|
|
3391
|
+
);
|
|
3392
|
+
const converted = convertBody(
|
|
3393
|
+
body,
|
|
3394
|
+
path.relative(project.root, file),
|
|
3395
|
+
project.context
|
|
3396
|
+
);
|
|
3397
|
+
project.writes.set(target, `${converted.trimEnd()}\n`);
|
|
3398
|
+
count("reusable content → _snippets/");
|
|
3399
|
+
}
|
|
3400
|
+
for (const name of [...project.context.customComponents].toSorted()) {
|
|
3401
|
+
todo(
|
|
3402
|
+
`custom_blocks/${name}.mdx is a React component: move it to islands/${name}.tsx (export default) or replace its uses`
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
3405
|
+
};
|
|
3406
|
+
|
|
3407
|
+
// --- Project: _order.yaml → meta.ts -------------------------------------------------
|
|
3408
|
+
|
|
3409
|
+
const orderFiles = (project) =>
|
|
3410
|
+
project.sectionDirs.flatMap((dir) =>
|
|
3411
|
+
walk(path.join(project.root, dir))
|
|
3412
|
+
.filter((file) => path.basename(file) === "_order.yaml")
|
|
3413
|
+
.map((file) => ({ dir, file }))
|
|
3414
|
+
);
|
|
3415
|
+
|
|
3416
|
+
const metaFields = (project, dir, folder, remaining, order) => {
|
|
3417
|
+
const fields = {};
|
|
3418
|
+
const section = SECTIONS[dir];
|
|
3419
|
+
const parts = path
|
|
3420
|
+
.relative(path.join(project.root, dir), folder)
|
|
3421
|
+
.split(path.sep)
|
|
3422
|
+
.filter(Boolean);
|
|
3423
|
+
const isCategory =
|
|
3424
|
+
(section === "docs" || section === "reference") && parts.length === 1;
|
|
3425
|
+
const index = project.pages.find(
|
|
3426
|
+
(page) => path.dirname(page.file) === folder && page.stem === "index"
|
|
3427
|
+
);
|
|
3428
|
+
if (!isCategory && parts.length > 0 && index) {
|
|
3429
|
+
const title = index.emptyParentTitle ?? textOf(index.data.title);
|
|
3430
|
+
if (title) {
|
|
3431
|
+
fields.title = title;
|
|
3432
|
+
}
|
|
3433
|
+
fields.display = "group";
|
|
3434
|
+
}
|
|
3435
|
+
// Blume labels a group folder by its name, capitalized and with `-` and `_`
|
|
3436
|
+
// read as spaces: keep the category's own spelling where that differs.
|
|
3437
|
+
const [category] = parts;
|
|
3438
|
+
if (isCategory && /^\p{Ll}|[-_]|^\d+\./u.test(category)) {
|
|
3439
|
+
fields.title = category;
|
|
3440
|
+
}
|
|
3441
|
+
if (section === "reference" && isCategory) {
|
|
3442
|
+
const position = project.inventory.referenceOrder.indexOf(parts[0]);
|
|
3443
|
+
if (position !== -1) {
|
|
3444
|
+
fields.order = position;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
fields.pages = order.filter((entry) =>
|
|
3448
|
+
remaining.some((page) => {
|
|
3449
|
+
const [first] = path.relative(folder, page.file).split(path.sep);
|
|
3450
|
+
return first === entry || first.replace(/\.mdx?$/u, "") === entry;
|
|
3451
|
+
})
|
|
3452
|
+
);
|
|
3453
|
+
return fields;
|
|
3454
|
+
};
|
|
3455
|
+
|
|
3456
|
+
const writeMeta = (project, { dir, file }) => {
|
|
3457
|
+
if (project.deletes.has(file)) {
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
const folder = path.dirname(file);
|
|
3461
|
+
const section = SECTIONS[dir];
|
|
3462
|
+
let order;
|
|
3463
|
+
try {
|
|
3464
|
+
order = readOrder(file);
|
|
3465
|
+
} catch (error) {
|
|
3466
|
+
todo(
|
|
3467
|
+
`${path.relative(project.root, file)} didn't parse (${error.message}); write its meta.ts by hand`
|
|
3468
|
+
);
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
project.deletes.add(file);
|
|
3472
|
+
if (
|
|
3473
|
+
section === "page" ||
|
|
3474
|
+
(section === "reference" && folder === path.join(project.root, dir))
|
|
3475
|
+
) {
|
|
3476
|
+
count("_order.yaml with no sidebar to order, deleted");
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
const remaining = project.pages.filter(
|
|
3480
|
+
(page) =>
|
|
3481
|
+
!project.handled.has(page.file) && page.file.startsWith(folder + path.sep)
|
|
3482
|
+
);
|
|
3483
|
+
if (remaining.length === 0) {
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
const target = path.join(
|
|
3487
|
+
destination(project, path.join(folder, "x")).replace(/[/\\]x$/u, ""),
|
|
3488
|
+
"meta.ts"
|
|
3489
|
+
);
|
|
3490
|
+
if (existsSync(target)) {
|
|
3491
|
+
note(
|
|
3492
|
+
path.relative(project.root, file),
|
|
3493
|
+
`${path.relative(project.root, target)} already exists; merge the order by hand`
|
|
3494
|
+
);
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
project.writes.set(
|
|
3498
|
+
target,
|
|
3499
|
+
metaModule(metaFields(project, dir, folder, remaining, order))
|
|
3500
|
+
);
|
|
3501
|
+
count("_order.yaml → meta.ts");
|
|
3502
|
+
};
|
|
3503
|
+
|
|
3504
|
+
// --- Project: overlays ----------------------------------------------------------------
|
|
3505
|
+
|
|
3506
|
+
const operationActions = (project, name, spec, describe) => {
|
|
3507
|
+
const actions = [];
|
|
3508
|
+
for (const [route, item] of Object.entries(recordOf(spec.data.paths))) {
|
|
3509
|
+
for (const [method, raw] of Object.entries(recordOf(item))) {
|
|
3510
|
+
const operation = recordOf(raw);
|
|
3511
|
+
if (!("responses" in operation || "operationId" in operation)) {
|
|
3512
|
+
continue;
|
|
3513
|
+
}
|
|
3514
|
+
const original = textOf(operation.description) ?? "";
|
|
3515
|
+
const converted = original === "" ? "" : describe(original);
|
|
3516
|
+
const prose = project.inventory.endpoints.find(
|
|
3517
|
+
(entry) =>
|
|
3518
|
+
entry.spec === name &&
|
|
3519
|
+
entry.operationId === operation.operationId &&
|
|
3520
|
+
entry.prose
|
|
3521
|
+
)?.prose;
|
|
3522
|
+
if (converted === original && !prose) {
|
|
3523
|
+
continue;
|
|
3524
|
+
}
|
|
3525
|
+
const reasons = [
|
|
3526
|
+
converted !== original && "ReadMe callouts as directives",
|
|
3527
|
+
prose && "the ReadMe endpoint page's prose",
|
|
3528
|
+
].filter(Boolean);
|
|
3529
|
+
actions.push({
|
|
3530
|
+
description: `${reasons.join("; ")} (${operation.operationId ?? `${method.toUpperCase()} ${route}`})`,
|
|
3531
|
+
target: `$.paths['${route.replaceAll("'", String.raw`\'`)}'].${method}`,
|
|
3532
|
+
update: {
|
|
3533
|
+
description: [converted, prose].filter(Boolean).join("\n\n"),
|
|
3534
|
+
},
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
return actions;
|
|
3539
|
+
};
|
|
3540
|
+
|
|
3541
|
+
const tagActions = (project, name, spec, describe) => {
|
|
3542
|
+
const actions = [];
|
|
3543
|
+
for (const [position, raw] of listOf(spec.data.tags).entries()) {
|
|
3544
|
+
const tag = recordOf(raw);
|
|
3545
|
+
const original = textOf(tag.description) ?? "";
|
|
3546
|
+
const converted = original === "" ? "" : describe(original);
|
|
3547
|
+
const prose = project.inventory.tagFolders.find(
|
|
3548
|
+
(entry) =>
|
|
3549
|
+
entry.prose &&
|
|
3550
|
+
project.inventory.endpoints.some(
|
|
3551
|
+
(endpoint) =>
|
|
3552
|
+
endpoint.spec === name &&
|
|
3553
|
+
endpoint.tag === tag.name &&
|
|
3554
|
+
endpoint.folder === entry.oldUrl.split("/").pop()
|
|
3555
|
+
)
|
|
3556
|
+
)?.prose;
|
|
3557
|
+
if (converted !== original || prose) {
|
|
3558
|
+
actions.push({
|
|
3559
|
+
description: `tag ${tag.name}`,
|
|
3560
|
+
target: `$.tags[${position}]`,
|
|
3561
|
+
update: {
|
|
3562
|
+
description: [converted, prose].filter(Boolean).join("\n\n"),
|
|
3563
|
+
},
|
|
3564
|
+
});
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
return actions;
|
|
3568
|
+
};
|
|
3569
|
+
|
|
3570
|
+
const writeOverlays = (project) => {
|
|
3571
|
+
for (const [name, spec] of [...project.specs.entries()].toSorted()) {
|
|
3572
|
+
if (spec.yaml) {
|
|
3573
|
+
todo(
|
|
3574
|
+
`reference/${name} is YAML: the overlay, redirect, and order steps read JSON only; convert it (npx @redocly/cli bundle reference/${name} --output reference/${name.replace(/\.ya?ml$/iu, ".json")}) and rerun`
|
|
3575
|
+
);
|
|
3576
|
+
}
|
|
3577
|
+
if (!spec.data) {
|
|
3578
|
+
continue;
|
|
3579
|
+
}
|
|
3580
|
+
const specFile = `reference/${name}`;
|
|
3581
|
+
const describe = (value) =>
|
|
3582
|
+
convertDescription(value, specFile, project.context);
|
|
3583
|
+
const actions = [
|
|
3584
|
+
...operationActions(project, name, spec, describe),
|
|
3585
|
+
...tagActions(project, name, spec, describe),
|
|
3586
|
+
];
|
|
3587
|
+
const info = textOf(recordOf(spec.data.info).description);
|
|
3588
|
+
if (info !== undefined && describe(info) !== info) {
|
|
3589
|
+
actions.push({
|
|
3590
|
+
description: "info",
|
|
3591
|
+
target: "$.info",
|
|
3592
|
+
update: { description: describe(info) },
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
if (actions.length === 0) {
|
|
3596
|
+
continue;
|
|
3597
|
+
}
|
|
3598
|
+
const overlay = `reference/overlays/${name.replace(/\.json$/iu, "")}.overlay.json`;
|
|
3599
|
+
project.writes.set(
|
|
3600
|
+
path.join(project.root, overlay),
|
|
3601
|
+
`${JSON.stringify({ actions, info: { title: `ReadMe migration (${name})`, version: "1.0.0" }, overlay: "1.0.0" }, null, 2)}\n`
|
|
3602
|
+
);
|
|
3603
|
+
count("overlay actions", actions.length);
|
|
3604
|
+
todo(`add the overlay to that spec's source: overlays: ["./${overlay}"]`);
|
|
3605
|
+
}
|
|
3606
|
+
};
|
|
3607
|
+
|
|
3608
|
+
const reportProject = (project) => {
|
|
3609
|
+
const specsByCategory = new Map();
|
|
3610
|
+
for (const endpoint of project.inventory.endpoints) {
|
|
3611
|
+
const set = specsByCategory.get(endpoint.category) ?? new Set();
|
|
3612
|
+
set.add(endpoint.spec);
|
|
3613
|
+
specsByCategory.set(endpoint.category, set);
|
|
3614
|
+
}
|
|
3615
|
+
for (const [category, set] of specsByCategory) {
|
|
3616
|
+
if (set.size > 1) {
|
|
3617
|
+
todo(
|
|
3618
|
+
`reference category "${category}" held ${set.size} specs (${[...set].toSorted().join(", ")}): give each its own label or route; they can't share one`
|
|
3619
|
+
);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
if (project.context.variables.size > 0) {
|
|
3623
|
+
todo(
|
|
3624
|
+
`define these in blume.config.ts variables (defaults from the hub): ${[...project.context.variables].toSorted().join(", ")}`
|
|
3625
|
+
);
|
|
3626
|
+
}
|
|
3627
|
+
};
|
|
3628
|
+
|
|
3629
|
+
const sortedRedirects = (redirects) =>
|
|
3630
|
+
[...redirects.values()].toSorted((a, b) => a.from.localeCompare(b.from));
|
|
3631
|
+
|
|
3632
|
+
const convertProject = (root, options) => {
|
|
3633
|
+
const project = createProject(root, options);
|
|
3634
|
+
recordUrls(project);
|
|
3635
|
+
classifyReference(project);
|
|
3636
|
+
removeEmptyParents(project);
|
|
3637
|
+
for (const page of project.pages.filter(
|
|
3638
|
+
(entry) => !project.handled.has(entry.file)
|
|
3639
|
+
)) {
|
|
3640
|
+
convertPage(project, page);
|
|
3641
|
+
}
|
|
3642
|
+
convertSnippetFiles(project);
|
|
3643
|
+
if (project.syncLayout) {
|
|
3644
|
+
for (const entry of orderFiles(project)) {
|
|
3645
|
+
writeMeta(project, entry);
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
writeOverlays(project);
|
|
3649
|
+
addSectionRedirects(project);
|
|
3650
|
+
reportProject(project);
|
|
3651
|
+
if (project.syncLayout) {
|
|
3652
|
+
project.writes.set(
|
|
3653
|
+
path.join(root, INVENTORY),
|
|
3654
|
+
`${JSON.stringify(project.inventory, null, 2)}\n`
|
|
3655
|
+
);
|
|
3656
|
+
project.writes.set(
|
|
3657
|
+
path.join(root, REDIRECTS),
|
|
3658
|
+
`${JSON.stringify(sortedRedirects(project.redirects), null, 2)}\n`
|
|
3659
|
+
);
|
|
3660
|
+
} else {
|
|
3661
|
+
todo(
|
|
3662
|
+
"not ReadMe's Git sync layout (no _order.yaml): only page content was converted; rebuild the tree from frontmatter as the reference describes"
|
|
3663
|
+
);
|
|
3664
|
+
}
|
|
3665
|
+
return { deletes: project.deletes, writes: project.writes };
|
|
3666
|
+
};
|
|
3667
|
+
|
|
3668
|
+
// --- Pass 2: routes -------------------------------------------------------------------
|
|
3669
|
+
|
|
3670
|
+
const resolveRoute = (state, endpoint) => {
|
|
3671
|
+
const reference = state.bySpec.get(endpoint.spec);
|
|
3672
|
+
const byId =
|
|
3673
|
+
state.references[reference]?.operationIds?.[endpoint.operationId];
|
|
3674
|
+
if (byId) {
|
|
3675
|
+
return byId;
|
|
3676
|
+
}
|
|
3677
|
+
if (!(endpoint.method && endpoint.path)) {
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
const key = `${endpoint.method} ${endpoint.path}`;
|
|
3681
|
+
const candidates = Object.entries(state.references)
|
|
3682
|
+
.filter(([route]) => !reference || route === reference)
|
|
3683
|
+
.map(([, entry]) => entry.endpoints?.[key])
|
|
3684
|
+
.filter(Boolean);
|
|
3685
|
+
if (candidates.length > 1) {
|
|
3686
|
+
todo(
|
|
3687
|
+
`${endpoint.oldUrl}: ${key} is in several references; pass --spec to pick one`
|
|
3688
|
+
);
|
|
3689
|
+
}
|
|
3690
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
3691
|
+
};
|
|
3692
|
+
|
|
3693
|
+
const rankOf = (inventory, endpoint) => [
|
|
3694
|
+
Math.max(inventory.referenceOrder.indexOf(endpoint.category), 0),
|
|
3695
|
+
Math.max(
|
|
3696
|
+
inventory.tagFolders.find(
|
|
3697
|
+
(tag) => tag.oldUrl.split("/").pop() === endpoint.folder
|
|
3698
|
+
)?.position ?? 0,
|
|
3699
|
+
0
|
|
3700
|
+
),
|
|
3701
|
+
endpoint.position < 0 ? Number.MAX_SAFE_INTEGER : endpoint.position,
|
|
3702
|
+
];
|
|
3703
|
+
|
|
3704
|
+
const compareRanks = (inventory) => (a, b) => {
|
|
3705
|
+
const left = rankOf(inventory, a);
|
|
3706
|
+
const right = rankOf(inventory, b);
|
|
3707
|
+
const index = left.findIndex((value, position) => value !== right[position]);
|
|
3708
|
+
return index === -1 ? 0 : left[index] - right[index];
|
|
3709
|
+
};
|
|
3710
|
+
|
|
3711
|
+
const redirectEndpoints = (state) => {
|
|
3712
|
+
for (const endpoint of state.inventory.endpoints) {
|
|
3713
|
+
const route = resolveRoute(state, endpoint);
|
|
3714
|
+
if (!route) {
|
|
3715
|
+
todo(
|
|
3716
|
+
`${endpoint.oldUrl} (${endpoint.spec} ${endpoint.operationId}) has no generated page in the routes file`
|
|
3717
|
+
);
|
|
3718
|
+
continue;
|
|
3719
|
+
}
|
|
3720
|
+
endpoint.route = route;
|
|
3721
|
+
state.moved.set(endpoint.oldUrl, route);
|
|
3722
|
+
if (
|
|
3723
|
+
state.taken.has(endpoint.oldUrl) ||
|
|
3724
|
+
state.pageUrls.has(endpoint.oldUrl)
|
|
3725
|
+
) {
|
|
3726
|
+
todo(
|
|
3727
|
+
`${endpoint.oldUrl} is now ${state.taken.has(endpoint.oldUrl) ? "a reference overview or operation" : "a page"}: no redirect (it would hide that page)`
|
|
3728
|
+
);
|
|
3729
|
+
} else if (!state.redirects.has(endpoint.oldUrl)) {
|
|
3730
|
+
state.redirects.set(endpoint.oldUrl, {
|
|
3731
|
+
from: endpoint.oldUrl,
|
|
3732
|
+
to: route,
|
|
3733
|
+
});
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
};
|
|
3737
|
+
|
|
3738
|
+
const redirectTags = (state) => {
|
|
3739
|
+
const routed = state.inventory.endpoints
|
|
3740
|
+
.filter((endpoint) => endpoint.route)
|
|
3741
|
+
.toSorted(state.compare);
|
|
3742
|
+
for (const tag of state.inventory.tagFolders) {
|
|
3743
|
+
const folder = tag.oldUrl.split("/").pop();
|
|
3744
|
+
const first = routed.find((endpoint) => endpoint.folder === folder);
|
|
3745
|
+
if (!first) {
|
|
3746
|
+
continue;
|
|
3747
|
+
}
|
|
3748
|
+
state.moved.set(tag.oldUrl, first.route);
|
|
3749
|
+
const free = !(
|
|
3750
|
+
state.taken.has(tag.oldUrl) || state.pageUrls.has(tag.oldUrl)
|
|
3751
|
+
);
|
|
3752
|
+
if (free && !state.redirects.has(tag.oldUrl)) {
|
|
3753
|
+
state.redirects.set(tag.oldUrl, {
|
|
3754
|
+
from: tag.oldUrl,
|
|
3755
|
+
status: 302,
|
|
3756
|
+
to: first.route,
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
const [first] = routed;
|
|
3761
|
+
if (
|
|
3762
|
+
!state.redirects.has("/reference") &&
|
|
3763
|
+
!state.taken.has("/reference") &&
|
|
3764
|
+
first &&
|
|
3765
|
+
state.inventory.referenceOrder[0] === first.category
|
|
3766
|
+
) {
|
|
3767
|
+
state.redirects.set("/reference", {
|
|
3768
|
+
from: "/reference",
|
|
3769
|
+
status: 302,
|
|
3770
|
+
to: first.route,
|
|
3771
|
+
});
|
|
3772
|
+
}
|
|
3773
|
+
};
|
|
3774
|
+
|
|
3775
|
+
// A redirect from a URL that is now a reference overview or operation page
|
|
3776
|
+
// would hide that page (pass 1 sends /reference to the first page, but a
|
|
3777
|
+
// single spec's overview lives there).
|
|
3778
|
+
const dropShadowingRedirects = (state) => {
|
|
3779
|
+
for (const from of state.redirects.keys()) {
|
|
3780
|
+
if (state.taken.has(from)) {
|
|
3781
|
+
state.redirects.delete(from);
|
|
3782
|
+
todo(
|
|
3783
|
+
`${from} is now a reference page: its redirect was removed (it would hide the page)`
|
|
3784
|
+
);
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
};
|
|
3788
|
+
|
|
3789
|
+
const groupByDirectory = (endpoints) => {
|
|
3790
|
+
const byDirectory = new Map();
|
|
3791
|
+
for (const endpoint of endpoints) {
|
|
3792
|
+
const directory = endpoint.route.split("/").slice(0, -1).join("/");
|
|
3793
|
+
byDirectory.set(directory, [
|
|
3794
|
+
...(byDirectory.get(directory) ?? []),
|
|
3795
|
+
endpoint,
|
|
3796
|
+
]);
|
|
3797
|
+
}
|
|
3798
|
+
const bySource = new Map();
|
|
3799
|
+
for (const [directory, list] of byDirectory) {
|
|
3800
|
+
const source = directory.split("/").slice(0, -1).join("/");
|
|
3801
|
+
bySource.set(source, [...(bySource.get(source) ?? []), [directory, list]]);
|
|
3802
|
+
}
|
|
3803
|
+
return bySource;
|
|
3804
|
+
};
|
|
3805
|
+
|
|
3806
|
+
// Tag-level meta.ts: ReadMe's endpoint order inside each tag, where it can
|
|
3807
|
+
// differ from the spec's order, which the generated sidebar follows.
|
|
3808
|
+
const writeTagMetas = (state) => {
|
|
3809
|
+
const routed = state.inventory.endpoints.filter((endpoint) => endpoint.route);
|
|
3810
|
+
for (const [source, directories] of sortedEntries(groupByDirectory(routed))) {
|
|
3811
|
+
const sorted = directories
|
|
3812
|
+
.map(([directory, list]) => [directory, list.toSorted(state.compare)])
|
|
3813
|
+
.toSorted((a, b) => state.compare(a[1][0], b[1][0]));
|
|
3814
|
+
for (const [position, [directory, endpoints]] of sorted.entries()) {
|
|
3815
|
+
const [{ tag }] = endpoints;
|
|
3816
|
+
if (!tag) {
|
|
3817
|
+
todo(
|
|
3818
|
+
`${directory.slice(1)}: tag name unknown (YAML spec?); write its meta.ts by hand`
|
|
3819
|
+
);
|
|
3820
|
+
continue;
|
|
3821
|
+
}
|
|
3822
|
+
const target = path.join(state.root, directory.slice(1), "meta.ts");
|
|
3823
|
+
const content = metaModule({
|
|
3824
|
+
order: position,
|
|
3825
|
+
pages: endpoints.map((endpoint) => endpoint.route.split("/").pop()),
|
|
3826
|
+
title: tag,
|
|
3827
|
+
});
|
|
3828
|
+
if (existsSync(target) && readFileSync(target, "utf-8") !== content) {
|
|
3829
|
+
note(
|
|
3830
|
+
path.relative(state.root, target),
|
|
3831
|
+
"exists with other content; left as is"
|
|
3832
|
+
);
|
|
3833
|
+
} else {
|
|
3834
|
+
state.writes.set(target, content);
|
|
3835
|
+
count("tag meta.ts written (ReadMe's endpoint order)");
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
const [[, [{ category }]]] = sorted;
|
|
3839
|
+
const position = state.inventory.referenceOrder.indexOf(category);
|
|
3840
|
+
todo(
|
|
3841
|
+
`${source.slice(1)}/meta.ts: defineMeta({ order: ${position === -1 ? "<n>" : position} }) puts it where ReadMe listed "${category}" (the source's label stays its title)`
|
|
3842
|
+
);
|
|
3843
|
+
}
|
|
3844
|
+
};
|
|
3845
|
+
|
|
3846
|
+
const rewriteMovedLinks = (state) => {
|
|
3847
|
+
if (state.moved.size === 0) {
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
const urls = [...state.moved.keys()]
|
|
3851
|
+
.toSorted((a, b) => b.length - a.length)
|
|
3852
|
+
.map(escapeRegExp);
|
|
3853
|
+
const pattern = new RegExp(
|
|
3854
|
+
String.raw`(?<=[\s("'\x60])(?<url>${urls.join("|")})(?<hash>#[^\s)"'\x60]*)?(?=[\s)"'\x60]|$)`,
|
|
3855
|
+
"gmu"
|
|
3856
|
+
);
|
|
3857
|
+
for (const dir of [
|
|
3858
|
+
"docs",
|
|
3859
|
+
"reference",
|
|
3860
|
+
"recipes",
|
|
3861
|
+
"page",
|
|
3862
|
+
"changelog",
|
|
3863
|
+
"_snippets",
|
|
3864
|
+
]) {
|
|
3865
|
+
for (const file of walk(path.join(state.root, dir)).filter((entry) =>
|
|
3866
|
+
entry.endsWith(".mdx")
|
|
3867
|
+
)) {
|
|
3868
|
+
const before = readFileSync(file, "utf-8");
|
|
3869
|
+
// Fenced code samples keep the URLs they show.
|
|
3870
|
+
const after = mapProse(before, (text) =>
|
|
3871
|
+
text.replaceAll(
|
|
3872
|
+
pattern,
|
|
3873
|
+
byGroups(({ hash, url }) => {
|
|
3874
|
+
count("link to an old endpoint or tag URL rewritten");
|
|
3875
|
+
if (hash) {
|
|
3876
|
+
note(
|
|
3877
|
+
path.relative(state.root, file),
|
|
3878
|
+
`anchor ${hash} dropped from a link to ${url}`
|
|
3879
|
+
);
|
|
3880
|
+
}
|
|
3881
|
+
return state.moved.get(url);
|
|
3882
|
+
})
|
|
3883
|
+
)
|
|
3884
|
+
);
|
|
3885
|
+
if (after !== before) {
|
|
3886
|
+
state.writes.set(file, after);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
};
|
|
3891
|
+
|
|
3892
|
+
const applyRoutes = (root, options) => {
|
|
3893
|
+
const inventory = readJson(path.join(root, INVENTORY), null);
|
|
3894
|
+
if (!inventory) {
|
|
3895
|
+
throw new Error(`${INVENTORY} not found: run the convert pass first`);
|
|
3896
|
+
}
|
|
3897
|
+
const routes = readJson(options.routes, null);
|
|
3898
|
+
if (!routes?.references) {
|
|
3899
|
+
throw new Error(`${options.routes} isn't operation-routes.mjs output`);
|
|
3900
|
+
}
|
|
3901
|
+
const references = recordOf(routes.references);
|
|
3902
|
+
const state = {
|
|
3903
|
+
bySpec: new Map(
|
|
3904
|
+
options.specs.map(({ file, route }) => [path.basename(file), route])
|
|
3905
|
+
),
|
|
3906
|
+
compare: compareRanks(inventory),
|
|
3907
|
+
inventory,
|
|
3908
|
+
moved: new Map(),
|
|
3909
|
+
pageUrls: new Set(
|
|
3910
|
+
inventory.urls
|
|
3911
|
+
.filter((entry) => entry.kind === "page")
|
|
3912
|
+
.map((entry) => entry.url)
|
|
3913
|
+
),
|
|
3914
|
+
redirects: new Map(
|
|
3915
|
+
readJson(path.join(root, REDIRECTS), []).map((entry) => [
|
|
3916
|
+
entry.from,
|
|
3917
|
+
entry,
|
|
3918
|
+
])
|
|
3919
|
+
),
|
|
3920
|
+
references,
|
|
3921
|
+
root,
|
|
3922
|
+
taken: new Set([
|
|
3923
|
+
...Object.keys(references),
|
|
3924
|
+
...Object.values(references).flatMap((entry) =>
|
|
3925
|
+
Object.values(recordOf(entry.endpoints))
|
|
3926
|
+
),
|
|
3927
|
+
]),
|
|
3928
|
+
writes: new Map(),
|
|
3929
|
+
};
|
|
3930
|
+
dropShadowingRedirects(state);
|
|
3931
|
+
redirectEndpoints(state);
|
|
3932
|
+
redirectTags(state);
|
|
3933
|
+
writeTagMetas(state);
|
|
3934
|
+
rewriteMovedLinks(state);
|
|
3935
|
+
state.writes.set(
|
|
3936
|
+
path.join(root, INVENTORY),
|
|
3937
|
+
`${JSON.stringify(inventory, null, 2)}\n`
|
|
3938
|
+
);
|
|
3939
|
+
state.writes.set(
|
|
3940
|
+
path.join(root, REDIRECTS),
|
|
3941
|
+
`${JSON.stringify(sortedRedirects(state.redirects), null, 2)}\n`
|
|
3942
|
+
);
|
|
3943
|
+
return { deletes: new Set(), writes: state.writes };
|
|
3944
|
+
};
|
|
3945
|
+
|
|
3946
|
+
// --- Driver ---------------------------------------------------------------------------
|
|
3947
|
+
|
|
3948
|
+
const HELP = `readme-codemod: the mechanical pass of a ReadMe → Blume migration
|
|
3949
|
+
|
|
3950
|
+
node readme-codemod.mjs [--write] [--json] [--site <origin>]... [--glossary <file>] <project-root>
|
|
3951
|
+
node readme-codemod.mjs --routes <routes.json> [--spec <route>=<file>]... [--write] [--json] <project-root>
|
|
3952
|
+
|
|
3953
|
+
Without --write it only reports. See the header of this file for what each pass does.`;
|
|
3954
|
+
|
|
3955
|
+
const OPTION_HANDLERS = {
|
|
3956
|
+
"--glossary": (options, value) => {
|
|
3957
|
+
options.glossary = path.resolve(value);
|
|
3958
|
+
},
|
|
3959
|
+
"--routes": (options, value) => {
|
|
3960
|
+
options.routes = path.resolve(value);
|
|
3961
|
+
},
|
|
3962
|
+
"--site": (options, value) => {
|
|
3963
|
+
options.sites.push(value.replace(/\/+$/u, ""));
|
|
3964
|
+
},
|
|
3965
|
+
"--spec": (options, value) => {
|
|
3966
|
+
const split = value.indexOf("=");
|
|
3967
|
+
if (split <= 0) {
|
|
3968
|
+
throw new Error(`--spec takes <route>=<file>, got ${value}`);
|
|
3969
|
+
}
|
|
3970
|
+
const route = value.slice(0, split).replaceAll(/^\/+|\/+$/gu, "");
|
|
3971
|
+
options.specs.push({ file: value.slice(split + 1), route: `/${route}` });
|
|
3972
|
+
},
|
|
3973
|
+
};
|
|
3974
|
+
const FLAGS = {
|
|
3975
|
+
"--help": "help",
|
|
3976
|
+
"--json": "json",
|
|
3977
|
+
"--write": "write",
|
|
3978
|
+
"-h": "help",
|
|
3979
|
+
};
|
|
3980
|
+
|
|
3981
|
+
const parseArgs = (argv) => {
|
|
3982
|
+
const options = {
|
|
3983
|
+
glossary: null,
|
|
3984
|
+
help: false,
|
|
3985
|
+
json: false,
|
|
3986
|
+
root: null,
|
|
3987
|
+
routes: null,
|
|
3988
|
+
sites: [],
|
|
3989
|
+
specs: [],
|
|
3990
|
+
write: false,
|
|
3991
|
+
};
|
|
3992
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
3993
|
+
const arg = argv[index];
|
|
3994
|
+
if (Object.hasOwn(FLAGS, arg)) {
|
|
3995
|
+
options[FLAGS[arg]] = true;
|
|
3996
|
+
} else if (Object.hasOwn(OPTION_HANDLERS, arg)) {
|
|
3997
|
+
index += 1;
|
|
3998
|
+
if (argv[index] === undefined) {
|
|
3999
|
+
throw new Error(`${arg} needs a value`);
|
|
4000
|
+
}
|
|
4001
|
+
OPTION_HANDLERS[arg](options, argv[index]);
|
|
4002
|
+
} else if (arg.startsWith("-")) {
|
|
4003
|
+
throw new Error(`unknown option ${arg}`);
|
|
4004
|
+
} else if (options.root === null) {
|
|
4005
|
+
options.root = path.resolve(arg);
|
|
4006
|
+
} else {
|
|
4007
|
+
throw new Error("one project root only");
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
return options;
|
|
4011
|
+
};
|
|
4012
|
+
|
|
4013
|
+
const removeEmptyDirs = (dir) => {
|
|
4014
|
+
if (!(existsSync(dir) && statSync(dir).isDirectory())) {
|
|
4015
|
+
return;
|
|
4016
|
+
}
|
|
4017
|
+
for (const name of readdirSync(dir)) {
|
|
4018
|
+
removeEmptyDirs(path.join(dir, name));
|
|
4019
|
+
}
|
|
4020
|
+
if (readdirSync(dir).length === 0) {
|
|
4021
|
+
rmdirSync(dir);
|
|
4022
|
+
}
|
|
4023
|
+
};
|
|
4024
|
+
|
|
4025
|
+
const apply = (root, writes, deletes) => {
|
|
4026
|
+
for (const [file, content] of [...writes.entries()].toSorted()) {
|
|
4027
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
4028
|
+
writeFileSync(file, content, "utf-8");
|
|
4029
|
+
}
|
|
4030
|
+
for (const file of [...deletes].toSorted()) {
|
|
4031
|
+
if (!writes.has(file) && existsSync(file)) {
|
|
4032
|
+
rmSync(file);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
for (const dir of [
|
|
4036
|
+
"docs",
|
|
4037
|
+
"reference",
|
|
4038
|
+
"recipes",
|
|
4039
|
+
"custom_pages",
|
|
4040
|
+
"page",
|
|
4041
|
+
"changelogs",
|
|
4042
|
+
"changelog",
|
|
4043
|
+
]) {
|
|
4044
|
+
removeEmptyDirs(path.join(root, dir));
|
|
4045
|
+
}
|
|
4046
|
+
};
|
|
4047
|
+
|
|
4048
|
+
const summarize = (root, writes, deletes, write) => {
|
|
4049
|
+
const relative = (file) => path.relative(root, file);
|
|
4050
|
+
return {
|
|
4051
|
+
counts: Object.fromEntries(
|
|
4052
|
+
sortedEntries(report.counts).filter(([, value]) => value !== 0)
|
|
4053
|
+
),
|
|
4054
|
+
deleted: [...deletes]
|
|
4055
|
+
.filter((file) => existsSync(file) || write)
|
|
4056
|
+
.map(relative)
|
|
4057
|
+
.toSorted(),
|
|
4058
|
+
files: Object.fromEntries(
|
|
4059
|
+
sortedEntries(report.files).map(([file, messages]) => [
|
|
4060
|
+
file,
|
|
4061
|
+
[...new Set(messages)],
|
|
4062
|
+
])
|
|
4063
|
+
),
|
|
4064
|
+
todo: [...new Set(report.todo)],
|
|
4065
|
+
written: [...writes.keys()].map(relative).toSorted(),
|
|
4066
|
+
wrote: write,
|
|
4067
|
+
};
|
|
4068
|
+
};
|
|
4069
|
+
|
|
4070
|
+
const printReport = (summary) => {
|
|
4071
|
+
const out = [];
|
|
4072
|
+
if (summary.written.length === 0 && summary.deleted.length === 0) {
|
|
4073
|
+
out.push("Nothing to change.");
|
|
4074
|
+
} else {
|
|
4075
|
+
out.push(
|
|
4076
|
+
summary.wrote ? "Applied:" : "Would apply (dry run; --write to apply):"
|
|
4077
|
+
);
|
|
4078
|
+
for (const [kind, value] of Object.entries(summary.counts)) {
|
|
4079
|
+
out.push(` ${value}× ${kind}`);
|
|
4080
|
+
}
|
|
4081
|
+
out.push(
|
|
4082
|
+
` ${summary.written.length} file(s) written, ${summary.deleted.length} deleted`
|
|
4083
|
+
);
|
|
4084
|
+
}
|
|
4085
|
+
const files = Object.entries(summary.files);
|
|
4086
|
+
if (files.length > 0) {
|
|
4087
|
+
out.push("", "Review, per file:");
|
|
4088
|
+
for (const [file, messages] of files) {
|
|
4089
|
+
out.push(` ${file}`, ...messages.map((message) => ` - ${message}`));
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
if (summary.todo.length > 0) {
|
|
4093
|
+
out.push("", "Left for you:", ...summary.todo.map((item) => ` - ${item}`));
|
|
4094
|
+
}
|
|
4095
|
+
process.stdout.write(`${out.join("\n")}\n`);
|
|
4096
|
+
};
|
|
4097
|
+
|
|
4098
|
+
const run = (options) => {
|
|
4099
|
+
const result = options.routes
|
|
4100
|
+
? applyRoutes(options.root, options)
|
|
4101
|
+
: convertProject(options.root, options);
|
|
4102
|
+
// Skip writes that wouldn't change a byte, so a rerun reports nothing.
|
|
4103
|
+
const writes = new Map(
|
|
4104
|
+
[...result.writes].filter(
|
|
4105
|
+
([file, content]) =>
|
|
4106
|
+
!(existsSync(file) && readFileSync(file, "utf-8") === content)
|
|
4107
|
+
)
|
|
4108
|
+
);
|
|
4109
|
+
const deletes = new Set(
|
|
4110
|
+
[...result.deletes].filter((file) => existsSync(file))
|
|
4111
|
+
);
|
|
4112
|
+
if (options.write) {
|
|
4113
|
+
apply(options.root, writes, deletes);
|
|
4114
|
+
}
|
|
4115
|
+
return summarize(options.root, writes, deletes, options.write);
|
|
4116
|
+
};
|
|
4117
|
+
|
|
4118
|
+
const main = () => {
|
|
4119
|
+
let options;
|
|
4120
|
+
let summary;
|
|
4121
|
+
try {
|
|
4122
|
+
options = parseArgs(process.argv.slice(2));
|
|
4123
|
+
if (options.help || options.root === null) {
|
|
4124
|
+
process.stdout.write(`${HELP}\n`);
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4127
|
+
summary = run(options);
|
|
4128
|
+
} catch (error) {
|
|
4129
|
+
process.stderr.write(`readme-codemod: ${error.message}\n`);
|
|
4130
|
+
process.exit(1);
|
|
4131
|
+
}
|
|
4132
|
+
if (options.json) {
|
|
4133
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
4134
|
+
} else {
|
|
4135
|
+
printReport(summary);
|
|
4136
|
+
}
|
|
4137
|
+
};
|
|
4138
|
+
|
|
4139
|
+
main();
|