diff --git a/.remarkrc.mjs b/.remarkrc.mjs index 7f66dd97..e6f3fdb7 100644 --- a/.remarkrc.mjs +++ b/.remarkrc.mjs @@ -5,6 +5,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'; import remarkLintNoLiteralUrls from 'remark-lint-no-literal-urls'; export default { @@ -33,5 +34,6 @@ export default { }, ] : () => undefined, + remarkForceRootRelative, ], }; diff --git a/astro.config.mjs b/astro.config.mjs index d2118519..846f209a 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], remarkRehype: { footnoteLabel: 'References', footnoteLabelTagName: 'h4', diff --git a/package.json b/package.json index b1b8e9f4..32d12791 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@astrojs/check": "^0.9.10", "@eslint/js": "^10.0.1", "@types/estree": "^1.0.9", + "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@types/node": "^26.4.0", "@frcsoftware/vale": "3.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d21241df..9555c7e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,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 diff --git a/src/plugins/rehype-external-links.ts b/src/plugins/rehype-external-links.ts new file mode 100644 index 00000000..0691107c --- /dev/null +++ b/src/plugins/rehype-external-links.ts @@ -0,0 +1,33 @@ +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?:/.test(URL.parse(node.properties.href)!.protocol) + ) { + // only run on valid external links + return; + } + const target = node.properties.target; + node.properties.target = target ? target : '_blank'; + let rel = node.properties.rel; + if (!rel) { + rel = ['noreferrer', 'noopener']; + } else { + // just in case someone overrides the remark rule + if (!(rel.includes('opener') || rel.includes('noopener'))) { + rel.push('noopener'); + } + if (!rel.includes('noreferrer')) { + rel.push('noreferrer'); + } + } + node.properties.rel = rel; + }); + }; +} diff --git a/src/plugins/remark-force-root-relative.mjs b/src/plugins/remark-force-root-relative.mjs new file mode 100644 index 00000000..978f987b --- /dev/null +++ b/src/plugins/remark-force-root-relative.mjs @@ -0,0 +1,29 @@ +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') || + !URL.canParse(node.url) + ) { + 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; + } + }); + }; +}