Skip to content

Commit 2a603b1

Browse files
authored
add content-linter rule to validate that introLinks keys exist in ui.yml (#58137)
1 parent a1ed181 commit 2a603b1

File tree

4 files changed

+201
-0
lines changed

4 files changed

+201
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
2+
import { addError } from 'markdownlint-rule-helpers'
3+
4+
import { getFrontmatter } from '../helpers/utils'
5+
import { getUIDataMerged } from '@/data-directory/lib/get-data'
6+
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
7+
8+
interface Frontmatter {
9+
introLinks?: Record<string, string>
10+
[key: string]: any
11+
}
12+
13+
// Get the valid introLinks keys from ui.yml
14+
function getValidIntroLinksKeys(): string[] {
15+
try {
16+
const ui = getUIDataMerged('en')
17+
18+
if (!ui || !ui.product_landing || typeof ui.product_landing !== 'object') {
19+
return []
20+
}
21+
22+
// Get all keys from product_landing in ui.yml
23+
return Object.keys(ui.product_landing)
24+
} catch (error) {
25+
console.error('Error loading ui.yml data:', error)
26+
return []
27+
}
28+
}
29+
30+
export const frontmatterIntroLinks: Rule = {
31+
names: ['GHD062', 'frontmatter-intro-links'],
32+
description: 'introLinks keys must be valid keys defined in data/ui.yml under product_landing',
33+
tags: ['frontmatter', 'single-source'],
34+
function: (params: RuleParams, onError: RuleErrorCallback) => {
35+
const fm = getFrontmatter(params.lines) as Frontmatter | null
36+
if (!fm || !fm.introLinks) return
37+
38+
const introLinks = fm.introLinks
39+
if (typeof introLinks !== 'object' || Array.isArray(introLinks)) return
40+
41+
const validKeys = getValidIntroLinksKeys()
42+
if (validKeys.length === 0) {
43+
// If we can't load the valid keys, skip validation
44+
return
45+
}
46+
47+
// Check each key in introLinks
48+
for (const key of Object.keys(introLinks)) {
49+
if (!validKeys.includes(key)) {
50+
// Find the line with this key
51+
const line = params.lines.find((line: string) => {
52+
const trimmed = line.trim()
53+
return trimmed.startsWith(`${key}:`) && !trimmed.startsWith('introLinks:')
54+
})
55+
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
56+
57+
addError(
58+
onError,
59+
lineNumber,
60+
`Invalid introLinks key: '${key}'. Valid keys are: ${validKeys.join(', ')}`,
61+
line || '',
62+
null, // No fix possible
63+
)
64+
}
65+
}
66+
},
67+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { journeyTracksLiquid } from './journey-tracks-liquid'
6262
import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists'
6363
import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
6464
import { frontmatterHeroImage } from './frontmatter-hero-image'
65+
import { frontmatterIntroLinks } from './frontmatter-intro-links'
6566

6667
// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
6768
// The elements in the array have a 'names' property that contains rule identifiers
@@ -132,6 +133,7 @@ export const gitHubDocsMarkdownlint = {
132133
journeyTracksGuidePathExists, // GHD059
133134
journeyTracksUniqueIds, // GHD060
134135
frontmatterHeroImage, // GHD061
136+
frontmatterIntroLinks, // GHD062
135137

136138
// Search-replace rules
137139
searchReplace, // Open-source plugin

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,12 @@ export const githubDocsFrontmatterConfig = {
354354
'partial-markdown-files': false,
355355
'yml-files': false,
356356
},
357+
'frontmatter-intro-links': {
358+
// GHD062
359+
severity: 'error',
360+
'partial-markdown-files': false,
361+
'yml-files': false,
362+
},
357363
}
358364

359365
// Configures rules from the `github/markdownlint-github` repo
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import path from 'path'
2+
3+
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
4+
5+
import { runRule } from '../../lib/init-test'
6+
import { frontmatterIntroLinks } from '../../lib/linting-rules/frontmatter-intro-links'
7+
8+
const fmOptions = { markdownlintOptions: { frontMatter: null } }
9+
10+
describe(frontmatterIntroLinks.names.join(' - '), () => {
11+
const envVarValueBefore = process.env.ROOT
12+
13+
beforeAll(() => {
14+
process.env.ROOT = path.join('src', 'fixtures', 'fixtures')
15+
})
16+
17+
afterAll(() => {
18+
process.env.ROOT = envVarValueBefore
19+
})
20+
21+
test('valid introLinks keys pass', async () => {
22+
const markdown = [
23+
'---',
24+
'title: Test',
25+
'introLinks:',
26+
' overview: /path/to/overview',
27+
' quickstart: /path/to/quickstart',
28+
'---',
29+
'',
30+
'# Test',
31+
].join('\n')
32+
const result = await runRule(frontmatterIntroLinks, {
33+
strings: { 'content/test/index.md': markdown },
34+
...fmOptions,
35+
})
36+
const errors = result['content/test/index.md']
37+
expect(errors.length).toBe(0)
38+
})
39+
40+
test('missing introLinks is ignored', async () => {
41+
const markdown = ['---', 'title: Test', '---', '', '# Test'].join('\n')
42+
const result = await runRule(frontmatterIntroLinks, {
43+
strings: { 'content/test/index.md': markdown },
44+
...fmOptions,
45+
})
46+
const errors = result['content/test/index.md']
47+
expect(errors.length).toBe(0)
48+
})
49+
50+
test('invalid introLinks key fails', async () => {
51+
const markdown = [
52+
'---',
53+
'title: Test',
54+
'introLinks:',
55+
' overview: /path/to/overview',
56+
' invalidKey: /path/to/invalid',
57+
'---',
58+
'',
59+
'# Test',
60+
].join('\n')
61+
const result = await runRule(frontmatterIntroLinks, {
62+
strings: { 'content/test/index.md': markdown },
63+
...fmOptions,
64+
})
65+
const errors = result['content/test/index.md']
66+
expect(errors.length).toBe(1)
67+
expect(errors[0].errorDetail).toContain('Invalid introLinks key')
68+
expect(errors[0].errorDetail).toContain('invalidKey')
69+
})
70+
71+
test('multiple invalid introLinks keys fail', async () => {
72+
const markdown = [
73+
'---',
74+
'title: Test',
75+
'introLinks:',
76+
' invalidKey1: /path/to/invalid1',
77+
' invalidKey2: /path/to/invalid2',
78+
'---',
79+
'',
80+
'# Test',
81+
].join('\n')
82+
const result = await runRule(frontmatterIntroLinks, {
83+
strings: { 'content/test/index.md': markdown },
84+
...fmOptions,
85+
})
86+
const errors = result['content/test/index.md']
87+
expect(errors.length).toBe(2)
88+
})
89+
90+
test('all common valid introLinks keys pass', async () => {
91+
const markdown = [
92+
'---',
93+
'title: Test',
94+
'introLinks:',
95+
' overview: /path/to/overview',
96+
' quickstart: /path/to/quickstart',
97+
' reference: /path/to/reference',
98+
'---',
99+
'',
100+
'# Test',
101+
].join('\n')
102+
const result = await runRule(frontmatterIntroLinks, {
103+
strings: { 'content/test/index.md': markdown },
104+
...fmOptions,
105+
})
106+
const errors = result['content/test/index.md']
107+
expect(errors.length).toBe(0)
108+
})
109+
110+
test('non-object introLinks is ignored', async () => {
111+
const markdown = [
112+
'---',
113+
'title: Test',
114+
'introLinks: this is not an object',
115+
'---',
116+
'',
117+
'# Test',
118+
].join('\n')
119+
const result = await runRule(frontmatterIntroLinks, {
120+
strings: { 'content/test/index.md': markdown },
121+
...fmOptions,
122+
})
123+
const errors = result['content/test/index.md']
124+
expect(errors.length).toBe(0)
125+
})
126+
})

0 commit comments

Comments
 (0)