diff --git a/src/content-linter/lib/linting-rules/frontmatter-hero-image.ts b/src/content-linter/lib/linting-rules/frontmatter-hero-image.ts new file mode 100644 index 0000000000..e7cd45bd8b --- /dev/null +++ b/src/content-linter/lib/linting-rules/frontmatter-hero-image.ts @@ -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 + ) + } + }, +} diff --git a/src/content-linter/lib/linting-rules/index.ts b/src/content-linter/lib/linting-rules/index.ts index eaa9706da0..d3cabfa448 100644 --- a/src/content-linter/lib/linting-rules/index.ts +++ b/src/content-linter/lib/linting-rules/index.ts @@ -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 diff --git a/src/content-linter/style/github-docs.ts b/src/content-linter/style/github-docs.ts index 8d53d4631f..eddf742b46 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -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 diff --git a/src/content-linter/tests/unit/frontmatter-hero-image.ts b/src/content-linter/tests/unit/frontmatter-hero-image.ts new file mode 100644 index 0000000000..ac3096c703 --- /dev/null +++ b/src/content-linter/tests/unit/frontmatter-hero-image.ts @@ -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) + } + }) +})