Skip to content

Commit fb82870

Browse files
authored
validate heroImage frontmatter for index.md files (#58127)
1 parent a01a63b commit fb82870

File tree

4 files changed

+225
-0
lines changed

4 files changed

+225
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
4+
import { addError } from 'markdownlint-rule-helpers'
5+
6+
import { getFrontmatter } from '../helpers/utils'
7+
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
8+
9+
interface Frontmatter {
10+
heroImage?: string
11+
[key: string]: any
12+
}
13+
14+
// Get the list of valid hero images
15+
function getValidHeroImages(): string[] {
16+
const ROOT = process.env.ROOT || '.'
17+
const heroImageDir = path.join(ROOT, 'assets/images/banner-images')
18+
19+
try {
20+
if (!fs.existsSync(heroImageDir)) {
21+
return []
22+
}
23+
24+
const files = fs.readdirSync(heroImageDir)
25+
// Return absolute paths as they would appear in frontmatter
26+
return files.map((file) => `/assets/images/banner-images/${file}`)
27+
} catch {
28+
return []
29+
}
30+
}
31+
32+
export const frontmatterHeroImage: Rule = {
33+
names: ['GHD061', 'frontmatter-hero-image'],
34+
description:
35+
'Hero image paths must be absolute and point to valid images in /assets/images/banner-images/',
36+
tags: ['frontmatter', 'images'],
37+
function: (params: RuleParams, onError: RuleErrorCallback) => {
38+
// Only check index.md files
39+
if (!params.name.endsWith('index.md')) return
40+
41+
const fm = getFrontmatter(params.lines) as Frontmatter | null
42+
if (!fm || !fm.heroImage) return
43+
44+
const heroImage = fm.heroImage
45+
46+
// Check if heroImage is an absolute path
47+
if (!heroImage.startsWith('/')) {
48+
const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:'))
49+
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
50+
addError(
51+
onError,
52+
lineNumber,
53+
`Hero image path must be absolute (start with /). Found: ${heroImage}`,
54+
line || '',
55+
null, // No fix possible
56+
)
57+
return
58+
}
59+
60+
// Check if heroImage points to banner-images directory
61+
if (!heroImage.startsWith('/assets/images/banner-images/')) {
62+
const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:'))
63+
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
64+
addError(
65+
onError,
66+
lineNumber,
67+
`Hero image must point to /assets/images/banner-images/. Found: ${heroImage}`,
68+
line || '',
69+
null, // No fix possible
70+
)
71+
return
72+
}
73+
74+
// Check if the file actually exists
75+
const validHeroImages = getValidHeroImages()
76+
if (validHeroImages.length > 0 && !validHeroImages.includes(heroImage)) {
77+
const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:'))
78+
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
79+
const availableImages = validHeroImages.join(', ')
80+
addError(
81+
onError,
82+
lineNumber,
83+
`Hero image file does not exist: ${heroImage}. Available images: ${availableImages}`,
84+
line || '',
85+
null, // No fix possible
86+
)
87+
}
88+
},
89+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema'
6161
import { journeyTracksLiquid } from './journey-tracks-liquid'
6262
import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists'
6363
import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
64+
import { frontmatterHeroImage } from './frontmatter-hero-image'
6465

6566
// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
6667
// The elements in the array have a 'names' property that contains rule identifiers
@@ -130,6 +131,7 @@ export const gitHubDocsMarkdownlint = {
130131
journeyTracksLiquid, // GHD058
131132
journeyTracksGuidePathExists, // GHD059
132133
journeyTracksUniqueIds, // GHD060
134+
frontmatterHeroImage, // GHD061
133135

134136
// Search-replace rules
135137
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
@@ -348,6 +348,12 @@ export const githubDocsFrontmatterConfig = {
348348
'partial-markdown-files': false,
349349
'yml-files': false,
350350
},
351+
'frontmatter-hero-image': {
352+
// GHD061
353+
severity: 'error',
354+
'partial-markdown-files': false,
355+
'yml-files': false,
356+
},
351357
}
352358

353359
// Configures rules from the `github/markdownlint-github` repo
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { runRule } from '../../lib/init-test'
4+
import { frontmatterHeroImage } from '../../lib/linting-rules/frontmatter-hero-image'
5+
6+
const fmOptions = { markdownlintOptions: { frontMatter: null } }
7+
8+
describe(frontmatterHeroImage.names.join(' - '), () => {
9+
test('valid absolute heroImage path passes', async () => {
10+
const markdown = [
11+
'---',
12+
'title: Test',
13+
"heroImage: '/assets/images/banner-images/hero-1.png'",
14+
'---',
15+
'',
16+
'# Test',
17+
].join('\n')
18+
const result = await runRule(frontmatterHeroImage, {
19+
strings: { 'content/test/index.md': markdown },
20+
...fmOptions,
21+
})
22+
const errors = result['content/test/index.md']
23+
expect(errors.length).toBe(0)
24+
})
25+
26+
test('non-index.md file is ignored', async () => {
27+
const markdown = [
28+
'---',
29+
'title: Test',
30+
"heroImage: 'invalid-path.png'",
31+
'---',
32+
'',
33+
'# Test',
34+
].join('\n')
35+
const result = await runRule(frontmatterHeroImage, {
36+
strings: { 'content/test/article.md': markdown },
37+
...fmOptions,
38+
})
39+
const errors = result['content/test/article.md']
40+
expect(errors.length).toBe(0)
41+
})
42+
43+
test('missing heroImage is ignored', async () => {
44+
const markdown = ['---', 'title: Test', '---', '', '# Test'].join('\n')
45+
const result = await runRule(frontmatterHeroImage, {
46+
strings: { 'content/test/index.md': markdown },
47+
...fmOptions,
48+
})
49+
const errors = result['content/test/index.md']
50+
expect(errors.length).toBe(0)
51+
})
52+
53+
test('relative heroImage path fails', async () => {
54+
const markdown = [
55+
'---',
56+
'title: Test',
57+
"heroImage: 'images/hero-1.png'",
58+
'---',
59+
'',
60+
'# Test',
61+
].join('\n')
62+
const result = await runRule(frontmatterHeroImage, {
63+
strings: { 'content/test/index.md': markdown },
64+
...fmOptions,
65+
})
66+
const errors = result['content/test/index.md']
67+
expect(errors.length).toBe(1)
68+
expect(errors[0].errorDetail).toContain('must be absolute')
69+
})
70+
71+
test('non-banner-images path fails', async () => {
72+
const markdown = [
73+
'---',
74+
'title: Test',
75+
"heroImage: '/assets/images/other/hero-1.png'",
76+
'---',
77+
'',
78+
'# Test',
79+
].join('\n')
80+
const result = await runRule(frontmatterHeroImage, {
81+
strings: { 'content/test/index.md': markdown },
82+
...fmOptions,
83+
})
84+
const errors = result['content/test/index.md']
85+
expect(errors.length).toBe(1)
86+
expect(errors[0].errorDetail).toContain('/assets/images/banner-images/')
87+
})
88+
89+
test('non-existent heroImage file fails', async () => {
90+
const markdown = [
91+
'---',
92+
'title: Test',
93+
"heroImage: '/assets/images/banner-images/non-existent.png'",
94+
'---',
95+
'',
96+
'# Test',
97+
].join('\n')
98+
const result = await runRule(frontmatterHeroImage, {
99+
strings: { 'content/test/index.md': markdown },
100+
...fmOptions,
101+
})
102+
const errors = result['content/test/index.md']
103+
expect(errors.length).toBe(1)
104+
expect(errors[0].errorDetail).toContain('does not exist')
105+
})
106+
107+
test('all valid hero images pass', async () => {
108+
// Test each valid hero image
109+
const validImages = [
110+
"heroImage: '/assets/images/banner-images/hero-1.png'",
111+
"heroImage: '/assets/images/banner-images/hero-2.png'",
112+
"heroImage: '/assets/images/banner-images/hero-3.png'",
113+
"heroImage: '/assets/images/banner-images/hero-4.png'",
114+
"heroImage: '/assets/images/banner-images/hero-5.png'",
115+
"heroImage: '/assets/images/banner-images/hero-6.png'",
116+
]
117+
118+
for (const heroImageLine of validImages) {
119+
const markdown = ['---', 'title: Test', heroImageLine, '---', '', '# Test'].join('\n')
120+
const result = await runRule(frontmatterHeroImage, {
121+
strings: { 'content/test/index.md': markdown },
122+
...fmOptions,
123+
})
124+
const errors = result['content/test/index.md']
125+
expect(errors.length).toBe(0)
126+
}
127+
})
128+
})

0 commit comments

Comments
 (0)