diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md
index 7e945c91fba..e10a2250d0a 100644
--- a/news/changelog-1.10.md
+++ b/news/changelog-1.10.md
@@ -17,6 +17,7 @@ All changes included in 1.10:
- ([#14468](https://github.com/quarto-dev/quarto-cli/issues/14468)): The `axe` accessibility report UI (HTML overlay, revealjs report slide, dashboard offcanvas) now uses its own theme-independent colors instead of inheriting from `brand` or theme. Keeps the report readable regardless of page styling, and stops `axe` from clobbering brand colors set via `_brand.yml`.
- ([#14602](https://github.com/quarto-dev/quarto-cli/pull/14602), [#14632](https://github.com/quarto-dev/quarto-cli/pull/14632)): Fix ORCID profile link having no accessible name for screen readers in HTML, Reveal.js, and ipynb title-block author metadata. (author: @mcanouil for #14602)
- ([#14604](https://github.com/quarto-dev/quarto-cli/issues/14604)): The `axe` accessibility report UI now shows each violation's WCAG conformance level (e.g. `WCAG 2.0 AA (1.4.3)`) or `Best Practice`, derived from the violation's axe-core tags.
+- ([#14655](https://github.com/quarto-dev/quarto-cli/issues/14655)): Add accessible names to code line-number links so screen readers and accessibility audits no longer report them as empty links.
## Formats
diff --git a/src/format/html/format-html.ts b/src/format/html/format-html.ts
index 942b74b66cf..cc70784bf12 100644
--- a/src/format/html/format-html.ts
+++ b/src/format/html/format-html.ts
@@ -732,6 +732,40 @@ function htmlFormatPostprocessor(
for (let i = 0; i < codeBlocks.length; i++) {
const code = codeBlocks[i] as Element;
+ // Give each line-number anchor an accessible name (#14655). Skylighting
+ // emits an empty as the first child of every line
+ // span and draws the visible number purely via a CSS counter on
+ // ::before, so axe-core reports these focusable links as having no
+ // discernible text (WCAG 2.4.4/4.1.2). Set aria-label to the visible
+ // line number. aria-hidden is not an option here: these anchors are
+ // focusable fragment links, and aria-hidden on a focusable element is
+ // itself an accessibility violation.
+ if (code.classList.contains("numberSource")) {
+ const codeEl = code.querySelector("code");
+ // Custom start numbers surface as `counter-reset: source-line N` on
+ // the ; the first visible line is then N + 1. Default is 0.
+ let startZero = 0;
+ // Signed digits: startFrom="0" emits `counter-reset: source-line -1`,
+ // and negative starts are possible — a bare \d+ would drop the sign and
+ // mislabel. skylighting emits exactly one well-formed declaration, so
+ // multiple/malformed counter-reset is not a real input.
+ const counterMatch = codeEl?.getAttribute("style")?.match(
+ /counter-reset:\s*source-line\s+(-?\d+)/,
+ );
+ if (counterMatch) {
+ startZero = parseInt(counterMatch[1], 10);
+ }
+ const lineAnchors = code.querySelectorAll(
+ "code > span > a:first-child",
+ );
+ for (let j = 0; j < lineAnchors.length; j++) {
+ const anchor = lineAnchors[j] as Element;
+ if (!anchor.hasAttribute("aria-label")) {
+ anchor.setAttribute("aria-label", String(startZero + j + 1));
+ }
+ }
+ }
+
// hoist hidden and cell-code to parent div
const parentHoist = (clz: string) => {
if (code.classList.contains(clz)) {
diff --git a/tests/docs/playwright/html/axe-code-line-numbers.qmd b/tests/docs/playwright/html/axe-code-line-numbers.qmd
new file mode 100644
index 00000000000..22deda8aaec
--- /dev/null
+++ b/tests/docs/playwright/html/axe-code-line-numbers.qmd
@@ -0,0 +1,13 @@
+---
+format:
+ html:
+ axe:
+ output: document
+---
+
+# Numbered code
+
+```{.r .numberLines}
+x <- 1
+y <- 2
+```
diff --git a/tests/docs/playwright/revealjs/axe-code-line-numbers.qmd b/tests/docs/playwright/revealjs/axe-code-line-numbers.qmd
new file mode 100644
index 00000000000..533c0a391a4
--- /dev/null
+++ b/tests/docs/playwright/revealjs/axe-code-line-numbers.qmd
@@ -0,0 +1,13 @@
+---
+format:
+ revealjs:
+ axe:
+ output: document
+---
+
+## Numbered code
+
+```{.r .numberLines}
+x <- 1
+y <- 2
+```
diff --git a/tests/docs/smoke-all/accessibility/code-line-numbers-aria-label.qmd b/tests/docs/smoke-all/accessibility/code-line-numbers-aria-label.qmd
new file mode 100644
index 00000000000..6ce22f77400
--- /dev/null
+++ b/tests/docs/smoke-all/accessibility/code-line-numbers-aria-label.qmd
@@ -0,0 +1,77 @@
+---
+title: "code-line-numbers anchors have accessible names (#14655)"
+format:
+ html: default
+ revealjs: default
+_quarto:
+ tests:
+ html:
+ ensureHtmlElements:
+ -
+ - 'pre.numberSource a[aria-label="1"]'
+ - 'pre.numberSource a[aria-label="2"]'
+ - 'pre.sourceCode:not(.numberSource) a[aria-hidden="true"]'
+ - '#startfrom a[aria-label="100"]'
+ - '#startfrom a[aria-label="101"]'
+ - '#badstart a[aria-label="1"]'
+ - '#badstart a[aria-label="2"]'
+ -
+ - 'pre.numberSource a[href]:not([aria-label])'
+ - 'pre.numberSource a[href][aria-label=""]'
+ - 'pre.sourceCode:not(.numberSource) a[aria-label]'
+ revealjs:
+ ensureHtmlElements:
+ -
+ - 'pre.numberSource a[aria-label="1"]'
+ - 'pre.numberSource a[aria-label="2"]'
+ -
+ - 'pre.numberSource a[href]:not([aria-label])'
+---
+
+## Canonical numbered (row 2)
+
+```{.r .numberLines}
+x <- 1
+y <- 2
+```
+
+## Hyphen-synonym numbered (row 3)
+
+```{.python .number-lines}
+a = 1
+b = 2
+```
+
+## No-language numbered (row 8)
+
+```{.numberLines}
+plain line one
+plain line two
+```
+
+## lineAnchors only — must stay non-numbered (row 10)
+
+```{.r .lineAnchors}
+z <- 3
+```
+
+## Second numbered block, no id (row 11)
+
+```{.python .numberLines}
+p = 1
+q = 2
+```
+
+## Custom start (row 5)
+
+```{#startfrom .python .numberLines startFrom="100"}
+a = 1
+b = 2
+```
+
+## Invalid start falls back to 1 (row 6)
+
+```{#badstart .python .numberLines startFrom="notanumber"}
+c = 3
+d = 4
+```
diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts
index 14619746faa..4b83c3165f0 100644
--- a/tests/integration/playwright/tests/axe-accessibility.spec.ts
+++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts
@@ -486,3 +486,31 @@ test.describe('Dashboard axe — re-scan on visibility change', () => {
'Tab A restored: #tab-a-contrast should be detected again').toBe(true);
});
});
+
+test.describe('Axe — code line-number anchors have accessible names (#14655)', () => {
+ test('html — numbered code block has no axe violations', async ({ page }) => {
+ await page.goto('/html/axe-code-line-numbers.html', { waitUntil: 'networkidle' });
+ await waitForAxeCompletion(page);
+
+ const axeReport = page.locator('.quarto-axe-report');
+ await expect(axeReport).toBeVisible({ timeout: 10000 });
+ await expect(axeReport.locator('.quarto-axe-no-violations'))
+ .toHaveText('No axe-core violations found.');
+ });
+
+ test('revealjs — code line-number anchors are not reported for missing link text', async ({ page }) => {
+ await page.goto('/revealjs/axe-code-line-numbers.html', { waitUntil: 'networkidle' });
+ await waitForAxeCompletion(page);
+
+ const reportSlide = page.locator('section.quarto-axe-report-slide');
+ await expect(reportSlide).toBeAttached({ timeout: 10000 });
+
+ // RevealJS chrome (.slide-menu-button) and its default viewport meta trip
+ // their own pre-existing, unrelated violations — out of scope for #14655.
+ // The regression guard here is narrower: no violation should target a
+ // code line-number anchor (id `cbN-n`).
+ const targets = await reportSlide.locator('.quarto-axe-violation-target').allTextContents();
+ const codeLineTargets = targets.filter((t) => /#cb\d+-\d+/.test(t));
+ expect(codeLineTargets, `Unexpected code-line-number targets: ${targets.join(', ')}`).toEqual([]);
+ });
+});