diff --git a/data/reusables/contributing/content-linter-rules.md b/data/reusables/contributing/content-linter-rules.md index 03f499a02e8c..fa3c668e30c9 100644 --- a/data/reusables/contributing/content-linter-rules.md +++ b/data/reusables/contributing/content-linter-rules.md @@ -68,6 +68,7 @@ | GHD049 | note-warning-formatting | Note and warning tags should be formatted according to style guide | warning | formatting, callouts, notes, warnings, style | | GHD050 | multiple-emphasis-patterns | Do not use more than one emphasis/strong, italics, or uppercase for a string | warning | formatting, emphasis, style | | GHD051 | frontmatter-versions-whitespace | Versions frontmatter should not contain unnecessary whitespace | warning | frontmatter, versions | +| GHD053 | header-content-requirement | Headers must have content between them, such as an introduction | warning | headers, structure, content | | GHD054 | third-party-actions-reusable | Code examples with third-party actions must include disclaimer reusable | warning | actions, reusable, third-party | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon- | The octicon liquid syntax used is deprecated. Use this format instead `octicon "" aria-label=""` | error | | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | | diff --git a/package-lock.json b/package-lock.json index c8867246464c..e18def1cced0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "cheerio": "^1.0.0-rc.12", "cheerio-to-text": "0.2.4", "classnames": "^2.5.1", - "connect-timeout": "1.9.0", + "connect-timeout": "1.9.1", "cookie-parser": "^1.4.7", "cuss": "2.2.0", "dayjs": "^1.11.13", @@ -68,7 +68,7 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-markdown": "2.1.2", "mdast-util-to-string": "^4.0.0", - "morgan": "^1.10.0", + "morgan": "^1.10.1", "next": "^15.3.3", "ora": "^8.0.1", "parse5": "7.1.2", @@ -6627,14 +6627,15 @@ "license": "MIT" }, "node_modules/connect-timeout": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/connect-timeout/-/connect-timeout-1.9.0.tgz", - "integrity": "sha512-q4bsBIPd+eSGtnh/u6EBOKfuG+4YvwsN0idlOsg6KAw71Qpi0DCf2eCc/Va63QU9qdOeYC8katxoC+rHMNygZg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/connect-timeout/-/connect-timeout-1.9.1.tgz", + "integrity": "sha512-kDcadOXwOu+EEVs31iOu0TOg1yyRTqSNfyJaHYm5Z4K/hEIi9HJXSOWP9d+WQr/wff7wQJRh/HX63vK1+wBErw==", + "license": "MIT", "dependencies": { "http-errors": "~1.6.1", "ms": "2.0.0", "on-finished": "~2.3.0", - "on-headers": "~1.0.1" + "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8" @@ -12499,14 +12500,16 @@ "license": "MIT" }, "node_modules/morgan": { - "version": "1.10.0", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", - "on-headers": "~1.0.2" + "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" @@ -13309,7 +13312,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" diff --git a/package.json b/package.json index c260e85a0dd4..f4dd1e63a763 100644 --- a/package.json +++ b/package.json @@ -265,7 +265,7 @@ "cheerio": "^1.0.0-rc.12", "cheerio-to-text": "0.2.4", "classnames": "^2.5.1", - "connect-timeout": "1.9.0", + "connect-timeout": "1.9.1", "cookie-parser": "^1.4.7", "cuss": "2.2.0", "dayjs": "^1.11.13", @@ -304,7 +304,7 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-markdown": "2.1.2", "mdast-util-to-string": "^4.0.0", - "morgan": "^1.10.0", + "morgan": "^1.10.1", "next": "^15.3.3", "ora": "^8.0.1", "parse5": "7.1.2", diff --git a/src/content-linter/lib/linting-rules/header-content-requirement.js b/src/content-linter/lib/linting-rules/header-content-requirement.js new file mode 100644 index 000000000000..68d6c0e74aab --- /dev/null +++ b/src/content-linter/lib/linting-rules/header-content-requirement.js @@ -0,0 +1,100 @@ +import { addError, filterTokens } from 'markdownlint-rule-helpers' + +export const headerContentRequirement = { + names: ['GHD053', 'header-content-requirement'], + description: 'Headers must have content between them, such as an introduction', + tags: ['headers', 'structure', 'content'], + function: (params, onError) => { + const headings = [] + + // Collect all heading tokens with their line numbers and levels + filterTokens(params, 'heading_open', (token) => { + headings.push({ + token, + lineNumber: token.lineNumber, + level: parseInt(token.tag.slice(1)), // Extract number from h1, h2, etc. + line: params.lines[token.lineNumber - 1], + }) + }) + + // Check each pair of consecutive headings + for (let i = 0; i < headings.length - 1; i++) { + const currentHeading = headings[i] + const nextHeading = headings[i + 1] + + // Only check if next heading is a subheading (higher level number) + if (nextHeading.level > currentHeading.level) { + const hasContent = checkForContentBetweenHeadings( + params.lines, + currentHeading.lineNumber, + nextHeading.lineNumber, + ) + + if (!hasContent) { + addError( + onError, + nextHeading.lineNumber, + `Header must have introductory content before subheader. Add content between "${currentHeading.line.trim()}" and "${nextHeading.line.trim()}".`, + nextHeading.line, + null, // No specific range within the line + null, // No fix possible - requires manual content addition + ) + } + } + } + }, +} + +/** + * Check if there is meaningful content between two headings + * Returns true if content exists, false if only whitespace/empty lines + */ +function checkForContentBetweenHeadings(lines, startLineNumber, endLineNumber) { + // Convert to 0-based indexes and skip the heading lines themselves + const startIndex = startLineNumber // Skip the current heading line + const endIndex = endLineNumber - 2 // Stop before the next heading line + + // Check each line between the headings + for (let i = startIndex; i <= endIndex; i++) { + if (i >= lines.length) break + + const line = lines[i].trim() + + // Skip empty lines + if (line === '') continue + + // Skip frontmatter delimiters + if (line === '---') continue + + // Skip Liquid tags that don't produce visible content + if (isNonContentLiquidTag(line)) continue + + // If we find any other content, consider it valid + if (line.length > 0) { + return true + } + } + + return false +} + +/** + * Check if a line contains only Liquid tags that don't produce visible content + * This helps avoid false positives for conditional blocks + */ +function isNonContentLiquidTag(line) { + // Match common non-content Liquid tags + const nonContentTags = [ + /^{%\s*ifversion\s+.*%}$/, + /^{%\s*elsif\s+.*%}$/, + /^{%\s*else\s*%}$/, + /^{%\s*endif\s*%}$/, + /^{%\s*if\s+.*%}$/, + /^{%\s*unless\s+.*%}$/, + /^{%\s*endunless\s*%}$/, + /^{%\s*comment\s*%}$/, + /^{%\s*endcomment\s*%}$/, + ] + + return nonContentTags.some((pattern) => pattern.test(line)) +} diff --git a/src/content-linter/lib/linting-rules/index.js b/src/content-linter/lib/linting-rules/index.js index aa8d5e557939..104b23fe83f3 100644 --- a/src/content-linter/lib/linting-rules/index.js +++ b/src/content-linter/lib/linting-rules/index.js @@ -50,8 +50,8 @@ import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-ru import { britishEnglishQuotes } from '@/content-linter/lib/linting-rules/british-english-quotes' import { multipleEmphasisPatterns } from '@/content-linter/lib/linting-rules/multiple-emphasis-patterns' import { noteWarningFormatting } from '@/content-linter/lib/linting-rules/note-warning-formatting' - import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace' +import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement' import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable' const noDefaultAltText = markdownlintGitHub.find((elem) => @@ -111,7 +111,7 @@ export const gitHubDocsMarkdownlint = { noteWarningFormatting, // GHD049 multipleEmphasisPatterns, // GHD050 frontmatterVersionsWhitespace, // GHD051 - + headerContentRequirement, // GHD053 thirdPartyActionsReusable, // GHD054 // Search-replace rules diff --git a/src/content-linter/style/github-docs.js b/src/content-linter/style/github-docs.js index 8f01be70d952..36a4431aca11 100644 --- a/src/content-linter/style/github-docs.js +++ b/src/content-linter/style/github-docs.js @@ -237,6 +237,12 @@ const githubDocsConfig = { 'partial-markdown-files': true, 'yml-files': true, }, + 'header-content-requirement': { + // GHD053 + severity: 'warning', + 'partial-markdown-files': true, + 'yml-files': true, + }, 'third-party-actions-reusable': { // GHD054 severity: 'warning', diff --git a/src/content-linter/tests/unit/header-content-requirement.js b/src/content-linter/tests/unit/header-content-requirement.js new file mode 100644 index 000000000000..67d262662bbd --- /dev/null +++ b/src/content-linter/tests/unit/header-content-requirement.js @@ -0,0 +1,385 @@ +import { describe, expect, test } from 'vitest' + +import { runRule } from '../../lib/init-test' +import { headerContentRequirement } from '../../lib/linting-rules/header-content-requirement' + +// Configure the test fixture to not split frontmatter and content +const fmOptions = { markdownlintOptions: { frontMatter: null } } + +describe(headerContentRequirement.names.join(' - '), () => { + describe('valid cases', () => { + test('should pass when headers have content between them', async () => { + const content = `--- +title: Test +--- + +# Main Header + +This is introductory content for the main section. + +## Subheader + +This section has proper introduction content. + +### Nested Subheader + +More content here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should pass when headers are at the same level', async () => { + const content = `--- +title: Test +--- + +# First Header + +Some content here. + +# Second Header + +More content here. + +# Third Header + +Even more content. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should pass when subheader goes back to higher level', async () => { + const content = `--- +title: Test +--- + +## Level 2 Header + +Content here. + +# Level 1 Header + +This is fine - going from h2 to h1. + +## Another Level 2 + +More content. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should pass with liquid conditionals between headers', async () => { + const content = `--- +title: Test +--- + +# Main Header + +Some intro content. + +{% ifversion fpt %} + +## Conditional Subheader + +This content appears conditionally. + +{% endif %} +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should pass with lists between headers', async () => { + const content = `--- +title: Test +--- + +# Main Header + +* Item 1 +* Item 2 +* Item 3 + +## Subheader + +More content. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should pass with code blocks between headers', async () => { + const content = `--- +title: Test +--- + +# Main Header + +\`\`\`javascript +console.log('hello'); +\`\`\` + +## Subheader + +More content. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + }) + + describe('invalid cases', () => { + test('should fail when h1 is immediately followed by h2', async () => { + const content = `--- +title: Test +--- + +# Main Header + +## Subheader + +Content here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(1) + expect(result.content[0].errorDetail).toContain('Header must have introductory content') + expect(result.content[0].errorDetail).toContain('Main Header') + expect(result.content[0].errorDetail).toContain('Subheader') + }) + + test('should fail when h2 is immediately followed by h3', async () => { + const content = `--- +title: Test +--- + +# Main Header + +Some content here. + +## Level 2 Header + +### Level 3 Header + +Content at level 3. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(1) + expect(result.content[0].errorDetail).toContain('Level 2 Header') + expect(result.content[0].errorDetail).toContain('Level 3 Header') + }) + + test('should fail with multiple consecutive violations', async () => { + const content = `--- +title: Test +--- + +# Main Header + +## Level 2 Header + +### Level 3 Header + +#### Level 4 Header + +Content finally appears here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(3) // Three violations: h1->h2, h2->h3, h3->h4 + }) + + test('should fail when only whitespace between headers', async () => { + const content = `--- +title: Test +--- + +# Main Header + + +## Subheader + +Content here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(1) + }) + + test('should fail when only liquid tags without content between headers', async () => { + const content = `--- +title: Test +--- + +# Main Header + +{% ifversion fpt %} +{% endif %} + +## Subheader + +Content here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(1) + }) + }) + + describe('edge cases', () => { + test('should handle document with only one header', async () => { + const content = `--- +title: Test +--- + +# Only Header + +Some content here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should handle deep nesting correctly', async () => { + const content = `--- +title: Test +--- + +# H1 + +Content for h1. + +## H2 + +Content for h2. + +### H3 + +Content for h3. + +#### H4 + +Content for h4. + +##### H5 + +Content for h5. + +###### H6 + +Content for h6. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should handle mixed content types between headers', async () => { + const content = `--- +title: Test +--- + +# Main Header + +Some text content. + +> Blockquote content. + +{% data reusables.example.reusable %} + +## Subheader + +More content. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + + test('should handle frontmatter correctly', async () => { + const content = `--- +title: Test +versions: + fpt: '*' + ghec: '*' +--- + +# Main Header + +## Subheader + +Content here. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(1) + }) + + test('should handle complex liquid blocks', async () => { + const content = `--- +title: Test +--- + +# Main Header + +{% ifversion fpt %} +This content appears in FPT. +{% elsif ghec %} +This content appears in GHEC. +{% else %} +This content appears elsewhere. +{% endif %} + +## Subheader + +More content. +` + const result = await runRule(headerContentRequirement, { + strings: { content }, + ...fmOptions, + }) + expect(result.content.length).toBe(0) + }) + }) +})