From 98a4668bbaf68684a5f4711b927e4f723de93ba8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 27 Oct 2026 15:39:31 +0100 Subject: [PATCH 1/2] Restore delink of listing item descriptions Default and grid listings wrap the item description in the link to the item, and the delink filter turns links inside the description into plain text so the item link is not nested. The filter only matches Pandoc Divs. Since 1.7, when the listing templates moved their HTML into raw blocks to keep Markdown working in descriptions, the delink container has been a raw HTML div, so the filter never ran. A description link then nested one in another, which the HTML parser splits into empty, unnamed links (axe link-name, extra tab stops) and leaves part of the item outside its link. Write the delink container as a fenced div again, keeping only the item link as raw HTML, which matches the fenced divs the templates already use. Output matches 1.6: the description link becomes a span inside one intact item link. Tests: a smoke-all project checks that default and grid descriptions have no link, keep the link text, and stay inside the item link. The listing axe test no longer excludes empty links. The description link icon test is removed since the link no longer exists, and the custom icon pagination test moves to a table listing, whose title link is the item link that keeps its icon. --- .../website/listing/item-default.ejs.md | 8 +++-- .../projects/website/listing/item-grid.ejs.md | 8 ++--- .../listings/external-links/custom-icon.qmd | 4 +-- .../listings/description-links/.gitignore | 4 +++ .../listings/description-links/_quarto.yml | 5 +++ .../listings/description-links/default.qmd | 33 +++++++++++++++++ .../listings/description-links/grid.qmd | 33 +++++++++++++++++ .../tests/html-listing-external-links.spec.ts | 36 ++++--------------- 8 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 tests/docs/smoke-all/listings/description-links/.gitignore create mode 100644 tests/docs/smoke-all/listings/description-links/_quarto.yml create mode 100644 tests/docs/smoke-all/listings/description-links/default.qmd create mode 100644 tests/docs/smoke-all/listings/description-links/grid.qmd diff --git a/src/resources/projects/website/listing/item-default.ejs.md b/src/resources/projects/website/listing/item-default.ejs.md index d949ee50cee..c309964a887 100644 --- a/src/resources/projects/website/listing/item-default.ejs.md +++ b/src/resources/projects/website/listing/item-default.ejs.md @@ -88,16 +88,20 @@ print(`
${listing.utilities.outputLi <% if (fields.includes('description')) { %> +::: {.delink .listing-description} + ```{=html} - + ``` +::: + <% } %> ::: diff --git a/src/resources/projects/website/listing/item-grid.ejs.md b/src/resources/projects/website/listing/item-grid.ejs.md index 0069d20a242..fffc56e06e6 100644 --- a/src/resources/projects/website/listing/item-grid.ejs.md +++ b/src/resources/projects/website/listing/item-grid.ejs.md @@ -97,15 +97,11 @@ return !["title", "image", "image-alt", "date", "author", "subtitle", "descripti <% if (showField('description')) { %> -```{=html} - -``` +::: <% } %> diff --git a/tests/docs/playwright/listings/external-links/custom-icon.qmd b/tests/docs/playwright/listings/external-links/custom-icon.qmd index 1a854b62ca7..588a5661f15 100644 --- a/tests/docs/playwright/listings/external-links/custom-icon.qmd +++ b/tests/docs/playwright/listings/external-links/custom-icon.qmd @@ -10,8 +10,8 @@ listing: title: "Local Post" - path: https://example.com/external-article title: "External Article" - description: "An external article, see the [docs](https://example.org/docs)." - type: default + type: table + fields: [title] sort: "title desc" page-size: 1 --- diff --git a/tests/docs/smoke-all/listings/description-links/.gitignore b/tests/docs/smoke-all/listings/description-links/.gitignore new file mode 100644 index 00000000000..812977e5f74 --- /dev/null +++ b/tests/docs/smoke-all/listings/description-links/.gitignore @@ -0,0 +1,4 @@ +/.quarto/ +_site/ + +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/listings/description-links/_quarto.yml b/tests/docs/smoke-all/listings/description-links/_quarto.yml new file mode 100644 index 00000000000..bd7c4278398 --- /dev/null +++ b/tests/docs/smoke-all/listings/description-links/_quarto.yml @@ -0,0 +1,5 @@ +project: + type: website + +website: + title: "Listing descriptions with links" diff --git a/tests/docs/smoke-all/listings/description-links/default.qmd b/tests/docs/smoke-all/listings/description-links/default.qmd new file mode 100644 index 00000000000..7339bf79165 --- /dev/null +++ b/tests/docs/smoke-all/listings/description-links/default.qmd @@ -0,0 +1,33 @@ +--- +title: "Default listing, description with a link" +listing: + id: description-links + type: default + contents: + - path: https://example.com/article + title: "Article" + description: "Read the article, see the [docs](https://example.org/docs)." +_quarto: + render-project: true + tests: + html: + ensureHtmlElements: + - + # the description stays inside the link to the item + - ".quarto-listing-container-default .listing-description > a[href='https://example.com/article'] span" + - + # the link must be removed, not nested in the item's wrapping + - ".quarto-listing-container-default .listing-description a[href='https://example.org/docs']" + ensureFileRegexMatches: + - + # the link text is kept + - 'docs' +--- + +::: {#description-links} +::: + +The default listing wraps the item description in a link to the item. A link +inside the description would nest one `` in another, which the HTML parser +splits into empty, unnamed links. The description link must be turned into +plain text instead. diff --git a/tests/docs/smoke-all/listings/description-links/grid.qmd b/tests/docs/smoke-all/listings/description-links/grid.qmd new file mode 100644 index 00000000000..e50a23af516 --- /dev/null +++ b/tests/docs/smoke-all/listings/description-links/grid.qmd @@ -0,0 +1,33 @@ +--- +title: "Grid listing, description with a link" +listing: + id: description-links + type: grid + contents: + - path: https://example.com/article + title: "Article" + description: "Read the article, see the [docs](https://example.org/docs)." +_quarto: + render-project: true + tests: + html: + ensureHtmlElements: + - + # the description stays inside the card's link to the item + - ".quarto-listing-container-grid a.quarto-grid-link[href='https://example.com/article'] .listing-description span" + - + # the link must be removed, not nested in the item's wrapping + - ".quarto-listing-container-grid .listing-description a[href='https://example.org/docs']" + ensureFileRegexMatches: + - + # the link text is kept + - 'docs' +--- + +::: {#description-links} +::: + +The grid listing wraps the whole item card in a link to the item. A link +inside the description would nest one `` in another, which the HTML parser +splits into empty, unnamed links. The description link must be turned into +plain text instead. diff --git a/tests/integration/playwright/tests/html-listing-external-links.spec.ts b/tests/integration/playwright/tests/html-listing-external-links.spec.ts index 7460367accc..7e2b216714d 100644 --- a/tests/integration/playwright/tests/html-listing-external-links.spec.ts +++ b/tests/integration/playwright/tests/html-listing-external-links.spec.ts @@ -94,15 +94,10 @@ listingTypes.forEach( const violations = await browserPage.evaluate(async (selector) => { const axe = (window as any).axe; const container = document.querySelector(selector)!; - // A description containing a link splits the item's wrapping , - // leaving empty links that fail link-name; a separate bug. - const emptyWrappers = Array.from( - container.querySelectorAll("a"), - ).filter((a) => a.children.length === 0 && a.textContent!.trim() === ""); // heading-order judges the listing heading levels against the // page, which this test does not cover. const result = await axe.run( - { include: [container], exclude: emptyWrappers }, + { include: [container] }, { rules: { "heading-order": { enabled: false } } }, ); return result.violations.map( @@ -127,24 +122,6 @@ listingTypes.forEach( await browserPage.evaluate(afterContent(itemAnchorSelector)), ).toBe("none"); }); - - test("description link keeps its external icon", async ({ page: browserPage }) => { - await browserPage.goto(`./listings/external-links/_site/${page}`); - await browserPage.waitForSelector(containerSelector); - - const descriptionLink = browserPage - .locator(containerSelector) - .locator('a[href="https://example.org/docs"]'); - - await expect(descriptionLink).toHaveClass(/external/); - const content = await browserPage.evaluate( - afterContent( - `${containerSelector} a[href="https://example.org/docs"]`, - ), - ); - expect(content).not.toBe("none"); - expect(content).not.toBe("missing"); - }); } }); }, @@ -183,18 +160,19 @@ test.describe("External links shown after listing updates", () => { browserPage.locator("#custom-icon-listing-pagination .page.page-link", { hasText: n, }); - const descriptionLink = browserPage.locator( - '.quarto-listing-default a[href="https://example.org/docs"]', + // Table listing titles are the only item links that keep the icon. + const titleLink = browserPage.locator( + '.quarto-listing-table a.listing-title[href="https://example.com/external-article"]', ); for (const n of ["2", "1", "2", "1", "2"]) { await pageLink(n).click(); } - await expect(descriptionLink).toBeVisible(); + await expect(titleLink).toBeVisible(); // The icon is inserted as the next sibling of the link it decorates. - await expect(descriptionLink.locator("xpath=following-sibling::i[contains(@class, 'quarto-ext-icon')]")) + await expect(titleLink.locator("xpath=following-sibling::i[contains(@class, 'quarto-ext-icon')]")) .toHaveCount(1); - await expect(descriptionLink).toHaveAttribute("aria-describedby", newWindowNoteId); + await expect(titleLink).toHaveAttribute("aria-describedby", newWindowNoteId); }); }); From c23642a3fd3320da42a8a1e6c19062414fa4ec1f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 27 Oct 2026 16:00:09 +0100 Subject: [PATCH 2/2] Add changelog entry for #15211 --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 4e98d750868..dc85f38003f 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -49,6 +49,7 @@ All changes included in 1.11: - ([#15080](https://github.com/quarto-dev/quarto-cli/issues/15080)): Fix footnote, citation, cross-reference and code annotation popups not closing with Esc, and staying open over the next focused element after keyboard focus leaves them. - ([#15129](https://github.com/quarto-dev/quarto-cli/issues/15129)): Fix default listings rendering an empty, focusable metadata link for posts with no date, author or other listed field to show, flagged by axe-core (`link-name`). - ([#15159](https://github.com/quarto-dev/quarto-cli/issues/15159), [#15160](https://github.com/quarto-dev/quarto-cli/issues/15160)): Fix the `revealjs` speaker view's Layout selector having no accessible name or visible keyboard focus, and its current slide, upcoming slide, time, and notes areas not being named regions. Changing layouts no longer forwards control keys to the slides, and the slide navigation buttons no longer introduce an unrelated complementary landmark. (author: @jooyoungseo) +- ([#15211](https://github.com/quarto-dev/quarto-cli/issues/15211)): Fix default and grid listings rendering empty, focusable links when an item description contains a link, flagged by axe-core (`link-name`), a regression introduced in v1.7.7. Links in descriptions are shown as text again, so the whole description links to the item. ## Formats