1
0
mirror of synced 2025-12-19 09:57:42 -05:00

Auto-generate aria-labels for octicons and remove linter rule (#58349)

This commit is contained in:
Kevin Heis
2025-11-05 11:37:59 -08:00
committed by GitHub
parent f677421627
commit 752ce26cf1
9 changed files with 126 additions and 223 deletions

View File

@@ -52,7 +52,6 @@
| GHD041 | third-party-action-pinning | Code examples that use third-party actions must always pin to a full length commit SHA | error | feature, actions |
| 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 |
| GHD043 | link-quotation | Internal link titles must not be surrounded by quotations | error | links, url |
| GHD044 | octicon-aria-labels | Octicons should always have an aria-label attribute even if aria-hidden. | warning | accessibility, octicons |
| GHD045 | code-annotation-comment-spacing | Code comments in annotation blocks must have exactly one space after the comment character(s) | warning | code, comments, annotate, spacing |
| GHD046 | outdated-release-phase-terminology | Outdated release phase terminology should be replaced with current GitHub terminology | warning | terminology, consistency, release-phases |
| GHD047 | table-column-integrity | Tables must have consistent column counts across all rows | warning | tables, accessibility, formatting |

View File

@@ -0,0 +1,69 @@
import { beforeAll, describe, expect, test } from 'vitest'
import { get } from '@/tests/helpers/e2etest'
const makeURL = (pathname: string): string =>
`/api/article/body?${new URLSearchParams({ pathname })}`
describe('article body api', () => {
beforeAll(() => {
// If you didn't set the `ROOT` variable, the tests will fail rather
// cryptically. So as a warning for engineers running these tests,
// alert in case it was accidentally forgotten.
if (!process.env.ROOT) {
console.warn(
'WARNING: The article body tests require the ROOT environment variable to be set to the fixture root',
)
}
})
test('happy path', async () => {
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'))
expect(res.statusCode).toBe(200)
expect(res.body).toContain('## Introduction')
expect(res.body).toContain('This is just a test.')
expect(res.headers['content-type']).toContain('text/markdown')
})
test('octicons auto-generate aria-labels', async () => {
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'))
expect(res.statusCode).toBe(200)
// Check that octicons without aria-label get auto-generated ones
expect(res.body).toContain('aria-label="check icon"')
expect(res.body).toContain('aria-label="git branch icon"')
})
test('octicons with custom aria-labels use the custom value', async () => {
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'))
expect(res.statusCode).toBe(200)
// Check that custom aria-labels are preserved
expect(res.body).toContain('aria-label="Supported"')
expect(res.body).toContain('aria-label="Not supported"')
})
test('octicons with other attributes still get auto-generated aria-labels', async () => {
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'))
expect(res.statusCode).toBe(200)
// Check that octicons with width attribute still get aria-labels
expect(res.body).toContain('aria-label="rocket icon"')
expect(res.body).toContain('width="32"')
})
test('a pathname that does not exist', async () => {
const res = await get(makeURL('/en/never/heard/of'))
expect(res.statusCode).toBe(404)
const { error } = JSON.parse(res.body)
expect(error).toBe("No page found for '/en/never/heard/of'")
})
test('non-article pages return error', async () => {
// Index pages are not articles and should not be renderable
const res = await get(makeURL('/en/get-started'))
expect(res.statusCode).toBe(403)
const { error } = JSON.parse(res.body)
expect(error).toContain("isn't yet available in markdown")
})
})

View File

@@ -42,7 +42,6 @@ import { tableColumnIntegrity } from '@/content-linter/lib/linting-rules/table-c
import { thirdPartyActionPinning } from '@/content-linter/lib/linting-rules/third-party-action-pinning'
import { liquidTagWhitespace } from '@/content-linter/lib/linting-rules/liquid-tag-whitespace'
import { linkQuotation } from '@/content-linter/lib/linting-rules/link-quotation'
import { octiconAriaLabels } from '@/content-linter/lib/linting-rules/octicon-aria-labels'
import { liquidIfversionVersions } from '@/content-linter/lib/linting-rules/liquid-ifversion-versions'
import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-rules/outdated-release-phase-terminology'
import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace'
@@ -105,7 +104,7 @@ export const gitHubDocsMarkdownlint = {
thirdPartyActionPinning, // GHD041
liquidTagWhitespace, // GHD042
linkQuotation, // GHD043
octiconAriaLabels, // GHD044
// GHD044 removed - octicon aria-labels are now auto-generated
codeAnnotationCommentSpacing, // GHD045
outdatedReleasePhaseTerminology, // GHD046
tableColumnIntegrity, // GHD047

View File

@@ -1,58 +0,0 @@
import { TokenKind } from 'liquidjs'
import { getLiquidTokens, getPositionData } from '../helpers/liquid-utils'
import { addFixErrorDetail } from '../helpers/utils'
import type { RuleParams, RuleErrorCallback, Rule } from '../../types'
/*
Octicons should always have an aria-label attribute even if aria hidden. For example:
DO use aria-label
{% octicon "alert" aria-label="alert" %}
{% octicon "alert" aria-label="alert" aria-hidden="true" %}
{% octicon "alert" aria-label="alert" aria-hidden="true" class="foo" %}
This is necessary for copilot to be able to recognize the svgs correctly when using our API.
*/
export const octiconAriaLabels: Rule = {
names: ['GHD044', 'octicon-aria-labels'],
description: 'Octicons should always have an aria-label attribute even if aria-hidden.',
tags: ['accessibility', 'octicons'],
parser: 'markdownit',
function: (params: RuleParams, onError: RuleErrorCallback) => {
const content = params.lines.join('\n')
// Using 'any' type for tokens as getLiquidTokens returns tokens from liquid-utils.ts which lacks type definitions
const tokens = getLiquidTokens(content)
.filter((token: any) => token.kind === TokenKind.Tag)
.filter((token: any) => token.name === 'octicon')
for (const token of tokens) {
const { lineNumber, column, length } = getPositionData(token, params.lines)
const hasAriaLabel = token.args.includes('aria-label=')
if (!hasAriaLabel) {
const range = [column, length]
const octiconNameMatch = token.args.match(/["']([^"']+)["']/)
const octiconName = octiconNameMatch ? octiconNameMatch[1] : 'icon'
const originalContent = token.content
const fixedContent = `${originalContent} aria-label="${octiconName}"`
addFixErrorDetail(
onError,
lineNumber,
`octicon should have an aria-label even if aria hidden. Try using 'aria-label=${octiconName}'`,
token.content,
range,
{
lineNumber,
editColumn: column,
deleteCount: length,
insertText: `{% ${fixedContent} %}`,
},
)
}
}
},
}

View File

@@ -162,12 +162,7 @@ const githubDocsConfig = {
'partial-markdown-files': true,
'yml-files': true,
},
'octicon-aria-labels': {
// GHD044
severity: 'warning',
'partial-markdown-files': true,
'yml-files': true,
},
// GHD044 removed - octicon aria-labels are now auto-generated
'code-annotation-comment-spacing': {
// GHD045
severity: 'warning',

View File

@@ -1,155 +0,0 @@
import { describe, expect, test } from 'vitest'
import { octiconAriaLabels } from '../../lib/linting-rules/octicon-aria-labels'
interface ErrorInfo {
lineNumber: number
detail?: string
context?: string
range?: [number, number]
fixInfo?: any // Matches RuleErrorCallback signature - fixInfo structure varies by rule
}
describe('octicon-aria-labels', () => {
const rule = octiconAriaLabels
// Helper to create onError callback that captures errors
function createErrorCollector() {
const errors: ErrorInfo[] = []
// Using any because the actual rule implementation calls onError with an object,
// not individual parameters as defined in RuleErrorCallback
const onError = (errorInfo: any) => {
errors.push(errorInfo)
}
return { errors, onError }
}
test('detects octicon without aria-label', () => {
const { errors, onError } = createErrorCollector()
const content = ['This is a test with an octicon:', '{% octicon "alert" %}', 'Some more text.']
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(2)
expect(errors[0].detail).toContain('aria-label=alert')
expect(errors[0].fixInfo.insertText).toContain('aria-label="alert"')
})
test('ignores octicons with aria-label', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with a proper octicon:',
'{% octicon "alert" aria-label="alert" %}',
'Some more text.',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(0)
})
test('detects multiple octicons without aria-label', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with multiple octicons:',
'{% octicon "alert" %}',
'Some text in between.',
'{% octicon "check" %}',
'More text.',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(2)
expect(errors[0].lineNumber).toBe(2)
expect(errors[0].detail).toContain('aria-label=alert')
expect(errors[1].lineNumber).toBe(4)
expect(errors[1].detail).toContain('aria-label=check')
})
test('ignores non-octicon liquid tags', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with non-octicon tags:',
'{% data foo.bar %}',
'{% ifversion fpt %}',
'Some text.',
'{% endif %}',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(0)
})
test('suggests correct fix for octicon with other attributes', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with an octicon with other attributes:',
'{% octicon "plus" aria-hidden="true" class="foo" %}',
'Some more text.',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(2)
expect(errors[0].fixInfo.insertText).toContain('aria-label="plus"')
expect(errors[0].fixInfo.insertText).toContain('aria-hidden="true"')
expect(errors[0].fixInfo.insertText).toContain('class="foo"')
})
test('handles octicons with unusual spacing', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with unusual spacing:',
'{% octicon "x" %}',
'Some more text.',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(1)
expect(errors[0].lineNumber).toBe(2)
expect(errors[0].detail).toContain('aria-label=x')
})
test('handles octicons split across multiple lines', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with a multi-line octicon:',
'{% octicon "chevron-down"',
' class="dropdown-menu-icon"',
'%}',
'Some more text.',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(1)
expect(errors[0].detail).toContain('aria-label=chevron-down')
})
test('falls back to "icon" when octicon name cannot be determined', () => {
const { errors, onError } = createErrorCollector()
const content = [
'This is a test with a malformed octicon:',
'{% octicon variable %}',
'Some more text.',
]
rule.function({ name: 'test.md', lines: content, frontMatterLines: [] }, onError)
expect(errors.length).toBe(1)
expect(errors[0].detail).toContain('aria-label=icon')
expect(errors[0].fixInfo.insertText).toContain('aria-label="icon"')
})
})

