diff --git a/README.md b/README.md index 4d5ae5a3..6d9e38fe 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ export default defineConfig([ | **Rule Name** | **Description** | **Recommended** | | :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------ | :-------------: | | [`fenced-code-language`](./docs/rules/fenced-code-language.md) | Require languages for fenced code blocks | yes | +| [`fenced-code-meta`](./docs/rules/fenced-code-meta.md) | Require or disallow metadata for fenced code blocks | no | | [`heading-increment`](./docs/rules/heading-increment.md) | Enforce heading levels increment by one | yes | | [`no-bare-urls`](./docs/rules/no-bare-urls.md) | Disallow bare URLs | no | | [`no-duplicate-definitions`](./docs/rules/no-duplicate-definitions.md) | Disallow duplicate definitions | yes | diff --git a/docs/rules/fenced-code-meta.md b/docs/rules/fenced-code-meta.md new file mode 100644 index 00000000..c3571ea7 --- /dev/null +++ b/docs/rules/fenced-code-meta.md @@ -0,0 +1,76 @@ +# fenced-code-meta + +Require or disallow metadata for fenced code blocks. + +## Background + +Fenced code blocks can include an [info string](https://spec.commonmark.org/0.31.2/#info-string) after the opening fence. The first word typically specifies the language (e.g., `js`). Many tools also support additional metadata after the language (separated by whitespace), such as titles or line highlighting parameters. This rule enforces a consistent policy for including such metadata. + +## Rule Details + +This rule warns when the presence of metadata in a fenced code block's info string does not match the configured mode. + +Examples of **incorrect** code for this rule: + +````markdown + + +```js +console.log("Hello, world!"); +``` +```` + +## Options + +This rule accepts a single string option: + +- `"always"` (default): Require metadata when a language is specified. +- `"never"`: Disallow metadata in the info string. + +Examples of **incorrect** code when configured as `"fenced-code-meta": ["error", "always"]`: + +````markdown + + +```js +console.log("Hello, world!"); +``` +```` + +Examples of **correct** code when configured as `"fenced-code-meta": ["error", "always"]`: + +````markdown + + +```js title="example.js" +console.log("Hello, world!"); +``` +```` + +Examples of **incorrect** code when configured as `"fenced-code-meta": ["error", "never"]`: + +````markdown + + +```js title="example.js" +console.log("Hello, world!"); +``` +```` + +Examples of **correct** code when configured as `"fenced-code-meta": ["error", "never"]`: + +````markdown + + +```js +console.log("Hello, world!"); +``` +```` + +## When Not to Use It + +If you aren't concerned with metadata in info strings, you can safely disable this rule. + +## Prior Art + +* [MD040 - Fenced code blocks should have a language specified](https://github.com/DavidAnson/markdownlint/blob/main/doc/md040.md) diff --git a/src/rules/fenced-code-meta.js b/src/rules/fenced-code-meta.js new file mode 100644 index 00000000..5a4294a2 --- /dev/null +++ b/src/rules/fenced-code-meta.js @@ -0,0 +1,102 @@ +/** + * @fileoverview Rule to require or disallow metadata for fenced code blocks. + * @author TKDev7 + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @import { MarkdownRuleDefinition } from "../types.js"; + * @typedef {"missingMetadata" | "disallowedMetadata"} FencedCodeMetaMessageIds + * @typedef {["always" | "never"]} FencedCodeMetaOptions + * @typedef {MarkdownRuleDefinition<{ RuleOptions: FencedCodeMetaOptions, MessageIds: FencedCodeMetaMessageIds }>} FencedCodeMetaRuleDefinition + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +/** @type {FencedCodeMetaRuleDefinition} */ +export default { + meta: { + type: "problem", + + docs: { + recommended: false, + description: "Require or disallow metadata for fenced code blocks", + url: "https://github.com/eslint/markdown/blob/main/docs/rules/fenced-code-meta.md", + }, + + messages: { + missingMetadata: "Missing code block metadata.", + disallowedMetadata: "Code block metadata is not allowed.", + }, + + schema: [ + { + enum: ["always", "never"], + }, + ], + + defaultOptions: ["always"], + }, + + create(context) { + const [mode] = context.options; + const { sourceCode } = context; + + return { + code(node) { + const lineText = sourceCode.lines[node.position.start.line - 1]; + const fenceLineText = lineText.slice( + node.position.start.column - 1, + ); + + if (mode === "always") { + if (node.lang && !node.meta) { + const langIndex = fenceLineText.indexOf(node.lang); + + context.report({ + loc: { + start: node.position.start, + end: { + line: node.position.start.line, + column: + node.position.start.column + + langIndex + + node.lang.length, + }, + }, + messageId: "missingMetadata", + }); + } + + return; + } + + if (node.meta) { + const metaIndex = fenceLineText.lastIndexOf(node.meta); + + context.report({ + loc: { + start: { + line: node.position.start.line, + column: node.position.start.column + metaIndex, + }, + end: { + line: node.position.start.line, + column: + node.position.start.column + + metaIndex + + node.meta.trimEnd().length, + }, + }, + messageId: "disallowedMetadata", + }); + } + }, + }; + }, +}; diff --git a/tests/rules/fenced-code-meta.test.js b/tests/rules/fenced-code-meta.test.js new file mode 100644 index 00000000..e7b07c65 --- /dev/null +++ b/tests/rules/fenced-code-meta.test.js @@ -0,0 +1,347 @@ +/** + * @fileoverview Tests for fenced-code-meta rule. + * @author TKDev7 + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/fenced-code-meta.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown, + }, + language: "markdown/commonmark", +}); + +ruleTester.run("fenced-code-meta", rule, { + valid: [ + dedent`\`\`\` + console.log("Hello, world!"); + \`\`\``, + dedent`\`\`\`js foo + console.log("Hello, world!"); + \`\`\``, + dedent`\`\`\` js foo + console.log("Hello, world!"); + \`\`\``, + { + code: dedent`\`\`\` + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + }, + { + code: dedent`\`\`\`js + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + }, + { + code: dedent`\`\`\` js + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + }, + dedent`~~~ + console.log("Hello, world!"); + ~~~`, + dedent`~~~js foo + console.log("Hello, world!"); + ~~~`, + dedent`~~~ js foo + console.log("Hello, world!"); + ~~~`, + { + code: dedent`~~~ + console.log("Hello, world!"); + ~~~`, + options: ["never"], + }, + { + code: dedent`~~~js + console.log("Hello, world!"); + ~~~`, + options: ["never"], + }, + { + code: dedent`~~~ js + console.log("Hello, world!"); + ~~~`, + options: ["never"], + }, + '\tconsole.log("Hello, world!");', + { + code: '\tconsole.log("Hello, world!");', + options: ["never"], + }, + '\n console.log("Hello, world!")\n', + { + code: '\n console.log("Hello, world!")\n', + options: ["never"], + }, + ], + invalid: [ + { + code: dedent`\`\`\`javascript + console.log("Hello, world!"); + \`\`\``, + errors: [ + { + messageId: "missingMetadata", + line: 1, + column: 1, + endLine: 1, + endColumn: 14, + }, + ], + }, + { + code: dedent`~~~javascript + console.log("Hello, world!"); + ~~~`, + errors: [ + { + messageId: "missingMetadata", + line: 1, + column: 1, + endLine: 1, + endColumn: 14, + }, + ], + }, + { + code: dedent`\`\`\` js + console.log("Hello, world!"); + \`\`\``, + errors: [ + { + messageId: "missingMetadata", + line: 1, + column: 1, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: dedent`~~~ js + console.log("Hello, world!"); + ~~~`, + errors: [ + { + messageId: "missingMetadata", + line: 1, + column: 1, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: " ```javascript\nconsole.log('Hello, world!');\n```", + errors: [ + { + messageId: "missingMetadata", + line: 1, + column: 3, + endLine: 1, + endColumn: 16, + }, + ], + }, + { + code: " ~~~javascript\nconsole.log('Hello, world!');\n~~~", + errors: [ + { + messageId: "missingMetadata", + line: 1, + column: 3, + endLine: 1, + endColumn: 16, + }, + ], + }, + { + code: dedent`\`\`\`js title="example.js" + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 7, + endLine: 1, + endColumn: 25, + }, + ], + }, + { + code: dedent`~~~js title="example.js" + console.log("Hello, world!"); + ~~~`, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 7, + endLine: 1, + endColumn: 25, + }, + ], + }, + { + code: " ```js title='example.js'\nconsole.log('Hello, world!');\n```", + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 9, + endLine: 1, + endColumn: 27, + }, + ], + }, + { + code: " ~~~js title='example.js'\nconsole.log('Hello, world!');\n~~~", + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 9, + endLine: 1, + endColumn: 27, + }, + ], + }, + { + code: dedent`\`\`\`js foo bar + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 7, + endLine: 1, + endColumn: 14, + }, + ], + }, + { + code: dedent`~~~js foo bar + console.log("Hello, world!"); + ~~~`, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 7, + endLine: 1, + endColumn: 14, + }, + ], + }, + { + code: dedent`\`\`\` js foo + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 8, + endLine: 1, + endColumn: 11, + }, + ], + }, + { + code: dedent`~~~ js foo + console.log("Hello, world!"); + ~~~`, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 8, + endLine: 1, + endColumn: 11, + }, + ], + }, + { + code: dedent`\`\`\`js js + console.log("Hello, world!"); + \`\`\``, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 7, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: dedent`~~~js js + console.log("Hello, world!"); + ~~~`, + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 7, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: '``` js foo \nconsole.log("Hello, world!");\n```', + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 10, + endLine: 1, + endColumn: 13, + }, + ], + }, + { + code: '~~~ js foo \nconsole.log("Hello, world!");\n~~~', + options: ["never"], + errors: [ + { + messageId: "disallowedMetadata", + line: 1, + column: 10, + endLine: 1, + endColumn: 13, + }, + ], + }, + ], +});