diff --git a/demo/markdownlint-browser.js b/demo/markdownlint-browser.js index 0a10b417..c79fb2ce 100644 --- a/demo/markdownlint-browser.js +++ b/demo/markdownlint-browser.js @@ -47,7 +47,8 @@ module.exports.inlineCommentStartRe = inlineCommentStartRe; const htmlElementRe = /<(([A-Za-z][A-Za-z0-9-]*)(?:\s[^`>]*)?)\/?>/g; module.exports.htmlElementRe = htmlElementRe; // Regular expressions for range matching -module.exports.bareUrlRe = /(?:http|ftp)s?:\/\/[^\s\]"']*(?:\/|[^\s\]"'\W])/ig; +module.exports.bareUrlRe = + /(?:http|ftp)s?:\/\/[^\s\]<>"'`]*(?:\/|[^\s\]<>"'`\W])/ig; module.exports.listItemMarkerRe = /^([\s>]*)(?:[*+-]|\d+[.)])\s+/; module.exports.orderedListItemMarkerRe = /^[\s>]*0*(\d+)[.)]/; // Regular expression for all instances of emphasis markers @@ -414,17 +415,22 @@ module.exports.flattenLists = function flattenLists(tokens) { } return flattenedLists; }; -// Calls the provided function for each specified inline child token -module.exports.forEachInlineChild = - function forEachInlineChild(params, type, handler) { - filterTokens(params, "inline", function forToken(token) { - for (const child of token.children) { - if (child.type === type) { - handler(child, token); - } - } - }); - }; +/** + * Calls the provided function for each specified inline child token. + * + * @param {Object} params RuleParams instance. + * @param {string} type Token type identifier. + * @param {Function} handler Callback function. + * @returns {void} + */ +function forEachInlineChild(params, type, handler) { + filterTokens(params, "inline", (token) => { + for (const child of token.children.filter((c) => c.type === type)) { + handler(child, token); + } + }); +} +module.exports.forEachInlineChild = forEachInlineChild; // Calls the provided function for each heading's content module.exports.forEachHeading = function forEachHeading(params, handler) { let heading = null; @@ -585,6 +591,7 @@ module.exports.codeBlockAndSpanRanges = (params, lineMetadata) => { */ module.exports.htmlElementRanges = (params, lineMetadata) => { const exclusions = []; + // Match with htmlElementRe forEachLine(lineMetadata, (line, lineIndex, inCode) => { let match = null; // eslint-disable-next-line no-unmodified-loop-condition @@ -592,6 +599,32 @@ module.exports.htmlElementRanges = (params, lineMetadata) => { exclusions.push([lineIndex, match.index, match[0].length]); } }); + // Match with html_inline + forEachInlineChild(params, "html_inline", (token, parent) => { + const parentContent = parent.content; + let tokenContent = token.content; + const parentIndex = parentContent.indexOf(tokenContent); + let deltaLines = 0; + let indent = 0; + for (let i = parentIndex - 1; i >= 0; i--) { + if (parentContent[i] === "\n") { + deltaLines++; + } + else if (deltaLines === 0) { + indent++; + } + } + let lineIndex = token.lineNumber - 1 + deltaLines; + do { + const index = tokenContent.indexOf("\n"); + const length = (index === -1) ? tokenContent.length : index; + exclusions.push([lineIndex, indent, length]); + tokenContent = tokenContent.slice(length + 1); + lineIndex++; + indent = 0; + } while (tokenContent.length > 0); + }); + // Return results return exclusions; }; /** @@ -3704,60 +3737,62 @@ module.exports = { "use strict"; // @ts-check -const { addErrorContext, bareUrlRe, filterTokens } = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js"); -const htmlLinkOpenRe = /^]/i; -const htmlLinkCloseRe = /^<\/a[\s>]/i; +const { addErrorContext, bareUrlRe, withinAnyRange } = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js"); +const { codeBlockAndSpanRanges, htmlElementRanges, referenceLinkImageData } = __webpack_require__(/*! ./cache */ "../lib/cache.js"); +const htmlLinkRe = /]+)>[^<>]*<\/a\s*>/ig; module.exports = { "names": ["MD034", "no-bare-urls"], "description": "Bare URL used", "tags": ["links", "url"], "function": function MD034(params, onError) { - filterTokens(params, "inline", (token) => { - let inLink = false; - let inInline = false; - for (const child of token.children) { - const { content, line, lineNumber, type } = child; + const { lines } = params; + const codeExclusions = [ + ...codeBlockAndSpanRanges(), + ...htmlElementRanges() + ]; + const { definitionLineIndices } = referenceLinkImageData(); + for (const [lineIndex, line] of lines.entries()) { + if (definitionLineIndices[0] === lineIndex) { + definitionLineIndices.shift(); + } + else { let match = null; - if (type === "link_open") { - inLink = true; + const lineExclusions = []; + while ((match = htmlLinkRe.exec(line)) !== null) { + lineExclusions.push([lineIndex, match.index, match[0].length]); } - else if (type === "link_close") { - inLink = false; - } - else if ((type === "html_inline") && htmlLinkOpenRe.test(content)) { - inInline = true; - } - else if ((type === "html_inline") && htmlLinkCloseRe.test(content)) { - inInline = false; - } - else if ((type === "text") && !inLink && !inInline) { - while ((match = bareUrlRe.exec(content)) !== null) { - const [bareUrl] = match; - const matchIndex = match.index; - const bareUrlLength = bareUrl.length; - // Allow "[LINK]" to avoid conflicts with MD011/no-reversed-links - // Allow quoting as a way of deliberately including a bare URL - const leftChar = content[matchIndex - 1]; - const rightChar = content[matchIndex + bareUrlLength]; - if (!((leftChar === "[") && (rightChar === "]")) && - !((leftChar === "\"") && (rightChar === "\"")) && - !((leftChar === "'") && (rightChar === "'"))) { - const index = line.indexOf(content); - const range = (index === -1) ? null : [ - index + matchIndex + 1, - bareUrlLength - ]; - const fixInfo = range ? { - "editColumn": range[0], - "deleteCount": range[1], - "insertText": `<${bareUrl}>` - } : null; - addErrorContext(onError, lineNumber, bareUrl, null, null, range, fixInfo); - } + while ((match = bareUrlRe.exec(line)) !== null) { + const [bareUrl] = match; + const matchIndex = match.index; + const bareUrlLength = bareUrl.length; + const prefix = line.slice(0, matchIndex); + const postfix = line.slice(matchIndex + bareUrlLength); + if ( + // Allow ](... to avoid reporting Markdown-style links + !(/\]\(\s*$/.test(prefix)) && + // Allow <...> to avoid reporting non-bare links + !(prefix.endsWith("<") && /^[#)]?>/.test(postfix)) && + // Allow [...] to avoid MD011/no-reversed-links and nested links + !(/\[[^\]]*$/.test(prefix) && /^[^[]*\]/.test(postfix)) && + // Allow "..." and '...' for deliberately including a bare link + !(prefix.endsWith("\"") && postfix.startsWith("\"")) && + !(prefix.endsWith("'") && postfix.startsWith("'")) && + !withinAnyRange(lineExclusions, lineIndex, matchIndex, bareUrlLength) && + !withinAnyRange(codeExclusions, lineIndex, matchIndex, bareUrlLength)) { + const range = [ + matchIndex + 1, + bareUrlLength + ]; + const fixInfo = { + "editColumn": range[0], + "deleteCount": range[1], + "insertText": `<${bareUrl}>` + }; + addErrorContext(onError, lineIndex + 1, bareUrl, null, null, range, fixInfo); } } } - }); + } } }; diff --git a/helpers/helpers.js b/helpers/helpers.js index 037b1cbb..54e53bd2 100644 --- a/helpers/helpers.js +++ b/helpers/helpers.js @@ -23,7 +23,8 @@ const htmlElementRe = /<(([A-Za-z][A-Za-z0-9-]*)(?:\s[^`>]*)?)\/?>/g; module.exports.htmlElementRe = htmlElementRe; // Regular expressions for range matching -module.exports.bareUrlRe = /(?:http|ftp)s?:\/\/[^\s\]"']*(?:\/|[^\s\]"'\W])/ig; +module.exports.bareUrlRe = + /(?:http|ftp)s?:\/\/[^\s\]<>"'`]*(?:\/|[^\s\]<>"'`\W])/ig; module.exports.listItemMarkerRe = /^([\s>]*)(?:[*+-]|\d+[.)])\s+/; module.exports.orderedListItemMarkerRe = /^[\s>]*0*(\d+)[.)]/; @@ -418,17 +419,22 @@ module.exports.flattenLists = function flattenLists(tokens) { return flattenedLists; }; -// Calls the provided function for each specified inline child token -module.exports.forEachInlineChild = +/** + * Calls the provided function for each specified inline child token. + * + * @param {Object} params RuleParams instance. + * @param {string} type Token type identifier. + * @param {Function} handler Callback function. + * @returns {void} + */ function forEachInlineChild(params, type, handler) { - filterTokens(params, "inline", function forToken(token) { - for (const child of token.children) { - if (child.type === type) { - handler(child, token); - } + filterTokens(params, "inline", (token) => { + for (const child of token.children.filter((c) => c.type === type)) { + handler(child, token); } }); -}; +} +module.exports.forEachInlineChild = forEachInlineChild; // Calls the provided function for each heading's content module.exports.forEachHeading = function forEachHeading(params, handler) { @@ -608,6 +614,7 @@ module.exports.codeBlockAndSpanRanges = (params, lineMetadata) => { */ module.exports.htmlElementRanges = (params, lineMetadata) => { const exclusions = []; + // Match with htmlElementRe forEachLine(lineMetadata, (line, lineIndex, inCode) => { let match = null; // eslint-disable-next-line no-unmodified-loop-condition @@ -615,6 +622,31 @@ module.exports.htmlElementRanges = (params, lineMetadata) => { exclusions.push([ lineIndex, match.index, match[0].length ]); } }); + // Match with html_inline + forEachInlineChild(params, "html_inline", (token, parent) => { + const parentContent = parent.content; + let tokenContent = token.content; + const parentIndex = parentContent.indexOf(tokenContent); + let deltaLines = 0; + let indent = 0; + for (let i = parentIndex - 1; i >= 0; i--) { + if (parentContent[i] === "\n") { + deltaLines++; + } else if (deltaLines === 0) { + indent++; + } + } + let lineIndex = token.lineNumber - 1 + deltaLines; + do { + const index = tokenContent.indexOf("\n"); + const length = (index === -1) ? tokenContent.length : index; + exclusions.push([ lineIndex, indent, length ]); + tokenContent = tokenContent.slice(length + 1); + lineIndex++; + indent = 0; + } while (tokenContent.length > 0); + }); + // Return results return exclusions; }; diff --git a/lib/md034.js b/lib/md034.js index 2c82cde9..27d6cb8a 100644 --- a/lib/md034.js +++ b/lib/md034.js @@ -2,67 +2,76 @@ "use strict"; -const { addErrorContext, bareUrlRe, filterTokens } = require("../helpers"); +const { addErrorContext, bareUrlRe, withinAnyRange } = require("../helpers"); +const { codeBlockAndSpanRanges, htmlElementRanges, referenceLinkImageData } = + require("./cache"); -const htmlLinkOpenRe = /^]/i; -const htmlLinkCloseRe = /^<\/a[\s>]/i; +const htmlLinkRe = /]+)>[^<>]*<\/a\s*>/ig; module.exports = { "names": [ "MD034", "no-bare-urls" ], "description": "Bare URL used", "tags": [ "links", "url" ], "function": function MD034(params, onError) { - filterTokens(params, "inline", (token) => { - let inLink = false; - let inInline = false; - for (const child of token.children) { - const { content, line, lineNumber, type } = child; + const { lines } = params; + const codeExclusions = [ + ...codeBlockAndSpanRanges(), + ...htmlElementRanges() + ]; + const { definitionLineIndices } = referenceLinkImageData(); + for (const [ lineIndex, line ] of lines.entries()) { + if (definitionLineIndices[0] === lineIndex) { + definitionLineIndices.shift(); + } else { let match = null; - if (type === "link_open") { - inLink = true; - } else if (type === "link_close") { - inLink = false; - } else if ((type === "html_inline") && htmlLinkOpenRe.test(content)) { - inInline = true; - } else if ((type === "html_inline") && htmlLinkCloseRe.test(content)) { - inInline = false; - } else if ((type === "text") && !inLink && !inInline) { - while ((match = bareUrlRe.exec(content)) !== null) { - const [ bareUrl ] = match; - const matchIndex = match.index; - const bareUrlLength = bareUrl.length; - // Allow "[LINK]" to avoid conflicts with MD011/no-reversed-links - // Allow quoting as a way of deliberately including a bare URL - const leftChar = content[matchIndex - 1]; - const rightChar = content[matchIndex + bareUrlLength]; - if ( - !((leftChar === "[") && (rightChar === "]")) && - !((leftChar === "\"") && (rightChar === "\"")) && - !((leftChar === "'") && (rightChar === "'")) - ) { - const index = line.indexOf(content); - const range = (index === -1) ? null : [ - index + matchIndex + 1, - bareUrlLength - ]; - const fixInfo = range ? { - "editColumn": range[0], - "deleteCount": range[1], - "insertText": `<${bareUrl}>` - } : null; - addErrorContext( - onError, - lineNumber, - bareUrl, - null, - null, - range, - fixInfo - ); - } + const lineExclusions = []; + while ((match = htmlLinkRe.exec(line)) !== null) { + lineExclusions.push([ lineIndex, match.index, match[0].length ]); + } + while ((match = bareUrlRe.exec(line)) !== null) { + const [ bareUrl ] = match; + const matchIndex = match.index; + const bareUrlLength = bareUrl.length; + const prefix = line.slice(0, matchIndex); + const postfix = line.slice(matchIndex + bareUrlLength); + if ( + // Allow ](... to avoid reporting Markdown-style links + !(/\]\(\s*$/.test(prefix)) && + // Allow <...> to avoid reporting non-bare links + !(prefix.endsWith("<") && /^[#)]?>/.test(postfix)) && + // Allow [...] to avoid MD011/no-reversed-links and nested links + !(/\[[^\]]*$/.test(prefix) && /^[^[]*\]/.test(postfix)) && + // Allow "..." and '...' for deliberately including a bare link + !(prefix.endsWith("\"") && postfix.startsWith("\"")) && + !(prefix.endsWith("'") && postfix.startsWith("'")) && + !withinAnyRange( + lineExclusions, lineIndex, matchIndex, bareUrlLength + ) && + !withinAnyRange( + codeExclusions, lineIndex, matchIndex, bareUrlLength + ) + ) { + const range = [ + matchIndex + 1, + bareUrlLength + ]; + const fixInfo = { + "editColumn": range[0], + "deleteCount": range[1], + "insertText": `<${bareUrl}>` + }; + addErrorContext( + onError, + lineIndex + 1, + bareUrl, + null, + null, + range, + fixInfo + ); } } } - }); + } } }; diff --git a/test/bare-urls.md b/test/bare-urls.md index dd65041b..5d33fb5d 100644 --- a/test/bare-urls.md +++ b/test/bare-urls.md @@ -29,3 +29,21 @@ As is https://example.com/info.htm text Another violation: https://example.com. {MD034}

Another violation: https://example.com. {MD034}
+ +This is not a bare [link]( https://example.com ). + +URLs in HTML are not bare: + + + Text + + + + +URLs in link and image text are not bare: + +Text [link to https://example.com site](https://example.com) text. + +Image ![for https://example.com site](https://example.com) text. diff --git a/test/links.md b/test/links.md index ee524fdd..7c8ea882 100644 --- a/test/links.md +++ b/test/links.md @@ -28,3 +28,9 @@ Other enclosures are not allowed: (https://example.com) {MD034} {https://example.com} {MD034} + +Duplicate links in tables should be handled: + +| Link | Same Link | Violation | +|----------------------|----------------------|-----------| +| https://example.com/ | https://example.com/ | {MD034} | diff --git a/test/snapshots/markdownlint-test-scenarios.js.md b/test/snapshots/markdownlint-test-scenarios.js.md index 0ccdf5fe..73629fdb 100644 --- a/test/snapshots/markdownlint-test-scenarios.js.md +++ b/test/snapshots/markdownlint-test-scenarios.js.md @@ -2998,6 +2998,24 @@ Generated by [AVA](https://avajs.dev).
Another violation: . {MD034}
␊ ␊
Another violation: . {MD034}
␊ + ␊ + This is not a bare [link]( https://example.com ).␊ + ␊ + URLs in HTML are not bare:␊ + ␊ + ␊ + Text␊ + ␊ + ␊ + ␊ + ␊ + URLs in link and image text are not bare:␊ + ␊ + Text [link to https://example.com site](https://example.com) text.␊ + ␊ + Image ![for https://example.com site](https://example.com) text.␊ `, } @@ -21928,8 +21946,15 @@ Generated by [AVA](https://avajs.dev). { errorContext: 'https://example.com/same', errorDetail: null, - errorRange: null, - fixInfo: null, + errorRange: [ + 46, + 24, + ], + fixInfo: { + deleteCount: 24, + editColumn: 46, + insertText: '', + }, lineNumber: 26, ruleDescription: 'Bare URL used', ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md034.md', @@ -22144,7 +22169,7 @@ Generated by [AVA](https://avajs.dev). ␊ Text more text still more text done {MD034}␊ ␊ - Text more \\* text https://example.com/same more \\[ text done {MD034}␊ + Text more \\* text more \\[ text done {MD034}␊ ␊ Text more text still more text done {MD034}␊ ␊ @@ -22379,6 +22404,46 @@ Generated by [AVA](https://avajs.dev). 'no-bare-urls', ], }, + { + errorContext: 'https://example.com/', + errorDetail: null, + errorRange: [ + 3, + 20, + ], + fixInfo: { + deleteCount: 20, + editColumn: 3, + insertText: '', + }, + lineNumber: 36, + ruleDescription: 'Bare URL used', + ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md034.md', + ruleNames: [ + 'MD034', + 'no-bare-urls', + ], + }, + { + errorContext: 'https://example.com/', + errorDetail: null, + errorRange: [ + 26, + 20, + ], + fixInfo: { + deleteCount: 20, + editColumn: 26, + insertText: '', + }, + lineNumber: 36, + ruleDescription: 'Bare URL used', + ruleInformation: 'https://github.com/DavidAnson/markdownlint/blob/v0.0.0/doc/md034.md', + ruleNames: [ + 'MD034', + 'no-bare-urls', + ], + }, ], fixed: `# Link test␊ ␊ @@ -22410,6 +22475,12 @@ Generated by [AVA](https://avajs.dev). ␊ () {MD034}␊ {} {MD034}␊ + ␊ + Duplicate links in tables should be handled:␊ + ␊ + | Link | Same Link | Violation |␊ + |----------------------|----------------------|-----------|␊ + | | | {MD034} |␊ `, } diff --git a/test/snapshots/markdownlint-test-scenarios.js.snap b/test/snapshots/markdownlint-test-scenarios.js.snap index 8ad7d68a..66d4f5b0 100644 Binary files a/test/snapshots/markdownlint-test-scenarios.js.snap and b/test/snapshots/markdownlint-test-scenarios.js.snap differ