Skip to content

Commit

Permalink
Add no-invalid-label-ref; add util
Browse files Browse the repository at this point in the history
  • Loading branch information
nzakas committed Aug 13, 2024
1 parent 47d94e7 commit 21b5226
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 28 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default [
| [`no-duplicate-headings`](./docs/rules/no-duplicate-headings.md) | Disallow duplicate headings in the same document. |
| [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links. |
| [`no-html`](./docs/rules/no-html.md) | Enforce fenced code blocks to specify a language. |
| [`no-invalid-label-refs`](./docs/rules/no-invalid-label-refs.md) | Disallow invalid label references. |
| [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references. |

**Note:** This plugin does not provide formatting rules. We recommend using a source code formatter such as [Prettier](https://prettier.io) for that purpose.
Expand Down
37 changes: 37 additions & 0 deletions docs/rules/no-invalid-label-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# no-invalid-label-refs

Disallow invalid label references.

## Background

CommonMark allows you to specify a label as a placeholder for a URL in both links and images using square brackets, such as:

```markdown
[ESLint][eslint]
[eslint][]
[eslint]

[eslint]: https://eslint.org
```

The shorthand form, `[label][]` does not allow any white space between the brackets, and when found, doesn't treat this as a link reference.

Confusingly, GitHub still treats this as a label reference and will render it as if there is no white space between the brackets. Relying on this behavior could result in errors when using CommonMark-compliant renderers.

## Rule Details

This rule warns when it finds text that looks like it's a shorthand label reference and there's white space between the brackets.

Examples of incorrect code:

```markdown
[eslint][ ]

[eslint][

]
```

## When Not to Use It

If you publish your Markdown exclusively on GitHub, then you can safely disable this rule.
140 changes: 140 additions & 0 deletions src/rules/no-invalid-label-refs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* @fileoverview Rule to prevent non-complaint link references.
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------

import { findOffsets, illegalShorthandTailPattern } from "../util.js";

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @typedef {import("unist").Position} Position */
/** @typedef {import("mdast").Text} TextNode */
/** @typedef {import("eslint").Rule.RuleModule} RuleModule */

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

// matches i.e., [foo][bar]
const labelPattern = /\]\[([^\]]+)\]/u;


/**
* Finds missing references in a node.
* @param {TextNode} node The node to check.
* @param {string} text The text of the node.
* @returns {Array<{label:string,position:Position}>} The missing references.
*/
function findIllegalLabelReferences(node, text) {

const invalid = [];
let startIndex = 0;

while (startIndex < node.value.length) {

const value = node.value.slice(startIndex);
const match = value.match(labelPattern);

if (!match) {
break;
}

if (!illegalShorthandTailPattern.test(match[0])) {
continue;
}

let columnStart = startIndex + match.index + 1;

// adding 1 to the index just in case we're in a ![] and need to skip the !.
const startFrom = node.position.start.offset + startIndex + 1;
const lastOpenBracket = text.lastIndexOf("[", startFrom);

if (lastOpenBracket === -1) {
startIndex += match.index + match[0].length;
continue;
}

const label = text.slice(lastOpenBracket, match.index + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1];

columnStart -= label.length;

const {
lineOffset,
columnOffset
} = findOffsets(node.value, columnStart);

const line = node.position.start.line + lineOffset;

/*
* If the columnOffset is 0, then the column is at the start of the line.
* In that case, we need to adjust the column number to be 1.
*/
invalid.push({
label: label.trim(),
position: {
start: {
line,
column: columnOffset || 1
},
end: {
line,
column: columnOffset + label.length
}
}
});

startIndex += match.index + match[0].length;
}

return invalid;

}

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------

/** @type {RuleModule} */
export default {
meta: {
type: "problem",

docs: {
recommended: true,
description: "Disallow invalid label references."
},

messages: {
illegalLabelRef: "Label reference '{{label}}' is invalid due to white space between [ and ]."
}
},

create(context) {

const { sourceCode } = context;

return {

text(node) {
const invalidReferences = findIllegalLabelReferences(node, sourceCode.text);

for (const invalidReference of invalidReferences) {
context.report({
loc: invalidReference.position,
messageId: "illegalLabelRef",
data: {
label: invalidReference.label
}
});
}
}

};
}
};
42 changes: 14 additions & 28 deletions src/rules/no-missing-label-refs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------

import { findOffsets, illegalShorthandTailPattern } from "../util.js";

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
Expand All @@ -21,39 +27,13 @@ const labelPatterns = [
/\]\[([^\]]+)\]/u,

// [foo][]
/(\]\[\s*\])/u,
/(\]\[\])/u,

