Skip to content

Commit ff17f9f

Browse files
authored
Add content linter rule to prevent multiple emphasis patterns (#56122)
1 parent 2cb8eca commit ff17f9f

File tree

3 files changed

+326
-0
lines changed

3 files changed

+326
-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 { multipleEmphasisPatterns } from './multiple-emphasis-patterns.js'
3839
import { noteWarningFormatting } from './note-warning-formatting.js'
3940

4041
const noDefaultAltText = markdownlintGitHub.find((elem) =>
@@ -85,6 +86,7 @@ export const gitHubDocsMarkdownlint = {
8586
liquidTagWhitespace,
8687
linkQuotation,
8788
octiconAriaLabels,
89+
multipleEmphasisPatterns,
8890
noteWarningFormatting,
8991
],
9092
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 multipleEmphasisPatterns = {
6+
names: ['GHD050', 'multiple-emphasis-patterns'],
7+
description: 'Do not use more than one emphasis/strong, italics, or uppercase for a string',
8+
tags: ['formatting', 'emphasis', '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 inCodeBlock = false
18+
19+
for (let i = 0; i < lines.length; i++) {
20+
const line = lines[i]
21+
const lineNumber = i + 1
22+
23+
// Track code block state
24+
if (line.trim().startsWith('```')) {
25+
inCodeBlock = !inCodeBlock
26+
continue
27+
}
28+
29+
// Skip code blocks and indented code
30+
if (inCodeBlock || line.trim().startsWith(' ')) continue
31+
32+
// Check for multiple emphasis patterns
33+
checkMultipleEmphasis(line, lineNumber, onError)
34+
}
35+
},
36+
}
37+
38+
/**
39+
* Check for multiple emphasis types in a single text segment
40+
*/
41+
function checkMultipleEmphasis(line, lineNumber, onError) {
42+
// Focus on the clearest violations of the style guide
43+
const multipleEmphasisPatterns = [
44+
// Bold + italic combinations (***text***)
45+
{ regex: /\*\*\*([^*]+)\*\*\*/g, types: ['bold', 'italic'] },
46+
{ regex: /___([^_]+)___/g, types: ['bold', 'italic'] },
47+
48+
// Bold with code nested inside
49+
{ regex: /\*\*([^*]*`[^`]+`[^*]*)\*\*/g, types: ['bold', 'code'] },
50+
{ regex: /__([^_]*`[^`]+`[^_]*)__/g, types: ['bold', 'code'] },
51+
52+
// Code with bold nested inside
53+
{ regex: /`([^`]*\*\*[^*]+\*\*[^`]*)`/g, types: ['code', 'bold'] },
54+
{ regex: /`([^`]*__[^_]+__[^`]*)`/g, types: ['code', 'bold'] },
55+
]
56+
57+
for (const pattern of multipleEmphasisPatterns) {
58+
let match
59+
while ((match = pattern.regex.exec(line)) !== null) {
60+
// Skip if this is likely intentional or very short
61+
if (shouldSkipMatch(match[0], match[1])) continue
62+
63+
const range = getRange(line, match[0])
64+
addError(
65+
onError,
66+
lineNumber,
67+
`Do not use multiple emphasis types in a single string: ${pattern.types.join(' + ')}`,
68+
line,
69+
range,
70+
null, // No auto-fix as this requires editorial judgment
71+
)
72+
}
73+
}
74+
}
75+
76+
/**
77+
* Determine if a match should be skipped (likely intentional formatting)
78+
*/
79+
function shouldSkipMatch(fullMatch, content) {
80+
// Skip common false positives
81+
if (!content) return true
82+
83+
// Skip very short content (likely intentional single chars)
84+
if (content.trim().length < 2) return true
85+
86+
// Skip if it's mostly code-like content (constants, variables)
87+
if (/^[A-Z_][A-Z0-9_]*$/.test(content.trim())) return true
88+
89+
// Skip file extensions or URLs
90+
if (/\.[a-z]{2,4}$/i.test(content.trim()) || /https?:\/\//.test(content)) return true
91+
92+
return false
93+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { runRule } from '../../lib/init-test.js'
4+
import { multipleEmphasisPatterns } from '../../lib/linting-rules/multiple-emphasis-patterns.js'
5+
6+
describe(multipleEmphasisPatterns.names.join(' - '), () => {
7+
test('Single emphasis types pass', async () => {
8+
const markdown = [
9+
'This is **bold text** that is fine.',
10+
'This is *italic text* that is okay.',
11+
'This is `code text` that is acceptable.',
12+
'This is a SCREAMING_CASE_WORD that is allowed.',
13+
'This is __bold with underscores__ that works.',
14+
'This is _italic with underscores_ that works.',
15+
].join('\n')
16+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
17+
const errors = result.markdown
18+
expect(errors.length).toBe(0)
19+
})
20+
21+
test('Multiple emphasis types in same string are flagged', async () => {
22+
const markdown = [
23+
'This is **bold and `code`** in the same string.',
24+
'This is ***bold and italic*** combined.',
25+
'This is `code with **bold**` inside.',
26+
'This is ___bold and italic___ with underscores.',
27+
].join('\n')
28+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
29+
const errors = result.markdown
30+
expect(errors.length).toBe(4)
31+
expect(errors[0].lineNumber).toBe(1)
32+
expect(errors[1].lineNumber).toBe(2)
33+
expect(errors[2].lineNumber).toBe(3)
34+
expect(errors[3].lineNumber).toBe(4)
35+
})
36+
37+
test('Nested emphasis patterns are flagged', async () => {
38+
const markdown = [
39+
'This is **bold with `code` inside**.',
40+
'This is `code with **bold** nested`.',
41+
].join('\n')
42+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
43+
const errors = result.markdown
44+
expect(errors.length).toBe(2)
45+
expect(errors[0].lineNumber).toBe(1)
46+
expect(errors[1].lineNumber).toBe(2)
47+
})
48+
49+
test('Separate emphasis patterns on same line pass', async () => {
50+
const markdown = [
51+
'This is **bold** and this is *italic* but separate.',
52+
'Here is `code` and here is UPPERCASE but apart.',
53+
'First **bold**, then some text, then *italic*.',
54+
].join('\n')
55+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
56+
const errors = result.markdown
57+
expect(errors.length).toBe(0)
58+
})
59+
60+
test('Code blocks are ignored', async () => {
61+
const markdown = [
62+
'```javascript',
63+
'const text = "**bold** and `code` mixed";',
64+
'const more = "***triple emphasis***";',
65+
'```',
66+
'',
67+
' // Indented code block',
68+
' const example = "**bold** with `code`";',
69+
].join('\n')
70+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
71+
const errors = result.markdown
72+
expect(errors.length).toBe(0)
73+
})
74+
75+
test('Inline code prevents other emphasis detection', async () => {
76+
const markdown = [
77+
'Use `**bold**` to make text bold.',
78+
'The `*italic*` syntax creates italic text.',
79+
'Type `__bold__` for bold formatting.',
80+
].join('\n')
81+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
82+
const errors = result.markdown
83+
expect(errors.length).toBe(2) // Code with bold inside is detected
84+
})
85+
86+
test('Complex mixed emphasis patterns', async () => {
87+
const markdown = [
88+
'This is **bold and `code`** mixed.',
89+
'Here is ***bold italic*** combined.',
90+
'Text with __bold and `code`__ together.',
91+
].join('\n')
92+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
93+
const errors = result.markdown
94+
expect(errors.length).toBe(3)
95+
expect(errors[0].lineNumber).toBe(1)
96+
expect(errors[1].lineNumber).toBe(2)
97+
expect(errors[2].lineNumber).toBe(3)
98+
})
99+
100+
test('Edge case: adjacent emphasis without overlap passes', async () => {
101+
const markdown = [
102+
'This is **bold**_italic_ adjacent but not overlapping.',
103+
'Here is `code`**bold** touching but separate.',
104+
'Text with UPPERCASE**bold** next to each other.',
105+
].join('\n')
106+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
107+
const errors = result.markdown
108+
expect(errors.length).toBe(0)
109+
})
110+
111+
test('Triple asterisk bold+italic is flagged', async () => {
112+
const markdown = [
113+
'This is ***bold and italic*** combined.',
114+
'Here is ___bold and italic___ with underscores.',
115+
].join('\n')
116+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
117+
const errors = result.markdown
118+
expect(errors.length).toBe(2)
119+
expect(errors[0].lineNumber).toBe(1)
120+
expect(errors[1].lineNumber).toBe(2)
121+
})
122+
123+
test('Mixed adjacent emphasis types are allowed', async () => {
124+
const markdown = [
125+
'This has **bold** and normal text.',
126+
'This has **bold** and other text.',
127+
'The API key and **configuration** work.',
128+
'The API key and **setup** process.',
129+
].join('\n')
130+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
131+
const errors = result.markdown
132+
expect(errors.length).toBe(0)
133+
})
134+
135+
test('Autogenerated files are skipped', async () => {
136+
const frontmatter = ['---', 'title: API Reference', 'autogenerated: rest', '---'].join('\n')
137+
const markdown = [
138+
'This is **bold and `code`** mixed.',
139+
'This is ***bold italic*** combined.',
140+
].join('\n')
141+
const result = await runRule(multipleEmphasisPatterns, {
142+
strings: {
143+
markdown: frontmatter + '\n' + markdown,
144+
},
145+
})
146+
const errors = result.markdown
147+
expect(errors.length).toBe(0)
148+
})
149+
150+
test('Links with emphasis are handled correctly', async () => {
151+
const markdown = [
152+
'See [**bold link**](http://example.com) for details.',
153+
'Check [*italic link*](http://example.com) here.',
154+
'Visit [`code link`](http://example.com) for info.',
155+
'Go to [**bold and `code`**](http://example.com) - should be flagged.',
156+
].join('\n')
157+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
158+
const errors = result.markdown
159+
expect(errors.length).toBe(1)
160+
expect(errors[0].lineNumber).toBe(4)
161+
})
162+
163+
test('Headers with emphasis are checked', async () => {
164+
const markdown = [
165+
'# This is **bold** header',
166+
'## This is *italic* header',
167+
'### This is **bold and `code`** header',
168+
'#### This is normal header',
169+
].join('\n')
170+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
171+
const errors = result.markdown
172+
expect(errors.length).toBe(1)
173+
expect(errors[0].lineNumber).toBe(3)
174+
})
175+
176+
test('List items with emphasis are checked', async () => {
177+
const markdown = [
178+
'- This is **bold** item',
179+
'- This is *italic* item',
180+
'- This is **bold and `code`** item',
181+
'1. This is numbered **bold** item',
182+
'2. This is numbered ***bold italic*** item',
183+
].join('\n')
184+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
185+
const errors = result.markdown
186+
expect(errors.length).toBe(2)
187+
expect(errors[0].lineNumber).toBe(3)
188+
expect(errors[1].lineNumber).toBe(5)
189+
})
190+
191+
test('Escaped emphasis characters are ignored', async () => {
192+
const markdown = [
193+
'This has \\*\\*escaped\\*\\* asterisks.',
194+
'This has \\`escaped\\` backticks.',
195+
'This has \\_escaped\\_ underscores.',
196+
].join('\n')
197+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
198+
const errors = result.markdown
199+
expect(errors.length).toBe(0)
200+
})
201+
202+
test('Rule has correct metadata', () => {
203+
expect(multipleEmphasisPatterns.names).toEqual(['GHD050', 'multiple-emphasis-patterns'])
204+
expect(multipleEmphasisPatterns.description).toContain('emphasis')
205+
expect(multipleEmphasisPatterns.tags).toContain('formatting')
206+
expect(multipleEmphasisPatterns.tags).toContain('emphasis')
207+
expect(multipleEmphasisPatterns.tags).toContain('style')
208+
expect(multipleEmphasisPatterns.severity).toBe('warning')
209+
})
210+
211+
test('Empty content does not cause errors', async () => {
212+
const markdown = ['', ' ', '\t'].join('\n')
213+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
214+
const errors = result.markdown
215+
expect(errors.length).toBe(0)
216+
})
217+
218+
test('Single character emphasis is handled', async () => {
219+
const markdown = [
220+
'This is **a** single letter.',
221+
'This is *b* single letter.',
222+
'This is `c` single letter.',
223+
'This is **a** and *b* separate.',
224+
'This is **`x`** nested single chars.',
225+
].join('\n')
226+
const result = await runRule(multipleEmphasisPatterns, { strings: { markdown } })
227+
const errors = result.markdown
228+
expect(errors.length).toBe(1) // Nested single chars still flagged
229+
expect(errors[0].lineNumber).toBe(5)
230+
})
231+
})

0 commit comments

Comments
 (0)