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.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ All changes included in 1.9:
- ([#13932](https://github.com/quarto-dev/quarto-cli/pull/13932)): Add `llms-txt: true` option to generate LLM-friendly content for websites. Creates `.llms.md` markdown files alongside HTML pages and a root `llms.txt` index file following the [llms.txt](https://llmstxt.org/) specification.
- ([#13951](https://github.com/quarto-dev/quarto-cli/issues/13951)): Fix `image-lazy-loading` not applying `loading="lazy"` attribute to auto-detected listing images.
- ([#14003](https://github.com/quarto-dev/quarto-cli/pull/14003)): Add text fragments to search result links so browsers scroll to and highlight the matched text on the target page.
- ([#9802](https://github.com/quarto-dev/quarto-cli/issues/9802), [#14047](https://github.com/quarto-dev/quarto-cli/issues/14047)): Fix search term highlighting disappearing on page scroll or layout events when navigating from search results. (author: @jtbayly, [#13442](https://github.com/quarto-dev/quarto-cli/pull/13442))

### `book`

Expand Down
108 changes: 108 additions & 0 deletions src/resources/projects/website/search/quarto-search.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
// perform any highlighting
highlight(escapeRegExp(query), mainEl);

// Activate tabs that contain highlighted matches on pageshow rather than
// DOMContentLoaded. tabsets.js (loaded as a module) registers its pageshow
// handler during module execution, before DOMContentLoaded. By registering
// ours during DOMContentLoaded, listener ordering guarantees we run after
// tabsets.js restores tab state from localStorage — so search activation
// wins over stored tab preference.
window.addEventListener("pageshow", function (event) {
if (!event.persisted) {
activateTabsWithMatches(mainEl);
// Let the browser settle layout after Bootstrap tab transitions
// before calculating scroll position.
requestAnimationFrame(() => scrollToFirstMatch(mainEl));
}
}, { once: true });

// fix up the URL to remove the q query param
const replacementUrl = new URL(window.location);
replacementUrl.searchParams.delete(kQueryArg);
Expand Down Expand Up @@ -1112,6 +1127,99 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}

// After search highlighting, activate any tabs whose panes contain <mark> matches.
// This ensures that search results inside inactive Bootstrap tabs become visible.
// Handles nested tabsets by walking up ancestor panes and activating outermost first.
function activateTabsWithMatches(mainEl) {
if (typeof bootstrap === "undefined") return;

const marks = mainEl.querySelectorAll("mark");
if (marks.length === 0) return;

// Collect all tab panes that contain marks, including ancestor panes for nesting.
// Group by their parent tabset (.tab-content container).
const tabsetMatches = new Map();

const recordPane = (pane) => {
const tabContent = pane.closest(".tab-content");
if (!tabContent) return;
if (!tabsetMatches.has(tabContent)) {
tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null });
}
const info = tabsetMatches.get(tabContent);
if (pane.classList.contains("active")) {
info.activeHasMatch = true;
} else if (!info.firstInactivePane) {
info.firstInactivePane = pane;
}
};

for (const mark of marks) {
// Walk up all ancestor tab panes (handles nested tabsets)
let pane = mark.closest(".tab-pane");
while (pane) {
recordPane(pane);
pane = pane.parentElement?.closest(".tab-pane") ?? null;
}
}

// Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
const sorted = [...tabsetMatches.entries()].sort((a, b) => {
const depthA = ancestorCount(a[0], mainEl);
const depthB = ancestorCount(b[0], mainEl);
return depthA - depthB;
});

for (const [, info] of sorted) {
if (info.activeHasMatch || !info.firstInactivePane) continue;

const escapedId = CSS.escape(info.firstInactivePane.id);
const tabButton = mainEl.querySelector(
`[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]`
);
if (tabButton) {
try {
new bootstrap.Tab(tabButton).show();
} catch (e) {
console.debug("Failed to activate tab for search match:", e);
}
}
}
}

function ancestorCount(el, stopAt) {
let count = 0;
let node = el.parentElement;
while (node && node !== stopAt) {
count++;
node = node.parentElement;
}
return count;
}

// After tab activation, scroll to the first visible search match so the user
// sees the highlighted result without manually scrolling.
// Only checks tab-pane visibility (not collapsed callouts, details/summary, etc.)
// since this runs specifically after tab activation for search results.
function scrollToFirstMatch(mainEl) {
const marks = mainEl.querySelectorAll("mark");
for (const mark of marks) {
let hidden = false;
let el = mark.parentElement;
while (el && el !== mainEl) {
if (el.classList.contains("tab-pane") && !el.classList.contains("active")) {
hidden = true;
break;
}
el = el.parentElement;
}
if (!hidden) {
mark.scrollIntoView({ block: "center" });
return;
}
}
}

// highlight matches
function highlight(term, el) {
const termRegex = new RegExp(term, "ig");
Expand Down
4 changes: 3 additions & 1 deletion tests/docs/playwright/html/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
*_files/
*.html
*.html
/.quarto/
**/*.quarto_ipynb
3 changes: 3 additions & 0 deletions tests/docs/playwright/html/search-highlight/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/.quarto/
/_site/
**/*.quarto_ipynb
11 changes: 11 additions & 0 deletions tests/docs/playwright/html/search-highlight/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
project:
type: website

website:
title: "Search Highlight Test"
navbar:
left:
- href: index.qmd
text: Home

format: html
7 changes: 7 additions & 0 deletions tests/docs/playwright/html/search-highlight/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: "Search Highlight Test"
---

This page contains a special keyword that we use for testing search highlighting.

The word special appears multiple times on this page to ensure search highlighting works correctly.
3 changes: 3 additions & 0 deletions tests/docs/playwright/html/search-tabsets/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/.quarto/
/_site/
**/*.quarto_ipynb
11 changes: 11 additions & 0 deletions tests/docs/playwright/html/search-tabsets/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
project:
type: website
website:
title: "Search Tab Test"
search: true
navbar:
left:
- href: index.qmd
text: Home

format: html
89 changes: 89 additions & 0 deletions tests/docs/playwright/html/search-tabsets/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: "Search Tab Test"
---

## Plain Section

This section contains epsilon-no-tabs content outside of any tabset.

## Ungrouped Tabset

::: {.panel-tabset}

### Tab Alpha

This tab contains alpha-visible-content that is in the default active tab.

### Tab Beta

This tab contains beta-unique-search-term that is only in this inactive tab.

:::

## Both Tabs Match

::: {.panel-tabset}

### R

This tab contains gamma-both-tabs content in the active R tab.

### Python

This tab also contains gamma-both-tabs content in the inactive Python tab.

:::

## Grouped Tabset

::: {.panel-tabset group="language"}

### R

This tab contains delta-r-only-term that is only in the R tab.

### Python

This tab contains python-only-content in the Python tab.

:::

## Second Grouped Tabset

::: {.panel-tabset group="language"}

### R

This tab shows R content in the second grouped tabset.

### Python

This tab shows Python content in the second grouped tabset.

:::

## Nested Tabset

::: {.panel-tabset}

### Outer Tab A

This is outer-tab-a-content in the default active outer tab.

### Outer Tab B

Content in outer tab B.

::: {.panel-tabset}

#### Inner Tab X

This is inner-tab-x-content in the default active inner tab.

#### Inner Tab Y

This tab contains nested-inner-only-term that is only in this deeply nested inactive tab.

:::

:::
2 changes: 1 addition & 1 deletion tests/integration/playwright/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"devDependencies": {
"@playwright/test": "^1.28.1"
"@playwright/test": "^1.31.0"
},
"scripts": {}
}
43 changes: 43 additions & 0 deletions tests/integration/playwright/tests/html-search-highlight.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";

const BASE = './html/search-highlight/_site/index.html';

test('Search highlights persist after scrolling', async ({ page }) => {
await page.goto(`${BASE}?q=special`);
const marks = page.locator('mark');

await expect(marks.first()).toBeVisible({ timeout: 5000 });
const initialCount = await marks.count();
expect(initialCount).toBeGreaterThanOrEqual(2);

// Scroll the page — marks should not be cleared
await page.evaluate(() => window.scrollBy(0, 300));
await page.waitForTimeout(500);
await expect(marks).toHaveCount(initialCount);
});

test('Search highlights cleared when query changes', async ({ page }) => {
await page.goto(`${BASE}?q=special`);
const marks = page.locator('mark');

await expect(marks.first()).toBeVisible({ timeout: 5000 });

// Open the search overlay and type a different query
await page.locator('#quarto-search').getByRole('button').click();
const input = page.getByRole('searchbox');
await expect(input).toBeVisible({ timeout: 2000 });

// Typing a different query triggers onStateChange which clears marks
await input.fill('different');
await expect(page.locator('main mark')).toHaveCount(0, { timeout: 2000 });
});

test('No highlights without search query', async ({ page }) => {
await page.goto(BASE);

// Wait for page to fully load
await expect(page.locator('main')).toBeVisible();

// No marks should exist without ?q= parameter
await expect(page.locator('mark')).toHaveCount(0);
});
Loading