Skip to content

Commit cdecbb4

Browse files
authored
Add frontmatter validation linter rule (GHD055) (#56150)
1 parent 6164dc2 commit cdecbb4

File tree

5 files changed

+678
-0
lines changed

5 files changed

+678
-0
lines changed

data/reusables/contributing/content-linter-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
| GHD051 | frontmatter-versions-whitespace | Versions frontmatter should not contain unnecessary whitespace | warning | frontmatter, versions |
7171
| GHD053 | header-content-requirement | Headers must have content between them, such as an introduction | warning | headers, structure, content |
7272
| GHD054 | third-party-actions-reusable | Code examples with third-party actions must include disclaimer reusable | warning | actions, reusable, third-party |
73+
| GHD055 | frontmatter-validation | Frontmatter properties must meet character limits and required property requirements | warning | frontmatter, character-limits, required-properties |
7374
| [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 | |
7475
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | |
7576
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { addError } from 'markdownlint-rule-helpers'
2+
import { getFrontmatter } from '@/content-linter/lib/helpers/utils'
3+
4+
export const frontmatterValidation = {
5+
names: ['GHD055', 'frontmatter-validation'],
6+
description:
7+
'Frontmatter properties must meet character limits and required property requirements',
8+
tags: ['frontmatter', 'character-limits', 'required-properties'],
9+
function: (params, onError) => {
10+
const fm = getFrontmatter(params.lines)
11+
if (!fm) return
12+
13+
// Detect content type based on frontmatter properties and file path
14+
const contentType = detectContentType(fm, params.name)
15+
16+
// Define character limits and requirements for different content types
17+
const contentRules = {
18+
category: {
19+
title: { max: 70, recommended: 67 },
20+
shortTitle: { max: 30, recommended: 27 },
21+
intro: { required: true, recommended: 280, max: 362 },
22+
requiredProperties: ['intro'],
23+
},
24+
mapTopic: {
25+
title: { max: 70, recommended: 63 },
26+
shortTitle: { max: 35, recommended: 30 },
27+
intro: { required: true, recommended: 280, max: 362 },
28+
requiredProperties: ['intro'],
29+
},
30+
article: {
31+
title: { max: 80, recommended: 60 },
32+
shortTitle: { max: 30, recommended: 25 },
33+
intro: { required: false, recommended: 251, max: 354 },
34+
requiredProperties: ['topics'],
35+
},
36+
}
37+
38+
const rules = contentRules[contentType]
39+
if (!rules) return
40+
41+
// Check required properties
42+
for (const property of rules.requiredProperties) {
43+
if (!fm[property]) {
44+
addError(
45+
onError,
46+
1,
47+
`Missing required property '${property}' for ${contentType} content type`,
48+
null,
49+
null,
50+
null,
51+
)
52+
}
53+
}
54+
55+
// Check title length
56+
if (fm.title) {
57+
validatePropertyLength(onError, params.lines, 'title', fm.title, rules.title, 'Title')
58+
}
59+
60+
// Check shortTitle length
61+
if (fm.shortTitle) {
62+
validatePropertyLength(
63+
onError,
64+
params.lines,
65+
'shortTitle',
66+
fm.shortTitle,
67+
rules.shortTitle,
68+
'ShortTitle',
69+
)
70+
}
71+
72+
// Check intro length if it exists
73+
if (fm.intro && rules.intro) {
74+
validatePropertyLength(onError, params.lines, 'intro', fm.intro, rules.intro, 'Intro')
75+
}
76+
77+
// Cross-property validation: if title is longer than shortTitle limit, shortTitle must exist
78+
if (fm.title && fm.title.length > rules.shortTitle.max && !fm.shortTitle) {
79+
const titleLine = findPropertyLine(params.lines, 'title')
80+
addError(
81+
onError,
82+
titleLine,
83+
`Title is ${fm.title.length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`,
84+
fm.title,
85+
null,
86+
null,
87+
)
88+
}
89+
90+
// Special validation for articles: should have at least one topic
91+
if (contentType === 'article' && fm.topics) {
92+
if (!Array.isArray(fm.topics)) {
93+
const topicsLine = findPropertyLine(params.lines, 'topics')
94+
addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null)
95+
} else if (fm.topics.length === 0) {
96+
const topicsLine = findPropertyLine(params.lines, 'topics')
97+
addError(
98+
onError,
99+
topicsLine,
100+
'Articles should have at least one topic',
101+
'topics: []',
102+
null,
103+
null,
104+
)
105+
}
106+
}
107+
},
108+
}
109+
110+
function validatePropertyLength(onError, lines, propertyName, propertyValue, limits, displayName) {
111+
const propertyLength = propertyValue.length
112+
const propertyLine = findPropertyLine(lines, propertyName)
113+
114+
// Only report the most severe error - maximum takes precedence over recommended
115+
if (propertyLength > limits.max) {
116+
addError(
117+
onError,
118+
propertyLine,
119+
`${displayName} exceeds maximum length of ${limits.max} characters (current: ${propertyLength})`,
120+
propertyValue,
121+
null,
122+
null,
123+
)
124+
} else if (propertyLength > limits.recommended) {
125+
addError(
126+
onError,
127+
propertyLine,
128+
`${displayName} exceeds recommended length of ${limits.recommended} characters (current: ${propertyLength})`,
129+
propertyValue,
130+
null,
131+
null,
132+
)
133+
}
134+
}
135+
136+
function detectContentType(frontmatter, filePath) {
137+
// Only apply validation to markdown files
138+
if (!filePath || !filePath.endsWith('.md')) {
139+
return null
140+
}
141+
142+
// Map topics have mapTopic: true
143+
if (frontmatter.mapTopic === true) {
144+
return 'mapTopic'
145+
}
146+
147+
// Categories are index.md files that contain children but no mapTopic
148+
// Only check files that look like they're in the content directory structure
149+
if (
150+
filePath.includes('/index.md') &&
151+
frontmatter.children &&
152+
Array.isArray(frontmatter.children) &&
153+
!frontmatter.mapTopic
154+
) {
155+
return 'category'
156+
}
157+
158+
// Everything else is an article
159+
return 'article'
160+
}
161+
162+
function findPropertyLine(lines, property) {
163+
const line = lines.find((line) => line.trim().startsWith(`${property}:`))
164+
return line ? lines.indexOf(line) + 1 : 1
165+
}

src/content-linter/lib/linting-rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { britishEnglishQuotes } from '@/content-linter/lib/linting-rules/british
5151
import { multipleEmphasisPatterns } from '@/content-linter/lib/linting-rules/multiple-emphasis-patterns'
5252
import { noteWarningFormatting } from '@/content-linter/lib/linting-rules/note-warning-formatting'
5353
import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace'
54+
import { frontmatterValidation } from '@/content-linter/lib/linting-rules/frontmatter-validation'
5455
import { headerContentRequirement } from '@/content-linter/lib/linting-rules/header-content-requirement'
5556
import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable'
5657

@@ -113,6 +114,7 @@ export const gitHubDocsMarkdownlint = {
113114
frontmatterVersionsWhitespace, // GHD051
114115
headerContentRequirement, // GHD053
115116
thirdPartyActionsReusable, // GHD054
117+
frontmatterValidation, // GHD055
116118

117119
// Search-replace rules
118120
searchReplace, // Open-source plugin

src/content-linter/style/github-docs.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,12 @@ export const githubDocsFrontmatterConfig = {
304304
'partial-markdown-files': false,
305305
'yml-files': false,
306306
},
307+
'frontmatter-validation': {
308+
// GHD055
309+
severity: 'warning',
310+
'partial-markdown-files': false,
311+
'yml-files': false,
312+
},
307313
}
308314

309315
// Configures rules from the `github/markdownlint-github` repo

0 commit comments

Comments
 (0)