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 --- "
+}