From 33ee1cd85ee1eeaaca4ba4d7f18e7e2edf67a48d Mon Sep 17 00:00:00 2001 From: Divlo Date: Wed, 26 Jan 2022 00:21:08 +0100 Subject: [PATCH] Initial implementation of MD051/valid-link-fragments (refs #253, closes #495). --- README.md | 3 +- demo/markdownlint-browser.js | 55 +++++++++++++++++- doc/Rules.md | 29 +++++++++- lib/md051.js | 46 +++++++++++++++ lib/rules.js | 3 +- schema/.markdownlint.jsonc | 5 +- schema/.markdownlint.yaml | 5 +- schema/markdownlint-config-schema.json | 12 +++- .../detailed-results-MD041-MD050.results.json | 26 +++++++++ test/empty-links.md | 14 +++-- test/markdownlint-test.js | 6 +- test/spaces_inside_codespan_elements.md | 14 ++--- test/valid-link-fragments.md | 56 +++++++++++++++++++ 13 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 lib/md051.js create mode 100644 test/valid-link-fragments.md diff --git a/README.md b/README.md index d1d110ff..07600f0c 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ playground for learning and exploring. * **[MD048](doc/Rules.md#md048)** *code-fence-style* - Code fence style * **[MD049](doc/Rules.md#md049)** *emphasis-style* - Emphasis style should be consistent * **[MD050](doc/Rules.md#md050)** *strong-style* - Strong style should be consistent +* **[MD051](doc/Rules.md#md051)** *valid-link-fragments* - Link fragments should be valid @@ -142,7 +143,7 @@ rules at once. * **indentation** - MD005, MD006, MD007, MD027 * **language** - MD040 * **line_length** - MD013 -* **links** - MD011, MD034, MD039, MD042 +* **links** - MD011, MD034, MD039, MD042, MD051 * **ol** - MD029, MD030, MD032 * **spaces** - MD018, MD019, MD020, MD021, MD023 * **spelling** - MD044 diff --git a/demo/markdownlint-browser.js b/demo/markdownlint-browser.js index 5e7b6c1e..c950255e 100644 --- a/demo/markdownlint-browser.js +++ b/demo/markdownlint-browser.js @@ -4383,6 +4383,58 @@ module.exports = { }; +/***/ }), + +/***/ "../lib/md051.js": +/*!***********************!*\ + !*** ../lib/md051.js ***! + \***********************/ +/***/ ((module, __unused_webpack_exports, __webpack_require__) => { + +"use strict"; +// @ts-check + +var _a = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js"), addError = _a.addError, forEachHeading = _a.forEachHeading, filterTokens = _a.filterTokens; +/** + * Converts a Markdown heading into an HTML fragment + * according to the rules used by GitHub. + * + * @param {string} string The string to convert. + * @returns {string} The converted string. + */ +function convertHeadingToHTMLFragment(string) { + return "#" + string + .toLowerCase() + .replace(/ /g, "-") + .replace(/[^-_a-z0-9]/g, ""); +} +module.exports = { + "names": ["MD051", "valid-link-fragments"], + "description": "Link fragments should be valid", + "tags": ["links"], + "function": function MD051(params, onError) { + var validLinkFragments = []; + forEachHeading(params, function (_heading, content) { + validLinkFragments.push(convertHeadingToHTMLFragment(content)); + }); + filterTokens(params, "inline", function (token) { + token.children.forEach(function (child) { + var lineNumber = child.lineNumber, type = child.type, attrs = child.attrs; + if (type === "link_open") { + var href = attrs.find(function (attr) { return attr[0] === "href"; }); + if (href !== undefined && + href[1].startsWith("#") && + !validLinkFragments.includes(href[1])) { + var detail = "Link Fragment is invalid"; + addError(onError, lineNumber, detail, href[1]); + } + } + }); + }); + } +}; + + /***/ }), /***/ "../lib/rules.js": @@ -4441,7 +4493,8 @@ var rules = [ __webpack_require__(/*! ./md047 */ "../lib/md047.js"), __webpack_require__(/*! ./md048 */ "../lib/md048.js"), __webpack_require__(/*! ./md049 */ "../lib/md049.js"), - __webpack_require__(/*! ./md050 */ "../lib/md050.js") + __webpack_require__(/*! ./md050 */ "../lib/md050.js"), + __webpack_require__(/*! ./md051 */ "../lib/md051.js") ]; rules.forEach(function (rule) { var name = rule.names[0].toLowerCase(); diff --git a/doc/Rules.md b/doc/Rules.md index c0f23c5f..664484b9 100644 --- a/doc/Rules.md +++ b/doc/Rules.md @@ -56,7 +56,8 @@ Aliases: first-heading-h1, first-header-h1 Parameters: level (number; default 1) > Note: *MD002 has been deprecated and is disabled by default.* -> [MD041/first-line-heading](#md041) offers an improved implementation. +> [MD041/first-line-heading](#md041---first-line-in-a-file-should-be-a-top-level-heading) +offers an improved implementation. This rule is intended to ensure document headings start at the top level and is triggered when the first heading in the document isn't an h1 heading: @@ -783,7 +784,8 @@ The `lines_above` and `lines_below` parameters can be used to specify a differen number of blank lines (including 0) above or below each heading. Note: If `lines_above` or `lines_below` are configured to require more than one -blank line, [MD012/no-multiple-blanks](#md012) should also be customized. +blank line, [MD012/no-multiple-blanks](#md012---multiple-consecutive-blank-lines) +should also be customized. Rationale: Aside from aesthetic reasons, some parsers, including `kramdown`, will not parse headings that don't have a blank line before, and will parse them as @@ -1984,3 +1986,26 @@ The configured strong style can be a specific symbol to use ("asterisk", "underscore"), or can require that usage be consistent within the document. Rationale: Consistent formatting makes it easier to understand a document. + + + +## MD051 - Link fragments should be valid + +Tags: links + +Aliases: valid-link-fragments + +This rule is triggered if a link fragment does not correspond to a +heading within the document: + +```markdown +# Title + +[Link](#invalid-fragment) +``` + +To fix this issue, ensure that the heading exists, +here you could replace `#invalid-fragment` by `#title`. + +It's not part of the CommonMark specification, +for example [GitHub turn headings into links](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#section-links). diff --git a/lib/md051.js b/lib/md051.js new file mode 100644 index 00000000..9367d98b --- /dev/null +++ b/lib/md051.js @@ -0,0 +1,46 @@ +// @ts-check + +"use strict"; + +const { addError, forEachHeading, filterTokens } = require("../helpers"); + +/** + * Converts a Markdown heading into an HTML fragment + * according to the rules used by GitHub. + * + * @param {string} string The string to convert. + * @returns {string} The converted string. + */ +function convertHeadingToHTMLFragment(string) { + return "#" + string + .toLowerCase() + .replace(/ /g, "-") + .replace(/[^-_a-z0-9]/g, ""); +} + +module.exports = { + "names": [ "MD051", "valid-link-fragments" ], + "description": "Link fragments should be valid", + "tags": [ "links" ], + "function": function MD051(params, onError) { + const validLinkFragments = []; + forEachHeading(params, (_heading, content) => { + validLinkFragments.push(convertHeadingToHTMLFragment(content)); + }); + filterTokens(params, "inline", (token) => { + token.children.forEach((child) => { + const { lineNumber, type, attrs } = child; + if (type === "link_open") { + const href = attrs.find((attr) => attr[0] === "href"); + if (href !== undefined && + href[1].startsWith("#") && + !validLinkFragments.includes(href[1]) + ) { + const detail = "Link Fragment is invalid"; + addError(onError, lineNumber, detail, href[1]); + } + } + }); + }); + } +}; diff --git a/lib/rules.js b/lib/rules.js index 28c21214..71f9eddf 100644 --- a/lib/rules.js +++ b/lib/rules.js @@ -50,7 +50,8 @@ const rules = [ require("./md047"), require("./md048"), require("./md049"), - require("./md050") + require("./md050"), + require("./md051") ]; rules.forEach((rule) => { const name = rule.names[0].toLowerCase(); diff --git a/schema/.markdownlint.jsonc b/schema/.markdownlint.jsonc index a7bfa5b2..a594bc07 100644 --- a/schema/.markdownlint.jsonc +++ b/schema/.markdownlint.jsonc @@ -262,5 +262,8 @@ "MD050": { // Strong style should be consistent "style": "consistent" - } + }, + + // MD051/valid-link-fragments - Link fragments should be valid + "MD051": true } \ No newline at end of file diff --git a/schema/.markdownlint.yaml b/schema/.markdownlint.yaml index ce92ec5c..2eb1505e 100644 --- a/schema/.markdownlint.yaml +++ b/schema/.markdownlint.yaml @@ -236,4 +236,7 @@ MD049: # MD050/strong-style - Strong style should be consistent MD050: # Strong style should be consistent - style: "consistent" \ No newline at end of file + style: "consistent" + +# MD051/valid-link-fragments - Link fragments should be valid +MD051: true \ No newline at end of file diff --git a/schema/markdownlint-config-schema.json b/schema/markdownlint-config-schema.json index 2e537748..2e721ad7 100644 --- a/schema/markdownlint-config-schema.json +++ b/schema/markdownlint-config-schema.json @@ -878,6 +878,16 @@ "strong-style": { "$ref": "#/properties/MD050" }, + "MD051": { + "description": "MD051/valid-link-fragments - Link fragments should be valid", + "type": "boolean", + "default": true + }, + "valid-link-fragments": { + "description": "MD051/valid-link-fragments - Link fragments should be valid", + "type": "boolean", + "default": true + }, "headings": { "description": "headings - MD001, MD002, MD003, MD018, MD019, MD020, MD021, MD022, MD023, MD024, MD025, MD026, MD036, MD041, MD043", "type": "boolean", @@ -914,7 +924,7 @@ "default": true }, "links": { - "description": "links - MD011, MD034, MD039, MD042", + "description": "links - MD011, MD034, MD039, MD042, MD051", "type": "boolean", "default": true }, diff --git a/test/detailed-results-MD041-MD050.results.json b/test/detailed-results-MD041-MD050.results.json index e4c35d34..7005b9de 100644 --- a/test/detailed-results-MD041-MD050.results.json +++ b/test/detailed-results-MD041-MD050.results.json @@ -314,5 +314,31 @@ "MD050", "strong-style" ] + }, + { + "errorContext": "#", + "errorDetail": "Link Fragment is invalid", + "errorRange": null, + "fixInfo": null, + "lineNumber": 5, + "ruleDescription": "Link fragments should be valid", + "ruleInformation": "https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/Rules.md#md051", + "ruleNames": [ + "MD051", + "valid-link-fragments" + ] + }, + { + "errorContext": "#one", + "errorDetail": "Link Fragment is invalid", + "errorRange": null, + "fixInfo": null, + "lineNumber": 17, + "ruleDescription": "Link fragments should be valid", + "ruleInformation": "https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/Rules.md#md051", + "ruleNames": [ + "MD051", + "valid-link-fragments" + ] } ] \ No newline at end of file diff --git a/test/empty-links.md b/test/empty-links.md index a54971e5..4c2311d7 100644 --- a/test/empty-links.md +++ b/test/empty-links.md @@ -12,22 +12,24 @@ [text]( <> "title" ) {MD042} -[text](#) {MD042} +[text](#) {MD042} {MD051} -[text]( # ) {MD042} +[text]( # ) {MD042} {MD051} -[text](# "title") {MD042} +[text](# "title") {MD042} {MD051} -[text]( # "title" ) {MD042} +[text]( # "title" ) {MD042} {MD051} -[text][frag] {MD042} +[text][frag] {MD042} {MD051} -[text][ frag ] {MD042} +[text][ frag ] {MD042} {MD051} [frag]: # ## Non-empty links +### frag + [text](link) [text]( link ) diff --git a/test/markdownlint-test.js b/test/markdownlint-test.js index fef63213..b904fa2f 100644 --- a/test/markdownlint-test.js +++ b/test/markdownlint-test.js @@ -841,7 +841,7 @@ test.cb("customFileSystemAsync", (t) => { }); test.cb("readme", (t) => { - t.plan(119); + t.plan(121); const tagToRules = {}; rules.forEach(function forRule(rule) { rule.tags.forEach(function forTag(tag) { @@ -917,7 +917,7 @@ test.cb("readme", (t) => { }); test.cb("rules", (t) => { - t.plan(352); + t.plan(359); fs.readFile("doc/Rules.md", "utf8", (err, contents) => { t.falsy(err); @@ -1094,7 +1094,7 @@ test("validateConfigExampleJson", async(t) => { }); test("allBuiltInRulesHaveValidUrl", (t) => { - t.plan(138); + t.plan(141); rules.forEach(function forRule(rule) { t.truthy(rule.information); t.true(Object.getPrototypeOf(rule.information) === URL.prototype); diff --git a/test/spaces_inside_codespan_elements.md b/test/spaces_inside_codespan_elements.md index c8c0f4ed..85a868b1 100644 --- a/test/spaces_inside_codespan_elements.md +++ b/test/spaces_inside_codespan_elements.md @@ -78,21 +78,21 @@ Text [link](https://example.com/link`link`link`link) text `code`. Text [link](https://example.com/link "title`title") text `code`. -Text [link](#link`link) text `code`. +Text [link](#link`link) text `code`. {MD051} Text [link] (#link`link) text `code`. {MD038} -Text [link[link](#link`link) text `code`. +Text [link[link](#link`link) text `code`. {MD051} -Text [link(link](#link`link) text `code`. +Text [link(link](#link`link) text `code`. {MD051} -Text [link)link](#link`link) text `code`. +Text [link)link](#link`link) text `code`. {MD051} -Text [link](#link[link`link) text `code`. +Text [link](#link[linklink) text `code`. {MD051} -Text [link](#link]link`link) text `code`. +Text [link](#link[linklink) text `code`. {MD051} -Text [link](#link(link`link) text `code`. {MD038} +Text [link](#link[linklink) text `code`. {MD051} Text [`link`](xref:custom.link`1) text `code`. diff --git a/test/valid-link-fragments.md b/test/valid-link-fragments.md new file mode 100644 index 00000000..a40cefe1 --- /dev/null +++ b/test/valid-link-fragments.md @@ -0,0 +1,56 @@ +# Valid/Invalid Link Fragments + +## Valid Fragments + +[Valid](#validinvalid-link-fragments) + +[Valid](#valid-fragments) + +[Valid](#valid-h3-heading) + +[Valid](#valid-heading-with-underscores-_) + +[Valid](#valid-heading-with-quotes--and-double-quotes-) + +[Valid](#-valid-heading-with-emoji) + +[Valid](#valid-heading--with-emoji-2) + +[Valid](#valid-closed-atx-heading) + +[Valid](#valid-setex-heading) + +### Valid H3 Heading + +Text + +### Valid Heading With Underscores _ + +Text + +### Valid Heading With Quotes ' And Double Quotes " + +Text + +### 🚀 Valid Heading With Emoji + +Text + +### Valid Heading 👀 With Emoji 2 + +Text + + +### Valid Closed ATX Heading ### + +Text + + +Valid Setex Heading +---- + +Text + +## Invalid Fragments + +[Invalid](#invalid-fragments-not-exist) {MD051}