Skip to content

Commit

Permalink
Add support for passing options as functions
Browse files Browse the repository at this point in the history
Related-to GH-3.
Closes GH-4.

Reviewed-by: Titus Wormer <tituswormer@gmail.com>
  • Loading branch information
okikio committed Jul 10, 2022
1 parent 4e77953 commit 4312904
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 15 deletions.
77 changes: 62 additions & 15 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,57 @@
/**
* @typedef {import('hast').Root} Root
* @typedef {import('hast').Properties} Properties
* @typedef {import('hast').Element['children'][number]} ElementChild
* @typedef {import('hast').Element} Element
*
* @typedef {Element['children'][number]} ElementChild
*
* @typedef {'_self'|'_blank'|'_parent'|'_top'|false} Target
* @typedef {Array<string>|string|false} Rel
* @typedef {Array<string>} Protocols
* @typedef {ElementChild|Array<ElementChild>} Content
* @typedef {Properties} ContentProperties
*
* @callback TargetCallback
* @param {Element} node
* @returns {Target|null|undefined}
*
* @callback RelCallback
* @param {Element} node
* @returns {Rel|null|undefined}
*
* @callback ProtocolsCallback
* @param {Element} node
* @returns {Protocols|null|undefined}
*
* @callback ContentCallback
* @param {Element} node
* @returns {Content|null|undefined}
*
* @callback ContentPropertiesCallback
* @param {Element} node
* @returns {Properties|null|undefined}
*
* @typedef Options
* Configuration.
* @property {'_self'|'_blank'|'_parent'|'_top'|false} [target='_blank']
* @property {Target|TargetCallback} [target='_blank']
* How to display referenced documents (`string?`: `_self`, `_blank`,
* `_parent`, or `_top`, default: `_blank`).
* Pass `false` to not set `target`s on links.
* @property {Array<string>|string|false} [rel=['nofollow', 'noopener', 'noreferrer']]
* @property {Rel|RelCallback} [rel=['nofollow', 'noopener', 'noreferrer']]
* Link types to hint about the referenced documents.
* Pass `false` to not set `rel`s on links.
*
* **Note**: when using a `target`, add `noopener` and `noreferrer` to avoid
* exploitation of the `window.opener` API.
* @property {Array<string>} [protocols=['http', 'https']]
* @property {Protocols|ProtocolsCallback} [protocols=['http', 'https']]
* Protocols to check, such as `mailto` or `tel`.
* @property {ElementChild|Array<ElementChild>} [content]
* @property {Content|ContentCallback} [content]
* hast content to insert at the end of external links.
* Will be inserted in a `<span>` element.
*
* Useful for improving accessibility by giving users advanced warning when
* opening a new window.
* @property {Properties} [contentProperties]
* @property {ContentProperties|ContentPropertiesCallback} [contentProperties]
* hast properties to add to the `span` wrapping `content`, when given.
*/

Expand All @@ -36,21 +64,25 @@ const defaultTarget = false
const defaultRel = ['nofollow']
const defaultProtocols = ['http', 'https']

/**
* If this is a value, return that.
* If this is a function instead, call it to get the result.
*
* @template T
* @param {T} value
* @param {Element} node
* @returns {T extends Function ? ReturnType<T> : T}
*/
function callIfNeeded(value, node) {
return typeof value === 'function' ? value(node) : value
}

