diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/workflows/bb.yml b/.github/workflows/bb.yml new file mode 100644 index 0000000..0198fc3 --- /dev/null +++ b/.github/workflows/bb.yml @@ -0,0 +1,13 @@ +name: bb +on: + issues: + types: [opened, reopened, edited, closed, labeled, unlabeled] + pull_request_target: + types: [opened, reopened, edited, closed, labeled, unlabeled] +jobs: + main: + runs-on: ubuntu-latest + steps: + - uses: unifiedjs/beep-boop-beta@main + with: + repo-token: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..fe284ad --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,21 @@ +name: main +on: + - pull_request + - push +jobs: + main: + name: ${{matrix.node}} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: dcodeIO/setup-node-nvm@master + with: + node-version: ${{matrix.node}} + - run: npm install + - run: npm test + - uses: codecov/codecov-action@v1 + strategy: + matrix: + node: + - lts/erbium + - node diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53a29e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +coverage/ +node_modules/ +.DS_Store +*.d.ts +*.log +yarn.lock diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..cebe81f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +coverage/ +*.md diff --git a/index.js b/index.js new file mode 100644 index 0000000..f6f854d --- /dev/null +++ b/index.js @@ -0,0 +1,85 @@ +/** + * @typedef {import('hast').Root} Root + * @typedef {import('hast').Properties} Properties + * @typedef {import('hast').Element['children'][number]} ElementChild + * + * @typedef Options + * Configuration. + * @property {'_self'|'_blank'|'_parent'|'_top'|false} [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 {string[]|string|false} [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 {string[]} [protocols=['http', 'https']] + * Protocols to check, such as `mailto` or `tel`. + * @property {ElementChild|ElementChild[]} [content] + * hast content to insert at the end of external links. + * Will be inserted in a `` element. + * + * Useful for improving accessibility by giving users advanced warning when + * opening a new window. + * @property {Properties} [contentProperties] + * hast properties to add to the `span` wrapping `content`, when given. + */ + +import {visit} from 'unist-util-visit' +import {parse} from 'space-separated-tokens' +import absolute from 'is-absolute-url' +import extend from 'extend' + +const defaultTarget = '_blank' +const defaultRel = ['nofollow', 'noopener', 'noreferrer'] +const defaultProtocols = ['http', 'https'] + +/** + * Plugin to automatically add `target` and `rel` attributes to external links. + * + * @type {import('unified').Plugin<[Options?] | 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 ( + node.tagName === 'a' && + node.properties && + typeof node.properties.href === 'string' + ) { + const url = node.properties.href + const protocol = url.slice(0, url.indexOf(':')) + + if (absolute(url) && protocols.includes(protocol)) { + if (target !== false) { + node.properties.target = target || defaultTarget + } + + if (rel !== false) { + node.properties.rel = (rel || defaultRel).concat() + } + + if (content) { + node.children.push({ + type: 'element', + tagName: 'span', + properties: extend(true, contentProperties), + children: extend(true, content) + }) + } + } + } + }) + } +} diff --git a/license b/license new file mode 100644 index 0000000..8d8660d --- /dev/null +++ b/license @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000..d735e29 --- /dev/null +++ b/package.json @@ -0,0 +1,86 @@ +{ + "name": "rehype-external-links", + "version": "0.0.0", + "description": "rehype plugin to automatically add `target` and `rel` attributes to external links", + "license": "MIT", + "keywords": [ + "unified", + "rehype", + "rehype-plugin", + "plugin", + "hast", + "html", + "markdown", + "external", + "link", + "url" + ], + "repository": "rehypejs/rehype-external-links", + "bugs": "https://github.com/rehypejs/rehype-external-links/issues", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "author": "Titus Wormer (https://wooorm.com)", + "contributors": [ + "Titus Wormer (https://wooorm.com)" + ], + "sideEffects": false, + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.d.ts", + "index.js" + ], + "dependencies": { + "@types/hast": "^2.0.0", + "extend": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + }, + "devDependencies": { + "@types/tape": "^4.0.0", + "c8": "^7.0.0", + "prettier": "^2.0.0", + "rehype": "^12.0.0", + "remark-cli": "^10.0.0", + "remark-preset-wooorm": "^9.0.0", + "rimraf": "^3.0.0", + "tape": "^5.0.0", + "type-coverage": "^2.0.0", + "typescript": "^4.0.0", + "xo": "^0.44.0" + }, + "scripts": { + "build": "rimraf \"*.d.ts\" && tsc && type-coverage", + "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", + "test-api": "node --conditions development test.js", + "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov npm run test-api", + "test": "npm run build && npm run format && npm run test-coverage" + }, + "prettier": { + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "bracketSpacing": false, + "semi": false, + "trailingComma": "none" + }, + "xo": { + "prettier": true + }, + "remarkConfig": { + "plugins": [ + "preset-wooorm" + ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true, + "ignoreCatch": true + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b564969 --- /dev/null +++ b/readme.md @@ -0,0 +1,182 @@ +# rehype-external-links + +[![Build][build-badge]][build] +[![Coverage][coverage-badge]][coverage] +[![Downloads][downloads-badge]][downloads] +[![Size][size-badge]][size] +[![Sponsors][sponsors-badge]][collective] +[![Backers][backers-badge]][collective] +[![Chat][chat-badge]][chat] + +[**rehype**][rehype] plugin to automatically add `target` and `rel` attributes +to external links. + +## Install + +This package is [ESM only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c): +Node 12+ is needed to use it and it must be `import`ed instead of `require`d. + +[npm][]: + +```sh +npm install rehype-external-links +``` + +## Use + +Say we have the following module, `example.js`: + +```js +import {unified} from 'unified' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import rehypeExternalLinks from 'rehype-external-links' +import rehypeStringify from 'rehype-stringify' + +const input = '[rehype](https://github.com/rehypejs/rehype)' + +unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeExternalLinks, {target: false, rel: ['nofollow']}) + .use(rehypeStringify) + .process(input) + .then((file) => { + console.log(String(file)) + }) +``` + +Now, running `node example` yields: + +```html +

rehype

+``` + +## API + +This package exports no identifiers. +The default export is `rehypeExternalLinks`. + +### `unified().use(rehypeExternalLinks[, options])` + +Automatically add `target` and `rel` attributes to external links. + +##### `options` + +###### `options.target` + +How to display referenced documents (`string?`: `_self`, `_blank`, `_parent`, +or `_top`, default: `_blank`). +Pass `false` to not set `target`s on links. + +###### `options.rel` + +[Link types][mdn-rel] to hint about the referenced documents (`Array.` +or `string`, default: `['nofollow', 'noopener', 'noreferrer']`). +Pass `false` to not set `rel`s on links. + +**Note**: when using a `target`, add [`noopener` and `noreferrer`][mdn-a] to +avoid exploitation of the `window.opener` API. + +###### `options.protocols` + +Protocols to check, such as `mailto` or `tel` (`Array.`, default: +`['http', 'https']`). + +###### `options.content` + +[**hast**][hast] content to insert at the end of external links +([**Node**][node] or [**Children**][children]). +Will be inserted in a `` element. + +Useful for improving accessibility by [giving users advanced warning when +opening a new window][g201]. + +###### `options.contentProperties` + +[`Properties`][properties] to add to the `span` wrapping `content`, when +given. + +## Security + +Improper use of `rehype-external-links` can open you up to a +[cross-site scripting (XSS)][xss] attack. + +Either do not combine this plugin with user content or use +[`rehype-sanitize`][sanitize]. + +## Contribute + +See [`contributing.md`][contributing] in [`rehypejs/.github`][health] for ways +to get started. +See [`support.md`][support] for ways to get help. + +This project has a [code of conduct][coc]. +By interacting with this repository, organization, or community you agree to +abide by its terms. + +## License + +[MIT][license] © [Titus Wormer][author] + + + +[build-badge]: https://github.com/rehypejs/rehype-external-links/workflows/main/badge.svg + +[build]: https://github.com/rehypejs/rehype-external-links/actions + +[coverage-badge]: https://img.shields.io/codecov/c/github/rehypejs/rehype-external-links.svg + +[coverage]: https://codecov.io/github/rehypejs/rehype-external-links + +[downloads-badge]: https://img.shields.io/npm/dm/rehype-external-links.svg + +[downloads]: https://www.npmjs.com/package/rehype-external-links + +[size-badge]: https://img.shields.io/bundlephobia/minzip/rehype-external-links.svg + +[size]: https://bundlephobia.com/result?p=rehype-external-links + +[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg + +[backers-badge]: https://opencollective.com/unified/backers/badge.svg + +[collective]: https://opencollective.com/unified + +[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg + +[chat]: https://github.com/rehypejs/rehype/discussions + +[npm]: https://docs.npmjs.com/cli/install + +[health]: https://github.com/rehypejs/.github + +[contributing]: https://github.com/rehypejs/.github/blob/HEAD/contributing.md + +[support]: https://github.com/rehypejs/.github/blob/HEAD/support.md + +[coc]: https://github.com/rehypejs/.github/blob/HEAD/code-of-conduct.md + +[license]: license + +[author]: https://wooorm.com + +[rehype]: https://github.com/rehypejs/rehype + +[xss]: https://en.wikipedia.org/wiki/Cross-site_scripting + +[sanitize]: https://github.com/rehypejs/rehype-sanitize + +[mdn-rel]: https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types + +[mdn-a]: https://developer.mozilla.org/en/docs/Web/HTML/Element/a + +[hast]: https://github.com/syntax-tree/hast + +[properties]: https://github.com/syntax-tree/hast#properties + +[node]: https://github.com/syntax-tree/hast#nodes + +[children]: https://github.com/syntax-tree/unist#child + +[g201]: https://www.w3.org/WAI/WCAG21/Techniques/general/G201 diff --git a/test.js b/test.js new file mode 100644 index 0000000..2a6ea0a --- /dev/null +++ b/test.js @@ -0,0 +1,199 @@ +import test from 'tape' +import {rehype} from 'rehype' +import rehypeExternalLinks from './index.js' + +test('rehypeExternalLinks', async (t) => { + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('relative') + ), + 'relative', + 'should not change a relative link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('fragment') + ), + 'fragment', + 'should not change a fragment link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('search') + ), + 'search', + 'should not change a search link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('mailto') + ), + 'mailto', + 'should not change a mailto link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('http') + ), + 'http', + 'should change a http link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('https') + ), + 'https', + 'should change a https link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks) + .process('www') + ), + 'www', + 'should not change a www link' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, {target: false}) + .process('http') + ), + 'http', + 'should not add a `[target]` w/ `target: false`' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, {rel: false}) + .process('http') + ), + 'http', + 'should not add a `[rel]` w/ `rel: false`' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, {target: '_parent', rel: false}) + .process('http') + ), + 'http', + 'should not add a `[target]` w/ `target` set to a known target' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, {target: false, rel: 'nofollow'}) + .process('http') + ), + 'http', + 'should not add a `[rel]` w/ `rel` set to a string' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, {target: false, rel: ['nofollow']}) + .process('http') + ), + 'http', + 'should not add a `[rel]` w/ `rel` set to an array' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, {protocols: ['mailto']}) + .process('mailto') + ), + 'mailto', + 'should support `mailto` protocols w/ `mailto` in `protocols`' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, { + content: {type: 'text', value: ' (opens in a new window)'} + }) + .process('http') + ), + 'http (opens in a new window)', + 'should add content at the end of the link w/ `content` as a single child' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, { + content: [ + {type: 'text', value: ' ('}, + { + type: 'element', + tagName: 'em', + properties: {}, + children: [{type: 'text', value: 'opens in a new window'}] + }, + {type: 'text', value: ')'} + ] + }) + .process('http') + ), + 'http (opens in a new window)', + 'should add content at the end of the link w/ `content` as an array of children' + ) + + t.equal( + String( + await rehype() + .use({settings: {fragment: true}}) + .use(rehypeExternalLinks, { + contentProperties: {className: ['alpha', 'bravo']}, + content: {type: 'text', value: ' (opens in a new window)'} + }) + .process('http') + ), + 'http (opens in a new window)', + 'should add properties to the span at the end of the link w/ `contentProperties`' + ) + + t.end() +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e31adf8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["*.js"], + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "strict": true + } +}