diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..c2e9737 --- /dev/null +++ b/.babelrc @@ -0,0 +1,10 @@ +{ + "env": { + "build": { + "presets": [ "es2015-rollup" ] + }, + "test": { + "presets": [ "es2015" ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59a3c3b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{js,json}] +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[{package.json}] +indent_style = space +indent_size = 2 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e1b2870 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +node_modules/ +coverage/ +.nyc_output/ +dist/ diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..cb6b313 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "holidaycheck/es2015" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e40ad70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/node_modules +*.log +coverage/ +.nyc_output/ +dist/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..41acdaf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +sudo: false +node_js: + - "6" + - "7" +notifications: + email: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e65846f --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2016 HolidayCheck + +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/README.md b/README.md new file mode 100644 index 0000000..38faf8b --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +[![NPM Version](https://img.shields.io/npm/v/delta-detect-links.svg?style=flat)](https://www.npmjs.org/package/delta-detect-links) +[![Build Status](https://img.shields.io/travis/holidaycheck/delta-detect-links/master.svg?style=flat)](https://travis-ci.org/holidaycheck/delta-detect-links) +[![Dependencies](http://img.shields.io/david/holidaycheck/delta-detect-links.svg?style=flat)](https://david-dm.org/holidaycheck/delta-detect-links) + +# delta-detect-links + +> Detect links in a delta object. + +This small library is based on [`linkify-it`](https://github.com/markdown-it/linkify-it) to detect links in a rich text [`Delta`](https://github.com/ottypes/rich-text) object. +The main purpose for this library is to automatically detect links in [Quill](http://quilljs.com/) documents. + +## API + +### `detectLinks` + +This function detects all links in the given `insert`-only delta object. + +```js +const detectLinks = require('delta-detect-links'); + +detectLinks((new Delta()).insert('Visit www.example.com\n')); +// → [ { insert: 'Visit ' }, { insert: 'www.example.com', attributes: { link: 'http://www.example.com' } }, { insert: '\n' } ] +``` + +Already existing links in the given delta object will be skipped and not highlighted a second time. + +#### Options + +`detectLinks` supports an optional second parameter to set the following options: + +* `skipTrailingMatch` (default: `false`): This is useful when you want to detect links in real-time while the user types. In order to prevent detection of partial links the link will only be detected after another stop word has been encountered. +* `lastIndex`: This option is only used in combination with `skipTrailingMatch`. This value is used to determine the trailing match. It is useful in scenarios where the current cursor is not at the same position as the end of the document. + +### `detectLinksDelta` + +Similar to `detectLinks` but only contains the changes delta object. + +```js +const detectLinksDelta = require('delta-detect-links').detectLinksDelta; + +detectLinksDelta((new Delta()).insert('Visit www.example.com\n')); +// → [ { retain: 6 }, { retain: 15, attributes: { link: 'http://www.example.com' } } ] +``` diff --git a/lib/detectLinks.js b/lib/detectLinks.js new file mode 100644 index 0000000..e0e266c --- /dev/null +++ b/lib/detectLinks.js @@ -0,0 +1,64 @@ +import linkifyIt from 'linkify-it'; +import tlds from 'tlds'; +import { Delta } from 'rich-text'; + +const punctuator = '!'; + +function getOperationText(op) { + if (typeof op.insert === 'string') { + if (op.attributes && op.attributes.link) { + return (new Array(op.insert.length + 1)).join(punctuator); + } + return op.insert; + } + return punctuator; +} + +const linkify = linkifyIt() + .tlds(tlds) + .add('ftp:', null) + .add('mailto:', null) + .set({ fuzzyLink: true }) + .set({ fuzzyIP: false }) + .set({ fuzzyEmail: false }); + +function isTrailingLinkMatch(link, lastIndex, text) { + const lastCharacter = text[lastIndex - 1]; + + if (lastCharacter === '.') { + return link.lastIndex === lastIndex - 1; + } + + return link.lastIndex === lastIndex; +} +function detectLinksDelta(delta, options = {}) { + const text = delta.ops.reduce((result, operation) => result + getOperationText(operation), ''); + const matchedLinks = linkify.match(text) || []; + let currentIndex = 0; + const links = new Delta(); + + matchedLinks.forEach(function (link) { + const numberOfCharactersToLinkStart = link.index - currentIndex; + const linkTextLength = link.lastIndex - link.index; + + links.retain(numberOfCharactersToLinkStart); + + if (options.skipTrailingMatch && isTrailingLinkMatch(link, options.lastIndex, text)) { + links.retain(linkTextLength); + } else { + links.retain(linkTextLength, { link: link.url }); + } + + currentIndex = link.lastIndex; + }); + + return links; +} + +export default function detectLinks(delta, options = {}) { + const links = detectLinksDelta(delta, options); + + return delta.compose(links); +} + +detectLinks.detectLinksDelta = detectLinksDelta; diff --git a/package.json b/package.json new file mode 100644 index 0000000..945965d --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "delta-detect-links", + "version": "1.0.0", + "description": "Detect links in a delta object.", + "main": "./dist/detectLinks.cjs.js", + "scripts": { + "build": "BABEL_ENV=build rollup -c rollup.config.js", + "test:unit": "BABEL_ENV=test ava 'test/**/*Spec.js'", + "lint": "eslint .", + "pretest": "npm run lint", + "test": "nyc npm run test:unit", + "prepublish": "npm run build" + }, + "author": "Mathias Schreck ", + "license": "MIT", + "files": [ "README.md", "LICENSE", "dist/" ], + "keywords": [ + "delta", + "rich-text", + "quill", + "detect", + "link" + ], + "devDependencies": { + "ava": "0.16.0", + "babel-preset-es2015": "6.18.0", + "babel-preset-es2015-rollup": "1.2.0", + "babel-register": "6.18.0", + "eslint": "3.9.1", + "eslint-config-holidaycheck": "0.10.0", + "nyc": "8.4.0", + "rollup": "0.36.3", + "rollup-plugin-babel": "2.6.1", + "rollup-plugin-commonjs": "5.0.5", + "rollup-plugin-node-resolve": "2.0.0" + }, + "ava": { + "require": "babel-register", + "babel": { + "babelrc": true + } + }, + "nyc": { + "lines": 100, + "statements": 100, + "functions": 100, + "branches": 100, + "include": [ + "lib/**/*.js" + ], + "reporter": [ + "lcov", + "text-summary" + ], + "cache": true, + "all": true, + "check-coverage": true + }, + "dependencies": { + "linkify-it": "2.0.2", + "rich-text": "3.1.0", + "tlds": "1.169.0" + }, + "repository": { + "type": "git", + "url": "git://github.com/holidaycheck/delta-detect-links.git" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..b7be331 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,20 @@ +/* eslint-env node */ + +import babel from 'rollup-plugin-babel'; +import nodeResolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; + +const plugins = [ + nodeResolve({ jsnext: true, main: true, browser: true, extensions: [ '.js', '.json' ] }), + commonjs({ namedExports: { 'node_modules/rich-text/index.js': [ 'Delta' ] } }), + babel() +]; + +module.exports = { + entry: 'lib/detectLinks.js', + plugins, + targets: [ + { dest: 'dist/detectLinks.cjs.js', format: 'cjs' }, + { dest: 'dist/detectLinks.amd.js', format: 'amd', moduleId: 'delta-detect-links' } + ] +}; diff --git a/test/detectLinksSpec.js b/test/detectLinksSpec.js new file mode 100644 index 0000000..ab9bfd1 --- /dev/null +++ b/test/detectLinksSpec.js @@ -0,0 +1,158 @@ +import test from 'ava'; +import { Delta } from 'rich-text'; +import detectLinks from '../lib/detectLinks'; + +function detectMacro(t, input, expected) { + const inputDelta = new Delta(input); + const composedDelta = detectLinks(inputDelta); + + t.deepEqual(composedDelta.ops, expected); +} + +function detectWithSkipOptionMacro(t, input, lastIndex, expected) { + const inputDelta = new Delta(input); + const composedDelta = detectLinks(inputDelta, { skipTrailingMatch: true, lastIndex }); + + t.deepEqual(composedDelta.ops, expected); +} + +function detectDeltaMacro(t, input, expected) { + const inputDelta = new Delta(input); + const composedDelta = detectLinks.detectLinksDelta(inputDelta); + + t.deepEqual(composedDelta.ops, expected); +} + +test( + 'auto-detect link without protocol', + detectMacro, + [ { insert: 'foo example.com bar' } ], + [ + { insert: 'foo ' }, + { insert: 'example.com', attributes: { link: 'http://example.com' } }, + { insert: ' bar' } + ] +); + +test( + 'auto-detect link with protocol', + detectMacro, + [ { insert: 'foo https://example.com bar' } ], + [ + { insert: 'foo ' }, + { insert: 'https://example.com', attributes: { link: 'https://example.com' } }, + { insert: ' bar' } + ] +); + +test( + 'auto-detect links while keeping other attributes', + detectMacro, + [ + { insert: 'foo ex' }, + { insert: 'ample', attributes: { bold: true } }, + { insert: '.com bar' } + ], + [ + { insert: 'foo ' }, + { insert: 'ex', attributes: { link: 'http://example.com' } }, + { insert: 'ample', attributes: { bold: true, link: 'http://example.com' } }, + { insert: '.com', attributes: { link: 'http://example.com' } }, + { insert: ' bar' } + ] +); + +test( + 'keeps already formatted links', + detectMacro, + [ + { insert: 'foo ' }, + { insert: 'example.com', attributes: { link: 'https://example.com/42' } } + ], + [ + { insert: 'foo ' }, + { insert: 'example.com', attributes: { link: 'https://example.com/42' } } + ] +); + +test( + 'auto-detects links alongside existing formatted links', + detectMacro, + [ + { insert: 'foo ' }, + { insert: 'example.com', attributes: { link: 'https://example.com/42' } }, + { insert: 'bar www.google.com' } + ], + [ + { insert: 'foo ' }, + { insert: 'example.com', attributes: { link: 'https://example.com/42' } }, + { insert: 'bar ' }, + { insert: 'www.google.com', attributes: { link: 'http://www.google.com' } } + ] +); + +test( + 'auto-detects links correctly when they appear after embeds', + detectMacro, + [ + { insert: 'foo ' }, + { insert: { image: 'picture.jpg' } }, + { insert: 'bar example.com' } + ], + [ + { insert: 'foo ' }, + { insert: { image: 'picture.jpg' } }, + { insert: 'bar ' }, + { insert: 'example.com', attributes: { link: 'http://example.com' } } + ] +); + +test( + 'skips matched links at the end of the document when the appropriate option is set', + detectWithSkipOptionMacro, + [ + { insert: 'foo www.example.co' } + ], + 18, + [ + { insert: 'foo www.example.co' } + ] +); + +test( + 'detects non trailing links when skip option is enabled', + detectWithSkipOptionMacro, + [ + { insert: 'www.example.com foo www.example.co' } + ], + 34, + [ + { insert: 'www.example.com', attributes: { link: 'http://www.example.com' } }, + { insert: ' foo www.example.co' } + ] +); + +test( + 'skips trailing matched link if the stopword at the end is a dot', + detectWithSkipOptionMacro, + [ + // Note: google is a valid TLD + { insert: 'foo www.google.' } + ], + 15, + [ + { insert: 'foo www.google.' } + ] +); + +test( + 'contains a delta with the changes only', + detectDeltaMacro, + [ + { insert: 'foo www.example.com' } + ], + [ + { retain: 4 }, + { retain: 15, attributes: { link: 'http://www.example.com' } } + ] +);