Skip to content

Commit cb1e971

Browse files
Saturn226heiskr
andauthored
API: created linter ensuring octicons have an aria label (#54798)
Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
1 parent 2c70eb4 commit cb1e971

File tree

5 files changed

+225
-2
lines changed

5 files changed

+225
-2
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,4 @@
6565
| GHD041 | third-party-action-pinning | Code examples that use third-party actions must always pin to a full length commit SHA | error | feature, actions |
6666
| GHD042 | liquid-tag-whitespace | Liquid tags should start and end with one whitespace. Liquid tag arguments should be separated by only one whitespace. | error | liquid, format |
6767
| GHD043 | link-quotation | Internal link titles must not be surrounded by quotations | error | links, url |
68-
| GHD022 | liquid-ifversion-versions | Liquid `ifversion`, `elsif`, and `else` tags should be valid and not contain unsupported versions. | error | liquid, versioning |
68+
| GHD044 | octicon-aria-labels | Octicons should always have an aria-label attribute even if aria-hidden. | warning | accessibility, octicons |

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { tableLiquidVersioning } from './table-liquid-versioning.js'
3333
import { thirdPartyActionPinning } from './third-party-action-pinning.js'
3434
import { liquidTagWhitespace } from './liquid-tag-whitespace.js'
3535
import { linkQuotation } from './link-quotation.js'
36+
import { octiconAriaLabels } from './octicon-aria-labels.js'
3637
import { liquidIfversionVersions } from './liquid-ifversion-versions.js'
3738

3839
const noDefaultAltText = markdownlintGitHub.find((elem) =>
@@ -82,6 +83,6 @@ export const gitHubDocsMarkdownlint = {
8283
thirdPartyActionPinning,
8384
liquidTagWhitespace,
8485
linkQuotation,
85-
liquidIfversionVersions,
86+
octiconAriaLabels,
8687
],
8788
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { TokenKind } from 'liquidjs'
2+
import { getLiquidTokens, getPositionData } from '../helpers/liquid-utils.js'
3+
import { addFixErrorDetail } from '../helpers/utils.js'
4+
/*
5+
Octicons should always have an aria-label attribute even if aria hidden. For example:
6+
7+
DO use aria-label
8+
{% octicon "alert" aria-label="alert" %}
9+
{% octicon "alert" aria-label="alert" aria-hidden="true" %}
10+
{% octicon "alert" aria-label="alert" aria-hidden="true" class="foo" %}
11+
12+
This is necessary for copilot to be able to recognize the svgs correctly when using our API.
13+
14+
*/
15+
16+
export const octiconAriaLabels = {
17+
names: ['GHD044', 'octicon-aria-labels'],
18+
description: 'Octicons should always have an aria-label attribute even if aria-hidden.',
19+
tags: ['accessibility', 'octicons'],
20+
parser: 'markdownit',
21+
function: (params, onError) => {
22+
const content = params.lines.join('\n')
23+
const tokens = getLiquidTokens(content)
24+
.filter((token) => token.kind === TokenKind.Tag)
25+
.filter((token) => token.name === 'octicon')
26+
27+
for (const token of tokens) {
28+
const { lineNumber, column, length } = getPositionData(token, params.lines)
29+
30+
const hasAriaLabel = token.args.includes('aria-label=')
31+
32+
if (!hasAriaLabel) {
33+
const range = [column, length]
34+
35+
const octiconNameMatch = token.args.match(/["']([^"']+)["']/)
36+
const octiconName = octiconNameMatch ? octiconNameMatch[1] : 'icon'
37+
const originalContent = token.content
38+
const fixedContent = originalContent + ` aria-label="${octiconName}"`
39+
40+
addFixErrorDetail(
41+
onError,
42+
lineNumber,
43+
`octicon should have an aria-label even if aria hidden. Try using 'aria-label=${octiconName}'`,
44+
token.content,
45+
range,
46+
{
47+
lineNumber,
48+
editColumn: column,
49+
deleteCount: length,
50+
insertText: `{% ${fixedContent} %}`,
51+
},
52+
)
53+
}
54+
}
55+
},
56+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ const githubDocsConfig = {
168168
'partial-markdown-files': true,
169169
'yml-files': true,
170170
},
171+
'octicon-aria-labels': {
172+
// GHD044
173+
severity: 'warning',
174+
'partial-markdown-files': true,
175+
'yml-files': true,
176+
},
171177
}
172178

173179
export const githubDocsFrontmatterConfig = {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { octiconAriaLabels } from '../../lib/linting-rules/octicon-aria-labels.js'
3+
4+
describe('octicon-aria-labels', () => {
5+
const rule = octiconAriaLabels
6+
7+
test('detects octicon without aria-label', () => {
8+
const errors = []
9+
const onError = (errorInfo) => {
10+
errors.push(errorInfo)
11+
}
12+
13+
const content = ['This is a test with an octicon:', '{% octicon "alert" %}', 'Some more text.']
14+
15+
rule.function({ lines: content }, onError)
16+
17+
expect(errors.length).toBe(1)
18+
expect(errors[0].lineNumber).toBe(2)
19+
expect(errors[0].detail).toContain('aria-label=alert')
20+
expect(errors[0].fixInfo.insertText).toContain('aria-label="alert"')
21+
})
22+
23+
test('ignores octicons with aria-label', () => {
24+
const errors = []
25+
const onError = (errorInfo) => {
26+
errors.push(errorInfo)
27+
}
28+
29+
const content = [
30+
'This is a test with a proper octicon:',
31+
'{% octicon "alert" aria-label="alert" %}',
32+
'Some more text.',
33+
]
34+
35+
rule.function({ lines: content }, onError)
36+
37+
expect(errors.length).toBe(0)
38+
})
39+
40+
test('detects multiple octicons without aria-label', () => {
41+
const errors = []
42+
const onError = (errorInfo) => {
43+
errors.push(errorInfo)
44+
}
45+
46+
const content = [
47+
'This is a test with multiple octicons:',
48+
'{% octicon "alert" %}',
49+
'Some text in between.',
50+
'{% octicon "check" %}',
51+
'More text.',
52+
]
53+
54+
rule.function({ lines: content }, onError)
55+
56+
expect(errors.length).toBe(2)
57+
expect(errors[0].lineNumber).toBe(2)
58+
expect(errors[0].detail).toContain('aria-label=alert')
59+
expect(errors[1].lineNumber).toBe(4)
60+
expect(errors[1].detail).toContain('aria-label=check')
61+
})
62+
63+
test('ignores non-octicon liquid tags', () => {
64+
const errors = []
65+
const onError = (errorInfo) => {
66+
errors.push(errorInfo)
67+
}
68+
69+
const content = [
70+
'This is a test with non-octicon tags:',
71+
'{% data foo.bar %}',
72+
'{% ifversion fpt %}',
73+
'Some text.',
74+
'{% endif %}',
75+
]
76+
77+
rule.function({ lines: content }, onError)
78+
79+
expect(errors.length).toBe(0)
80+
})
81+
82+
test('suggests correct fix for octicon with other attributes', () => {
83+
const errors = []
84+
const onError = (errorInfo) => {
85+
errors.push(errorInfo)
86+
}
87+
88+
const content = [
89+
'This is a test with an octicon with other attributes:',
90+
'{% octicon "plus" aria-hidden="true" class="foo" %}',
91+
'Some more text.',
92+
]
93+
94+
rule.function({ lines: content }, onError)
95+
96+
expect(errors.length).toBe(1)
97+
expect(errors[0].lineNumber).toBe(2)
98+
expect(errors[0].fixInfo.insertText).toContain('aria-label="plus"')
99+
expect(errors[0].fixInfo.insertText).toContain('aria-hidden="true"')
100+
expect(errors[0].fixInfo.insertText).toContain('class="foo"')
101+
})
102+
103+
test('handles octicons with unusual spacing', () => {
104+
const errors = []
105+
const onError = (errorInfo) => {
106+
errors.push(errorInfo)
107+
}
108+
109+
const content = [
110+
'This is a test with unusual spacing:',
111+
'{% octicon "x" %}',
112+
'Some more text.',
113+
]
114+
115+
rule.function({ lines: content }, onError)
116+
117+
expect(errors.length).toBe(1)
118+
expect(errors[0].lineNumber).toBe(2)
119+
expect(errors[0].detail).toContain('aria-label=x')
120+
})
121+
122+
test('handles octicons split across multiple lines', () => {
123+
const errors = []
124+
const onError = (errorInfo) => {
125+
errors.push(errorInfo)
126+
}
127+
128+
const content = [
129+
'This is a test with a multi-line octicon:',
130+
'{% octicon "chevron-down"',
131+
' class="dropdown-menu-icon"',
132+
'%}',
133+
'Some more text.',
134+
]
135+
136+
rule.function({ lines: content }, onError)
137+
138+
expect(errors.length).toBe(1)
139+
expect(errors[0].detail).toContain('aria-label=chevron-down')
140+
})
141+
142+
test('falls back to "icon" when octicon name cannot be determined', () => {
143+
const errors = []
144+
const onError = (errorInfo) => {
145+
errors.push(errorInfo)
146+
}
147+
148+
const content = [
149+
'This is a test with a malformed octicon:',
150+
'{% octicon variable %}',
151+
'Some more text.',
152+
]
153+
154+
rule.function({ lines: content }, onError)
155+
156+
expect(errors.length).toBe(1)
157+
expect(errors[0].detail).toContain('aria-label=icon')
158+
expect(errors[0].fixInfo.insertText).toContain('aria-label="icon"')
159+
})
160+
})

0 commit comments

Comments
 (0)