diff --git a/helpers/strip-and-downcase-text.js b/helpers/strip-and-downcase-text.js new file mode 100644 index 0000000..af5c238 --- /dev/null +++ b/helpers/strip-and-downcase-text.js @@ -0,0 +1,10 @@ +/* Downcase and strip extra whitespaces and punctuation */ +function stripAndDowncaseText(text) { + return text + .toLowerCase() + .replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +module.exports = { stripAndDowncaseText }; diff --git a/index.js b/index.js index 62fefba..c00a98e 100644 --- a/index.js +++ b/index.js @@ -3,8 +3,9 @@ const _ = require("lodash"); const accessibilityRules = require("./style/accessibility.json"); const base = require("./style/base.json"); const noDefaultAltText = require("./no-default-alt-text"); +const noGenericLinkText = require("./no-generic-link-text"); -const customRules = [noDefaultAltText]; +const customRules = [noDefaultAltText, noGenericLinkText]; module.exports = [...customRules]; diff --git a/no-generic-link-text.js b/no-generic-link-text.js new file mode 100644 index 0000000..c16cd67 --- /dev/null +++ b/no-generic-link-text.js @@ -0,0 +1,50 @@ +const { stripAndDowncaseText } = require("./helpers/strip-and-downcase-text"); + +const bannedLinkText = [ + "read more", + "learn more", + "more", + "here", + "click here", + "link", +]; + +module.exports = { + names: ["GH002", "no-generic-link-text"], + description: + "Avoid using generic link text like `Learn more` or `Click here`", + information: new URL( + "https://primer.style/design/accessibility/links#writing-link-text" + ), + tags: ["accessibility", "links"], + function: function GH002(params, onError) { + // markdown syntax + const allBannedLinkTexts = bannedLinkText.concat( + params.config.additional_banned_texts || [] + ); + const inlineTokens = params.tokens.filter((t) => t.type === "inline"); + for (const token of inlineTokens) { + const { children } = token; + let inLink = false; + let linkText = ""; + + for (const child of children) { + const { content, type } = child; + if (type === "link_open") { + inLink = true; + linkText = ""; + } else if (type === "link_close") { + inLink = false; + if (allBannedLinkTexts.includes(stripAndDowncaseText(linkText))) { + onError({ + lineNumber: child.lineNumber, + detail: `For link: ${linkText}`, + }); + } + } else if (inLink) { + linkText += content; + } + } + } + }, +}; diff --git a/style/accessibility.json b/style/accessibility.json index 3937674..61c3082 100644 --- a/style/accessibility.json +++ b/style/accessibility.json @@ -2,6 +2,7 @@ "no-default-alt-text": true, "no-duplicate-header": true, "no-emphasis-as-header": true, + "no-generic-link-text": true, "no-space-in-links": false, "ol-prefix": "ordered", "single-h1": true, diff --git a/test/helpers/strip-and-downcase-text.test.js b/test/helpers/strip-and-downcase-text.test.js new file mode 100644 index 0000000..2617b25 --- /dev/null +++ b/test/helpers/strip-and-downcase-text.test.js @@ -0,0 +1,20 @@ +const { + stripAndDowncaseText, +} = require("../../helpers/strip-and-downcase-text"); + +describe("stripAndDowncaseText", () => { + test("strips extra whitespace", () => { + expect(stripAndDowncaseText(" read more ")).toBe("read more"); + expect(stripAndDowncaseText(" learn ")).toBe("learn"); + }); + + test("strips punctuation", () => { + expect(stripAndDowncaseText("learn more!!!!")).toBe("learn more"); + expect(stripAndDowncaseText("I like dogs...")).toBe("i like dogs"); + }); + + test("downcases text", () => { + expect(stripAndDowncaseText("HeRe")).toBe("here"); + expect(stripAndDowncaseText("CLICK HERE")).toBe("click here"); + }); +}); diff --git a/test/no-default-alt-text.test.js b/test/no-default-alt-text.test.js index 796933e..5032250 100644 --- a/test/no-default-alt-text.test.js +++ b/test/no-default-alt-text.test.js @@ -1,33 +1,5 @@ -const markdownlint = require("markdownlint"); const altTextRule = require("../no-default-alt-text"); - -const thisRuleName = altTextRule.names[1]; - -const config = { - config: { - default: false, - [thisRuleName]: true, - }, - customRules: [altTextRule], -}; - -async function runTest(strings) { - return await Promise.all( - strings.map((variation) => { - const thisTestConfig = { - ...config, - strings: [variation], - }; - - return new Promise((resolve, reject) => { - markdownlint(thisTestConfig, (err, result) => { - if (err) reject(err); - resolve(result[0][0]); - }); - }); - }) - ); -} +const runTest = require("./utils/run-test").runTest; describe("GH001: No Default Alt Text", () => { describe("successes", () => { @@ -36,7 +8,7 @@ describe("GH001: No Default Alt Text", () => { "![Chart with a single root node reading 'Example'](https://user-images.githubusercontent.com/abcdef.png)", ]; - const results = await runTest(strings); + const results = await runTest(strings, altTextRule); for (const result of results) { expect(result).not.toBeDefined(); @@ -47,7 +19,7 @@ describe("GH001: No Default Alt Text", () => { 'A helpful description', ]; - const results = await runTest(strings); + const results = await runTest(strings, altTextRule); for (const result of results) { expect(result).not.toBeDefined(); @@ -63,7 +35,7 @@ describe("GH001: No Default Alt Text", () => { "![Screenshot 2022-06-26 at 7 41 30 PM](https://user-images.githubusercontent.com/abcdef.png)", ]; - const results = await runTest(strings); + const results = await runTest(strings, altTextRule); const failedRules = results .map((result) => result.ruleNames) @@ -72,7 +44,7 @@ describe("GH001: No Default Alt Text", () => { expect(failedRules).toHaveLength(4); for (const rule of failedRules) { - expect(rule).toBe(thisRuleName); + expect(rule).toBe("no-default-alt-text"); } }); @@ -84,7 +56,7 @@ describe("GH001: No Default Alt Text", () => { 'Screenshot 2022-06-26 at 7 41 30 PM', ]; - const results = await runTest(strings); + const results = await runTest(strings, altTextRule); const failedRules = results .map((result) => result.ruleNames) @@ -93,7 +65,7 @@ describe("GH001: No Default Alt Text", () => { expect(failedRules).toHaveLength(4); for (const rule of failedRules) { - expect(rule).toBe(thisRuleName); + expect(rule).toBe("no-default-alt-text"); } }); @@ -103,7 +75,7 @@ describe("GH001: No Default Alt Text", () => { 'Screen Shot 2022-06-26 at 7 41 30 PM', ]; - const results = await runTest(strings); + const results = await runTest(strings, altTextRule); expect(results[0].ruleDescription).toMatch( /Images should not use the MacOS default screenshot filename as alternate text/ diff --git a/test/no-generic-link-text.test.js b/test/no-generic-link-text.test.js new file mode 100644 index 0000000..f51a1db --- /dev/null +++ b/test/no-generic-link-text.test.js @@ -0,0 +1,79 @@ +const noGenericLinkTextRule = require("../no-generic-link-text"); +const runTest = require("./utils/run-test").runTest; + +describe("GH002: No Generic Link Text", () => { + describe("successes", () => { + test("inline", async () => { + const strings = [ + "[GitHub](https://www.github.com)", + "[Read more about GitHub](https://www.github.com/about)", + "[](www.github.com)", + "![Image](www.github.com)", + ` + ## Hello + I am not a link, and unrelated. + ![GitHub](some_image.png) + `, + ]; + + const results = await runTest(strings, noGenericLinkTextRule); + + for (const result of results) { + expect(result).not.toBeDefined(); + } + }); + }); + describe("failures", () => { + test("inline", async () => { + const strings = [ + "[Click here](www.github.com)", + "[here](www.github.com)", + "Please [read more](www.github.com)", + "[more](www.github.com)", + "[link](www.github.com)", + "You may [learn more](www.github.com) at GitHub", + "[learn more.](www.github.com)", + "[click here!](www.github.com)", + ]; + + const results = await runTest(strings, noGenericLinkTextRule); + + const failedRules = results + .map((result) => result.ruleNames) + .flat() + .filter((name) => !name.includes("GH")); + + expect(failedRules).toHaveLength(8); + for (const rule of failedRules) { + expect(rule).toBe("no-generic-link-text"); + } + }); + + test("error message", async () => { + const strings = ["[Click here](www.github.com)"]; + + const results = await runTest(strings, noGenericLinkTextRule); + + expect(results[0].ruleDescription).toMatch( + /Avoid using generic link text like `Learn more` or `Click here`/ + ); + expect(results[0].errorDetail).toBe("For link: Click here"); + }); + + test("additional words can be configured", async () => { + const results = await runTest( + ["[something](www.github.com)"], + noGenericLinkTextRule, + // eslint-disable-next-line camelcase + { additional_banned_texts: ["something"] } + ); + + const failedRules = results + .map((result) => result.ruleNames) + .flat() + .filter((name) => !name.includes("GH")); + + expect(failedRules).toHaveLength(1); + }); + }); +}); diff --git a/test/usage.test.js b/test/usage.test.js index 9d923e4..0e31e8e 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -4,7 +4,7 @@ describe("usage", () => { describe("default export", () => { test("custom rules on default export", () => { const rules = githubMarkdownLint; - expect(rules).toHaveLength(1); + expect(rules).toHaveLength(2); expect(rules[0].names).toEqual(["GH001", "no-default-alt-text"]); }); }); @@ -17,6 +17,7 @@ describe("usage", () => { "no-space-in-links": false, "single-h1": true, "no-emphasis-as-header": true, + "no-generic-link-text": true, "ul-style": true, default: true, "no-inline-html": false, diff --git a/test/utils/run-test.js b/test/utils/run-test.js new file mode 100644 index 0000000..1fe69c0 --- /dev/null +++ b/test/utils/run-test.js @@ -0,0 +1,31 @@ +const markdownlint = require("markdownlint"); + +async function runTest(strings, rule, ruleConfig) { + const thisRuleName = rule.names[1]; + + const config = { + config: { + default: false, + [thisRuleName]: ruleConfig || true, + }, + customRules: [rule], + }; + + return await Promise.all( + strings.map((variation) => { + const thisTestConfig = { + ...config, + strings: [variation], + }; + + return new Promise((resolve, reject) => { + markdownlint(thisTestConfig, (err, result) => { + if (err) reject(err); + resolve(result[0][0]); + }); + }); + }) + ); +} + +exports.runTest = runTest;