Merge pull request #571 from Tyriar/570_link_validation

Implement link validation
This commit is contained in:
Daniel Imms
2017-03-01 09:12:53 -08:00
committed by GitHub
12 changed files with 256 additions and 135 deletions
+2 -8
View File
@@ -6,7 +6,6 @@ const gulp = require('gulp');
const istanbul = require('gulp-istanbul');
const merge = require('merge-stream');
const mocha = require('gulp-mocha');
const mochaPhantomJs = require('gulp-mocha-phantomjs');
const sorcery = require('sorcery');
const source = require('vinyl-source-stream');
const sourcemaps = require('gulp-sourcemaps');
@@ -80,17 +79,12 @@ gulp.task('instrument-test', function () {
.pipe(istanbul.hookRequire());
});
gulp.task('test-mocha', ['instrument-test'], function () {
gulp.task('mocha', ['instrument-test'], function () {
return gulp.src(['lib/*test.js', 'lib/**/*test.js'], {read: false})
.pipe(mocha())
.pipe(istanbul.writeReports());
});
gulp.task('test-mocha-phantomjs', function () {
return gulp.src('test-harness.html')
.pipe(mochaPhantomJs());
});
/**
* Use `sorcery` to resolve the source map chain and point back to the TypeScript files.
* (Without this task the source maps produced for the JavaScript bundle points into the
@@ -111,5 +105,5 @@ gulp.task('coveralls', function () {
});
gulp.task('build', ['sorcery']);
gulp.task('test', ['test-mocha', 'test-mocha-phantomjs']);
gulp.task('test', ['mocha']);
gulp.task('default', ['build']);
+3 -2
View File
@@ -36,6 +36,7 @@
],
"devDependencies": {
"@types/chai": "^3.4.34",
"@types/jsdom": "^2.0.30",
"@types/mocha": "^2.2.33",
"@types/node": "^6.0.41",
"browserify": "^13.1.0",
@@ -50,17 +51,17 @@
"gulp-coveralls": "^0.1.4",
"gulp-istanbul": "^1.1.1",
"gulp-mocha": "^3.0.1",
"gulp-mocha-phantomjs": "^0.12.0",
"gulp-sourcemaps": "1.9.1",
"gulp-typescript": "^3.1.3",
"jsdoc": "3.4.3",
"jsdom": "^9.11.0",
"merge-stream": "^1.0.1",
"node-pty": "^0.4.1",
"nodemon": "1.10.2",
"sleep": "^3.0.1",
"sorcery": "^0.10.0",
"tslint": "^4.0.2",
"typescript": "~2.1.0",
"typescript": "~2.2.0",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0"
},
+23
View File
@@ -2,6 +2,9 @@
* @license MIT
*/
import { LinkMatcherOptions } from './Interfaces';
import { LinkMatcherHandler, LinkMatcherValidationCallback } from './Types';
export interface IBrowser {
isNode: boolean;
userAgent: string;
@@ -50,6 +53,13 @@ export interface ICharMeasure {
measure(): void;
}
export interface ILinkifier {
linkifyRow(rowIndex: number): void;
attachHypertextLinkHandler(handler: LinkMatcherHandler): void;
registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: LinkMatcherOptions): number;
deregisterLinkMatcher(matcherId: number): boolean;
}
interface ICircularList<T> {
length: number;
maxLength: number;
@@ -64,6 +74,19 @@ interface ICircularList<T> {
shiftElements(start: number, count: number, offset: number): void;
}
export interface LinkMatcherOptions {
/**
* The index of the link from the regex.match(text) call. This defaults to 0
* (for regular expressions without capture groups).
*/
matchIndex?: number;
/**
* A callback that validates an individual link, returning true if valid and
* false if invalid.
*/
validationCallback?: LinkMatcherValidationCallback;
}
/**
* Handles actions generated by the parser.
*/
+76
View File
@@ -0,0 +1,76 @@
/**
* @license MIT
*/
import jsdom = require('jsdom');
import { assert } from 'chai';
import { ITerminal, ILinkifier } from './Interfaces';
import { Linkifier } from './Linkifier';
class TestLinkifier extends Linkifier {
constructor(document: Document, rows: HTMLElement[]) {
Linkifier.TIME_BEFORE_LINKIFY = 0;
super(document, rows);
}
}
describe('Linkifier', () => {
let window: Window;
let document: Document;
let container: HTMLElement;
let rows: HTMLElement[];
let linkifier: ILinkifier;
beforeEach(done => {
rows = [];
jsdom.env('', (err, w) => {
window = w;
document = window.document;
linkifier = new Linkifier(document, rows);
container = document.createElement('div');
document.body.appendChild(container);
done();
});
});
function addRow(text: string) {
const element = document.createElement('div');
element.textContent = text;
container.appendChild(element);
rows.push(element);
}
function clickElement(element: Node) {
const event = document.createEvent('MouseEvent');
event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
element.dispatchEvent(event);
}
describe('validationCallback', () => {
it('should enable link if true', done => {
addRow('test');
linkifier.registerLinkMatcher(/test/, () => done(), {
validationCallback: (url, cb) => {
cb(true);
assert.equal((<HTMLElement>rows[0].firstChild).tagName, 'A');
setTimeout(() => clickElement(rows[0].firstChild), 0);
}
});
linkifier.linkifyRow(0);
});
it('should disable link if false', done => {
addRow('test');
linkifier.registerLinkMatcher(/test/, () => assert.fail(), {
validationCallback: (url, cb) => {
cb(false);
assert.equal((<HTMLElement>rows[0].firstChild).tagName, 'A');
setTimeout(() => clickElement(rows[0].firstChild), 0);
}
});
linkifier.linkifyRow(0);
// Allow time for the click to be performed
setTimeout(() => done(), 10);
});
});
});
+55 -27
View File
@@ -2,9 +2,18 @@
* @license MIT
*/
export type LinkHandler = (uri: string) => void;
import { LinkMatcherOptions } from './Interfaces';
import { LinkMatcherHandler, LinkMatcherValidationCallback } from './Types';
type LinkMatcher = {id: number, regex: RegExp, matchIndex?: number, handler: LinkHandler};
type LinkMatcher = {
id: number,
regex: RegExp,
handler: LinkMatcherHandler,
matchIndex?: number,
validationCallback?: LinkMatcherValidationCallback;
};
const INVALID_LINK_CLASS = 'xterm-invalid-link';
const protocolClause = '(https?:\\/\\/)';
const domainCharacterSet = '[\\da-z\\.-]+';
@@ -27,23 +36,25 @@ const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
*/
const HYPERTEXT_LINK_MATCHER_ID = 0;
/**
* The time to wait after a row is changed before it is linkified. This prevents
* the costly operation of searching every row multiple times, pntentially a
* huge aount of times.
*/
const TIME_BEFORE_LINKIFY = 200;
/**
* The Linkifier applies links to rows shortly after they have been refreshed.
*/
export class Linkifier {
/**
* The time to wait after a row is changed before it is linkified. This prevents
* the costly operation of searching every row multiple times, pntentially a
* huge aount of times.
*/
protected static TIME_BEFORE_LINKIFY = 200;
private _document: Document;
private _rows: HTMLElement[];
private _rowTimeoutIds: number[];
private _linkMatchers: LinkMatcher[];
private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
constructor(rows: HTMLElement[]) {
constructor(document: Document, rows: HTMLElement[]) {
this._document = document;
this._rows = rows;
this._rowTimeoutIds = [];
this._linkMatchers = [];
@@ -59,7 +70,7 @@ export class Linkifier {
if (timeoutId) {
clearTimeout(timeoutId);
}
this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY);
this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), Linkifier.TIME_BEFORE_LINKIFY);
}
/**
@@ -68,7 +79,7 @@ export class Linkifier {
* @param {LinkHandler} handler The handler to use, this can be cleared with
* null.
*/
public attachHypertextLinkHandler(handler: LinkHandler): void {
public attachHypertextLinkHandler(handler: LinkMatcherHandler): void {
this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
}
@@ -79,11 +90,10 @@ export class Linkifier {
* this searches the textContent of the rows. You will want to use \s to match
* a space ' ' character for example.
* @param {LinkHandler} handler The callback when the link is called.
* @param {number} matchIndex The index of the link from the regex.match(text)
* call. This defaults to 0 (for regular expressions without capture groups).
* @param {LinkMatcherOptions} [options] Options for the link matcher.
* @return {number} The ID of the new matcher, this can be used to deregister.
*/
public registerLinkMatcher(regex: RegExp, handler: LinkHandler, matchIndex?: number): number {
public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: LinkMatcherOptions = {}): number {
if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
throw new Error('handler cannot be falsy');
}
@@ -91,7 +101,8 @@ export class Linkifier {
id: this._nextLinkMatcherId++,
regex,
handler,
matchIndex
matchIndex: options.matchIndex,
validationCallback: options.validationCallback
};
this._linkMatchers.push(matcher);
return matcher.id;
@@ -123,11 +134,20 @@ export class Linkifier {
return;
}
const text = row.textContent;
// TODO: Onl execute handler if isValid
for (let i = 0; i < this._linkMatchers.length; i++) {
const matcher = this._linkMatchers[i];
const uri = this._findLinkMatch(text, matcher.regex, matcher.matchIndex);
if (uri) {
this._doLinkifyRow(rowIndex, uri, matcher.handler);
const linkElement = this._doLinkifyRow(rowIndex, uri, matcher.handler);
// Fire validation callback
if (matcher.validationCallback) {
matcher.validationCallback(uri, isValid => {
if (!isValid) {
linkElement.classList.add(INVALID_LINK_CLASS);
}
});
}
// Only allow a single LinkMatcher to trigger on any given row.
return;
}
@@ -139,8 +159,9 @@ export class Linkifier {
* @param {number} rowIndex The index of the row to linkify.
* @param {string} uri The uri that has been found.
* @param {handler} handler The handler to trigger when the link is triggered.
* @return The link element if it was added, otherwise undefined.
*/
private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void {
private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkMatcherHandler): HTMLElement {
// Iterate over nodes as we want to consider text nodes
const nodes = this._rows[rowIndex].childNodes;
for (let i = 0; i < nodes.length; i++) {
@@ -150,7 +171,8 @@ export class Linkifier {
const linkElement = this._createAnchorElement(uri, handler);
if (node.textContent.length === uri.length) {
// Matches entire string
if (node.nodeType === Node.TEXT_NODE) {
if (node.nodeType === 3 /*Node.TEXT_NODE*/) {
this._replaceNode(node, linkElement);
} else {
const element = (<HTMLElement>node);
@@ -165,6 +187,7 @@ export class Linkifier {
// Matches part of string
this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
}
return linkElement;
}
}
}
@@ -188,11 +211,16 @@ export class Linkifier {
* @param {string} uri The uri of the link.
* @return {HTMLAnchorElement} The link.
*/
private _createAnchorElement(uri: string, handler: LinkHandler): HTMLAnchorElement {
const element = document.createElement('a');
private _createAnchorElement(uri: string, handler: LinkMatcherHandler): HTMLAnchorElement {
const element = this._document.createElement('a');
element.textContent = uri;
if (handler) {
element.addEventListener('click', () => handler(uri));
element.addEventListener('click', () => {
// Only execute the handler if the link is not flagged as invalid
if (!element.classList.contains(INVALID_LINK_CLASS)) {
handler(uri);
}
});
} else {
element.href = uri;
// Force link on another tab so work is not lost
@@ -224,7 +252,7 @@ export class Linkifier {
*/
private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void {
let node = targetNode;
if (node.nodeType !== Node.TEXT_NODE) {
if (node.nodeType !== 3/*Node.TEXT_NODE*/) {
node = node.childNodes[0];
}
@@ -240,19 +268,19 @@ export class Linkifier {
if (substringIndex === 0) {
// Replace with <newNode><textnode>
const rightText = fullText.substring(substring.length);
const rightTextNode = document.createTextNode(rightText);
const rightTextNode = this._document.createTextNode(rightText);
this._replaceNode(node, newNode, rightTextNode);
} else if (substringIndex === targetNode.textContent.length - substring.length) {
// Replace with <textnode><newNode>
const leftText = fullText.substring(0, substringIndex);
const leftTextNode = document.createTextNode(leftText);
const leftTextNode = this._document.createTextNode(leftText);
this._replaceNode(node, leftTextNode, newNode);
} else {
// Replace with <textnode><newNode><textnode>
const leftText = fullText.substring(0, substringIndex);
const leftTextNode = document.createTextNode(leftText);
const leftTextNode = this._document.createTextNode(leftText);
const rightText = fullText.substring(substringIndex + substring.length);
const rightTextNode = document.createTextNode(rightText);
const rightTextNode = this._document.createTextNode(rightText);
this._replaceNode(node, leftTextNode, newNode, rightTextNode);
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @license MIT
*/
export type LinkMatcherHandler = (uri: string) => void;
export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void;
-65
View File
@@ -1,65 +0,0 @@
/**
* @license MIT
*/
import { ICharMeasure, ITerminal } from '../Interfaces';
declare var assert: Chai.Assert;
declare var Terminal: ITerminal;
// Do not describe tests unless in PhantomJS environment
if (typeof Terminal !== 'undefined') {
const CharMeasure = (<any>Terminal).CharMeasure;
describe('CharMeasure', () => {
const parentElement = document.createElement('div');
let charMeasure: ICharMeasure;
beforeEach(() => {
charMeasure = new CharMeasure(parentElement);
document.querySelector('#xterm').appendChild(parentElement);
});
afterEach(() => {
if (parentElement && parentElement.parentElement) {
parentElement.parentElement.removeChild(parentElement);
}
});
describe('measure', () => {
it('should be performed async on first call', done => {
assert.equal(charMeasure.width, null);
charMeasure.measure();
assert.equal(charMeasure.width, null);
setTimeout(() => {
assert.isTrue(charMeasure.width > 0);
done();
}, 0);
});
it('should be performed sync on successive calls', done => {
charMeasure.measure();
setTimeout(() => {
const firstWidth = charMeasure.width;
parentElement.style.fontSize = '2em';
charMeasure.measure();
assert.equal(charMeasure.width, firstWidth * 2);
done();
}, 0);
});
it('should NOT do a measure when the parent is hidden', done => {
charMeasure.measure();
setTimeout(() => {
const firstWidth = charMeasure.width;
parentElement.style.display = 'none';
parentElement.style.fontSize = '2em';
charMeasure.measure();
assert.equal(charMeasure.width, firstWidth);
done();
}, 0);
});
});
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* @license MIT
*/
import jsdom = require('jsdom');
import { assert } from 'chai';
import { ICharMeasure, ITerminal } from '../Interfaces';
import { CharMeasure } from './CharMeasure';
describe('CharMeasure', () => {
let window: Window;
let document: Document;
let container: HTMLElement;
let charMeasure: ICharMeasure;
beforeEach(done => {
jsdom.env('', (err, w) => {
window = w;
document = window.document;
container = document.createElement('div');
document.body.appendChild(container);
charMeasure = new CharMeasure(document, container);
done();
});
});
describe('measure', () => {
it('should set _measureElement on first call', () => {
charMeasure.measure();
assert.isDefined((<any>charMeasure)._measureElement, 'CharMeasure.measure should have created _measureElement');
});
it('should be performed async on first call', done => {
assert.equal(charMeasure.width, null);
charMeasure.measure();
// Mock getBoundingClientRect since jsdom doesn't have a layout engine
(<any>charMeasure)._measureElement.getBoundingClientRect = () => {
return { width: 1, height: 1 };
};
assert.equal(charMeasure.width, null);
setTimeout(() => {
assert.equal(charMeasure.width, 1);
done();
}, 0);
});
it('should be performed sync on successive calls', done => {
charMeasure.measure();
// Mock getBoundingClientRect since jsdom doesn't have a layout engine
(<any>charMeasure)._measureElement.getBoundingClientRect = () => {
return { width: 1, height: 1 };
};
setTimeout(() => {
const firstWidth = charMeasure.width;
// Mock getBoundingClientRect since jsdom doesn't have a layout engine
(<any>charMeasure)._measureElement.getBoundingClientRect = () => {
return { width: 2, height: 2 };
};
charMeasure.measure();
assert.equal(charMeasure.width, firstWidth * 2);
done();
}, 0);
});
it('should NOT do a measure when the parent is hidden', done => {
charMeasure.measure();
setTimeout(() => {
const firstWidth = charMeasure.width;
container.style.display = 'none';
container.style.fontSize = '2em';
charMeasure.measure();
assert.equal(charMeasure.width, firstWidth);
done();
}, 0);
});
});
});
+4 -2
View File
@@ -9,13 +9,15 @@ import { EventEmitter } from '../EventEmitter.js';
* Utility class that measures the size of a character.
*/
export class CharMeasure extends EventEmitter {
private _document: Document;
private _parentElement: HTMLElement;
private _measureElement: HTMLElement;
private _width: number;
private _height: number;
constructor(parentElement: HTMLElement) {
constructor(document: Document, parentElement: HTMLElement) {
super();
this._document = document;
this._parentElement = parentElement;
}
@@ -29,7 +31,7 @@ export class CharMeasure extends EventEmitter {
public measure(): void {
if (!this._measureElement) {
this._measureElement = document.createElement('span');
this._measureElement = this._document.createElement('span');
this._measureElement.style.position = 'absolute';
this._measureElement.style.top = '0';
this._measureElement.style.left = '-9999em';
+5
View File
@@ -81,6 +81,11 @@
text-decoration: underline;
}
.terminal a.xterm-invalid-link:hover {
cursor: default;
text-decoration: none;
}
.terminal:not(.xterm-cursor-style-underline):not(.xterm-cursor-style-bar) .terminal-cursor {
background-color: #fff;
color: #000;
+5 -9
View File
@@ -612,7 +612,7 @@ Terminal.prototype.open = function(parent) {
this.rowContainer.classList.add('xterm-rows');
this.element.appendChild(this.rowContainer);
this.children = [];
this.linkifier = new Linkifier(this.children);
this.linkifier = new Linkifier(document, this.children);
// Create the container that will hold helpers like the textarea for
// capturing DOM Events. Then produce the helpers.
@@ -647,7 +647,7 @@ Terminal.prototype.open = function(parent) {
}
this.parent.appendChild(this.element);
this.charMeasure = new CharMeasure(this.helperContainer);
this.charMeasure = new CharMeasure(document, this.helperContainer);
this.charMeasure.on('charsizechanged', function () {
self.updateCharSizeCSS();
});
@@ -1303,13 +1303,12 @@ Terminal.prototype.attachHypertextLinkHandler = function(handler) {
* this searches the textContent of the rows. You will want to use \s to match
* a space ' ' character for example.
* @param {LinkHandler} handler The callback when the link is called.
* @param {number} matchIndex The index of the link from the regex.match(text)
* call. This defaults to 0 (for regular expressions without capture groups).
* @param {LinkMatcherOptions} [options] Options for the link matcher.
* @return {number} The ID of the new matcher, this can be used to deregister.
*/
Terminal.prototype.registerLinkMatcher = function(regex, handler, matchIndex) {
Terminal.prototype.registerLinkMatcher = function(regex, handler, options) {
if (this.linkifier) {
var matcherId = this.linkifier.registerLinkMatcher(regex, handler, matchIndex);
var matcherId = this.linkifier.registerLinkMatcher(regex, handler, options);
this.refresh(0, this.rows - 1);
return matcherId;
}
@@ -2301,9 +2300,6 @@ function keys(obj) {
Terminal.EventEmitter = EventEmitter;
Terminal.inherits = inherits;
// Expose for Phantom.JS tests
Terminal.CharMeasure = CharMeasure;
/**
* Adds an event listener to the terminal.
*
-22
View File
@@ -1,22 +0,0 @@
<html>
<head>
<meta charset="utf-8">
<!-- encoding must be set for mocha's special characters to render properly -->
<link rel="stylesheet" href="node_modules/mocha/mocha.css" />
</head>
<body>
<div id="mocha"></div>
<div id="xterm"></div>
<script src="node_modules/mocha/mocha.js"></script>
<script src="node_modules/chai/chai.js"></script>
<script>
mocha.ui('bdd')
assert = chai.assert
</script>
<script src="build/xterm.js"></script>
<script src="lib/utils/CharMeasure.phantom.js"></script>
<script>
mocha.run()
</script>
</body>
</html>