add content linter rule for carousel recommended articles (#57216)
This commit is contained in:
@@ -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 | |
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
14
src/content-linter/tests/fixtures/landing-recommended/article-one.md
vendored
Normal file
14
src/content-linter/tests/fixtures/landing-recommended/article-one.md
vendored
Normal 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.
|
||||
14
src/content-linter/tests/fixtures/landing-recommended/article-two.md
vendored
Normal file
14
src/content-linter/tests/fixtures/landing-recommended/article-two.md
vendored
Normal 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.
|
||||
19
src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md
vendored
Normal file
19
src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md
vendored
Normal 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.
|
||||
18
src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md
vendored
Normal file
18
src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md
vendored
Normal 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.
|
||||
18
src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md
vendored
Normal file
18
src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md
vendored
Normal 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.
|
||||
14
src/content-linter/tests/fixtures/landing-recommended/no-recommended.md
vendored
Normal file
14
src/content-linter/tests/fixtures/landing-recommended/no-recommended.md
vendored
Normal 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.
|
||||
14
src/content-linter/tests/fixtures/landing-recommended/subdir/article-three.md
vendored
Normal file
14
src/content-linter/tests/fixtures/landing-recommended/subdir/article-three.md
vendored
Normal 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.
|
||||
18
src/content-linter/tests/fixtures/landing-recommended/valid-landing.md
vendored
Normal file
18
src/content-linter/tests/fixtures/landing-recommended/valid-landing.md
vendored
Normal 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.
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user