2025-09-05 14:59:21 +08:00

180 lines
5.7 KiB
JavaScript

/**
* @typedef {import('hast').Element} Element
* @typedef {import('hast').ElementContent} ElementContent
* @typedef {import('hast').Properties} Properties
* @typedef {import('hast').Root} Root
* @typedef {import('hast-util-is-element').Test} Test
*/
/**
* @callback CreateContent
* Create a target for the element.
* @param {Element} element
* Element to check.
* @returns {Array<ElementContent> | ElementContent | null | undefined}
* Content to add.
*
* @callback CreateProperties
* Create properties for an element.
* @param {Element} element
* Element to check.
* @returns {Properties | null | undefined}
* Properties to add.
*
* @callback CreateRel
* Create a `rel` for the element.
* @param {Element} element
* Element to check.
* @returns {Array<string> | string | null | undefined}
* `rel` to use.
*
* @callback CreateTarget
* Create a `target` for the element.
* @param {Element} element
* Element to check.
* @returns {Target | null | undefined}
* `target` to use.
*
* @typedef Options
* Configuration.
* @property {Array<ElementContent> | CreateContent | ElementContent | null | undefined} [content]
* Content to insert at the end of external links (optional); will be
* inserted in a `<span>` element; useful for improving accessibility by
* giving users advanced warning when opening a new window.
* @property {CreateProperties | Properties | null | undefined} [contentProperties]
* Properties to add to the `span` wrapping `content` (optional).
* @property {CreateProperties | Properties | null | undefined} [properties]
* Properties to add to the link itself (optional).
* @property {Array<string> | null | undefined} [protocols=['http', 'https']]
* Protocols to check, such as `mailto` or `tel` (default: `['http',
* 'https']`).
* @property {Array<string> | CreateRel | string | null | undefined} [rel=['nofollow']]
* Link types to hint about the referenced documents (default:
* `['nofollow']`); pass an empty array (`[]`) to not set `rel`s on links;
* when using a `target`, add `noopener` and `noreferrer` to avoid
* exploitation of the `window.opener` API.
* @property {CreateTarget | Target | null | undefined} [target]
* How to display referenced documents; the default (nothing) is to not set
* `target`s on links.
* @property {Test | null | undefined} [test]
* Extra test to define which external link elements are modified (optional);
* any test that can be given to `hast-util-is-element` is supported.
*
* @typedef {'_blank' | '_parent' | '_self' | '_top'} Target
* Target.
*/
import structuredClone from '@ungap/structured-clone'
import {convertElement} from 'hast-util-is-element'
import isAbsoluteUrl from 'is-absolute-url'
import {parse} from 'space-separated-tokens'
import {visit} from 'unist-util-visit'
const defaultProtocols = ['http', 'https']
const defaultRel = ['nofollow']
/** @type {Options} */
const emptyOptions = {}
/**
* Automatically add `rel` (and `target`?) to external links.
*
* ###### Notes
*
* You should [likely not configure `target`][css-tricks].
*
* You should at least set `rel` to `['nofollow']`.
* When using a `target`, add `noopener` and `noreferrer` to avoid exploitation
* of the `window.opener` API.
*
* When using a `target`, you should set `content` to adhere to accessibility
* guidelines by giving users advanced warning when opening a new window.
*
* [css-tricks]: https://css-tricks.com/use-target_blank/
*
* @param {Readonly<Options> | null | undefined} [options]
* Configuration (optional).
* @returns
* Transform.
*/
export default function rehypeExternalLinks(options) {
const settings = options || emptyOptions
const protocols = settings.protocols || defaultProtocols
const is = convertElement(settings.test)
/**
* Transform.
*
* @param {Root} tree
* Tree.
* @returns {undefined}
* Nothing.
*/
return function (tree) {
visit(tree, 'element', function (node, index, parent) {
if (
node.tagName === 'a' &&
typeof node.properties.href === 'string' &&
is(node, index, parent)
) {
const url = node.properties.href
if (
isAbsoluteUrl(url)
? protocols.includes(url.slice(0, url.indexOf(':')))
: url.startsWith('//')
) {
const contentRaw = createIfNeeded(settings.content, node)
const content =
contentRaw && !Array.isArray(contentRaw) ? [contentRaw] : contentRaw
const relRaw = createIfNeeded(settings.rel, node) || defaultRel
const rel = typeof relRaw === 'string' ? parse(relRaw) : relRaw
const target = createIfNeeded(settings.target, node)
const properties = createIfNeeded(settings.properties, node)
if (properties) {
Object.assign(node.properties, structuredClone(properties))
}
if (rel.length > 0) {
node.properties.rel = [...rel]
}
if (target) {
node.properties.target = target
}
if (content) {
const properties =
createIfNeeded(settings.contentProperties, node) || {}
node.children.push({
type: 'element',
tagName: 'span',
properties: structuredClone(properties),
children: structuredClone(content)
})
}
}
}
})
}
}
/**
* Call a function to get a return value or use the value.
*
* @template T
* Type of value.
* @param {T} value
* Value.
* @param {Element} element
* Element.
* @returns {T extends Function ? ReturnType<T> : T}
* Result.
*/
function createIfNeeded(value, element) {
return typeof value === 'function' ? value(element) : value
}