Skip to content

Commit d2c803e

Browse files
authored
Add note and warning formatting linter rule (#56120)
1 parent 9811045 commit d2c803e

File tree

3 files changed

+546
-0
lines changed

3 files changed

+546
-0
lines changed

src/content-linter/lib/linting-rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { liquidTagWhitespace } from './liquid-tag-whitespace.js'
3535
import { linkQuotation } from './link-quotation.js'
3636
import { octiconAriaLabels } from './octicon-aria-labels.js'
3737
import { liquidIfversionVersions } from './liquid-ifversion-versions.js'
38+
import { noteWarningFormatting } from './note-warning-formatting.js'
3839

3940
const noDefaultAltText = markdownlintGitHub.find((elem) =>
4041
elem.names.includes('no-default-alt-text'),
@@ -84,5 +85,6 @@ export const gitHubDocsMarkdownlint = {
8485
liquidTagWhitespace,
8586
linkQuotation,
8687
octiconAriaLabels,
88+
noteWarningFormatting,
8789
],
8890
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)