|
| 1 | +import { addError } from 'markdownlint-rule-helpers' |
| 2 | +import { getFrontmatter } from '@/content-linter/lib/helpers/utils' |
| 3 | + |
| 4 | +export const frontmatterValidation = { |
| 5 | + names: ['GHD055', 'frontmatter-validation'], |
| 6 | + description: |
| 7 | + 'Frontmatter properties must meet character limits and required property requirements', |
| 8 | + tags: ['frontmatter', 'character-limits', 'required-properties'], |
| 9 | + function: (params, onError) => { |
| 10 | + const fm = getFrontmatter(params.lines) |
| 11 | + if (!fm) return |
| 12 | + |
| 13 | + // Detect content type based on frontmatter properties and file path |
| 14 | + const contentType = detectContentType(fm, params.name) |
| 15 | + |
| 16 | + // Define character limits and requirements for different content types |
| 17 | + const contentRules = { |
| 18 | + category: { |
| 19 | + title: { max: 70, recommended: 67 }, |
| 20 | + shortTitle: { max: 30, recommended: 27 }, |
| 21 | + intro: { required: true, recommended: 280, max: 362 }, |
| 22 | + requiredProperties: ['intro'], |
| 23 | + }, |
| 24 | + mapTopic: { |
| 25 | + title: { max: 70, recommended: 63 }, |
| 26 | + shortTitle: { max: 35, recommended: 30 }, |
| 27 | + intro: { required: true, recommended: 280, max: 362 }, |
| 28 | + requiredProperties: ['intro'], |
| 29 | + }, |
| 30 | + article: { |
| 31 | + title: { max: 80, recommended: 60 }, |
| 32 | + shortTitle: { max: 30, recommended: 25 }, |
| 33 | + intro: { required: false, recommended: 251, max: 354 }, |
| 34 | + requiredProperties: ['topics'], |
| 35 | + }, |
| 36 | + } |
| 37 | + |
| 38 | + const rules = contentRules[contentType] |
| 39 | + if (!rules) return |
| 40 | + |
| 41 | + // Check required properties |
| 42 | + for (const property of rules.requiredProperties) { |
| 43 | + if (!fm[property]) { |
| 44 | + addError( |
| 45 | + onError, |
| 46 | + 1, |
| 47 | + `Missing required property '${property}' for ${contentType} content type`, |
| 48 | + null, |
| 49 | + null, |
| 50 | + null, |
| 51 | + ) |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + // Check title length |
| 56 | + if (fm.title) { |
| 57 | + validatePropertyLength(onError, params.lines, 'title', fm.title, rules.title, 'Title') |
| 58 | + } |
| 59 | + |
| 60 | + // Check shortTitle length |
| 61 | + if (fm.shortTitle) { |
| 62 | + validatePropertyLength( |
| 63 | + onError, |
| 64 | + params.lines, |
| 65 | + 'shortTitle', |
| 66 | + fm.shortTitle, |
| 67 | + rules.shortTitle, |
| 68 | + 'ShortTitle', |
| 69 | + ) |
| 70 | + } |
| 71 | + |
| 72 | + // Check intro length if it exists |
| 73 | + if (fm.intro && rules.intro) { |
| 74 | + validatePropertyLength(onError, params.lines, 'intro', fm.intro, rules.intro, 'Intro') |
| 75 | + } |
| 76 | + |
| 77 | + // Cross-property validation: if title is longer than shortTitle limit, shortTitle must exist |
| 78 | + if (fm.title && fm.title.length > rules.shortTitle.max && !fm.shortTitle) { |
| 79 | + const titleLine = findPropertyLine(params.lines, 'title') |
| 80 | + addError( |
| 81 | + onError, |
| 82 | + titleLine, |
| 83 | + `Title is ${fm.title.length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`, |
| 84 | + fm.title, |
| 85 | + null, |
| 86 | + null, |
| 87 | + ) |
| 88 | + } |
| 89 | + |
| 90 | + // Special validation for articles: should have at least one topic |
| 91 | + if (contentType === 'article' && fm.topics) { |
| 92 | + if (!Array.isArray(fm.topics)) { |
| 93 | + const topicsLine = findPropertyLine(params.lines, 'topics') |
| 94 | + addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null) |
| 95 | + } else if (fm.topics.length === 0) { |
| 96 | + const topicsLine = findPropertyLine(params.lines, 'topics') |
| 97 | + addError( |
| 98 | + onError, |
| 99 | + topicsLine, |
| 100 | + 'Articles should have at least one topic', |
| 101 | + 'topics: []', |
| 102 | + null, |
| 103 | + null, |
| 104 | + ) |
| 105 | + } |
| 106 | + } |
| 107 | + }, |
| 108 | +} |
| 109 | + |
| 110 | +function validatePropertyLength(onError, lines, propertyName, propertyValue, limits, displayName) { |
| 111 | + const propertyLength = propertyValue.length |
| 112 | + const propertyLine = findPropertyLine(lines, propertyName) |
| 113 | + |
| 114 | + // Only report the most severe error - maximum takes precedence over recommended |
| 115 | + if (propertyLength > limits.max) { |
| 116 | + addError( |
| 117 | + onError, |
| 118 | + propertyLine, |
| 119 | + `${displayName} exceeds maximum length of ${limits.max} characters (current: ${propertyLength})`, |
| 120 | + propertyValue, |
| 121 | + null, |
| 122 | + null, |
| 123 | + ) |
| 124 | + } else if (propertyLength > limits.recommended) { |
| 125 | + addError( |
| 126 | + onError, |
| 127 | + propertyLine, |
| 128 | + `${displayName} exceeds recommended length of ${limits.recommended} characters (current: ${propertyLength})`, |
| 129 | + propertyValue, |
| 130 | + null, |
| 131 | + null, |
| 132 | + ) |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +function detectContentType(frontmatter, filePath) { |
| 137 | + // Only apply validation to markdown files |
| 138 | + if (!filePath || !filePath.endsWith('.md')) { |
| 139 | + return null |
| 140 | + } |
| 141 | + |
| 142 | + // Map topics have mapTopic: true |
| 143 | + if (frontmatter.mapTopic === true) { |
| 144 | + return 'mapTopic' |
| 145 | + } |
| 146 | + |
| 147 | + // Categories are index.md files that contain children but no mapTopic |
| 148 | + // Only check files that look like they're in the content directory structure |
| 149 | + if ( |
| 150 | + filePath.includes('/index.md') && |
| 151 | + frontmatter.children && |
| 152 | + Array.isArray(frontmatter.children) && |
| 153 | + !frontmatter.mapTopic |
| 154 | + ) { |
| 155 | + return 'category' |
| 156 | + } |
| 157 | + |
| 158 | + // Everything else is an article |
| 159 | + return 'article' |
| 160 | +} |
| 161 | + |
| 162 | +function findPropertyLine(lines, property) { |
| 163 | + const line = lines.find((line) => line.trim().startsWith(`${property}:`)) |
| 164 | + return line ? lines.indexOf(line) + 1 : 1 |
| 165 | +} |
0 commit comments