// ==UserScript==
// @name Linkify Plus Plus
// @version 8.2.0
// @description Based on Linkify Plus. Turn plain text URLs into links.
// @license BSD-3-Clause
// @homepageURL https://github.com/eight04/linkify-plus-plus
// @supportURL https://github.com/eight04/linkify-plus-plus/issues
// @namespace eight04.blogspot.com
// @include *
// @exclude https://www.google.*/search*
// @exclude https://www.google.*/webhp*
// @exclude https://music.google.com/*
// @exclude https://mail.google.com/*
// @exclude https://docs.google.com/*
// @exclude https://encrypted.google.com/*
// @exclude http://mxr.mozilla.org/*
// @exclude http://w3c*.github.io/*
// @require https://gf.qytechs.cn/scripts/7212-gm-config-eight-s-version/code/GM_config%20(eight's%20version).js?version=156587
// @require https://gf.qytechs.cn/scripts/27630-linkify-plus-plus-core/code/linkify-plus-plus-core.js?version=213494
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @compatible firefox Tampermonkey latest
// @compatible chrome Tampermonkey latest
// ==/UserScript==
/* globals GM_config */
(function(){
// Limit contentType to "text/plain" or "text/html"
if (document.contentType != undefined && document.contentType != "text/plain" && document.contentType != "text/html") {
return;
}
var {linkify, UrlMatcher, INVALID_TAGS} = window.linkifyPlusPlusCore;
const BUFFER_SIZE = 100;
// Valid root node before linkifing
function validRoot(node, validator) {
// Cache valid state in node.VALID
if (node.VALID !== undefined) {
return node.VALID;
}
// Loop through ancestor
var cache = [], isValid;
while (node != document.documentElement) {
cache.push(node);
// It is invalid if it has invalid ancestor
if (!validator(node) || INVALID_TAGS[node.nodeName]) {
isValid = false;
break;
}
// The node was removed from DOM tree
if (!node.parentNode) {
return false;
}
node = node.parentNode;
if (node.VALID !== undefined) {
isValid = node.VALID;
break;
}
}
// All ancestors are fine
if (isValid === undefined) {
isValid = true;
}
// Cache the result
var i;
for (i = 0; i < cache.length; i++) {
cache[i].VALID = isValid;
}
return isValid;
}
function createValidator({selector, skipSelector}) {
return function(node) {
if (node.isContentEditable) {
return false;
}
if (selector && node.matches && node.matches(selector)) {
return true;
}
if (skipSelector && node.matches && node.matches(skipSelector)) {
return false;
}
return true;
};
}
function selectorTest(selector) {
try {
document.documentElement.matches(selector);
} catch (err) {
alert(`Invalid selector: ${selector}`);
return false;
}
return true;
}
function createList(text) {
text = text.trim();
if (!text) {
return null;
}
return text.split("\n");
}
function createBuffer(size) {
const set = new Set;
const buff = Array(size);
const eventBus = document.createElement("span");
let start = 0;
let end = 0;
return {push, eventBus, shift};
function push(item) {
if (set.has(item)) {
return;
}
if (set.size && start === end) {
// overflow
eventBus.dispatchEvent(new CustomEvent("overflow"));
set.clear();
return;
}
set.add(item);
buff[end] = item;
end = (end + 1) % size;
eventBus.dispatchEvent(new CustomEvent("add"));
}
function shift() {
if (!set.size) {
return;
}
const item = buff[start];
set.delete(item);
buff[start] = null;
start = (start + 1) % size;
return item;
}
}
function createLinkifyProcess(options) {
const buffer = createBuffer(BUFFER_SIZE);
let overflowed = false;
let started = false;
buffer.eventBus.addEventListener("add", start);
buffer.eventBus.addEventListener("overflow", () => overflowed = true);
return {process};
function process(root) {
if (overflowed) {
return false
}
if (validRoot(root, options.validator)) {
buffer.push(root);
}
return true;
}
function start() {
if (started) {
return;
}
deque();
}
function deque() {
let root;
if (overflowed) {
root = document.body;
overflowed = false;
} else {
root = buffer.shift();
}
if (!root) {
started = false;
return;
}
linkify(root, options)
.then(() => {
var p = Promise.resolve();
if (options.selector) {
for (var node of root.querySelectorAll(options.selector)) {
p = p.then(linkify.bind(null, node, options));
}
}
return p;
})
.catch(err => {
console.error(err);
})
.then(deque);
}
}
function createOptions() {
const options = {};
setup();
return options;
function setup() {
GM_config.setup({
ip: {
label: "Match 4 digits IP",
type: "checkbox",
default: true
},
image: {
label: "Embed images",
type: "checkbox",
default: true
},
imageSkipSelector: {
label: "Don't embed images under following elements",
type: "textarea",
default: ".hljs, .highlight, .brush\\:"
},
unicode: {
label: "Allow non-ascii character",
type: "checkbox",
default: false
},
newTab: {
label: "Open link in new tab",
type: "checkbox",
default: false
},
standalone: {
label: "URL must be surrounded by whitespace",
type: "checkbox",
default: false
},
boundaryLeft: {
label: "Boundary characters between whitespace and URL (left)",
type: "text",
default: "{[(\"'"
},
boundaryRight: {
label: "Boundary characters between whitespace and URL (right)",
type: "text",
default: "'\")]},.;?!"
},
skipSelector: {
label: "Do not linkify these elements. (CSS selector)",
type: "textarea",
default: ".highlight, .editbox, .brush\\:, .bdsug, .spreadsheetinfo"
},
selector: {
label: "Always linkify these elements, override above. (CSS selector)",
type: "textarea",
default: ""
},
timeout: {
label: "Max execution time (ms).",
type: "number",
default: 10000
},
maxRunTime: {
label: "Max script run time (ms). If the script is freezing your browser, try to decrease this value.",
type: "number",
default: 100
},
customRules: {
label: "Custom rules. One pattern per line. (RegExp)",
type: "textarea",
default: ""
}
}, function() {
Object.assign(options, GM_config.get());
if (options.selector && !selectorTest(options.selector)) {
options.selector = null;
}
if (options.skipSelector && !selectorTest(options.skipSelector)) {
options.skipSelector = null;
}
if (options.customRules) {
options.customRules = createList(options.customRules);
}
options.validator = createValidator(options);
options.fuzzyIp = options.ip;
options.ignoreMustache = unsafeWindow.angular || unsafeWindow.Vue;
options.embedImage = options.image;
options.matcher = new UrlMatcher(options);
options.onlink = options.imageSkipSelector ? onlink : null;
});
}
function onlink({link, range, content}) {
if (link.childNodes[0].nodeName != "IMG") return;
var parent = range.startContainer;
// it might be a text node
if (!parent.closest) {
parent = parent.parentNode;
}
if (!parent.closest(options.imageSkipSelector)) return;
// remove image
link.innerHTML = "";
link.appendChild(content);
}
}
// Program init
GM_addStyle(".linkifyplus img { max-width: 90%; }");
const linkifyProcess = createLinkifyProcess(createOptions());
const observer = new MutationObserver(function(mutations){
// Filter out mutations generated by LPP
var lastRecord = mutations[mutations.length - 1],
nodes = lastRecord.addedNodes,
i;
if (nodes.length >= 2) {
for (i = 0; i < 2; i++) {
if (nodes[i].className == "linkifyplus") {
return;
}
}
}
for (var record of mutations) {
if (record.addedNodes.length) {
if (!linkifyProcess.process(record.target)) {
// it's full
break;
}
}
}
});
function init() {
observer.observe(document.body, {
childList: true,
subtree: true
});
linkifyProcess.process(document.body);
}
if (document.body) {
init();
} else {
// https://github.com/Tampermonkey/tampermonkey/issues/485
document.addEventListener("DOMContentLoaded", init, {once: true});
}
})();