diff --git a/.remarkrc.mjs b/.remarkrc.mjs index 3a3379bb..83c497cf 100644 --- a/.remarkrc.mjs +++ b/.remarkrc.mjs @@ -4,6 +4,7 @@ import remarkPresetLintRecommended from 'remark-preset-lint-recommended'; import remarkNoInlineCodeFences from './src/plugins/remark-no-inline-code-fences.mjs'; import remarkNoHtmlLinks from './src/plugins/remark-no-html-links.mjs'; import remarkLintNoDeadUrls from 'remark-lint-no-dead-urls'; +import remarkForceRootRelative from './src/plugins/remark-force-root-relative.mjs'; export default { plugins: [ @@ -26,5 +27,6 @@ export default { ], // Add known flaky URL patterns here }, ], + remarkForceRootRelative, ], }; diff --git a/astro.config.mjs b/astro.config.mjs index dbe7e048..f107557b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -10,6 +10,7 @@ import remarkImageAttributes from './src/plugins/remark-image-attributes'; import { remarkMdxGlobalImports } from './src/plugins/remark-mdx-global-imports'; import remarkCodeRegion from './src/plugins/remark-code-region'; import { unified } from '@astrojs/markdown-remark'; +import rehypeTargetBlank from './src/plugins/rehype-external-links'; export default defineConfig({ site: 'https://frcsoftware.org', @@ -25,6 +26,7 @@ export default defineConfig({ remarkMdxGlobalImports, remarkCodeRegion, ], + rehypePlugins: [rehypeTargetBlank], }), }, diff --git a/package.json b/package.json index 1cb3ef1d..4da9aea4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@astrojs/check": "^0.9.9", "@eslint/js": "^10.0.1", "@types/estree": "^1.0.9", + "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@vvago/vale": "^3.16.0", "astro-eslint-parser": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bd58001..c4b58ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@types/estree': specifier: ^1.0.9 version: 1.0.9 + '@types/hast': + specifier: ^3.0.5 + version: 3.0.5 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -1135,8 +1138,8 @@ packages: '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} @@ -4119,7 +4122,7 @@ snapshots: '@astrojs/internal-helpers@0.10.1': dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 js-yaml: 4.1.1 picomatch: 4.0.5 @@ -4222,7 +4225,7 @@ snapshots: '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(rollup@4.59.0)(tsx@4.22.4)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.2 '@pagefind/default-ui': 1.4.0 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 astro: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(rollup@4.59.0)(tsx@4.22.4)(yaml@2.9.0) @@ -4671,7 +4674,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdx': 2.0.13 acorn: 8.16.0 collapse-white-space: 2.1.0 @@ -4939,7 +4942,7 @@ snapshots: '@shikijs/primitive': 4.1.0 '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/engine-javascript@4.1.0': @@ -4961,7 +4964,7 @@ snapshots: dependencies: '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/themes@4.1.0': dependencies: @@ -4970,7 +4973,7 @@ snapshots: '@shikijs/types@4.1.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} @@ -5012,7 +5015,7 @@ snapshots: dependencies: '@types/unist': 2.0.11 - '@types/hast@3.0.4': + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -5578,7 +5581,7 @@ snapshots: dead-or-alive@1.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 fast-content-type-parse: 3.0.0 hast-util-from-html-isomorphic: 2.0.0 hast-util-is-element: 3.0.0 @@ -6061,12 +6064,12 @@ snapshots: hast-util-embedded@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element: 3.0.0 hast-util-format@1.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-minify-whitespace: 1.0.1 hast-util-phrasing: 3.0.1 @@ -6076,20 +6079,20 @@ snapshots: hast-util-from-dom@5.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript: 9.0.1 web-namespaces: 2.0.1 hast-util-from-html-isomorphic@2.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-from-dom: 5.0.1 hast-util-from-html: 2.0.3 unist-util-remove-position: 5.0.0 hast-util-from-html@2.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 devlop: 1.1.0 hast-util-from-parse5: 8.0.3 parse5: 7.3.0 @@ -6098,7 +6101,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -6109,19 +6112,19 @@ snapshots: hast-util-has-property@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-body-ok-link@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace@1.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-is-element: 3.0.0 hast-util-whitespace: 3.0.0 @@ -6129,11 +6132,11 @@ snapshots: hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-phrasing@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-has-property: 3.0.0 hast-util-is-body-ok-link: 3.0.1 @@ -6141,7 +6144,7 @@ snapshots: hast-util-raw@9.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 '@ungap/structured-clone': 1.3.0 hast-util-from-parse5: 8.0.3 @@ -6157,7 +6160,7 @@ snapshots: hast-util-select@6.0.4: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 bcp-47-match: 2.0.3 comma-separated-tokens: 2.0.3 @@ -6197,7 +6200,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-attach-comments: 3.0.0 @@ -6216,7 +6219,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -6231,7 +6234,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.9 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 @@ -6250,7 +6253,7 @@ snapshots: hast-util-to-parse5@8.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 property-information: 7.1.0 @@ -6260,11 +6263,11 @@ snapshots: hast-util-to-string@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-text@4.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 @@ -6273,11 +6276,11 @@ snapshots: hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 property-information: 7.1.0 @@ -6707,7 +6710,7 @@ snapshots: mdast-util-math@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 longest-streak: 3.1.0 @@ -6730,7 +6733,7 @@ snapshots: mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 @@ -6758,7 +6761,7 @@ snapshots: mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 @@ -6805,7 +6808,7 @@ snapshots: mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 @@ -6836,7 +6839,7 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 @@ -7797,38 +7800,38 @@ snapshots: rehype-format@5.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-format: 1.1.0 rehype-parse@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-from-html: 2.0.3 unified: 11.0.5 rehype-raw@7.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw: 9.1.0 vfile: 6.0.3 rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.9 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color rehype-stringify@10.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 unified: 11.0.5 rehype@13.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 rehype-parse: 9.0.1 rehype-stringify: 10.0.1 unified: 11.0.5 @@ -8073,7 +8076,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -8232,7 +8235,7 @@ snapshots: satteri@0.9.5: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: @@ -8298,7 +8301,7 @@ snapshots: '@shikijs/themes': 4.1.0 '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 signal-exit@4.1.0: {} diff --git a/src/plugins/rehype-external-links.ts b/src/plugins/rehype-external-links.ts new file mode 100644 index 00000000..a800b245 --- /dev/null +++ b/src/plugins/rehype-external-links.ts @@ -0,0 +1,21 @@ +import { visit } from 'unist-util-visit'; +import type { Root } from 'hast'; + +export default function rehypeTargetBlank() { + return (tree: Root) => { + visit(tree, 'element', (node) => { + if ( + node.tagName !== 'a' || + typeof node.properties.href !== 'string' || + !URL.canParse(node.properties.href) || + // only match http/s + !/^https?:/gm.test(URL.parse(node.properties.href)!.protocol) + ) { + // only run on valid external links + return; + } + node.properties.target = '_blank'; + node.properties.rel = ['noreferrer', 'noopener']; + }); + }; +} diff --git a/src/plugins/remark-force-root-relative.mjs b/src/plugins/remark-force-root-relative.mjs new file mode 100644 index 00000000..1b80f8f1 --- /dev/null +++ b/src/plugins/remark-force-root-relative.mjs @@ -0,0 +1,35 @@ +import { visit } from 'unist-util-visit'; + +export default function remarkForceRootRelative() { + /** + * @param {import("mdast").Root} tree + * @param {import("vfile").VFile} file + */ + return (tree, file) => { + visit(tree, ['link', 'image', 'definition'], (node) => { + if ( + !( + node.type === 'link' || + node.type === 'image' || + node.type === 'definition' + ) + ) { + // genuinely just a type guard + return; + } + if (!URL.canParse(node.url)) { + // already root-relative, skip + return; + } + // can't use the site property from astro.config.mjs, see https://github.com/withastro/starlight/pull/3572 + const hostname = 'frcsoftware.org'; + const url = new URL(node.url); + if (url.hostname === hostname) { + file.message( + 'Use root-relative URL syntax for internal links.', + node, + ).fatal = true; + } + }); + }; +}