Skip to content

Commit 029e6eb

Browse files
authored
Add British English quotes detection content linter rule (#56115)
1 parent ff17f9f commit 029e6eb

File tree

3 files changed

+316
-0
lines changed

3 files changed

+316
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 britishEnglishQuotes = {
6+
names: ['GHD048', 'british-english-quotes'],
7+
description:
8+
'Periods and commas should be placed inside quotation marks (American English style)',
9+
tags: ['punctuation', 'quotes', 'style', 'consistency'],
10+
severity: 'warning', // Non-blocking as requested in the issue
11+
function: (params, onError) => {
12+
// Skip autogenerated files
13+
const frontmatterString = params.frontMatterLines.join('\n')
14+
const fm = frontmatter(frontmatterString).data
15+
if (fm && fm.autogenerated) return
16+
17+
// Check each line for British English quote patterns
18+
for (let i = 0; i < params.lines.length; i++) {
19+
const line = params.lines[i]
20+
const lineNumber = i + 1
21+
22+
// Skip code blocks, code spans, and URLs
23+
if (isInCodeContext(line, params.lines, i)) {
24+
continue
25+
}
26+
27+
// Find British English quote patterns and report them
28+
findAndReportBritishQuotes(line, lineNumber, onError)
29+
}
30+
},
31+
}
32+
33+
/**
34+
* Check if the current position is within a code context (code blocks, inline code, URLs)
35+
*/
36+
function isInCodeContext(line, allLines, lineIndex) {
37+
// Skip if line contains code fences
38+
if (line.includes('```') || line.includes('~~~')) {
39+
return true
40+
}
41+
42+
// Check if we're inside a code block
43+
let inCodeBlock = false
44+
for (let i = 0; i < lineIndex; i++) {
45+
if (allLines[i].includes('```') || allLines[i].includes('~~~')) {
46+
inCodeBlock = !inCodeBlock
47+
}
48+
}
49+
if (inCodeBlock) {
50+
return true
51+
}
52+
53+
// Skip if line appears to be mostly code (has multiple backticks)
54+
const backtickCount = (line.match(/`/g) || []).length
55+
if (backtickCount >= 4) {
56+
return true
57+
}
58+
59+
// Skip URLs and email addresses
60+
if (line.includes('http://') || line.includes('https://') || line.includes('mailto:')) {
61+
return true
62+
}
63+
64+
return false
65+
}
66+
67+
/**
68+
* Find and report British English quote patterns in a line
69+
*/
70+
function findAndReportBritishQuotes(line, lineNumber, onError) {
71+
// Pattern to find quote followed by punctuation outside
72+
// Matches: "text". or 'text', or "text", etc.
73+
const britishPattern = /(["'])([^"']*?)\1\s*([.,])/g
74+
75+
let match
76+
while ((match = britishPattern.exec(line)) !== null) {
77+
const quoteChar = match[1]
78+
const quotedText = match[2]
79+
const punctuation = match[3]
80+
const fullMatch = match[0]
81+
const startIndex = match.index
82+
83+
// Create the corrected version (punctuation inside quotes)
84+
const correctedText = quoteChar + quotedText + punctuation + quoteChar
85+
86+
const range = getRange(line, fullMatch)
87+
const punctuationName = punctuation === '.' ? 'period' : 'comma'
88+
const errorMessage = `Use American English punctuation: place ${punctuationName} inside the quotation marks`
89+
90+
// Provide auto-fix
91+
const fixInfo = {
92+
editColumn: startIndex + 1,
93+
deleteCount: fullMatch.length,
94+
insertText: correctedText,
95+
}
96+
97+
addError(onError, lineNumber, errorMessage, line, range, fixInfo)
98+
}
99+
}

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 { britishEnglishQuotes } from './british-english-quotes.js'
3839
import { multipleEmphasisPatterns } from './multiple-emphasis-patterns.js'
3940
import { noteWarningFormatting } from './note-warning-formatting.js'
4041

@@ -86,6 +87,7 @@ export const gitHubDocsMarkdownlint = {
8687
liquidTagWhitespace,
8788
linkQuotation,
8889
octiconAriaLabels,
90+
britishEnglishQuotes,
8991
multipleEmphasisPatterns,
9092
noteWarningFormatting,
9193
],
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { runRule } from '../../lib/init-test.js'
4+
import { britishEnglishQuotes } from '../../lib/linting-rules/british-english-quotes.js'
5+
6+
describe(britishEnglishQuotes.names.join(' - '), () => {
7+
test('Correct American English punctuation passes', async () => {
8+
const markdown = [
9+
'She said, "Hello, world."',
10+
'The guide mentions "Getting started."',
11+
'See "[AUTOTITLE]."',
12+
'Zara replied, "That sounds great!"',
13+
'The section titled "Prerequisites," explains the setup.',
14+
].join('\n')
15+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
16+
const errors = result.markdown
17+
expect(errors.length).toBe(0)
18+
})
19+
20+
test('British English quotes with AUTOTITLE are flagged', async () => {
21+
const markdown = [
22+
'For more information, see "[AUTOTITLE]".',
23+
'The article "[AUTOTITLE]", covers this topic.',
24+
].join('\n')
25+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
26+
const errors = result.markdown
27+
expect(errors.length).toBe(2)
28+
expect(errors[0].lineNumber).toBe(1)
29+
if (errors[0].detail) {
30+
expect(errors[0].detail).toContain('place period inside the quotation marks')
31+
}
32+
expect(errors[1].lineNumber).toBe(2)
33+
if (errors[1].detail) {
34+
expect(errors[1].detail).toContain('place comma inside the quotation marks')
35+
}
36+
})
37+
38+
test('General British English punctuation patterns are detected', async () => {
39+
const markdown = [
40+
'Priya said "Hello".',
41+
'The tutorial called "Advanced Git", is helpful.',
42+
'Marcus mentioned "DevOps best practices".',
43+
'See the guide titled "Getting Started", for details.',
44+
].join('\n')
45+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
46+
const errors = result.markdown
47+
expect(errors.length).toBe(4)
48+
if (errors[0].detail) {
49+
expect(errors[0].detail).toContain('period inside')
50+
}
51+
if (errors[1].detail) {
52+
expect(errors[1].detail).toContain('comma inside')
53+
}
54+
if (errors[2].detail) {
55+
expect(errors[2].detail).toContain('period inside')
56+
}
57+
if (errors[3].detail) {
58+
expect(errors[3].detail).toContain('comma inside')
59+
}
60+
})
61+
62+
test('Single quotes are also detected', async () => {
63+
const markdown = [
64+
"Aisha said 'excellent work'.",
65+
"The term 'API endpoint', refers to a specific URL.",
66+
].join('\n')
67+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
68+
const errors = result.markdown
69+
expect(errors.length).toBe(2)
70+
if (errors[0].detail) {
71+
expect(errors[0].detail).toContain('period inside')
72+
}
73+
if (errors[1].detail) {
74+
expect(errors[1].detail).toContain('comma inside')
75+
}
76+
})
77+
78+
test('Code blocks and inline code are ignored', async () => {
79+
const markdown = [
80+
'```javascript',
81+
'console.log("Hello");',
82+
'const message = "World";',
83+
'```',
84+
'',
85+
'In code, use `console.log("Debug");` for logging.',
86+
'The command `git commit -m "Fix bug";` creates a commit.',
87+
].join('\n')
88+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
89+
const errors = result.markdown
90+
expect(errors.length).toBe(0)
91+
})
92+
93+
test('URLs and emails are ignored', async () => {
94+
const markdown = [
95+
'Visit https://example.com/api"docs" for more info.',
96+
'Email [email protected]"help" for assistance.',
97+
'The webhook URL http://api.service.com"endpoint" should work.',
98+
].join('\n')
99+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
100+
const errors = result.markdown
101+
expect(errors.length).toBe(0)
102+
})
103+
104+
test('Auto-fix suggestions work correctly', async () => {
105+
const markdown = [
106+
'See "[AUTOTITLE]".',
107+
'The guide "Setup Instructions", explains everything.',
108+
].join('\n')
109+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
110+
const errors = result.markdown
111+
expect(errors.length).toBe(2)
112+
113+
// Check that fix info is provided
114+
expect(errors[0].fixInfo).toBeDefined()
115+
expect(errors[0].fixInfo.insertText).toContain('."')
116+
expect(errors[1].fixInfo).toBeDefined()
117+
expect(errors[1].fixInfo.insertText).toContain(',"')
118+
})
119+
120+
test('Mixed punctuation scenarios', async () => {
121+
const markdown = [
122+
'Chen explained, "The process involves three steps". First, prepare the data.',
123+
'The error message "File not found", appears when the path is incorrect.',
124+
'As Fatima noted, "Testing is crucial"; quality depends on it.',
125+
].join('\n')
126+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
127+
const errors = result.markdown
128+
expect(errors.length).toBe(2)
129+
expect(errors[0].lineNumber).toBe(1)
130+
expect(errors[1].lineNumber).toBe(2)
131+
})
132+
133+
test('Nested quotes are handled appropriately', async () => {
134+
const markdown = [
135+
'She said, "The article \'Best Practices\', is recommended".',
136+
'The message "Error: \'Invalid input\'" appears sometimes.',
137+
].join('\n')
138+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
139+
const errors = result.markdown
140+
expect(errors.length).toBe(1)
141+
if (errors[0].detail) {
142+
expect(errors[0].detail).toContain('period inside')
143+
}
144+
})
145+
146+
test('Edge cases with spacing', async () => {
147+
const markdown = [
148+
'The command "npm install" .',
149+
'See documentation "API Guide" , which covers authentication.',
150+
'Reference "[AUTOTITLE]" .',
151+
].join('\n')
152+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
153+
const errors = result.markdown
154+
expect(errors.length).toBe(3)
155+
if (errors[0].detail) {
156+
expect(errors[0].detail).toContain('period inside')
157+
}
158+
if (errors[1].detail) {
159+
expect(errors[1].detail).toContain('comma inside')
160+
}
161+
if (errors[2].detail) {
162+
expect(errors[2].detail).toContain('period inside')
163+
}
164+
})
165+
166+
test('Autogenerated files are skipped', async () => {
167+
const frontmatter = ['---', 'title: API Reference', 'autogenerated: rest', '---'].join('\n')
168+
const markdown = ['The endpoint "GET /users", returns user data.', 'See "[AUTOTITLE]".'].join(
169+
'\n',
170+
)
171+
const result = await runRule(britishEnglishQuotes, {
172+
strings: {
173+
markdown: frontmatter + '\n' + markdown,
174+
},
175+
})
176+
const errors = result.markdown
177+
expect(errors.length).toBe(0)
178+
})
179+
180+
test('Complex real-world examples', async () => {
181+
const markdown = [
182+
'## Configuration Options',
183+
'',
184+
'To enable the feature, set `enabled: true` in "config.yml".',
185+
'Aaliyah mentioned that the tutorial "Docker Basics", covers containers.',
186+
'The error "Permission denied", occurs when access is restricted.',
187+
'For troubleshooting, see "[AUTOTITLE]".',
188+
'',
189+
'```yaml',
190+
'name: "production"',
191+
'debug: false',
192+
'```',
193+
'',
194+
'Dmitri explained, "The workflow has multiple stages."',
195+
].join('\n')
196+
const result = await runRule(britishEnglishQuotes, { strings: { markdown } })
197+
const errors = result.markdown
198+
expect(errors.length).toBe(4)
199+
expect(errors[0].lineNumber).toBe(3) // config.yml line
200+
expect(errors[1].lineNumber).toBe(4) // Docker Basics line
201+
expect(errors[2].lineNumber).toBe(5) // Permission denied line
202+
expect(errors[3].lineNumber).toBe(6) // AUTOTITLE line
203+
})
204+
205+
test('Warning severity is set correctly', () => {
206+
expect(britishEnglishQuotes.severity).toBe('warning')
207+
})
208+
209+
test('Rule has correct metadata', () => {
210+
expect(britishEnglishQuotes.names).toEqual(['GHD048', 'british-english-quotes'])
211+
expect(britishEnglishQuotes.description).toContain('American English style')
212+
expect(britishEnglishQuotes.tags).toContain('punctuation')
213+
expect(britishEnglishQuotes.tags).toContain('quotes')
214+
})
215+
})

0 commit comments

Comments
 (0)