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 |
|
| 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 |
|
| 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 |
|
| 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: 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) | 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 | |
|
| [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 { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontmatter-validation'
|
||||||
import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
|
import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
|
||||||
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
|
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) =>
|
const noDefaultAltText = markdownlintGitHub.find((elem) =>
|
||||||
elem.names.includes('no-default-alt-text'),
|
elem.names.includes('no-default-alt-text'),
|
||||||
@@ -115,6 +116,7 @@ export const gitHubDocsMarkdownlint = {
|
|||||||
headerContentRequirement, // GHD053
|
headerContentRequirement, // GHD053
|
||||||
thirdPartyActionsReusable, // GHD054
|
thirdPartyActionsReusable, // GHD054
|
||||||
frontmatterValidation, // GHD055
|
frontmatterValidation, // GHD055
|
||||||
|
frontmatterLandingRecommended, // GHD056
|
||||||
|
|
||||||
// Search-replace rules
|
// Search-replace rules
|
||||||
searchReplace, // Open-source plugin
|
searchReplace, // Open-source plugin
|
||||||
|
|||||||
@@ -310,6 +310,12 @@ export const githubDocsFrontmatterConfig = {
|
|||||||
'partial-markdown-files': false,
|
'partial-markdown-files': false,
|
||||||
'yml-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
|
// 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 configuration for category landing pages
|
||||||
recommended: {
|
recommended: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
minItems: 3,
|
||||||
|
maxItems: 9,
|
||||||
description: 'Array of articles to feature in the carousel section',
|
description: 'Array of articles to feature in the carousel section',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user