View File

@@ -24,7 +24,10 @@ const SyntaxHelp = 'Syntax Error in tag \'octicon\' - Valid syntax: octicon "<na
* Uses the octicons library to render the chosen icon. Also
* supports passing attributes like `width="64"`.
*
* {% octicon "check" %}
* If no aria-label is provided, a default one will be auto-generated
* based on the icon name (e.g., "check icon", "git-branch icon").
*
* {% octicon "check" %} <!-- auto-generates aria-label="check icon" -->
* {% octicon "check" width="64" aria-label="Example label" %}
*/
const Octicon: LiquidTag = {
@@ -70,6 +73,13 @@ const Octicon: LiquidTag = {
throw new Error(`Octicon ${this.icon} does not exist`)
}
// Auto-generate aria-label if not provided
// Replace non-alphanumeric characters with spaces and append " icon"
if (!this.options['aria-label']) {
const defaultLabel = `${this.icon.toLowerCase().replace(/[^a-z0-9]+/gi, ' ')} icon`
this.options['aria-label'] = defaultLabel
}
const result: string = octicons[this.icon].toSVG(this.options)
return result
},

View File

@@ -42,4 +42,34 @@ describe('octicon tag', () => {
'Octicon pizza-patrol does not exist',
)
})
test('auto-generates aria-label when not provided', async () => {
const actual = await renderContent('{% octicon "check" %}')
expect(actual).toContain('<svg ')
expect(actual).toContain('class="octicon octicon-check"')
expect(actual).toContain('aria-label="check icon"')
})
test('auto-generates aria-label with spaces for hyphenated icon names', async () => {
const actual = await renderContent('{% octicon "git-branch" %}')
expect(actual).toContain('<svg ')
expect(actual).toContain('class="octicon octicon-git-branch"')
expect(actual).toContain('aria-label="git branch icon"')
})
test('uses custom aria-label instead of auto-generated one when provided', async () => {
const actual = await renderContent('{% octicon "check" aria-label="Supported" %}')
expect(actual).toContain('<svg ')
expect(actual).toContain('class="octicon octicon-check"')
expect(actual).toContain('aria-label="Supported"')
expect(actual).not.toContain('aria-label="check icon"')
})
test('auto-generates aria-label even when other attributes are provided', async () => {
const actual = await renderContent('{% octicon "filter" width="32" %}')
expect(actual).toContain('<svg ')
expect(actual).toContain('class="octicon octicon-filter"')
expect(actual).toContain('aria-label="filter icon"')
expect(actual).toContain('width="32"')
})
})

View File

@@ -33,3 +33,17 @@ like "Enterprise Server X.Y". It should change the above sentence.
## Use of a reusable that might have auto-title links
{% data reusables.gated-features.more-info %}
## Octicons for testing
This section tests octicon rendering with auto-generated aria-labels.
Here's a check icon without aria-label: {% octicon "check" %}
Here's a git-branch icon without aria-label: {% octicon "git-branch" %}
Here's a check icon with custom aria-label: {% octicon "check" aria-label="Supported" %}
Here's an x icon with custom aria-label: {% octicon "x" aria-label="Not supported" %}
Here's a rocket icon with width attribute: {% octicon "rocket" width="32" %}