blume 2.1.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +126 -0
- package/README.md +4 -4
- package/dist/cli/{chunk-74wfhv79.js → chunk-05f21r8h.js} +1 -1
- package/dist/cli/{chunk-9qzgg6mj.js → chunk-1cm0h819.js} +3 -3
- package/dist/cli/{chunk-nbyvgnnm.js → chunk-1rkxzg3n.js} +104 -47
- package/dist/cli/chunk-1rkxzg3n.js.map +36 -0
- package/dist/cli/{chunk-desvm72z.js → chunk-2nr29y95.js} +22 -26
- package/dist/cli/chunk-2nr29y95.js.map +13 -0
- package/dist/cli/{chunk-32khwcb6.js → chunk-49r370mb.js} +42 -69
- package/dist/cli/{chunk-32khwcb6.js.map → chunk-49r370mb.js.map} +9 -10
- package/dist/cli/{chunk-ms9z66nz.js → chunk-4rftmttv.js} +2 -2
- package/dist/cli/{chunk-s0bdnc7v.js → chunk-79bzzkx4.js} +5 -4
- package/dist/cli/chunk-79bzzkx4.js.map +10 -0
- package/dist/cli/{chunk-hvcxng5f.js → chunk-7tekknev.js} +6 -6
- package/dist/cli/{chunk-dv2z0rzf.js → chunk-8b489v1b.js} +38 -13
- package/dist/cli/{chunk-dv2z0rzf.js.map → chunk-8b489v1b.js.map} +5 -4
- package/dist/cli/{chunk-0pmb4tr4.js → chunk-8f8xw4x2.js} +1195 -222
- package/dist/cli/chunk-8f8xw4x2.js.map +44 -0
- package/dist/cli/chunk-96snx8yy.js +329 -0
- package/dist/cli/chunk-96snx8yy.js.map +13 -0
- package/dist/cli/{chunk-ckrcm08h.js → chunk-a3ydrqny.js} +48 -62
- package/dist/cli/{chunk-ckrcm08h.js.map → chunk-a3ydrqny.js.map} +4 -5
- package/dist/cli/{chunk-bxwhtvw8.js → chunk-ahc5gc9h.js} +5 -5
- package/dist/cli/{chunk-dpy8qdjf.js → chunk-as09pafp.js} +73 -182
- package/dist/cli/chunk-as09pafp.js.map +18 -0
- package/dist/cli/{chunk-tfycddbt.js → chunk-bjk964fp.js} +2 -2
- package/dist/cli/{chunk-gfc5fqvf.js → chunk-br0n8n3e.js} +18 -18
- package/dist/cli/{chunk-gfc5fqvf.js.map → chunk-br0n8n3e.js.map} +1 -1
- package/dist/cli/{chunk-297ajdcv.js → chunk-c69tw29j.js} +6 -6
- package/dist/cli/{chunk-297ajdcv.js.map → chunk-c69tw29j.js.map} +3 -3
- package/dist/cli/chunk-cn3dy3hz.js +146 -0
- package/dist/cli/chunk-cn3dy3hz.js.map +11 -0
- package/dist/cli/{chunk-124fvmzq.js → chunk-dm4mg9bj.js} +11064 -8742
- package/dist/cli/{chunk-124fvmzq.js.map → chunk-dm4mg9bj.js.map} +78 -64
- package/dist/cli/{chunk-dqhg54mr.js → chunk-dmx6cezx.js} +1 -1
- package/dist/cli/{chunk-60hz265r.js → chunk-dpcahcrb.js} +24 -11
- package/dist/cli/chunk-dpcahcrb.js.map +10 -0
- package/dist/cli/{chunk-a57yh0h9.js → chunk-dyk3r7jw.js} +7 -7
- package/dist/cli/{chunk-a57yh0h9.js.map → chunk-dyk3r7jw.js.map} +1 -1
- package/dist/cli/{chunk-twy7psdz.js → chunk-enk76qd5.js} +14 -10
- package/dist/cli/chunk-enk76qd5.js.map +10 -0
- package/dist/cli/{chunk-k3gr1nm9.js → chunk-fqy4n3q2.js} +13 -16
- package/dist/cli/{chunk-k3gr1nm9.js.map → chunk-fqy4n3q2.js.map} +3 -3
- package/dist/cli/{chunk-sc93apfs.js → chunk-frk2wdq1.js} +17 -17
- package/dist/cli/{chunk-sc93apfs.js.map → chunk-frk2wdq1.js.map} +2 -2
- package/dist/cli/{chunk-kdpzw38a.js → chunk-gprwej7h.js} +95 -7
- package/dist/cli/chunk-gprwej7h.js.map +11 -0
- package/dist/cli/chunk-kkrteb15.js +157 -0
- package/dist/cli/chunk-kkrteb15.js.map +10 -0
- package/dist/cli/{chunk-ave1wcbr.js → chunk-m9bp2hc1.js} +1 -1
- package/dist/cli/{chunk-58bt3hag.js → chunk-mrvh4c88.js} +4 -4
- package/dist/cli/{chunk-5cmts6dv.js → chunk-p8zg3a2h.js} +7 -7
- package/dist/cli/{chunk-5cmts6dv.js.map → chunk-p8zg3a2h.js.map} +1 -1
- package/dist/cli/{chunk-0w6hs479.js → chunk-rkqae7kh.js} +30 -18
- package/dist/cli/chunk-rkqae7kh.js.map +10 -0
- package/dist/cli/{chunk-bqxaqn5p.js → chunk-t5xx74rd.js} +8 -8
- package/dist/cli/{chunk-bqxaqn5p.js.map → chunk-t5xx74rd.js.map} +1 -1
- package/dist/cli/{chunk-1f882900.js → chunk-th0pgaj5.js} +2 -2
- package/dist/cli/{chunk-dhwtqnnd.js → chunk-vnbvhgmd.js} +2 -2
- package/dist/cli/{chunk-gxtg6ed2.js → chunk-vzy9rh6y.js} +21 -21
- package/dist/cli/{chunk-gxtg6ed2.js.map → chunk-vzy9rh6y.js.map} +3 -3
- package/dist/cli/{chunk-j1x92mx9.js → chunk-w6e455pz.js} +1 -1
- package/dist/cli/chunk-wehv26ry.js +155 -0
- package/dist/cli/chunk-wehv26ry.js.map +11 -0
- package/dist/cli/{chunk-y4msrwm9.js → chunk-xgq4eytj.js} +15 -15
- package/dist/cli/{chunk-36bxs9c7.js → chunk-xt0qvz1t.js} +27 -23
- package/dist/cli/chunk-xt0qvz1t.js.map +10 -0
- package/dist/cli/index.js +78 -42
- package/dist/cli/index.js.map +7 -6
- package/dist/types/ai/changelog-components.d.ts +18 -0
- package/dist/types/ai/changelog-markdown.d.ts +12 -0
- package/dist/types/ai/openapi-components.d.ts +7 -7
- package/dist/types/ai/operation-details.d.ts +39 -0
- package/dist/types/ai/relative-links.d.ts +3 -1
- package/dist/types/ai/serializers.d.ts +6 -4
- package/dist/types/analytics/head.d.ts +4 -2
- package/dist/types/analytics/script.d.ts +8 -2
- package/dist/types/components/content/youtube.d.ts +20 -0
- package/dist/types/components/layout/nav-utils.d.ts +8 -0
- package/dist/types/components/openapi/example-panels.d.ts +33 -0
- package/dist/types/components/openapi/helpers.d.ts +53 -11
- package/dist/types/components/openapi/snippets.d.ts +7 -1
- package/dist/types/core/alert-diagnostics.d.ts +10 -0
- package/dist/types/core/builtin-tags.d.ts +16 -0
- package/dist/types/core/changelog-list.d.ts +48 -0
- package/dist/types/core/closest-match.d.ts +6 -0
- package/dist/types/core/code-fence-diagnostics.d.ts +18 -0
- package/dist/types/core/code-fences.d.ts +8 -1
- package/dist/types/core/config-input.d.ts +1 -1
- package/dist/types/core/content-assets.d.ts +16 -18
- package/dist/types/core/diagnostics.d.ts +14 -0
- package/dist/types/core/directive-diagnostics.d.ts +22 -8
- package/dist/types/core/former-routes.d.ts +14 -0
- package/dist/types/core/github.d.ts +5 -4
- package/dist/types/core/heading-markers.d.ts +65 -2
- package/dist/types/core/hidden-pages.d.ts +3 -0
- package/dist/types/core/includes.d.ts +23 -0
- package/dist/types/core/last-modified.d.ts +18 -6
- package/dist/types/core/links.d.ts +42 -2
- package/dist/types/core/load-module.d.ts +19 -4
- package/dist/types/core/manifest.d.ts +14 -1
- package/dist/types/core/markdown-mirrors.d.ts +22 -0
- package/dist/types/core/mdx-elements.d.ts +32 -0
- package/dist/types/core/mdx-syntax.d.ts +100 -0
- package/dist/types/core/meta-diagnostics.d.ts +7 -0
- package/dist/types/core/meta.d.ts +32 -5
- package/dist/types/core/nav-diagnostics.d.ts +9 -2
- package/dist/types/core/navigation.d.ts +13 -0
- package/dist/types/core/ordering-prefix.d.ts +6 -0
- package/dist/types/core/pinned-ids.d.ts +23 -0
- package/dist/types/core/project-graph.d.ts +9 -1
- package/dist/types/core/redirect-patterns.d.ts +18 -0
- package/dist/types/core/relative-files.d.ts +31 -0
- package/dist/types/core/schema.d.ts +267 -7
- package/dist/types/core/sources/normalize.d.ts +101 -9
- package/dist/types/core/sources/types.d.ts +16 -0
- package/dist/types/core/static-files.d.ts +10 -0
- package/dist/types/core/static-host.d.ts +42 -0
- package/dist/types/core/svg-images.d.ts +26 -0
- package/dist/types/core/syntax-diagnostics.d.ts +14 -0
- package/dist/types/core/types.d.ts +42 -1
- package/dist/types/deploy/generated-files.d.ts +41 -0
- package/dist/types/deploy/redirects.d.ts +21 -7
- package/dist/types/markdown/code-title.d.ts +9 -5
- package/dist/types/markdown/directives.d.ts +8 -0
- package/dist/types/markdown/fence-language.d.ts +51 -0
- package/dist/types/markdown/fence-meta.d.ts +5 -0
- package/dist/types/markdown/github-alerts.d.ts +35 -0
- package/dist/types/openapi/asyncapi.d.ts +6 -5
- package/dist/types/openapi/checks.d.ts +10 -5
- package/dist/types/openapi/model.d.ts +55 -8
- package/dist/types/openapi/parse.d.ts +4 -1
- package/dist/types/sources/filesystem.d.ts +14 -4
- package/dist/types/sources/mdx-remote.d.ts +6 -3
- package/dist/types/sources/registry.d.ts +3 -3
- package/dist/types/theme/color.d.ts +33 -0
- package/dist/types/theme/palette.d.ts +122 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +23 -8
- package/docs/04-migrating.mdx +16 -4
- package/docs/advanced/changelog.mdx +27 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/cli/audit.mdx +1 -1
- package/docs/cli/doctor.mdx +2 -2
- package/docs/cli/evals.mdx +1 -1
- package/docs/cli/index.mdx +4 -2
- package/docs/cli/validate.mdx +10 -7
- package/docs/configuration/analytics.mdx +2 -0
- package/docs/configuration/customization.mdx +3 -1
- package/docs/configuration/index.mdx +2 -2
- package/docs/configuration/theming.mdx +5 -1
- package/docs/content/components.mdx +26 -6
- package/docs/content/frontmatter.mdx +1 -1
- package/docs/content/includes.mdx +6 -2
- package/docs/content/index.mdx +11 -1
- package/docs/content/islands.mdx +2 -0
- package/docs/content/meta.mdx +9 -3
- package/docs/content/navigation.mdx +14 -6
- package/docs/content/sources/obsidian.mdx +1 -1
- package/docs/content/sources/remote-mdx.mdx +3 -1
- package/docs/content/syntax.mdx +76 -8
- package/docs/content/variables.mdx +3 -1
- package/docs/discoverability/agent-discovery.mdx +1 -1
- package/docs/discoverability/json-api.mdx +1 -1
- package/docs/discoverability/llms-txt.mdx +1 -1
- package/docs/discoverability/markdown.mdx +2 -2
- package/docs/discoverability/open-graph.mdx +1 -1
- package/docs/discoverability/sitemap-and-robots.mdx +1 -1
- package/docs/references/graphql.mdx +2 -0
- package/docs/references/openapi.mdx +45 -7
- package/package.json +4 -4
- package/skills/blume/SKILL.md +2 -2
- package/skills/blume-migrate/SKILL.md +49 -27
- package/skills/blume-migrate/references/docsify.md +262 -0
- package/skills/blume-migrate/references/docus.md +298 -0
- package/skills/blume-migrate/references/docusaurus.md +3 -3
- package/skills/blume-migrate/references/fern.md +327 -0
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/gitbook.md +267 -0
- package/skills/blume-migrate/references/github-wiki.md +259 -0
- package/skills/blume-migrate/references/jekyll.md +253 -0
- package/skills/blume-migrate/references/mdbook.md +256 -0
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/mkdocs.md +236 -0
- package/skills/blume-migrate/references/monorepo.md +20 -8
- package/skills/blume-migrate/references/nextra.md +2 -2
- package/skills/blume-migrate/references/readme.md +257 -0
- package/skills/blume-migrate/references/redocly.md +198 -0
- package/skills/blume-migrate/references/starlight.md +3 -3
- package/skills/blume-migrate/references/vitepress.md +204 -0
- package/skills/blume-migrate/references/vuepress.md +271 -0
- package/skills/blume-migrate/scripts/docsify-codemod.mjs +2387 -0
- package/skills/blume-migrate/scripts/docus-codemod.mjs +2542 -0
- package/skills/blume-migrate/scripts/fern-codemod.mjs +2633 -0
- package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +2808 -0
- package/skills/blume-migrate/scripts/include-excerpts.mjs +649 -0
- package/skills/blume-migrate/scripts/jekyll-codemod.mjs +3649 -0
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +17 -13
- package/skills/blume-migrate/scripts/mkdocs-codemod.mjs +2526 -0
- package/skills/blume-migrate/scripts/operation-routes.mjs +508 -0
- package/skills/blume-migrate/scripts/pin-heading-ids.mjs +1049 -0
- package/skills/blume-migrate/scripts/readme-codemod.mjs +4139 -0
- package/skills/blume-migrate/scripts/vitepress-codemod.mjs +1511 -0
- package/src/ai/changelog-components.ts +24 -0
- package/src/ai/changelog-markdown.ts +45 -41
- package/src/ai/component-markdown.ts +22 -9
- package/src/ai/llms.ts +36 -5
- package/src/ai/markdown.ts +2 -2
- package/src/ai/openapi-components.ts +25 -9
- package/src/ai/operation-details.ts +208 -0
- package/src/ai/relative-links.ts +48 -8
- package/src/ai/serializers.ts +7 -6
- package/src/ai/site-skill.ts +6 -2
- package/src/analytics/head.ts +11 -5
- package/src/analytics/script.ts +13 -3
- package/src/astro/generate.ts +32 -6
- package/src/astro/index.ts +1 -0
- package/src/astro/integration.ts +27 -1
- package/src/astro/mdx-source-errors.ts +46 -0
- package/src/astro/templates.ts +89 -185
- package/src/audit/checks/redirects.ts +5 -1
- package/src/audit/redirects.ts +36 -19
- package/src/audit/report.ts +22 -9
- package/src/audit/run.ts +12 -28
- package/src/audit/terms.ts +23 -0
- package/src/audit/url.ts +2 -2
- package/src/cli/build-failure.ts +256 -7
- package/src/cli/command-meta.ts +1 -1
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.ts +12 -3
- package/src/cli/commands/check.ts +13 -2
- package/src/cli/commands/dev.ts +1 -1
- package/src/cli/commands/doctor.ts +4 -2
- package/src/cli/commands/init.ts +3 -3
- package/src/cli/commands/preview.ts +17 -1
- package/src/cli/commands/validate.ts +22 -10
- package/src/cli/prepare.ts +1 -1
- package/src/cli/preview-pages.ts +161 -0
- package/src/cli/unknown-flags.ts +1 -42
- package/src/components/code-copy.ts +51 -0
- package/src/components/code-language.ts +75 -0
- package/src/components/content/AccordionItem.astro +16 -1
- package/src/components/content/Callout.astro +11 -1
- package/src/components/content/Changelog.astro +189 -0
- package/src/components/content/CodeBlock.astro +8 -11
- package/src/components/content/Tabs.astro +16 -2
- package/src/components/content/YouTube.astro +6 -4
- package/src/components/content/code-block.ts +33 -0
- package/src/components/content/content-strings.ts +5 -1
- package/src/components/content/inline-markdown.ts +35 -8
- package/src/components/content/youtube.ts +49 -7
- package/src/components/layout/Analytics.astro +5 -1
- package/src/components/layout/NavTree.astro +8 -3
- package/src/components/layout/RootLayout.astro +18 -31
- package/src/components/layout/nav-utils.ts +14 -0
- package/src/components/layout/search/pagefind.ts +2 -1
- package/src/components/openapi/Operation.astro +4 -0
- package/src/components/openapi/PanelTabs.astro +9 -3
- package/src/components/openapi/RequestBody.astro +42 -8
- package/src/components/openapi/RequestPanel.astro +34 -53
- package/src/components/openapi/example-panels.ts +110 -0
- package/src/components/openapi/helpers.ts +195 -20
- package/src/components/openapi/operation-model.ts +63 -26
- package/src/components/openapi/request.ts +13 -0
- package/src/components/openapi/snippets.ts +14 -2
- package/src/core/alert-diagnostics.ts +89 -0
- package/src/core/builtin-tags.ts +76 -0
- package/src/core/changelog-list.ts +87 -0
- package/src/core/closest-match.ts +41 -0
- package/src/core/code-fence-diagnostics.ts +267 -0
- package/src/core/code-fences.ts +43 -7
- package/src/core/component-diagnostics.ts +65 -12
- package/src/core/config-input.ts +1 -1
- package/src/core/config.ts +46 -1
- package/src/core/content-assets.ts +64 -144
- package/src/core/diagnostics.ts +36 -3
- package/src/core/directive-diagnostics.ts +265 -47
- package/src/core/former-routes.ts +46 -0
- package/src/core/git-ignored.ts +53 -0
- package/src/core/github.ts +5 -4
- package/src/core/heading-markers.ts +156 -2
- package/src/core/hidden-pages.ts +64 -0
- package/src/core/includes.ts +181 -41
- package/src/core/last-modified.ts +47 -10
- package/src/core/links.ts +438 -83
- package/src/core/load-module.ts +98 -9
- package/src/core/manifest.ts +12 -9
- package/src/core/markdown-mirrors.ts +52 -0
- package/src/core/mdx-elements.ts +153 -0
- package/src/core/mdx-syntax.ts +377 -0
- package/src/core/meta-diagnostics.ts +134 -0
- package/src/core/meta.ts +154 -55
- package/src/core/nav-diagnostics.ts +16 -4
- package/src/core/navigation.ts +70 -10
- package/src/core/ordering-prefix.ts +22 -2
- package/src/core/pinned-ids.ts +141 -0
- package/src/core/project-graph.ts +221 -29
- package/src/core/redirect-patterns.ts +41 -0
- package/src/core/relative-files.ts +145 -0
- package/src/core/schema.ts +17 -3
- package/src/core/sources/mdx-remote.ts +75 -14
- package/src/core/sources/normalize.ts +885 -179
- package/src/core/sources/notion.ts +5 -4
- package/src/core/sources/obsidian.ts +2 -5
- package/src/core/sources/types.ts +17 -0
- package/src/core/static-files.ts +40 -0
- package/src/core/static-host.ts +125 -0
- package/src/core/svg-images.ts +98 -0
- package/src/core/syntax-diagnostics.ts +491 -0
- package/src/core/types.ts +43 -1
- package/src/deploy/artifacts.ts +2 -28
- package/src/deploy/generated-files.ts +128 -0
- package/src/deploy/platforms/vercel.ts +25 -1
- package/src/deploy/redirects.ts +43 -16
- package/src/deploy/sitemap.ts +2 -1
- package/src/markdown/base-links.ts +87 -6
- package/src/markdown/code-title.ts +29 -13
- package/src/markdown/content-assets.ts +210 -0
- package/src/markdown/directives.ts +69 -5
- package/src/markdown/fence-language.ts +106 -0
- package/src/markdown/fence-meta.ts +42 -0
- package/src/markdown/github-alerts.ts +172 -0
- package/src/markdown/heading-anchors.ts +265 -56
- package/src/markdown/include.ts +30 -3
- package/src/markdown/index.ts +145 -40
- package/src/markdown/prompt-text.ts +95 -0
- package/src/markdown/relative-links.ts +37 -2
- package/src/markdown/route-snapshot.ts +22 -12
- package/src/markdown/svg-fallback.ts +51 -0
- package/src/markdown/title-heading.ts +134 -0
- package/src/migrate/migrate.ts +127 -2
- package/src/og/card.ts +14 -4
- package/src/openapi/asyncapi.ts +14 -6
- package/src/openapi/checks.ts +160 -15
- package/src/openapi/model.ts +172 -33
- package/src/openapi/parse.ts +13 -4
- package/src/openapi/render-mdx.ts +55 -9
- package/src/openapi/source.ts +87 -6
- package/src/registry/eject.ts +0 -1
- package/src/search/documents.ts +10 -2
- package/src/sources/filesystem.ts +28 -3
- package/src/sources/mdx-remote.ts +7 -15
- package/src/theme/color.ts +14 -0
- package/src/theme/entry.ts +22 -1
- package/src/theme/palette.ts +74 -1
- package/dist/cli/chunk-0pmb4tr4.js.map +0 -35
- package/dist/cli/chunk-0w6hs479.js.map +0 -10
- package/dist/cli/chunk-150jk7km.js +0 -52
- package/dist/cli/chunk-150jk7km.js.map +0 -10
- package/dist/cli/chunk-36bxs9c7.js.map +0 -10
- package/dist/cli/chunk-53jqw8z0.js +0 -89
- package/dist/cli/chunk-53jqw8z0.js.map +0 -10
- package/dist/cli/chunk-60hz265r.js.map +0 -10
- package/dist/cli/chunk-desvm72z.js.map +0 -14
- package/dist/cli/chunk-dpy8qdjf.js.map +0 -17
- package/dist/cli/chunk-es8h0v3j.js +0 -441
- package/dist/cli/chunk-es8h0v3j.js.map +0 -11
- package/dist/cli/chunk-kdpzw38a.js.map +0 -11
- package/dist/cli/chunk-nbyvgnnm.js.map +0 -36
- package/dist/cli/chunk-s0bdnc7v.js.map +0 -10
- package/dist/cli/chunk-twy7psdz.js.map +0 -10
- package/dist/cli/chunk-ydp3065p.js +0 -64
- package/dist/cli/chunk-ydp3065p.js.map +0 -10
- /package/dist/cli/{chunk-74wfhv79.js.map → chunk-05f21r8h.js.map} +0 -0
- /package/dist/cli/{chunk-9qzgg6mj.js.map → chunk-1cm0h819.js.map} +0 -0
- /package/dist/cli/{chunk-ms9z66nz.js.map → chunk-4rftmttv.js.map} +0 -0
- /package/dist/cli/{chunk-hvcxng5f.js.map → chunk-7tekknev.js.map} +0 -0
- /package/dist/cli/{chunk-bxwhtvw8.js.map → chunk-ahc5gc9h.js.map} +0 -0
- /package/dist/cli/{chunk-tfycddbt.js.map → chunk-bjk964fp.js.map} +0 -0
- /package/dist/cli/{chunk-dqhg54mr.js.map → chunk-dmx6cezx.js.map} +0 -0
- /package/dist/cli/{chunk-ave1wcbr.js.map → chunk-m9bp2hc1.js.map} +0 -0
- /package/dist/cli/{chunk-58bt3hag.js.map → chunk-mrvh4c88.js.map} +0 -0
- /package/dist/cli/{chunk-1f882900.js.map → chunk-th0pgaj5.js.map} +0 -0
- /package/dist/cli/{chunk-dhwtqnnd.js.map → chunk-vnbvhgmd.js.map} +0 -0
- /package/dist/cli/{chunk-j1x92mx9.js.map → chunk-w6e455pz.js.map} +0 -0
- /package/dist/cli/{chunk-y4msrwm9.js.map → chunk-xgq4eytj.js.map} +0 -0
|
@@ -0,0 +1,2808 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// github-wiki-codemod.mjs — the mechanical part of a GitHub wiki → Blume
|
|
3
|
+
// migration. Run it from a copy of the wiki clone, which becomes the Blume
|
|
4
|
+
// project (`blume.config.ts` beside it, pages moved into `docs/`). It has four
|
|
5
|
+
// commands:
|
|
6
|
+
//
|
|
7
|
+
// routes Lists the wiki's pages the way GitHub serves them (every file in a
|
|
8
|
+
// markup format GitHub renders, named by its file name, folders
|
|
9
|
+
// ignored, a root file winning a name clash) and writes the route
|
|
10
|
+
// table: `wiki-routes.json` (page name → new flat route) and
|
|
11
|
+
// `wiki-files.json` (page name → file). It suggests which pages are
|
|
12
|
+
// moved notices or not content, for you to classify by editing the
|
|
13
|
+
// table (a target URL or route, or `null`). It never overwrites a
|
|
14
|
+
// table that exists: it compares the two instead.
|
|
15
|
+
// convert Reads the classified table and, per Markdown page that stays:
|
|
16
|
+
// moves it to `docs/(group)/<route>.md` (`Home` → `docs/index.md`),
|
|
17
|
+
// writes `title` (the page name as GitHub showed it), `sidebar.label`,
|
|
18
|
+
// and `slug` where the file's place doesn't give its route; turns
|
|
19
|
+
// `[[wiki links]]` (text first, `[[Page]]`, `[[text|Page#anchor]]`,
|
|
20
|
+
// `[[text|https://…]]`, image tags, `[[_TOC_]]`) and links to the
|
|
21
|
+
// wiki (absolute, root-relative, bare page names, `#user-content-`
|
|
22
|
+
// anchors, the mirror repository's copies, relative links that leave
|
|
23
|
+
// the wiki) into routes; moves the wiki's own images next to the
|
|
24
|
+
// pages and points them there (Markdown images, wiki image tags, and
|
|
25
|
+
// a raw `<img>` alone on its line), dropping a relative image that
|
|
26
|
+
// pointed at nothing on GitHub either; converts GitHub alerts to `:::`
|
|
27
|
+
// directives (an indented or lazy one stays an alert, which Blume
|
|
28
|
+
// renders as a callout in `.mdx`); lowercases fence languages (Blume
|
|
29
|
+
// reads any case, so that only tidies) and maps Linguist-only ones;
|
|
30
|
+
// pins `<a name>` heading anchors as `[#id]` (Blume would take the
|
|
31
|
+
// name as the id anyway; the pin drops the empty tag); deletes a
|
|
32
|
+
// leading H1 that repeats the page name
|
|
33
|
+
// and demotes every heading one level on a page that still has an H1
|
|
34
|
+
// (ATX and one-line setext); deletes a Foam "Autogenerated link
|
|
35
|
+
// references" block and link definitions a same-label one above
|
|
36
|
+
// already shadows (CommonMark uses the first); and renames a page
|
|
37
|
+
// that now needs MDX to `.mdx` with its comments, void tags,
|
|
38
|
+
// autolinks, bare `<`, and braces made MDX-safe. It writes a
|
|
39
|
+
// `meta.ts` per `_Sidebar.md` group, a root one in sidebar order, and
|
|
40
|
+
// a collapsed "More pages" group for the pages the sidebar doesn't
|
|
41
|
+
// list. It leaves fenced, indented, and inline code, `<code>` and
|
|
42
|
+
// `<tt>`, HTML comments, and gollum's `'[[…]]` escape alone, and
|
|
43
|
+
// writes a wiki link inside an HTML block as `<a href>`, since
|
|
44
|
+
// Markdown there shows as text.
|
|
45
|
+
// stubs In a fresh clone of the wiki (with both tables copied in),
|
|
46
|
+
// replaces every page with a one-line link to its new URL and the
|
|
47
|
+
// sidebar with a notice. It only writes files: it never commits or
|
|
48
|
+
// pushes, and it refuses to run beside a `blume.config.ts`. The
|
|
49
|
+
// user pushes. `--notice-only` only puts the notice at the top of
|
|
50
|
+
// `Home` and `_Sidebar.md`.
|
|
51
|
+
// check After `blume build`: every route in the table must exist in
|
|
52
|
+
// `dist/`, anchors included. Given old wiki URLs (inbound links from
|
|
53
|
+
// the main repository), it prints each one's new route and checks
|
|
54
|
+
// its page and anchor, suggesting ids for an anchor that's gone.
|
|
55
|
+
//
|
|
56
|
+
// It reports, and never fakes, what needs judgment: pages in other markup
|
|
57
|
+
// formats (convert them with pandoc first), sidebar lines it can't place,
|
|
58
|
+
// wiki links to missing or left-out pages (made plain text), Markdown links
|
|
59
|
+
// that were already broken on GitHub (left for you to retarget), wiki UI links,
|
|
60
|
+
// files stored in the wiki that pages link or show in raw HTML, uploaded
|
|
61
|
+
// attachments to vendor, dropped images and image sizes, multi-line setext
|
|
62
|
+
// headings, emoji in headings, MDX hazards it can't fix, and the files a
|
|
63
|
+
// converted copy leaves behind.
|
|
64
|
+
//
|
|
65
|
+
// Design constraints (as mintlify-codemod.mjs): zero dependencies, sorted and
|
|
66
|
+
// deterministic, idempotent (a second run changes nothing), dry run by default.
|
|
67
|
+
//
|
|
68
|
+
// Usage, from the copy of the wiki clone:
|
|
69
|
+
// node github-wiki-codemod.mjs routes --repo owner/repo # report
|
|
70
|
+
// node github-wiki-codemod.mjs routes --repo owner/repo --write # write the table
|
|
71
|
+
// node github-wiki-codemod.mjs convert --repo owner/repo # dry run
|
|
72
|
+
// node github-wiki-codemod.mjs convert --repo owner/repo --write # apply
|
|
73
|
+
// node github-wiki-codemod.mjs stubs --site https://docs.example.com --write
|
|
74
|
+
// node github-wiki-codemod.mjs check [https://github.com/owner/repo/wiki/FAQ#x …]
|
|
75
|
+
//
|
|
76
|
+
// Options:
|
|
77
|
+
// --repo <owner/repo> The repository the wiki belongs to (routes, convert).
|
|
78
|
+
// --mirror <owner/repo> A repository that mirrors the wiki (`<repo>-wiki`):
|
|
79
|
+
// links to its copies of pages become routes too.
|
|
80
|
+
// --content <dir> The content root (default: docs).
|
|
81
|
+
// --emoji <file> Shortcode → emoji map (GitHub's /emojis JSON works).
|
|
82
|
+
// --site <url> The new site, base path included (stubs).
|
|
83
|
+
// --notice-only Stubs: only the notice on Home and the sidebar.
|
|
84
|
+
// --dist <dir> The build to check (default: dist).
|
|
85
|
+
// --write Apply. Without it, report only.
|
|
86
|
+
|
|
87
|
+
import {
|
|
88
|
+
existsSync,
|
|
89
|
+
mkdirSync,
|
|
90
|
+
readdirSync,
|
|
91
|
+
readFileSync,
|
|
92
|
+
renameSync,
|
|
93
|
+
rmSync,
|
|
94
|
+
statSync,
|
|
95
|
+
writeFileSync,
|
|
96
|
+
} from "node:fs";
|
|
97
|
+
import path from "node:path";
|
|
98
|
+
|
|
99
|
+
const { posix } = path;
|
|
100
|
+
|
|
101
|
+
// --- Tables -------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
// Every extension GitHub renders as a wiki page (github/markup).
|
|
104
|
+
const MARKUP =
|
|
105
|
+
/\.(?:md|markdown|mdown|mkdn|textile|rdoc|org|creole|mediawiki|wiki|rst|rest|asciidoc|adoc|asc|pod6?)$/iu;
|
|
106
|
+
const MARKDOWN = /\.(?:md|markdown|mdown|mkdn)$/iu;
|
|
107
|
+
const CHROME = /^_(?:sidebar|footer)\./iu;
|
|
108
|
+
const SIDEBAR = /^_sidebar\./iu;
|
|
109
|
+
const FOOTER = /^_footer\./iu;
|
|
110
|
+
const IMAGE = /\.(?:apng|avif|bmp|gif|ico|jpe?g|png|svg|webp)$/iu;
|
|
111
|
+
const ALERTS = {
|
|
112
|
+
caution: "danger",
|
|
113
|
+
important: "note",
|
|
114
|
+
note: "note",
|
|
115
|
+
tip: "tip",
|
|
116
|
+
warning: "warning",
|
|
117
|
+
};
|
|
118
|
+
// Linguist names Shiki doesn't know.
|
|
119
|
+
const FENCE_LANGUAGES = { posh: "powershell" };
|
|
120
|
+
const DROPPED_FENCES = new Set(["geojson", "stl", "topojson"]);
|
|
121
|
+
const NOT_CONTENT = new Set([
|
|
122
|
+
"code_of_conduct",
|
|
123
|
+
"contributing",
|
|
124
|
+
"license",
|
|
125
|
+
"readme",
|
|
126
|
+
"security",
|
|
127
|
+
]);
|
|
128
|
+
const NOT_CONTENT_FOLDERS = new Set(["fixtures", "scripts", "test", "tests"]);
|
|
129
|
+
const SKIP_DIRS = new Set([".blume", ".git", "dist", "node_modules", "public"]);
|
|
130
|
+
const PROJECT_FILES = new Set([
|
|
131
|
+
".gitignore",
|
|
132
|
+
"blume.config.ts",
|
|
133
|
+
"bun.lock",
|
|
134
|
+
"package-lock.json",
|
|
135
|
+
"package.json",
|
|
136
|
+
"pnpm-lock.yaml",
|
|
137
|
+
"theme.css",
|
|
138
|
+
"wiki-files.json",
|
|
139
|
+
"wiki-routes.json",
|
|
140
|
+
"yarn.lock",
|
|
141
|
+
]);
|
|
142
|
+
const HTML_TAGS = new Set(
|
|
143
|
+
[
|
|
144
|
+
"a abbr b bdo blockquote br caption cite code col colgroup dd del details",
|
|
145
|
+
"dfn div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i img input ins",
|
|
146
|
+
"kbd li mark ol p picture pre q rp rt ruby s samp small source span strike",
|
|
147
|
+
"strong sub summary sup table tbody td tfoot th thead time tr tt u ul var",
|
|
148
|
+
"video wbr",
|
|
149
|
+
]
|
|
150
|
+
.join(" ")
|
|
151
|
+
.split(" ")
|
|
152
|
+
);
|
|
153
|
+
const ROUTES_FILE = "wiki-routes.json";
|
|
154
|
+
const FILES_FILE = "wiki-files.json";
|
|
155
|
+
const DELETED = "\u{0}";
|
|
156
|
+
const UNICODE_HYPHEN = "\u{2010}";
|
|
157
|
+
const MAX_NOTICE_LINES = 3;
|
|
158
|
+
const MAX_ID_SUGGESTIONS = 3;
|
|
159
|
+
const HEADING_LEVELS = 6;
|
|
160
|
+
|
|
161
|
+
// --- Patterns -----------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const ATX =
|
|
164
|
+
/^(?<prefix> {0,3}(?:>(?:\t {0,3}| {0,4}))*)(?<hashes>#{1,6})(?<rest>(?:[ \t].*)?)$/u;
|
|
165
|
+
const SETEXT = /^ {0,3}(?<marker>=+|-+)[ \t]*$/u;
|
|
166
|
+
const LIST_ITEM =
|
|
167
|
+
/^(?<indent>[ \t]*)(?<marker>[-*+]|\d{1,9}[.)])(?<gap>[ \t]+|$)(?<content>.*)$/u;
|
|
168
|
+
const FENCE = /^(?<prefix>[ \t>]*)(?<marker>`{3,}|~{3,})(?<info>.*)$/u;
|
|
169
|
+
// A backslash escapes the backtick after it (an even run of backslashes doesn't).
|
|
170
|
+
const CODE_SPAN =
|
|
171
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)(?<ticks>`+)(?<code>[\s\S]*?[^`])\k<ticks>(?!`)/gu;
|
|
172
|
+
const COMMENT = /<!--[\s\S]*?(?:-->|$)/gu;
|
|
173
|
+
// GitHub leaves `[[…]]` alone inside `<code>` and `<tt>` too.
|
|
174
|
+
const CODE_ELEMENT = /<(?<name>code|tt)\b[^<>]*>[\s\S]*?<\/\k<name>\s*>/giu;
|
|
175
|
+
const TAG = /<\/?[A-Za-z][^<>]*>/gu;
|
|
176
|
+
const LINK_DESTINATION = /\]\((?:[^()\s]|\([^()\s]*\))*(?:\s+"[^"]*")?\)/gu;
|
|
177
|
+
// `'[[Page]]` is gollum's escape for literal brackets (unless a `'` follows),
|
|
178
|
+
// and the text may hold balanced brackets (`[[execute[.*]|Page]]`).
|
|
179
|
+
const WIKI_LINK =
|
|
180
|
+
/(?<!\\)(?<quote>'?)\[\[(?<inner>(?:[^[\]\n]|\[[^[\]\n]*\])+)\]\](?<after>'?)/dgu;
|
|
181
|
+
const ESCAPED_WIKI_LINK = /\\\[\\\[(?<inner>(?:[^[\]\\\n]|\\\|)+?)\\\]\\\]/dgu;
|
|
182
|
+
const PIPE = /\s*\\?\|\s*/u;
|
|
183
|
+
const DESTINATION = String.raw`(?<dest><[^<>\n]*>|[^\s()<>]*(?:\([^\s()]*\)[^\s()<>]*)*)`;
|
|
184
|
+
const TITLE = String.raw`(?<title>\s+(?:"[^"\n]*"|'[^'\n]*'))?`;
|
|
185
|
+
const MD_IMAGE = new RegExp(
|
|
186
|
+
String.raw`!\[(?<alt>[^[\]\n]*)\]\(\s*${DESTINATION}${TITLE}\s*\)`,
|
|
187
|
+
"dgu"
|
|
188
|
+
);
|
|
189
|
+
const MD_LINK = new RegExp(
|
|
190
|
+
String.raw`(?<!!)\[(?<label>(?:[^[\]\n]|\[[^[\]\n]*\])*)\]\(\s*${DESTINATION}${TITLE}\s*\)`,
|
|
191
|
+
"dgu"
|
|
192
|
+
);
|
|
193
|
+
const RAW_HREF =
|
|
194
|
+
/<a\b[^<>]*?\bhref\s*=\s*(?<quote>["'])(?<dest>[^"']*)\k<quote>/dgiu;
|
|
195
|
+
const RAW_IMG_LINE =
|
|
196
|
+
/^(?<indent>[ \t]*)<img\b(?<attributes>[^<>]*?)\/?>[ \t]*$/iu;
|
|
197
|
+
const DEFINITION =
|
|
198
|
+
/^(?<indent> {0,3})\[(?<label>(?:[^[\]\\\n]|\\.)+)\]:[ \t]*(?<dest><[^<>\n]*>|[^\s<]\S*)(?<rest>.*)$/u;
|
|
199
|
+
const ALERT_START =
|
|
200
|
+
/^>[ \t]*\[!(?<type>caution|important|note|tip|warning)\][ \t]*$/iu;
|
|
201
|
+
const ANY_ALERT = /^[ \t]*>[ \t]*\[!(?:caution|important|note|tip|warning)\]/iu;
|
|
202
|
+
const QUOTED = /^ {0,3}>/u;
|
|
203
|
+
const NAMED_ANCHOR =
|
|
204
|
+
/<a\s+(?:id|name)\s*=\s*["'](?<id>[^"']+)["']\s*(?:\/>|>\s*<\/a\s*>)[ \t]*/iu;
|
|
205
|
+
const HEADING_MARKER = /\s(?:\[#[^\s\]]+\]|\\?\{#[^\s}]+\\?\})\s*$/u;
|
|
206
|
+
const CLOSING_HASHES = /[ \t]+#+[ \t]*$/u;
|
|
207
|
+
const FOAM_BEGIN = /^\[\/\/begin\]:\s*#/u;
|
|
208
|
+
const FOAM_END = /^\[\/\/end\]:\s*#/u;
|
|
209
|
+
const SHORTCODE = /(?<![\w:]):(?<name>[a-z0-9_+][a-z0-9_+-]*):(?![\w:])/dgu;
|
|
210
|
+
const EMOJI_ENTRY =
|
|
211
|
+
/["']?(?<name>[\w+-]+)["']?\s*:\s*["'][^"']*unicode\/(?<hex>[0-9a-f-]+)/giu;
|
|
212
|
+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
|
|
213
|
+
// A wiki link's target is a URL only with `//` or `mailto:`: page names can
|
|
214
|
+
// hold a colon (`[[Module: Battery]]`).
|
|
215
|
+
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:\/\/|mailto:)/iu;
|
|
216
|
+
const ATTACHMENT =
|
|
217
|
+
/^https?:\/\/(?:(?:private-)?user-images\.githubusercontent\.com|github\.com\/user-attachments)\//iu;
|
|
218
|
+
const BLOB =
|
|
219
|
+
/^https?:\/\/(?:www\.)?github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/blob\/(?<rest>[^?#]+)(?:\?raw=true)?$/iu;
|
|
220
|
+
const TRAILING_PUNCTUATION = /[.,;:!?]+$/u;
|
|
221
|
+
const UI_PAGE = /^_(?:access|compare|history|new|pages|search)$/iu;
|
|
222
|
+
const ORDERING_PREFIX = /^(?<order>\d+)[-_.]/u;
|
|
223
|
+
const NOT_AN_ORDER = /^(?:\d+\.\d|\d{4}-\d{2}-\d{2}(?:[-_.]|$))/u;
|
|
224
|
+
const PLAIN_SCALAR = /^[\p{L}\p{N}][^:#\n"'[\]{}&*!|>%@`]*$/u;
|
|
225
|
+
const YAML_KEYWORD = /^(?:true|false|yes|no|on|off|null|~|[\d.+-]+)$/iu;
|
|
226
|
+
const MOVED_NOTICE =
|
|
227
|
+
/\bmoved\s+to\b|\bhas\s+moved\b|\bnow\s+(?:lives|hosted)\b|\bis\s+now\s+at\b/iu;
|
|
228
|
+
const FIRST_URL = /https?:\/\/[^\s)>\]"'`]+/u;
|
|
229
|
+
const ID_ATTRIBUTE = /\sid="(?<id>[^"]+)"/gu;
|
|
230
|
+
const VOID_TAG =
|
|
231
|
+
/<(?<tag>area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)\b(?<attributes>(?:[^<>/]|\/(?!>))*)>/dgiu;
|
|
232
|
+
const ANGLE_AUTOLINK = /<(?<url>https?:\/\/[^\s<>]+)>/dgu;
|
|
233
|
+
const STRAY_ANGLE = /<(?![A-Za-z/!])/dgu;
|
|
234
|
+
const BRACE = /(?<!\\)[{}]/dgu;
|
|
235
|
+
const OPEN_TAG = /<(?<tag>[A-Za-z][\w-]*)(?:[\s/][^<>]*)?>/gu;
|
|
236
|
+
|
|
237
|
+
// --- Small helpers --------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
const compareText = (left, right) => {
|
|
240
|
+
if (left < right) {
|
|
241
|
+
return -1;
|
|
242
|
+
}
|
|
243
|
+
return left > right ? 1 : 0;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const escapeRegExp = (text) =>
|
|
247
|
+
text.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`);
|
|
248
|
+
|
|
249
|
+
const decode = (text) => {
|
|
250
|
+
try {
|
|
251
|
+
return decodeURIComponent(text);
|
|
252
|
+
} catch {
|
|
253
|
+
return text;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** A page's new route segment: lowercase, apostrophes dropped, other runs → `-`. */
|
|
258
|
+
const slugOf = (name) =>
|
|
259
|
+
name
|
|
260
|
+
.normalize("NFC")
|
|
261
|
+
.replaceAll(/['\p{Pf}]/gu, "")
|
|
262
|
+
.toLowerCase()
|
|
263
|
+
.replaceAll(/[^\p{L}\p{N}]+/gu, "-")
|
|
264
|
+
.replaceAll(/^-+|-+$/gu, "");
|
|
265
|
+
|
|
266
|
+
/** GitHub's page name for a file: its base name minus the last extension. */
|
|
267
|
+
const pageNameOf = (file) =>
|
|
268
|
+
posix.basename(file).replace(MARKUP, "").replaceAll(" ", "-");
|
|
269
|
+
|
|
270
|
+
/** How GitHub matched a name: case-insensitive, spaces read as `-`. */
|
|
271
|
+
const keyOf = (name) => name.trim().replaceAll(" ", "-").toLowerCase();
|
|
272
|
+
|
|
273
|
+
const ownRouteOf = (name) =>
|
|
274
|
+
keyOf(name) === "home" ? "/" : `/${slugOf(name)}`;
|
|
275
|
+
|
|
276
|
+
/** The page name as GitHub showed it above the page. */
|
|
277
|
+
const titleOf = (name) =>
|
|
278
|
+
name.replaceAll("-", " ").replaceAll(UNICODE_HYPHEN, "-");
|
|
279
|
+
|
|
280
|
+
const plainText = (text) =>
|
|
281
|
+
text
|
|
282
|
+
.replaceAll(/[*_`~]/gu, "")
|
|
283
|
+
.replaceAll(/\[(?<text>[^\]]*)\]\([^)]*\)/gu, "$<text>")
|
|
284
|
+
.trim();
|
|
285
|
+
|
|
286
|
+
const comparable = (text) =>
|
|
287
|
+
plainText(text)
|
|
288
|
+
.toLowerCase()
|
|
289
|
+
.replaceAll(/[^\p{L}\p{N}]/gu, "");
|
|
290
|
+
|
|
291
|
+
const yamlScalar = (value) =>
|
|
292
|
+
PLAIN_SCALAR.test(value) &&
|
|
293
|
+
!YAML_KEYWORD.test(value) &&
|
|
294
|
+
value.trim() === value
|
|
295
|
+
? value
|
|
296
|
+
: JSON.stringify(value);
|
|
297
|
+
|
|
298
|
+
const cleanAnchor = (anchor) => anchor.replace(/^user-content-/u, "");
|
|
299
|
+
|
|
300
|
+
const unwrapAngle = (dest) =>
|
|
301
|
+
dest.startsWith("<") && dest.endsWith(">") ? dest.slice(1, -1) : dest;
|
|
302
|
+
|
|
303
|
+
const depthOf = (file) => file.split("/").length;
|
|
304
|
+
|
|
305
|
+
const byDepthThenName = (left, right) =>
|
|
306
|
+
depthOf(left) - depthOf(right) || compareText(left, right);
|
|
307
|
+
|
|
308
|
+
/** Blume's route for a file under the content root (ordering prefixes and `(group)` folders dropped). */
|
|
309
|
+
const derivedRoute = (relative) => {
|
|
310
|
+
const segments = relative
|
|
311
|
+
.replace(/\.mdx?$/u, "")
|
|
312
|
+
.split("/")
|
|
313
|
+
.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")))
|
|
314
|
+
.map((segment) => {
|
|
315
|
+
const order = NOT_AN_ORDER.test(segment)
|
|
316
|
+
? null
|
|
317
|
+
: ORDERING_PREFIX.exec(segment);
|
|
318
|
+
return order ? segment.slice(order.groups.order.length + 1) : segment;
|
|
319
|
+
});
|
|
320
|
+
if (segments.at(-1) === "index") {
|
|
321
|
+
segments.pop();
|
|
322
|
+
}
|
|
323
|
+
return `/${segments.join("/")}`;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const relativeAsset = (fromFile, toFile) => {
|
|
327
|
+
const relative = posix.relative(posix.dirname(fromFile), toFile);
|
|
328
|
+
const dotted = relative.startsWith(".") ? relative : `./${relative}`;
|
|
329
|
+
return dotted.replaceAll(" ", "%20");
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const walk = (root, skip, dir = "") => {
|
|
333
|
+
const files = [];
|
|
334
|
+
const entries = readdirSync(path.join(root, dir), { withFileTypes: true });
|
|
335
|
+
for (const entry of entries.toSorted((left, right) =>
|
|
336
|
+
compareText(left.name, right.name)
|
|
337
|
+
)) {
|
|
338
|
+
const relative = dir ? `${dir}/${entry.name}` : entry.name;
|
|
339
|
+
if (entry.isDirectory()) {
|
|
340
|
+
if (!skip.has(relative) && !SKIP_DIRS.has(entry.name)) {
|
|
341
|
+
files.push(...walk(root, skip, relative));
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
files.push(relative);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return files;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const resolveUrl = (href, base) => {
|
|
351
|
+
try {
|
|
352
|
+
return new URL(href, base).href;
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const isFile = (file) => existsSync(file) && statSync(file).isFile();
|
|
359
|
+
|
|
360
|
+
const readJson = (file) => JSON.parse(readFileSync(file, "utf-8"));
|
|
361
|
+
|
|
362
|
+
const writeJson = (file, value) =>
|
|
363
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
364
|
+
|
|
365
|
+
const loadEmoji = (file) => {
|
|
366
|
+
const map = new Map();
|
|
367
|
+
if (!file) {
|
|
368
|
+
return map;
|
|
369
|
+
}
|
|
370
|
+
for (const match of readFileSync(file, "utf-8").matchAll(EMOJI_ENTRY)) {
|
|
371
|
+
const points = match.groups.hex
|
|
372
|
+
.split("-")
|
|
373
|
+
.map((hex) => Number.parseInt(hex, 16));
|
|
374
|
+
map.set(match.groups.name, String.fromCodePoint(...points));
|
|
375
|
+
}
|
|
376
|
+
return map;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** `line` with code spans, comments, tags, and link destinations blanked out (same length). */
|
|
380
|
+
const blankOut = (span) => DELETED.repeat(span.length);
|
|
381
|
+
|
|
382
|
+
const mask = (
|
|
383
|
+
line,
|
|
384
|
+
{ comments = true, destinations = false, elements = false, tags = false } = {}
|
|
385
|
+
) => {
|
|
386
|
+
let masked = line.replaceAll(CODE_SPAN, blankOut);
|
|
387
|
+
if (comments) {
|
|
388
|
+
masked = masked.replaceAll(COMMENT, blankOut);
|
|
389
|
+
}
|
|
390
|
+
if (elements) {
|
|
391
|
+
masked = masked.replaceAll(CODE_ELEMENT, blankOut);
|
|
392
|
+
}
|
|
393
|
+
if (tags) {
|
|
394
|
+
masked = masked.replaceAll(TAG, blankOut);
|
|
395
|
+
}
|
|
396
|
+
if (destinations) {
|
|
397
|
+
masked = masked.replaceAll(LINK_DESTINATION, blankOut);
|
|
398
|
+
}
|
|
399
|
+
return masked;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Replace each match of `pattern` (a `d`-flagged global regex) found outside
|
|
404
|
+
* code spans and comments. `replace(group, original)` gets the match's text
|
|
405
|
+
* from the real line, and returns the replacement or null to keep it.
|
|
406
|
+
*/
|
|
407
|
+
const replaceOutside = (line, pattern, replace, options) => {
|
|
408
|
+
const masked = mask(line, options);
|
|
409
|
+
let out = "";
|
|
410
|
+
let last = 0;
|
|
411
|
+
for (const match of masked.matchAll(pattern)) {
|
|
412
|
+
const end = match.index + match[0].length;
|
|
413
|
+
const original = line.slice(match.index, end);
|
|
414
|
+
const group = (name) => {
|
|
415
|
+
const span = match.indices.groups?.[name];
|
|
416
|
+
return span ? line.slice(span[0], span[1]) : null;
|
|
417
|
+
};
|
|
418
|
+
const next = original.includes(DELETED) ? null : replace(group, original);
|
|
419
|
+
out += `${line.slice(last, match.index)}${next ?? original}`;
|
|
420
|
+
last = end;
|
|
421
|
+
}
|
|
422
|
+
return `${out}${line.slice(last)}`;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// --- Arguments -------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
const VALUE_OPTIONS = new Set([
|
|
428
|
+
"content",
|
|
429
|
+
"dist",
|
|
430
|
+
"emoji",
|
|
431
|
+
"mirror",
|
|
432
|
+
"repo",
|
|
433
|
+
"site",
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
const parseArgs = (argv) => {
|
|
437
|
+
const options = new Map();
|
|
438
|
+
const positional = [];
|
|
439
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
440
|
+
const arg = argv[index];
|
|
441
|
+
const [key, inline] = arg.startsWith("--")
|
|
442
|
+
? arg.slice(2).split("=", 2)
|
|
443
|
+
: [];
|
|
444
|
+
if (key === undefined) {
|
|
445
|
+
positional.push(arg);
|
|
446
|
+
} else if (VALUE_OPTIONS.has(key) && inline === undefined) {
|
|
447
|
+
index += 1;
|
|
448
|
+
options.set(key, argv[index]);
|
|
449
|
+
} else {
|
|
450
|
+
options.set(key, inline ?? "");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { options, positional };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const HELP = [
|
|
457
|
+
"Usage:",
|
|
458
|
+
" node github-wiki-codemod.mjs routes --repo <owner/repo> [--write]",
|
|
459
|
+
" node github-wiki-codemod.mjs convert --repo <owner/repo> [--mirror <owner/repo>] [--emoji <file>] [--write]",
|
|
460
|
+
" node github-wiki-codemod.mjs stubs --site <url> [--notice-only] [--write]",
|
|
461
|
+
" node github-wiki-codemod.mjs check [--dist dist] [old wiki URL …]",
|
|
462
|
+
"",
|
|
463
|
+
" --repo <owner/repo> the repository the wiki belongs to",
|
|
464
|
+
" --mirror <owner/repo> a repository that mirrors the wiki",
|
|
465
|
+
" --content <dir> the content root (default: docs)",
|
|
466
|
+
" --emoji <file> shortcode → emoji map (GitHub's /emojis JSON works)",
|
|
467
|
+
" --site <url> the new site, base path included (stubs)",
|
|
468
|
+
" --notice-only stubs: only a notice on Home and the sidebar",
|
|
469
|
+
" --dist <dir> the build to check (default: dist)",
|
|
470
|
+
" --write apply (default: dry run)",
|
|
471
|
+
].join("\n");
|
|
472
|
+
|
|
473
|
+
// --- The wiki ----------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
const REPO = /^(?<owner>[\w.-]+)\/(?<name>[\w.-]+)$/u;
|
|
476
|
+
|
|
477
|
+
const repoUrls = (repo, mirror) => {
|
|
478
|
+
const { owner, name } = REPO.exec(repo).groups;
|
|
479
|
+
const base = `${escapeRegExp(owner)}/${escapeRegExp(name)}`;
|
|
480
|
+
const mirrorMatch = mirror ? REPO.exec(mirror) : null;
|
|
481
|
+
const mirrorBase = mirrorMatch
|
|
482
|
+
? `${escapeRegExp(mirrorMatch.groups.owner)}/${escapeRegExp(mirrorMatch.groups.name)}`
|
|
483
|
+
: null;
|
|
484
|
+
return {
|
|
485
|
+
angle: new RegExp(
|
|
486
|
+
String.raw`<(?<url>https?://(?:www\.)?github\.com/${base}/wiki(?:[/#][^<>\s]*)?)>`,
|
|
487
|
+
"dgiu"
|
|
488
|
+
),
|
|
489
|
+
bare: new RegExp(
|
|
490
|
+
String.raw`(?<![\w(<\["'=/])(?<url>https?://(?:www\.)?github\.com/${base}/wiki(?:[/#][^\s<>()[\]"'\u{60}]*)?)`,
|
|
491
|
+
"dgiu"
|
|
492
|
+
),
|
|
493
|
+
base: `https://github.com/${owner}/${name}/wiki/`,
|
|
494
|
+
mirror: mirrorBase
|
|
495
|
+
? new RegExp(
|
|
496
|
+
String.raw`^https?://(?:www\.)?github\.com/${mirrorBase}/blob/[^/]+/(?<file>[^?#]+)(?<hash>#.*)?$`,
|
|
497
|
+
"iu"
|
|
498
|
+
)
|
|
499
|
+
: null,
|
|
500
|
+
owner,
|
|
501
|
+
raw: new RegExp(
|
|
502
|
+
String.raw`^https?://raw\.githubusercontent\.com/wiki/${base}/(?<file>[^?#]+)`,
|
|
503
|
+
"iu"
|
|
504
|
+
),
|
|
505
|
+
repoRoot: new RegExp(String.raw`^/${base}(?:[/?#]|$)`, "iu"),
|
|
506
|
+
wiki: new RegExp(
|
|
507
|
+
String.raw`^(?:https?://(?:www\.)?github\.com)?/${base}/wiki(?<rest>/[^?#]*)?(?:\?[^#]*)?(?<hash>#.*)?$`,
|
|
508
|
+
"iu"
|
|
509
|
+
),
|
|
510
|
+
};
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/** The wiki's pages, the way GitHub serves them. */
|
|
514
|
+
const listPages = (root, content) => {
|
|
515
|
+
const files = walk(root, new Set([content])).toSorted(byDepthThenName);
|
|
516
|
+
const pages = files.filter((file) => {
|
|
517
|
+
const base = posix.basename(file);
|
|
518
|
+
return MARKUP.test(base) && !CHROME.test(base);
|
|
519
|
+
});
|
|
520
|
+
return { files, pages };
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const buildTable = (pages) => {
|
|
524
|
+
const routes = {};
|
|
525
|
+
const sources = {};
|
|
526
|
+
const taken = new Map();
|
|
527
|
+
const owners = new Map();
|
|
528
|
+
const shadowed = [];
|
|
529
|
+
const clashes = [];
|
|
530
|
+
for (const file of pages) {
|
|
531
|
+
const name = pageNameOf(file);
|
|
532
|
+
const key = keyOf(name);
|
|
533
|
+
if (taken.has(key)) {
|
|
534
|
+
shadowed.push(`${file} (GitHub served ${taken.get(key)})`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
taken.set(key, file);
|
|
538
|
+
const route = ownRouteOf(name);
|
|
539
|
+
if (owners.has(route)) {
|
|
540
|
+
clashes.push(`${file} and ${owners.get(route)} → ${route}`);
|
|
541
|
+
}
|
|
542
|
+
owners.set(route, file);
|
|
543
|
+
routes[name] = route;
|
|
544
|
+
sources[name] = file;
|
|
545
|
+
}
|
|
546
|
+
return { clashes, routes, shadowed, sources };
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const kindOf = (name, value, owners) => {
|
|
550
|
+
if (value === null) {
|
|
551
|
+
return "dropped";
|
|
552
|
+
}
|
|
553
|
+
if (URL_SCHEME.test(value) || value.includes("#")) {
|
|
554
|
+
return "moved";
|
|
555
|
+
}
|
|
556
|
+
const owner = owners.get(value);
|
|
557
|
+
return owner && owner !== name ? "moved" : "content";
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/** Each page's role from the classified table: content, moved, or dropped. */
|
|
561
|
+
const classify = (routes) => {
|
|
562
|
+
const owners = new Map(
|
|
563
|
+
Object.keys(routes).map((name) => [ownRouteOf(name), name])
|
|
564
|
+
);
|
|
565
|
+
const kinds = new Map();
|
|
566
|
+
const byKey = new Map();
|
|
567
|
+
for (const [name, value] of Object.entries(routes)) {
|
|
568
|
+
kinds.set(name, kindOf(name, value, owners));
|
|
569
|
+
byKey.set(keyOf(name), name);
|
|
570
|
+
}
|
|
571
|
+
return { byKey, kinds, routes };
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const findPage = (index, target) =>
|
|
575
|
+
index.byKey.get(keyOf(target)) ??
|
|
576
|
+
index.byKey.get(keyOf(target.replaceAll("_", "-"))) ??
|
|
577
|
+
null;
|
|
578
|
+
|
|
579
|
+
/** A page target (`Page Name#anchor`) → { kind, name, href }. */
|
|
580
|
+
const resolvePage = (index, target, self) => {
|
|
581
|
+
const hashAt = target.indexOf("#");
|
|
582
|
+
const page = hashAt === -1 ? target : target.slice(0, hashAt);
|
|
583
|
+
const anchor = hashAt === -1 ? "" : cleanAnchor(target.slice(hashAt + 1));
|
|
584
|
+
if (page.trim() === "") {
|
|
585
|
+
return anchor
|
|
586
|
+
? { href: `#${anchor}`, kind: "content", name: self }
|
|
587
|
+
: { kind: "missing", name: target };
|
|
588
|
+
}
|
|
589
|
+
const name =
|
|
590
|
+
findPage(index, page) ?? findPage(index, page.replace(MARKUP, ""));
|
|
591
|
+
if (name === null) {
|
|
592
|
+
return { kind: "missing", name: page };
|
|
593
|
+
}
|
|
594
|
+
const kind = index.kinds.get(name);
|
|
595
|
+
const value = index.routes[name];
|
|
596
|
+
if (kind === "dropped") {
|
|
597
|
+
return { kind, name };
|
|
598
|
+
}
|
|
599
|
+
if (kind === "moved") {
|
|
600
|
+
return { href: value, kind, name };
|
|
601
|
+
}
|
|
602
|
+
return { href: anchor ? `${value}#${anchor}` : value, kind, name };
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/** What a URL into the wiki names: a page, a file stored in the wiki, or GitHub's UI. */
|
|
606
|
+
const wikiTarget = (ctx, href) => {
|
|
607
|
+
const match = ctx.urls.wiki.exec(href);
|
|
608
|
+
if (!match) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
const rest = decode((match.groups.rest ?? "").replace(/^\//u, "")).replace(
|
|
612
|
+
/\/$/u,
|
|
613
|
+
""
|
|
614
|
+
);
|
|
615
|
+
const hash = match.groups.hash ?? "";
|
|
616
|
+
if (rest === "") {
|
|
617
|
+
return { kind: "page", target: `Home${hash}` };
|
|
618
|
+
}
|
|
619
|
+
if (ctx.fileExists(rest) && !MARKUP.test(rest)) {
|
|
620
|
+
return { file: rest, kind: "file" };
|
|
621
|
+
}
|
|
622
|
+
const [first, ...more] = rest.split("/");
|
|
623
|
+
const revision =
|
|
624
|
+
more.length === 1 && /^(?:_history|[0-9a-f]{7,40})$/iu.test(more[0]);
|
|
625
|
+
if (UI_PAGE.test(first) || revision) {
|
|
626
|
+
return { kind: "ui" };
|
|
627
|
+
}
|
|
628
|
+
if (more.length > 0) {
|
|
629
|
+
// GitHub serves a page's file at its path (`/wiki/dir/Page.md`) raw.
|
|
630
|
+
const name = MARKUP.test(rest)
|
|
631
|
+
? findPage(ctx.index, pageNameOf(rest))
|
|
632
|
+
: null;
|
|
633
|
+
return name !== null && ctx.files[name] === rest
|
|
634
|
+
? { kind: "page", raw: true, target: `${name}${hash}` }
|
|
635
|
+
: { kind: "unknown" };
|
|
636
|
+
}
|
|
637
|
+
return { kind: "page", raw: MARKUP.test(first), target: `${first}${hash}` };
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/** A wiki file an image source names, `{ outside: true }` for a path above the wiki, or null. */
|
|
641
|
+
const wikiFileOf = (ctx, src) => {
|
|
642
|
+
const raw = ctx.urls.raw.exec(src);
|
|
643
|
+
if (raw) {
|
|
644
|
+
return { file: decode(raw.groups.file) };
|
|
645
|
+
}
|
|
646
|
+
const mirrored = ctx.urls.mirror?.exec(src);
|
|
647
|
+
if (mirrored) {
|
|
648
|
+
return { file: decode(mirrored.groups.file) };
|
|
649
|
+
}
|
|
650
|
+
if (src.startsWith("#") || src.startsWith("//")) {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const relative = !URL_SCHEME.test(src) && !src.startsWith("/");
|
|
654
|
+
const absolute = relative
|
|
655
|
+
? resolveUrl(src, `${ctx.urls.base}${encodeURIComponent(ctx.name)}`)
|
|
656
|
+
: src;
|
|
657
|
+
const match = absolute === null ? null : ctx.urls.wiki.exec(absolute);
|
|
658
|
+
if (match) {
|
|
659
|
+
const rest = decode((match.groups.rest ?? "").replace(/^\//u, ""));
|
|
660
|
+
return posix.extname(rest) === "" ? null : { file: rest };
|
|
661
|
+
}
|
|
662
|
+
return relative && absolute !== null ? { outside: true } : null;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// --- Findings ----------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
const createPage = (file, name) => ({
|
|
668
|
+
edits: {},
|
|
669
|
+
file,
|
|
670
|
+
name,
|
|
671
|
+
reviews: [],
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const edit = (page, kind, count = 1) => {
|
|
675
|
+
page.edits[kind] = (page.edits[kind] ?? 0) + count;
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const review = (page, line, message) => {
|
|
679
|
+
page.reviews.push({ line, message });
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// --- Links and images -----------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
/** An image source → its new source, "" to drop an image GitHub couldn't show either, or null (keep). */
|
|
685
|
+
const rewriteImage = (ctx, rawSrc) => {
|
|
686
|
+
const src = unwrapAngle(rawSrc);
|
|
687
|
+
if (ctx.done.has(src)) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
const found = wikiFileOf(ctx, src);
|
|
691
|
+
const relative = !URL_SCHEME.test(src) && !src.startsWith("/");
|
|
692
|
+
if (found?.outside || (found && relative && !ctx.fileExists(found.file))) {
|
|
693
|
+
review(
|
|
694
|
+
ctx.page,
|
|
695
|
+
ctx.line,
|
|
696
|
+
`image ${src} pointed at nothing on GitHub (${found.outside ? "above the wiki root" : "no such file"}): dropped`
|
|
697
|
+
);
|
|
698
|
+
edit(ctx.page, "broken image dropped");
|
|
699
|
+
return "";
|
|
700
|
+
}
|
|
701
|
+
if (found && !ctx.fileExists(found.file)) {
|
|
702
|
+
review(
|
|
703
|
+
ctx.page,
|
|
704
|
+
ctx.line,
|
|
705
|
+
`image ${src} names a file the wiki doesn't have`
|
|
706
|
+
);
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
if (found) {
|
|
710
|
+
ctx.images.add(found.file);
|
|
711
|
+
edit(ctx.page, "wiki image → file beside the pages");
|
|
712
|
+
const moved = relativeAsset(ctx.dest, `${ctx.content}/${found.file}`);
|
|
713
|
+
ctx.done.add(moved);
|
|
714
|
+
return moved;
|
|
715
|
+
}
|
|
716
|
+
if (ATTACHMENT.test(src)) {
|
|
717
|
+
review(ctx.page, ctx.line, `uploaded attachment ${src}: vendor it`);
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
const blob = BLOB.exec(src);
|
|
721
|
+
if (blob) {
|
|
722
|
+
edit(ctx.page, "blob image URL → raw URL");
|
|
723
|
+
return `https://raw.githubusercontent.com/${blob.groups.owner}/${blob.groups.repo}/${blob.groups.rest}`;
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
/** A page target → `{ href }`; a wiki link to a missing page, or any link to a left-out page, → `{ unlink }`. */
|
|
729
|
+
const pageHref = (ctx, target, how, hint = "") => {
|
|
730
|
+
const result = resolvePage(ctx.index, target, ctx.name);
|
|
731
|
+
if (result.kind === "missing" && how !== "[[wiki link]]") {
|
|
732
|
+
review(
|
|
733
|
+
ctx.page,
|
|
734
|
+
ctx.line,
|
|
735
|
+
`${how} ${target} names no page in the wiki, so it was broken on GitHub too: retarget it or unlink it`
|
|
736
|
+
);
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
if (result.kind === "missing" || result.kind === "dropped") {
|
|
740
|
+
const why =
|
|
741
|
+
result.kind === "missing"
|
|
742
|
+
? "names no page in the wiki"
|
|
743
|
+
: "names a page you left out";
|
|
744
|
+
review(
|
|
745
|
+
ctx.page,
|
|
746
|
+
ctx.line,
|
|
747
|
+
`${how} ${target} ${why}: made plain text${hint}`
|
|
748
|
+
);
|
|
749
|
+
return { unlink: true };
|
|
750
|
+
}
|
|
751
|
+
if (result.kind === "moved") {
|
|
752
|
+
edit(ctx.page, `${how} to a moved page → its target`);
|
|
753
|
+
} else {
|
|
754
|
+
edit(ctx.page, `${how} → route`);
|
|
755
|
+
}
|
|
756
|
+
return { href: result.href };
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
/** A Markdown or HTML link destination → `{ href }`, `{ unlink: true }`, or null (keep). */
|
|
760
|
+
const anchorHref = (ctx, dest) => {
|
|
761
|
+
const anchor = cleanAnchor(dest.slice(1));
|
|
762
|
+
if (anchor === dest.slice(1)) {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
edit(ctx.page, "#user-content- anchor → its id");
|
|
766
|
+
return { href: `#${anchor}` };
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
/** A root path: into the wiki (handled with absolute URLs), elsewhere in the repository, or neither. */
|
|
770
|
+
const rootPathHref = (ctx, dest) => {
|
|
771
|
+
if (ctx.urls.wiki.test(dest)) {
|
|
772
|
+
return { wiki: true };
|
|
773
|
+
}
|
|
774
|
+
if (ctx.urls.repoRoot.test(dest)) {
|
|
775
|
+
edit(ctx.page, "repository path → absolute GitHub URL");
|
|
776
|
+
return { href: `https://github.com${dest}` };
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const wikiHref = (ctx, dest, target, relative) => {
|
|
782
|
+
if (target.kind === "ui") {
|
|
783
|
+
review(
|
|
784
|
+
ctx.page,
|
|
785
|
+
ctx.line,
|
|
786
|
+
`${dest} points at the wiki's UI: it stops working with the wiki`
|
|
787
|
+
);
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
if (target.kind === "file") {
|
|
791
|
+
review(
|
|
792
|
+
ctx.page,
|
|
793
|
+
ctx.line,
|
|
794
|
+
`${dest} links ${target.file}, a file stored in the wiki: copy it to public/ and link /${target.file}`
|
|
795
|
+
);
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
if (target.kind === "unknown") {
|
|
799
|
+
review(
|
|
800
|
+
ctx.page,
|
|
801
|
+
ctx.line,
|
|
802
|
+
`${dest} names no page or file in the wiki, so it was broken on GitHub too: retarget it or unlink it`
|
|
803
|
+
);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
const result = pageHref(
|
|
807
|
+
ctx,
|
|
808
|
+
decode(target.target),
|
|
809
|
+
relative ? "page-name link" : "wiki URL"
|
|
810
|
+
);
|
|
811
|
+
if (target.raw && result?.href) {
|
|
812
|
+
review(
|
|
813
|
+
ctx.page,
|
|
814
|
+
ctx.line,
|
|
815
|
+
`${dest} opened the page's raw file on GitHub: now its page`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return result;
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
/** A Markdown or HTML link destination → `{ href }`, `{ unlink: true }`, or null (keep). */
|
|
822
|
+
const rewriteHref = (ctx, rawDest) => {
|
|
823
|
+
const dest = unwrapAngle(rawDest);
|
|
824
|
+
if (dest.startsWith("#")) {
|
|
825
|
+
return anchorHref(ctx, dest);
|
|
826
|
+
}
|
|
827
|
+
const mirrored = ctx.urls.mirror?.exec(dest);
|
|
828
|
+
if (mirrored && MARKUP.test(mirrored.groups.file)) {
|
|
829
|
+
const name = pageNameOf(decode(mirrored.groups.file));
|
|
830
|
+
return pageHref(ctx, `${name}${mirrored.groups.hash ?? ""}`, "mirror link");
|
|
831
|
+
}
|
|
832
|
+
if (dest.startsWith("/") && !dest.startsWith("//")) {
|
|
833
|
+
const rooted = rootPathHref(ctx, dest);
|
|
834
|
+
if (rooted === null || rooted.href) {
|
|
835
|
+
return rooted;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const relative =
|
|
839
|
+
!URL_SCHEME.test(dest) && !dest.startsWith("/") && dest !== "";
|
|
840
|
+
const absolute = relative
|
|
841
|
+
? resolveUrl(dest, `${ctx.urls.base}${encodeURIComponent(ctx.name)}`)
|
|
842
|
+
: dest;
|
|
843
|
+
const target = absolute === null ? null : wikiTarget(ctx, absolute);
|
|
844
|
+
if (target !== null) {
|
|
845
|
+
return wikiHref(ctx, dest, target, relative);
|
|
846
|
+
}
|
|
847
|
+
if (relative && absolute !== null) {
|
|
848
|
+
edit(ctx.page, "relative link outside the wiki → absolute GitHub URL");
|
|
849
|
+
return { href: absolute };
|
|
850
|
+
}
|
|
851
|
+
return null;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const escapeHtml = (text) =>
|
|
855
|
+
text
|
|
856
|
+
.replaceAll("&", "&")
|
|
857
|
+
.replaceAll("<", "<")
|
|
858
|
+
.replaceAll(">", ">")
|
|
859
|
+
.replaceAll('"', """);
|
|
860
|
+
|
|
861
|
+
/** A link in the page's syntax: Markdown, or `<a>` inside an HTML block, where Markdown stays literal. */
|
|
862
|
+
const linkMarkup = (ctx, text, href) => {
|
|
863
|
+
if (!ctx.html) {
|
|
864
|
+
return `[${text}](${href})`;
|
|
865
|
+
}
|
|
866
|
+
edit(ctx.page, "link in an HTML block written as <a href>");
|
|
867
|
+
return `<a href="${escapeHtml(href)}">${escapeHtml(text)}</a>`;
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
/** A wiki image tag's path: `/x.png` is the wiki root; a path the root lacks may sit in the page's folder. */
|
|
871
|
+
const imageTagPath = (ctx, first) => {
|
|
872
|
+
const file = first.replace(/^\/(?!\/)/u, "");
|
|
873
|
+
const folder = posix.dirname(ctx.page.file);
|
|
874
|
+
if (
|
|
875
|
+
URL_SCHEME.test(file) ||
|
|
876
|
+
ctx.fileExists(file) ||
|
|
877
|
+
folder === "." ||
|
|
878
|
+
!ctx.fileExists(posix.join(folder, file))
|
|
879
|
+
) {
|
|
880
|
+
return file;
|
|
881
|
+
}
|
|
882
|
+
review(
|
|
883
|
+
ctx.page,
|
|
884
|
+
ctx.line,
|
|
885
|
+
`image ${first} found only in the page's folder (${folder}/), which is where gollum looks: check the live page`
|
|
886
|
+
);
|
|
887
|
+
return posix.join(folder, file);
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
/** `[[image.png|alt=Text,frame]]`: an image whenever the first part names one, as on GitHub. */
|
|
891
|
+
const convertImageTag = (ctx, first, parts) => {
|
|
892
|
+
const options = parts
|
|
893
|
+
.slice(1)
|
|
894
|
+
.flatMap((part) => part.split(","))
|
|
895
|
+
.map((option) => option.trim())
|
|
896
|
+
.filter(Boolean);
|
|
897
|
+
const alt =
|
|
898
|
+
options
|
|
899
|
+
.find((option) => /^alt\s*=/iu.test(option))
|
|
900
|
+
?.replace(/^alt\s*=\s*/iu, "") ?? "";
|
|
901
|
+
const dropped = options.filter((option) => !/^alt\s*=/iu.test(option));
|
|
902
|
+
if (dropped.length > 0) {
|
|
903
|
+
review(ctx.page, ctx.line, `image options dropped: ${dropped.join(", ")}`);
|
|
904
|
+
}
|
|
905
|
+
const file = imageTagPath(ctx, first);
|
|
906
|
+
const src = rewriteImage(ctx, file);
|
|
907
|
+
if (src === "") {
|
|
908
|
+
return "";
|
|
909
|
+
}
|
|
910
|
+
if (ctx.html) {
|
|
911
|
+
review(
|
|
912
|
+
ctx.page,
|
|
913
|
+
ctx.line,
|
|
914
|
+
`[[${first}]] sits in an HTML block, where a Markdown image shows as text: write it as <img src="${src ?? file}" />, which Blume publishes with the page, or move the image out of the block`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
edit(ctx.page, "[[image]] → Markdown image");
|
|
918
|
+
return ``;
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
/** A wiki link's target that names a file stored in the wiki (`[[Spec|docs/spec.pdf]]`), not a page. */
|
|
922
|
+
const isWikiFile = (ctx, target) => {
|
|
923
|
+
const file = target.replace(/^\//u, "");
|
|
924
|
+
return (
|
|
925
|
+
posix.extname(file) !== "" &&
|
|
926
|
+
!MARKUP.test(file) &&
|
|
927
|
+
findPage(ctx.index, file) === null &&
|
|
928
|
+
ctx.fileExists(file)
|
|
929
|
+
);
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
/** For `[[Page|text]]` in a Markdown page: the target-first order GitHub's docs show, which only MediaWiki pages use. */
|
|
933
|
+
const swappedHint = (ctx, parts) => {
|
|
934
|
+
if (
|
|
935
|
+
parts.length !== 2 ||
|
|
936
|
+
resolvePage(ctx.index, parts[1].trim(), ctx.name).kind !== "missing" ||
|
|
937
|
+
resolvePage(ctx.index, parts[0].trim(), ctx.name).kind === "missing"
|
|
938
|
+
) {
|
|
939
|
+
return "";
|
|
940
|
+
}
|
|
941
|
+
return ` (${parts[0].trim()} is a page: a Markdown page puts the text first, so GitHub showed a link to a missing page too)`;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const convertWikiLink = (ctx, inner) => {
|
|
945
|
+
const parts = inner.split(PIPE);
|
|
946
|
+
const first = parts[0].trim();
|
|
947
|
+
if (first === "_TOC_") {
|
|
948
|
+
edit(ctx.page, "[[_TOC_]] removed");
|
|
949
|
+
return "";
|
|
950
|
+
}
|
|
951
|
+
if (IMAGE.test(first.split("#")[0])) {
|
|
952
|
+
return convertImageTag(ctx, first, parts);
|
|
953
|
+
}
|
|
954
|
+
if (parts.length > 2) {
|
|
955
|
+
review(
|
|
956
|
+
ctx.page,
|
|
957
|
+
ctx.line,
|
|
958
|
+
`[[${inner}]] has more than one |: convert it by hand`
|
|
959
|
+
);
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
const text = first;
|
|
963
|
+
const target = (parts[1] ?? parts[0]).trim();
|
|
964
|
+
if (EXTERNAL.test(target)) {
|
|
965
|
+
edit(ctx.page, "[[text|URL]] → link");
|
|
966
|
+
return linkMarkup(ctx, text, target);
|
|
967
|
+
}
|
|
968
|
+
if (isWikiFile(ctx, target)) {
|
|
969
|
+
review(
|
|
970
|
+
ctx.page,
|
|
971
|
+
ctx.line,
|
|
972
|
+
`[[${inner}]] links ${target}, a file stored in the wiki: copy it to public/ and link /${target.replace(/^\//u, "")}`
|
|
973
|
+
);
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
const result = pageHref(
|
|
977
|
+
ctx,
|
|
978
|
+
target,
|
|
979
|
+
"[[wiki link]]",
|
|
980
|
+
swappedHint(ctx, parts)
|
|
981
|
+
);
|
|
982
|
+
return result.unlink ? text : linkMarkup(ctx, text, result.href);
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
/** One `[[…]]` match: `'[[…]]` stays (gollum's escape), the rest convert. */
|
|
986
|
+
const convertWikiMatch = (ctx, group) => {
|
|
987
|
+
const quote = group("quote");
|
|
988
|
+
const after = group("after");
|
|
989
|
+
if (quote && !after) {
|
|
990
|
+
review(
|
|
991
|
+
ctx.page,
|
|
992
|
+
ctx.line,
|
|
993
|
+
`'[[${group("inner")}]] is gollum's escape for literal brackets, so it was left as is: check the live page, and drop the ' if it showed the brackets`
|
|
994
|
+
);
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
const converted = convertWikiLink(ctx, group("inner"));
|
|
998
|
+
return converted === null ? null : `${quote}${converted}${after}`;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const convertImages = (ctx, line) =>
|
|
1002
|
+
replaceOutside(line, MD_IMAGE, (group) => {
|
|
1003
|
+
const src = rewriteImage(ctx, group("dest"));
|
|
1004
|
+
if (src === null || src === "") {
|
|
1005
|
+
return src;
|
|
1006
|
+
}
|
|
1007
|
+
return ` ?? ""})`;
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
const convertLinks = (ctx, line) =>
|
|
1011
|
+
replaceOutside(line, MD_LINK, (group) => {
|
|
1012
|
+
const label = group("label");
|
|
1013
|
+
const result = rewriteHref(ctx, group("dest"));
|
|
1014
|
+
if (result === null) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
if (result.unlink) {
|
|
1018
|
+
return label;
|
|
1019
|
+
}
|
|
1020
|
+
const title = group("title") ?? "";
|
|
1021
|
+
// Pandoc writes a MediaWiki link's text as its title too.
|
|
1022
|
+
const keepTitle = title && title.trim().slice(1, -1) !== label;
|
|
1023
|
+
return `[${label}](${result.href}${keepTitle ? title : ""})`;
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
const convertRawHrefs = (ctx, line) =>
|
|
1027
|
+
replaceOutside(line, RAW_HREF, (group, original) => {
|
|
1028
|
+
const dest = group("dest");
|
|
1029
|
+
const result = rewriteHref(ctx, dest);
|
|
1030
|
+
if (result === null || result.unlink) {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
return original.replace(dest, result.href);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const urlReplacement = (ctx, url) => {
|
|
1037
|
+
const target = wikiTarget(ctx, url);
|
|
1038
|
+
if (target?.kind !== "page") {
|
|
1039
|
+
review(ctx.page, ctx.line, `bare wiki URL left as is: ${url}`);
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
const result = resolvePage(ctx.index, decode(target.target), ctx.name);
|
|
1043
|
+
if (result.kind === "missing" || result.kind === "dropped") {
|
|
1044
|
+
review(
|
|
1045
|
+
ctx.page,
|
|
1046
|
+
ctx.line,
|
|
1047
|
+
`bare wiki URL to a page that isn't kept: ${url}`
|
|
1048
|
+
);
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
edit(ctx.page, "bare wiki URL → link");
|
|
1052
|
+
return linkMarkup(ctx, titleOf(result.name), result.href);
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
const convertBareUrls = (ctx, line) => {
|
|
1056
|
+
const angled = replaceOutside(line, ctx.urls.angle, (group) =>
|
|
1057
|
+
urlReplacement(ctx, group("url"))
|
|
1058
|
+
);
|
|
1059
|
+
return replaceOutside(angled, ctx.urls.bare, (group) => {
|
|
1060
|
+
const url = group("url");
|
|
1061
|
+
const trail = TRAILING_PUNCTUATION.exec(url)?.[0] ?? "";
|
|
1062
|
+
const link = urlReplacement(ctx, url.slice(0, url.length - trail.length));
|
|
1063
|
+
return link === null ? null : `${link}${trail}`;
|
|
1064
|
+
});
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const convertEmoji = (ctx, line, heading) =>
|
|
1068
|
+
replaceOutside(
|
|
1069
|
+
line,
|
|
1070
|
+
SHORTCODE,
|
|
1071
|
+
(group, original) => {
|
|
1072
|
+
const emoji = ctx.emoji.get(group("name"));
|
|
1073
|
+
if (!emoji) {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
if (heading) {
|
|
1077
|
+
review(
|
|
1078
|
+
ctx.page,
|
|
1079
|
+
ctx.line,
|
|
1080
|
+
`${original} in a heading: GitHub's id spelled the name; convert it and pin the old id with [#…]`
|
|
1081
|
+
);
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
edit(ctx.page, "emoji shortcode → emoji");
|
|
1085
|
+
return emoji;
|
|
1086
|
+
},
|
|
1087
|
+
{ destinations: true }
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
/** `\[\[…\]\]` is how pandoc writes a wiki link; in a page that was always Markdown, GitHub showed the brackets. */
|
|
1091
|
+
const convertEscapedWikiLink = (ctx, inner) => {
|
|
1092
|
+
const converted = convertWikiLink(ctx, inner.replaceAll(String.raw`\|`, "|"));
|
|
1093
|
+
if (converted !== null) {
|
|
1094
|
+
review(
|
|
1095
|
+
ctx.page,
|
|
1096
|
+
ctx.line,
|
|
1097
|
+
String.raw`\[\[${inner}\]\] read as a wiki link (pandoc's escape): if this page was Markdown on GitHub, it showed the brackets, so restore them`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
return converted;
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const convertInline = (ctx, line, heading = false) => {
|
|
1104
|
+
let next = replaceOutside(
|
|
1105
|
+
line,
|
|
1106
|
+
WIKI_LINK,
|
|
1107
|
+
(group) => convertWikiMatch(ctx, group),
|
|
1108
|
+
{ elements: true }
|
|
1109
|
+
);
|
|
1110
|
+
next = replaceOutside(
|
|
1111
|
+
next,
|
|
1112
|
+
ESCAPED_WIKI_LINK,
|
|
1113
|
+
(group) => convertEscapedWikiLink(ctx, group("inner")),
|
|
1114
|
+
{ elements: true }
|
|
1115
|
+
);
|
|
1116
|
+
next = convertImages(ctx, next);
|
|
1117
|
+
next = convertLinks(ctx, next);
|
|
1118
|
+
next = convertRawHrefs(ctx, next);
|
|
1119
|
+
next = convertBareUrls(ctx, next);
|
|
1120
|
+
return ctx.emoji.size > 0 ? convertEmoji(ctx, next, heading) : next;
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
// --- Line structure -------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
const indentWidth = (text) =>
|
|
1126
|
+
[...text].reduce((width, char) => width + (char === "\t" ? 4 : 1), 0);
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* One state per line: `open`/`fence`/`close` for fenced code (any indent, in
|
|
1130
|
+
* a quote or not), `comment` inside a multi-line HTML comment, `indented` for
|
|
1131
|
+
* indented code, `prose` otherwise.
|
|
1132
|
+
*/
|
|
1133
|
+
const closesFence = (fence, line) => {
|
|
1134
|
+
const close = FENCE.exec(line);
|
|
1135
|
+
return (
|
|
1136
|
+
close?.groups.marker[0] === fence.char &&
|
|
1137
|
+
close.groups.marker.length >= fence.length &&
|
|
1138
|
+
close.groups.info.trim() === ""
|
|
1139
|
+
);
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const opensFence = (line) => {
|
|
1143
|
+
const open = FENCE.exec(line);
|
|
1144
|
+
if (
|
|
1145
|
+
open === null ||
|
|
1146
|
+
(open.groups.marker.startsWith("`") && open.groups.info.includes("`"))
|
|
1147
|
+
) {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
return { char: open.groups.marker[0], length: open.groups.marker.length };
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
/** The content column of the list a line belongs to, or -1 outside lists. */
|
|
1154
|
+
const nextListIndent = (line, listIndent) => {
|
|
1155
|
+
if (line.trim() === "") {
|
|
1156
|
+
return listIndent;
|
|
1157
|
+
}
|
|
1158
|
+
const width = indentWidth(/^[ \t]*/u.exec(line)[0]);
|
|
1159
|
+
const item = LIST_ITEM.exec(line);
|
|
1160
|
+
if (item && width < listIndent + 4) {
|
|
1161
|
+
return indentWidth(
|
|
1162
|
+
`${item.groups.indent}${item.groups.marker}${item.groups.gap}`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return width === 0 ? -1 : listIndent;
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* One state per line: `open`/`fence`/`close` for fenced code (any indent, in
|
|
1170
|
+
* a quote or not), `comment` inside a multi-line HTML comment, `indented` for
|
|
1171
|
+
* indented code, `prose` otherwise.
|
|
1172
|
+
*/
|
|
1173
|
+
const lineStates = (lines) => {
|
|
1174
|
+
const states = [];
|
|
1175
|
+
let fence = null;
|
|
1176
|
+
let comment = false;
|
|
1177
|
+
let previousBlank = true;
|
|
1178
|
+
let listIndent = -1;
|
|
1179
|
+
for (const line of lines) {
|
|
1180
|
+
if (fence) {
|
|
1181
|
+
const closes = closesFence(fence, line);
|
|
1182
|
+
states.push(closes ? "close" : "fence");
|
|
1183
|
+
fence = closes ? null : fence;
|
|
1184
|
+
previousBlank = false;
|
|
1185
|
+
} else if (comment) {
|
|
1186
|
+
states.push("comment");
|
|
1187
|
+
comment = !line.includes("-->");
|
|
1188
|
+
} else {
|
|
1189
|
+
fence = opensFence(line);
|
|
1190
|
+
if (fence) {
|
|
1191
|
+
states.push("open");
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const blank = line.trim() === "";
|
|
1195
|
+
listIndent = nextListIndent(line, listIndent);
|
|
1196
|
+
const width = indentWidth(/^[ \t]*/u.exec(line)[0]);
|
|
1197
|
+
const codeIndent = listIndent === -1 ? 4 : listIndent + 4;
|
|
1198
|
+
const indented =
|
|
1199
|
+
!blank &&
|
|
1200
|
+
width >= codeIndent &&
|
|
1201
|
+
(previousBlank || states.at(-1) === "indented");
|
|
1202
|
+
states.push(indented ? "indented" : "prose");
|
|
1203
|
+
comment =
|
|
1204
|
+
!indented &&
|
|
1205
|
+
line.trimStart().startsWith("<!--") &&
|
|
1206
|
+
!line.includes("-->");
|
|
1207
|
+
previousBlank = blank;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return states;
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const isCode = (state) => state !== "prose";
|
|
1214
|
+
|
|
1215
|
+
// CommonMark HTML blocks, whose lines stay raw HTML: Markdown written there
|
|
1216
|
+
// shows as text, while GitHub turned `[[…]]` in them into links.
|
|
1217
|
+
const HTML_BLOCK_RAW = /^ {0,3}<(?<tag>pre|script|style|textarea)(?:[\s>]|$)/iu;
|
|
1218
|
+
const HTML_BLOCK_TAG = new RegExp(
|
|
1219
|
+
String.raw`^ {0,3}<\/?(?:${[
|
|
1220
|
+
"address article aside base basefont blockquote body caption center col",
|
|
1221
|
+
"colgroup dd details dialog dir div dl dt fieldset figcaption figure footer",
|
|
1222
|
+
"form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend",
|
|
1223
|
+
"li link main menu menuitem nav noframes ol optgroup option p param search",
|
|
1224
|
+
"section summary table tbody td tfoot th thead title tr track ul",
|
|
1225
|
+
]
|
|
1226
|
+
.join(" ")
|
|
1227
|
+
.replaceAll(" ", "|")})(?:[\s>]|\/>|$)`,
|
|
1228
|
+
"iu"
|
|
1229
|
+
);
|
|
1230
|
+
const HTML_BLOCK_LONE_TAG =
|
|
1231
|
+
/^ {0,3}(?:<[A-Za-z][\w-]*(?:\s+[^<>]*)?\/?>|<\/[A-Za-z][\w-]*\s*>)[ \t]*$/u;
|
|
1232
|
+
|
|
1233
|
+
/** The HTML block opening at `index`: `until` is its closing tag, or null when a blank line ends it. */
|
|
1234
|
+
const htmlBlockStart = (lines, index) => {
|
|
1235
|
+
const line = lines[index];
|
|
1236
|
+
const raw = HTML_BLOCK_RAW.exec(line);
|
|
1237
|
+
if (raw) {
|
|
1238
|
+
return { until: new RegExp(String.raw`</${raw.groups.tag}\s*>`, "iu") };
|
|
1239
|
+
}
|
|
1240
|
+
const previousBlank = index === 0 || lines[index - 1].trim() === "";
|
|
1241
|
+
return HTML_BLOCK_TAG.test(line) ||
|
|
1242
|
+
(previousBlank && HTML_BLOCK_LONE_TAG.test(line))
|
|
1243
|
+
? { until: null }
|
|
1244
|
+
: null;
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
/** The indexes of prose lines inside an HTML block. */
|
|
1248
|
+
const htmlBlockLines = (lines, states) => {
|
|
1249
|
+
const inside = new Set();
|
|
1250
|
+
let block = null;
|
|
1251
|
+
for (const [index, line] of lines.entries()) {
|
|
1252
|
+
if (states[index] !== "prose") {
|
|
1253
|
+
block = null;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
block ??= htmlBlockStart(lines, index);
|
|
1257
|
+
if (block === null || (block.until === null && line.trim() === "")) {
|
|
1258
|
+
block = null;
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
inside.add(index);
|
|
1262
|
+
if (block.until?.test(line)) {
|
|
1263
|
+
block = null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return inside;
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
/** The lines of the paragraph ending at `index`, or null if it isn't a plain paragraph. */
|
|
1270
|
+
const paragraphAbove = (lines, states, index) => {
|
|
1271
|
+
let start = index;
|
|
1272
|
+
while (
|
|
1273
|
+
start > 0 &&
|
|
1274
|
+
states[start - 1] === "prose" &&
|
|
1275
|
+
lines[start - 1].trim() !== ""
|
|
1276
|
+
) {
|
|
1277
|
+
start -= 1;
|
|
1278
|
+
}
|
|
1279
|
+
if (start === index) {
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
for (let line = start; line < index; line += 1) {
|
|
1283
|
+
const text = lines[line];
|
|
1284
|
+
if (
|
|
1285
|
+
LIST_ITEM.test(text) ||
|
|
1286
|
+
QUOTED.test(text) ||
|
|
1287
|
+
ATX.test(text) ||
|
|
1288
|
+
DEFINITION.test(text) ||
|
|
1289
|
+
/^(?: {4}|\t)/u.test(text) ||
|
|
1290
|
+
/^\s*[|<]/u.test(text)
|
|
1291
|
+
) {
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return { count: index - start, start };
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// --- Page content ------------------------------------------------------------------
|
|
1299
|
+
|
|
1300
|
+
const removeFoam = (page, lines) => {
|
|
1301
|
+
const begin = lines.findIndex((line) => FOAM_BEGIN.test(line));
|
|
1302
|
+
const end = lines.findIndex((line) => FOAM_END.test(line));
|
|
1303
|
+
if (begin === -1 || end < begin) {
|
|
1304
|
+
return lines;
|
|
1305
|
+
}
|
|
1306
|
+
edit(page, "Foam autogenerated link block removed");
|
|
1307
|
+
return [...lines.slice(0, begin), ...lines.slice(end + 1)];
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Top-level alerts → `:::` directives. Blume renders an alert as a callout in
|
|
1312
|
+
* `.mdx` too, so one this can't rewrite line by line (indented in a list, or
|
|
1313
|
+
* with lazy lines) stays as written and only makes the page `.mdx`.
|
|
1314
|
+
*/
|
|
1315
|
+
const convertAlerts = (page, lines, states) => {
|
|
1316
|
+
const out = [];
|
|
1317
|
+
let converted = 0;
|
|
1318
|
+
let kept = 0;
|
|
1319
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1320
|
+
const start =
|
|
1321
|
+
states[index] === "prose" ? ALERT_START.exec(lines[index]) : null;
|
|
1322
|
+
if (start === null) {
|
|
1323
|
+
if (states[index] === "prose" && ANY_ALERT.test(lines[index])) {
|
|
1324
|
+
if (/^[ \t]/u.test(lines[index])) {
|
|
1325
|
+
kept += 1;
|
|
1326
|
+
} else {
|
|
1327
|
+
review(
|
|
1328
|
+
page,
|
|
1329
|
+
index + 1,
|
|
1330
|
+
"a GitHub alert with text after its marker: convert it by hand"
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
out.push(lines[index]);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
let end = index + 1;
|
|
1338
|
+
while (end < lines.length && QUOTED.test(lines[end])) {
|
|
1339
|
+
end += 1;
|
|
1340
|
+
}
|
|
1341
|
+
const body = lines
|
|
1342
|
+
.slice(index + 1, end)
|
|
1343
|
+
.map((line) => line.replace(/^ {0,3}>[ \t]?/u, ""));
|
|
1344
|
+
if (end < lines.length && lines[end].trim() !== "") {
|
|
1345
|
+
kept += 1;
|
|
1346
|
+
out.push(lines[index]);
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
if (body.every((line) => line.trim() === "")) {
|
|
1350
|
+
review(
|
|
1351
|
+
page,
|
|
1352
|
+
index + 1,
|
|
1353
|
+
"a GitHub alert with no body lines: convert it by hand"
|
|
1354
|
+
);
|
|
1355
|
+
out.push(lines[index]);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
while (body[0].trim() === "") {
|
|
1359
|
+
body.shift();
|
|
1360
|
+
}
|
|
1361
|
+
while (body.at(-1).trim() === "") {
|
|
1362
|
+
body.pop();
|
|
1363
|
+
}
|
|
1364
|
+
const fence = body.some((line) => /^\s*:{3,}/u.test(line)) ? "::::" : ":::";
|
|
1365
|
+
out.push(
|
|
1366
|
+
`${fence}${ALERTS[start.groups.type.toLowerCase()]}`,
|
|
1367
|
+
...body,
|
|
1368
|
+
fence
|
|
1369
|
+
);
|
|
1370
|
+
converted += 1;
|
|
1371
|
+
index = end - 1;
|
|
1372
|
+
}
|
|
1373
|
+
if (converted > 0) {
|
|
1374
|
+
edit(page, "GitHub alert → ::: directive", converted);
|
|
1375
|
+
}
|
|
1376
|
+
if (kept > 0) {
|
|
1377
|
+
edit(page, "indented or lazy GitHub alert kept: a callout in .mdx", kept);
|
|
1378
|
+
}
|
|
1379
|
+
return { converted, kept, lines: out };
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
const headingText = (rest) =>
|
|
1383
|
+
rest.replace(HEADING_MARKER, "").replace(CLOSING_HASHES, "").trim();
|
|
1384
|
+
|
|
1385
|
+
/** The page's heading plan: the leading H1 to delete, and whether to demote. */
|
|
1386
|
+
const planHeadings = (lines, states, title) => {
|
|
1387
|
+
const first = lines.findIndex(
|
|
1388
|
+
(line, index) => states[index] === "prose" && line.trim() !== ""
|
|
1389
|
+
);
|
|
1390
|
+
let deleted = [];
|
|
1391
|
+
if (first !== -1) {
|
|
1392
|
+
const atx = ATX.exec(lines[first]);
|
|
1393
|
+
const underline = SETEXT.exec(lines[first + 1] ?? "");
|
|
1394
|
+
if (atx && atx.groups.prefix === "" && atx.groups.hashes === "#") {
|
|
1395
|
+
deleted =
|
|
1396
|
+
comparable(headingText(atx.groups.rest)) === comparable(title)
|
|
1397
|
+
? [first]
|
|
1398
|
+
: [];
|
|
1399
|
+
} else if (
|
|
1400
|
+
underline?.groups.marker.startsWith("=") &&
|
|
1401
|
+
states[first + 1] === "prose" &&
|
|
1402
|
+
comparable(lines[first]) === comparable(title)
|
|
1403
|
+
) {
|
|
1404
|
+
deleted = [first, first + 1];
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const skip = new Set(deleted);
|
|
1408
|
+
const hasH1 = lines.some((line, index) => {
|
|
1409
|
+
if (skip.has(index) || states[index] !== "prose") {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
if (ATX.exec(line)?.groups.hashes === "#") {
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
return (
|
|
1416
|
+
SETEXT.exec(line)?.groups.marker.startsWith("=") === true &&
|
|
1417
|
+
paragraphAbove(lines, states, index) !== null
|
|
1418
|
+
);
|
|
1419
|
+
});
|
|
1420
|
+
return { deleted: skip, hasH1 };
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
const pinNamedAnchor = (page, line, index) => {
|
|
1424
|
+
const atx = ATX.exec(line);
|
|
1425
|
+
const anchor = atx ? NAMED_ANCHOR.exec(atx.groups.rest) : null;
|
|
1426
|
+
if (anchor === null) {
|
|
1427
|
+
return line;
|
|
1428
|
+
}
|
|
1429
|
+
if (HEADING_MARKER.test(atx.groups.rest)) {
|
|
1430
|
+
review(
|
|
1431
|
+
page,
|
|
1432
|
+
index + 1,
|
|
1433
|
+
"a heading with both <a name> and an id marker: keep one"
|
|
1434
|
+
);
|
|
1435
|
+
return line;
|
|
1436
|
+
}
|
|
1437
|
+
const text = atx.groups.rest.replace(NAMED_ANCHOR, "").trim();
|
|
1438
|
+
edit(page, "<a name> in a heading → [#id]");
|
|
1439
|
+
return `${atx.groups.prefix}${atx.groups.hashes} ${text} [#${anchor.groups.id}]`;
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const demoteAtx = (line) => {
|
|
1443
|
+
const atx = ATX.exec(line);
|
|
1444
|
+
if (atx === null || atx.groups.hashes.length >= HEADING_LEVELS) {
|
|
1445
|
+
return line;
|
|
1446
|
+
}
|
|
1447
|
+
return `${atx.groups.prefix}#${atx.groups.hashes}${atx.groups.rest}`;
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
/** Setext headings → ATX one level down, on a page whose headings are demoted. */
|
|
1451
|
+
const demoteSetext = (page, lines, states) => {
|
|
1452
|
+
const out = [...lines];
|
|
1453
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1454
|
+
const underline =
|
|
1455
|
+
states[index] === "prose" ? SETEXT.exec(lines[index]) : null;
|
|
1456
|
+
const paragraph = underline ? paragraphAbove(lines, states, index) : null;
|
|
1457
|
+
if (paragraph === null) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
const level = underline.groups.marker.startsWith("=") ? 2 : 3;
|
|
1461
|
+
if (paragraph.count > 1) {
|
|
1462
|
+
review(
|
|
1463
|
+
page,
|
|
1464
|
+
index + 1,
|
|
1465
|
+
`a ${paragraph.count}-line setext heading can't be demoted: rewrite it as one ${"#".repeat(level)} line`
|
|
1466
|
+
);
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
out[paragraph.start] =
|
|
1470
|
+
`${"#".repeat(level)} ${lines[paragraph.start].trim()}`;
|
|
1471
|
+
out[index] = null;
|
|
1472
|
+
edit(page, "setext heading → ATX, demoted");
|
|
1473
|
+
}
|
|
1474
|
+
return out.filter((line) => line !== null);
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
const convertFenceInfo = (page, line, index) => {
|
|
1478
|
+
const open = FENCE.exec(line);
|
|
1479
|
+
const [language = "", ...rest] = open.groups.info.trim().split(/\s+/u);
|
|
1480
|
+
const lower = language.toLowerCase();
|
|
1481
|
+
const mapped = FENCE_LANGUAGES[lower] ?? lower;
|
|
1482
|
+
if (DROPPED_FENCES.has(lower)) {
|
|
1483
|
+
review(
|
|
1484
|
+
page,
|
|
1485
|
+
index + 1,
|
|
1486
|
+
`a ${lower} block renders as code in Blume: maps and 3D models drop`
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (mapped === language) {
|
|
1490
|
+
return { language: lower, line };
|
|
1491
|
+
}
|
|
1492
|
+
edit(page, `fence language ${language} → ${mapped}`);
|
|
1493
|
+
const info = [mapped, ...rest].join(" ");
|
|
1494
|
+
return {
|
|
1495
|
+
language: mapped,
|
|
1496
|
+
line: `${open.groups.prefix}${open.groups.marker}${info}`,
|
|
1497
|
+
};
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
/** Delete link definitions a same-label definition above already shadows, and rewrite the rest. */
|
|
1501
|
+
const convertDefinition = (ctx, line, index, lines, seen) => {
|
|
1502
|
+
const definition = DEFINITION.exec(line);
|
|
1503
|
+
const previous = lines[index - 1] ?? "";
|
|
1504
|
+
const startsBlock =
|
|
1505
|
+
index === 0 ||
|
|
1506
|
+
previous.trim() === "" ||
|
|
1507
|
+
DEFINITION.test(previous) ||
|
|
1508
|
+
ATX.test(previous) ||
|
|
1509
|
+
/^\s*<!--[\s\S]*-->\s*$/u.test(previous) ||
|
|
1510
|
+
previous.includes("-->");
|
|
1511
|
+
if (definition === null || !startsBlock) {
|
|
1512
|
+
return { isDefinition: false, line };
|
|
1513
|
+
}
|
|
1514
|
+
const label = definition.groups.label
|
|
1515
|
+
.toLowerCase()
|
|
1516
|
+
.replaceAll(/\s+/gu, " ")
|
|
1517
|
+
.trim();
|
|
1518
|
+
if (seen.has(label)) {
|
|
1519
|
+
edit(ctx.page, "shadowed duplicate link definition removed");
|
|
1520
|
+
return { isDefinition: true, line: null };
|
|
1521
|
+
}
|
|
1522
|
+
seen.add(label);
|
|
1523
|
+
const result = rewriteHref(ctx, definition.groups.dest);
|
|
1524
|
+
if (result === null || result.unlink) {
|
|
1525
|
+
return { isDefinition: true, line };
|
|
1526
|
+
}
|
|
1527
|
+
const { indent, rest } = definition.groups;
|
|
1528
|
+
return {
|
|
1529
|
+
isDefinition: true,
|
|
1530
|
+
line: `${indent}[${definition.groups.label}]: ${result.href}${rest}`,
|
|
1531
|
+
};
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
const RAW_IMG_SRC =
|
|
1535
|
+
/<img\b[^<>]*?\bsrc\s*=\s*(?<quote>["'])(?<src>[^"']*)\k<quote>/giu;
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* A raw `<img>` of a wiki file inside other HTML: its `src` reads from the
|
|
1539
|
+
* wiki root, not the page's new folder, so it needs a hand.
|
|
1540
|
+
*/
|
|
1541
|
+
const reviewRawImages = (ctx, line) => {
|
|
1542
|
+
for (const match of mask(line).matchAll(RAW_IMG_SRC)) {
|
|
1543
|
+
const found = wikiFileOf(ctx, match.groups.src);
|
|
1544
|
+
if (found?.file) {
|
|
1545
|
+
const file = `${ctx.content}/${found.file}`;
|
|
1546
|
+
review(
|
|
1547
|
+
ctx.page,
|
|
1548
|
+
ctx.line,
|
|
1549
|
+
`raw <img src="${match.groups.src}"> shows a file stored in the wiki: copy it to ${file} and point src at ${relativeAsset(ctx.dest, file)}, which Blume publishes with the page, or make it a Markdown image`
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
const convertRawImageLine = (ctx, line, previous) => {
|
|
1556
|
+
const img = RAW_IMG_LINE.exec(line);
|
|
1557
|
+
if (img === null || (previous ?? "").trim() !== "") {
|
|
1558
|
+
reviewRawImages(ctx, line);
|
|
1559
|
+
return line;
|
|
1560
|
+
}
|
|
1561
|
+
const attribute = (name) =>
|
|
1562
|
+
new RegExp(
|
|
1563
|
+
String.raw`\b${name}\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>`,
|
|
1564
|
+
"iu"
|
|
1565
|
+
).exec(img.groups.attributes)?.groups.value ?? null;
|
|
1566
|
+
const srcValue = attribute("src");
|
|
1567
|
+
const found = srcValue ? wikiFileOf(ctx, srcValue) : null;
|
|
1568
|
+
if (found === null || found.outside) {
|
|
1569
|
+
return line;
|
|
1570
|
+
}
|
|
1571
|
+
const src = rewriteImage(ctx, srcValue);
|
|
1572
|
+
if (src === null || src === "") {
|
|
1573
|
+
return src === "" ? "" : line;
|
|
1574
|
+
}
|
|
1575
|
+
const dropped = ["width", "height", "align"]
|
|
1576
|
+
.map((name) => [name, attribute(name)])
|
|
1577
|
+
.filter(([, value]) => value !== null)
|
|
1578
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
1579
|
+
if (dropped.length > 0) {
|
|
1580
|
+
review(
|
|
1581
|
+
ctx.page,
|
|
1582
|
+
ctx.line,
|
|
1583
|
+
`raw <img> → Markdown image; dropped ${dropped.join(", ")}`
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
edit(ctx.page, "raw <img> → Markdown image");
|
|
1587
|
+
return `${img.groups.indent}`;
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// --- MDX safety ----------------------------------------------------------------------
|
|
1591
|
+
|
|
1592
|
+
const commentText = (text) =>
|
|
1593
|
+
text
|
|
1594
|
+
.replace(/^\s*<!--/u, "")
|
|
1595
|
+
.replace(/--!?>\s*$/u, "")
|
|
1596
|
+
.replaceAll("*/", "* /")
|
|
1597
|
+
.replaceAll(/\s+/gu, " ")
|
|
1598
|
+
.trim();
|
|
1599
|
+
|
|
1600
|
+
const mdxComments = (page, lines, states) => {
|
|
1601
|
+
const out = [];
|
|
1602
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1603
|
+
const line = lines[index];
|
|
1604
|
+
if (
|
|
1605
|
+
states[index] === "prose" &&
|
|
1606
|
+
line.trimStart().startsWith("<!--") &&
|
|
1607
|
+
!line.includes("-->")
|
|
1608
|
+
) {
|
|
1609
|
+
let end = index;
|
|
1610
|
+
while (end + 1 < lines.length && states[end + 1] === "comment") {
|
|
1611
|
+
end += 1;
|
|
1612
|
+
}
|
|
1613
|
+
const [indent] = /^[ \t]*/u.exec(line);
|
|
1614
|
+
out.push(
|
|
1615
|
+
`${indent}{/* ${commentText(lines.slice(index, end + 1).join(" "))} */}`
|
|
1616
|
+
);
|
|
1617
|
+
edit(page, "HTML comment → MDX comment");
|
|
1618
|
+
index = end;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
if (states[index] !== "prose") {
|
|
1622
|
+
out.push(line);
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
out.push(
|
|
1626
|
+
replaceOutside(
|
|
1627
|
+
line,
|
|
1628
|
+
/<!--(?<text>[\s\S]*?)-->/dgu,
|
|
1629
|
+
(group) => {
|
|
1630
|
+
edit(page, "HTML comment → MDX comment");
|
|
1631
|
+
return `{/* ${commentText(group("text"))} */}`;
|
|
1632
|
+
},
|
|
1633
|
+
{ comments: false }
|
|
1634
|
+
)
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
return out;
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
const mdxLine = (page, line, index) => {
|
|
1641
|
+
let next = replaceOutside(line, ANGLE_AUTOLINK, (group) => {
|
|
1642
|
+
edit(page, "<https://…> autolink → link");
|
|
1643
|
+
return `[${group("url")}](${group("url")})`;
|
|
1644
|
+
});
|
|
1645
|
+
next = replaceOutside(next, VOID_TAG, (group) => {
|
|
1646
|
+
edit(page, "void tag closed");
|
|
1647
|
+
return `<${group("tag")}${group("attributes").trimEnd()} />`;
|
|
1648
|
+
});
|
|
1649
|
+
const strays = replaceOutside(
|
|
1650
|
+
next,
|
|
1651
|
+
STRAY_ANGLE,
|
|
1652
|
+
() => {
|
|
1653
|
+
edit(page, "bare < → <");
|
|
1654
|
+
return "<";
|
|
1655
|
+
},
|
|
1656
|
+
{ destinations: true }
|
|
1657
|
+
);
|
|
1658
|
+
const braces = replaceOutside(
|
|
1659
|
+
strays,
|
|
1660
|
+
BRACE,
|
|
1661
|
+
(group, original) => {
|
|
1662
|
+
edit(page, "brace in prose escaped");
|
|
1663
|
+
return `\\${original}`;
|
|
1664
|
+
},
|
|
1665
|
+
{ destinations: true, tags: true }
|
|
1666
|
+
);
|
|
1667
|
+
for (const match of mask(braces).matchAll(OPEN_TAG)) {
|
|
1668
|
+
if (!HTML_TAGS.has(match.groups.tag)) {
|
|
1669
|
+
review(
|
|
1670
|
+
page,
|
|
1671
|
+
index + 1,
|
|
1672
|
+
`<${match.groups.tag}> reads as a JSX element in MDX: escape it or put it in code`
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return braces;
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
const HTML_BLOCK = /<(?:center|details|div|li|ol|p|summary|table|td|tr|ul)\b/iu;
|
|
1680
|
+
|
|
1681
|
+
/** Whether the indented block starting at `index` sits at the top level, not inside a list. */
|
|
1682
|
+
const topLevelBlock = (lines, states, index) => {
|
|
1683
|
+
for (let line = index - 1; line >= 0; line -= 1) {
|
|
1684
|
+
if (lines[line].trim() !== "" && states[line] !== "indented") {
|
|
1685
|
+
return !LIST_ITEM.test(lines[line]) && /^\S/u.test(lines[line]);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
return true;
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
/** The last line of the indented block starting at `index` (blank lines inside it included). */
|
|
1692
|
+
const indentedBlockEnd = (lines, states, index) => {
|
|
1693
|
+
let end = index;
|
|
1694
|
+
for (let line = index + 1; line < lines.length; line += 1) {
|
|
1695
|
+
if (states[line] === "indented") {
|
|
1696
|
+
end = line;
|
|
1697
|
+
} else if (lines[line].trim() !== "") {
|
|
1698
|
+
break;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return end;
|
|
1702
|
+
};
|
|
1703
|
+
|
|
1704
|
+
/** MDX has no indented code: fence each top-level indented block, and report the rest. */
|
|
1705
|
+
const fenceIndentedCode = (page, lines, states) => {
|
|
1706
|
+
const out = [];
|
|
1707
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1708
|
+
if (states[index] !== "indented" || states[index - 1] === "indented") {
|
|
1709
|
+
out.push(lines[index]);
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
if (!topLevelBlock(lines, states, index)) {
|
|
1713
|
+
review(
|
|
1714
|
+
page,
|
|
1715
|
+
index + 1,
|
|
1716
|
+
"indented code inside a list renders as text in MDX: fence it"
|
|
1717
|
+
);
|
|
1718
|
+
out.push(lines[index]);
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
const end = indentedBlockEnd(lines, states, index);
|
|
1722
|
+
const body = lines
|
|
1723
|
+
.slice(index, end + 1)
|
|
1724
|
+
.map((line) => line.replace(/^(?: {4}|\t| {0,3}\t)/u, ""));
|
|
1725
|
+
const longest = Math.max(
|
|
1726
|
+
2,
|
|
1727
|
+
...body
|
|
1728
|
+
.flatMap((line) => line.match(/`+/gu) ?? [])
|
|
1729
|
+
.map((run) => run.length)
|
|
1730
|
+
);
|
|
1731
|
+
const fence = "`".repeat(longest + 1);
|
|
1732
|
+
out.push(fence, ...body, fence);
|
|
1733
|
+
edit(page, "indented code → fenced block");
|
|
1734
|
+
index = end;
|
|
1735
|
+
}
|
|
1736
|
+
return out;
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
const mdxSafe = (page, lines) => {
|
|
1740
|
+
const fenced = fenceIndentedCode(page, lines, lineStates(lines));
|
|
1741
|
+
const states = lineStates(fenced);
|
|
1742
|
+
const html = fenced.findIndex(
|
|
1743
|
+
(line, index) => states[index] === "prose" && HTML_BLOCK.test(mask(line))
|
|
1744
|
+
);
|
|
1745
|
+
if (html !== -1) {
|
|
1746
|
+
review(
|
|
1747
|
+
page,
|
|
1748
|
+
html + 1,
|
|
1749
|
+
"raw HTML blocks in an .mdx page: MDX reads Markdown inside them only with blank lines around it, and fails on interleaved tags; check the build"
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
const safe = fenced.map((line, index) =>
|
|
1753
|
+
states[index] === "prose" ? mdxLine(page, line, index) : line
|
|
1754
|
+
);
|
|
1755
|
+
return mdxComments(page, safe, lineStates(safe));
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
// --- One page ---------------------------------------------------------------------------
|
|
1759
|
+
|
|
1760
|
+
/** One line of a fence: its opener's language fixed, a ```math fence turned into $$. */
|
|
1761
|
+
const convertFenceLine = (ctx, line, index, state, flags) => {
|
|
1762
|
+
if (state === "open") {
|
|
1763
|
+
const fence = convertFenceInfo(ctx.page, line, index);
|
|
1764
|
+
const bare = FENCE.exec(fence.line).groups.prefix === "";
|
|
1765
|
+
flags.needsMdx ||= fence.language === "mermaid";
|
|
1766
|
+
flags.math = fence.language === "math" && bare;
|
|
1767
|
+
if (flags.math) {
|
|
1768
|
+
edit(ctx.page, "```math → $$ block");
|
|
1769
|
+
flags.needsMdx = true;
|
|
1770
|
+
}
|
|
1771
|
+
return flags.math ? "$$" : fence.line;
|
|
1772
|
+
}
|
|
1773
|
+
if (state === "close") {
|
|
1774
|
+
const out = flags.math ? "$$" : line;
|
|
1775
|
+
flags.math = false;
|
|
1776
|
+
return out;
|
|
1777
|
+
}
|
|
1778
|
+
if (state === "indented" && line.includes("[[")) {
|
|
1779
|
+
review(
|
|
1780
|
+
ctx.page,
|
|
1781
|
+
index + 1,
|
|
1782
|
+
"[[…]] in an indented block was left alone: check whether it's code"
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
return line;
|
|
1786
|
+
};
|
|
1787
|
+
|
|
1788
|
+
const convertProseLine = (ctx, lines, index, plan, seen) => {
|
|
1789
|
+
const line = lines[index];
|
|
1790
|
+
const definition = convertDefinition(ctx, line, index, lines, seen);
|
|
1791
|
+
if (definition.isDefinition) {
|
|
1792
|
+
return definition.line;
|
|
1793
|
+
}
|
|
1794
|
+
// In an HTML block, `#` lines are text, not headings.
|
|
1795
|
+
const pinned = ctx.html ? line : pinNamedAnchor(ctx.page, line, index);
|
|
1796
|
+
const isHeading = !ctx.html && ATX.test(pinned);
|
|
1797
|
+
const imaged = convertRawImageLine(ctx, pinned, lines[index - 1]);
|
|
1798
|
+
const converted = convertInline(ctx, imaged, isHeading);
|
|
1799
|
+
return plan.hasH1 && isHeading ? demoteAtx(converted) : converted;
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1802
|
+
const trimBlankEdges = (lines) => {
|
|
1803
|
+
const first = lines.findIndex((line) => line.trim() !== "");
|
|
1804
|
+
if (first === -1) {
|
|
1805
|
+
return [];
|
|
1806
|
+
}
|
|
1807
|
+
const last = lines.findLastIndex((line) => line.trim() !== "");
|
|
1808
|
+
return lines.slice(first, last + 1);
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
const convertBody = (ctx, text) => {
|
|
1812
|
+
const { page } = ctx;
|
|
1813
|
+
const lines = removeFoam(
|
|
1814
|
+
page,
|
|
1815
|
+
text
|
|
1816
|
+
.replace(/^\u{FEFF}/u, "")
|
|
1817
|
+
.replaceAll("\r\n", "\n")
|
|
1818
|
+
.split("\n")
|
|
1819
|
+
);
|
|
1820
|
+
const alerts = convertAlerts(page, lines, lineStates(lines));
|
|
1821
|
+
const current = alerts.lines;
|
|
1822
|
+
const states = lineStates(current);
|
|
1823
|
+
const html = htmlBlockLines(current, states);
|
|
1824
|
+
const plan = planHeadings(
|
|
1825
|
+
current,
|
|
1826
|
+
states.map((state, index) => (html.has(index) ? "html" : state)),
|
|
1827
|
+
titleOf(ctx.name)
|
|
1828
|
+
);
|
|
1829
|
+
if (plan.deleted.size > 0) {
|
|
1830
|
+
edit(page, "leading H1 repeating the page name removed");
|
|
1831
|
+
}
|
|
1832
|
+
const flags = { math: false, needsMdx: alerts.converted + alerts.kept > 0 };
|
|
1833
|
+
const seen = new Set();
|
|
1834
|
+
const out = [];
|
|
1835
|
+
for (let index = 0; index < current.length; index += 1) {
|
|
1836
|
+
ctx.line = index + 1;
|
|
1837
|
+
ctx.html = html.has(index);
|
|
1838
|
+
if (plan.deleted.has(index)) {
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
out.push(
|
|
1842
|
+
isCode(states[index])
|
|
1843
|
+
? convertFenceLine(ctx, current[index], index, states[index], flags)
|
|
1844
|
+
: convertProseLine(ctx, current, index, plan, seen)
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
let result = out.filter((line) => line !== null);
|
|
1848
|
+
if (plan.hasH1) {
|
|
1849
|
+
edit(page, "page with body H1s: every heading demoted one level");
|
|
1850
|
+
result = demoteSetext(page, result, lineStates(result));
|
|
1851
|
+
}
|
|
1852
|
+
if (flags.needsMdx) {
|
|
1853
|
+
result = mdxSafe(page, result);
|
|
1854
|
+
}
|
|
1855
|
+
return { body: trimBlankEdges(result).join("\n"), needsMdx: flags.needsMdx };
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
const frontmatter = (fields) => {
|
|
1859
|
+
const lines = ["---", `title: ${yamlScalar(fields.title)}`];
|
|
1860
|
+
if (fields.label) {
|
|
1861
|
+
lines.push("sidebar:", ` label: ${yamlScalar(fields.label)}`);
|
|
1862
|
+
}
|
|
1863
|
+
if (fields.slug) {
|
|
1864
|
+
lines.push(`slug: ${yamlScalar(fields.slug)}`);
|
|
1865
|
+
}
|
|
1866
|
+
lines.push("---");
|
|
1867
|
+
return lines.join("\n");
|
|
1868
|
+
};
|
|
1869
|
+
|
|
1870
|
+
// --- The sidebar ------------------------------------------------------------------
|
|
1871
|
+
|
|
1872
|
+
const HEADING_LABEL = /^ {0,3}#{1,6}[ \t]+(?<text>.*?)[ \t#]*$/u;
|
|
1873
|
+
const BOLD_LABEL =
|
|
1874
|
+
/^ {0,3}(?:\*\*|__)(?<text>[^*_]+?)(?:\*\*|__)[ \t]*:?[ \t]*$/u;
|
|
1875
|
+
const ONLY_WIKI_LINK = /^\[\[(?<inner>[^[\]]+)\]\]$/u;
|
|
1876
|
+
const ONLY_MD_LINK = new RegExp(
|
|
1877
|
+
String.raw`^\[(?<text>[^[\]]+)\]\(\s*${DESTINATION}${TITLE}\s*\)$`,
|
|
1878
|
+
"u"
|
|
1879
|
+
);
|
|
1880
|
+
|
|
1881
|
+
const parseItem = (content) => {
|
|
1882
|
+
const text = content.trim();
|
|
1883
|
+
const wiki = ONLY_WIKI_LINK.exec(text);
|
|
1884
|
+
if (wiki) {
|
|
1885
|
+
const parts = wiki.groups.inner.split(PIPE);
|
|
1886
|
+
if (parts.length > 2) {
|
|
1887
|
+
return { kind: "other", text };
|
|
1888
|
+
}
|
|
1889
|
+
const target = (parts[1] ?? parts[0]).trim();
|
|
1890
|
+
return { kind: "wiki", label: parts[0].trim(), target };
|
|
1891
|
+
}
|
|
1892
|
+
const link = ONLY_MD_LINK.exec(text);
|
|
1893
|
+
if (link) {
|
|
1894
|
+
return {
|
|
1895
|
+
dest: unwrapAngle(link.groups.dest),
|
|
1896
|
+
kind: "link",
|
|
1897
|
+
label: link.groups.text.trim(),
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
if (text.includes("[") || text.includes("<")) {
|
|
1901
|
+
return { kind: "other", text };
|
|
1902
|
+
}
|
|
1903
|
+
return { kind: "label", label: plainText(text) };
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
/** `_Sidebar.md` → groups of item trees, in order. */
|
|
1907
|
+
const parseSidebar = (text) => {
|
|
1908
|
+
const groups = [];
|
|
1909
|
+
const notes = [];
|
|
1910
|
+
let group = null;
|
|
1911
|
+
let stack = [];
|
|
1912
|
+
const lines = text.replaceAll("\r\n", "\n").split("\n");
|
|
1913
|
+
const states = lineStates(lines);
|
|
1914
|
+
for (const [index, line] of lines.entries()) {
|
|
1915
|
+
if (line.trim() === "" || isCode(states[index])) {
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
const heading = HEADING_LABEL.exec(line) ?? BOLD_LABEL.exec(line);
|
|
1919
|
+
if (heading) {
|
|
1920
|
+
group = {
|
|
1921
|
+
children: [],
|
|
1922
|
+
head: parseItem(heading.groups.text),
|
|
1923
|
+
line: index + 1,
|
|
1924
|
+
};
|
|
1925
|
+
groups.push(group);
|
|
1926
|
+
stack = [];
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
const item = LIST_ITEM.exec(line);
|
|
1930
|
+
if (item === null) {
|
|
1931
|
+
notes.push(
|
|
1932
|
+
`_Sidebar line ${index + 1} has no place in Blume's sidebar: ${line.trim()}`
|
|
1933
|
+
);
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1936
|
+
const indent = indentWidth(item.groups.indent);
|
|
1937
|
+
const node = {
|
|
1938
|
+
...parseItem(item.groups.content),
|
|
1939
|
+
children: [],
|
|
1940
|
+
line: index + 1,
|
|
1941
|
+
};
|
|
1942
|
+
while (stack.length > 0 && stack.at(-1).indent >= indent) {
|
|
1943
|
+
stack.pop();
|
|
1944
|
+
}
|
|
1945
|
+
if (group === null) {
|
|
1946
|
+
group = { children: [], head: null, line: index + 1 };
|
|
1947
|
+
groups.push(group);
|
|
1948
|
+
}
|
|
1949
|
+
const parent = stack.at(-1)?.node ?? group;
|
|
1950
|
+
parent.children.push(node);
|
|
1951
|
+
stack.push({ indent, node });
|
|
1952
|
+
}
|
|
1953
|
+
return { groups, notes };
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1956
|
+
/** A sidebar item → `{ kind: "page", name }`, `{ kind: "external", href }`, or `{ kind: "skip", why }`. */
|
|
1957
|
+
const sidebarTarget = (ctx, node) => {
|
|
1958
|
+
if (node.kind === "label" || node.kind === "other") {
|
|
1959
|
+
return { kind: "label" };
|
|
1960
|
+
}
|
|
1961
|
+
let { target } = node;
|
|
1962
|
+
if (node.kind === "link") {
|
|
1963
|
+
const relative = !URL_SCHEME.test(node.dest) && !node.dest.startsWith("/");
|
|
1964
|
+
const absolute = relative
|
|
1965
|
+
? resolveUrl(node.dest, ctx.urls.base)
|
|
1966
|
+
: node.dest;
|
|
1967
|
+
const wiki = absolute === null ? null : wikiTarget(ctx, absolute);
|
|
1968
|
+
if (wiki === null) {
|
|
1969
|
+
return URL_SCHEME.test(node.dest)
|
|
1970
|
+
? { href: node.dest, kind: "external" }
|
|
1971
|
+
: { kind: "skip", why: `${node.dest} isn't a page` };
|
|
1972
|
+
}
|
|
1973
|
+
if (wiki.kind !== "page") {
|
|
1974
|
+
return { kind: "skip", why: `${node.dest} isn't a page` };
|
|
1975
|
+
}
|
|
1976
|
+
target = decode(wiki.target);
|
|
1977
|
+
}
|
|
1978
|
+
if (EXTERNAL.test(target)) {
|
|
1979
|
+
return { href: target, kind: "external" };
|
|
1980
|
+
}
|
|
1981
|
+
if (target.includes("#")) {
|
|
1982
|
+
return {
|
|
1983
|
+
kind: "skip",
|
|
1984
|
+
why: `${target} links a heading, not a page (the page's outline lists it)`,
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
const result = resolvePage(ctx.index, target, "");
|
|
1988
|
+
if (result.kind !== "content") {
|
|
1989
|
+
return {
|
|
1990
|
+
kind: "skip",
|
|
1991
|
+
why: `${target} names a page that isn't kept (${result.kind})`,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
if (!MARKDOWN.test(ctx.files[result.name])) {
|
|
1995
|
+
return {
|
|
1996
|
+
kind: "skip",
|
|
1997
|
+
why: `${ctx.files[result.name]} isn't Markdown yet (convert it, then rerun)`,
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
return { kind: "page", name: result.name };
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
const uniqueName = (taken, base) => {
|
|
2004
|
+
let name = base;
|
|
2005
|
+
for (let count = 2; taken.has(name); count += 1) {
|
|
2006
|
+
name = `${base}-${count}`;
|
|
2007
|
+
}
|
|
2008
|
+
taken.add(name);
|
|
2009
|
+
return name;
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
const fileBase = (index, name) => {
|
|
2013
|
+
const route = index.routes[name];
|
|
2014
|
+
return route === "/" ? "index" : route.split("/").at(-1);
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
const createLayout = (ctx) => ({
|
|
2018
|
+
ctx,
|
|
2019
|
+
featured: [],
|
|
2020
|
+
folders: new Map([["", new Set()]]),
|
|
2021
|
+
metas: new Map(),
|
|
2022
|
+
notes: [],
|
|
2023
|
+
placed: new Map(),
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
const addMeta = (layout, dir, meta) => {
|
|
2027
|
+
layout.metas.set(dir, meta);
|
|
2028
|
+
layout.folders.set(dir, new Set());
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
const labelFor = (name, text) =>
|
|
2032
|
+
text && comparable(text) !== comparable(titleOf(name)) ? text : null;
|
|
2033
|
+
|
|
2034
|
+
const placeNodes = (layout, nodes, dir, pages) => {
|
|
2035
|
+
const { ctx } = layout;
|
|
2036
|
+
for (const node of nodes) {
|
|
2037
|
+
const target = sidebarTarget(ctx, node);
|
|
2038
|
+
if (target.kind === "external") {
|
|
2039
|
+
layout.featured.push({ href: target.href, label: node.label });
|
|
2040
|
+
layout.notes.push(
|
|
2041
|
+
`_Sidebar line ${node.line}: external link "${node.label}" → navigation.featured`
|
|
2042
|
+
);
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
if (target.kind === "skip") {
|
|
2046
|
+
layout.notes.push(`_Sidebar line ${node.line}: dropped, ${target.why}`);
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
if (target.kind === "label") {
|
|
2050
|
+
if (node.children.length === 0 || node.kind === "other") {
|
|
2051
|
+
layout.notes.push(
|
|
2052
|
+
`_Sidebar line ${node.line} has no place in Blume's sidebar: ${node.text ?? node.label}`
|
|
2053
|
+
);
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
const folder = uniqueName(
|
|
2057
|
+
layout.folders.get(dir),
|
|
2058
|
+
`(${slugOf(node.label) || "group"})`
|
|
2059
|
+
);
|
|
2060
|
+
const sub = posix.join(dir, folder);
|
|
2061
|
+
pages.push(folder.slice(1, -1));
|
|
2062
|
+
const meta = { display: "group", pages: [], title: node.label };
|
|
2063
|
+
addMeta(layout, sub, meta);
|
|
2064
|
+
placeNodes(layout, node.children, sub, meta.pages);
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
const { name } = target;
|
|
2068
|
+
if (layout.placed.has(name) || ctx.index.routes[name] === "/") {
|
|
2069
|
+
const why =
|
|
2070
|
+
ctx.index.routes[name] === "/"
|
|
2071
|
+
? "is the home page, which stays the root index"
|
|
2072
|
+
: "is already placed";
|
|
2073
|
+
layout.notes.push(
|
|
2074
|
+
`_Sidebar line ${node.line}: ${name} ${why}, so this entry is dropped`
|
|
2075
|
+
);
|
|
2076
|
+
continue;
|
|
2077
|
+
}
|
|
2078
|
+
const base = fileBase(ctx.index, name);
|
|
2079
|
+
const label = labelFor(name, node.label);
|
|
2080
|
+
if (node.children.length === 0) {
|
|
2081
|
+
layout.placed.set(name, { dir, file: base, label });
|
|
2082
|
+
pages.push(base);
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
const folder = posix.join(dir, uniqueName(layout.folders.get(dir), base));
|
|
2086
|
+
layout.placed.set(name, { dir: folder, file: "index", label });
|
|
2087
|
+
pages.push(posix.basename(folder));
|
|
2088
|
+
const meta = { display: "group", pages: [], title: titleOf(name) };
|
|
2089
|
+
addMeta(layout, folder, meta);
|
|
2090
|
+
placeNodes(layout, node.children, folder, meta.pages);
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
const placeGroup = (layout, group, rootPages) => {
|
|
2095
|
+
const { ctx } = layout;
|
|
2096
|
+
if (group.head === null) {
|
|
2097
|
+
placeNodes(layout, group.children, "", rootPages);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
const head = sidebarTarget(ctx, { ...group.head, line: group.line });
|
|
2101
|
+
const label = group.head.label ?? group.head.text;
|
|
2102
|
+
if (head.kind === "external") {
|
|
2103
|
+
layout.featured.push({ href: head.href, label });
|
|
2104
|
+
layout.notes.push(
|
|
2105
|
+
`_Sidebar line ${group.line}: heading link "${label}" → navigation.featured`
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
if (head.kind === "skip") {
|
|
2109
|
+
layout.notes.push(
|
|
2110
|
+
`_Sidebar line ${group.line}: the heading's link was dropped, ${head.why}`
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
if (group.children.length === 0) {
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (head.kind === "page" && !layout.placed.has(head.name)) {
|
|
2117
|
+
const dir = uniqueName(
|
|
2118
|
+
layout.folders.get(""),
|
|
2119
|
+
fileBase(ctx.index, head.name)
|
|
2120
|
+
);
|
|
2121
|
+
layout.placed.set(head.name, {
|
|
2122
|
+
dir,
|
|
2123
|
+
file: "index",
|
|
2124
|
+
label: labelFor(head.name, label),
|
|
2125
|
+
});
|
|
2126
|
+
rootPages.push(dir);
|
|
2127
|
+
const meta = { pages: [], title: label };
|
|
2128
|
+
addMeta(layout, dir, meta);
|
|
2129
|
+
placeNodes(layout, group.children, dir, meta.pages);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
const dir = uniqueName(
|
|
2133
|
+
layout.folders.get(""),
|
|
2134
|
+
`(${slugOf(label) || "group"})`
|
|
2135
|
+
);
|
|
2136
|
+
rootPages.push(dir.slice(1, -1));
|
|
2137
|
+
const meta = { pages: [], title: label };
|
|
2138
|
+
addMeta(layout, dir, meta);
|
|
2139
|
+
placeNodes(layout, group.children, dir, meta.pages);
|
|
2140
|
+
};
|
|
2141
|
+
|
|
2142
|
+
/** Where every kept page goes, and the meta.ts files that order them. */
|
|
2143
|
+
const layoutPages = (ctx, sidebar) => {
|
|
2144
|
+
const layout = createLayout(ctx);
|
|
2145
|
+
const contentPages = [...ctx.index.kinds]
|
|
2146
|
+
.filter(
|
|
2147
|
+
([name, kind]) =>
|
|
2148
|
+
kind === "content" &&
|
|
2149
|
+
ctx.index.routes[name] !== "/" &&
|
|
2150
|
+
MARKDOWN.test(ctx.files[name])
|
|
2151
|
+
)
|
|
2152
|
+
.map(([name]) => name);
|
|
2153
|
+
if (sidebar === null) {
|
|
2154
|
+
for (const name of contentPages) {
|
|
2155
|
+
layout.placed.set(name, {
|
|
2156
|
+
dir: "",
|
|
2157
|
+
file: fileBase(ctx.index, name),
|
|
2158
|
+
label: null,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
return layout;
|
|
2162
|
+
}
|
|
2163
|
+
const rootPages = [];
|
|
2164
|
+
layout.notes.push(...sidebar.notes);
|
|
2165
|
+
for (const group of sidebar.groups) {
|
|
2166
|
+
placeGroup(layout, group, rootPages);
|
|
2167
|
+
}
|
|
2168
|
+
const unlisted = contentPages
|
|
2169
|
+
.filter((name) => !layout.placed.has(name))
|
|
2170
|
+
.toSorted((left, right) =>
|
|
2171
|
+
compareText(titleOf(left).toLowerCase(), titleOf(right).toLowerCase())
|
|
2172
|
+
);
|
|
2173
|
+
if (unlisted.length > 0) {
|
|
2174
|
+
const dir = uniqueName(layout.folders.get(""), "(more)");
|
|
2175
|
+
rootPages.push(dir.slice(1, -1));
|
|
2176
|
+
addMeta(layout, dir, {
|
|
2177
|
+
collapsed: true,
|
|
2178
|
+
display: "group",
|
|
2179
|
+
pages: unlisted.map((name) => fileBase(ctx.index, name)),
|
|
2180
|
+
title: "More pages",
|
|
2181
|
+
});
|
|
2182
|
+
for (const name of unlisted) {
|
|
2183
|
+
layout.placed.set(name, {
|
|
2184
|
+
dir,
|
|
2185
|
+
file: fileBase(ctx.index, name),
|
|
2186
|
+
label: null,
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
layout.metas.set("", { pages: rootPages });
|
|
2191
|
+
return layout;
|
|
2192
|
+
};
|
|
2193
|
+
|
|
2194
|
+
const metaSource = (meta) => {
|
|
2195
|
+
const lines = [
|
|
2196
|
+
'import { defineMeta } from "blume";',
|
|
2197
|
+
"",
|
|
2198
|
+
"export default defineMeta({",
|
|
2199
|
+
];
|
|
2200
|
+
for (const key of ["title", "display", "collapsed", "pages"]) {
|
|
2201
|
+
if (meta[key] !== undefined) {
|
|
2202
|
+
const value = Array.isArray(meta[key])
|
|
2203
|
+
? `[\n${meta[key].map((item) => ` ${JSON.stringify(item)},`).join("\n")}\n ]`
|
|
2204
|
+
: JSON.stringify(meta[key]);
|
|
2205
|
+
lines.push(` ${key}: ${value},`);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
lines.push("});", "");
|
|
2209
|
+
return lines.join("\n");
|
|
2210
|
+
};
|
|
2211
|
+
|
|
2212
|
+
// --- convert ---------------------------------------------------------------------------
|
|
2213
|
+
|
|
2214
|
+
const readTables = (root) => {
|
|
2215
|
+
const routesFile = path.join(root, ROUTES_FILE);
|
|
2216
|
+
const filesFile = path.join(root, FILES_FILE);
|
|
2217
|
+
if (!existsSync(routesFile) || !existsSync(filesFile)) {
|
|
2218
|
+
throw new Error(
|
|
2219
|
+
`no ${ROUTES_FILE}/${FILES_FILE} here: run \`routes --write\` first`
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
const routes = readJson(routesFile);
|
|
2223
|
+
const files = readJson(filesFile);
|
|
2224
|
+
for (const [name, value] of Object.entries(routes)) {
|
|
2225
|
+
if (value !== null && !/^(?:\/|https?:\/\/)/u.test(String(value))) {
|
|
2226
|
+
throw new Error(
|
|
2227
|
+
`${ROUTES_FILE}: "${name}" must map to a route, a URL, or null`
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
if (!(name in files)) {
|
|
2231
|
+
throw new Error(`${FILES_FILE} has no file for "${name}"`);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return { files, routes };
|
|
2235
|
+
};
|
|
2236
|
+
|
|
2237
|
+
const findChrome = (files, pattern) =>
|
|
2238
|
+
files
|
|
2239
|
+
.filter((file) => !file.includes("/") && pattern.test(file))
|
|
2240
|
+
.toSorted(
|
|
2241
|
+
(left, right) =>
|
|
2242
|
+
Number(MARKDOWN.test(right)) - Number(MARKDOWN.test(left))
|
|
2243
|
+
)[0] ?? null;
|
|
2244
|
+
|
|
2245
|
+
const planPage = (ctx, layout, name, source) => {
|
|
2246
|
+
const place = layout.placed.get(name) ?? {
|
|
2247
|
+
dir: "",
|
|
2248
|
+
file: "index",
|
|
2249
|
+
label: null,
|
|
2250
|
+
};
|
|
2251
|
+
const page = createPage(source, name);
|
|
2252
|
+
const draft = posix.join(ctx.content, place.dir, `${place.file}.md`);
|
|
2253
|
+
const pageCtx = {
|
|
2254
|
+
...ctx,
|
|
2255
|
+
dest: draft,
|
|
2256
|
+
done: new Set(),
|
|
2257
|
+
html: false,
|
|
2258
|
+
line: 0,
|
|
2259
|
+
name,
|
|
2260
|
+
page,
|
|
2261
|
+
};
|
|
2262
|
+
const { body, needsMdx } = convertBody(
|
|
2263
|
+
pageCtx,
|
|
2264
|
+
readFileSync(path.join(ctx.root, source), "utf-8")
|
|
2265
|
+
);
|
|
2266
|
+
const dest = needsMdx ? draft.replace(/\.md$/u, ".mdx") : draft;
|
|
2267
|
+
const route = ctx.index.routes[name];
|
|
2268
|
+
const slug =
|
|
2269
|
+
derivedRoute(posix.relative(ctx.content, dest)) === route
|
|
2270
|
+
? null
|
|
2271
|
+
: route.slice(1);
|
|
2272
|
+
if (slug) {
|
|
2273
|
+
edit(page, "slug pinned");
|
|
2274
|
+
}
|
|
2275
|
+
if (needsMdx) {
|
|
2276
|
+
edit(page, "renamed to .mdx");
|
|
2277
|
+
}
|
|
2278
|
+
const head = frontmatter({ label: place.label, slug, title: titleOf(name) });
|
|
2279
|
+
return { dest, page, text: `${head}\n\n${body}\n` };
|
|
2280
|
+
};
|
|
2281
|
+
|
|
2282
|
+
const leftBehind = (ctx, files, pages, moved) => {
|
|
2283
|
+
const kept = new Set(moved);
|
|
2284
|
+
const lines = [];
|
|
2285
|
+
for (const [name, kind] of ctx.index.kinds) {
|
|
2286
|
+
const file = ctx.files[name];
|
|
2287
|
+
if (kind !== "content" && existsSync(path.join(ctx.root, file))) {
|
|
2288
|
+
lines.push(`${file} (${kind === "moved" ? "moved notice" : "left out"})`);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
const served = new Set(Object.values(ctx.files));
|
|
2292
|
+
for (const file of pages) {
|
|
2293
|
+
if (!served.has(file)) {
|
|
2294
|
+
lines.push(`${file} (never served: another file has its name)`);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
const assets = files.filter(
|
|
2298
|
+
(file) =>
|
|
2299
|
+
!MARKUP.test(file) &&
|
|
2300
|
+
!CHROME.test(posix.basename(file)) &&
|
|
2301
|
+
!PROJECT_FILES.has(file) &&
|
|
2302
|
+
!kept.has(file)
|
|
2303
|
+
);
|
|
2304
|
+
return { assets, pages: lines.toSorted(compareText) };
|
|
2305
|
+
};
|
|
2306
|
+
|
|
2307
|
+
const siteNotes = (root, files, sidebarFile, layout) => {
|
|
2308
|
+
const notes = [...layout.notes];
|
|
2309
|
+
if (sidebarFile && !MARKDOWN.test(sidebarFile)) {
|
|
2310
|
+
notes.push(`${sidebarFile} isn't Markdown: rebuild the groups by hand`);
|
|
2311
|
+
}
|
|
2312
|
+
for (const file of files) {
|
|
2313
|
+
if (file.includes("/") && CHROME.test(posix.basename(file))) {
|
|
2314
|
+
notes.push(
|
|
2315
|
+
`${file} replaced the sidebar or footer for its folder: map that folder by hand (i18n or a tab)`
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
const footer = findChrome(files, FOOTER);
|
|
2320
|
+
if (footer) {
|
|
2321
|
+
const text = readFileSync(path.join(root, footer), "utf-8")
|
|
2322
|
+
.trim()
|
|
2323
|
+
.split("\n")
|
|
2324
|
+
.slice(0, MAX_NOTICE_LINES);
|
|
2325
|
+
notes.push(
|
|
2326
|
+
`${footer}: ${text.join(" / ")} (links → footer.links; a "contribute to this wiki" footer drops with its link)`
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
return notes;
|
|
2330
|
+
};
|
|
2331
|
+
|
|
2332
|
+
const planAll = (ctx, layout) => {
|
|
2333
|
+
const results = [];
|
|
2334
|
+
const unconverted = [];
|
|
2335
|
+
let already = 0;
|
|
2336
|
+
for (const [name, kind] of ctx.index.kinds) {
|
|
2337
|
+
const source = ctx.files[name];
|
|
2338
|
+
if (kind === "content" && !MARKDOWN.test(source)) {
|
|
2339
|
+
unconverted.push(source);
|
|
2340
|
+
} else if (kind === "content" && existsSync(path.join(ctx.root, source))) {
|
|
2341
|
+
results.push(planPage(ctx, layout, name, source));
|
|
2342
|
+
} else if (kind === "content") {
|
|
2343
|
+
already += 1;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
return { already, results, unconverted };
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
const writeConversion = (ctx, results, metas) => {
|
|
2350
|
+
const { content, root } = ctx;
|
|
2351
|
+
for (const result of results) {
|
|
2352
|
+
const destination = path.join(root, result.dest);
|
|
2353
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
2354
|
+
writeFileSync(destination, result.text);
|
|
2355
|
+
if (result.dest !== result.page.file) {
|
|
2356
|
+
rmSync(path.join(root, result.page.file));
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
for (const image of [...ctx.images].toSorted(compareText)) {
|
|
2360
|
+
const from = path.join(root, image);
|
|
2361
|
+
if (existsSync(from)) {
|
|
2362
|
+
mkdirSync(path.join(root, content, path.dirname(image)), {
|
|
2363
|
+
recursive: true,
|
|
2364
|
+
});
|
|
2365
|
+
renameSync(from, path.join(root, content, image));
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
for (const [dir, meta] of metas) {
|
|
2369
|
+
const file = path.join(root, content, dir, "meta.ts");
|
|
2370
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
2371
|
+
writeFileSync(file, metaSource(meta));
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
|
|
2375
|
+
const convert = (root, options) => {
|
|
2376
|
+
const repo = options.get("repo") ?? "";
|
|
2377
|
+
if (!REPO.test(repo)) {
|
|
2378
|
+
throw new Error("convert needs --repo <owner/repo>");
|
|
2379
|
+
}
|
|
2380
|
+
const content = options.get("content") || "docs";
|
|
2381
|
+
const tables = readTables(root);
|
|
2382
|
+
const { files, pages } = listPages(root, content);
|
|
2383
|
+
const ctx = {
|
|
2384
|
+
content,
|
|
2385
|
+
emoji: loadEmoji(options.get("emoji")),
|
|
2386
|
+
// Files only: after a first run, `docs/<route>/` folders exist, and on a
|
|
2387
|
+
// case-insensitive disk `docs/Some-Page` would otherwise name one.
|
|
2388
|
+
fileExists: (file) =>
|
|
2389
|
+
isFile(path.join(root, file)) || isFile(path.join(root, content, file)),
|
|
2390
|
+
files: tables.files,
|
|
2391
|
+
images: new Set(),
|
|
2392
|
+
index: classify(tables.routes),
|
|
2393
|
+
root,
|
|
2394
|
+
urls: repoUrls(repo, options.get("mirror")),
|
|
2395
|
+
};
|
|
2396
|
+
const sidebarFile = findChrome(files, SIDEBAR);
|
|
2397
|
+
const harvested =
|
|
2398
|
+
sidebarFile === null && existsSync(path.join(root, content, "meta.ts"));
|
|
2399
|
+
const sidebar =
|
|
2400
|
+
sidebarFile && MARKDOWN.test(sidebarFile)
|
|
2401
|
+
? parseSidebar(readFileSync(path.join(root, sidebarFile), "utf-8"))
|
|
2402
|
+
: null;
|
|
2403
|
+
const layout = layoutPages(ctx, sidebar);
|
|
2404
|
+
const planned = planAll(ctx, layout);
|
|
2405
|
+
const metas = sidebar === null || harvested ? [] : [...layout.metas];
|
|
2406
|
+
if (options.has("write")) {
|
|
2407
|
+
writeConversion(ctx, planned.results, metas);
|
|
2408
|
+
}
|
|
2409
|
+
return {
|
|
2410
|
+
...planned,
|
|
2411
|
+
featured: layout.featured,
|
|
2412
|
+
images: [...ctx.images].toSorted(compareText),
|
|
2413
|
+
left: leftBehind(ctx, files, pages, ctx.images),
|
|
2414
|
+
metas: metas.map(([dir]) => posix.join(content, dir, "meta.ts")),
|
|
2415
|
+
notes: siteNotes(root, files, sidebarFile, layout),
|
|
2416
|
+
};
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
// --- routes ----------------------------------------------------------------------------
|
|
2420
|
+
|
|
2421
|
+
const noticeTarget = (root, file, urls, routes) => {
|
|
2422
|
+
const text = readFileSync(path.join(root, file), "utf-8");
|
|
2423
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
2424
|
+
if (lines.length > MAX_NOTICE_LINES || !MOVED_NOTICE.test(text)) {
|
|
2425
|
+
return null;
|
|
2426
|
+
}
|
|
2427
|
+
const url = FIRST_URL.exec(text)?.[0] ?? null;
|
|
2428
|
+
const wiki = url && urls ? urls.wiki.exec(url) : null;
|
|
2429
|
+
if (wiki) {
|
|
2430
|
+
const name = decode((wiki.groups.rest ?? "/Home").slice(1));
|
|
2431
|
+
const own = Object.keys(routes).find((key) => keyOf(key) === keyOf(name));
|
|
2432
|
+
return own ? `${routes[own]}${wiki.groups.hash ?? ""}` : url;
|
|
2433
|
+
}
|
|
2434
|
+
return url;
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
const looksNotContent = (root, file) => {
|
|
2438
|
+
const folders = file.split("/").slice(0, -1);
|
|
2439
|
+
if (folders.some((folder) => NOT_CONTENT_FOLDERS.has(folder.toLowerCase()))) {
|
|
2440
|
+
return true;
|
|
2441
|
+
}
|
|
2442
|
+
if (!file.includes("/") && NOT_CONTENT.has(pageNameOf(file).toLowerCase())) {
|
|
2443
|
+
return true;
|
|
2444
|
+
}
|
|
2445
|
+
const text = readFileSync(path.join(root, file), "utf-8").trim();
|
|
2446
|
+
return (
|
|
2447
|
+
text.split("\n").length === 1 && text.length < 40 && !text.includes("](")
|
|
2448
|
+
);
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
const routesCommand = (root, options) => {
|
|
2452
|
+
const content = options.get("content") || "docs";
|
|
2453
|
+
const repo = options.get("repo") ?? "";
|
|
2454
|
+
const urls = REPO.test(repo) ? repoUrls(repo, null) : null;
|
|
2455
|
+
const { pages } = listPages(root, content);
|
|
2456
|
+
const table = buildTable(pages);
|
|
2457
|
+
const moved = [];
|
|
2458
|
+
const notContent = [];
|
|
2459
|
+
for (const [name, file] of Object.entries(table.sources)) {
|
|
2460
|
+
const target = MARKDOWN.test(file)
|
|
2461
|
+
? noticeTarget(root, file, urls, table.routes)
|
|
2462
|
+
: null;
|
|
2463
|
+
if (target) {
|
|
2464
|
+
moved.push(`"${name}": "${target}"`);
|
|
2465
|
+
} else if (looksNotContent(root, file)) {
|
|
2466
|
+
notContent.push(`"${name}": null (${file})`);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
const digitLed = Object.entries(table.routes)
|
|
2470
|
+
.filter(([, route]) => ORDERING_PREFIX.test(route.slice(1)))
|
|
2471
|
+
.map(([name, route]) => `${name} → ${route} (convert pins its slug)`);
|
|
2472
|
+
const other = pages.filter((file) => !MARKDOWN.test(file));
|
|
2473
|
+
const routesFile = path.join(root, ROUTES_FILE);
|
|
2474
|
+
const existing = existsSync(routesFile) ? readJson(routesFile) : null;
|
|
2475
|
+
const changes = [];
|
|
2476
|
+
if (existing) {
|
|
2477
|
+
for (const name of Object.keys(table.routes)) {
|
|
2478
|
+
if (!(name in existing)) {
|
|
2479
|
+
changes.push(`not in ${ROUTES_FILE}: ${name} (${table.sources[name]})`);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
for (const name of Object.keys(existing)) {
|
|
2483
|
+
if (!(name in table.routes)) {
|
|
2484
|
+
changes.push(`in ${ROUTES_FILE} but not a page here: ${name}`);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
} else if (options.has("write")) {
|
|
2488
|
+
writeJson(routesFile, table.routes);
|
|
2489
|
+
writeJson(path.join(root, FILES_FILE), table.sources);
|
|
2490
|
+
}
|
|
2491
|
+
return {
|
|
2492
|
+
changes,
|
|
2493
|
+
digitLed,
|
|
2494
|
+
existing: existing !== null,
|
|
2495
|
+
moved,
|
|
2496
|
+
notContent,
|
|
2497
|
+
other,
|
|
2498
|
+
table,
|
|
2499
|
+
};
|
|
2500
|
+
};
|
|
2501
|
+
|
|
2502
|
+
// --- stubs -------------------------------------------------------------------------------
|
|
2503
|
+
|
|
2504
|
+
const stubWriter = (root, write, changes) => (file, text) => {
|
|
2505
|
+
const target = path.join(root, file);
|
|
2506
|
+
if (existsSync(target) && readFileSync(target, "utf-8") === text) {
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
changes.push(file);
|
|
2510
|
+
if (write) {
|
|
2511
|
+
writeFileSync(target, text);
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
const removeFile = (root, write, changes, file) => {
|
|
2516
|
+
changes.push(`${file} (removed)`);
|
|
2517
|
+
if (write) {
|
|
2518
|
+
rmSync(path.join(root, file));
|
|
2519
|
+
}
|
|
2520
|
+
};
|
|
2521
|
+
|
|
2522
|
+
const noticeOnly = (root, tables, all, put, notice) => {
|
|
2523
|
+
const home = Object.keys(tables.routes).find(
|
|
2524
|
+
(name) => keyOf(name) === "home"
|
|
2525
|
+
);
|
|
2526
|
+
const targets = [
|
|
2527
|
+
home ? tables.files[home] : null,
|
|
2528
|
+
findChrome(all, SIDEBAR) ?? "_Sidebar.md",
|
|
2529
|
+
];
|
|
2530
|
+
for (const file of targets.filter(Boolean)) {
|
|
2531
|
+
const target = path.join(root, file);
|
|
2532
|
+
const text = existsSync(target) ? readFileSync(target, "utf-8") : "";
|
|
2533
|
+
if (!text.startsWith(notice)) {
|
|
2534
|
+
put(file, `${notice}\n\n${text}`);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
};
|
|
2538
|
+
|
|
2539
|
+
const stubsCommand = (root, options) => {
|
|
2540
|
+
const site = (options.get("site") ?? "").replace(/\/+$/u, "");
|
|
2541
|
+
if (!/^https?:\/\//u.test(site)) {
|
|
2542
|
+
throw new Error("stubs needs --site <the new site's URL>");
|
|
2543
|
+
}
|
|
2544
|
+
if (existsSync(path.join(root, "blume.config.ts"))) {
|
|
2545
|
+
throw new Error(
|
|
2546
|
+
"stubs runs in a fresh clone of the wiki, not in the Blume project: clone the wiki elsewhere and copy the two tables in"
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
const tables = readTables(root);
|
|
2550
|
+
const changes = [];
|
|
2551
|
+
const left = [];
|
|
2552
|
+
const write = options.has("write");
|
|
2553
|
+
const put = stubWriter(root, write, changes);
|
|
2554
|
+
const notice = `**The docs have moved to <${site}>.**`;
|
|
2555
|
+
const all = walk(root, new Set());
|
|
2556
|
+
if (options.has("notice-only")) {
|
|
2557
|
+
noticeOnly(root, tables, all, put, notice);
|
|
2558
|
+
return { changes, left };
|
|
2559
|
+
}
|
|
2560
|
+
for (const [name, value] of Object.entries(tables.routes)) {
|
|
2561
|
+
const source = tables.files[name];
|
|
2562
|
+
if (value === null) {
|
|
2563
|
+
left.push(source);
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2566
|
+
const url = URL_SCHEME.test(value) ? value : `${site}${value}`;
|
|
2567
|
+
const stub = source.replace(/\.[^./]+$/u, ".md");
|
|
2568
|
+
put(stub, `**This page has moved to <${url}>.**\n`);
|
|
2569
|
+
if (stub !== source && existsSync(path.join(root, source))) {
|
|
2570
|
+
removeFile(root, write, changes, source);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
for (const file of all) {
|
|
2574
|
+
if (CHROME.test(posix.basename(file)) && file !== "_Sidebar.md") {
|
|
2575
|
+
removeFile(root, write, changes, file);
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
put("_Sidebar.md", `${notice}\n`);
|
|
2579
|
+
return { changes, left };
|
|
2580
|
+
};
|
|
2581
|
+
|
|
2582
|
+
// --- check ---------------------------------------------------------------------------------
|
|
2583
|
+
|
|
2584
|
+
const PAGE_IN_URL =
|
|
2585
|
+
/\/wiki(?:\/(?<page>[^?#]*))?(?:\?[^#]*)?(?:#(?<anchor>.*))?$/u;
|
|
2586
|
+
|
|
2587
|
+
const idsOf = (html) =>
|
|
2588
|
+
new Set([...html.matchAll(ID_ATTRIBUTE)].map((match) => match.groups.id));
|
|
2589
|
+
|
|
2590
|
+
/** Where the pages are: a server build (`vercel()`, `node()`, …) keeps them in `client/`. */
|
|
2591
|
+
const pagesRoot = (dist) => {
|
|
2592
|
+
const client = path.join(dist, "client");
|
|
2593
|
+
return existsSync(client) &&
|
|
2594
|
+
statSync(client).isDirectory() &&
|
|
2595
|
+
!existsSync(path.join(dist, "index.html"))
|
|
2596
|
+
? client
|
|
2597
|
+
: dist;
|
|
2598
|
+
};
|
|
2599
|
+
|
|
2600
|
+
const checkRoute = (dist, route) => {
|
|
2601
|
+
const [page, anchor = ""] = route.split("#");
|
|
2602
|
+
const file =
|
|
2603
|
+
page === "/"
|
|
2604
|
+
? path.join(dist, "index.html")
|
|
2605
|
+
: path.join(dist, decode(page), "index.html");
|
|
2606
|
+
if (!existsSync(file)) {
|
|
2607
|
+
return { status: "missing page" };
|
|
2608
|
+
}
|
|
2609
|
+
if (anchor === "") {
|
|
2610
|
+
return { status: "ok" };
|
|
2611
|
+
}
|
|
2612
|
+
const ids = idsOf(readFileSync(file, "utf-8"));
|
|
2613
|
+
if (ids.has(decode(anchor))) {
|
|
2614
|
+
return { status: "ok" };
|
|
2615
|
+
}
|
|
2616
|
+
const words = decode(anchor)
|
|
2617
|
+
.toLowerCase()
|
|
2618
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
2619
|
+
.filter(Boolean);
|
|
2620
|
+
const suggestions = [...ids]
|
|
2621
|
+
.filter(
|
|
2622
|
+
(id) => words.length > 0 && words.every((word) => id.includes(word))
|
|
2623
|
+
)
|
|
2624
|
+
.slice(0, MAX_ID_SUGGESTIONS);
|
|
2625
|
+
return { status: "missing anchor", suggestions };
|
|
2626
|
+
};
|
|
2627
|
+
|
|
2628
|
+
const checkCommand = (root, options, urls) => {
|
|
2629
|
+
const dist = pagesRoot(path.join(root, options.get("dist") || "dist"));
|
|
2630
|
+
const { routes } = readTables(root);
|
|
2631
|
+
const index = classify(routes);
|
|
2632
|
+
const lines = [];
|
|
2633
|
+
let problems = 0;
|
|
2634
|
+
const report = (label, route) => {
|
|
2635
|
+
const result = checkRoute(dist, route);
|
|
2636
|
+
if (result.status !== "ok") {
|
|
2637
|
+
problems += 1;
|
|
2638
|
+
}
|
|
2639
|
+
const hint = result.suggestions?.length
|
|
2640
|
+
? ` (ids like: ${result.suggestions.join(", ")})`
|
|
2641
|
+
: "";
|
|
2642
|
+
lines.push(
|
|
2643
|
+
`${result.status === "ok" ? "ok" : result.status.toUpperCase()} ${label} → ${route}${hint}`
|
|
2644
|
+
);
|
|
2645
|
+
};
|
|
2646
|
+
if (urls.length === 0) {
|
|
2647
|
+
for (const [name, value] of Object.entries(routes)) {
|
|
2648
|
+
if (value !== null && !URL_SCHEME.test(value)) {
|
|
2649
|
+
report(name, value);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return { lines, problems };
|
|
2653
|
+
}
|
|
2654
|
+
for (const url of urls) {
|
|
2655
|
+
const match = PAGE_IN_URL.exec(url);
|
|
2656
|
+
const target = `${decode(match?.groups.page || "Home")}${match?.groups.anchor ? `#${match.groups.anchor}` : ""}`;
|
|
2657
|
+
const result = resolvePage(index, target, "Home");
|
|
2658
|
+
if (result.href?.startsWith("/")) {
|
|
2659
|
+
report(url, result.href);
|
|
2660
|
+
} else if (result.kind === "moved") {
|
|
2661
|
+
lines.push(`moved ${url} → ${result.href}`);
|
|
2662
|
+
} else {
|
|
2663
|
+
problems += 1;
|
|
2664
|
+
lines.push(`${result.kind.toUpperCase()} ${url}`);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
return { lines, problems };
|
|
2668
|
+
};
|
|
2669
|
+
|
|
2670
|
+
// --- Reports -----------------------------------------------------------------------------
|
|
2671
|
+
|
|
2672
|
+
const printList = (heading, items) => {
|
|
2673
|
+
if (items.length === 0) {
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
process.stdout.write(`\n${heading}\n`);
|
|
2677
|
+
for (const item of items) {
|
|
2678
|
+
process.stdout.write(` ${item}\n`);
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
|
|
2682
|
+
const printRoutes = (result, write) => {
|
|
2683
|
+
const { table } = result;
|
|
2684
|
+
process.stdout.write(`${Object.keys(table.routes).length} page(s)\n`);
|
|
2685
|
+
printList(
|
|
2686
|
+
"Never served (GitHub served another file with the same name): leave them out",
|
|
2687
|
+
table.shadowed
|
|
2688
|
+
);
|
|
2689
|
+
printList(
|
|
2690
|
+
"Same route: give one of them another route in the table",
|
|
2691
|
+
table.clashes
|
|
2692
|
+
);
|
|
2693
|
+
printList(
|
|
2694
|
+
"Looks like a moved notice: set its entry to the target",
|
|
2695
|
+
result.moved
|
|
2696
|
+
);
|
|
2697
|
+
printList(
|
|
2698
|
+
"Looks like it isn't content (check it): set its entry to null",
|
|
2699
|
+
result.notContent
|
|
2700
|
+
);
|
|
2701
|
+
printList("Starts with digits", result.digitLed);
|
|
2702
|
+
printList(
|
|
2703
|
+
"Not Markdown: convert to Markdown first (pandoc), then rerun",
|
|
2704
|
+
result.other
|
|
2705
|
+
);
|
|
2706
|
+
printList("The table and the pages differ", result.changes);
|
|
2707
|
+
let verdict = `\nWrote ${ROUTES_FILE} and ${FILES_FILE}. Classify the pages, then run convert.\n`;
|
|
2708
|
+
if (result.existing) {
|
|
2709
|
+
verdict = `\n${ROUTES_FILE} exists, so nothing was written.\n`;
|
|
2710
|
+
} else if (!write) {
|
|
2711
|
+
verdict = "\nRe-run with --write to write the table.\n";
|
|
2712
|
+
}
|
|
2713
|
+
process.stdout.write(verdict);
|
|
2714
|
+
};
|
|
2715
|
+
|
|
2716
|
+
const printConvert = (result, write) => {
|
|
2717
|
+
const totals = new Map();
|
|
2718
|
+
let reviews = 0;
|
|
2719
|
+
for (const { dest, page } of result.results) {
|
|
2720
|
+
process.stdout.write(`\n${page.file} → ${dest}\n`);
|
|
2721
|
+
for (const [kind, count] of Object.entries(page.edits).toSorted()) {
|
|
2722
|
+
process.stdout.write(` ${count} × ${kind}\n`);
|
|
2723
|
+
totals.set(kind, (totals.get(kind) ?? 0) + count);
|
|
2724
|
+
}
|
|
2725
|
+
for (const { line, message } of page.reviews) {
|
|
2726
|
+
process.stdout.write(` REVIEW line ${line}: ${message}\n`);
|
|
2727
|
+
}
|
|
2728
|
+
reviews += page.reviews.length;
|
|
2729
|
+
}
|
|
2730
|
+
printList(
|
|
2731
|
+
"Site",
|
|
2732
|
+
result.notes.map((note) => `REVIEW ${note}`)
|
|
2733
|
+
);
|
|
2734
|
+
if (result.featured.length > 0) {
|
|
2735
|
+
process.stdout.write(
|
|
2736
|
+
`\nnavigation.featured for blume.config.ts:\n ${JSON.stringify(result.featured)}\n`
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
printList("meta.ts files", result.metas);
|
|
2740
|
+
printList("Images moved beside the pages", result.images);
|
|
2741
|
+
printList(
|
|
2742
|
+
"Not Markdown: convert to Markdown first (pandoc), then rerun",
|
|
2743
|
+
result.unconverted
|
|
2744
|
+
);
|
|
2745
|
+
printList(
|
|
2746
|
+
"Pages left behind: delete them from the project (the wiki keeps them)",
|
|
2747
|
+
result.left.pages
|
|
2748
|
+
);
|
|
2749
|
+
printList(
|
|
2750
|
+
"Other files left in place: wiki tooling or unreferenced files to delete or keep",
|
|
2751
|
+
result.left.assets
|
|
2752
|
+
);
|
|
2753
|
+
printList(
|
|
2754
|
+
"Totals",
|
|
2755
|
+
[...totals].toSorted().map(([kind, count]) => `${count} × ${kind}`)
|
|
2756
|
+
);
|
|
2757
|
+
const verb = write ? "converted" : "would convert";
|
|
2758
|
+
const hint = write ? "" : " Re-run with --write to apply.";
|
|
2759
|
+
const done =
|
|
2760
|
+
result.already > 0 ? ` ${result.already} already converted.` : "";
|
|
2761
|
+
process.stdout.write(
|
|
2762
|
+
`\n${result.results.length} page(s) ${verb},${done} ${reviews + result.notes.length} item(s) to review.${hint}\n`
|
|
2763
|
+
);
|
|
2764
|
+
};
|
|
2765
|
+
|
|
2766
|
+
const main = () => {
|
|
2767
|
+
const { options, positional } = parseArgs(process.argv.slice(2));
|
|
2768
|
+
const [command, ...rest] = positional;
|
|
2769
|
+
const root = process.cwd();
|
|
2770
|
+
const write = options.has("write");
|
|
2771
|
+
if (options.has("help") || command === undefined) {
|
|
2772
|
+
process.stdout.write(`${HELP}\n`);
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
try {
|
|
2776
|
+
if (command === "routes") {
|
|
2777
|
+
printRoutes(routesCommand(root, options), write);
|
|
2778
|
+
} else if (command === "convert") {
|
|
2779
|
+
printConvert(convert(root, options), write);
|
|
2780
|
+
} else if (command === "stubs") {
|
|
2781
|
+
const result = stubsCommand(root, options);
|
|
2782
|
+
printList(write ? "Changed" : "Would change", result.changes);
|
|
2783
|
+
printList(
|
|
2784
|
+
"Left as they are (entries set to null): decide by hand",
|
|
2785
|
+
result.left
|
|
2786
|
+
);
|
|
2787
|
+
process.stdout.write(
|
|
2788
|
+
write
|
|
2789
|
+
? "\nDone. Nothing was committed or pushed: the stubs reach the wiki only when the user reviews them and pushes, after the new site is live.\n"
|
|
2790
|
+
: "\nRe-run with --write to apply.\n"
|
|
2791
|
+
);
|
|
2792
|
+
} else if (command === "check") {
|
|
2793
|
+
const result = checkCommand(root, options, rest);
|
|
2794
|
+
process.stdout.write(
|
|
2795
|
+
`${result.lines.join("\n")}\n\n${result.problems} problem(s).\n`
|
|
2796
|
+
);
|
|
2797
|
+
process.exitCode = result.problems > 0 ? 1 : 0;
|
|
2798
|
+
} else {
|
|
2799
|
+
process.stdout.write(`${HELP}\n`);
|
|
2800
|
+
process.exitCode = 1;
|
|
2801
|
+
}
|
|
2802
|
+
} catch (error) {
|
|
2803
|
+
process.stderr.write(`github-wiki-codemod: ${error.message}\n`);
|
|
2804
|
+
process.exitCode = 1;
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
|
|
2808
|
+
main();
|