├── src
├── extension
│ ├── dialog.js
│ ├── content.js
│ ├── background.js
│ └── options.js
├── static
│ ├── css
│ │ ├── dialog.css
│ │ └── options.css
│ ├── dialog.html
│ ├── icon.svg
│ ├── options.html
│ ├── icon-light.svg
│ ├── manifest.json
│ └── _locales
│ │ ├── nl
│ │ └── messages.json
│ │ └── en
│ │ └── messages.json
├── lib
│ ├── triggers
│ │ ├── cache.mjs
│ │ ├── index.mjs
│ │ ├── load.mjs
│ │ ├── click.mjs
│ │ ├── hover.mjs
│ │ ├── mutation.mjs
│ │ └── util.mjs
│ ├── extension-pref.js
│ ├── pref-default.js
│ ├── main.mjs
│ └── pref-body.js
└── userscript
│ └── index.js
├── .editorconfig
├── .gitignore
├── .cjsescache
├── eslint.config.mjs
├── .github
└── workflows
│ └── test.yml
├── LICENSE
├── package.json
├── rollup.config.mjs
├── README.md
└── demo
├── demo.html
└── demo-no-script.html
/src/extension/dialog.js:
--------------------------------------------------------------------------------
1 | require("webext-dialog/popup");
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_size = 2
3 | indent_style = space
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bower_components
2 | node_modules
3 | web-ext-artifacts
4 | .eslintcache
5 | dist-extension
6 |
--------------------------------------------------------------------------------
/src/static/css/dialog.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-size: 16px;
3 | }
4 | textarea, button {
5 | font-size: inherit;
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/triggers/cache.mjs:
--------------------------------------------------------------------------------
1 | export const processedNodes = new WeakSet;
2 | export const nodeValidationCache = new WeakMap; // Node -> boolean
3 |
4 |
--------------------------------------------------------------------------------
/src/lib/triggers/index.mjs:
--------------------------------------------------------------------------------
1 | import load from "./load.mjs"
2 | import click from "./click.mjs"
3 | import hover from "./hover.mjs"
4 | import mutation from "./mutation.mjs";
5 |
6 | export default [load, click, hover, mutation];
7 |
--------------------------------------------------------------------------------
/src/extension/content.js:
--------------------------------------------------------------------------------
1 | const {startLinkifyPlusPlus} = require("../lib/main");
2 | const pref = require("../lib/extension-pref");
3 |
4 | startLinkifyPlusPlus(async () => {
5 | await pref.ready;
6 | await pref.setCurrentScope(location.hostname);
7 | return pref;
8 | });
9 |
--------------------------------------------------------------------------------
/src/lib/extension-pref.js:
--------------------------------------------------------------------------------
1 | const {createPref, createWebextStorage} = require("webext-pref");
2 | const prefDefault = require("./pref-default");
3 |
4 | const pref = createPref(prefDefault());
5 | pref.ready = pref.connect(createWebextStorage());
6 |
7 | module.exports = pref;
8 |
--------------------------------------------------------------------------------
/src/static/dialog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/static/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/triggers/load.mjs:
--------------------------------------------------------------------------------
1 | import {linkifyRoot, prepareDocument} from "./util.mjs";
2 | // import {processedNodes} from "./cache.mjs";
3 |
4 | export default {
5 | key: "triggerByPageLoad",
6 | enable: async options => {
7 | await prepareDocument();
8 | await linkifyRoot(document.body, options);
9 | },
10 | disable: () => {}
11 | };
12 |
--------------------------------------------------------------------------------
/src/static/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/static/icon-light.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/.cjsescache:
--------------------------------------------------------------------------------
1 | [
2 | "node_modules/event-lite/event-lite.mjs",
3 | "node_modules/webext-pref/lib/promisify.js",
4 | "node_modules/webextension-polyfill/dist/browser-polyfill.js",
5 | "src/lib/extension-pref.js",
6 | "src/lib/pref-body.js",
7 | "src/lib/pref-default.js",
8 | "src/lib/triggers/click.mjs",
9 | "src/lib/triggers/hover.mjs",
10 | "src/lib/triggers/index.mjs",
11 | "src/lib/triggers/load.mjs",
12 | "src/lib/triggers/mutation.mjs"
13 | ]
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 |
4 | export default [
5 | {
6 | ignores: ["dist", "dist-extension"]
7 | },
8 | js.configs.recommended,
9 | {
10 | "rules": {
11 | "dot-notation": 2,
12 | "max-statements-per-line": 2,
13 | },
14 | languageOptions: {
15 | globals: {
16 | ...globals.browser,
17 | ...globals.node
18 | }
19 | }
20 | }
21 | ];
22 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | test:
6 | runs-on: ubuntu-latest
7 |
8 | steps:
9 | - uses: actions/checkout@v3
10 | - uses: actions/setup-node@v3
11 | with:
12 | node-version: '18'
13 | - run: npm ci
14 | - run: npm run build
15 | - run: npm test
16 | # - uses: codecov/codecov-action@v3
17 | # with:
18 | # fail_ci_if_error: true
19 | # verbose: true
20 |
--------------------------------------------------------------------------------
/src/lib/pref-default.js:
--------------------------------------------------------------------------------
1 | module.exports = function() {
2 | return {
3 | fuzzyIp: true,
4 | embedImage: true,
5 | embedImageExcludeElement: ".hljs, .highlight, .brush\\:",
6 | ignoreMustache: false,
7 | unicode: false,
8 | mail: true,
9 | newTab: false,
10 | standalone: false,
11 | boundaryLeft: "{[(\"'",
12 | boundaryRight: "'\")]},.;?!",
13 | excludeElement: ".highlight, .editbox, .brush\\:, .bdsug, .spreadsheetinfo",
14 | includeElement: "",
15 | timeout: 10000,
16 | triggerByPageLoad: false,
17 | triggerByNewNode: false,
18 | triggerByHover: true,
19 | triggerByClick: !supportHover(),
20 | maxRunTime: 100,
21 | customRules: "",
22 | };
23 | };
24 |
25 | function supportHover() {
26 | return window.matchMedia("(hover)").matches;
27 | }
28 |
--------------------------------------------------------------------------------
/src/extension/background.js:
--------------------------------------------------------------------------------
1 | import browser from "webextension-polyfill"
2 |
3 | let domain = "";
4 | const ports = new Set;
5 |
6 | browser.runtime.onConnect.addListener(port => {
7 | if (port.name !== "optionsPage" || port.error) {
8 | return;
9 | }
10 | ports.add(port);
11 | port.onDisconnect.addListener(() => ports.delete(port));
12 | port.postMessage({method: "domainChange", domain});
13 | });
14 |
15 | browser.browserAction.onClicked.addListener(tab => {
16 | const url = new URL(tab.url);
17 | domain = url.hostname;
18 | for (const port of ports) {
19 | port.postMessage({method: "domainChange", domain});
20 | }
21 | // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1949504
22 | browser.runtime.openOptionsPage();
23 | });
24 |
25 | if (/Chrome\/\d+/.test(navigator.userAgent)) {
26 | browser.browserAction.setIcon({
27 | path: "/icon.svg"
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/triggers/click.mjs:
--------------------------------------------------------------------------------
1 | import {linkify} from "linkify-plus-plus-core";
2 | import {processedNodes} from "./cache.mjs";
3 | import {validRoot} from "./util.mjs";
4 |
5 | let options;
6 |
7 | const EVENTS = [
8 | ["click", handle, {passive: true}],
9 | ]
10 |
11 | function handle(e) {
12 | const el = e.target;
13 | if (validRoot(el, options.validator)) {
14 | processedNodes.add(el);
15 | linkify({...options, root: el, recursive: false});
16 | }
17 | }
18 |
19 | function enable(_options) {
20 | options = _options;
21 | for (const [event, handler, options] of EVENTS) {
22 | document.addEventListener(event, handler, options);
23 | }
24 | }
25 |
26 | function disable() {
27 | for (const [event, handler, options] of EVENTS) {
28 | document.removeEventListener(event, handler, options);
29 | }
30 | }
31 |
32 | export default {
33 | key: "triggerByClick",
34 | enable,
35 | disable
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/src/lib/triggers/hover.mjs:
--------------------------------------------------------------------------------
1 | import {linkify} from "linkify-plus-plus-core";
2 | import {processedNodes} from "./cache.mjs";
3 | import {validRoot} from "./util.mjs";
4 |
5 | let options;
6 |
7 | const EVENTS = [
8 | // catch the first mousemove event since mouseover doesn't fire at page refresh
9 | ["mousemove", handle, {passive: true, once: true}],
10 | ["mouseover", handle, {passive: true}]
11 | ]
12 |
13 | function handle(e) {
14 | const el = e.target;
15 | if (validRoot(el, options.validator)) {
16 | processedNodes.add(el);
17 | linkify({...options, root: el, recursive: false});
18 | }
19 | }
20 |
21 | function enable(_options) {
22 | options = _options;
23 | for (const [event, handler, options] of EVENTS) {
24 | document.addEventListener(event, handler, options);
25 | }
26 | }
27 |
28 | function disable() {
29 | for (const [event, handler, options] of EVENTS) {
30 | document.removeEventListener(event, handler, options);
31 | }
32 | }
33 |
34 | export default {
35 | key: "triggerByHover",
36 | enable,
37 | disable
38 | }
39 |
--------------------------------------------------------------------------------
/src/userscript/index.js:
--------------------------------------------------------------------------------
1 | const translate = require("../static/_locales/en/messages.json"); // default
2 | const GM_webextPref = require("gm-webext-pref");
3 | const prefDefault = require("../lib/pref-default");
4 | const prefBody = require("../lib/pref-body");
5 | const {startLinkifyPlusPlus} = require("../lib/main");
6 |
7 | function getMessageFactory() {
8 | return (key, params) => {
9 | if (!params) {
10 | return translate[key];
11 | }
12 | if (!Array.isArray(params)) {
13 | params = [params];
14 | }
15 | return translate[key].replace(/\$\d/g, m => {
16 | const index = Number(m.slice(1));
17 | return params[index - 1];
18 | });
19 | };
20 | }
21 |
22 | startLinkifyPlusPlus(async () => {
23 | const getMessage = getMessageFactory();
24 | const pref = GM_webextPref({
25 | default: prefDefault(),
26 | body: prefBody(getMessage),
27 | getMessage,
28 | getNewScope: () => location.hostname
29 | });
30 | await pref.ready();
31 | await pref.setCurrentScope(location.hostname);
32 | return pref;
33 | });
34 |
--------------------------------------------------------------------------------
/src/static/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Linkify Plus Plus",
3 | "version": "12.0.1",
4 | "description": "__MSG_extensionDescription__",
5 | "homepage_url": "https://github.com/eight04/linkify-plus-plus",
6 | "developer": {
7 | "name": "eight04",
8 | "url": "https://github.com/eight04"
9 | },
10 | "content_scripts": [
11 | {
12 | "matches": [""],
13 | "js": [],
14 | "run_at": "document_start"
15 | }
16 | ],
17 | "default_locale": "en",
18 | "manifest_version": 2,
19 | "options_ui": {
20 | "page": "options.html"
21 | },
22 | "browser_action": {
23 | "default_icon": "icon.svg",
24 | "default_title": "__MSG_extensionOptions__",
25 | "theme_icons": [
26 | {
27 | "dark": "icon.svg",
28 | "light": "icon-light.svg",
29 | "size": 32
30 | }
31 | ]
32 | },
33 | "background": {
34 | "scripts": []
35 | },
36 | "permissions": [
37 | "storage",
38 | "activeTab"
39 | ],
40 | "browser_specific_settings": {
41 | "gecko": {
42 | },
43 | "gecko_android": {
44 | "strict_min_version": "113.0"
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/extension/options.js:
--------------------------------------------------------------------------------
1 | const browser = require("webextension-polyfill");
2 | const {createUI, createBinding} = require("webext-pref-ui");
3 | const {createDialogService} = require("webext-dialog");
4 |
5 | const prefBody = require("../lib/pref-body");
6 | const pref = require("../lib/extension-pref");
7 |
8 | const dialog = createDialogService({
9 | path: "dialog.html",
10 | getMessage: key => browser.i18n.getMessage(`dialog${cap(key)}`)
11 | });
12 |
13 | function cap(s) {
14 | return s[0].toUpperCase() + s.slice(1);
15 | }
16 |
17 | pref.ready.then(() => {
18 | let domain = "";
19 |
20 | const root = document.querySelector(".pref-root");
21 |
22 | const getMessage = (key, params) => browser.i18n.getMessage(`pref${cap(key)}`, params);
23 |
24 | root.append(createUI({
25 | body: prefBody(browser.i18n.getMessage),
26 | getMessage
27 | }));
28 |
29 | createBinding({
30 | pref,
31 | root,
32 | getNewScope: () => domain,
33 | getMessage,
34 | alert: dialog.alert,
35 | confirm: dialog.confirm,
36 | prompt: dialog.prompt
37 | });
38 |
39 | const port = browser.runtime.connect({
40 | name: "optionsPage"
41 | });
42 | port.onMessage.addListener(message => {
43 | if (message.method === "domainChange") {
44 | domain = message.domain;
45 | pref.setCurrentScope(pref.getScopeList().includes(domain) ? domain : "global");
46 | }
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/lib/triggers/mutation.mjs:
--------------------------------------------------------------------------------
1 | import {prepareDocument, linkifyRoot} from "./util.mjs";
2 | import {processedNodes} from "./cache.mjs";
3 |
4 | const MAX_PROCESSES = 100;
5 | let processes = 0;
6 | let observer;
7 |
8 | async function enable(options) {
9 | await prepareDocument();
10 | observer = new MutationObserver(function(mutations){
11 | // Filter out mutations generated by LPP
12 | var lastRecord = mutations[mutations.length - 1],
13 | nodes = lastRecord.addedNodes,
14 | i;
15 |
16 | if (nodes.length >= 2) {
17 | for (i = 0; i < 2; i++) {
18 | if (nodes[i].className == "linkifyplus") {
19 | return;
20 | }
21 | }
22 | }
23 |
24 | for (var record of mutations) {
25 | for (const node of record.addedNodes) {
26 | if (node.nodeType === 1 && !processedNodes.has(node)) {
27 | if (processes >= MAX_PROCESSES) {
28 | throw new Error("Too many processes");
29 | }
30 | processes++;
31 | linkifyRoot(node, options)
32 | .finally(() => {
33 | processes--;
34 | });
35 | }
36 | }
37 | }
38 | });
39 | observer.observe(document.body, {
40 | childList: true,
41 | subtree: true
42 | });
43 | }
44 |
45 | async function disable() {
46 | await prepareDocument();
47 | observer && observer.disconnect();
48 | }
49 |
50 | export default {
51 | key: "triggerByNewNode",
52 | enable,
53 | disable
54 | }
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2011, Anthony Lieuallen
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 | * Neither the name of Anthony Lieuallen nor the names of its contributors may
13 | be used to endorse or promote products derived from this software without
14 | specific prior written permission.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/src/lib/triggers/util.mjs:
--------------------------------------------------------------------------------
1 | import {linkify} from "linkify-plus-plus-core";
2 |
3 | import { processedNodes, nodeValidationCache } from "./cache.mjs";
4 |
5 | export async function linkifyRoot(root, options, useIncludeElement = true) {
6 | if (validRoot(root, options.validator)) {
7 | processedNodes.add(root);
8 | await linkify({...options, root, recursive: true});
9 | }
10 | if (options.includeElement && useIncludeElement) {
11 | for (const el of root.querySelectorAll(options.includeElement)) {
12 | await linkifyRoot(el, options, false);
13 | }
14 | }
15 | }
16 |
17 | export function validRoot(node, validator) {
18 | if (processedNodes.has(node)) {
19 | return false;
20 | }
21 | return getValidation(node);
22 |
23 | function getValidation(p) {
24 | if (!p.parentNode) {
25 | return false;
26 | }
27 | let r = nodeValidationCache.get(p);
28 | if (r === undefined) {
29 | if (validator.isIncluded(p)) {
30 | r = true;
31 | } else if (validator.isExcluded(p)) {
32 | r = false;
33 | } else if (p.parentNode != document.documentElement) {
34 | r = getValidation(p.parentNode);
35 | } else {
36 | r = true;
37 | }
38 | nodeValidationCache.set(p, r);
39 | }
40 | return r;
41 | }
42 | }
43 |
44 | export function prepareDocument() {
45 | // wait till everything is ready
46 | return prepareBody().then(prepareApp);
47 |
48 | function prepareApp() {
49 | const appRoot = document.querySelector("[data-server-rendered]");
50 | if (!appRoot) {
51 | return;
52 | }
53 | return new Promise(resolve => {
54 | const onChange = () => {
55 | if (!appRoot.hasAttribute("data-server-rendered")) {
56 | resolve();
57 | observer.disconnect();
58 | }
59 | };
60 | const observer = new MutationObserver(onChange);
61 | observer.observe(appRoot, {attributes: true});
62 | });
63 | }
64 |
65 | function prepareBody() {
66 | if (document.readyState !== "loading") {
67 | return Promise.resolve();
68 | }
69 | return new Promise(resolve => {
70 | // https://github.com/Tampermonkey/tampermonkey/issues/485
71 | document.addEventListener("DOMContentLoaded", resolve, {once: true});
72 | });
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/static/css/options.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 6px;
3 | }
4 |
5 | button.browser-style,
6 | select.browser-style {
7 | font-size: inherit;
8 | height: auto;
9 | padding: 4px 8px;
10 | }
11 |
12 | /* root */
13 | .pref-root {
14 | font-size: 15px;
15 | line-height: 1.5;
16 | }
17 |
18 | /* toolbar */
19 | .webext-pref-toolbar {
20 | display: flex;
21 | justify-content: center;
22 | margin-bottom: 16px;
23 | }
24 | .webext-pref-toolbar button {
25 | min-width: 8em;
26 | text-align: center;
27 | }
28 | .webext-pref-toolbar button:not(:first-child) {
29 | margin-left: 8px;
30 | }
31 |
32 | /* nav */
33 | .webext-pref-nav {
34 | display: flex;
35 | margin-bottom: 12px;
36 | }
37 | .webext-pref-nav select {
38 | flex-grow: 1;
39 | }
40 | .webext-pref-nav select,
41 | .webext-pref-nav button {
42 | min-width: 30px;
43 | text-align: center;
44 | text-align-last: center;
45 | }
46 | .webext-pref-nav button {
47 | margin-left: 8px;
48 | }
49 |
50 | /* checkbox */
51 | .webext-pref-checkbox {
52 | margin: 8px 0;
53 | padding-left: 24px;
54 | }
55 | .webext-pref-checkbox::before {
56 | content: "";
57 | margin-left: -24px;
58 | }
59 | .webext-pref-checkbox > input {
60 | font: inherit;
61 | width: 1em;
62 | height: 1em;
63 | margin: 0 calc(24px - 1em) 0 0;
64 | vertical-align: middle;
65 | }
66 | .webext-pref-checkbox > label {
67 | vertical-align: middle;
68 | }
69 | .webext-pref-checkbox-children {
70 | margin: 6px 0;
71 | padding: 0;
72 | border-width: 0;
73 | }
74 | .webext-pref-checkbox-children[disabled] {
75 | opacity: 0.5;
76 | }
77 | .webext-pref-checkbox-children > :first-child {
78 | margin-top: 0;
79 | }
80 | .webext-pref-checkbox-children > :last-child {
81 | margin-bottom: 0;
82 | }
83 | .webext-pref-checkbox-children > :last-child > :last-child {
84 | margin-bottom: 0;
85 | }
86 |
87 | /* text */
88 | .webext-pref-text,
89 | .webext-pref-number,
90 | .webext-pref-textarea {
91 | margin: 8px 0;
92 | }
93 | .webext-pref-text input,
94 | .webext-pref-number input,
95 | .webext-pref-textarea textarea {
96 | padding: 3px 6px;
97 | display: block;
98 | width: 100%;
99 | font: inherit;
100 | box-sizing: border-box;
101 | }
102 | .webext-pref-textarea textarea {
103 | height: 6em;
104 | }
105 |
106 | /* help */
107 | .webext-pref-help {
108 | margin: 4px 0 6px;
109 | color: #737373;
110 | }
111 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "linkify-plus-plus",
3 | "description": "Based on Linkify Plus. Turn plain text URLs into links.",
4 | "version": "12.0.1",
5 | "repository": "eight04/linkify-plus-plus",
6 | "license": "BSD-3-Clause",
7 | "author": "eight04 ",
8 | "devDependencies": {
9 | "@rollup/plugin-inject": "^5.0.5",
10 | "@rollup/plugin-json": "^6.1.0",
11 | "@rollup/plugin-node-resolve": "^16.0.0",
12 | "@rollup/plugin-terser": "^0.4.4",
13 | "dataurl": "^0.1.0",
14 | "eslint": "^9.20.1",
15 | "rollup": "^4.34.8",
16 | "rollup-plugin-cjs-es": "^3.0.0",
17 | "rollup-plugin-copy-glob": "^0.4.1",
18 | "rollup-plugin-iife": "^0.7.1",
19 | "rollup-plugin-write-output": "^0.2.1",
20 | "shx": "^0.3.4",
21 | "sync-version": "^1.0.1",
22 | "tiny-glob": "^0.2.9",
23 | "userscript-meta-cli": "^0.4.2",
24 | "web-ext": "^8.4.0"
25 | },
26 | "scripts": {
27 | "build": "sync-version src/static/manifest.json && shx rm -rf dist-extension/* && rollup -c && web-ext build",
28 | "build-dev": "rollup -cw",
29 | "build-git": "git archive --output web-ext-artifacts/source.zip master",
30 | "test": "eslint . --cache && web-ext lint",
31 | "preversion": "npm test",
32 | "version": "npm run build && git add .",
33 | "postversion": "git push --follow-tags && npm run build-git",
34 | "start": "web-ext run"
35 | },
36 | "userscript": {
37 | "name": "Linkify Plus Plus",
38 | "namespace": "eight04.blogspot.com",
39 | "include": "*",
40 | "exclude": [
41 | "https://www.google.*/search*",
42 | "https://www.google.*/webhp*",
43 | "https://music.google.com/*",
44 | "https://mail.google.com/*",
45 | "https://docs.google.com/*",
46 | "https://encrypted.google.com/*",
47 | "https://*101weiqi.com/*",
48 | "https://w3c*.github.io/*",
49 | "https://www.paypal.com/*",
50 | "https://term.ptt.cc/*",
51 | "https://mastodon.social/*"
52 | ],
53 | "grant": [
54 | "GM.getValue",
55 | "GM.setValue",
56 | "GM.deleteValue",
57 | "GM_addStyle",
58 | "GM_registerMenuCommand",
59 | "GM_getValue",
60 | "GM_setValue",
61 | "GM_deleteValue",
62 | "GM_addValueChangeListener",
63 | "unsafeWindow"
64 | ],
65 | "compatible": [
66 | "firefox Tampermonkey latest",
67 | "chrome Tampermonkey latest"
68 | ]
69 | },
70 | "private": true,
71 | "dependencies": {
72 | "event-lite": "^1.0.0",
73 | "gm-webext-pref": "^0.4.2",
74 | "linkify-plus-plus-core": "^0.7.0",
75 | "sentinel-js": "^0.0.7",
76 | "webext-dialog": "^0.1.1",
77 | "webext-pref": "^0.6.0",
78 | "webextension-polyfill": "^0.12.0"
79 | },
80 | "webExt": {
81 | "sourceDir": "dist-extension",
82 | "build": {
83 | "overwriteDest": true
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/lib/main.mjs:
--------------------------------------------------------------------------------
1 | import {UrlMatcher, INVALID_TAGS} from "linkify-plus-plus-core";
2 |
3 | import triggers from "./triggers/index.mjs";
4 | import {processedNodes} from "./triggers/cache.mjs";
5 |
6 | function createValidator({includeElement, excludeElement}) {
7 | const f = function(node) {
8 | if (processedNodes.has(node)) {
9 | return false;
10 | }
11 |
12 | if (node.isContentEditable) {
13 | return false;
14 | }
15 |
16 | if (node.matches) {
17 | if (includeElement && node.matches(includeElement)) {
18 | return true;
19 | }
20 | if (excludeElement && node.matches(excludeElement)) {
21 | return false;
22 | }
23 | }
24 | return true;
25 | };
26 | f.isIncluded = node => {
27 | return includeElement && node.matches(includeElement);
28 | };
29 | f.isExcluded = node => {
30 | if (INVALID_TAGS[node.localName]) {
31 | return true;
32 | }
33 | return excludeElement && node.matches(excludeElement);
34 | };
35 | return f;
36 | }
37 |
38 | function stringToList(value) {
39 | value = value.trim();
40 | if (!value) {
41 | return [];
42 | }
43 | return value.split(/\s*\n\s*/g);
44 | }
45 |
46 | function createOptions(pref) {
47 | const options = {};
48 | pref.on("change", update);
49 | update(pref.getAll());
50 | return options;
51 |
52 | function update(changes) {
53 | Object.assign(options, changes);
54 | options.validator = createValidator(options);
55 | if (typeof options.customRules === "string") {
56 | options.customRules = stringToList(options.customRules);
57 | }
58 | options.matcher = new UrlMatcher(options);
59 | options.onlink = options.embedImageExcludeElement ? onlink : null;
60 | }
61 |
62 | function onlink({link, range, content}) {
63 | if (link.childNodes[0].localName !== "img" || !options.embedImageExcludeElement) {
64 | return;
65 | }
66 |
67 | var parent = range.startContainer;
68 | // it might be a text node
69 | if (!parent.closest) {
70 | parent = parent.parentNode;
71 | }
72 | if (!parent.closest(options.embedImageExcludeElement)) return;
73 | // remove image
74 | link.innerHTML = "";
75 | link.appendChild(content);
76 | }
77 | }
78 |
79 | export async function startLinkifyPlusPlus(getPref) {
80 | // Limit contentType to specific content type
81 | if (
82 | document.contentType &&
83 | !["text/plain", "text/html", "application/xhtml+xml"].includes(document.contentType)
84 | ) {
85 | return;
86 | }
87 |
88 | const pref = await getPref();
89 | const options = createOptions(pref);
90 | for (const trigger of triggers) {
91 | if (pref.get(trigger.key)) {
92 | trigger.enable(options);
93 | }
94 | }
95 | pref.on("change", changes => {
96 | for (const trigger of triggers) {
97 | if (changes[trigger.key] === true) {
98 | trigger.enable(options);
99 | }
100 | if (changes[trigger.key] === false) {
101 | trigger.disable();
102 | }
103 | }
104 | });
105 | }
106 |
107 |
--------------------------------------------------------------------------------
/src/static/_locales/nl/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "dialogOk": {
3 | "message": "Oké"
4 | },
5 | "dialogCancel": {
6 | "message": "Annuleren"
7 | },
8 | "extensionOptions": {
9 | "message": "Linkify Plus Plus-instellingen"
10 | },
11 | "extensionDescription": {
12 | "message": "Gebaseerd op Linkify Plus. Zet platte tekst-url's om in links."
13 | },
14 | "optionsFuzzyIpLabel": {
15 | "message": "IP-adressen met 4 tekens omzetten"
16 | },
17 | "optionsIgnoreMustacheLabel": {
18 | "message": "URL's binnen accolades, {{ … }}, negeren"
19 | },
20 | "optionsEmbedImageLabel": {
21 | "message": "Afbeeldingen insluiten"
22 | },
23 | "optionsEmbedImageExcludeElementLabel": {
24 | "message": "Elementen uitsluiten (css-selectie):"
25 | },
26 | "optionsUnicodeLabel": {
27 | "message": "Unicode-tekens omzetten"
28 | },
29 | "optionsMailLabel": {
30 | "message": "E-mailadressen omzetten"
31 | },
32 | "optionsNewTabLabel": {
33 | "message": "Links openen op nieuwe tabbladen"
34 | },
35 | "optionsStandaloneLabel": {
36 | "message": "Alleen links met witruimte rondom omzetten"
37 | },
38 | "optionsBoundaryLeftLabel": {
39 | "message": "Toegestane tekens tussen witruimtes en links (linkerzijde)"
40 | },
41 | "optionsBoundaryRightLabel": {
42 | "message": "Toegestane tekens tussen witruimtes en links (rechterzijde)"
43 | },
44 | "optionsExcludeElementLabel": {
45 | "message": "Elementen uitsluiten (css-selectie):"
46 | },
47 | "optionsIncludeElementLabel": {
48 | "message": "Elementen die bovenstaande negeren (css-selectie):"
49 | },
50 | "optionsTimeoutLabel": {
51 | "message": "Max. uitvoertijd (in ms):"
52 | },
53 | "optionsTimeoutHelp": {
54 | "message": "Het script wordt onderbroken als het te lang duurt om alle links op de gehele pagina om te zetten."
55 | },
56 | "optionsMaxRunTimeLabel": {
57 | "message": "Max. scriptuitvoertijd (in ms)"
58 | },
59 | "optionsMaxRunTimeHelp": {
60 | "message": "Verdeel het proces in kleinere stukken om te voorkomen dat de browser vastloopt."
61 | },
62 | "optionsCustomRulesLabel": {
63 | "message": "Eigen regels (één reguliere uitdrukking per regel):"
64 | },
65 | "prefCurrentScopeLabel": {
66 | "message": "Huidig domein"
67 | },
68 | "prefAddScopeLabel": {
69 | "message": "Domein toevoegen"
70 | },
71 | "prefAddScopePrompt": {
72 | "message": "Domein toevoegen"
73 | },
74 | "prefDeleteScopeLabel": {
75 | "message": "Huidig domein verwijderen"
76 | },
77 | "prefDeleteScopeConfirm": {
78 | "message": "Weet je zeker dat je $DOMAIN$ wilt verwijderen?",
79 | "placeholders": {
80 | "domain": {
81 | "content": "$1",
82 | "example": "www.voorbeeld.nl"
83 | }
84 | }
85 | },
86 | "prefLearnMoreButton": {
87 | "message": "Meer informatie"
88 | },
89 | "prefImportButton": {
90 | "message": "Importeren"
91 | },
92 | "prefImportPrompt": {
93 | "message": "Instellingen plakken"
94 | },
95 | "prefExportButton": {
96 | "message": "Exporteren"
97 | },
98 | "prefExportPrompt": {
99 | "message": "Instellingen kopiëren"
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import dataurl from "dataurl";
2 | import fs from "fs";
3 | import usm from "userscript-meta-cli";
4 | import glob from "tiny-glob";
5 |
6 | import copy from 'rollup-plugin-copy-glob';
7 | import cjs from "rollup-plugin-cjs-es";
8 | import iife from "rollup-plugin-iife";
9 | import json from "@rollup/plugin-json";
10 | import output from "rollup-plugin-write-output";
11 | import terser from "@rollup/plugin-terser";
12 | import resolve from "@rollup/plugin-node-resolve";
13 | import inject from "@rollup/plugin-inject";
14 |
15 | const DEV = process.env.ROLLUP_WATCH;
16 |
17 | function commonPlugins(cache) {
18 | return [
19 | resolve({
20 | dedupe: ["event-lite"]
21 | }),
22 | json(),
23 | cjs({nested: true, cache})
24 | ];
25 | }
26 |
27 | export default async () => [
28 | {
29 | input: await glob("src/extension/*.js"),
30 | output: {
31 | format: "es",
32 | dir: "dist-extension/js"
33 | },
34 | plugins: [
35 | copy([
36 | {
37 | files: "src/static/**/*",
38 | dest: "dist-extension"
39 | }
40 | ]),
41 | ...commonPlugins(),
42 | inject({
43 | exclude: ["**/*/browser-polyfill.js"],
44 | browser: "webextension-polyfill"
45 | }),
46 | iife(),
47 | output([
48 | {
49 | test: /(options|dialog)\.js$/,
50 | target: "dist-extension/$1.html",
51 | handle: (content, {htmlScripts}) => content.replace(/.*<\/body>/, `${htmlScripts}