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); +});