diff --git a/data/reusables/contributing/content-linter-rules.md b/data/reusables/contributing/content-linter-rules.md index 702e116f94fc..65dd85360b74 100644 --- a/data/reusables/contributing/content-linter-rules.md +++ b/data/reusables/contributing/content-linter-rules.md @@ -60,4 +60,6 @@ | GHD019 | liquid-if-tags | Liquid `ifversion` tags should be used instead of `if` tags when the argument is a valid version | error | liquid, versioning | | GHD020 | liquid-ifversion-tags | Liquid `ifversion` tags should contain valid version names as arguments | error | liquid, versioning | | GHD035 | rai-reusable-usage | RAI articles and reusables can only reference reusable content in the data/reusables/rai directory | error | feature, rai | -| GHD036 | image-no-gif | Image must not be a gif, styleguide reference: contributing/style-guide-and-content-model/style-guide.md#images | error | images | \ No newline at end of file +| GHD036 | image-no-gif | Image must not be a gif, styleguide reference: contributing/style-guide-and-content-model/style-guide.md#images | error | images | +| GHD038 | expired-content | Expired content must be remediated. | error | expired | +| GHD039 | expiring-soon | Content that expires soon should be proactively addressed. | warning | expired | \ No newline at end of file diff --git a/src/content-linter/lib/linting-rules/expired-content.js b/src/content-linter/lib/linting-rules/expired-content.js new file mode 100644 index 000000000000..02c0d9d5bac2 --- /dev/null +++ b/src/content-linter/lib/linting-rules/expired-content.js @@ -0,0 +1,86 @@ +import { addError } from 'markdownlint-rule-helpers' + +// This rule looks for opening and closing HTML comment tags that +// contain an expiration date in the format: +// +// This is content that is +// expired that does not expire. +// +// The `end expires` closing tag closes the content that is expired +// and must be removed. +export const expiredContent = { + names: ['GHD038', 'expired-content'], + description: 'Expired content must be remediated.', + tags: ['expired'], + function: (params, onError) => { + const tokensToCheck = params.tokens.filter( + (token) => token.type === 'inline' || token.type === 'html_block', + ) + + tokensToCheck.forEach((token) => { + // Looking for just opening tag with format: + // + const match = token.content.match(//) + if (!match) return + + const expireDate = new Date(match.splice(1, 3).join(' ')) + const today = new Date() + if (today < expireDate) return + + addError( + onError, + token.lineNumber, + `Content marked with an expiration date has now expired. The content exists between 2 HTML comment tags in the format and . You should remove or rewrite this content, and delete the expiration comments. Alternatively, choose a new expiration date.`, + match[0], + [token.content.indexOf(match[0]) + 1, match[0].length], + null, // No fix possible + ) + }) + }, +} + +export const DAYS_TO_WARN_BEFORE_EXPIRED = 14 + +// This rule looks for content that will expire in `DAYS_TO_WARN_BEFORE_EXPIRED` +// days. The rule looks for opening and closing HTML comment tags that +// contain an expiration date in the format: +// +// This is content that is +// expired that does not expire. +// +// The `end expires` closing tag closes the content that is expired +// and must be removed. +export const expiringSoon = { + names: ['GHD039', 'expiring-soon'], + description: 'Content that expires soon should be proactively addressed.', + tags: ['expired'], + function: (params, onError) => { + const tokensToCheck = params.tokens.filter( + (token) => token.type === 'inline' || token.type === 'html_block', + ) + + tokensToCheck.forEach((token) => { + // Looking for just opening tag with format: + // + const match = token.content.match(//) + if (!match) return + + const expireDate = new Date(match.splice(1, 3).join(' ')) + const today = new Date() + const futureDate = new Date() + futureDate.setDate(today.getDate() + DAYS_TO_WARN_BEFORE_EXPIRED) + // Don't set warning if the content is already expired or + // if the content expires later than the DAYS_TO_WARN_BEFORE_EXPIRED + if (today > expireDate || expireDate > futureDate) return + + addError( + onError, + token.lineNumber, + `Content marked with an expiration date will expire soon. The content exists between 2 HTML comment tags in the format and . Check whether this content can be removed or rewritten before it expires.`, + match[0], + [token.content.indexOf(match[0]) + 1, match[0].length], + null, // No fix possible + ) + }) + }, +} diff --git a/src/content-linter/lib/linting-rules/index.js b/src/content-linter/lib/linting-rules/index.js index e24f1b32ab8d..9697233323ff 100644 --- a/src/content-linter/lib/linting-rules/index.js +++ b/src/content-linter/lib/linting-rules/index.js @@ -28,6 +28,7 @@ import { frontmatterLiquidSyntax, liquidSyntax } from './liquid-syntax.js' import { liquidIfTags, liquidIfVersionTags } from './liquid-versioning.js' import { raiReusableUsage } from './rai-reusable-usage.js' import { imageNoGif } from './image-no-gif.js' +import { expiredContent, expiringSoon } from './expired-content.js' const noDefaultAltText = markdownlintGitHub.find((elem) => elem.names.includes('no-default-alt-text'), @@ -69,5 +70,7 @@ export const gitHubDocsMarkdownlint = { liquidIfVersionTags, raiReusableUsage, imageNoGif, + expiredContent, + expiringSoon, ], } diff --git a/src/content-linter/style/github-docs.js b/src/content-linter/style/github-docs.js index 6b0991a9a6c6..5af983c240e1 100644 --- a/src/content-linter/style/github-docs.js +++ b/src/content-linter/style/github-docs.js @@ -135,6 +135,16 @@ const githubDocsConfig = { severity: 'error', 'partial-markdown-files': true, }, + 'expired-content': { + // GHD038 + severity: 'error', + 'partial-markdown-files': true, + }, + 'expiring-soon': { + // GHD039 + severity: 'warning', + 'partial-markdown-files': true, + }, } export const githubDocsFrontmatterConfig = { diff --git a/src/content-linter/tests/unit/expired-content.js b/src/content-linter/tests/unit/expired-content.js new file mode 100644 index 000000000000..93fd10573766 --- /dev/null +++ b/src/content-linter/tests/unit/expired-content.js @@ -0,0 +1,149 @@ +import { runRule } from '../../lib/init-test.js' +import { + expiredContent, + expiringSoon, + DAYS_TO_WARN_BEFORE_EXPIRED, +} from '../../lib/linting-rules/expired-content.js' + +describe(expiredContent.names.join(' - '), () => { + test('Date in the past triggers error', async () => { + const markdown = [ + '', + 'This is some content that is', + 'expiring soon never expires.', + ].join('\n') + const result = await runRule(expiredContent, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(2) + expect(errors[0].errorRange).toEqual([27, 27]) + expect(errors[0].fixInfo).toBeNull() + }) + + test('Date in the future does not trigger error', async () => { + const markdown = [ + '', + 'This is some content that is', + 'expiring soon never expires.', + ].join('\n') + const result = await runRule(expiredContent, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) + + test('multiple spaces in HTML comment triggers error', async () => { + const markdown = [ + '', + 'This is some content that is', + 'expiring soon never expires.', + ].join('\n') + const result = await runRule(expiredContent, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(2) + expect(errors[0].errorRange).toEqual([27, 34]) + expect(errors[0].fixInfo).toBeNull() + }) + + test('no surrounding spaces in HTML comment triggers error', async () => { + const markdown = [ + '', + 'This is some content that is', + 'expiring soon never expires.', + ].join('\n') + const result = await runRule(expiredContent, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(2) + expect(errors[0].errorRange).toEqual([27, 25]) + expect(errors[0].fixInfo).toBeNull() + }) + + test('HTML tag on its own line triggers error', async () => { + const markdown = [ + '', + '', + `This is expiring soon.`, + '', + ].join('\n') + const result = await runRule(expiredContent, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(2) + expect(errors[0].errorRange).toEqual([1, 27]) + expect(errors[0].fixInfo).toBeNull() + }) +}) + +describe(expiringSoon.names.join(' - '), () => { + test('Date more than number of days to warn does not trigger warning', async () => { + const formattedDate = getFormattedDate(1) + const markdown = [ + '', + `This is some content that is`, + `expiring soon never expires.`, + ].join('\n') + const result = await runRule(expiringSoon, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) + + test('Date equivalent to number of days to warn does trigger warning', async () => { + const formattedDate = getFormattedDate() + const markdown = [ + '', + `This is some content that is`, + `expiring soon never expires.`, + ].join('\n') + const result = await runRule(expiringSoon, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(2) + expect(errors[0].errorRange).toEqual([27, 27]) + expect(errors[0].fixInfo).toBeNull() + }) + + test('Date less than number of days to warn does trigger warning', async () => { + const formattedDate = getFormattedDate(-1) + const markdown = [ + '', + `This is some content that is`, + `expiring soon never expires.`, + ].join('\n') + const result = await runRule(expiringSoon, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(2) + expect(errors[0].errorRange).toEqual([27, 27]) + expect(errors[0].fixInfo).toBeNull() + }) + test('HTML tag on its own line triggeres warning', async () => { + const formattedDate = getFormattedDate(-1) + const markdown = [ + '', + ``, + `This is expiring soon.`, + `