Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ All changes included in 1.11:
- ([#10756](https://github.com/quarto-dev/quarto-cli/issues/10756)): Fix the notebook preview of an embedded `.qmd` file going missing from the output directory when a project is rendered again, which broke its source notebook link.
- ([#12014](https://github.com/quarto-dev/quarto-cli/issues/12014)): Fix `twitter-card` `image-width` and `image-height` being listed as options although Twitter Cards have no image size tags. They are no longer documented, and setting them prints a warning pointing to `open-graph`, where an image size is used. The size Quarto measures from a local image is no longer written as `twitter:image-height` and `twitter:image-width` tags, only as `og:image:height` and `og:image:width`.
- ([#12016](https://github.com/quarto-dev/quarto-cli/issues/12016)): Fix `open-graph` `image-width` and `image-height` being dropped when `site-url` is set and Quarto cannot measure the image, such as a remote image. A size declared in the site `open-graph` options no longer applies when a page sets its own `image`, even the same file: declare `image-width` and `image-height` in the page `open-graph` options or in shared metadata such as `_metadata.yml`.
- ([#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 more than one listing. (author: @mcanouil)
- ([#14917](https://github.com/quarto-dev/quarto-cli/issues/14917)): Fix `og:site_name` ignoring a page's `open-graph.site-name` and always showing the website title. Quarto also no longer rewrites `og:site_name`, `og:title`, `og:description`, `twitter:title` or `twitter:description` tags it did not write, such as ones added with `include-in-header` or by an extension. (author: @Masad791)
- ([#14972](https://github.com/quarto-dev/quarto-cli/issues/14972)): Fix the website navbar, sidebar toggle bar, or announcement covering the header of notebook preview pages, which hid the back link, notebook title, and download link. The notebook preview header now sits below the site header.
- ([#14974](https://github.com/quarto-dev/quarto-cli/issues/14974)): Fix math in `.llms.md` files from `llms-txt` coming out garbled with the default `mathjax` method, and with `katex` and `webtex`. Math is now written as `$...$` and `$$...$$` with the `mathjax`, `katex`, `webtex` and `mathml` methods.
Expand Down
80 changes: 50 additions & 30 deletions src/resources/formats/html/quarto.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
return window.document.querySelector(categorySelector) !== null;
}

// The directory part of a path, with its trailing slash.
function directoryOf(path) {
return path.replace(/[^/]+$/, "");
}

function offsetRelativeUrl(url) {
const offset = getMeta("quarto:offset");
return offset ? offset + url : url;
Expand Down Expand Up @@ -288,25 +293,40 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
offsetAbsoluteUrl(window.location.pathname),
listingHrefs
);
if (nearestListing) {
activateCategories(nearestListing);
} else {
// See if the referrer is a listing page for this item
const referredRelativePath = offsetAbsoluteUrl(document.referrer);
const referrerListing = listingHrefs.find((listingHref) => {
const isListingReferrer =
listingHref === referredRelativePath ||
listingHref === referredRelativePath + "index.html";
return isListingReferrer;
});

if (referrerListing) {
// Try to use the referrer if possible
activateCategories(referrerListing);
} else if (listingHrefs.length > 0) {
// Otherwise, just fall back to the first listing
activateCategories(listingHrefs[0]);
}
// See if the referrer is a listing page for this item. listingHrefs
// are full pathnames, so compare against the referrer's pathname.
const referrer = document.referrer
? new URL(document.referrer)
: undefined;
const referrerListing =
referrer?.origin === window.location.origin
? listingHrefs.find(
(listingHref) =>
listingHref === referrer.pathname ||
listingHref === referrer.pathname + "index.html"
)
: undefined;

// The listing the reader came from wins when the nearest listing is
// no more specific: the site root listing, which is an ancestor of
// every page, or another listing in the referrer's own directory.
const nearestDir = nearestListing && directoryOf(nearestListing);
const siteRootDir = directoryOf(
new URL(offsetRelativeUrl(""), window.location).pathname
);
const preferReferrer =
referrerListing &&
(nearestDir === siteRootDir ||
nearestDir === directoryOf(referrerListing));

if (nearestListing && !preferReferrer) {
activateCategories(nearestListing);
} else if (referrerListing) {
activateCategories(referrerListing);
} else if (listingHrefs.length > 0) {
// Otherwise, just fall back to the first listing
activateCategories(listingHrefs[0]);
}
});
}
Expand All @@ -316,22 +336,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 = directoryOf(href);
let best;
let bestLen = -1;
for (const listingHref of listingHrefs) {
const listingDir = directoryOf(listingHref);
if (postDir.startsWith(listingDir) && listingDir.length > bestLen) {
best = listingHref;
bestLen = listingDir.length;
}
relativeParts.pop();
}

return undefined;
return best;
};

const manageSidebarVisiblity = (el, placeholderDescriptor) => {
Expand Down
4 changes: 4 additions & 0 deletions tests/docs/playwright/blog/multi-listing-blog/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.quarto/
/_site/

**/*.quarto_ipynb
15 changes: 15 additions & 0 deletions tests/docs/playwright/blog/multi-listing-blog/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
project:
type: website

website:
title: "Multi-listing blog"
navbar:
left:
- href: index.qmd
text: Home
- href: blog/index.qmd
text: Blog
- href: topics/index.qmd
text: Topics

format: html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Archive"
listing:
contents: "post-two/index.qmd"
type: table
sort: "date asc"
categories: true
---
8 changes: 8 additions & 0 deletions tests/docs/playwright/blog/multi-listing-blog/blog/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Blog"
listing:
contents: "*/index.qmd"
type: default
sort: "date desc"
categories: true
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: "Post one"
date: "2026-01-02"
categories:
- alpha
- beta
---

Body of post one.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Post two"
date: "2026-01-01"
categories:
- alpha
---

Body of post two.
17 changes: 17 additions & 0 deletions tests/docs/playwright/blog/multi-listing-blog/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: "Home"
listing:
id: latest
contents:
- blog/*/index.qmd
- notes/*/index.qmd
type: default
sort: "date desc"
filter-ui: false
sort-ui: false
---

Site root with a listing of latest posts.

::: {#latest}
:::
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Note one"
date: "2026-01-03"
categories:
- gamma
---

Body of note one.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Topics"
listing:
contents: "../notes/*/index.qmd"
type: default
sort: "date desc"
categories: true
---
124 changes: 124 additions & 0 deletions tests/integration/playwright/tests/blog-multi-listing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 categoryHrefPattern = (name: string, listing = "blog/index") =>
new RegExp(`/${listing}\\.html#category=${name}$`);

const categoryLinkLocator = (page: Page, name: string) =>
page
.locator("header.quarto-title-block .quarto-category", { hasText: name })
.locator("a");

// The order of listings.json depends on how the site was rendered, so each
// case serves an explicit order. The link must not depend on that order.
// Both put the blog listing before the archive, so the first of two listings
// in the same directory is never the expected one.
const listingOrders = [
{
name: "homepage listing last",
order: [
"/blog/index.html",
"/blog/archive.html",
"/topics/index.html",
"/index.html",
],
},
{
name: "homepage listing first",
order: [
"/index.html",
"/blog/index.html",
"/blog/archive.html",
"/topics/index.html",
],
},
];

const serveListings = async (page: Page, order: string[]) => {
await page.route("**/listings.json", async (route) => {
const response = await route.fetch();
const listings: { listing: string }[] = await response.json();
const rank = (entry: { listing: string }) => order.indexOf(entry.listing);
await route.fulfill({
response,
json: [...listings].sort((a, b) => rank(a) - rank(b)),
});
});
};

for (const { name, order } of listingOrders) {
test.describe(name, () => {
test.beforeEach(async ({ page }) => {
await serveListings(page, order);
});

test("Category link on a post filters the nearest parent listing", async ({ page }) => {
await page.goto(`./${fixtureRoot}/blog/post-one/`);

// Only "Post one" carries `beta`, so the filter must hide "Post two".
await expect(categoryLinkLocator(page, "beta")).toHaveAttribute(
"href",
categoryHrefPattern("beta"),
);

await categoryLinkLocator(page, "beta").click();
await expect(page).toHaveURL(
getUrl(`${fixtureRoot}/blog/index.html#category=beta`),
);
await expect(
page.locator(
`div.category[data-category="${btoa(encodeURIComponent("beta"))}"]`,
),
).toHaveClass(/active/);
await expect(page.getByRole("link", { name: "Post one", exact: true }))
.toBeVisible();
await expect(page.getByRole("link", { name: "Post two", exact: true }))
.toBeHidden();
});

test("Category link on a post does not point at the homepage listing", async ({ page }) => {
// Reach the post from the homepage so document.referrer points at the
// homepage 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(categoryLinkLocator(page, "alpha")).toHaveAttribute(
"href",
categoryHrefPattern("alpha"),
);
});

test("Category link on a post with only the site listing above it follows the referrer listing", async ({ page }) => {
// "Note one" sits under notes/, which has no listing of its own, so the
// only ancestor listing is the site root. The reader came from Topics,
// which lists the note, so its categories should filter Topics.
await page.goto(`./${fixtureRoot}/topics/`);
await page.getByRole("link", { name: "Note one", exact: true }).click();
await expect(page).toHaveURL(getUrl(`${fixtureRoot}/notes/note-one/`));

await expect(categoryLinkLocator(page, "gamma")).toHaveAttribute(
"href",
categoryHrefPattern("gamma", "topics/index"),
);
});

test("Category link on a post follows the referrer among listings in the same directory", async ({ page }) => {
// blog/index.html and blog/archive.html both list "Post two" from the
// same directory, so neither is nearer; the reader came from Archive.
await page.goto(`./${fixtureRoot}/blog/archive.html`);
await page.getByRole("link", { name: "Post two", exact: true }).click();
await expect(page).toHaveURL(getUrl(`${fixtureRoot}/blog/post-two/`));

await expect(categoryLinkLocator(page, "alpha")).toHaveAttribute(
"href",
categoryHrefPattern("alpha", "blog/archive"),
);
});
});
}
Loading