From a310b0f1686fad0716e9dfee5046ca35653c41de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Wed, 6 May 2026 23:14:12 +0200 Subject: [PATCH] fix(website/listing): resolve category links to nearest parent listing When a post is included in multiple listings (for example a homepage that lists everything plus a blog listing under `blog/`), the category badges on the post page wired to the wrong listing href. The previous prefix walk in `findNearestParentListing` stripped the leading slash from the post path before comparing against listing pathnames that retained theirs, so no listing ever matched and the function returned `undefined`. The caller then fell back to `document.referrer` or `listingHrefs[0]`, neither of which tracks the post's actual position in the directory tree. Replace the walk with a longest-directory-prefix match anchored at path boundaries: each listing is reduced to its containing directory, and the deepest directory that prefixes the post's directory wins. The fallback to referrer and first listing is retained for posts that are not under any listing's directory. Closes #14493 --- news/changelog-1.10.md | 1 + src/resources/formats/html/quarto.js | 24 +++++------ .../blog/multi-listing-blog/.gitignore | 2 + .../blog/multi-listing-blog/_quarto.yml | 13 ++++++ .../blog/multi-listing-blog/blog/index.qmd | 8 ++++ .../blog/post-one/index.qmd | 9 ++++ .../blog/post-two/index.qmd | 8 ++++ .../blog/multi-listing-blog/index.qmd | 15 +++++++ .../tests/blog-multi-listing.spec.ts | 42 +++++++++++++++++++ 9 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 tests/docs/playwright/blog/multi-listing-blog/.gitignore create mode 100644 tests/docs/playwright/blog/multi-listing-blog/_quarto.yml create mode 100644 tests/docs/playwright/blog/multi-listing-blog/blog/index.qmd create mode 100644 tests/docs/playwright/blog/multi-listing-blog/blog/post-one/index.qmd create mode 100644 tests/docs/playwright/blog/multi-listing-blog/blog/post-two/index.qmd create mode 100644 tests/docs/playwright/blog/multi-listing-blog/index.qmd create mode 100644 tests/integration/playwright/tests/blog-multi-listing.spec.ts diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index fb34c2e35bc..5d88aa130f3 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -32,6 +32,7 @@ All changes included in 1.10: ### Websites - ([#13565](https://github.com/quarto-dev/quarto-cli/issues/13565), [#14353](https://github.com/quarto-dev/quarto-cli/issues/14353)): Fix sidebar logo not appearing on secondary sidebars in multi-sidebar website layouts. +- ([#14493](https://github.com/quarto-dev/quarto-cli/issues/14493)): Fix category links on a post going to the wrong listing when the post appears in multiple listings. ## Commands diff --git a/src/resources/formats/html/quarto.js b/src/resources/formats/html/quarto.js index ee807684be1..87af95342c7 100644 --- a/src/resources/formats/html/quarto.js +++ b/src/resources/formats/html/quarto.js @@ -311,22 +311,22 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { } const findNearestParentListing = (href, listingHrefs) => { - if (!href || !listingHrefs) { + if (!href || !listingHrefs?.length) { return undefined; } - // Look up the tree for a nearby linting and use that if we find one - const relativeParts = href.substring(1).split("/"); - while (relativeParts.length > 0) { - const path = relativeParts.join("/"); - for (const listingHref of listingHrefs) { - if (listingHref.startsWith(path)) { - return listingHref; - } + // Match listings whose directory is a prefix of the post directory at a + // path-segment boundary, then keep the deepest one. + const postDir = href.replace(/[^/]+$/, ""); + let best; + let bestLen = -1; + for (const listingHref of listingHrefs) { + const listingDir = listingHref.replace(/[^/]+$/, ""); + if (postDir.startsWith(listingDir) && listingDir.length > bestLen) { + best = listingHref; + bestLen = listingDir.length; } - relativeParts.pop(); } - - return undefined; + return best; }; const manageSidebarVisiblity = (el, placeholderDescriptor) => { diff --git a/tests/docs/playwright/blog/multi-listing-blog/.gitignore b/tests/docs/playwright/blog/multi-listing-blog/.gitignore new file mode 100644 index 00000000000..47c274c17b6 --- /dev/null +++ b/tests/docs/playwright/blog/multi-listing-blog/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +/_site/ diff --git a/tests/docs/playwright/blog/multi-listing-blog/_quarto.yml b/tests/docs/playwright/blog/multi-listing-blog/_quarto.yml new file mode 100644 index 00000000000..1bbaf455dcc --- /dev/null +++ b/tests/docs/playwright/blog/multi-listing-blog/_quarto.yml @@ -0,0 +1,13 @@ +project: + type: website + +website: + title: "Multi-listing blog" + navbar: + left: + - href: index.qmd + text: Home + - href: blog/index.qmd + text: Blog + +format: html diff --git a/tests/docs/playwright/blog/multi-listing-blog/blog/index.qmd b/tests/docs/playwright/blog/multi-listing-blog/blog/index.qmd new file mode 100644 index 00000000000..01edff182ba --- /dev/null +++ b/tests/docs/playwright/blog/multi-listing-blog/blog/index.qmd @@ -0,0 +1,8 @@ +--- +title: "Blog" +listing: + contents: "*/index.qmd" + type: default + sort: "date desc" + categories: true +--- diff --git a/tests/docs/playwright/blog/multi-listing-blog/blog/post-one/index.qmd b/tests/docs/playwright/blog/multi-listing-blog/blog/post-one/index.qmd new file mode 100644 index 00000000000..1e2f336f5d9 --- /dev/null +++ b/tests/docs/playwright/blog/multi-listing-blog/blog/post-one/index.qmd @@ -0,0 +1,9 @@ +--- +title: "Post one" +date: "2026-01-02" +categories: + - alpha + - beta +--- + +Body of post one. diff --git a/tests/docs/playwright/blog/multi-listing-blog/blog/post-two/index.qmd b/tests/docs/playwright/blog/multi-listing-blog/blog/post-two/index.qmd new file mode 100644 index 00000000000..e4e164d71ea --- /dev/null +++ b/tests/docs/playwright/blog/multi-listing-blog/blog/post-two/index.qmd @@ -0,0 +1,8 @@ +--- +title: "Post two" +date: "2026-01-01" +categories: + - alpha +--- + +Body of post two. diff --git a/tests/docs/playwright/blog/multi-listing-blog/index.qmd b/tests/docs/playwright/blog/multi-listing-blog/index.qmd new file mode 100644 index 00000000000..02a7b42c199 --- /dev/null +++ b/tests/docs/playwright/blog/multi-listing-blog/index.qmd @@ -0,0 +1,15 @@ +--- +title: "Home" +listing: + id: latest + contents: blog/*/index.qmd + type: default + sort: "date desc" + filter-ui: false + sort-ui: false +--- + +Site root with a listing of latest posts. + +::: {#latest} +::: diff --git a/tests/integration/playwright/tests/blog-multi-listing.spec.ts b/tests/integration/playwright/tests/blog-multi-listing.spec.ts new file mode 100644 index 00000000000..8ea82c55355 --- /dev/null +++ b/tests/integration/playwright/tests/blog-multi-listing.spec.ts @@ -0,0 +1,42 @@ +import { expect, Page, test } from "@playwright/test"; +import { getUrl } from "../src/utils"; + +// Regression test for https://github.com/quarto-dev/quarto-cli/issues/14493: +// when a post is in multiple listings, the post's category link must target +// the nearest parent listing, not just the first entry in listings.json. + +const fixtureRoot = "blog/multi-listing-blog/_site"; +const alphaHrefPattern = /\/blog\/index\.html#category=alpha$/; + +const alphaLinkLocator = (page: Page) => + page + .locator("header.quarto-title-block .quarto-category", { hasText: "alpha" }) + .locator("a"); + +test("Category link on a post resolves to the nearest parent listing", async ({ page }) => { + await page.goto(`./${fixtureRoot}/blog/post-one/`); + + await expect(alphaLinkLocator(page)).toHaveAttribute("href", alphaHrefPattern); + + await alphaLinkLocator(page).click(); + await expect(page).toHaveURL( + getUrl(`${fixtureRoot}/blog/index.html#category=alpha`), + ); + await expect( + page.locator( + `div.category[data-category="${btoa(encodeURIComponent("alpha"))}"]`, + ), + ).toHaveClass(/active/); + await expect(page.getByRole("link", { name: "Post one", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Post two", exact: true })).toBeVisible(); +}); + +test("Category link on a post does not point at the homepage listing", async ({ page }) => { + // Reach the post from the homepage so document.referrer triggers the + // fallback branch that previously selected the wrong listing. + await page.goto(`./${fixtureRoot}/`); + await page.getByRole("link", { name: "Post one", exact: true }).click(); + await expect(page).toHaveURL(getUrl(`${fixtureRoot}/blog/post-one/`)); + + await expect(alphaLinkLocator(page)).toHaveAttribute("href", alphaHrefPattern); +});