diff --git a/src/content-linter/README.md b/src/content-linter/README.md index c072251536..e0b9ca4feb 100644 --- a/src/content-linter/README.md +++ b/src/content-linter/README.md @@ -34,6 +34,7 @@ We are using the [markdownlint](https://github.com/DavidAnson/markdownlint) fram | [MD113](./linting-rules/internal-links-slash.js) | Internal links must start with a `/`. | error | | [MD114](./linting-rules/internal-links-lang.js) | Internal links must not have a hardcoded language code. | error | | [MD115](./linting-rules/image-file-kebab.js) | Image file names should be lowercase kebab case. | error | +| [MD117](./linting-rules/code-fence-line-length.js) | Code fence content should be 60 lines or less in length. | warning | ## Linting Tests diff --git a/src/content-linter/lib/init-test.js b/src/content-linter/lib/init-test.js new file mode 100644 index 0000000000..91b84f4edb --- /dev/null +++ b/src/content-linter/lib/init-test.js @@ -0,0 +1,8 @@ +import markdownlint from 'markdownlint' + +import { testOptions } from './default-markdownlint-options.js' + +export async function runRule(module, fixtureFile) { + const options = testOptions(module.names[0], module, fixtureFile) + return await markdownlint.promises.markdownlint(options) +} diff --git a/src/content-linter/lib/linting-rules/code-fence-line-length.js b/src/content-linter/lib/linting-rules/code-fence-line-length.js new file mode 100644 index 0000000000..7b6576282a --- /dev/null +++ b/src/content-linter/lib/linting-rules/code-fence-line-length.js @@ -0,0 +1,33 @@ +import { addError } from 'markdownlint-rule-helpers' + +import { getCodeFenceTokens, getCodeFenceLines } from '../markdownlint-helpers.js' + +export const codeFenceLineLength = { + names: ['MD117', 'code-fence-line-length'], + description: 'Code fence lines should not exceed a maximum length', + tags: ['code'], + severity: 'warning', + function: function MD117(params, onError) { + const MAX_LINE_LENGTH = String(params.config.maxLength || 60) + const codeFenceTokens = getCodeFenceTokens(params) + codeFenceTokens.forEach((token) => { + const lines = getCodeFenceLines(token) + lines.forEach((line, index) => { + if (line.length > MAX_LINE_LENGTH) { + // The token line number is the line number of the first line of the + // code fence. We want to report the line number of the content within + // the code fence so we need to add 1 + the index. + const lineNumber = token.lineNumber + index + 1 + addError( + onError, + lineNumber, + `Code fence line exceeds ${MAX_LINE_LENGTH} characters.`, + undefined, // N/A + undefined, // N/A + undefined, // N/A + ) + } + }) + }) + }, +} diff --git a/src/content-linter/lib/markdownlint-helpers.js b/src/content-linter/lib/markdownlint-helpers.js new file mode 100644 index 0000000000..26825b0876 --- /dev/null +++ b/src/content-linter/lib/markdownlint-helpers.js @@ -0,0 +1,9 @@ +import { newLineRe } from 'markdownlint-rule-helpers' + +export function getCodeFenceTokens(params) { + return params.tokens.filter((t) => t.type === 'fence') +} + +export function getCodeFenceLines(token) { + return token.content.split(newLineRe) +} diff --git a/src/content-linter/scripts/markdownlint.js b/src/content-linter/scripts/markdownlint.js index 31a313388a..0396f27cca 100755 --- a/src/content-linter/scripts/markdownlint.js +++ b/src/content-linter/scripts/markdownlint.js @@ -85,6 +85,7 @@ async function main() { MD113: true, MD114: true, MD115: true, + MD117: true, } const files = walkFiles(path, ['.md'], { includeBasePath: true }) diff --git a/src/content-linter/tests/fixtures/code-fence-line-length.md b/src/content-linter/tests/fixtures/code-fence-line-length.md new file mode 100644 index 0000000000..b0af054972 --- /dev/null +++ b/src/content-linter/tests/fixtures/code-fence-line-length.md @@ -0,0 +1,31 @@ +# Heading + +Line length exceeds max length by 1 + +```shell +111 +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +bbb +``` + +Line length equals max length + +```shell +111 +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +bbbbbbb +``` + +Line length is less than max length + +```shell +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +``` + +Multiple lines in code fence exceed max length by 1 + +```shell +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaccc +1 +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbb +``` diff --git a/src/content-linter/tests/unit/code-fence-line-length.js b/src/content-linter/tests/unit/code-fence-line-length.js new file mode 100644 index 0000000000..2d5587bf8e --- /dev/null +++ b/src/content-linter/tests/unit/code-fence-line-length.js @@ -0,0 +1,29 @@ +import { jest } from '@jest/globals' + +import { runRule } from '../../lib/init-test.js' +import { codeFenceLineLength } from '../../lib/linting-rules/code-fence-line-length.js' + +jest.setTimeout(60 * 1000) + +const fixtureFilePath = 'src/content-linter/tests/fixtures/code-fence-line-length.md' +const result = await runRule(codeFenceLineLength, fixtureFilePath) +const errors = result[fixtureFilePath] + +describe(codeFenceLineLength.names.join(' - '), () => { + test('line length of max length + 1 fails', async () => { + expect(errors.map((error) => error.lineNumber).includes(7)).toBe(true) + }) + test('line length equals max length passes', async () => { + expect(errors.map((error) => error.lineNumber).includes(15)).toBe(false) + }) + test('line length less than max length passes', async () => { + expect(errors.map((error) => error.lineNumber).includes(22)).toBe(false) + }) + test('multiple lines in code block that exceed max length fail', async () => { + expect(errors.map((error) => error.lineNumber).includes(28)).toBe(true) + expect(errors.map((error) => error.lineNumber).includes(30)).toBe(true) + }) + test('errors only occur on expected lines', async () => { + expect(errors.length).toBe(3) + }) +}) diff --git a/src/content-linter/tests/unit/image-alt-text-end-punctuation.js b/src/content-linter/tests/unit/image-alt-text-end-punctuation.js index 69d399d7d2..49571646e4 100644 --- a/src/content-linter/tests/unit/image-alt-text-end-punctuation.js +++ b/src/content-linter/tests/unit/image-alt-text-end-punctuation.js @@ -1,18 +1,18 @@ import { jest } from '@jest/globals' -import markdownlint from 'markdownlint' +import { runRule } from '../../lib/init-test.js' import { imageAltTextEndPunctuation } from '../../lib/linting-rules/image-alt-text-end-punctuation.js' -import { testOptions } from '../../lib/default-markdownlint-options.js' jest.setTimeout(60 * 1000) const fixtureFile = 'src/content-linter/tests/fixtures/image-alt-text-end-punctuation.md' -const options = testOptions('MD112', imageAltTextEndPunctuation, fixtureFile) -const result = await markdownlint.promises.markdownlint(options) +const result = await runRule(imageAltTextEndPunctuation, fixtureFile) +const errors = result[fixtureFile] -test('image alt text must have an end punctuation', () => { - const errors = result[fixtureFile] - expect(Object.keys(result).length).toBe(1) - expect(errors.length).toBe(2) - expect(errors.map((error) => error.lineNumber)).toEqual([3, 15]) +describe(imageAltTextEndPunctuation.names.join(' - '), () => { + test('image alt text must have an end punctuation', () => { + expect(Object.keys(result).length).toBe(1) + expect(errors.length).toBe(2) + expect(errors.map((error) => error.lineNumber)).toEqual([3, 15]) + }) }) diff --git a/src/content-linter/tests/unit/image-alt-text-length.js b/src/content-linter/tests/unit/image-alt-text-length.js index 7c8e9f6bec..7e9c30967f 100644 --- a/src/content-linter/tests/unit/image-alt-text-length.js +++ b/src/content-linter/tests/unit/image-alt-text-length.js @@ -1,18 +1,18 @@ import { jest } from '@jest/globals' -import markdownlint from 'markdownlint' +import { runRule } from '../../lib/init-test.js' import { incorrectAltTextLength } from '../../lib/linting-rules/image-alt-text-length.js' -import { testOptions } from '../../lib/default-markdownlint-options.js' jest.setTimeout(60 * 1000) const fixtureFile = 'src/content-linter/tests/fixtures/image-alt-text-length.md' -const options = testOptions('MD111', incorrectAltTextLength, fixtureFile) -const result = await markdownlint.promises.markdownlint(options) +const result = await runRule(incorrectAltTextLength, fixtureFile) +const errors = result[fixtureFile] -test('image with correct length alt text', () => { - const errors = result[fixtureFile] - expect(Object.keys(result).length).toBe(1) - expect(errors.length).toBe(2) - expect(errors.map((error) => error.lineNumber)).toEqual([1, 7]) +describe(incorrectAltTextLength.names.join(' - '), () => { + test('image with correct length alt text', () => { + expect(Object.keys(result).length).toBe(1) + expect(errors.length).toBe(2) + expect(errors.map((error) => error.lineNumber)).toEqual([1, 7]) + }) }) diff --git a/src/content-linter/tests/unit/image-file-kebab.js b/src/content-linter/tests/unit/image-file-kebab.js index dd94d5ddb1..b75762b750 100644 --- a/src/content-linter/tests/unit/image-file-kebab.js +++ b/src/content-linter/tests/unit/image-file-kebab.js @@ -1,18 +1,18 @@ import { jest } from '@jest/globals' -import markdownlint from 'markdownlint' +import { runRule } from '../../lib/init-test.js' import { imageFileKebab } from '../../lib/linting-rules/image-file-kebab' -import { testOptions } from '../../lib/default-markdownlint-options.js' jest.setTimeout(20 * 1000) const fixtureFile = 'src/content-linter/tests/fixtures/image-file-kebab.md' -const options = testOptions('MD115', imageFileKebab, fixtureFile) -const result = await markdownlint.promises.markdownlint(options) +const result = await runRule(imageFileKebab, fixtureFile) +const errors = result[fixtureFile] -test('image file with lowercase kebab case', () => { - const errors = result[fixtureFile] - expect(Object.keys(result).length).toBe(1) - expect(errors.length).toBe(4) - expect(errors.map((error) => error.lineNumber)).toEqual([4, 5, 6, 7]) +describe(imageFileKebab.names.join(' - '), () => { + test('image file with lowercase kebab case', () => { + expect(Object.keys(result).length).toBe(1) + expect(errors.length).toBe(4) + expect(errors.map((error) => error.lineNumber)).toEqual([4, 5, 6, 7]) + }) }) diff --git a/src/content-linter/tests/unit/internal-links-lang.js b/src/content-linter/tests/unit/internal-links-lang.js index 48c6546b43..f47e1a0a1b 100644 --- a/src/content-linter/tests/unit/internal-links-lang.js +++ b/src/content-linter/tests/unit/internal-links-lang.js @@ -1,18 +1,17 @@ import { jest } from '@jest/globals' -import markdownlint from 'markdownlint' +import { runRule } from '../../lib/init-test.js' import { internalLinksLang } from '../../lib/linting-rules/internal-links-lang.js' -import { testOptions } from '../../lib/default-markdownlint-options.js' - -const fixtureFile = 'src/content-linter/tests/fixtures/internal-links-lang.md' jest.setTimeout(30 * 1000) -const options = testOptions('MD114', internalLinksLang, fixtureFile) +const fixtureFilePath = 'src/content-linter/tests/fixtures/internal-links-lang.md' +const result = await runRule(internalLinksLang, fixtureFilePath) +const errors = result[fixtureFilePath] -const result = await markdownlint.promises.markdownlint(options) -test('internal links and hardcoded language codes', () => { - const errors = result[fixtureFile] - expect(Object.keys(result).length).toBe(1) - expect(errors.length).toBe(3) - expect(errors.map((error) => error.lineNumber)).toEqual([3, 4, 8]) +describe(internalLinksLang.names.join(' - '), () => { + test('internal links and hardcoded language codes', () => { + expect(Object.keys(result).length).toBe(1) + expect(errors.length).toBe(3) + expect(errors.map((error) => error.lineNumber)).toEqual([3, 4, 8]) + }) }) diff --git a/src/content-linter/tests/unit/internal-links-slash.js b/src/content-linter/tests/unit/internal-links-slash.js index 524ad95a21..ea169fc0b4 100755 --- a/src/content-linter/tests/unit/internal-links-slash.js +++ b/src/content-linter/tests/unit/internal-links-slash.js @@ -1,18 +1,18 @@ import { jest } from '@jest/globals' -import markdownlint from 'markdownlint' +import { runRule } from '../../lib/init-test.js' import { internalLinksSlash } from '../../lib/linting-rules/internal-links-slash.js' -import { testOptions } from '../../lib/default-markdownlint-options.js' jest.setTimeout(60 * 1000) const fixtureFile = 'src/content-linter/tests/fixtures/internal-links-slash.md' -const options = testOptions('MD113', internalLinksSlash, fixtureFile) -const result = await markdownlint.promises.markdownlint(options) +const result = await runRule(internalLinksSlash, fixtureFile) +const errors = result[fixtureFile] -test('relative links start with /', () => { - const errors = result[fixtureFile] - expect(Object.keys(result).length).toBe(1) - expect(errors.length).toBe(1) - expect(errors.map((error) => error.lineNumber)).toEqual([5]) +describe(internalLinksSlash.names.join(' - '), () => { + test('relative links start with /', () => { + expect(Object.keys(result).length).toBe(1) + expect(errors.length).toBe(1) + expect(errors.map((error) => error.lineNumber)).toEqual([5]) + }) })