Skip to content
Draft
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
116 changes: 92 additions & 24 deletions integrations/pagefind-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const isHiddenPath = (path: string): boolean => {
* - properties (inline code terms) → weight 5
* - body text → weight 1 (default)
* - changelog body → weight 0.5 (demoted)
* - changelog title → weight 2 (demoted)
*
* The changelog title is demoted alongside its body. Demoting only the body
* left every entry's headline at full title weight, which is how a changelog
* post outranked the documentation page it was announcing: searching "queue
* modes" put two changelog entries above every page but one.
*
* The `data-pagefind-meta` attributes let us pass custom metadata (pageTitle)
* that the search client can read from `result.meta`.
Expand All @@ -32,6 +38,7 @@ function buildRecordHtml(opts: {
}): string {
const { title, body, properties, pageTitle, headingPath, isChangelog } = opts;
const bodyWeight = isChangelog ? '0.5' : '1';
const titleWeight = isChangelog ? '2' : '7';

const propsHtml =
properties.length > 0
Expand All @@ -44,12 +51,17 @@ function buildRecordHtml(opts: {
metaParts.push(`headingPath:${escapeAttr(headingPath.join(' > '))}`);
const metaAttr = metaParts.length > 0 ? ` data-pagefind-meta="${metaParts.join(', ')}"` : '';

// Properties sit after the body, not before it. Pagefind builds a result's
// excerpt from a window around the match, so a bare list of config keywords
// wedged between the title and the prose became the excerpt for a great many
// results. Behind the body it still matches exactly as before, and only wins
// the excerpt when the match really is in a keyword and nowhere else.
return [
'<html lang="en">',
`<body${metaAttr}>`,
`<h1 data-pagefind-weight="7">${escapeHtml(title)}</h1>`,
propsHtml,
`<h1 data-pagefind-weight="${titleWeight}">${escapeHtml(title)}</h1>`,
`<div data-pagefind-weight="${bodyWeight}">${escapeHtml(body)}</div>`,
propsHtml,
'</body></html>',
].join('');
}
Expand Down Expand Up @@ -106,26 +118,48 @@ export function PagefindIndex(): AstroIntegration {
$('meta[property="og:description"]').attr('content') ||
'';

// Page-level record (intro content before first heading)
const intro = extractIntro(main, $);
const introBody = [intro.text, pageDescription].filter(Boolean).join(' ');
if (introBody) {
const properties = extractProperties(intro.html, $);
const recordHtml = buildRecordHtml({
title: pageTitle,
body: introBody,
properties,
isChangelog,
});
await index.addHTMLFile({
url: `/${baseUrl}/`,
content: recordHtml,
});
recordCount++;
}
const sections = extractSections(main, $);

// Page-level record.
//
// Its body is the whole page, not just the intro. Built from the
// intro alone it was always the thinnest record for its own page, so
// any section record outscored it — and the client, which keeps one
// result per URL, then dropped it. That is why searching a build
// tool never returned that tool's page: "bazel" resolved to
// "Detecting Scopes with bazel-diff" and landed the reader past the
// "Configuring Manual Scopes" step the page opens with.
//
// Giving it the full text lets relevance decide instead of record
// size. A page-wide query ("bazel") matches this record across its
// whole length and wins; a section-specific query ("barrier files")
// still goes to that section, because a short record dense in those
// terms outscores a long one where they are diluted.
const pageBody = [intro.text, pageDescription, ...sections.map((s) => s.text)]
.filter(Boolean)
.join(' ');
const pageProperties = [
...new Set([
...extractProperties(intro.html, $),
...sections.flatMap((s) => extractProperties(s.html, $)),
]),
];
// No `if (pageBody)` guard: a page whose body came out empty used to
// be missing from the index entirely rather than merely ranking low.
const pageRecordHtml = buildRecordHtml({
title: pageTitle,
body: pageBody,
properties: pageProperties,
isChangelog,
});
await index.addHTMLFile({
url: `/${baseUrl}/`,
content: pageRecordHtml,
});
recordCount++;

// Heading-level records
const sections = extractSections(main, $);
for (const section of sections) {
const properties = extractProperties(section.html, $);
const recordHtml = buildRecordHtml({
Expand Down Expand Up @@ -207,6 +241,37 @@ function extractIntro(main: any, $: any): { text: string; html: string } {
return { text: textParts.join(' '), html: htmlParts.join('\n') };
}

/**
* Decorations that live inside a heading element and are not part of its name.
*
* `CliCommand.astro` and `Endpoint.astro` put a literal "#" permalink (and, on
* CLI commands, a "deprecated" pill) inside the `<h2>` itself, so reading the
* heading's text verbatim indexed titles like `mergify ci scopes-send #` and
* `List a test's executions #` — which is exactly how they rendered in the
* results list. MDX headings put their anchor next to the heading rather than
* inside it, so they were never affected.
*/
const HEADING_DECORATIONS = [
'.cli-anchor',
'.endpoint-anchor',
'.cli-deprecated-badge',
'.header-link',
'.anchor-icon',
'.sr-only',
].join(', ');

/** The heading's own name, with permalinks and status pills removed. */
function headingText($el: any): string {
const clone = $el.clone();
clone.find(HEADING_DECORATIONS).remove();
// Belt and braces for any future permalink this list does not know about.
return clone
.text()
.replace(/\s*#\s*$/, '')
.replace(/\s+/g, ' ')
.trim();
}

function extractSections(main: any, $: any): Section[] {
const sections: Section[] = [];
const allHeadings = main.find('h2, h3, h4').toArray();
Expand All @@ -218,8 +283,8 @@ function extractSections(main: any, $: any): Section[] {
const id = $el.attr('id');
if (!id || id === 'on-this-page-heading') continue;

const headingText = $el.text().trim();
if (!headingText) continue;
const heading = headingText($el);
if (!heading) continue;

const tag = el.tagName.toLowerCase();
const level = Number.parseInt(tag.replace('h', ''), 10);
Expand All @@ -229,12 +294,15 @@ function extractSections(main: any, $: any): Section[] {
while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) {
headingStack.pop();
}
headingStack.push({ text: headingText, level });
headingStack.push({ text: heading, level });

const $wrapper = $el.parent();
const $startElement = $wrapper.hasClass('heading-wrapper') ? $wrapper : $el;

const textParts: string[] = [headingText];
// The heading is not repeated into the body: the <h1> already carries it at
// title weight, and repeating it made every excerpt open by restating the
// result's own title back at the reader.
const textParts: string[] = [];
const htmlParts: string[] = [$.html($startElement)];
let $current = $startElement.next();
while ($current.length) {
Expand All @@ -252,7 +320,7 @@ function extractSections(main: any, $: any): Section[] {

sections.push({
anchor: id,
heading: headingText,
heading,
text: textParts.join(' '),
html: htmlParts.join('\n'),
headingPath: headingStack.map((h) => h.text),
Expand Down
12 changes: 11 additions & 1 deletion plugins/remark-graphviz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,17 @@ function classesOf(svg: string, id: string): string[] {
describe('remarkGraphvizPlugin', () => {
it('renders a fence to an SVG carrying the dg class plus the fence classes', async () => {
const svg = await render('digraph { A -> B; }', 'class="queue"');
expect(svg).toMatch(/^<svg[^>]*class="dg queue"/);
expect(svg).toMatch(/<svg[^>]*class="dg queue"/);
});

it('wraps the SVG so a narrow viewport can scroll it instead of shrinking it', async () => {
// Without the wrapper, `.dg`'s width rule scales a ~600pt diagram down to a
// phone's column and takes its 13px labels to about 6px with it. The
// media query in index.css hangs off this element, so its absence is not a
// cosmetic regression — it is the diagram becoming unreadable on mobile.
const svg = await render('digraph { A -> B; }');
expect(svg).toMatch(/^<div class="dg-wrap"><svg\b/);
expect(svg).toMatch(/<\/svg><\/div>$/);
});

it('leaves no colour in the output at all', async () => {
Expand Down
9 changes: 8 additions & 1 deletion plugins/remark-graphviz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,16 @@ export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> {
// the rendered SVG. mdast has no in-place conversion, so the node
// itself is retyped — asserting the string into `Code['type']` would
// claim `'html'` is `'code'` and leave the node lying about itself.
//
// The SVG ships inside `.dg-wrap`, the diagram counterpart to the
// `.table-wrap` tables already use. A diagram is authored around
// 600pt wide with 13px labels; letting it shrink to a phone's column
// scales that text to about 6px, so below the breakpoint in
// `index.css` the wrapper scrolls and the diagram keeps its intrinsic
// size instead. Above it, the wrapper is inert.
const htmlNode = node as unknown as mdast.Html;
htmlNode.type = `html`;
htmlNode.value = $.html(`svg`);
htmlNode.value = `<div class="dg-wrap">${$.html(`svg`)}</div>`;
} catch (error) {
// The fence survives as a code block rather than taking the build
// down, so name it loudly: a diagram silently becoming a wall of DOT
Expand Down
41 changes: 40 additions & 1 deletion src/components/LeftSidebar/NavLink.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ const productIconName = parseProductIcon(navItem.icon);
---

<li class="nav-link">
<a href={navItem.path} aria-current={`${isHomepageMatch || isPageMatch ? 'page' : 'false'}`} title={navItem.title}>
<a
href={navItem.path}
aria-current={`${isHomepageMatch || isPageMatch ? 'page' : 'false'}`}
title={navItem.badge ? `${navItem.title} (${navItem.badge})` : navItem.title}
>
{showIcon && productIconName && <ProductIcon name={productIconName} class="left-icon" />}
{showIcon && !productIconName && navItem.icon && <Icon name={navItem.icon} class="left-icon" />}
<span class="nav-link-text" set:html={navItem.title} />
{navItem.badge && <span class="nav-link-badge">{navItem.badge}</span>}
</a>
</li>
<style>
Expand Down Expand Up @@ -75,5 +80,39 @@ const productIconName = parseProductIcon(navItem.icon);
.nav-link-text {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}

/* The pill sits after the label and wraps under it when the row is too
narrow to hold both.
The deepest nav rows leave 152px beside the icon. "Post Check" (76px) and
the pill (62px) share that comfortably; "Delete Head Branch" (133px) never
could, and truncating the label to make room reads as a different page.
Wrapping decides per row instead of per label, so it keeps holding if a
label is renamed or the sidebar is resized — and the label is never
shortened to protect the pill. */
.nav-link a:has(.nav-link-badge) {
flex-wrap: wrap;
row-gap: 3px;
}

.nav-link-badge {
flex: none;
font-size: 9px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
padding: 0 4px;
border-radius: 4px;
/* Secondary, not muted: at 10px on the offset background, muted lands at
4.39:1 — just under the 4.5:1 AA floor for text this size. */
color: var(--theme-text-secondary);
background-color: var(--theme-bg-offset);
border: 1px solid var(--theme-divider);
white-space: nowrap;
}

.nav-link a[aria-current='page'] .nav-link-badge {
color: var(--theme-text);
}
</style>
22 changes: 22 additions & 0 deletions src/components/Search/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
* Does the title itself contain a query term?
*
* Rows whose title matched need no further explanation — the highlight is the
* explanation. Rows that matched somewhere in the body used to render with no
* highlight anywhere and no snippet, so half a result list could look unrelated
* to what was typed. Those rows get Pagefind's excerpt; the rest stay compact.
*/
function titleMatchesQuery(title: string, query: string): boolean {
const haystack = title.toLowerCase();
return query
.split(/\s+/)
.filter((t) => t.length >= 2)
.some((t) => haystack.includes(t.toLowerCase()));
}

/** Map URL prefix to a known section key. Returns empty string for paths
* that don't match any product section; CSS then uses the default accent. */
const SECTION_KEYS = [
Expand Down Expand Up @@ -51,6 +67,7 @@ interface PageResultProps {

function PageResult({ entry, query, onHover, onNavigate, active }: PageResultProps) {
const section = getSectionFromUrl(entry.url);
const showExcerpt = Boolean(entry.excerpt) && !titleMatchesQuery(entry.title, query);
return (
<a
className={`page-result${active ? ' active' : ''}`}
Expand All @@ -66,6 +83,11 @@ function PageResult({ entry, query, onHover, onNavigate, active }: PageResultPro
dangerouslySetInnerHTML={{ __html: highlightTerms(entry.title, query) }}
/>
{entry.breadcrumb && <p className="result-description">{entry.breadcrumb}</p>}
{showExcerpt && (
// Pagefind returns the excerpt with its own <mark> tags around the
// matched terms, so it is rendered rather than re-highlighted.
<p className="result-excerpt" dangerouslySetInnerHTML={{ __html: entry.excerpt }} />
)}
</div>
{active && <Icon icon="lucide:corner-down-left" />}
</a>
Expand Down
12 changes: 12 additions & 0 deletions src/components/Search/Search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,18 @@
text-overflow: ellipsis;
}

/* Shown only on rows whose title did not match, so the row can say why it
is in the list. One line, clipped — the preview pane carries the detail. */
.result-excerpt {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--theme-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}

/* ----- Highlight ----- */
mark {
color: var(--theme-link);
Expand Down
Loading