Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add no-generic-link-text rule #10

Merged
merged 17 commits into from
Dec 21, 2022
10 changes: 10 additions & 0 deletions helpers/strip-and-downcase-text.js
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
50 changes: 50 additions & 0 deletions no-generic-link-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const { stripAndDowncaseText } = require("./helpers/strip-and-downcase-text");

const bannedLinkText = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible or worth making this work for various languages?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This plugin doesn't currently have i18n support, but I added support so people can additionally configure text so they can through that!

Related: #10 (comment)

Copy link
Contributor Author

@khiga8 khiga8 Dec 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we will want to address i18n in a separate issue! I'll make an issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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 || []
Copy link
Contributor Author

@khiga8 khiga8 Dec 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using snake case to follow the convention set in markdownlint for how rule configs are set.

);
const inlineTokens = params.tokens.filter((t) => t.type === "inline");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found markdown-it demo and using the debug functionality helpful for understanding how markdown is parsed.

Additionally, I referenced: md042.

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;
}
}
}
},
};
1 change: 1 addition & 0 deletions style/accessibility.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions test/helpers/strip-and-downcase-text.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
44 changes: 8 additions & 36 deletions test/no-default-alt-text.test.js
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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();
Expand All @@ -47,7 +19,7 @@ describe("GH001: No Default Alt Text", () => {
'<img alt="A helpful description" src="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();
Expand All @@ -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)
Expand All @@ -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");
}
});

Expand All @@ -84,7 +56,7 @@ describe("GH001: No Default Alt Text", () => {
'<img alt="Screenshot 2022-06-26 at 7 41 30 PM" src="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)
Expand All @@ -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");
}
});

Expand All @@ -103,7 +75,7 @@ describe("GH001: No Default Alt Text", () => {
'<img alt="Screen Shot 2022-06-26 at 7 41 30 PM" src="https://user-images.githubusercontent.com/abcdef.png">',
];

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/
Expand Down
79 changes: 79 additions & 0 deletions test/no-generic-link-text.test.js
Original file line number Diff line number Diff line change
@@ -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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💖

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);
});
});
});
3 changes: 2 additions & 1 deletion test/usage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
Expand All @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions test/utils/run-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const markdownlint = require("markdownlint");
khiga8 marked this conversation as resolved.
Show resolved Hide resolved

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;