diff --git a/README.md b/README.md
index 073b602..3bc08be 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ The following are custom rules defined in this plugin.
* [**GH001** _no-default-alt-text_](./docs/rules/GH001-no-default-alt-text.md)
* [**GH002** _no-generic-link-text_](./docs/rules/GH002-no-generic-link-text.md)
+* [**GH002** _no-empty-alt-text_](./docs/rules/GH002-no-empty-alt-text.md)
See [`markdownlint` rules](https://github.com/DavidAnson/markdownlint#rules--aliases) for documentation on rules pulled in from `markdownlint`.
diff --git a/docs/rules/GH003-no-empty-alt-text.md b/docs/rules/GH003-no-empty-alt-text.md
new file mode 100644
index 0000000..dc73ef6
--- /dev/null
+++ b/docs/rules/GH003-no-empty-alt-text.md
@@ -0,0 +1,27 @@
+# GH003 No Empty Alt Text
+
+## Rule details
+
+⚠️ This rule is _off_ by default and is only applicable for GitHub rendered markdown.
+
+Currently, all images on github.com are automatically wrapped in an anchor tag.
+
+As a result, images that are intentionally marked as decorative (via `alt=""`) end up rendering as a link without an accessible name. This is confusing and inaccessible for assistive technology users.
+
+This rule can be enabled to enforce that the alt attribute is always set to descriptive text.
+
+This rule should be removed once this behavior is updated on GitHub's UI.
+
+## Examples
+
+### Incorrect 👎
+
+```html
+
+```
+
+### Correct 👍
+
+```html
+
+```
diff --git a/index.js b/index.js
index c16a4ff..06ba60c 100644
--- a/index.js
+++ b/index.js
@@ -6,8 +6,11 @@ const gitHubCustomRules = require("./src/rules/index").rules;
module.exports = [...gitHubCustomRules];
+const offByDefault = ["no-empty-alt-text"];
+
for (const rule of gitHubCustomRules) {
- base[rule.names[1]] = true;
+ const ruleName = rule.names[1];
+ base[ruleName] = offByDefault.includes(ruleName) ? false : true;
}
module.exports.init = function init(consumerConfig) {
diff --git a/src/rules/index.js b/src/rules/index.js
index c6c9664..08fd9e6 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -1,3 +1,7 @@
module.exports = {
- rules: [require("./no-default-alt-text"), require("./no-generic-link-text")],
+ rules: [
+ require("./no-default-alt-text"),
+ require("./no-generic-link-text"),
+ require("./no-empty-alt-text"),
+ ],
};
diff --git a/src/rules/no-default-alt-text.js b/src/rules/no-default-alt-text.js
index 9eae5d0..3254561 100644
--- a/src/rules/no-default-alt-text.js
+++ b/src/rules/no-default-alt-text.js
@@ -23,7 +23,12 @@ module.exports = {
function: function GH001(params, onError) {
const htmlTagsWithImages = params.parsers.markdownit.tokens.filter(
(token) => {
- return token.type === "html_block" && token.content.includes("
child.type === "html_inline"))
+ );
},
);
const inlineImages = params.parsers.markdownit.tokens.filter(
@@ -36,12 +41,15 @@ module.exports = {
const lineRange = token.map;
const lineNumber = token.lineNumber;
const lines = params.lines.slice(lineRange[0], lineRange[1]);
-
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let matches;
if (token.type === "inline") {
- matches = line.matchAll(markdownAltRegex);
+ if (token.children.some((child) => child.type === "html_inline")) {
+ matches = line.matchAll(htmlAltRegex);
+ } else {
+ matches = line.matchAll(markdownAltRegex);
+ }
} else {
matches = line.matchAll(htmlAltRegex);
}
@@ -51,6 +59,7 @@ module.exports = {
onError({
lineNumber: lineNumber + i,
range: [startIndex + 1, altText.length],
+ detail: `Flagged alt: ${altText}`,
});
}
}
diff --git a/src/rules/no-empty-alt-text.js b/src/rules/no-empty-alt-text.js
new file mode 100644
index 0000000..02cab26
--- /dev/null
+++ b/src/rules/no-empty-alt-text.js
@@ -0,0 +1,40 @@
+module.exports = {
+ names: ["GH003", "no-empty-alt-text"],
+ description: "Please provide an alternative text for the image.",
+ information: new URL(
+ "https://github.com/github/markdownlint-github/blob/main/docs/rules/GH003-no-empty-alt-text.md",
+ ),
+ tags: ["accessibility", "images"],
+ function: function GH003(params, onError) {
+ const htmlTagsWithImages = params.parsers.markdownit.tokens.filter(
+ (token) => {
+ return (
+ (token.type === "html_block" && token.content.includes("
child.type === "html_inline"))
+ );
+ },
+ );
+
+ const htmlAltRegex = new RegExp(/alt=['"]['"]/, "gid");
+
+ for (const token of htmlTagsWithImages) {
+ const lineRange = token.map;
+ const lineNumber = token.lineNumber;
+ const lines = params.lines.slice(lineRange[0], lineRange[1]);
+
+ for (const [i, line] of lines.entries()) {
+ const matches = line.matchAll(htmlAltRegex);
+ for (const match of matches) {
+ const matchingContent = match[0];
+ const startIndex = match.indices[0][0];
+ onError({
+ lineNumber: lineNumber + i,
+ range: [startIndex + 1, matchingContent.length],
+ });
+ }
+ }
+ }
+ },
+};
diff --git a/test/accessibility-rules.test.js b/test/accessibility-rules.test.js
index ef0e72a..e41e7bf 100644
--- a/test/accessibility-rules.test.js
+++ b/test/accessibility-rules.test.js
@@ -26,7 +26,7 @@ describe("when A11y rules applied", () => {
.map((failure) => failure.ruleNames)
.flat();
- expect(failuresForExampleFile).toHaveLength(1);
+ expect(failuresForExampleFile).toHaveLength(3);
expect(failureNames).toContain("no-default-alt-text");
});
});
diff --git a/test/example.md b/test/example.md
index 69ea19c..61e0808 100644
--- a/test/example.md
+++ b/test/example.md
@@ -1,3 +1,5 @@
# Example Violations

+
+![image]()
\ No newline at end of file
diff --git a/test/no-default-alt-text.test.js b/test/no-default-alt-text.test.js
index feca45f..314a781 100644
--- a/test/no-default-alt-text.test.js
+++ b/test/no-default-alt-text.test.js
@@ -11,10 +11,7 @@ describe("GH001: No Default Alt Text", () => {
];
const results = await runTest(strings, altTextRule);
-
- for (const result of results) {
- expect(result).not.toBeDefined();
- }
+ expect(results.length).toBe(0);
});
test("html image", async () => {
const strings = [
@@ -22,10 +19,7 @@ describe("GH001: No Default Alt Text", () => {
];
const results = await runTest(strings, altTextRule);
-
- for (const result of results) {
- expect(result).not.toBeDefined();
- }
+ expect(results.length).toBe(0);
});
});
describe("failures", () => {
@@ -77,6 +71,17 @@ describe("GH001: No Default Alt Text", () => {
}
});
+ test("flags multiple consecutive inline images", async () => {
+ const strings = ['![image]()
'];
+ const results = await runTest(strings, altTextRule);
+ expect(results).toHaveLength(2);
+
+ expect(results[0].errorRange).toEqual([11, 5]);
+ expect(results[0].errorDetail).toEqual("Flagged alt: image");
+ expect(results[1].errorRange).toEqual([28, 5]);
+ expect(results[1].errorDetail).toEqual("Flagged alt: Image");
+ });
+
test("error message", async () => {
const strings = [
"",
diff --git a/test/no-empty-alt-text.test.js b/test/no-empty-alt-text.test.js
new file mode 100644
index 0000000..0b6fd43
--- /dev/null
+++ b/test/no-empty-alt-text.test.js
@@ -0,0 +1,60 @@
+const noEmptyStringAltRule = require("../src/rules/no-empty-alt-text");
+const runTest = require("./utils/run-test").runTest;
+
+describe("GH003: No Empty Alt Text", () => {
+ describe("successes", () => {
+ test("html image", async () => {
+ const strings = [
+ '
',
+ "`
`", // code block
+ ];
+
+ const results = await runTest(strings, noEmptyStringAltRule);
+ expect(results).toHaveLength(0);
+ });
+ });
+ describe("failures", () => {
+ test("HTML example", async () => {
+ const strings = [
+ '
',
+ "
",
+ '
',
+ ];
+
+ const results = await runTest(strings, noEmptyStringAltRule);
+
+ const failedRules = results
+ .map((result) => result.ruleNames)
+ .flat()
+ .filter((name) => !name.includes("GH"));
+
+ expect(failedRules).toHaveLength(4);
+ for (const rule of failedRules) {
+ expect(rule).toBe("no-empty-alt-text");
+ }
+ });
+
+ test("error message", async () => {
+ const strings = [
+ '
',
+ '
',
+ ];
+
+ const results = await runTest(strings, noEmptyStringAltRule);
+
+ expect(results[0].ruleDescription).toMatch(
+ "Please provide an alternative text for the image.",
+ );
+ expect(results[0].errorRange).toEqual([6, 6]);
+
+ expect(results[1].ruleDescription).toMatch(
+ "Please provide an alternative text for the image.",
+ );
+ expect(results[1].errorRange).toEqual([20, 6]);
+ expect(results[2].ruleDescription).toMatch(
+ "Please provide an alternative text for the image.",
+ );
+ expect(results[2].errorRange).toEqual([49, 6]);
+ });
+ });
+});
diff --git a/test/no-generic-link-text.test.js b/test/no-generic-link-text.test.js
index 16ac44a..ac11941 100644
--- a/test/no-generic-link-text.test.js
+++ b/test/no-generic-link-text.test.js
@@ -17,10 +17,7 @@ describe("GH002: No Generic Link Text", () => {
];
const results = await runTest(strings, noGenericLinkTextRule);
-
- for (const result of results) {
- expect(result).not.toBeDefined();
- }
+ expect(results.length).toBe(0);
});
});
describe("failures", () => {
diff --git a/test/usage.test.js b/test/usage.test.js
index 24cd235..a87d10b 100644
--- a/test/usage.test.js
+++ b/test/usage.test.js
@@ -4,8 +4,11 @@ describe("usage", () => {
describe("default export", () => {
test("custom rules on default export", () => {
const rules = githubMarkdownLint;
- expect(rules).toHaveLength(2);
+ expect(rules).toHaveLength(3);
+
expect(rules[0].names).toEqual(["GH001", "no-default-alt-text"]);
+ expect(rules[1].names).toEqual(["GH002", "no-generic-link-text"]);
+ expect(rules[2].names).toEqual(["GH003", "no-empty-alt-text"]);
});
});
describe("init method", () => {
@@ -17,6 +20,7 @@ describe("usage", () => {
"no-space-in-links": false,
"single-h1": true,
"no-emphasis-as-header": true,
+ "no-empty-alt-text": false,
"heading-increment": true,
"no-generic-link-text": true,
"ul-style": {
diff --git a/test/utils/run-test.js b/test/utils/run-test.js
index a70e283..b3a6aa1 100644
--- a/test/utils/run-test.js
+++ b/test/utils/run-test.js
@@ -11,7 +11,7 @@ async function runTest(strings, rule, ruleConfig) {
customRules: [rule],
};
- return await Promise.all(
+ const results = await Promise.all(
strings.map((variation) => {
const thisTestConfig = {
...config,
@@ -21,11 +21,13 @@ async function runTest(strings, rule, ruleConfig) {
return new Promise((resolve, reject) => {
markdownlint(thisTestConfig, (err, result) => {
if (err) reject(err);
- resolve(result[0][0]);
+ resolve(result[0]);
});
});
}),
);
+
+ return results.flat();
}
exports.runTest = runTest;