|
6 | 6 | * plain text with IPFS addresses with clickable links.
|
7 | 7 | * Loosely based on https://github.com/mdn/webextensions-examples/blob/master/emoji-substitution/substitute.js
|
8 | 8 | * Note that this is a quick&dirty PoC and may slow down browsing experience.
|
| 9 | + * Test page: http://bit.ly/2fgkF4E |
9 | 10 | * TODO: measure & improve performance
|
10 | 11 | */
|
11 | 12 |
|
|
14 | 15 | return
|
15 | 16 | }
|
16 | 17 |
|
| 18 | + // Limit contentType to "text/plain" or "text/html" |
| 19 | + if (document.contentType !== undefined && document.contentType !== 'text/plain' && document.contentType !== 'text/html') { |
| 20 | + return |
| 21 | + } |
| 22 | + |
17 | 23 | // linkify lock
|
18 | 24 | window.ipfsLinkifiedDOM = true
|
| 25 | + window.ipfsLinkifyValidationCache = new Map() |
| 26 | + |
| 27 | + const urlRE = /(?:\s+|^)(\/ip(?:f|n)s\/|dweb:\/ip(?:f|n)s\/|ipns:\/\/|ipfs:\/\/)([^\s+"<>]+)/g |
19 | 28 |
|
20 |
| - const urlRE = /(?:\s+|^)(?:\/ip(f|n)s\/|fs:|ipns:|ipfs:)[^\s+"<>]+/g |
| 29 | + // Chrome compatibility |
| 30 | + // var browser = browser || chrome |
21 | 31 |
|
22 | 32 | // tags we will scan looking for un-hyperlinked IPFS addresses
|
23 | 33 | const allowedParents = [
|
|
28 | 38 | 's', 'strong', 'sub', 'sup', 'td', 'th', 'tt', 'u', 'var'
|
29 | 39 | ]
|
30 | 40 |
|
31 |
| - const textNodeXpath = '//text()[(parent::' + allowedParents.join(' or parent::') + ') and ' + |
32 |
| - "(contains(., 'ipfs') or contains(., 'ipns')) ]" |
| 41 | + const textNodeXpath = './/text()[' + |
| 42 | + "(contains(., '/ipfs/') or contains(., '/ipns/') or contains(., 'ipns:/') or contains(., 'ipfs:/')) and " + |
| 43 | + 'not(ancestor::a) and not(ancestor::script) and not(ancestor::style) and ' + |
| 44 | + '(parent::' + allowedParents.join(' or parent::') + ') ' + |
| 45 | + ']' |
33 | 46 |
|
34 |
| - linkifyContainer(document.body) |
| 47 | + function init () { |
| 48 | + linkifyContainer(document.body) |
35 | 49 |
|
36 |
| - // body.appendChild(document.createTextNode('fooo /ipfs/QmTAsnXoWmLZQEpvyZscrReFzqxP3pvULfGVgpJuayrp1w bar')) |
37 |
| - new MutationObserver(function (mutations) { |
38 |
| - for (let mutation of mutations) { |
39 |
| - if (mutation.type === 'childList') { |
40 |
| - for (let addedNode of mutation.addedNodes) { |
41 |
| - if (addedNode.nodeType === Node.TEXT_NODE) { |
42 |
| - linkifyTextNode(addedNode) |
43 |
| - } else { |
44 |
| - linkifyContainer(addedNode) |
| 50 | + // body.appendChild(document.createTextNode('fooo /ipfs/QmTAsnXoWmLZQEpvyZscrReFzqxP3pvULfGVgpJuayrp1w bar')) |
| 51 | + new MutationObserver(function (mutations) { |
| 52 | + mutations.forEach(function (mutation) { |
| 53 | + if (mutation.type === 'childList') { |
| 54 | + for (let addedNode of mutation.addedNodes) { |
| 55 | + if (addedNode.nodeType === Node.TEXT_NODE) { |
| 56 | + setTimeout(() => linkifyTextNode(addedNode), 0) |
| 57 | + } else { |
| 58 | + setTimeout(() => linkifyContainer(addedNode), 0) |
| 59 | + } |
45 | 60 | }
|
46 | 61 | }
|
47 |
| - } |
48 |
| - if (mutation.type === 'characterData') { |
49 |
| - linkifyTextNode(mutation.target) |
50 |
| - } |
51 |
| - } |
52 |
| - }).observe(document.body, { |
53 |
| - characterData: true, |
54 |
| - childList: true, |
55 |
| - subtree: true |
56 |
| - }) |
| 62 | + if (mutation.type === 'characterData') { |
| 63 | + setTimeout(() => linkifyTextNode(mutation.target), 0) |
| 64 | + } |
| 65 | + }) |
| 66 | + }).observe(document.body, { |
| 67 | + characterData: true, |
| 68 | + childList: true, |
| 69 | + subtree: true |
| 70 | + }) |
| 71 | + } |
57 | 72 |
|
58 | 73 | function linkifyContainer (container) {
|
59 |
| - // console.log('linkifyContainer', container) |
60 |
| - if (!container.nodeType) { |
| 74 | + if (!container || !container.nodeType) { |
61 | 75 | return
|
62 | 76 | }
|
63 | 77 | if (container.className && container.className.match(/\blinkifiedIpfsAddress\b/)) {
|
64 | 78 | // prevent infinite recursion
|
65 | 79 | return
|
66 | 80 | }
|
67 |
| - const xpathResult = document.evaluate(textNodeXpath, container, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null) |
| 81 | + const xpathResult = document.evaluate(textNodeXpath, container, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null) |
68 | 82 | let i = 0
|
69 |
| - function continuation () { |
| 83 | + async function continuation () { |
70 | 84 | let node = null
|
71 | 85 | let counter = 0
|
72 | 86 | while ((node = xpathResult.snapshotItem(i++))) {
|
73 | 87 | const parent = node.parentNode
|
74 |
| - if (!parent) continue |
| 88 | + // Skip if no longer in visible DOM |
| 89 | + if (!parent || !document.body.contains(node)) continue |
| 90 | + // Skip already linkified nodes |
| 91 | + if (parent.className && parent.className.match(/\blinkifiedIpfsAddress\b/)) continue |
75 | 92 | // Skip styled <pre> -- often highlighted by script.
|
76 | 93 | if (parent.tagName === 'PRE' && parent.className) continue
|
77 | 94 | // Skip forms, textareas
|
78 | 95 | if (parent.isContentEditable) continue
|
79 |
| - linkifyTextNode(node) |
80 |
| - if (++counter > 50) { |
81 |
| - return setTimeout(continuation, 0) |
| 96 | + await linkifyTextNode(node) |
| 97 | + if (++counter > 10) { |
| 98 | + return setTimeout(continuation, 100) |
82 | 99 | }
|
83 | 100 | }
|
84 | 101 | }
|
85 |
| - setTimeout(continuation, 0) |
| 102 | + window.requestAnimationFrame(continuation) |
86 | 103 | }
|
87 | 104 |
|
88 |
| - function normalizeHref (href) { |
89 |
| - // console.log(href) |
90 |
| - // convert various variants to regular URL at the public gateway |
91 |
| - if (href.startsWith('ipfs:')) { |
92 |
| - href = href.replace('ipfs:', '/ipfs/') |
| 105 | + function textToIpfsResource (match) { |
| 106 | + let root = match[1] |
| 107 | + let path = match[2] |
| 108 | + |
| 109 | + // skip trailing dots and commas |
| 110 | + path = path.replace(/[.,]*$/, '') |
| 111 | + |
| 112 | + // convert various protocol variants to regular URL at the public gateway |
| 113 | + if (root === 'ipfs://') { |
| 114 | + root = '/ipfs/' |
| 115 | + } else if (root === 'ipns://') { |
| 116 | + root = '/ipns/' |
| 117 | + } else if (root === 'dweb:/ipfs/') { |
| 118 | + root = '/ipfs/' |
| 119 | + } else if (root === 'dweb:/ipns/') { |
| 120 | + root = '/ipns/' |
93 | 121 | }
|
94 |
| - if (href.startsWith('ipns:')) { |
95 |
| - href = href.replace('ipns:', '/ipns/') |
| 122 | + return validIpfsResource(root + path) |
| 123 | + } |
| 124 | + |
| 125 | + async function validIpfsResource (path) { |
| 126 | + // validation is expensive, caching result improved performance |
| 127 | + // on page that have multiple copies of the same path |
| 128 | + if (window.ipfsLinkifyValidationCache.has(path)) { |
| 129 | + return window.ipfsLinkifyValidationCache.get(path) |
96 | 130 | }
|
97 |
| - if (href.startsWith('fs:')) { |
98 |
| - href = href.replace('fs:', '') |
| 131 | + try { |
| 132 | + // Callback wrapped in promise -- Chrome compatibility |
| 133 | + const checkResult = await browser.runtime.sendMessage({isIpfsPath: path}) |
| 134 | + if (checkResult.isIpfsPath) { |
| 135 | + // TODO: use customizable public gateway |
| 136 | + window.ipfsLinkifyValidationCache.set(path, 'https://ipfs.io' + path) |
| 137 | + } else { |
| 138 | + window.ipfsLinkifyValidationCache.set(path, null) |
| 139 | + } |
| 140 | + } catch (error) { |
| 141 | + window.ipfsLinkifyValidationCache.set(path, null) |
| 142 | + console.error('isIpfsPath.error for ' + path, error) |
99 | 143 | }
|
100 |
| - href = 'https://ipfs.io/' + href // for now just point to public gw, we will switch to custom protocol when https://github.com/ipfs/ipfs-companion/issues/164 is closed |
101 |
| - href = href.replace(/([^:]\/)\/+/g, '$1') // remove redundant slashes |
102 |
| - return href |
| 144 | + return window.ipfsLinkifyValidationCache.get(path) |
103 | 145 | }
|
104 | 146 |
|
105 |
| - function linkifyTextNode (node) { |
106 |
| - // console.log('linkifyTextNode', node) |
| 147 | + async function linkifyTextNode (node) { |
107 | 148 | let link
|
108 | 149 | let match
|
109 | 150 | const txt = node.textContent
|
110 | 151 | let span = null
|
111 | 152 | let point = 0
|
112 | 153 | while ((match = urlRE.exec(txt))) {
|
| 154 | + link = await textToIpfsResource(match) |
113 | 155 | if (span == null) {
|
114 |
| - // Create a span to hold the new text with links in it. |
| 156 | + // Create a span to hold the new text with links in it. |
115 | 157 | span = document.createElement('span')
|
116 | 158 | span.className = 'linkifiedIpfsAddress'
|
117 | 159 | }
|
118 |
| - // get the link without trailing dots and commas |
119 |
| - link = match[0].replace(/[.,]*$/, '') |
120 |
| - const replaceLength = link.length |
121 |
| - // put in text up to the link |
122 |
| - span.appendChild(document.createTextNode(txt.substring(point, match.index))) |
123 |
| - // create a link and put it in the span |
124 |
| - const a = document.createElement('a') |
125 |
| - a.className = 'linkifiedIpfsAddress' |
126 |
| - a.appendChild(document.createTextNode(link)) |
127 |
| - a.setAttribute('href', normalizeHref(link.trim())) |
128 |
| - span.appendChild(a) |
129 |
| - // track insertion point |
| 160 | + const replaceLength = match[0].length |
| 161 | + if (link) { |
| 162 | + // put in text up to the link |
| 163 | + span.appendChild(document.createTextNode(txt.substring(point, match.index))) |
| 164 | + // create a link and put it in the span |
| 165 | + const a = document.createElement('a') |
| 166 | + a.className = 'linkifiedIpfsAddress' |
| 167 | + a.appendChild(document.createTextNode(match[0])) |
| 168 | + a.setAttribute('href', link) |
| 169 | + span.appendChild(a) |
| 170 | + } else { |
| 171 | + // wrap text in span to exclude it from future processing |
| 172 | + span.appendChild(document.createTextNode(match[0])) |
| 173 | + } |
| 174 | + // track insertion point |
130 | 175 | point = match.index + replaceLength
|
131 | 176 | }
|
132 | 177 | if (span) {
|
133 | 178 | // take the text after the last link
|
134 | 179 | span.appendChild(document.createTextNode(txt.substring(point, txt.length)))
|
| 180 | + span.normalize() |
135 | 181 | // replace the original text with the new span
|
136 | 182 | try {
|
137 | 183 | node.parentNode.replaceChild(span, node)
|
138 | 184 | } catch (e) {
|
139 | 185 | console.error(e)
|
140 |
| - console.log(node) |
| 186 | + // console.log(node) |
141 | 187 | }
|
142 | 188 | }
|
143 | 189 | }
|
| 190 | + |
| 191 | + init() |
144 | 192 | }(window.ipfsLinkifiedDOM))
|
0 commit comments