blume 2.1.2 → 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 +133 -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 +10 -9
- 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
|
@@ -11,10 +11,18 @@ import { mountBasePath } from "../base-path.ts";
|
|
|
11
11
|
import { nextFenceState } from "../code-fences.ts";
|
|
12
12
|
import type { FenceState } from "../code-fences.ts";
|
|
13
13
|
import { diagnosticsFromIssues, diagnosticsFromZod } from "../diagnostics.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
headingSlug,
|
|
16
|
+
joinHeadingText,
|
|
17
|
+
occupySlug,
|
|
18
|
+
parseHeadingMarkers,
|
|
19
|
+
rawHeadingTag,
|
|
20
|
+
} from "../heading-markers.ts";
|
|
15
21
|
import { localePlacement, localizeRoute } from "../i18n.ts";
|
|
22
|
+
import { extractElementUses } from "../mdx-elements.ts";
|
|
16
23
|
import { titleWord } from "../navigation.ts";
|
|
17
|
-
import { stripOrderingPrefix } from "../ordering-prefix.ts";
|
|
24
|
+
import { formerRouteName, stripOrderingPrefix } from "../ordering-prefix.ts";
|
|
25
|
+
import { escapePinnedIds } from "../pinned-ids.ts";
|
|
18
26
|
import { relatedPageLinks } from "../related.ts";
|
|
19
27
|
import { pageMetaSchema } from "../schema.ts";
|
|
20
28
|
import type {
|
|
@@ -25,6 +33,7 @@ import type {
|
|
|
25
33
|
import { trimChar } from "../trim.ts";
|
|
26
34
|
import type {
|
|
27
35
|
Diagnostic,
|
|
36
|
+
ElementUse,
|
|
28
37
|
ExampleUse,
|
|
29
38
|
Heading,
|
|
30
39
|
PageLink,
|
|
@@ -157,6 +166,14 @@ export const frontmatterYamlDiagnostic = (
|
|
|
157
166
|
'Wrap the value at that line in double quotes, e.g. description: "Setup: the easy way".',
|
|
158
167
|
});
|
|
159
168
|
|
|
169
|
+
/**
|
|
170
|
+
* A file or folder name as its route segment spells it: without its ordering
|
|
171
|
+
* prefix, or — for the `former` route — as an earlier Blume dropped one (see
|
|
172
|
+
* {@link formerRouteName}).
|
|
173
|
+
*/
|
|
174
|
+
const cleanName = (part: string, former: boolean): string =>
|
|
175
|
+
(former ? formerRouteName(part) : undefined) ?? stripNumericPrefix(part);
|
|
176
|
+
|
|
160
177
|
/**
|
|
161
178
|
* Fold one raw path part into the accumulating route segments/groups.
|
|
162
179
|
* `ordered` parts are file or folder names, whose ordering prefix is dropped.
|
|
@@ -165,7 +182,8 @@ const addRouteSegment = (
|
|
|
165
182
|
part: string,
|
|
166
183
|
segments: string[],
|
|
167
184
|
groups: string[],
|
|
168
|
-
ordered: boolean
|
|
185
|
+
ordered: boolean,
|
|
186
|
+
former: boolean
|
|
169
187
|
): void => {
|
|
170
188
|
// A leading/trailing/double slash yields an empty part; keeping it would
|
|
171
189
|
// produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
|
|
@@ -177,7 +195,7 @@ const addRouteSegment = (
|
|
|
177
195
|
groups.push(group);
|
|
178
196
|
return;
|
|
179
197
|
}
|
|
180
|
-
const clean = ordered ?
|
|
198
|
+
const clean = ordered ? cleanName(part, former) : part;
|
|
181
199
|
if (clean === "index") {
|
|
182
200
|
return;
|
|
183
201
|
}
|
|
@@ -202,12 +220,14 @@ interface MappedRoute {
|
|
|
202
220
|
* `ordered` says they are file and folder names: the route prefix, a slug,
|
|
203
221
|
* a release tag, or a CMS slug is a route spelled out, kept as written. Not
|
|
204
222
|
* exported: a source that needs to predict a route goes through
|
|
205
|
-
* {@link resolveEntryRoute}, so there is exactly one derivation.
|
|
223
|
+
* {@link resolveEntryRoute}, so there is exactly one derivation. `former`
|
|
224
|
+
* maps the route an earlier Blume gave the path instead.
|
|
206
225
|
*/
|
|
207
226
|
const mapRoute = (
|
|
208
227
|
prefix: string | undefined,
|
|
209
228
|
relativePath: string,
|
|
210
|
-
ordered: boolean
|
|
229
|
+
ordered: boolean,
|
|
230
|
+
former = false
|
|
211
231
|
): MappedRoute => {
|
|
212
232
|
const withoutExt = relativePath.slice(
|
|
213
233
|
0,
|
|
@@ -218,10 +238,10 @@ const mapRoute = (
|
|
|
218
238
|
const groups: string[] = [];
|
|
219
239
|
|
|
220
240
|
for (const part of prefix ? prefix.split("/") : []) {
|
|
221
|
-
addRouteSegment(part, segments, groups, false);
|
|
241
|
+
addRouteSegment(part, segments, groups, false, false);
|
|
222
242
|
}
|
|
223
243
|
for (const part of withoutExt.split("/")) {
|
|
224
|
-
addRouteSegment(part, segments, groups, ordered);
|
|
244
|
+
addRouteSegment(part, segments, groups, ordered, former);
|
|
225
245
|
}
|
|
226
246
|
|
|
227
247
|
const route = segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
@@ -271,6 +291,59 @@ const PROMPT_CLOSE = /<\/Prompt>/u;
|
|
|
271
291
|
const BARE_CURLY_MARKER =
|
|
272
292
|
/(?<!\\)(?<marker>\{:?\s*#(?<id>[^\s}]+)\s*\})(?:\s*\[(?:#[^\s\]]+|!?toc)\])*\s*$/u;
|
|
273
293
|
|
|
294
|
+
// One attribute in a brace-wrapped attribute list (MkDocs' `attr_list`,
|
|
295
|
+
// kramdown's IAL): `#id`, `.class`, or `key=value`, quoted or not.
|
|
296
|
+
export const ATTRIBUTE = String.raw`(?:#[\w-]+|\.[A-Za-z_-][\w-]*|[\w-]+=(?:"[^"\n]*"|'[^'\n]*'|[^\s"'{}]+))`;
|
|
297
|
+
const ATTRIBUTES = new RegExp(ATTRIBUTE, "gu");
|
|
298
|
+
|
|
299
|
+
// A heading's trailing attribute list of more than one attribute, in the
|
|
300
|
+
// place `BARE_CURLY_MARKER` reads (`## Setup { #setup .wide }`, `{: .wide
|
|
301
|
+
// #setup }`). Neither format reads it: it stays in an `.md` heading's text,
|
|
302
|
+
// and `.mdx` reads it as a JSX expression. One that sets an id is reported
|
|
303
|
+
// with the `{#id}` markers, since pinning that id is what it was for.
|
|
304
|
+
const HEADING_ATTRIBUTE_LIST = new RegExp(
|
|
305
|
+
String.raw`(?<!\\)(?<marker>\{:?[\t ]*${ATTRIBUTE}(?:[\t ]+${ATTRIBUTE})+[\t ]*\})(?:\s*\[(?:#[^\s\]]+|!?toc)\])*\s*$`,
|
|
306
|
+
"u"
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
/** A heading's trailing `{#id}` marker, where its text holds one. */
|
|
310
|
+
export interface TrailingCurlyMarker {
|
|
311
|
+
/** Whether the braces also set classes or attributes (`{ #id .wide }`). */
|
|
312
|
+
attributes: boolean;
|
|
313
|
+
id: string;
|
|
314
|
+
/** Where the marker starts in the text. */
|
|
315
|
+
index: number;
|
|
316
|
+
/** The braces as written. */
|
|
317
|
+
marker: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The `{#id}` marker a heading's text ends with: one of the spellings
|
|
322
|
+
* `BARE_CURLY_MARKER` matches, or an attribute list that sets an id among
|
|
323
|
+
* other attributes (see `HEADING_ATTRIBUTE_LIST`).
|
|
324
|
+
*/
|
|
325
|
+
export const trailingCurlyMarker = (
|
|
326
|
+
text: string
|
|
327
|
+
): TrailingCurlyMarker | undefined => {
|
|
328
|
+
const bare = BARE_CURLY_MARKER.exec(text);
|
|
329
|
+
if (bare?.groups?.id !== undefined && bare.groups.marker !== undefined) {
|
|
330
|
+
return {
|
|
331
|
+
attributes: false,
|
|
332
|
+
id: bare.groups.id,
|
|
333
|
+
index: bare.index,
|
|
334
|
+
marker: bare.groups.marker,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const list = HEADING_ATTRIBUTE_LIST.exec(text);
|
|
338
|
+
const marker = list?.groups?.marker ?? "";
|
|
339
|
+
const id = [...marker.matchAll(ATTRIBUTES)]
|
|
340
|
+
.find(([attribute]) => attribute.startsWith("#"))?.[0]
|
|
341
|
+
.slice(1);
|
|
342
|
+
return list && id !== undefined
|
|
343
|
+
? { attributes: true, id, index: list.index, marker }
|
|
344
|
+
: undefined;
|
|
345
|
+
};
|
|
346
|
+
|
|
274
347
|
// A raw HTML element carrying an `id` — `<a id="…">`, `<section id='…'>`,
|
|
275
348
|
// the unquoted `<a id=plain>`, or the JSX spelling `<div id={"…"}>` — whose
|
|
276
349
|
// element is a fragment-link target outside headings. Lowercase tags only: a
|
|
@@ -278,16 +351,30 @@ const BARE_CURLY_MARKER =
|
|
|
278
351
|
// `JSX_OPEN` split below). `[^<>]` bounds the attribute run, so a wrapped tag
|
|
279
352
|
// (`<div\n id="x"\n>`) still matches while a long tag-free line can't
|
|
280
353
|
// backtrack quadratically, and the run never reaches into a neighboring tag.
|
|
354
|
+
//
|
|
355
|
+
// `HTML_ID`, `ELEMENT_HREF`, `COMPONENT_PATH`, and `JSX_OPEN` open with the
|
|
356
|
+
// lookbehind `(?<=(?:^|[^\\])(?:\\\\)*)`: a `<` opens a tag only after an even
|
|
357
|
+
// run of backslashes (none, or `\\` pairs, each an escaped backslash). An odd
|
|
358
|
+
// run escapes the `<` itself, so `\<a id="x">` and `Promise\<App>` render as
|
|
359
|
+
// text in `.md` and `.mdx` alike.
|
|
281
360
|
const HTML_ID =
|
|
282
|
-
|
|
361
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<[a-z][a-z0-9-]*(?:\s[^<>]*?)?\sid=(?:(?<quote>["'])(?<quoted>[^"'<>]+)\k<quote>|\{(?<jsxQuote>["'])(?<jsx>[^"'<>]+)\k<jsxQuote>\}|(?<bare>[^\s"'=<>`{}]+))/gu;
|
|
362
|
+
// An `<a name="…">`, read like `HTML_ID`: a browser scrolls a fragment to an
|
|
363
|
+
// `a` element whose `name` matches it (the HTML spec's indicated part of the
|
|
364
|
+
// document), so it's an anchor target too. On any other element, `name` is
|
|
365
|
+
// no target.
|
|
366
|
+
const HTML_A_NAME =
|
|
367
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<a(?:\s[^<>]*?)?\sname=(?:(?<quote>["'])(?<quoted>[^"'<>]+)\k<quote>|\{(?<jsxQuote>["'])(?<jsx>[^"'<>]+)\k<jsxQuote>\}|(?<bare>[^\s"'=<>`{}]+))/gu;
|
|
283
368
|
// Commented-out markup renders nothing; matched across lines once the
|
|
284
369
|
// scannable lines are joined back together. Shared with the search
|
|
285
370
|
// extractor so both agree on what a comment is.
|
|
286
371
|
export const HTML_COMMENT = /<!--[\s\S]*?-->/gu;
|
|
287
372
|
export const INLINE_CODE = /`[^`]*`/gu;
|
|
288
373
|
|
|
289
|
-
/** A heading whose trailing `{#id}` marker is unescaped — see `
|
|
374
|
+
/** A heading whose trailing `{#id}` marker is unescaped — see `trailingCurlyMarker`. */
|
|
290
375
|
export interface CurlyMarker {
|
|
376
|
+
/** Whether the braces also set classes or attributes (`{ #id .wide }`). */
|
|
377
|
+
attributes: boolean;
|
|
291
378
|
id: string;
|
|
292
379
|
/** 1-based line of the heading (a setext heading's first text line) in the body. */
|
|
293
380
|
line: number;
|
|
@@ -403,6 +490,10 @@ const SMART_PUNCTUATION = /['"]|--|\.\.\./u;
|
|
|
403
490
|
const REF_DEFINITION =
|
|
404
491
|
/^(?: {0,3}|[ \t]*(?:(?:>|(?:[-*+]|\d{1,9}[.)])[ \t])[ \t]*)+)\[(?<label>[^\]]+)\]:[ \t]*(?<rest>.*)$/u;
|
|
405
492
|
|
|
493
|
+
/** A link label as CommonMark matches it: case-folded, whitespace collapsed. */
|
|
494
|
+
const labelKey = (label: string): string =>
|
|
495
|
+
label.trim().replaceAll(/\s+/gu, " ").toLowerCase();
|
|
496
|
+
|
|
406
497
|
/**
|
|
407
498
|
* The normalized labels of every link-reference definition in the body
|
|
408
499
|
* (outside fenced code), footnote definitions (`^1`) included. A heading
|
|
@@ -437,7 +528,7 @@ const refDefinitionLabels = (lines: readonly string[]): Set<string> => {
|
|
|
437
528
|
if (destination === "" && (lines[index + 1] ?? "").trim() === "") {
|
|
438
529
|
continue;
|
|
439
530
|
}
|
|
440
|
-
labels.add(groups.label
|
|
531
|
+
labels.add(labelKey(groups.label));
|
|
441
532
|
}
|
|
442
533
|
return labels;
|
|
443
534
|
};
|
|
@@ -534,9 +625,72 @@ const inlineText = (nodes: readonly Nodes[], context: HeadingContext): string =>
|
|
|
534
625
|
})
|
|
535
626
|
.join("");
|
|
536
627
|
|
|
628
|
+
/** A heading's text, with what its own empty anchor pins. */
|
|
629
|
+
interface HeadingContent {
|
|
630
|
+
/** The `id` (else `name`) of an empty `<a>` in the heading. */
|
|
631
|
+
target?: string;
|
|
632
|
+
text: string;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** True for a raw `</a>`. */
|
|
636
|
+
const isAnchorClose = (node: Nodes | undefined): boolean =>
|
|
637
|
+
node?.type === "html" && rawHeadingTag(node.value).kind === "anchor-close";
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* A heading's text and own anchor, read the way `markdown/heading-anchors`
|
|
641
|
+
* reads them: an empty `<a>` (`<a id="x"></a>`, `<a name="x" />`) is the
|
|
642
|
+
* heading's own anchor, whose `id` (else `name`) pins the heading's id unless
|
|
643
|
+
* it is all the heading holds, and a `<Badge>`'s text is no part of the
|
|
644
|
+
* heading's text. In `.mdx` both are JSX, which this `.md` parse reads as the
|
|
645
|
+
* raw tags around their content, as the renderer does for `.md`.
|
|
646
|
+
*/
|
|
647
|
+
const headingContent = (
|
|
648
|
+
children: readonly Nodes[],
|
|
649
|
+
context: HeadingContext
|
|
650
|
+
): HeadingContent => {
|
|
651
|
+
const pieces: (string | null)[] = [];
|
|
652
|
+
let target: string | undefined;
|
|
653
|
+
let kept = 0;
|
|
654
|
+
let badges = false;
|
|
655
|
+
let badgeDepth = 0;
|
|
656
|
+
let closesAnchor = false;
|
|
657
|
+
for (const [index, child] of children.entries()) {
|
|
658
|
+
if (closesAnchor) {
|
|
659
|
+
closesAnchor = false;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const tag = child.type === "html" ? rawHeadingTag(child.value) : null;
|
|
663
|
+
if (
|
|
664
|
+
tag?.kind === "anchor-open" &&
|
|
665
|
+
(tag.selfClosing || isAnchorClose(children[index + 1]))
|
|
666
|
+
) {
|
|
667
|
+
target ??= tag.target;
|
|
668
|
+
closesAnchor = !tag.selfClosing;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
kept += 1;
|
|
672
|
+
if (tag?.kind === "badge") {
|
|
673
|
+
badges = true;
|
|
674
|
+
pieces.push(null);
|
|
675
|
+
if (!tag.selfClosing) {
|
|
676
|
+
badgeDepth = Math.max(0, badgeDepth + (tag.closing ? -1 : 1));
|
|
677
|
+
}
|
|
678
|
+
} else if (badgeDepth === 0) {
|
|
679
|
+
pieces.push(inlineText([child], context));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
target: kept === 0 ? undefined : target,
|
|
684
|
+
text: badges ? joinHeadingText(pieces) : pieces.join(""),
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
|
|
537
688
|
/** A heading's rendered text plus the trailing markers the renderer strips. */
|
|
538
689
|
interface RenderedHeading {
|
|
539
|
-
/**
|
|
690
|
+
/**
|
|
691
|
+
* Author-pinned anchor id from `[#id]`/`{#id}`, else from the heading's own
|
|
692
|
+
* empty `<a>`, used verbatim.
|
|
693
|
+
*/
|
|
540
694
|
id?: string;
|
|
541
695
|
/** The text content the renderer slugs, markers stripped, untrimmed. */
|
|
542
696
|
text: string;
|
|
@@ -560,7 +714,8 @@ const plainHeading = (raw: string): RenderedHeading => {
|
|
|
560
714
|
* text remains. Parsing (rather than regex-stripping) resolves escapes —
|
|
561
715
|
* `\[toc]` still reaches the marker parse as `[toc]` — and emphasis the way
|
|
562
716
|
* CommonMark does, so `snake_case` stays whole while `_note_` loses its
|
|
563
|
-
* underscores.
|
|
717
|
+
* underscores. A pinned id's punctuation is escaped first, as the renderer
|
|
718
|
+
* escapes it, so smart punctuation leaves `[#a--b]` as written.
|
|
564
719
|
*/
|
|
565
720
|
const renderHeading = (
|
|
566
721
|
raw: string,
|
|
@@ -573,7 +728,10 @@ const renderHeading = (
|
|
|
573
728
|
}
|
|
574
729
|
const source = form === "atx" ? `# ${raw}` : `${raw}\n=`;
|
|
575
730
|
const tree = markdownToMdast(
|
|
576
|
-
|
|
731
|
+
escapePinnedIds(
|
|
732
|
+
`${source}${definitionsFor(raw, context.labels)}`,
|
|
733
|
+
"markdown"
|
|
734
|
+
),
|
|
577
735
|
parse
|
|
578
736
|
);
|
|
579
737
|
const heading = "children" in tree ? tree.children.at(0) : undefined;
|
|
@@ -584,18 +742,18 @@ const renderHeading = (
|
|
|
584
742
|
return plainHeading(raw);
|
|
585
743
|
}
|
|
586
744
|
const { children } = heading;
|
|
587
|
-
const text =
|
|
745
|
+
const { target, text } = headingContent(children, context);
|
|
588
746
|
const last = children.at(-1);
|
|
589
747
|
if (last?.type !== "text") {
|
|
590
|
-
return { text };
|
|
748
|
+
return { id: target, text };
|
|
591
749
|
}
|
|
592
750
|
const markers = parseHeadingMarkers(last.value);
|
|
593
751
|
const stripped = last.value.length - markers.text.length;
|
|
594
752
|
if (stripped === 0 || (markers.text === "" && children.length === 1)) {
|
|
595
|
-
return { text };
|
|
753
|
+
return { id: target, text };
|
|
596
754
|
}
|
|
597
755
|
return {
|
|
598
|
-
id: markers.id,
|
|
756
|
+
id: markers.id ?? target,
|
|
599
757
|
text: text.slice(0, text.length - stripped),
|
|
600
758
|
toc: markers.toc,
|
|
601
759
|
};
|
|
@@ -640,7 +798,7 @@ const toHeading = (
|
|
|
640
798
|
return { heading: { depth, slug: rendered.id, text }, pinned: true };
|
|
641
799
|
}
|
|
642
800
|
return {
|
|
643
|
-
heading: { depth, slug: slugger
|
|
801
|
+
heading: { depth, slug: headingSlug(slugger, rendered.text), text },
|
|
644
802
|
pinned: false,
|
|
645
803
|
};
|
|
646
804
|
};
|
|
@@ -662,9 +820,10 @@ const noteCurlyMarker = (
|
|
|
662
820
|
line: number,
|
|
663
821
|
state: HeadingScanState
|
|
664
822
|
): void => {
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
|
|
823
|
+
const found = trailingCurlyMarker(text);
|
|
824
|
+
if (found) {
|
|
825
|
+
const { attributes, id, marker } = found;
|
|
826
|
+
state.curlyMarkers.push({ attributes, id, line, marker });
|
|
668
827
|
}
|
|
669
828
|
};
|
|
670
829
|
|
|
@@ -832,10 +991,10 @@ export interface HeadingSite {
|
|
|
832
991
|
/** Everything one walk over a body yields for the anchor index. */
|
|
833
992
|
export interface BodyScan {
|
|
834
993
|
/**
|
|
835
|
-
* Raw HTML element ids (`<a id="…">`)
|
|
836
|
-
* code, comments, and `<Prompt>` regions —
|
|
837
|
-
* `blume validate` accepts alongside heading
|
|
838
|
-
* document order.
|
|
994
|
+
* Raw HTML element ids (`<a id="…">`) and `<a name="…">` names outside
|
|
995
|
+
* headings, fences, inline code, comments, and `<Prompt>` regions —
|
|
996
|
+
* fragment-link targets that `blume validate` accepts alongside heading
|
|
997
|
+
* slugs. Deduplicated, in document order.
|
|
839
998
|
*/
|
|
840
999
|
anchors: string[];
|
|
841
1000
|
/** Headings whose trailing `{#id}` marker is unescaped, for `.mdx` pages. */
|
|
@@ -888,7 +1047,10 @@ export const scanBody = (body: string): BodyScan => {
|
|
|
888
1047
|
// can splice its neighbors into a new `<!--` (`<!-<!-- x -->->`), and a
|
|
889
1048
|
// space keeps a tag from fusing with an `id=` that only a comment separated.
|
|
890
1049
|
const markup = state.anchorLines.join("\n").replaceAll(HTML_COMMENT, " ");
|
|
891
|
-
for (const match of
|
|
1050
|
+
for (const match of [
|
|
1051
|
+
...markup.matchAll(HTML_ID),
|
|
1052
|
+
...markup.matchAll(HTML_A_NAME),
|
|
1053
|
+
].toSorted((a, b) => a.index - b.index)) {
|
|
892
1054
|
const id = match.groups?.quoted ?? match.groups?.jsx ?? match.groups?.bare;
|
|
893
1055
|
if (id !== undefined) {
|
|
894
1056
|
anchors.add(id);
|
|
@@ -905,131 +1067,273 @@ export const scanBody = (body: string): BodyScan => {
|
|
|
905
1067
|
export const extractHeadings = (body: string): Heading[] =>
|
|
906
1068
|
scanBody(body).headings;
|
|
907
1069
|
|
|
1070
|
+
// A backslash escape: before ASCII punctuation, a backslash makes the
|
|
1071
|
+
// character literal (CommonMark), and the renderer drops it.
|
|
1072
|
+
const ESCAPED = /\\(?<char>[!-/:-@[-`{-~])/gu;
|
|
1073
|
+
// One character of a bare link destination: an escape (`\(` is a literal
|
|
1074
|
+
// parenthesis, opening no group), a backslash that escapes nothing, or any
|
|
1075
|
+
// other character but a parenthesis or whitespace. The alternatives never
|
|
1076
|
+
// overlap, so a long run can't backtrack.
|
|
1077
|
+
const DESTINATION_CHAR = String.raw`\\[!-/:-@[-\x60{-~]|\\(?![!-/:-@[-\x60{-~])|[^()\s\\]`;
|
|
1078
|
+
// A link destination as written (CommonMark): `<…>`, which may hold spaces
|
|
1079
|
+
// and unbalanced parentheses (Prettier writes this form for a URL with
|
|
1080
|
+
// either), or a run that doesn't start with `<` and admits one level of
|
|
1081
|
+
// balanced parens, so a Wikipedia-style URL (`/wiki/Foo_(bar)`) isn't
|
|
1082
|
+
// truncated at its first `)`. {@link destinationLink} reads it.
|
|
1083
|
+
const DESTINATION = String.raw`<(?:\\[!-/:-@[-\x60{-~]|\\(?![!-/:-@[-\x60{-~])|[^<>\n\\])*>|(?!<)(?:${DESTINATION_CHAR}|\((?:${DESTINATION_CHAR})*\))+`;
|
|
1084
|
+
// A bare destination on its own, for telling whether a target can be written
|
|
1085
|
+
// back without angle brackets (see `writeDestination`).
|
|
1086
|
+
const BARE_DESTINATION = new RegExp(
|
|
1087
|
+
String.raw`^(?!<)(?:${DESTINATION_CHAR}|\((?:${DESTINATION_CHAR})*\))+$`,
|
|
1088
|
+
"u"
|
|
1089
|
+
);
|
|
1090
|
+
// A link title (CommonMark): `"…"`, `'…'`, or `(…)`, each holding backslash
|
|
1091
|
+
// escapes. Every alternative starts with its own delimiter, so they never
|
|
1092
|
+
// overlap.
|
|
1093
|
+
const TITLE = String.raw`"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'|\((?:[^()\\]|\\[\s\S])*\)`;
|
|
1094
|
+
// What follows a link's `[label]`: the destination, an optional title, and
|
|
1095
|
+
// the spaces CommonMark allows around both, in parentheses. The destination
|
|
1096
|
+
// holds no whitespace and the title starts with a delimiter, so each space
|
|
1097
|
+
// run has one home.
|
|
1098
|
+
const DESTINATION_TAIL = String.raw`\([ \t]*(?<target>${DESTINATION})(?:[ \t]+(?<title>${TITLE}))?[ \t]*\)`;
|
|
1099
|
+
|
|
908
1100
|
// The label admits one level of nested brackets so an image-wrapped link
|
|
909
1101
|
// (`[](/target)`) matches as the *outer* link — with a flat
|
|
910
1102
|
// `[^\]]*` label the match stopped at the image's `]` and the outer target was
|
|
911
|
-
// never seen. The target
|
|
912
|
-
//
|
|
913
|
-
const MD_LINK =
|
|
914
|
-
|
|
1103
|
+
// never seen. The target is the destination as written (see `DESTINATION`).
|
|
1104
|
+
// Matches carry indices (`d`), which locate the target past any spaces.
|
|
1105
|
+
const MD_LINK = new RegExp(
|
|
1106
|
+
String.raw`\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]${DESTINATION_TAIL}`,
|
|
1107
|
+
"dgu"
|
|
1108
|
+
);
|
|
915
1109
|
// An image inside a link label; its target was matched (and so validated) as a
|
|
916
1110
|
// link of its own before labels admitted nesting, and still should be.
|
|
917
|
-
|
|
918
|
-
|
|
1111
|
+
const LABEL_IMAGE = new RegExp(
|
|
1112
|
+
String.raw`!\[[^\]]*\]${DESTINATION_TAIL}`,
|
|
1113
|
+
"dgu"
|
|
1114
|
+
);
|
|
1115
|
+
// An image embed, the way the include and content-asset rewriters move one:
|
|
1116
|
+
// every destination form (`<…>`, escapes, a title in any of its quotes),
|
|
1117
|
+
// read and written back by `rewriteImageTargets`.
|
|
1118
|
+
const MD_IMAGE = new RegExp(
|
|
1119
|
+
String.raw`!\[(?:[^[\]]|\[[^\]]*\])*\]${DESTINATION_TAIL}`,
|
|
1120
|
+
"dgu"
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
/** Where a match's `target` group starts, as an offset into the matched text. */
|
|
1124
|
+
const targetStart = (match: RegExpExecArray | RegExpMatchArray): number =>
|
|
1125
|
+
(match.indices?.groups?.target?.[0] ?? match.index ?? 0) - (match.index ?? 0);
|
|
1126
|
+
|
|
1127
|
+
/** A destination as written, and the target the renderer reads from it. */
|
|
1128
|
+
interface ReadDestination {
|
|
1129
|
+
/** Whether it was written in `<…>`. */
|
|
1130
|
+
angled: boolean;
|
|
1131
|
+
/** The text the target is read from: inside any brackets. */
|
|
1132
|
+
source: string;
|
|
1133
|
+
target: string;
|
|
1134
|
+
}
|
|
919
1135
|
|
|
920
|
-
/**
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1136
|
+
/**
|
|
1137
|
+
* A destination as written, read the way the renderer reads it: angle
|
|
1138
|
+
* brackets dropped and backslash escapes resolved, so `<https://x.dev/a(b>`
|
|
1139
|
+
* is `https://x.dev/a(b` and `image%20\(1\).png` is `image%20(1).png`.
|
|
1140
|
+
*/
|
|
1141
|
+
const readDestination = (written: string): ReadDestination => {
|
|
1142
|
+
const angled = written.startsWith("<");
|
|
1143
|
+
const source = angled ? written.slice(1, -1) : written;
|
|
1144
|
+
return { angled, source, target: source.replaceAll(ESCAPED, "$<char>") };
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Write `target` back as a destination: as it stands where the bare form
|
|
1149
|
+
* reads it unchanged, else with its parentheses and backslashes escaped, and
|
|
1150
|
+
* in angle brackets where it was written in them or holds whitespace, which
|
|
1151
|
+
* no bare destination can.
|
|
1152
|
+
*/
|
|
1153
|
+
const writeDestination = (target: string, angled: boolean): string => {
|
|
1154
|
+
if (!angled && BARE_DESTINATION.test(target) && !target.includes("\\")) {
|
|
1155
|
+
return target;
|
|
1156
|
+
}
|
|
1157
|
+
if (!(angled || /[\s<>]/u.test(target))) {
|
|
1158
|
+
return target.replaceAll(/[\\()]/gu, String.raw`\$&`);
|
|
1159
|
+
}
|
|
1160
|
+
return `<${target.replaceAll(/[\\<>]/gu, String.raw`\$&`)}>`;
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Rewrite the target of every image embed on one line with `rewrite`, which
|
|
1165
|
+
* gets the target as the renderer reads it (see `readDestination`) and
|
|
1166
|
+
* returns the new one, or `null` to leave it as written. Inline code is
|
|
1167
|
+
* skipped: matches run on a copy with code spans blanked, and replacements
|
|
1168
|
+
* splice into the real line by index, which the same-length mask keeps in
|
|
1169
|
+
* step. A rewritten target is written back in the form its destination took.
|
|
1170
|
+
*/
|
|
1171
|
+
export const rewriteImageTargets = (
|
|
1172
|
+
line: string,
|
|
1173
|
+
rewrite: (target: string) => string | null
|
|
1174
|
+
): string => {
|
|
1175
|
+
const masked = line.replaceAll(INLINE_CODE, (span) =>
|
|
1176
|
+
" ".repeat(span.length)
|
|
1177
|
+
);
|
|
1178
|
+
let out = "";
|
|
1179
|
+
let cursor = 0;
|
|
1180
|
+
for (const match of masked.matchAll(MD_IMAGE)) {
|
|
1181
|
+
const written = match.groups?.target ?? "";
|
|
1182
|
+
const { angled, target } = readDestination(written);
|
|
1183
|
+
const next = rewrite(target);
|
|
1184
|
+
if (next === null) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const at = match.index + targetStart(match);
|
|
1188
|
+
out += line.slice(cursor, at) + writeDestination(next, angled);
|
|
1189
|
+
cursor = at + written.length;
|
|
1190
|
+
}
|
|
1191
|
+
return out + line.slice(cursor);
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* The link a destination written at `at` (the 1-based position of its first
|
|
1196
|
+
* character) leads to, as the renderer reads it (see `readDestination`). It's
|
|
1197
|
+
* located where the target's text starts, inside any brackets. An empty `<>`
|
|
1198
|
+
* leads nowhere.
|
|
1199
|
+
*/
|
|
1200
|
+
const destinationLink = (
|
|
1201
|
+
written: string,
|
|
1202
|
+
at: Pick<PageLink, "column" | "line">
|
|
1203
|
+
): PageLink | undefined => {
|
|
1204
|
+
const { angled, source, target } = readDestination(written);
|
|
1205
|
+
if (target === "") {
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
const link: PageLink = {
|
|
1209
|
+
column: at.column + (angled ? 1 : 0),
|
|
1210
|
+
line: at.line,
|
|
1211
|
+
target,
|
|
1212
|
+
};
|
|
1213
|
+
if (source.length !== target.length) {
|
|
1214
|
+
link.sourceLength = source.length;
|
|
1215
|
+
}
|
|
1216
|
+
return link;
|
|
1217
|
+
};
|
|
926
1218
|
|
|
927
1219
|
// A link-reference definition's destination, at the start of what follows its
|
|
928
|
-
// `[label]:` (see `REF_DEFINITION`)
|
|
929
|
-
//
|
|
930
|
-
|
|
1220
|
+
// `[label]:` (see `REF_DEFINITION`), and only when whitespace or the end of
|
|
1221
|
+
// the line follows it: `[1]: <src/x.ts` has an unclosed `<…>`, so it defines
|
|
1222
|
+
// nothing (CommonMark).
|
|
1223
|
+
const DEFINITION_DESTINATION = new RegExp(
|
|
1224
|
+
String.raw`^(?:${DESTINATION})(?=\s|$)`,
|
|
1225
|
+
"u"
|
|
1226
|
+
);
|
|
1227
|
+
// A whole definition on its line: the destination, then at most a title.
|
|
1228
|
+
// Anything else after it makes the line paragraph text (CommonMark).
|
|
1229
|
+
const DEFINITION_LINE = new RegExp(
|
|
1230
|
+
String.raw`^(?:${DESTINATION})(?:[ \t]+(?:${TITLE}))?[ \t]*$`,
|
|
1231
|
+
"u"
|
|
1232
|
+
);
|
|
931
1233
|
|
|
932
1234
|
// A CommonMark autolink (`<https://example.com>`). Only http(s) targets are
|
|
933
1235
|
// ever checked, so no other scheme is read. MDX rejects the syntax, so in
|
|
934
|
-
// practice it's a `.md` page's.
|
|
935
|
-
|
|
1236
|
+
// practice it's a `.md` page's. A link's `<…>` destination
|
|
1237
|
+
// (`[x](<https://…>)`) is no autolink.
|
|
1238
|
+
const AUTOLINK = /(?<!\]\()<(?<target>https?:\/\/[^\s<>]*)>/giu;
|
|
936
1239
|
|
|
937
1240
|
/**
|
|
938
1241
|
* Record the destination of a link-reference definition on `line`: a
|
|
939
1242
|
* reference link (`[text][label]`, `[label]`) renders with it, so it's the
|
|
940
|
-
* target to check, once for every link that cites it.
|
|
941
|
-
*
|
|
1243
|
+
* target to check, once for every link that cites it. Only the first
|
|
1244
|
+
* definition of a label counts (CommonMark), so a later one is skipped:
|
|
1245
|
+
* `defined` holds the labels defined so far. A footnote definition (`[^1]: …`)
|
|
1246
|
+
* is no link; the links inside it are read like any others. Returns whether
|
|
1247
|
+
* the line is a definition.
|
|
942
1248
|
*/
|
|
943
1249
|
const scanDefinition = (
|
|
944
1250
|
line: string,
|
|
945
1251
|
lineNumber: number,
|
|
946
|
-
links: PageLink[]
|
|
947
|
-
|
|
1252
|
+
links: PageLink[],
|
|
1253
|
+
defined: Set<string>
|
|
1254
|
+
): boolean => {
|
|
948
1255
|
const groups = line.match(REF_DEFINITION)?.groups;
|
|
949
1256
|
if (groups?.label === undefined || groups.label.startsWith("^")) {
|
|
950
|
-
return;
|
|
1257
|
+
return false;
|
|
951
1258
|
}
|
|
952
1259
|
const rest = groups.rest ?? "";
|
|
953
|
-
const
|
|
954
|
-
const
|
|
955
|
-
if (
|
|
956
|
-
|
|
957
|
-
// one past its `<`.
|
|
958
|
-
const at = line.length - rest.length + (destination?.angle ? 1 : 0);
|
|
959
|
-
links.push({ column: at + 1, line: lineNumber, target });
|
|
1260
|
+
const written = rest.match(DEFINITION_DESTINATION)?.[0];
|
|
1261
|
+
const key = labelKey(groups.label);
|
|
1262
|
+
if (written === undefined || key === "") {
|
|
1263
|
+
return false;
|
|
960
1264
|
}
|
|
1265
|
+
if (!defined.has(key)) {
|
|
1266
|
+
defined.add(key);
|
|
1267
|
+
// `rest` runs to the end of the line, so the destination starts where it
|
|
1268
|
+
// does.
|
|
1269
|
+
const link = destinationLink(written, {
|
|
1270
|
+
column: line.length - rest.length + 1,
|
|
1271
|
+
line: lineNumber,
|
|
1272
|
+
});
|
|
1273
|
+
if (link) {
|
|
1274
|
+
links.push(link);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return true;
|
|
961
1278
|
};
|
|
962
1279
|
|
|
963
1280
|
/**
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
* diagnostics that point into the raw file must add the stripped block's height.
|
|
1281
|
+
* Scan one line for link targets. The line comes masked (see `maskCode`):
|
|
1282
|
+
* code and comments are blanked with same-length padding, so recorded
|
|
1283
|
+
* columns stay accurate.
|
|
968
1284
|
*/
|
|
969
|
-
/** Scan one line for link targets; returns the next fenced-block state. */
|
|
970
1285
|
const scanLinkLine = (
|
|
971
|
-
|
|
1286
|
+
masked: string,
|
|
972
1287
|
lineNumber: number,
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
):
|
|
976
|
-
const next = nextFenceState(line, fence);
|
|
977
|
-
// Skip fence delimiter lines themselves and anything inside a fence.
|
|
978
|
-
if (fence !== null || next !== null) {
|
|
979
|
-
return next;
|
|
980
|
-
}
|
|
981
|
-
// Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
|
|
982
|
-
// with same-length padding so recorded columns stay accurate.
|
|
983
|
-
const masked = line.replaceAll(INLINE_CODE, (span) =>
|
|
984
|
-
" ".repeat(span.length)
|
|
985
|
-
);
|
|
1288
|
+
links: PageLink[],
|
|
1289
|
+
defined: Set<string>
|
|
1290
|
+
): void => {
|
|
986
1291
|
for (const match of masked.matchAll(MD_LINK)) {
|
|
987
|
-
const
|
|
988
|
-
if (
|
|
1292
|
+
const written = match.groups?.target;
|
|
1293
|
+
if (written === undefined || match.index === undefined) {
|
|
989
1294
|
continue;
|
|
990
1295
|
}
|
|
991
|
-
// Locate the target by
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
column: match.index + targetOffset + 1,
|
|
1296
|
+
// Locate the target by its group's index rather than searching for its
|
|
1297
|
+
// text — a label that contains the same text (e.g. `[/a/b](/a/b)`) would
|
|
1298
|
+
// otherwise report the column inside the label.
|
|
1299
|
+
const entry = destinationLink(written, {
|
|
1300
|
+
column: match.index + targetStart(match) + 1,
|
|
997
1301
|
line: lineNumber,
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1302
|
+
});
|
|
1303
|
+
if (entry) {
|
|
1304
|
+
// `MD_LINK` matches the `[label](target)` tail of an image embed too;
|
|
1305
|
+
// the preceding `!` is what marks the target as going through the
|
|
1306
|
+
// image pipeline rather than resolving as a site route.
|
|
1307
|
+
if (masked[match.index - 1] === "!") {
|
|
1308
|
+
entry.image = true;
|
|
1309
|
+
}
|
|
1310
|
+
links.push(entry);
|
|
1005
1311
|
}
|
|
1006
|
-
links.push(entry);
|
|
1007
1312
|
// An image nested in the label (`[](/target)`) carries its
|
|
1008
1313
|
// own target; surface it too so a missing image is still caught.
|
|
1009
|
-
const label = match
|
|
1010
|
-
for (const image of label.matchAll(
|
|
1011
|
-
const
|
|
1012
|
-
if (
|
|
1314
|
+
const label = `[${match.groups?.label ?? ""}`;
|
|
1315
|
+
for (const image of label.matchAll(LABEL_IMAGE)) {
|
|
1316
|
+
const imageWritten = image.groups?.target;
|
|
1317
|
+
if (imageWritten === undefined || image.index === undefined) {
|
|
1013
1318
|
continue;
|
|
1014
1319
|
}
|
|
1015
|
-
|
|
1016
|
-
column:
|
|
1017
|
-
match.index +
|
|
1018
|
-
image.index +
|
|
1019
|
-
targetOffsetIn(image[0], imageTarget, image.groups?.title) +
|
|
1020
|
-
1,
|
|
1021
|
-
image: true,
|
|
1320
|
+
const imageLink = destinationLink(imageWritten, {
|
|
1321
|
+
column: match.index + image.index + targetStart(image) + 1,
|
|
1022
1322
|
line: lineNumber,
|
|
1023
|
-
target: imageTarget,
|
|
1024
1323
|
});
|
|
1324
|
+
if (imageLink) {
|
|
1325
|
+
links.push({ ...imageLink, image: true });
|
|
1326
|
+
}
|
|
1025
1327
|
}
|
|
1026
1328
|
}
|
|
1329
|
+
// A definition's `<…>` destination is no autolink either.
|
|
1330
|
+
if (scanDefinition(masked, lineNumber, links, defined)) {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1027
1333
|
for (const match of masked.matchAll(AUTOLINK)) {
|
|
1028
1334
|
const target = match.groups?.target ?? "";
|
|
1029
1335
|
links.push({ column: match.index + 2, line: lineNumber, target });
|
|
1030
1336
|
}
|
|
1031
|
-
scanDefinition(masked, lineNumber, links);
|
|
1032
|
-
return next;
|
|
1033
1337
|
};
|
|
1034
1338
|
|
|
1035
1339
|
/**
|
|
@@ -1037,10 +1341,59 @@ const scanLinkLine = (
|
|
|
1037
1341
|
* `<a href>`: the attribute may sit on a later line than the tag name, where
|
|
1038
1342
|
* a formatter wraps a long element, so the gap between them spans lines. An
|
|
1039
1343
|
* expression-valued `href={…}` isn't a literal target, so it isn't matched,
|
|
1040
|
-
* and no other lowercase tag is a link.
|
|
1344
|
+
* and no other lowercase tag is a link. An escaped `\<a href>` is text, not a
|
|
1345
|
+
* link (see `HTML_ID`).
|
|
1041
1346
|
*/
|
|
1042
1347
|
const ELEMENT_HREF =
|
|
1043
|
-
|
|
1348
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<(?:[A-Z][\w.]*|(?<anchor>a))(?=[\s/>])[^<>]*?\shref=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* A media element's string `src` (`<img src="./diagram.png">`, `<source>`,
|
|
1352
|
+
* `<video>`, `<audio>`): raw HTML in a `.md` page, a plain element in an
|
|
1353
|
+
* `.mdx` one, with the attribute on the tag's line or a later one. An
|
|
1354
|
+
* expression-valued `src={…}` isn't a literal path, so it isn't matched, and
|
|
1355
|
+
* an escaped `\<img src>` is text (see `HTML_ID`).
|
|
1356
|
+
*/
|
|
1357
|
+
const ELEMENT_SRC =
|
|
1358
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<(?<tag>img|source|video|audio)(?=[\s/>])[^<>]*?\ssrc=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* A `<Card>`'s string `img` (`<Card img="./cover.png">`), read the way
|
|
1362
|
+
* `ELEMENT_HREF` reads an `href`. It's an image embed: resolved from beside
|
|
1363
|
+
* the page source and published like `` (see
|
|
1364
|
+
* {@link rewriteCardImages}).
|
|
1365
|
+
*/
|
|
1366
|
+
const CARD_IMG =
|
|
1367
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<Card(?=[\s/>])[^<>]*?\simg=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Any element's string `href` or `src` (the first a tag carries), read the
|
|
1371
|
+
* way `ELEMENT_HREF` and `ELEMENT_SRC` read theirs: wrapped onto later lines
|
|
1372
|
+
* or not, an expression value unmatched, an escaped `\<tag>` text.
|
|
1373
|
+
*/
|
|
1374
|
+
const ELEMENT_URL =
|
|
1375
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<(?<tag>[A-Za-z][\w.:-]*)(?=[\s/>])[^<>]*?\s(?<attribute>href|src)=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
|
|
1376
|
+
|
|
1377
|
+
/** A string `href` or `src` on an element, as {@link rewriteElementUrls} reads it. */
|
|
1378
|
+
export interface ElementUrl {
|
|
1379
|
+
attribute: string;
|
|
1380
|
+
/** The element's name as written: `img`, `a`, `Card`. */
|
|
1381
|
+
tag: string;
|
|
1382
|
+
value: string;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const MEDIA_TAGS = new Set(["audio", "img", "source", "video"]);
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Whether an element URL is one `blume validate` reads as a link (see
|
|
1389
|
+
* `ELEMENT_HREF` and `ELEMENT_SRC`): a media element's `src`, or an `<a>`'s or
|
|
1390
|
+
* a component's `href`. Only these name a file the build publishes from
|
|
1391
|
+
* beside the page.
|
|
1392
|
+
*/
|
|
1393
|
+
export const isLinkElementUrl = (url: ElementUrl): boolean =>
|
|
1394
|
+
url.attribute === "src"
|
|
1395
|
+
? MEDIA_TAGS.has(url.tag)
|
|
1396
|
+
: url.attribute === "href" && (url.tag === "a" || /^[A-Z]/u.test(url.tag));
|
|
1044
1397
|
|
|
1045
1398
|
/**
|
|
1046
1399
|
* An inline link whose label wraps onto later lines of its paragraph
|
|
@@ -1048,11 +1401,23 @@ const ELEMENT_HREF =
|
|
|
1048
1401
|
* it. The label holds a line break but no blank line (which would end the
|
|
1049
1402
|
* paragraph) and no brackets, so a match never repeats a one-line link.
|
|
1050
1403
|
*/
|
|
1051
|
-
const WRAPPED_LINK =
|
|
1052
|
-
|
|
1404
|
+
const WRAPPED_LINK = new RegExp(
|
|
1405
|
+
String.raw`\[[^[\]\n]*(?:\n(?![ \t]*(?:\n|$))[^[\]\n]*)+\]${DESTINATION_TAIL}`,
|
|
1406
|
+
"dgu"
|
|
1407
|
+
);
|
|
1408
|
+
|
|
1409
|
+
// An MDX comment (`{/* … */}`): JavaScript, so nothing in it renders.
|
|
1410
|
+
const JSX_COMMENT = /\{\s*\/\*[\s\S]*?\*\/\s*\}/gu;
|
|
1053
1411
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1412
|
+
// What each format reads as a comment. `.md` renders a `{/* … */}` as text,
|
|
1413
|
+
// links and all, and MDX rejects `<!-- … -->` outright.
|
|
1414
|
+
const COMMENTS = { md: HTML_COMMENT, mdx: JSX_COMMENT } as const;
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* The body with fenced blocks and inline code blanked, shape preserved — and,
|
|
1418
|
+
* given the page's format, its comments too.
|
|
1419
|
+
*/
|
|
1420
|
+
const maskCode = (lines: readonly string[], format?: "md" | "mdx"): string => {
|
|
1056
1421
|
let fence: FenceState = null;
|
|
1057
1422
|
const masked = lines.map((line) => {
|
|
1058
1423
|
const next = nextFenceState(line, fence);
|
|
@@ -1062,7 +1427,12 @@ const maskCode = (lines: readonly string[]): string => {
|
|
|
1062
1427
|
? " ".repeat(line.length)
|
|
1063
1428
|
: line.replaceAll(INLINE_CODE, (span) => " ".repeat(span.length));
|
|
1064
1429
|
});
|
|
1065
|
-
|
|
1430
|
+
const text = masked.join("\n");
|
|
1431
|
+
return format === undefined
|
|
1432
|
+
? text
|
|
1433
|
+
: text.replaceAll(COMMENTS[format], (comment) =>
|
|
1434
|
+
comment.replaceAll(/[^\n]/gu, " ")
|
|
1435
|
+
);
|
|
1066
1436
|
};
|
|
1067
1437
|
|
|
1068
1438
|
/** The offset each of `lines` starts at once they're joined with `\n`. */
|
|
@@ -1103,18 +1473,244 @@ const positionIn = (
|
|
|
1103
1473
|
};
|
|
1104
1474
|
|
|
1105
1475
|
/**
|
|
1106
|
-
* Every
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
|
|
1476
|
+
* Every media element's string `src` in the masked `text` (see
|
|
1477
|
+
* `ELEMENT_SRC`), with its 1-based position. Like a raw `<a href>`, the
|
|
1478
|
+
* browser resolves it as written, unless it names a file beside the page,
|
|
1479
|
+
* which the build publishes and points it at (see `core/content-assets.ts`).
|
|
1480
|
+
*/
|
|
1481
|
+
const mediaSourceLinks = (
|
|
1482
|
+
text: string,
|
|
1483
|
+
lineStarts: readonly number[],
|
|
1484
|
+
lineOffset: number
|
|
1485
|
+
): PageLink[] =>
|
|
1486
|
+
[...text.matchAll(ELEMENT_SRC)].flatMap((match): PageLink[] => {
|
|
1487
|
+
const target = match.groups?.double ?? match.groups?.single ?? "";
|
|
1488
|
+
const tag = match.groups?.tag;
|
|
1489
|
+
if (target === "" || tag === undefined) {
|
|
1490
|
+
return [];
|
|
1491
|
+
}
|
|
1492
|
+
// The value ends one character (its closing quote) before the match does.
|
|
1493
|
+
const at = match.index + match[0].length - target.length - 1;
|
|
1494
|
+
return [
|
|
1495
|
+
{
|
|
1496
|
+
...positionIn(lineStarts, at, lineOffset),
|
|
1497
|
+
raw: true,
|
|
1498
|
+
src: tag,
|
|
1499
|
+
target,
|
|
1500
|
+
},
|
|
1501
|
+
];
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
/** Each `<Card img>` value in the masked `text`, with its offset there. */
|
|
1505
|
+
const cardImages = (text: string): { at: number; target: string }[] =>
|
|
1506
|
+
[...text.matchAll(CARD_IMG)].map((match) => {
|
|
1507
|
+
const target = match.groups?.double ?? match.groups?.single ?? "";
|
|
1508
|
+
// The value ends one character (its closing quote) before the match does.
|
|
1509
|
+
return { at: match.index + match[0].length - target.length - 1, target };
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Rewrite every `<Card img="…">` value in `text` (outside code) with
|
|
1514
|
+
* `rewrite`, which returns the new value or `null` to leave one as written.
|
|
1515
|
+
* The include expander rebases a partial's values with it and the
|
|
1516
|
+
* content-asset rewriter points them at their served URL, exactly as both
|
|
1517
|
+
* treat image embeds, so a card's image resolves like ``.
|
|
1518
|
+
*/
|
|
1519
|
+
export const rewriteCardImages = (
|
|
1520
|
+
text: string,
|
|
1521
|
+
rewrite: (target: string) => string | null
|
|
1522
|
+
): string => {
|
|
1523
|
+
if (!text.includes("<Card")) {
|
|
1524
|
+
return text;
|
|
1525
|
+
}
|
|
1526
|
+
let out = "";
|
|
1527
|
+
let cursor = 0;
|
|
1528
|
+
for (const { at, target } of cardImages(maskCode(text.split("\n")))) {
|
|
1529
|
+
const next = rewrite(target);
|
|
1530
|
+
if (next !== null) {
|
|
1531
|
+
out += text.slice(cursor, at) + next;
|
|
1532
|
+
cursor = at + target.length;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return out + text.slice(cursor);
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
/** A same-text edit: replace `length` characters at `at` with `text`. */
|
|
1539
|
+
interface Splice {
|
|
1540
|
+
at: number;
|
|
1541
|
+
length: number;
|
|
1542
|
+
text: string;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Apply non-overlapping splices to `text`, in any order. */
|
|
1546
|
+
const applySplices = (text: string, splices: readonly Splice[]): string => {
|
|
1547
|
+
let out = "";
|
|
1548
|
+
let cursor = 0;
|
|
1549
|
+
for (const splice of splices.toSorted((a, b) => a.at - b.at)) {
|
|
1550
|
+
out += text.slice(cursor, splice.at) + splice.text;
|
|
1551
|
+
cursor = splice.at + splice.length;
|
|
1552
|
+
}
|
|
1553
|
+
return out + text.slice(cursor);
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* Rewrite every element's string `href` or `src` in `text` (outside code)
|
|
1558
|
+
* with `rewrite`, which returns the new value or `null` to leave one as
|
|
1559
|
+
* written. The include expander rebases a partial's files with it, the
|
|
1560
|
+
* content-asset rewriter points them at their served URL, and the render
|
|
1561
|
+
* plugins rewrite raw HTML with it.
|
|
1562
|
+
*/
|
|
1563
|
+
export const rewriteElementUrls = (
|
|
1564
|
+
text: string,
|
|
1565
|
+
rewrite: (url: ElementUrl) => string | null
|
|
1566
|
+
): string => {
|
|
1567
|
+
if (!(text.includes("href=") || text.includes("src="))) {
|
|
1568
|
+
return text;
|
|
1569
|
+
}
|
|
1570
|
+
const splices: Splice[] = [];
|
|
1571
|
+
for (const match of maskCode(text.split("\n")).matchAll(ELEMENT_URL)) {
|
|
1572
|
+
const value = match.groups?.double ?? match.groups?.single ?? "";
|
|
1573
|
+
const next = rewrite({
|
|
1574
|
+
attribute: match.groups?.attribute ?? "",
|
|
1575
|
+
tag: match.groups?.tag ?? "",
|
|
1576
|
+
value,
|
|
1577
|
+
});
|
|
1578
|
+
if (next !== null) {
|
|
1579
|
+
// The value ends one character (its closing quote) before the match.
|
|
1580
|
+
const at = match.index + match[0].length - value.length - 1;
|
|
1581
|
+
splices.push({ at, length: value.length, text: next });
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return applySplices(text, splices);
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
/** A destination as written in a line, and its offset there. */
|
|
1588
|
+
interface WrittenDestination {
|
|
1589
|
+
at: number;
|
|
1590
|
+
written: string;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* The destination of the link-reference definition on `line`, if it is one;
|
|
1595
|
+
* a footnote definition (`[^1]: …`) is no link.
|
|
1596
|
+
*/
|
|
1597
|
+
const definitionDestination = (
|
|
1598
|
+
line: string
|
|
1599
|
+
): WrittenDestination | undefined => {
|
|
1600
|
+
const groups = line.match(REF_DEFINITION)?.groups;
|
|
1601
|
+
const rest = groups?.label?.startsWith("^") ? "" : (groups?.rest ?? "");
|
|
1602
|
+
const written = rest.match(DEFINITION_DESTINATION)?.[0];
|
|
1603
|
+
return written === undefined
|
|
1604
|
+
? undefined
|
|
1605
|
+
: { at: line.length - rest.length, written };
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Rewrite the target of every Markdown link in `text` (outside code) with
|
|
1610
|
+
* `rewrite`, which gets the target as the renderer reads it and returns the
|
|
1611
|
+
* new one, or `null` to leave it as written: inline links (an image embed is
|
|
1612
|
+
* no link; see {@link rewriteImageTargets}), links whose label wraps onto
|
|
1613
|
+
* later lines, and link-reference definitions (`[spec]: ./spec.pdf`). Each
|
|
1614
|
+
* rewritten target is written back in the form its destination took.
|
|
1615
|
+
*/
|
|
1616
|
+
export const rewriteLinkTargets = (
|
|
1617
|
+
text: string,
|
|
1618
|
+
rewrite: (target: string) => string | null
|
|
1619
|
+
): string => {
|
|
1620
|
+
if (!(text.includes("](") || text.includes("]:"))) {
|
|
1621
|
+
return text;
|
|
1622
|
+
}
|
|
1623
|
+
const splices: Splice[] = [];
|
|
1624
|
+
const add = (at: number, written: string): void => {
|
|
1625
|
+
const { angled, target } = readDestination(written);
|
|
1626
|
+
const next = rewrite(target);
|
|
1627
|
+
if (next !== null) {
|
|
1628
|
+
splices.push({
|
|
1629
|
+
at,
|
|
1630
|
+
length: written.length,
|
|
1631
|
+
text: writeDestination(next, angled),
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1635
|
+
const lines = text.split("\n");
|
|
1636
|
+
const masked = maskCode(lines);
|
|
1637
|
+
const lineStarts = lineStartsOf(lines);
|
|
1638
|
+
for (const [index, line] of masked.split("\n").entries()) {
|
|
1639
|
+
const start = lineStarts[index] ?? 0;
|
|
1640
|
+
for (const match of line.matchAll(MD_LINK)) {
|
|
1641
|
+
if (line[match.index - 1] !== "!") {
|
|
1642
|
+
add(
|
|
1643
|
+
start + match.index + targetStart(match),
|
|
1644
|
+
match.groups?.target ?? ""
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
const definition = definitionDestination(line);
|
|
1649
|
+
if (definition) {
|
|
1650
|
+
add(start + definition.at, definition.written);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
for (const match of masked.matchAll(WRAPPED_LINK)) {
|
|
1654
|
+
if (masked[match.index - 1] !== "!") {
|
|
1655
|
+
add(match.index + targetStart(match), match.groups?.target ?? "");
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return applySplices(text, splices);
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
/** A link-reference definition, as {@link linkDefinitions} reads one. */
|
|
1662
|
+
export interface LinkDefinition {
|
|
1663
|
+
/** The label as CommonMark matches it: case-folded, spaces collapsed. */
|
|
1664
|
+
key: string;
|
|
1665
|
+
/** The definition on a line of its own, out of any container. */
|
|
1666
|
+
text: string;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Every link-reference definition in `text` (`[label]: /url "Title"`,
|
|
1671
|
+
* outside code; footnotes aren't links), in order, the first of each label
|
|
1672
|
+
* only, since that's the one that renders. A definition is read from its own
|
|
1673
|
+
* line: its destination and any title there, and nothing else, since a line
|
|
1674
|
+
* with more after them is text.
|
|
1675
|
+
*/
|
|
1676
|
+
export const linkDefinitions = (text: string): LinkDefinition[] => {
|
|
1677
|
+
const definitions = new Map<string, LinkDefinition>();
|
|
1678
|
+
const lines = text.split("\n");
|
|
1679
|
+
for (const [index, line] of maskCode(lines).split("\n").entries()) {
|
|
1680
|
+
const groups = line.match(REF_DEFINITION)?.groups;
|
|
1681
|
+
const label = groups?.label ?? "^";
|
|
1682
|
+
const key = labelKey(label);
|
|
1683
|
+
const rest = groups?.rest ?? "";
|
|
1684
|
+
if (
|
|
1685
|
+
label.startsWith("^") ||
|
|
1686
|
+
key === "" ||
|
|
1687
|
+
definitions.has(key) ||
|
|
1688
|
+
!DEFINITION_LINE.test(rest)
|
|
1689
|
+
) {
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
// The real line's text, at the same offset the masked one's starts.
|
|
1693
|
+
const written = (lines[index] ?? "").slice(line.length - rest.length);
|
|
1694
|
+
definitions.set(key, { key, text: `[${label}]: ${written}` });
|
|
1695
|
+
}
|
|
1696
|
+
return [...definitions.values()];
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Every link in the masked `text` (see `maskCode`) of `lines` that can span
|
|
1701
|
+
* lines, with its 1-based position: an element's `href` (see `ELEMENT_HREF`),
|
|
1702
|
+
* a media element's `src` (see `ELEMENT_SRC`), a `<Card>`'s `img` (see
|
|
1703
|
+
* `CARD_IMG`, an image embed), and a link whose label wraps (see
|
|
1704
|
+
* `WRAPPED_LINK`). A lowercase `<a>` is raw
|
|
1705
|
+
* HTML in `.md` and a plain element in `.mdx`; the Markdown pipeline passes
|
|
1706
|
+
* either through as written, so its target is marked `raw`.
|
|
1111
1707
|
*/
|
|
1112
1708
|
const multilineLinks = (
|
|
1709
|
+
text: string,
|
|
1113
1710
|
lines: readonly string[],
|
|
1114
1711
|
lineOffset: number
|
|
1115
1712
|
): PageLink[] => {
|
|
1116
1713
|
const links: PageLink[] = [];
|
|
1117
|
-
const text = maskCode(lines);
|
|
1118
1714
|
const lineStarts = lineStartsOf(lines);
|
|
1119
1715
|
for (const match of text.matchAll(ELEMENT_HREF)) {
|
|
1120
1716
|
const target = match.groups?.double ?? match.groups?.single ?? "";
|
|
@@ -1129,44 +1725,62 @@ const multilineLinks = (
|
|
|
1129
1725
|
}
|
|
1130
1726
|
links.push(link);
|
|
1131
1727
|
}
|
|
1728
|
+
links.push(
|
|
1729
|
+
...mediaSourceLinks(text, lineStarts, lineOffset),
|
|
1730
|
+
...cardImages(text).flatMap(({ at, target }): PageLink[] =>
|
|
1731
|
+
target === ""
|
|
1732
|
+
? []
|
|
1733
|
+
: [{ ...positionIn(lineStarts, at, lineOffset), image: true, target }]
|
|
1734
|
+
)
|
|
1735
|
+
);
|
|
1132
1736
|
for (const match of text.matchAll(WRAPPED_LINK)) {
|
|
1133
|
-
const
|
|
1134
|
-
const at =
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1737
|
+
const written = match.groups?.target ?? "";
|
|
1738
|
+
const at = match.index + targetStart(match);
|
|
1739
|
+
const link = destinationLink(
|
|
1740
|
+
written,
|
|
1741
|
+
positionIn(lineStarts, at, lineOffset)
|
|
1742
|
+
);
|
|
1743
|
+
if (link) {
|
|
1744
|
+
// As on one line, a preceding `!` makes it an image embed.
|
|
1745
|
+
if (text[match.index - 1] === "!") {
|
|
1746
|
+
link.image = true;
|
|
1747
|
+
}
|
|
1748
|
+
links.push(link);
|
|
1143
1749
|
}
|
|
1144
|
-
links.push(link);
|
|
1145
1750
|
}
|
|
1146
1751
|
return links;
|
|
1147
1752
|
};
|
|
1148
1753
|
|
|
1149
|
-
|
|
1754
|
+
/**
|
|
1755
|
+
* Extract link targets from a markdown body for later validation, recording the
|
|
1756
|
+
* 1-based line/column of each target. Skips fenced code blocks, inline code,
|
|
1757
|
+
* and — given the page's `format` — comments, none of which render a link.
|
|
1758
|
+
* `lineOffset` shifts every recorded line: the body is frontmatter-stripped, so
|
|
1759
|
+
* diagnostics that point into the raw file must add the stripped block's height.
|
|
1760
|
+
*/
|
|
1761
|
+
export const extractLinks = (
|
|
1762
|
+
body: string,
|
|
1763
|
+
lineOffset = 0,
|
|
1764
|
+
format?: "md" | "mdx"
|
|
1765
|
+
): PageLink[] => {
|
|
1150
1766
|
const links: PageLink[] = [];
|
|
1151
|
-
|
|
1152
|
-
let lineNumber = lineOffset;
|
|
1153
|
-
|
|
1767
|
+
const defined = new Set<string>();
|
|
1154
1768
|
const lines = body.split("\n");
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1769
|
+
const text = maskCode(lines, format);
|
|
1770
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
1771
|
+
scanLinkLine(line, lineOffset + index + 1, links, defined);
|
|
1158
1772
|
}
|
|
1159
|
-
|
|
1160
|
-
return [...links, ...multilineLinks(lines, lineOffset)];
|
|
1773
|
+
return [...links, ...multilineLinks(text, lines, lineOffset)];
|
|
1161
1774
|
};
|
|
1162
1775
|
|
|
1163
1776
|
/**
|
|
1164
1777
|
* A `<Component>` example's string `path` (`<Component path="forms/login" />`),
|
|
1165
1778
|
* wrapped onto a later line or not, read the way `ELEMENT_HREF` reads an
|
|
1166
|
-
* `href`. An expression-valued `path={…}` isn't a literal, so it isn't matched
|
|
1779
|
+
* `href`. An expression-valued `path={…}` isn't a literal, so it isn't matched,
|
|
1780
|
+
* and an escaped `\<Component>` is text (see `HTML_ID`).
|
|
1167
1781
|
*/
|
|
1168
1782
|
const COMPONENT_PATH =
|
|
1169
|
-
|
|
1783
|
+
/(?<=(?:^|[^\\])(?:\\\\)*)<Component(?=[\s/>])[^<>]*?\spath=(?:"(?<double>[^"]*)"|'(?<single>[^']*)')/gu;
|
|
1170
1784
|
|
|
1171
1785
|
/**
|
|
1172
1786
|
* Every `<Component path>` in an `.mdx` body, with the 1-based position of its
|
|
@@ -1195,13 +1809,15 @@ export const extractExampleUses = (
|
|
|
1195
1809
|
// isn't a real usage. Single quotes are left alone so prose apostrophes don't
|
|
1196
1810
|
// swallow a real tag between two words.
|
|
1197
1811
|
const DOUBLE_QUOTED = /"[^"]*"/gu;
|
|
1198
|
-
|
|
1812
|
+
// An escaped `Promise\<App>` renders `Promise<App>` as text, so it uses no
|
|
1813
|
+
// component (see `HTML_ID`); `\\<App>` is an escaped backslash, then a tag.
|
|
1814
|
+
const JSX_OPEN = /(?<=(?:^|[^\\])(?:\\\\)*)<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
|
|
1199
1815
|
|
|
1200
1816
|
/**
|
|
1201
1817
|
* Capitalized JSX component tags used in an `.mdx` body (`<Callout>`,
|
|
1202
|
-
* `<Tree.File>` → `Tree`). Skips fenced code, inline code,
|
|
1203
|
-
* strings so code samples and prose don't count.
|
|
1204
|
-
* diagnostic.
|
|
1818
|
+
* `<Tree.File>` → `Tree`). Skips fenced code, inline code, double-quoted
|
|
1819
|
+
* strings, and escaped `\<Tag>` text so code samples and prose don't count.
|
|
1820
|
+
* Powers the missing-component diagnostic.
|
|
1205
1821
|
*/
|
|
1206
1822
|
/** Scan one line for JSX component tags; returns the next fenced-block state. */
|
|
1207
1823
|
const scanTagLine = (
|
|
@@ -1314,26 +1930,93 @@ const entryLinks = <T extends Located>(
|
|
|
1314
1930
|
* at render time. An included `.md` partial is spliced into the including
|
|
1315
1931
|
* page and parsed in *its* format (see `markdown/include.ts`), so a partial's
|
|
1316
1932
|
* markers count too and are reported against the partial via the expansion's
|
|
1317
|
-
* origins. `.md` pages
|
|
1933
|
+
* origins. `.md` pages pin the unspaced `{#id}` and are warned about the
|
|
1934
|
+
* other spellings instead (see {@link mdCurlyMarkerDiagnostics}). A marker
|
|
1935
|
+
* that also sets classes or attributes is an attribute list, which
|
|
1936
|
+
* `BLUME_MDX_ATTRIBUTE_LIST` reports (see `core/syntax-diagnostics.ts`).
|
|
1318
1937
|
*/
|
|
1319
1938
|
const curlyMarkerDiagnostics = (
|
|
1320
1939
|
entry: SourceEntry,
|
|
1321
1940
|
markers: CurlyMarker[],
|
|
1322
1941
|
sourceName: string
|
|
1323
1942
|
): Diagnostic[] =>
|
|
1324
|
-
markers
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1943
|
+
markers
|
|
1944
|
+
.filter(({ attributes }) => !attributes)
|
|
1945
|
+
.map(({ id, line, marker }) => {
|
|
1946
|
+
const origin = entry.expanded?.origins[line - 1];
|
|
1947
|
+
const page = entry.sourcePath ?? `${sourceName}:${entry.ref}`;
|
|
1948
|
+
const inPartial =
|
|
1949
|
+
origin !== undefined && origin.file !== entry.sourcePath;
|
|
1950
|
+
return {
|
|
1951
|
+
code: "BLUME_MDX_CURLY_ANCHOR",
|
|
1952
|
+
file: origin?.file ?? page,
|
|
1953
|
+
line: origin?.line ?? line + entryLineOffset(entry),
|
|
1954
|
+
message: inPartial
|
|
1955
|
+
? `\`${marker}\` is a JSX expression once this partial is included in ${page} (.mdx), so that page fails to compile.`
|
|
1956
|
+
: `\`${marker}\` is a JSX expression in .mdx, so this page fails to compile.`,
|
|
1957
|
+
severity: "error",
|
|
1958
|
+
suggestion: `Write \`[#${id}]\` or escape it as \`\\{#${id}\\}\` — both pin the same anchor in .md and .mdx.`,
|
|
1959
|
+
};
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Warnings for the spaced `{ #id }` and kramdown `{: #id }` heading markers
|
|
1964
|
+
* (MkDocs' `attr_list` writes both) in an `.md` page. Only the unspaced
|
|
1965
|
+
* `{#id}` pins an anchor there; the other spellings stay in the heading's
|
|
1966
|
+
* text, so the page shows them and the heading's id is slugged from them
|
|
1967
|
+
* (`## Setup { #setup }` anchors as `setup--setup`). So does an attribute
|
|
1968
|
+
* list that sets the id beside classes or attributes (`{ #setup .wide }`),
|
|
1969
|
+
* whose other attributes no heading takes either.
|
|
1970
|
+
*/
|
|
1971
|
+
const mdCurlyMarkerDiagnostics = (
|
|
1972
|
+
entry: SourceEntry,
|
|
1973
|
+
markers: CurlyMarker[],
|
|
1974
|
+
sourceName: string
|
|
1975
|
+
): Diagnostic[] =>
|
|
1976
|
+
markers
|
|
1977
|
+
.filter(({ id, marker }) => marker !== `{#${id}}`)
|
|
1978
|
+
.map(({ attributes, id, line, marker }) => {
|
|
1979
|
+
const origin = entry.expanded?.origins[line - 1];
|
|
1980
|
+
return {
|
|
1981
|
+
code: "BLUME_MD_CURLY_ANCHOR",
|
|
1982
|
+
file: origin?.file ?? entry.sourcePath ?? `${sourceName}:${entry.ref}`,
|
|
1983
|
+
line: origin?.line ?? line + entryLineOffset(entry),
|
|
1984
|
+
message: `\`${marker}\` doesn't pin an anchor: only the unspaced \`{#${id}}\` does, so this one shows in the heading and its id is slugged from it.`,
|
|
1985
|
+
severity: "warning",
|
|
1986
|
+
suggestion: `Write \`{#${id}}\` or \`[#${id}]\` to pin the anchor \`#${id}\`.${
|
|
1987
|
+
attributes
|
|
1988
|
+
? " Blume sets no classes or other attributes on a heading."
|
|
1989
|
+
: ""
|
|
1990
|
+
}`,
|
|
1991
|
+
};
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
const LIST = new Intl.ListFormat("en", { type: "conjunction" });
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Diagnostics for JavaScript event handlers on HTML elements in an `.mdx`
|
|
1998
|
+
* page (`<button onClick={() => …}>`). The page is static HTML: Astro writes
|
|
1999
|
+
* the function's source into the attribute, where the browser reads it as a
|
|
2000
|
+
* handler body that only defines a function, so nothing happens. Reported at
|
|
2001
|
+
* the element's line, in the partial that holds it when an `<include>`
|
|
2002
|
+
* brought it in.
|
|
2003
|
+
*/
|
|
2004
|
+
const eventHandlerDiagnostics = (
|
|
2005
|
+
entry: SourceEntry,
|
|
2006
|
+
handlers: ElementUse[],
|
|
2007
|
+
sourceName: string
|
|
2008
|
+
): Diagnostic[] =>
|
|
2009
|
+
handlers.map(({ column, file, line, props, tag }) => {
|
|
2010
|
+
const names = LIST.format(props.map((prop) => `\`${prop}\``));
|
|
1328
2011
|
return {
|
|
1329
|
-
code: "
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
2012
|
+
code: "BLUME_MDX_EVENT_HANDLER",
|
|
2013
|
+
column,
|
|
2014
|
+
file: file ?? entry.sourcePath ?? `${sourceName}:${entry.ref}`,
|
|
2015
|
+
line,
|
|
2016
|
+
message: `${names} on \`<${tag}>\` ${props.length === 1 ? "is a JavaScript function" : "are JavaScript functions"}, which a static page never runs.`,
|
|
2017
|
+
severity: "warning",
|
|
2018
|
+
suggestion:
|
|
2019
|
+
"Move the interactive markup into an island (islands/Name.tsx), which runs in the browser, and use that component in the page.",
|
|
1337
2020
|
};
|
|
1338
2021
|
});
|
|
1339
2022
|
|
|
@@ -1437,6 +2120,12 @@ export interface EntryRoute extends Pick<
|
|
|
1437
2120
|
* through {@link localizedRoute} for the route one locale publishes at.
|
|
1438
2121
|
*/
|
|
1439
2122
|
logicalRoute: string;
|
|
2123
|
+
/**
|
|
2124
|
+
* The logical route an earlier Blume published the entry at, when it
|
|
2125
|
+
* differs: a date-named file or folder (`12-05-2022`) lost its first number
|
|
2126
|
+
* as an ordering prefix. Its old URL redirects to the new one.
|
|
2127
|
+
*/
|
|
2128
|
+
formerLogicalRoute?: string;
|
|
1440
2129
|
/** The prefixed, locale- and version-stripped nav path. */
|
|
1441
2130
|
navPath: string;
|
|
1442
2131
|
segments: string[];
|
|
@@ -1475,16 +2164,21 @@ export const resolveEntryRoute = (
|
|
|
1475
2164
|
// A frontmatter slug is a route spelled out; an adapter's `entry.slug` is
|
|
1476
2165
|
// one too unless the source's names are ordered file names (a vault note's
|
|
1477
2166
|
// path), which lose their prefixes like the ref does.
|
|
1478
|
-
const ordered =
|
|
1479
|
-
ctx.orderingPrefixes === true && frontmatterSlug === undefined
|
|
2167
|
+
const ordered = slug
|
|
2168
|
+
? ctx.orderingPrefixes === true && frontmatterSlug === undefined
|
|
2169
|
+
: ctx.orderingPrefixes === true;
|
|
2170
|
+
const input = slug ? `${slug}${ext}` : navPath;
|
|
1480
2171
|
const {
|
|
1481
2172
|
segments,
|
|
1482
2173
|
groups,
|
|
1483
2174
|
route: versionKey,
|
|
1484
|
-
} =
|
|
1485
|
-
|
|
1486
|
-
|
|
2175
|
+
} = mapRoute(ctx.prefix, input, ordered);
|
|
2176
|
+
const former = ordered
|
|
2177
|
+
? mapRoute(ctx.prefix, input, true, true).route
|
|
2178
|
+
: versionKey;
|
|
1487
2179
|
return {
|
|
2180
|
+
formerLogicalRoute:
|
|
2181
|
+
former === versionKey ? undefined : versionizeRoute(former, version),
|
|
1488
2182
|
groups,
|
|
1489
2183
|
locales,
|
|
1490
2184
|
logicalRoute: versionizeRoute(versionKey, version),
|
|
@@ -1686,19 +2380,12 @@ export const normalizeEntry = (
|
|
|
1686
2380
|
return { diagnostics: parsed.diagnostics, pages: [] };
|
|
1687
2381
|
}
|
|
1688
2382
|
|
|
2383
|
+
// The schema has already folded the top-level `hidden`/`noindex` shorthands
|
|
2384
|
+
// into `sidebar.hidden`/`seo.noindex` (see `foldShorthands`).
|
|
1689
2385
|
const { meta } = parsed;
|
|
1690
2386
|
|
|
1691
|
-
// Top-level `hidden`/`noindex` are accepted as shorthands for their nested
|
|
1692
|
-
// equivalents — the schema declares them, so silently ignoring them would
|
|
1693
|
-
// strand authors with no diagnostic.
|
|
1694
|
-
if (meta.hidden) {
|
|
1695
|
-
meta.sidebar.hidden = true;
|
|
1696
|
-
}
|
|
1697
|
-
if (meta.noindex) {
|
|
1698
|
-
meta.seo.noindex = true;
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
2387
|
const {
|
|
2388
|
+
formerLogicalRoute,
|
|
1702
2389
|
groups,
|
|
1703
2390
|
locales,
|
|
1704
2391
|
logicalRoute,
|
|
@@ -1718,6 +2405,9 @@ export const normalizeEntry = (
|
|
|
1718
2405
|
const bodyText = entry.expanded?.text ?? entry.body.text;
|
|
1719
2406
|
const { anchors, curlyMarkers, headings } = scanBody(bodyText);
|
|
1720
2407
|
const { staged } = ctx.source;
|
|
2408
|
+
const elements =
|
|
2409
|
+
format === "mdx" ? entryLinks(entry, extractElementUses) : [];
|
|
2410
|
+
const unknownProps = elements.filter((use) => use.kind === "prop");
|
|
1721
2411
|
|
|
1722
2412
|
const base = {
|
|
1723
2413
|
anchors,
|
|
@@ -1739,7 +2429,9 @@ export const normalizeEntry = (
|
|
|
1739
2429
|
includes: entryIncludes(entry),
|
|
1740
2430
|
lastModified: meta.lastModified ?? entry.lastModified,
|
|
1741
2431
|
links: [
|
|
1742
|
-
...entryLinks(entry,
|
|
2432
|
+
...entryLinks(entry, (text, lineOffset) =>
|
|
2433
|
+
extractLinks(text, lineOffset, format)
|
|
2434
|
+
),
|
|
1743
2435
|
...relatedPageLinks(meta.related),
|
|
1744
2436
|
],
|
|
1745
2437
|
meta,
|
|
@@ -1750,6 +2442,7 @@ export const normalizeEntry = (
|
|
|
1750
2442
|
sourcePath: entry.sourcePath,
|
|
1751
2443
|
title: deriveTitle(meta, headings, navPath),
|
|
1752
2444
|
translationKey: logicalRoute,
|
|
2445
|
+
unknownProps: unknownProps.length > 0 ? unknownProps : undefined,
|
|
1753
2446
|
version,
|
|
1754
2447
|
versionKey,
|
|
1755
2448
|
} satisfies Omit<PageRecord, "locale" | "route">;
|
|
@@ -1761,20 +2454,33 @@ export const normalizeEntry = (
|
|
|
1761
2454
|
// stay base-less so the nav tree and translation matching are unaffected.
|
|
1762
2455
|
// The base is mounted unconditionally: a `docs/` folder under a `/docs`
|
|
1763
2456
|
// base is a real `/docs/docs/…` route, not an already-based one.
|
|
2457
|
+
const routeIn = (logical: string, locale: string): string =>
|
|
2458
|
+
mountBasePath(
|
|
2459
|
+
ctx.basePath ?? "",
|
|
2460
|
+
localizedRoute(logical, locale, ctx.i18n)
|
|
2461
|
+
);
|
|
1764
2462
|
const pages = locales.map((locale) => ({
|
|
1765
2463
|
...base,
|
|
2464
|
+
formerRoute:
|
|
2465
|
+
formerLogicalRoute === undefined
|
|
2466
|
+
? undefined
|
|
2467
|
+
: routeIn(formerLogicalRoute, locale),
|
|
1766
2468
|
locale,
|
|
1767
|
-
route:
|
|
1768
|
-
ctx.basePath ?? "",
|
|
1769
|
-
localizedRoute(logicalRoute, locale, ctx.i18n)
|
|
1770
|
-
),
|
|
2469
|
+
route: routeIn(logicalRoute, locale),
|
|
1771
2470
|
}));
|
|
1772
2471
|
|
|
1773
2472
|
return {
|
|
1774
2473
|
diagnostics:
|
|
1775
2474
|
format === "mdx"
|
|
1776
|
-
?
|
|
1777
|
-
|
|
2475
|
+
? [
|
|
2476
|
+
...curlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name),
|
|
2477
|
+
...eventHandlerDiagnostics(
|
|
2478
|
+
entry,
|
|
2479
|
+
elements.filter((use) => use.kind === "handler"),
|
|
2480
|
+
ctx.source.name
|
|
2481
|
+
),
|
|
2482
|
+
]
|
|
2483
|
+
: mdCurlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name),
|
|
1778
2484
|
pages,
|
|
1779
2485
|
};
|
|
1780
2486
|
};
|