|
| 1 | +import { addError } from 'markdownlint-rule-helpers' |
| 2 | +import { getRange } from '../helpers/utils.js' |
| 3 | +import frontmatter from '#src/frame/lib/read-frontmatter.js' |
| 4 | + |
| 5 | +export const noteWarningFormatting = { |
| 6 | + names: ['GHD049', 'note-warning-formatting'], |
| 7 | + description: 'Note and warning tags should be formatted according to style guide', |
| 8 | + tags: ['formatting', 'callouts', 'notes', 'warnings', 'style'], |
| 9 | + severity: 'warning', |
| 10 | + function: (params, onError) => { |
| 11 | + // Skip autogenerated files |
| 12 | + const frontmatterString = params.frontMatterLines.join('\n') |
| 13 | + const fm = frontmatter(frontmatterString).data |
| 14 | + if (fm && fm.autogenerated) return |
| 15 | + |
| 16 | + const lines = params.lines |
| 17 | + let inLegacyNote = false |
| 18 | + let noteStartLine = null |
| 19 | + let noteContent = [] |
| 20 | + |
| 21 | + for (let i = 0; i < lines.length; i++) { |
| 22 | + const line = lines[i] |
| 23 | + const lineNumber = i + 1 |
| 24 | + |
| 25 | + // Check for legacy {% note %} tags |
| 26 | + if (line.trim() === '{% note %}') { |
| 27 | + inLegacyNote = true |
| 28 | + noteStartLine = lineNumber |
| 29 | + noteContent = [] |
| 30 | + |
| 31 | + // Check for missing line break before {% note %} |
| 32 | + const prevLine = i > 0 ? lines[i - 1] : '' |
| 33 | + if (prevLine.trim() !== '') { |
| 34 | + const range = getRange(line, '{% note %}') |
| 35 | + addError(onError, lineNumber, 'Add a blank line before {% note %} tag', line, range, { |
| 36 | + editColumn: 1, |
| 37 | + deleteCount: 0, |
| 38 | + insertText: '\n', |
| 39 | + }) |
| 40 | + } |
| 41 | + continue |
| 42 | + } |
| 43 | + |
| 44 | + // Check for end of legacy note |
| 45 | + if (line.trim() === '{% endnote %}') { |
| 46 | + if (inLegacyNote) { |
| 47 | + inLegacyNote = false |
| 48 | + |
| 49 | + // Check for missing line break after {% endnote %} |
| 50 | + const nextLine = i < lines.length - 1 ? lines[i + 1] : '' |
| 51 | + if (nextLine.trim() !== '') { |
| 52 | + const range = getRange(line, '{% endnote %}') |
| 53 | + addError(onError, lineNumber, 'Add a blank line after {% endnote %} tag', line, range, { |
| 54 | + editColumn: line.length + 1, |
| 55 | + deleteCount: 0, |
| 56 | + insertText: '\n', |
| 57 | + }) |
| 58 | + } |
| 59 | + |
| 60 | + // Check note content formatting |
| 61 | + validateNoteContent(noteContent, noteStartLine, onError) |
| 62 | + } |
| 63 | + continue |
| 64 | + } |
| 65 | + |
| 66 | + // Collect content inside legacy notes |
| 67 | + if (inLegacyNote) { |
| 68 | + noteContent.push({ text: line, lineNumber: lineNumber }) |
| 69 | + continue |
| 70 | + } |
| 71 | + |
| 72 | + // Check for new-style callouts > [!NOTE], > [!WARNING], > [!DANGER] |
| 73 | + const calloutMatch = line.match(/^>\s*\[!(NOTE|WARNING|DANGER)\]\s*$/) |
| 74 | + if (calloutMatch) { |
| 75 | + const calloutType = calloutMatch[1] |
| 76 | + |
| 77 | + // Check for missing line break before callout |
| 78 | + const prevLine = i > 0 ? lines[i - 1] : '' |
| 79 | + if (prevLine.trim() !== '') { |
| 80 | + const range = getRange(line, line.trim()) |
| 81 | + addError( |
| 82 | + onError, |
| 83 | + lineNumber, |
| 84 | + `Add a blank line before > [!${calloutType}] callout`, |
| 85 | + line, |
| 86 | + range, |
| 87 | + { |
| 88 | + editColumn: 1, |
| 89 | + deleteCount: 0, |
| 90 | + insertText: '\n', |
| 91 | + }, |
| 92 | + ) |
| 93 | + } |
| 94 | + |
| 95 | + // Find the end of this callout block and validate content |
| 96 | + const calloutContent = [] |
| 97 | + let j = i + 1 |
| 98 | + while (j < lines.length && lines[j].startsWith('>')) { |
| 99 | + if (lines[j].trim() !== '>') { |
| 100 | + calloutContent.push({ text: lines[j], lineNumber: j + 1 }) |
| 101 | + } |
| 102 | + j++ |
| 103 | + } |
| 104 | + |
| 105 | + // Check for missing line break after callout |
| 106 | + if (j < lines.length && lines[j].trim() !== '') { |
| 107 | + const range = getRange(lines[j], lines[j].trim()) |
| 108 | + addError( |
| 109 | + onError, |
| 110 | + j + 1, |
| 111 | + `Add a blank line after > [!${calloutType}] callout block`, |
| 112 | + lines[j], |
| 113 | + range, |
| 114 | + { |
| 115 | + editColumn: 1, |
| 116 | + deleteCount: 0, |
| 117 | + insertText: '\n', |
| 118 | + }, |
| 119 | + ) |
| 120 | + } |
| 121 | + |
| 122 | + validateCalloutContent(calloutContent, calloutType, lineNumber, onError) |
| 123 | + i = j - 1 // Skip to end of callout block |
| 124 | + continue |
| 125 | + } |
| 126 | + |
| 127 | + // Check for orphaned **Note:**/**Warning:**/**Danger:** outside callouts |
| 128 | + const orphanedPrefixMatch = line.match(/\*\*(Note|Warning|Danger):\*\*/) |
| 129 | + if (orphanedPrefixMatch && !inLegacyNote && !line.startsWith('>')) { |
| 130 | + const range = getRange(line, orphanedPrefixMatch[0]) |
| 131 | + addError( |
| 132 | + onError, |
| 133 | + lineNumber, |
| 134 | + `${orphanedPrefixMatch[1]} prefix should be inside a callout block`, |
| 135 | + line, |
| 136 | + range, |
| 137 | + null, // No auto-fix as this requires human decision |
| 138 | + ) |
| 139 | + } |
| 140 | + } |
| 141 | + }, |
| 142 | +} |
| 143 | + |
| 144 | +/** |
| 145 | + * Validate content inside legacy {% note %} blocks |
| 146 | + */ |
| 147 | +function validateNoteContent(noteContent, noteStartLine, onError) { |
| 148 | + if (noteContent.length === 0) return |
| 149 | + |
| 150 | + const contentLines = noteContent.filter((item) => item.text.trim() !== '') |
| 151 | + if (contentLines.length === 0) return |
| 152 | + |
| 153 | + // Count bullet points |
| 154 | + const bulletLines = contentLines.filter((item) => item.text.trim().match(/^[*\-+]\s/)) |
| 155 | + if (bulletLines.length > 2) { |
| 156 | + const range = getRange(bulletLines[2].text, bulletLines[2].text.trim()) |
| 157 | + addError( |
| 158 | + onError, |
| 159 | + bulletLines[2].lineNumber, |
| 160 | + 'Do not include more than 2 bullet points inside a callout', |
| 161 | + bulletLines[2].text, |
| 162 | + range, |
| 163 | + null, // No auto-fix as this requires content restructuring |
| 164 | + ) |
| 165 | + } |
| 166 | + |
| 167 | + // Check for missing prefix (only if it looks like a traditional note) |
| 168 | + const firstContentLine = contentLines[0] |
| 169 | + const allContent = contentLines.map((line) => line.text).join(' ') |
| 170 | + const hasButtons = |
| 171 | + allContent.includes('<a href=') || allContent.includes('btn') || allContent.includes('class=') |
| 172 | + |
| 173 | + if ( |
| 174 | + !hasButtons && |
| 175 | + !firstContentLine.text.includes('**Note:**') && |
| 176 | + !firstContentLine.text.includes('**Warning:**') && |
| 177 | + !firstContentLine.text.includes('**Danger:**') |
| 178 | + ) { |
| 179 | + const range = getRange(firstContentLine.text, firstContentLine.text.trim()) |
| 180 | + addError( |
| 181 | + onError, |
| 182 | + firstContentLine.lineNumber, |
| 183 | + 'Note content should start with **Note:**, **Warning:**, or **Danger:**', |
| 184 | + firstContentLine.text, |
| 185 | + range, |
| 186 | + { |
| 187 | + editColumn: firstContentLine.text.indexOf(firstContentLine.text.trim()) + 1, |
| 188 | + deleteCount: 0, |
| 189 | + insertText: '**Note:** ', |
| 190 | + }, |
| 191 | + ) |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +/** |
| 196 | + * Validate content inside new-style callouts |
| 197 | + */ |
| 198 | +function validateCalloutContent(calloutContent, calloutType, calloutStartLine, onError) { |
| 199 | + if (calloutContent.length === 0) return |
| 200 | + |
| 201 | + const contentLines = calloutContent.filter((item) => item.text.trim() !== '>') |
| 202 | + if (contentLines.length === 0) return |
| 203 | + |
| 204 | + // Count bullet points |
| 205 | + const bulletLines = contentLines.filter((item) => item.text.match(/^>\s*[*\-+]\s/)) |
| 206 | + if (bulletLines.length > 2) { |
| 207 | + const range = getRange(bulletLines[2].text, bulletLines[2].text.trim()) |
| 208 | + addError( |
| 209 | + onError, |
| 210 | + bulletLines[2].lineNumber, |
| 211 | + 'Do not include more than 2 bullet points inside a callout', |
| 212 | + bulletLines[2].text, |
| 213 | + range, |
| 214 | + null, // No auto-fix as this requires content restructuring |
| 215 | + ) |
| 216 | + } |
| 217 | + |
| 218 | + // For new-style callouts, the prefix is handled by the [!NOTE] syntax itself |
| 219 | + // so we don't need to check for manual **Note:** prefixes |
| 220 | +} |
0 commit comments