diff --git a/README.md b/README.md index e4f3d10c..5f2ccd27 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ export default defineConfig([ | :- | :- | :-: | | [`fenced-code-language`](./docs/rules/fenced-code-language.md) | Require languages for fenced code blocks | yes | | [`heading-increment`](./docs/rules/heading-increment.md) | Enforce heading levels increment by one | yes | +| [`no-duplicate-definitions`](./docs/rules/no-duplicate-definitions.md) | Disallow duplicate definitions | yes | | [`no-duplicate-headings`](./docs/rules/no-duplicate-headings.md) | Disallow duplicate headings in the same document | no | | [`no-empty-images`](./docs/rules/no-empty-images.md) | Disallow empty images | yes | | [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links | yes | diff --git a/docs/rules/no-duplicate-definitions.md b/docs/rules/no-duplicate-definitions.md new file mode 100644 index 00000000..0e8258f9 --- /dev/null +++ b/docs/rules/no-duplicate-definitions.md @@ -0,0 +1,91 @@ +# no-duplicate-definitions + +Disallow duplicate definitions. + +## Background + +In Markdown, it's possible to define the same definition identifier multiple times. However, this is usually a mistake, as it can lead to unintended or incorrect link, image, and footnote references. + +Please note that this rule does not report definition-style comments. For example: + +```markdown +[//]: # (This is a comment 1) +[//]: <> (This is a comment 2) +``` + +## Rule Details + +> [!IMPORTANT] +> +> The `FootnoteDefinition` node is detected only when using `language` mode [`markdown/gfm`](/README.md#languages). + +This rule warns when `Definition` and `FootnoteDefinition` type identifiers are defined multiple times. Please note that this rule is **case-insensitive**, meaning `earth` and `Earth` are treated as the same identifier. + +Examples of **incorrect** code: + +```markdown + + + +[mercury]: https://example.com/mercury/ +[mercury]: https://example.com/venus/ + +[earth]: https://example.com/earth/ +[Earth]: https://example.com/mars/ + + + +[^mercury]: Hello, Mercury! +[^mercury]: Hello, Venus! + +[^earth]: Hello, Earth! +[^Earth]: Hello, Mars! +``` + +Examples of **correct** code: + +```markdown + + + +[mercury]: https://example.com/mercury/ +[venus]: https://example.com/venus/ + + + +[^mercury]: Hello, Mercury! +[^venus]: Hello, Venus! + + + +[//]: # (This is a comment 1) +[//]: <> (This is a comment 2) +``` + +## Options + +The following options are available on this rule: + +- `allowDefinitions: Array` - when specified, duplicate definitions are allowed if they match one of the identifiers in this array. This is useful for ignoring definitions that are intentionally duplicated. (default: `["//"]`) + + Examples of **correct** code when configured as `"no-duplicate-definitions: ["error", { allowDefinitions: ["mercury"] }]`: + + ```markdown + + [mercury]: https://example.com/mercury/ + [mercury]: https://example.com/venus/ + ``` + +- `allowFootnoteDefinitions: Array` - when specified, duplicate footnote definitions are allowed if they match one of the identifiers in this array. This is useful for ignoring footnote definitions that are intentionally duplicated. (default: `[]`) + + Examples of **correct** code when configured as `"no-duplicate-definitions: ["error", { allowFootnoteDefinitions: ["mercury"] }]`: + + ```markdown + + [^mercury]: Hello, Mercury! + [^mercury]: Hello, Venus! + ``` + +## When Not to Use It + +If you are using a different style of definition comments, or not concerned with duplicate definitions, you can safely disable this rule. diff --git a/src/rules/no-duplicate-definitions.js b/src/rules/no-duplicate-definitions.js new file mode 100644 index 00000000..b4be6964 --- /dev/null +++ b/src/rules/no-duplicate-definitions.js @@ -0,0 +1,108 @@ +/** + * @fileoverview Rule to prevent duplicate definitions in Markdown. + * @author 루밀LuMir(lumirlumir) + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: [{ allowDefinitions: string[], allowFootnoteDefinitions: string[]; }]; }>} + * NoDuplicateDefinitionsRuleDefinition + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +/** @type {NoDuplicateDefinitionsRuleDefinition} */ +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: "Disallow duplicate definitions", + url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-duplicate-definitions.md", + }, + + messages: { + duplicateDefinition: "Unexpected duplicate definition found.", + duplicateFootnoteDefinition: + "Unexpected duplicate footnote definition found.", + }, + + schema: [ + { + type: "object", + properties: { + allowDefinitions: { + type: "array", + items: { + type: "string", + }, + uniqueItems: true, + }, + allowFootnoteDefinitions: { + type: "array", + items: { + type: "string", + }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + + defaultOptions: [ + { + allowDefinitions: ["//"], + allowFootnoteDefinitions: [], + }, + ], + }, + + create(context) { + const allowDefinitions = new Set(context.options[0]?.allowDefinitions); + const allowFootnoteDefinitions = new Set( + context.options[0]?.allowFootnoteDefinitions, + ); + + const definitions = new Set(); + const footnoteDefinitions = new Set(); + + return { + definition(node) { + if (allowDefinitions.has(node.identifier)) { + return; + } + + if (definitions.has(node.identifier)) { + context.report({ + node, + messageId: "duplicateDefinition", + }); + } else { + definitions.add(node.identifier); + } + }, + + footnoteDefinition(node) { + if (allowFootnoteDefinitions.has(node.identifier)) { + return; + } + + if (footnoteDefinitions.has(node.identifier)) { + context.report({ + node, + messageId: "duplicateFootnoteDefinition", + }); + } else { + footnoteDefinitions.add(node.identifier); + } + }, + }; + }, +}; diff --git a/tests/rules/no-duplicate-definitions.test.js b/tests/rules/no-duplicate-definitions.test.js new file mode 100644 index 00000000..2faa3540 --- /dev/null +++ b/tests/rules/no-duplicate-definitions.test.js @@ -0,0 +1,297 @@ +/** + * @fileoverview Tests for no-duplicate-definitions rule. + * @author 루밀LuMir(lumirlumir) + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-duplicate-definitions.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown, + }, + language: "markdown/gfm", +}); + +ruleTester.run("no-duplicate-definitions", rule, { + valid: [ + ` +[mercury]: https://example.com/mercury/ +`, + + ` +[mercury]: https://example.com/mercury/ +[venus]: https://example.com/venus/ +`, + + ` +[^mercury]: Hello, Mercury! +`, + + ` +[^mercury]: Hello, Mercury! +[^venus]: Hello, Venus! +`, + + ` +[alpha]: bravo + +[^alpha]: bravo +`, + + ` +[//]: # (This is a comment 1) +[//]: <> (This is a comment 2) +`, + + { + code: ` +[mercury]: https://example.com/mercury/ +[mercury]: https://example.com/venus/ +`, + options: [ + { + allowDefinitions: ["mercury"], + }, + ], + }, + + { + code: ` +[^mercury]: Hello, Mercury! +[^mercury]: Hello, Venus! +`, + options: [ + { + allowFootnoteDefinitions: ["mercury"], + }, + ], + }, + ], + + invalid: [ + { + code: ` +[mercury]: https://example.com/mercury/ +[mercury]: https://example.com/venus/ +`, + errors: [ + { + messageId: "duplicateDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 38, + }, + ], + }, + + { + code: ` +[mercury]: https://example.com/mercury/ +[mercury]: https://example.com/venus/ +[mercury]: https://example.com/earth/ +[mercury]: https://example.com/mars/ +`, + errors: [ + { + messageId: "duplicateDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 38, + }, + { + messageId: "duplicateDefinition", + line: 4, + column: 1, + endLine: 4, + endColumn: 38, + }, + { + messageId: "duplicateDefinition", + line: 5, + column: 1, + endLine: 5, + endColumn: 37, + }, + ], + }, + + { + code: ` +[mercury]: https://example.com/mercury/ +[Mercury]: https://example.com/venus/ +`, // case insensitive + errors: [ + { + messageId: "duplicateDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 38, + }, + ], + }, + + { + code: ` +[mercury]: https://example.com/mercury/ +[mercury ]: https://example.com/venus/ +`, + errors: [ + { + messageId: "duplicateDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 42, + }, + ], + }, + + { + code: ` +[mercury]: https://example.com/mercury/ +[mercury]: https://example.com/venus/ +`, + options: [ + { + allowDefinitions: ["venus"], + allowFootnoteDefinitions: ["mercury"], + }, + ], + + errors: [ + { + messageId: "duplicateDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 38, + }, + ], + }, + + { + code: ` +[^mercury]: Hello, Mercury! +[^mercury]: Hello, Venus! +`, + errors: [ + { + messageId: "duplicateFootnoteDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 26, + }, + ], + }, + + { + code: ` +[^mercury]: Hello, Mercury! +[^mercury]: Hello, Venus! +[^mercury]: Hello, Earth! +[^mercury]: Hello, Mars! +`, + errors: [ + { + messageId: "duplicateFootnoteDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 26, + }, + { + messageId: "duplicateFootnoteDefinition", + line: 4, + column: 1, + endLine: 4, + endColumn: 26, + }, + { + messageId: "duplicateFootnoteDefinition", + line: 5, + column: 1, + endLine: 5, + endColumn: 25, + }, + ], + }, + + { + code: ` +[^mercury]: Hello, Mercury! +[^Mercury]: Hello, Venus! +`, // case insensitive + errors: [ + { + messageId: "duplicateFootnoteDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 26, + }, + ], + }, + + { + code: ` +[^mercury]: Hello, Mercury! +[^mercury]: Hello, Venus! +`, + options: [ + { + allowDefinitions: ["mercury"], + allowFootnoteDefinitions: ["venus"], + }, + ], + + errors: [ + { + messageId: "duplicateFootnoteDefinition", + line: 3, + column: 1, + endLine: 3, + endColumn: 26, + }, + ], + }, + + { + code: ` +[mercury]: https://example.com/mercury/ +[earth]: https://example.com/earth/ +[mars]: https://example.com/mars/ + +[//]: # (comment about mars) + +[jupiter]: https://example.com/jupiter/ + +[//]: # (comment about jupiter) + +[mercury]: https://example.com/venus/ +`, + errors: [ + { + messageId: "duplicateDefinition", + line: 12, + column: 1, + endLine: 12, + endColumn: 38, + }, + ], + }, + ], +});