diff --git a/demo/index.html b/demo/index.html index d5ee156..245116f 100644 --- a/demo/index.html +++ b/demo/index.html @@ -66,6 +66,8 @@

this is a really big heading tag. 51 chars long even. this is a really big h

- List item

Split breaks:

1. List Item
2. List Item

+ Bad Link + Good Link (cross domain) diff --git a/package-lock.json b/package-lock.json index 3c918dd..eadbcb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12287,6 +12287,17 @@ "requires": { "node-fetch": "^1.0.1", "whatwg-fetch": ">=0.10.0" + }, + "dependencies": { + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + } } }, "isstream": { @@ -15285,13 +15296,9 @@ "integrity": "sha512-YTzGAJOo/B6hkodeT5SKKHpOhAzjMfkUCCXjLJwjWk2F4/InIg+HbdH9kmT7bKpleDuqLZDTRy2OdNtAj0IVyQ==" }, "node-fetch": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", - "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", - "requires": { - "encoding": "^0.1.11", - "is-stream": "^1.0.1" - } + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz", + "integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U=" }, "node-forge": { "version": "0.7.5", diff --git a/src/rules/__tests__/__snapshots__/valid-links.js.snap b/src/rules/__tests__/__snapshots__/valid-links.js.snap new file mode 100644 index 0000000..9e0f400 --- /dev/null +++ b/src/rules/__tests__/__snapshots__/valid-links.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`data returns the proper object 1`] = ` +Object { + "href": null, +} +`; + +exports[`form returns the proper object 1`] = ` +Array [ + Object { + "dataKey": "href", + "disabledIf": [Function], + "label": "Ensure this link is correct.", + }, + Object { + "checkbox": true, + "dataKey": "ignore", + "label": "Ignore this link in the future.", + }, +] +`; + +exports[`message returns the proper message 1`] = `"This link could not be verified."`; + +exports[`why returns the proper why message 1`] = `"Links should not be broken."`; diff --git a/src/rules/__tests__/valid-links.js b/src/rules/__tests__/valid-links.js new file mode 100644 index 0000000..1e0aab7 --- /dev/null +++ b/src/rules/__tests__/valid-links.js @@ -0,0 +1,88 @@ +import rule from "../valid-links" + +let body, a + +beforeEach(() => { + body = document.createElement("body") + a = document.createElement("a") + body.appendChild(a) + a.textContent = "Link Text" + window.fetch = () => Promise.resolve({ ok: true }) +}) + +describe("test", () => { + test("returns true if not A element", async () => { + expect(await rule.test(document.createElement("div"))).toBe(true) + }) + + test("returns true for valid links", async () => { + window.fetch = () => Promise.resolve({ ok: true }) + a.setAttribute("href", "https://www.instructure.com/") + expect(await rule.test(a)).toBe(true) + }) + + test("returns false for invalid links", async () => { + window.fetch = () => Promise.resolve({ ok: false }) + a.setAttribute("href", "http://something weird") + expect(await rule.test(a)).toBe(false) + a.setAttribute("href", "plaintext") + expect(await rule.test(a)).toBe(false) + window.fetch = () => Promise.reject(new Error()) + a.setAttribute("href", "http://something-valid-but-not-reachable/123") + expect(await rule.test(a)).toBe(false) + }) +}) + +describe("data", () => { + test("returns the proper object", () => { + expect(rule.data(a)).toMatchSnapshot() + }) +}) + +describe("form", () => { + test("returns the proper object", () => { + expect(rule.form(a)).toMatchSnapshot() + }) +}) + +describe("update", () => { + test("returns same element", () => { + expect(rule.update(a, {})).toBe(a) + }) + test("does not change href if href does not change", () => { + const href = "http://example.com" + a.setAttribute("href", href) + expect(rule.update(a, { href })).toBe(a) + }) + test("changes href if it has been changed", () => { + const href = "bad" + a.setAttribute("href", href) + rule.update(a, { href: "https://www.instructure.com/" }) + expect(a.getAttribute("href")).toBe("https://www.instructure.com/") + }) + test("does not change href if href does not change", () => { + const href = "http://example.com" + a.setAttribute("href", href) + expect( + rule.update(a, { ignore: true }).getAttribute("data-ignore-a11y-check") + ).toBe("true") + }) +}) + +describe("rootNode", () => { + test("returns the parentNode of an element", () => { + expect(rule.rootNode(a).tagName).toBe("BODY") + }) +}) + +describe("message", () => { + test("returns the proper message", () => { + expect(rule.message()).toMatchSnapshot() + }) +}) + +describe("why", () => { + test("returns the proper why message", () => { + expect(rule.why()).toMatchSnapshot() + }) +}) diff --git a/src/rules/index.js b/src/rules/index.js index 5f6df63..8605c80 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -10,6 +10,7 @@ import headingsSequence from "./headings-sequence" import imageAltLength from "./img-alt-length" import paragraphsForHeadings from "./paragraphs-for-headings" import listStructure from "./list-structure" +import validLinks from "./valid-links" export default [ imgAlt, @@ -23,5 +24,6 @@ export default [ headingsSequence, imageAltLength, paragraphsForHeadings, - listStructure + listStructure, + validLinks ] diff --git a/src/rules/valid-links.js b/src/rules/valid-links.js new file mode 100644 index 0000000..f7804d2 --- /dev/null +++ b/src/rules/valid-links.js @@ -0,0 +1,230 @@ +import formatMessage from "../format-message" + +/** + * How does this Link Checker work? + * + * This checker takes inspiration from JSONP, + * where cross domain requests are still valid + * even if they load from a 3rd party site + * + * 1. An iframe sandbox is created from a data url. + * 2. This iframe contains a script tag will create + * a web worker. + * 3. The top frame sends the iframe the url in + * question, and the frame creates the worker + * with the url embeded in a `loadScripts` call. + * 4. If the script loads, it will fail silently + * - If silent, the worker responds true (good) + * - else the worker responds false (bad link) + * - on timeout, the worker responds false + * 5. The top frame waits for the response and + * removes the iframe. + * + * Note, as of writing, this does not work on + * non-chromium browsers. In that case, all links + * are flagged as bad with the message: + * "This link could not be verified." + * + * It would also appear that jsdom (used for tests) + * cannot handle this as well. + */ + +const isValidURL = url => { + try { + // the URL constructor is more accurate than regex + // but not supported in IE. + new URL(url) + return true + } catch (_) { + // If this does throw, either: + // 1. the url is invalid + // 2. the URL constructor is not there. + // The user will be prompted to check the link manually. + return false + } +} + +const send = (type, payload, frame) => + new Promise((resolve, reject) => { + const id = Math.random() + Date.now() + const message = JSON.stringify({ type, payload, id }) + + const handler = event => { + let obj = event.data + try { + if (typeof obj === "string") obj = JSON.parse(event.data) + } catch (e) { + return + } + + const { error, response, id: returnedId } = obj + if (returnedId !== id) return + window.removeEventListener("message", handler) + if (error) return reject(error.href ? error : new Error(error)) + resolve(response) + } + + const win = frame ? frame.contentWindow : window.top + window.addEventListener("message", handler) + win.postMessage(message, "*") + }) + +const on = (type, fn) => { + window.addEventListener("message", event => { + let obj = event.data + try { + if (typeof obj === "string") obj = JSON.parse(event.data) + } catch (e) { + return + } + + const reply = o => { + event.source.postMessage( + JSON.stringify(Object.assign(o, { id: obj.id })), + "*" + ) + } + + if (obj.type === type) { + Promise.resolve(fn(obj.payload)) + .then(response => reply({ response })) + .catch(error => { + console.error(error) + reply({ + error: error.stack || error.message + }) + }) + return true + } + }) +} + +const checkUrl = src => + new Promise(resolve => { + const workerBody = + "data:application/javascript," + + encodeURIComponent(` +function reply(ok){ + self.postMessage(JSON.stringify({ok: ok})); +} + +try { + importScripts("${src}"); + reply(true); +} catch(e) { + reply(!(e instanceof DOMException)); +} +`) + + const worker = new Worker(workerBody) + + const timeout = setTimeout(() => { + resolve(false) + worker.terminate() + }, 3000) + + worker.onmessage = e => { + const { ok } = JSON.parse(e.data) + resolve(ok) + worker.terminate() + clearTimeout(timeout) + } + }) + +const checkUrlWithIframe = src => + new Promise(r => { + const body = `data:text/html,${encodeURIComponent( + `` + )}` + + const iframe = document.createElement("iframe") + + iframe.setAttribute("sandbox", "allow-scripts") + iframe.setAttribute("hidden", "true") + iframe.setAttribute("src", body) + document.body.appendChild(iframe) + + iframe.onload = () => { + send("checkUrl", src, iframe).then(result => { + r(result) + document.body.removeChild(iframe) + }) + } + }) + +const debouncedFetch = (() => { + let timeout = null + + return href => + new Promise((resolve, reject) => { + clearTimeout(timeout) + timeout = setTimeout(() => { + checkUrlWithIframe(href) + .then(resolve) + .catch(reject) + }, 500) + }) +})() + +export default { + test: function(elem) { + return new Promise((resolve, reject) => { + if (elem.tagName !== "A") return resolve(true) + const href = elem.getAttribute("href") + + // If url is invalid + if (!isValidURL(href)) return resolve(false) + + debouncedFetch(href).then(resolve) + }) + }, + + data: elem => { + return { + href: elem.getAttribute("href") + } + }, + + form: () => [ + { + label: formatMessage("Ensure this link is correct."), + dataKey: "href", + disabledIf: data => data.ignore + }, + { + label: formatMessage("Ignore this link in the future."), + checkbox: true, + dataKey: "ignore" + } + ], + + update: function(elem, data) { + const rootElem = elem.parentNode + + if (data.ignore) { + elem.setAttribute("data-ignore-a11y-check", "true") + } + + if (data.href !== elem.getAttribute("href")) { + elem.setAttribute("href", data.href) + } + + return elem + }, + + rootNode: function(elem) { + return elem.parentNode + }, + + // Note, these messages are poor and should be replaced with + // better text. + message: () => formatMessage("This link could not be verified."), + + why: () => formatMessage("Links should not be broken."), + + link: " --- fill in --- " +}