Skip to content

Commit 6246b23

Browse files
committed
feat(content-linter): warn about consecutive duplicate words
1 parent 18a2a21 commit 6246b23

5 files changed

Lines changed: 297 additions & 0 deletions

File tree

data/reusables/contributing/content-linter-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
| GHD065 | frontmatter-content-type | Content files in content-type directories must have a contentType frontmatter property that matches the parent directory. | error | frontmatter, content-type |
7070
| GHD066 | frontmatter-docs-team-metrics | Articles whose path contains a path-enforced docsTeamMetrics value must include that value in their docsTeamMetrics frontmatter property. | error | frontmatter, docs-team-metrics |
7171
| GHD067 | frontmatter-rest-api-category | Autogenerated REST API endpoint files must have a valid `category` frontmatter property | error | frontmatter, rest, category |
72+
| GHD068 | consecutive-duplicate-words | Consecutive words must not be repeated | warning | format |
7273
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon-<icon-name> | The octicon liquid syntax used is deprecated. Use this format instead `octicon "<octicon-name>" aria-label="<Octicon aria label>"` | error | |
7374
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | |
7475
| [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | |
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { addError, ellipsify, filterTokens } from 'markdownlint-rule-helpers'
2+
3+
import type { MarkdownToken, Rule, RuleErrorCallback, RuleParams } from '@/content-linter/types'
4+
5+
interface MarkdownItToken extends MarkdownToken {
6+
info?: string
7+
map?: [number, number]
8+
}
9+
10+
interface DuplicateWordMatch {
11+
firstWord: string
12+
secondWord: string
13+
start: number
14+
text: string
15+
whitespaceLength: number
16+
}
17+
18+
const DUPLICATE_WORD_PATTERN =
19+
/(?<![\p{L}\p{M}'-])([\p{L}][\p{L}\p{M}'-]*)([^\S\r\n]+)(\1)(?![\p{L}\p{M}'-])/giu
20+
21+
function isAllUppercase(word: string): boolean {
22+
const letters = word.match(/\p{L}/gu)?.join('') || ''
23+
return (
24+
letters.length > 0 && letters === letters.toUpperCase() && letters !== letters.toLowerCase()
25+
)
26+
}
27+
28+
function isPlaceholderOrOperatorPair(firstWord: string, secondWord: string): boolean {
29+
return firstWord !== secondWord && (isAllUppercase(firstWord) || isAllUppercase(secondWord))
30+
}
31+
32+
function findDuplicateWords(text: string): DuplicateWordMatch[] {
33+
const matches: DuplicateWordMatch[] = []
34+
35+
DUPLICATE_WORD_PATTERN.lastIndex = 0
36+
let match: RegExpExecArray | null
37+
while ((match = DUPLICATE_WORD_PATTERN.exec(text)) !== null) {
38+
const [, firstWord, whitespace, secondWord] = match
39+
if (isPlaceholderOrOperatorPair(firstWord, secondWord)) continue
40+
41+
matches.push({
42+
firstWord,
43+
secondWord,
44+
start: match.index,
45+
text: match[0],
46+
whitespaceLength: whitespace.length,
47+
})
48+
}
49+
50+
return matches
51+
}
52+
53+
function reportDuplicateWords(
54+
text: string,
55+
sourceLine: string,
56+
lineNumber: number,
57+
sourceOffset: number,
58+
onError: RuleErrorCallback,
59+
reportedLocations: Set<string>,
60+
): void {
61+
for (const match of findDuplicateWords(text)) {
62+
const expectedMatchOffset = sourceOffset + match.start
63+
const sourceMatchOffset =
64+
sourceLine.slice(expectedMatchOffset, expectedMatchOffset + match.text.length) === match.text
65+
? expectedMatchOffset
66+
: sourceLine.indexOf(match.text, sourceOffset)
67+
const resolvedMatchOffset = sourceMatchOffset >= 0 ? sourceMatchOffset : expectedMatchOffset
68+
const duplicateOffset = match.start + match.firstWord.length + match.whitespaceLength
69+
const duplicateColumn = resolvedMatchOffset + duplicateOffset - match.start + 1
70+
const location = `${lineNumber}:${duplicateColumn}`
71+
if (reportedLocations.has(location)) continue
72+
reportedLocations.add(location)
73+
74+
addError(
75+
onError,
76+
lineNumber,
77+
`Check whether the repeated word "${match.secondWord}" is intentional.`,
78+
ellipsify(sourceLine),
79+
[duplicateColumn, match.secondWord.length],
80+
null,
81+
)
82+
}
83+
}
84+
85+
function isSentenceLikeText(line: string): boolean {
86+
return /[.!?]["')\]}]*$/.test(line.trim())
87+
}
88+
89+
export const consecutiveDuplicateWords: Rule = {
90+
names: ['GHD068', 'consecutive-duplicate-words'],
91+
description: 'Consecutive words must not be repeated',
92+
tags: ['format'],
93+
parser: 'markdownit',
94+
function: (params: RuleParams, onError: RuleErrorCallback) => {
95+
const reportedLocations = new Set<string>()
96+
97+
filterTokens(params, 'inline', (token: MarkdownItToken) => {
98+
let currentLineNumber = token.lineNumber || 1
99+
let sourceCursor = 0
100+
101+
for (const child of token.children || []) {
102+
const childLineNumber = child.lineNumber || token.lineNumber || 1
103+
const sourceLine = child.line || token.line || params.lines[childLineNumber - 1] || ''
104+
105+
if (childLineNumber !== currentLineNumber) {
106+
currentLineNumber = childLineNumber
107+
sourceCursor = 0
108+
}
109+
110+
const childContent = child.content || ''
111+
const childOffset = childContent ? sourceLine.indexOf(childContent, sourceCursor) : -1
112+
113+
if (child.type === 'text' && childContent) {
114+
const sourceOffset = childOffset >= 0 ? childOffset : 0
115+
reportDuplicateWords(
116+
childContent,
117+
sourceLine,
118+
childLineNumber,
119+
sourceOffset,
120+
onError,
121+
reportedLocations,
122+
)
123+
}
124+
125+
if (childOffset >= 0) sourceCursor = childOffset + childContent.length
126+
}
127+
})
128+
129+
filterTokens(params, 'fence', (token: MarkdownItToken) => {
130+
const language = token.info?.trim().split(/\s+/)[0]?.toLowerCase()
131+
if (language !== 'text' || !token.map) return
132+
133+
const contentLines = (token.content || '').split('\n')
134+
const firstContentLineIndex = token.map[0] + 1
135+
136+
for (const [offset, contentLine] of contentLines.entries()) {
137+
if (!contentLine || !isSentenceLikeText(contentLine)) continue
138+
139+
const sourceLineIndex = firstContentLineIndex + offset
140+
const sourceLine = params.lines[sourceLineIndex] || contentLine
141+
const sourceOffset = Math.max(0, sourceLine.indexOf(contentLine))
142+
reportDuplicateWords(
143+
contentLine,
144+
sourceLine,
145+
sourceLineIndex + 1,
146+
sourceOffset,
147+
onError,
148+
reportedLocations,
149+
)
150+
}
151+
})
152+
},
153+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { raiAppCardStructure } from '@/content-linter/lib/linting-rules/rai-app-
5858
import { frontmatterContentType } from '@/content-linter/lib/linting-rules/frontmatter-content-type'
5959
import { frontmatterDocsTeamMetrics } from '@/content-linter/lib/linting-rules/frontmatter-docs-team-metrics'
6060
import { frontmatterRestApiCategory } from '@/content-linter/lib/linting-rules/frontmatter-rest-api-category'
61+
import { consecutiveDuplicateWords } from '@/content-linter/lib/linting-rules/consecutive-duplicate-words'
6162

6263
const noDefaultAltText = markdownlintGitHub.find((elem: { names: string[] }) =>
6364
elem.names.includes('no-default-alt-text'),
@@ -124,6 +125,7 @@ export const gitHubDocsMarkdownlint = {
124125
frontmatterContentType, // GHD065
125126
frontmatterDocsTeamMetrics, // GHD066
126127
frontmatterRestApiCategory, // GHD067
128+
consecutiveDuplicateWords, // GHD068
127129

128130
// Search-replace rules
129131
searchReplace, // Open-source plugin

src/content-linter/style/github-docs.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@ const githubDocsConfig = {
192192
severity: 'error',
193193
'partial-markdown-files': false,
194194
},
195+
'consecutive-duplicate-words': {
196+
// GHD068
197+
severity: 'warning',
198+
'partial-markdown-files': true,
199+
'yml-files': true,
200+
},
195201
}
196202

197203
export const githubDocsFrontmatterConfig = {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { runRule } from '@/content-linter/lib/init-test'
4+
import { consecutiveDuplicateWords } from '@/content-linter/lib/linting-rules/consecutive-duplicate-words'
5+
6+
describe(consecutiveDuplicateWords.names.join(' - '), () => {
7+
test('reports lowercase and case-insensitive duplicate words', async () => {
8+
const markdown = [
9+
'You will use use this process.',
10+
'For more more information, see the guide.',
11+
'This version is newer than the the published version.',
12+
'Open the Terminal Chat chat window.',
13+
'Create an Azure Blob Storage storage account.',
14+
'The café café is nearby.',
15+
].join('\n')
16+
17+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
18+
const errors = result.markdown
19+
20+
expect(errors).toHaveLength(6)
21+
expect(errors.map((error) => error.lineNumber)).toEqual([1, 2, 3, 4, 5, 6])
22+
expect(errors[0].errorDetail).toBe('Check whether the repeated word "use" is intentional.')
23+
expect(errors[0].errorRange).toEqual([14, 3])
24+
expect(errors[3].errorRange).toEqual([24, 4])
25+
expect(errors[4].errorRange).toEqual([30, 7])
26+
})
27+
28+
test('reports duplicates in common Markdown prose constructs', async () => {
29+
const markdown = [
30+
'# A repeated repeated heading',
31+
'',
32+
'* A duplicate duplicate list item.',
33+
'',
34+
'> A repeated repeated blockquote.',
35+
'',
36+
'**Terminal Chat chat** window.',
37+
'',
38+
'[More more information](https://example.com).',
39+
'',
40+
'| Value | Description |',
41+
'| --- | --- |',
42+
'| Test | A repeated repeated value. |',
43+
].join('\n')
44+
45+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
46+
const errors = result.markdown
47+
48+
expect(errors).toHaveLength(6)
49+
expect(errors.map((error) => error.lineNumber)).toEqual([1, 3, 5, 7, 9, 13])
50+
expect(errors[3].errorRange).toEqual([17, 4])
51+
expect(errors[4].errorRange).toEqual([7, 4])
52+
})
53+
54+
test('reports every duplicate pair on the same line', async () => {
55+
const markdown = 'This is is wrong, and that that is also wrong.'
56+
57+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
58+
const errors = result.markdown
59+
60+
expect(errors).toHaveLength(2)
61+
expect(errors.map((error) => error.errorRange)).toEqual([
62+
[9, 2],
63+
[28, 4],
64+
])
65+
})
66+
67+
test('reports accurate ranges after encoded text and tabs', async () => {
68+
const markdown = ['An &amp; repeated repeated phrase.', 'This is\tis wrong.'].join('\n')
69+
70+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
71+
const errors = result.markdown
72+
73+
expect(errors).toHaveLength(2)
74+
expect(errors.map((error) => error.errorRange)).toEqual([
75+
[19, 8],
76+
[9, 2],
77+
])
78+
})
79+
80+
test('reports sentence-like prose in text fences', async () => {
81+
const markdown = [
82+
'```text',
83+
'Currently, the option is used to to pass a token.',
84+
'',
85+
'view View sub-issues',
86+
'```',
87+
].join('\n')
88+
89+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
90+
const errors = result.markdown
91+
92+
expect(errors).toHaveLength(1)
93+
expect(errors[0].lineNumber).toBe(2)
94+
expect(errors[0].errorRange).toEqual([34, 2])
95+
})
96+
97+
test('ignores code, placeholders, operators, punctuation, and hyphenated words', async () => {
98+
const markdown = [
99+
'This is very, very important.',
100+
'Follow the how-to to complete the setup.',
101+
'Use logical OR or logical AND.',
102+
'Replace hostname HOSTNAME in the command.',
103+
'Click **Delete Tag TAG NAME**.',
104+
'The API API pair is intentionally tested separately.',
105+
'`use use` is an inline code example.',
106+
'',
107+
'```shell',
108+
'python -m venv venv',
109+
'```',
110+
'',
111+
'<a class="btn btn-primary">Button</a>',
112+
].join('\n')
113+
114+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
115+
const errors = result.markdown
116+
117+
expect(errors).toHaveLength(1)
118+
expect(errors[0].errorDetail).toBe('Check whether the repeated word "API" is intentional.')
119+
expect(errors[0].lineNumber).toBe(6)
120+
})
121+
122+
test('respects Markdownlint suppression comments', async () => {
123+
const markdown = [
124+
'<!-- markdownlint-disable-next-line GHD068 -->',
125+
'The words had had a deliberate meaning.',
126+
'This is is still an error.',
127+
].join('\n')
128+
129+
const result = await runRule(consecutiveDuplicateWords, { strings: { markdown } })
130+
const errors = result.markdown
131+
132+
expect(errors).toHaveLength(1)
133+
expect(errors[0].lineNumber).toBe(3)
134+
})
135+
})

0 commit comments

Comments
 (0)