Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand All @@ -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']);
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
23 changes: 23 additions & 0 deletions src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
* @license MIT
*/

import { LinkMatcherOptions } from './Interfaces';
import { LinkMatcherHandler, LinkMatcherValidationCallback } from './Types';

export interface IBrowser {
isNode: boolean;
userAgent: string;
Expand Down Expand Up @@ -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;
Expand All @@ -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.
*/
Expand Down
76 changes: 76 additions & 0 deletions src/Linkifier.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
82 changes: 55 additions & 27 deletions src/Linkifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\\.-]+';
Expand All @@ -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 = [];
Expand All @@ -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);
}

/**
Expand All @@ -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;
}

Expand All @@ -79,19 +90,19 @@ 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');
}
const matcher: LinkMatcher = {
id: this._nextLinkMatcherId++,
regex,
handler,
matchIndex
matchIndex: options.matchIndex,
validationCallback: options.validationCallback
};
this._linkMatchers.push(matcher);
return matcher.id;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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++) {
Expand All @@ -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);
Expand All @@ -165,6 +187,7 @@ export class Linkifier {
// Matches part of string
this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
}
return linkElement;
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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];
}

Expand All @@ -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);
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/Types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @license MIT
*/

export type LinkMatcherHandler = (uri: string) => void;
export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void;
Loading