/**
* Plugin to automatically add `target` and `rel` attributes to external links.
*
* @type {import('unified').Plugin<[Options?] | Array<void>, Root>}
*/
export default function rehypeExternalLinks(options = {}) {
const target = options.target
const rel = typeof options.rel === 'string' ? parse(options.rel) : options.rel
const protocols = options.protocols || defaultProtocols
const content =
options.content && !Array.isArray(options.content)
? [options.content]
: options.content
const contentProperties = options.contentProperties || {}

return (tree) => {
visit(tree, 'element', (node) => {
if (
Expand All @@ -61,6 +93,21 @@ export default function rehypeExternalLinks(options = {}) {
const url = node.properties.href
const protocol = url.slice(0, url.indexOf(':'))

const target = callIfNeeded(options.target, node)

const _rel = callIfNeeded(options.rel, node)
const rel = typeof _rel === 'string' ? parse(_rel) : _rel

const protocols =
callIfNeeded(options.protocols, node) || defaultProtocols

const _content = callIfNeeded(options.content, node)
const content =
_content && !Array.isArray(_content) ? [_content] : _content

const contentProperties =
callIfNeeded(options.contentProperties, node) || {}

if (absolute(url) && protocols.includes(protocol)) {
if (target !== false) {
node.properties.target = target || defaultTarget
Expand Down
22 changes: 22 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,28 @@ Add `rel` (and `target`) to external links.
##### `options`

Configuration (optional).
Each config option can also be a callback function which has
a node as an argument, and returns the corresponding config values.

e.g.

```ts
{
target(node) {
return node.properties.id == 5 ? "_blank" : false;
}
}
...
```

Or

```ts
{
target: "_blank"
}
...
```

###### `options.target`

Expand Down
83 changes: 83 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,5 +195,88 @@ test('rehypeExternalLinks', async (t) => {
'should add properties to the span at the end of the link w/ `contentProperties`'
)

t.equal(
String(
await rehype()
.use({settings: {fragment: true}})
.use(rehypeExternalLinks, {
contentProperties: {className: ['alpha', 'bravo']},
content(node) {
// True, If node doesn't contain an image
const noImage = node.children.every((x) => {
if (x.type === 'element') return x.tagName !== 'img'
return true
})

if (noImage)
return {type: 'text', value: ' (opens in a new window)'}
}
})
.process(
`<a href="http://example.com">http</a>\n<a href="http://example.com"><img src="./image.png" /></a>`
)
),
`<a href="http://example.com" rel="nofollow">http<span class="alpha bravo"> (opens in a new window)</span></a>\n<a href="http://example.com" rel="nofollow"><img src="./image.png"></a>`,
"should only add (open in window) text to the span at the end of the link w/ a `options(node)` function, whose node doesn't have an `img` element as a direct child"
)

t.equal(
String(
await rehype()
.use({settings: {fragment: true}})
.use(rehypeExternalLinks, {
contentProperties(node) {
// True, If node doesn't contains an image
const noImage = !node.children.every((x) => {
if (x.type === 'element') return x.tagName !== 'img'
return true
})

if (noImage) return {className: ['alpha', 'bravo']}
},
content: {type: 'text', value: ' (opens in a new window)'}
})
.process(
`<a href="http://example.com">http</a>\n<a href="http://example.com"><img src="./image.png" /></a>`
)
),
`<a href="http://example.com" rel="nofollow">http<span> (opens in a new window)</span></a>\n<a href="http://example.com" rel="nofollow"><img src="./image.png"><span class="alpha bravo"> (opens in a new window)</span></a>`,
"should only add 'alpha bravo' classes to the span at the end of the link w/ a `options(node)` function, whose node doesn't have an `img` element as a direct child"
)

t.equal(
String(
await rehype()
.use({settings: {fragment: true}})
.use(rehypeExternalLinks, {
target(node) {
// True, If node doesn't contains an image
const noImage = node.children.every((x) => {
if (x.type === 'element') return x.tagName !== 'img'
return true
})

return noImage ? '_blank' : false
},
rel(node) {
// True, If node doesn't contains an image
const noImage = node.children.every((x) => {
if (x.type === 'element') return x.tagName !== 'img'
return true
})

return noImage ? ['noopener', 'noreferrer'] : 'nofollow'
},
contentProperties: {className: ['alpha', 'bravo']},
content: {type: 'text', value: ' (opens in a new window)'}
})
.process(
`<a href="http://example.com">http</a>\n<a href="http://example.com"><img src="./image.png" /></a>`
)
),
`<a href="http://example.com" target="_blank" rel="noopener noreferrer">http<span class="alpha bravo"> (opens in a new window)</span></a>\n<a href="http://example.com" rel="nofollow"><img src="./image.png"><span class="alpha bravo"> (opens in a new window)</span></a>`,
"should only add 'alpha bravo' classes to the span at the end of the link w/ `options(node)` function, whose node doesn't have an `img` element as a direct child"
)

t.end()
})

0 comments on commit 4312904

Please sign in to comment.