// [foo]
/\[([^\]]+)\]/u
];

const shorthandTailPattern = /\]\[\s*\]$/u;

/**
* Finds the line and column offsets for a given start offset in a string.
* @param {string} text The text to search.
* @param {number} startOffset The offset to find.
* @returns {{lineOffset:number,columnOffset:number}} The location of the offset.
*/
function findOffsets(text, startOffset) {

let lineOffset = 0;
let columnOffset = 0;

for (let i = 0; i < startOffset; i++) {
if (text[i] === "\n") {
lineOffset++;
columnOffset = 0;
} else {
columnOffset++;
}
}

return {
lineOffset,
columnOffset
};
}
const shorthandTailPattern = /\]\[\]$/u;

/**
* Finds missing references in a node.
Expand Down Expand Up @@ -86,6 +66,12 @@ function findMissingReferences(node, text) {
let label = match[1];
let columnStart = startIndex + match.index + 1;

// check for illegal shorthand tail
if (illegalShorthandTailPattern.test(match[0])) {
startIndex += match.index + match[0].length;
continue;
}

// need to look backward to get the label
if (shorthandTailPattern.test(match[0])) {

Expand Down
38 changes: 38 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @fileoverview Utility Library
* @author Nicholas C. Zakas
*/


/*
* CommonMark does not allow any white space between the brackets in a reference link.
* If that pattern is detected, then it's treated as text and not as a link. This pattern
* is used to detect that situation.
*/
export const illegalShorthandTailPattern = /\]\[\s+\]$/u;

/**
* Finds the line and column offsets for a given start offset in a string.
* @param {string} text The text to search.
* @param {number} startOffset The offset to find.
* @returns {{lineOffset:number,columnOffset:number}} The location of the offset.
*/
export function findOffsets(text, startOffset) {

let lineOffset = 0;
let columnOffset = 0;

for (let i = 0; i < startOffset; i++) {
if (text[i] === "\n") {
lineOffset++;
columnOffset = 0;
} else {
columnOffset++;
}
}

return {
lineOffset,
columnOffset
};
}
63 changes: 63 additions & 0 deletions tests/rules/no-invalid-label-refs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @fileoverview Tests for no-invalid-label-refs rule.
* @author Nicholas C. Zakas
*/

//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------

import rule from "../../src/rules/no-invalid-label-refs.js";
import markdown from "../../src/index.js";
import { RuleTester } from "eslint";

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
plugins: {
markdown
},
language: "markdown/commonmark"
});

ruleTester.run("no-invalid-label-refs", rule, {
valid: [
"[*foo*]",
"[foo]\n\n[foo]: http://bar.com",
"[foo][ foo ]\n\n[foo]: http://bar.com",
"![foo][foo]\n\n[foo]: http://bar.com/image.jpg",
"[foo][]\n\n[foo]: http://bar.com/image.jpg",
"![foo][]\n\n[foo]: http://bar.com/image.jpg",
"[ foo ][]\n\n[foo]: http://bar.com/image.jpg",

Check failure on line 33 in tests/rules/no-invalid-label-refs.test.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected trailing comma
],
invalid: [
{
code: "[foo][ ]\n\n[foo]: http://bar.com/image.jpg",
errors: [
{
messageId: "illegalLabelRef",
data: { label: "foo" },
line: 1,
column: 2,
endLine: 1,
endColumn: 5
}
]
},
{
code: "[\nfoo\n][\n]\n\n[foo]: http://bar.com/image.jpg",
errors: [
{
messageId: "illegalLabelRef",
data: { label: "foo" },
line: 2,
column: 1,
endLine: 2,
endColumn: 5
}
]
}
]
});
2 changes: 2 additions & 0 deletions tests/rules/no-missing-label-refs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ ruleTester.run("no-missing-label-refs", rule, {
"[*foo*]",
"[foo]\n\n[foo]: http://bar.com",
"[foo][foo]\n\n[foo]: http://bar.com",
"[foo][foo]\n\n[ foo ]: http://bar.com",
"[foo][ foo ]\n\n[ foo ]: http://bar.com",
"![foo][foo]\n\n[foo]: http://bar.com/image.jpg",
"[foo][]\n\n[foo]: http://bar.com/image.jpg",
"![foo][]\n\n[foo]: http://bar.com/image.jpg",
Expand Down

0 comments on commit 21b5226

Please sign in to comment.