From bc7b927ba3be9eb68a92d38d0ea7f458c388e31a Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Mon, 16 Aug 2021 14:32:42 +0200 Subject: [PATCH] . --- .editorconfig | 9 ++ .github/workflows/bb.yml | 13 +++ .github/workflows/main.yml | 21 ++++ .gitignore | 6 ++ .npmrc | 1 + .prettierignore | 2 + index.js | 85 ++++++++++++++++ license | 22 ++++ package.json | 86 ++++++++++++++++ readme.md | 182 +++++++++++++++++++++++++++++++++ test.js | 199 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 16 +++ 12 files changed, 642 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/bb.yml create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 index.js create mode 100644 license create mode 100644 package.json create mode 100644 readme.md create mode 100644 test.js create mode 100644 tsconfig.json 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 + } +}