1
0
mirror of synced 2025-12-19 18:10:59 -05:00
Files
docs/src/content-linter/lib/linting-rules/frontmatter-validation.ts
2025-10-14 20:50:09 +00:00

215 lines
6.3 KiB
TypeScript

// @ts-ignore - no types available for markdownlint-rule-helpers
import { addError } from 'markdownlint-rule-helpers'
import { getFrontmatter } from '@/content-linter/lib/helpers/utils'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
interface PropertyLimits {
max: number
recommended: number
required?: boolean
}
interface ContentRules {
title: PropertyLimits
shortTitle: PropertyLimits
intro: PropertyLimits
requiredProperties: string[]
}
type ContentType = 'category' | 'mapTopic' | 'article' | null
// Strip liquid tags from text for character counting purposes
function stripLiquidTags(text: unknown): string {
if (typeof text !== 'string') return text as string
// Remove both {% %} and {{ }} liquid tags
return text.replace(/\{%.*?%\}/g, '').replace(/\{\{.*?\}\}/g, '')
}
export const frontmatterValidation = {
names: ['GHD055', 'frontmatter-validation'],
description:
'Frontmatter properties must meet character limits and required property requirements',
tags: ['frontmatter', 'character-limits', 'required-properties'],
function: (params: RuleParams, onError: RuleErrorCallback) => {
const fm = getFrontmatter(params.lines as string[])
if (!fm) return
// Detect content type based on frontmatter properties and file path
const contentType = detectContentType(fm, params.name)
// Define character limits and requirements for different content types
const contentRules: Record<string, ContentRules> = {
category: {
title: { max: 70, recommended: 67 },
shortTitle: { max: 30, recommended: 27 },
intro: { required: true, recommended: 280, max: 362 },
requiredProperties: ['intro'],
},
mapTopic: {
title: { max: 70, recommended: 63 },
shortTitle: { max: 35, recommended: 30 },
intro: { required: true, recommended: 280, max: 362 },
requiredProperties: ['intro'],
},
article: {
title: { max: 80, recommended: 60 },
shortTitle: { max: 30, recommended: 25 },
intro: { required: false, recommended: 251, max: 354 },
requiredProperties: ['topics'],
},
}
const rules = contentType ? contentRules[contentType] : null
if (!rules) return
// Check required properties
for (const property of rules.requiredProperties) {
if (!fm[property]) {
addError(
onError,
1,
`Missing required property '${property}' for ${contentType} content type`,
null,
null,
null,
)
}
}
// Check title length
if (fm.title) {
validatePropertyLength(
onError,
params.lines as string[],
'title',
fm.title,
rules.title,
'Title',
)
}
// Check shortTitle length
if (fm.shortTitle) {
validatePropertyLength(
onError,
params.lines as string[],
'shortTitle',
fm.shortTitle,
rules.shortTitle,
'ShortTitle',
)
}
// Check intro length if it exists
if (fm.intro && rules.intro) {
validatePropertyLength(
onError,
params.lines as string[],
'intro',
fm.intro,
rules.intro,
'Intro',
)
}
// Cross-property validation: if title is longer than shortTitle limit, shortTitle must exist
const strippedTitle = stripLiquidTags(fm.title)
if (fm.title && (strippedTitle as string).length > rules.shortTitle.max && !fm.shortTitle) {
const titleLine = findPropertyLine(params.lines as string[], 'title')
addError(
onError,
titleLine,
`Title is ${(strippedTitle as string).length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`,
fm.title,
null,
null,
)
}
// Special validation for articles: should have at least one topic
if (contentType === 'article' && fm.topics) {
if (!Array.isArray(fm.topics)) {
const topicsLine = findPropertyLine(params.lines as string[], 'topics')
addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null)
} else if (fm.topics.length === 0) {
const topicsLine = findPropertyLine(params.lines as string[], 'topics')
addError(
onError,
topicsLine,
'Articles should have at least one topic',
'topics: []',
null,
null,
)
}
}
},
}
function validatePropertyLength(
onError: RuleErrorCallback,
lines: string[],
propertyName: string,
propertyValue: string,
limits: PropertyLimits,
displayName: string,
): void {
const strippedValue = stripLiquidTags(propertyValue)
const propertyLength = (strippedValue as string).length
const propertyLine = findPropertyLine(lines, propertyName)
// Only report the most severe error - maximum takes precedence over recommended
if (propertyLength > limits.max) {
addError(
onError,
propertyLine,
`${displayName} exceeds maximum length of ${limits.max} characters (current: ${propertyLength})`,
propertyValue,
null,
null,
)
} else if (propertyLength > limits.recommended) {
addError(
onError,
propertyLine,
`${displayName} exceeds recommended length of ${limits.recommended} characters (current: ${propertyLength})`,
propertyValue,
null,
null,
)
}
}
// frontmatter object structure varies based on YAML content, using any for flexibility
function detectContentType(frontmatter: any, filePath: string): ContentType {
// Only apply validation to markdown files
if (!filePath || !filePath.endsWith('.md')) {
return null
}
// Map topics have mapTopic: true
if (frontmatter.mapTopic === true) {
return 'mapTopic'
}
// Categories are index.md files that contain children but no mapTopic
// Only check files that look like they're in the content directory structure
if (
filePath.includes('/index.md') &&
frontmatter.children &&
Array.isArray(frontmatter.children) &&
!frontmatter.mapTopic
) {
return 'category'
}
// Everything else is an article
return 'article'
}
function findPropertyLine(lines: string[], property: string): number {
const line = lines.find((line) => line.trim().startsWith(`${property}:`))
return line ? lines.indexOf(line) + 1 : 1
}