1
0
mirror of synced 2025-12-19 18:10:59 -05:00

validate heroImage frontmatter for index.md files (#58127)

This commit is contained in:
Evan Bonsignori
2025-10-22 12:43:39 -07:00
committed by GitHub
parent a01a63b052
commit fb82870348
4 changed files with 225 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import fs from 'fs'
import path from 'path'
// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types'
interface Frontmatter {
heroImage?: string
[key: string]: any
}
// Get the list of valid hero images
function getValidHeroImages(): string[] {
const ROOT = process.env.ROOT || '.'
const heroImageDir = path.join(ROOT, 'assets/images/banner-images')
try {
if (!fs.existsSync(heroImageDir)) {
return []
}
const files = fs.readdirSync(heroImageDir)
// Return absolute paths as they would appear in frontmatter
return files.map((file) => `/assets/images/banner-images/${file}`)
} catch {
return []
}
}
export const frontmatterHeroImage: Rule = {
names: ['GHD061', 'frontmatter-hero-image'],
description:
'Hero image paths must be absolute and point to valid images in /assets/images/banner-images/',
tags: ['frontmatter', 'images'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Only check index.md files
if (!params.name.endsWith('index.md')) return
const fm = getFrontmatter(params.lines) as Frontmatter | null
if (!fm || !fm.heroImage) return
const heroImage = fm.heroImage
// Check if heroImage is an absolute path
if (!heroImage.startsWith('/')) {
const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:'))
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
addError(
onError,
lineNumber,
`Hero image path must be absolute (start with /). Found: ${heroImage}`,
line || '',
null, // No fix possible
)
return
}
// Check if heroImage points to banner-images directory
if (!heroImage.startsWith('/assets/images/banner-images/')) {
const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:'))
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
addError(
onError,
lineNumber,
`Hero image must point to /assets/images/banner-images/. Found: ${heroImage}`,
line || '',
null, // No fix possible
)
return
}
// Check if the file actually exists
const validHeroImages = getValidHeroImages()
if (validHeroImages.length > 0 && !validHeroImages.includes(heroImage)) {
const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:'))
const lineNumber = line ? params.lines.indexOf(line) + 1 : 1
const availableImages = validHeroImages.join(', ')
addError(
onError,
lineNumber,
`Hero image file does not exist: ${heroImage}. Available images: ${availableImages}`,
line || '',
null, // No fix possible
)
}
},
}

View File

@@ -61,6 +61,7 @@ import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema'
import { journeyTracksLiquid } from './journey-tracks-liquid'
import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists'
import { journeyTracksUniqueIds } from './journey-tracks-unique-ids'
import { frontmatterHeroImage } from './frontmatter-hero-image'
// Using any type because @github/markdownlint-github doesn't provide TypeScript declarations
// The elements in the array have a 'names' property that contains rule identifiers
@@ -130,6 +131,7 @@ export const gitHubDocsMarkdownlint = {
journeyTracksLiquid, // GHD058
journeyTracksGuidePathExists, // GHD059
journeyTracksUniqueIds, // GHD060
frontmatterHeroImage, // GHD061
// Search-replace rules
searchReplace, // Open-source plugin

View File

@@ -348,6 +348,12 @@ export const githubDocsFrontmatterConfig = {
'partial-markdown-files': false,
'yml-files': false,
},
'frontmatter-hero-image': {
// GHD061
severity: 'error',
'partial-markdown-files': false,
'yml-files': false,
},
}
// Configures rules from the `github/markdownlint-github` repo

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from 'vitest'
import { runRule } from '../../lib/init-test'
import { frontmatterHeroImage } from '../../lib/linting-rules/frontmatter-hero-image'
const fmOptions = { markdownlintOptions: { frontMatter: null } }
describe(frontmatterHeroImage.names.join(' - '), () => {
test('valid absolute heroImage path passes', async () => {
const markdown = [
'---',
'title: Test',
"heroImage: '/assets/images/banner-images/hero-1.png'",
'---',
'',
'# Test',
].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/index.md': markdown },
...fmOptions,
})
const errors = result['content/test/index.md']
expect(errors.length).toBe(0)
})
test('non-index.md file is ignored', async () => {
const markdown = [
'---',
'title: Test',
"heroImage: 'invalid-path.png'",
'---',
'',
'# Test',
].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/article.md': markdown },
...fmOptions,
})
const errors = result['content/test/article.md']
expect(errors.length).toBe(0)
})
test('missing heroImage is ignored', async () => {
const markdown = ['---', 'title: Test', '---', '', '# Test'].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/index.md': markdown },
...fmOptions,
})
const errors = result['content/test/index.md']
expect(errors.length).toBe(0)
})
test('relative heroImage path fails', async () => {
const markdown = [
'---',
'title: Test',
"heroImage: 'images/hero-1.png'",
'---',
'',
'# Test',
].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/index.md': markdown },
...fmOptions,
})
const errors = result['content/test/index.md']
expect(errors.length).toBe(1)
expect(errors[0].errorDetail).toContain('must be absolute')
})
test('non-banner-images path fails', async () => {
const markdown = [
'---',
'title: Test',
"heroImage: '/assets/images/other/hero-1.png'",
'---',
'',
'# Test',
].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/index.md': markdown },
...fmOptions,
})
const errors = result['content/test/index.md']
expect(errors.length).toBe(1)
expect(errors[0].errorDetail).toContain('/assets/images/banner-images/')
})
test('non-existent heroImage file fails', async () => {
const markdown = [
'---',
'title: Test',
"heroImage: '/assets/images/banner-images/non-existent.png'",
'---',
'',
'# Test',
].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/index.md': markdown },
...fmOptions,
})
const errors = result['content/test/index.md']
expect(errors.length).toBe(1)
expect(errors[0].errorDetail).toContain('does not exist')
})
test('all valid hero images pass', async () => {
// Test each valid hero image
const validImages = [
"heroImage: '/assets/images/banner-images/hero-1.png'",
"heroImage: '/assets/images/banner-images/hero-2.png'",
"heroImage: '/assets/images/banner-images/hero-3.png'",
"heroImage: '/assets/images/banner-images/hero-4.png'",
"heroImage: '/assets/images/banner-images/hero-5.png'",
"heroImage: '/assets/images/banner-images/hero-6.png'",
]
for (const heroImageLine of validImages) {
const markdown = ['---', 'title: Test', heroImageLine, '---', '', '# Test'].join('\n')
const result = await runRule(frontmatterHeroImage, {
strings: { 'content/test/index.md': markdown },
...fmOptions,
})
const errors = result['content/test/index.md']
expect(errors.length).toBe(0)
}
})
})