validate heroImage frontmatter for index.md files (#58127)
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
128
src/content-linter/tests/unit/frontmatter-hero-image.ts
Normal file
128
src/content-linter/tests/unit/frontmatter-hero-image.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user