1
0
mirror of synced 2025-12-19 18:10:59 -05:00

add content linter rule for carousel recommended articles (#57216)

This commit is contained in:
Robert Sese
2025-09-03 17:15:52 -05:00
committed by GitHub
parent a292bb3824
commit d01e267cab
14 changed files with 306 additions and 0 deletions

View File

@@ -71,6 +71,7 @@
| 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 |
| GHD055 | frontmatter-validation | Frontmatter properties must meet character limits and required property requirements | warning | frontmatter, character-limits, required-properties |
| GHD056 | frontmatter-landing-recommended | Only landing pages can have recommended articles, there should be no duplicate recommended articles, and all recommended articles must exist | error | frontmatter, landing, recommended |
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon-<icon-name> | The octicon liquid syntax used is deprecated. Use this format instead `octicon "<octicon-name>" aria-label="<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 | |
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |

View File

@@ -0,0 +1,88 @@
import fs from 'fs'
import path from 'path'
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '../helpers/utils'
function isValidArticlePath(articlePath, currentFilePath) {
// Article paths in recommended are relative to the current page's directory
const relativePath = articlePath.startsWith('/') ? articlePath.substring(1) : articlePath
const currentDir = path.dirname(currentFilePath)
const fullPath = path.join(currentDir, `${relativePath}.md`)
try {
return fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()
} catch {
return false
}
}
export const frontmatterLandingRecommended = {
names: ['GHD056', 'frontmatter-landing-recommended'],
description:
'Only landing pages can have recommended articles, there should be no duplicate recommended articles, and all recommended articles must exist',
tags: ['frontmatter', 'landing', 'recommended'],
function: (params, onError) => {
const fm = getFrontmatter(params.lines)
if (!fm || !fm.recommended) return
const recommendedLine = params.lines.find((line) => line.startsWith('recommended:'))
if (!recommendedLine) return
const lineNumber = params.lines.indexOf(recommendedLine) + 1
if (!fm.layout || !fm.layout.includes('landing')) {
addError(
onError,
lineNumber,
'recommended frontmatter key is only valid for landing pages',
recommendedLine,
[1, recommendedLine.length],
null,
)
}
// Check for duplicate recommended items and invalid paths
if (Array.isArray(fm.recommended)) {
const seen = new Set()
const duplicates = []
const invalidPaths = []
fm.recommended.forEach((item) => {
if (seen.has(item)) {
duplicates.push(item)
} else {
seen.add(item)
}
// Validate that the article path exists
if (!isValidArticlePath(item, params.name)) {
invalidPaths.push(item)
}
})
if (duplicates.length > 0) {
addError(
onError,
lineNumber,
`Found duplicate recommended articles: ${duplicates.join(', ')}`,
recommendedLine,
[1, recommendedLine.length],
null,
)
}
if (invalidPaths.length > 0) {
addError(
onError,
lineNumber,
`Found invalid recommended article paths: ${invalidPaths.join(', ')}`,
recommendedLine,
[1, recommendedLine.length],
null,
)
}
}
},
}

View File

@@ -54,6 +54,7 @@ import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rule
import { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontmatter-validation'
import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
const noDefaultAltText = markdownlintGitHub.find((elem) =>
elem.names.includes('no-default-alt-text'),
@@ -115,6 +116,7 @@ export const gitHubDocsMarkdownlint = {
headerContentRequirement, // GHD053
thirdPartyActionsReusable, // GHD054
frontmatterValidation, // GHD055
frontmatterLandingRecommended, // GHD056
// Search-replace rules
searchReplace, // Open-source plugin

View File

@@ -310,6 +310,12 @@ export const githubDocsFrontmatterConfig = {
'partial-markdown-files': false,
'yml-files': false,
},
'frontmatter-landing-recommended': {
// GHD056
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
}
// Configures rules from the `github/markdownlint-github` repo

View File

@@ -0,0 +1,14 @@
---
title: Article One
layout: inline
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
---
# Article One
This is a supporting article for testing.

View File

@@ -0,0 +1,14 @@
---
title: Article Two
layout: inline
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
---
# Article Two
This is another supporting article for testing.

View File

@@ -0,0 +1,19 @@
---
title: Landing with Duplicates
layout: product-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
recommended:
- /article-one
- /article-two
- /article-one
- /subdir/article-three
---
# Landing with Duplicates
This landing page has duplicate recommended articles.

View File

@@ -0,0 +1,18 @@
---
title: Not a Landing Page
layout: inline
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
recommended:
- /article-one
- /article-two
- /subdir/article-three
---
# Not a Landing Page
This page has a recommended property but is not a landing page.

View File

@@ -0,0 +1,18 @@
---
title: Landing with Invalid Paths
layout: product-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
recommended:
- /article-one
- /nonexistent/path
- /another/invalid/path
---
# Landing with Invalid Paths
This landing page has some invalid recommended article paths.

View File

@@ -0,0 +1,14 @@
---
title: Landing without Recommended
layout: product-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
---
# Landing without Recommended
This is a landing page without any recommended articles.

View File

@@ -0,0 +1,14 @@
---
title: Article Three
layout: inline
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
---
# Article Three
This is a supporting article in a subdirectory for testing.

View File

@@ -0,0 +1,18 @@
---
title: Valid Landing Page
layout: product-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
topics:
- Testing
recommended:
- /article-one
- /article-two
- /subdir/article-three
---
# Valid Landing Page
This is a valid landing page with recommended articles.

View File

@@ -0,0 +1,78 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '@/content-linter/lib/init-test'
import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended'
const VALID_LANDING = 'src/content-linter/tests/fixtures/landing-recommended/valid-landing.md'
const INVALID_NON_LANDING =
'src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md'
const DUPLICATE_RECOMMENDED =
'src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md'
const INVALID_PATHS = 'src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md'
const NO_RECOMMENDED = 'src/content-linter/tests/fixtures/landing-recommended/no-recommended.md'
const ruleName = frontmatterLandingRecommended.names[1]
// Configure the test fixture to not split frontmatter and content
const fmOptions = { markdownlintOptions: { frontMatter: null } }
describe(ruleName, () => {
test('landing page with recommended articles passes', async () => {
const result = await runRule(frontmatterLandingRecommended, {
files: [VALID_LANDING],
...fmOptions,
})
expect(result[VALID_LANDING]).toEqual([])
})
test('non-landing page with recommended property fails', async () => {
const result = await runRule(frontmatterLandingRecommended, {
files: [INVALID_NON_LANDING],
...fmOptions,
})
expect(result[INVALID_NON_LANDING]).toHaveLength(1)
expect(result[INVALID_NON_LANDING][0].errorDetail).toContain(
'recommended frontmatter key is only valid for landing pages',
)
})
test('pages without recommended property pass', async () => {
const result = await runRule(frontmatterLandingRecommended, {
files: [NO_RECOMMENDED],
...fmOptions,
})
expect(result[NO_RECOMMENDED]).toEqual([])
})
test('page with duplicate recommended articles fails', async () => {
const result = await runRule(frontmatterLandingRecommended, {
files: [DUPLICATE_RECOMMENDED],
...fmOptions,
})
expect(result[DUPLICATE_RECOMMENDED]).toHaveLength(1) // Only duplicate error since all paths are valid
expect(result[DUPLICATE_RECOMMENDED][0].errorDetail).toContain(
'Found duplicate recommended articles: /article-one',
)
})
test('page with invalid recommended article paths fails', async () => {
const result = await runRule(frontmatterLandingRecommended, {
files: [INVALID_PATHS],
...fmOptions,
})
expect(result[INVALID_PATHS]).toHaveLength(1)
expect(result[INVALID_PATHS][0].errorDetail).toContain(
'Found invalid recommended article paths:',
)
expect(result[INVALID_PATHS][0].errorDetail).toContain('/nonexistent/path')
expect(result[INVALID_PATHS][0].errorDetail).toContain('/another/invalid/path')
})
test('page with valid recommended articles passes', async () => {
const result = await runRule(frontmatterLandingRecommended, {
files: [VALID_LANDING],
...fmOptions,
})
expect(result[VALID_LANDING]).toEqual([])
})
})

View File

@@ -331,6 +331,8 @@ export const schema = {
// Recommended configuration for category landing pages
recommended: {
type: 'array',
minItems: 3,
maxItems: 9,
description: 'Array of articles to feature in the carousel section',
},
},