From 0af96db06f297afa7812513ffe57996a8f903934 Mon Sep 17 00:00:00 2001 From: Frank Lagendijk Date: Fri, 4 Sep 2026 21:10:09 +0200 Subject: [PATCH 1/4] fix(search): return the page a query names, not a heading inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching a build tool never returned that tool's page. "bazel" answered with "Detecting Scopes with bazel-diff", landing the reader past the "Configuring Manual Scopes" step every one of those pages opens with; "pants" returned two results and neither was the Pants page. Two causes, one on each side of the index. The page-level record held only the intro paragraph and the meta description, so it was always the thinnest record for its own page and lost to every heading on it. It now holds the whole page, which lets relevance decide instead of record size: a page-wide query matches it across its whole length and wins, while a section-specific query ("barrier files") still goes to the heading, because a short record dense in those terms outscores a long one where they are diluted. The `if (introBody)` guard is gone too — a page with no intro was missing from the index entirely rather than merely ranking low. The client then kept one result per URL, first wins, so the winning heading silently evicted the page record. It now groups a page's records and lets the page answer when the query names the page. Also here, all visible in the results list: - Whole-word title matches sort above incidental ones. "direct merge" ranked "Using TestNG Directly" first, matching "Direct" inside "Directly" — a match nobody typing those two words meant. - Rows whose title did not match now carry Pagefind's excerpt. Half a result list could otherwise show no highlight and no snippet, with nothing on screen explaining why those rows were there. - Heading permalinks are stripped before indexing. `CliCommand.astro` and `Endpoint.astro` put a literal "#" inside the `

`, which is how "List a test's executions #" reached the results. - Changelog titles are demoted alongside their bodies. Demoting only the body left every entry's headline at full title weight, so a changelog post outranked the page it was announcing. - Properties sit after the body in each record, so a bare list of config keywords stops winning the excerpt over prose. The full-text page records grow the index 8.7M to 9.0M (+3.4%); fragments load lazily, so per-search transfer is unchanged. Co-Authored-By: Claude Opus 5 Change-Id: I91bd9f09d1025d6ea0dc66eec979fc782731aeb6 Claude-Session-Id: 18413631-aa75-4182-9e79-4c1ce31bb683 --- integrations/pagefind-index.ts | 116 ++++++++++++++++++++++------ src/components/Search/Results.tsx | 22 ++++++ src/components/Search/Search.scss | 12 +++ src/components/Search/SearchBar.tsx | 75 +++++++++++++++--- 4 files changed, 191 insertions(+), 34 deletions(-) diff --git a/integrations/pagefind-index.ts b/integrations/pagefind-index.ts index 182f584952..b153a49b8b 100644 --- a/integrations/pagefind-index.ts +++ b/integrations/pagefind-index.ts @@ -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`. @@ -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 @@ -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 [ '', ``, - `

${escapeHtml(title)}

`, - propsHtml, + `

${escapeHtml(title)}

`, `
${escapeHtml(body)}
`, + propsHtml, '', ].join(''); } @@ -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({ @@ -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 `

` 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(); @@ -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); @@ -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

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) { @@ -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), diff --git a/src/components/Search/Results.tsx b/src/components/Search/Results.tsx index 6b36994021..f9102409b7 100644 --- a/src/components/Search/Results.tsx +++ b/src/components/Search/Results.tsx @@ -24,6 +24,22 @@ function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } +/** + * 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 = [ @@ -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 ( {entry.breadcrumb &&

{entry.breadcrumb}

} + {showExcerpt && ( + // Pagefind returns the excerpt with its own tags around the + // matched terms, so it is rendered rather than re-highlighted. +

+ )} {active && } diff --git a/src/components/Search/Search.scss b/src/components/Search/Search.scss index db1546c7f6..467681bd3e 100644 --- a/src/components/Search/Search.scss +++ b/src/components/Search/Search.scss @@ -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); diff --git a/src/components/Search/SearchBar.tsx b/src/components/Search/SearchBar.tsx index 5e1fbd73b4..659ce8914f 100644 --- a/src/components/Search/SearchBar.tsx +++ b/src/components/Search/SearchBar.tsx @@ -23,6 +23,34 @@ function formatSlugToTitle(slug: string): string { .join(' '); } +/** + * How squarely a title answers the query. Higher wins; 0 means the title did + * not match at all and only the body did. + * + * 3 every term is a whole word in the title "bazel" → "Scopes with Bazel" + * 2 every term starts a word "config" → "Configuration" + * 1 every term appears somewhere "direct" → "Directly" + * 0 no title match + * + * The tiers exist to separate the first case from the last: matching inside a + * longer word is what put "Using TestNG Directly" above the Direct Merge page. + */ +function titleMatchRank(title: string, query: string): number { + const terms = query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length >= 2); + if (terms.length === 0) return 0; + + const haystack = title.toLowerCase(); + const words = haystack.split(/[^a-z0-9]+/).filter(Boolean); + + if (terms.every((term) => words.includes(term))) return 3; + if (terms.every((term) => words.some((w) => w.startsWith(term)))) return 2; + if (terms.every((term) => haystack.includes(term))) return 1; + return 0; +} + function buildBreadcrumb(url: string, headingPath?: string): string { const path = url.split('#')[0].replace(/^\/|\/$/g, ''); const urlParts = path ? path.split('/').filter(Boolean).map(formatSlugToTitle) : []; @@ -158,23 +186,50 @@ function usePagefindSearch(query: string, open: boolean) { const loaded = await Promise.all(response.results.slice(0, 30).map((r) => r.data())); if (cancelled) return; - const seen = new Set(); - const entries: SearchEntry[] = []; + // One row per page, in Pagefind's order. The index holds a record per + // page and a record per heading, so a page can occupy several results. + const groups = new Map(); for (const page of loaded) { const pageUrl = page.url.split('#')[0]; - if (seen.has(pageUrl)) continue; - seen.add(pageUrl); + const group = groups.get(pageUrl); + if (group) group.push(page); + else groups.set(pageUrl, [page]); + } + + const entries: SearchEntry[] = []; + for (const [pageUrl, records] of groups) { + // Which record represents the page. Taking the best-ranked one meant a + // heading always spoke for its page, because a section record is short + // and dense where the page record is long and diluted — so "bazel" + // answered with "Detecting Scopes with bazel-diff" and dropped the + // reader past the setup step the page opens with. + // + // When the query names the page, the page answers. When it names + // something inside the page ("barrier files" on the Scopes page), the + // page's own title does not match and the heading still wins, which is + // the behaviour worth keeping. + const pageRecord = records.find((r) => !r.url.includes('#')); + const primary = + pageRecord && titleMatchRank(pageRecord.meta.title, query) > 0 ? pageRecord : records[0]; + entries.push({ - id: page.id, - url: page.url, - title: page.meta.title, - excerpt: page.excerpt, - pageTitle: page.meta.pageTitle || page.meta.title, + id: primary.id, + url: primary.url, + title: primary.meta.title, + excerpt: primary.excerpt, + pageTitle: primary.meta.pageTitle || primary.meta.title, pageUrl, - breadcrumb: buildBreadcrumb(page.url, page.meta.headingPath), + breadcrumb: buildBreadcrumb(primary.url, primary.meta.headingPath), }); } + // Lift whole-word title matches above incidental ones. Pagefind scores + // "Using TestNG Directly" over the Direct Merge page for "direct merge", + // because "Direct" sits inside "Directly" — a match no reader typing + // those two words meant. Array.sort is stable, so results that tie keep + // the relevance order Pagefind gave them. + entries.sort((a, b) => titleMatchRank(b.title, query) - titleMatchRank(a.title, query)); + setResults(entries); setLoading(false); }; From 81195c1b2bf8fcbae58e95e5b46f756466ff16e5 Mon Sep 17 00:00:00 2001 From: Frank Lagendijk Date: Fri, 4 Sep 2026 21:10:57 +0200 Subject: [PATCH 2/4] fix(diagrams): scroll a diagram on a narrow viewport instead of shrinking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every diagram on the site was unreadable on a phone. The Graphviz fences are authored around 600pt wide with 13px labels, and `.dg` sizes them to 80% of the prose column, so at 390px they rendered at 286px and took their labels to roughly 6px. The wrapper was `overflow-x: visible`, so there was nothing to scroll and nothing to tap: 19 diagrams across 12 pages that a phone reader could see the shape of but not read. Tables and code blocks on the same pages already solve this by scrolling. Diagrams were the one kind of wide content that silently scaled itself past legibility instead. Each rendered fence now ships inside `.dg-wrap`, the diagram counterpart to the `.table-wrap` tables use. Below 700px that wrapper scrolls and the SVG takes back its intrinsic width, so the labels return to the size they were drawn at; a diagram narrower than the column still fills it rather than collapsing left. Above the breakpoint the wrapper is inert and the 80% rule is untouched. The wrapper carries a regression test, because its absence is not a cosmetic change — it is the diagram becoming unreadable on mobile. `` renders its own SVG outside this plugin and already sizes to fit, so it is unchanged. Co-Authored-By: Claude Opus 5 Change-Id: I88e3fbd0be0cdee1d85b52d901cd041ee4fe09ea Claude-Session-Id: 18413631-aa75-4182-9e79-4c1ce31bb683 --- plugins/remark-graphviz.test.ts | 12 +++++++++++- plugins/remark-graphviz.ts | 9 ++++++++- src/styles/index.css | 30 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/plugins/remark-graphviz.test.ts b/plugins/remark-graphviz.test.ts index 10109f461a..18a3f0cd30 100644 --- a/plugins/remark-graphviz.test.ts +++ b/plugins/remark-graphviz.test.ts @@ -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(/^]*class="dg queue"/); + expect(svg).toMatch(/]*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>$/); }); it('leaves no colour in the output at all', async () => { diff --git a/plugins/remark-graphviz.ts b/plugins/remark-graphviz.ts index eb4b8be5c2..e92207671a 100644 --- a/plugins/remark-graphviz.ts +++ b/plugins/remark-graphviz.ts @@ -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 = `
${$.html(`svg`)}
`; } 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 diff --git a/src/styles/index.css b/src/styles/index.css index 988c7cbc0d..c06eab5da0 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -895,6 +895,36 @@ html { font-family: var(--font-body); } +/* The wrapper `remark-graphviz.ts` puts around every rendered fence. On a wide + viewport it does nothing: the diagram already fits, and `.dg` keeps the 80% + width above. */ +.dg-wrap { + margin: 1.75em 0; +} +.dg-wrap > .dg { + margin-block: 0; +} + +/* Below the prose column's own comfortable width, stop scaling and start + scrolling. A diagram authored at ~600pt carries 13px labels; squeezed into a + 360px column those render near 6px, which is a picture of a diagram rather + than a diagram. Tables on the same page already solve this with `.table-wrap`, + so this is the gesture readers have here — not a new one. */ +@media (max-width: 700px) { + .dg-wrap { + overflow-x: auto; + overscroll-behavior-x: contain; + } + .dg-wrap > .dg { + /* `width: auto` hands the SVG back its intrinsic `width="NNNpt"`, so the + labels return to the size they were drawn at. `min-width: 100%` keeps a + diagram narrower than the column from collapsing to the left. */ + width: auto; + max-width: none; + min-width: 100%; + } +} + /* Only direct children are painted: the plugin strips their inline fill and stroke, and leaves anything deeper alone. `:where()` again, so `plain` below wins on specificity rather than on source order. From 7ad2513f7f88fa3d7ea2caa39030385a950e2cd9 Mon Sep 17 00:00:00 2001 From: Frank Lagendijk Date: Fri, 4 Sep 2026 21:10:57 +0200 Subject: [PATCH 3/4] fix(nav): keep the deprecated marker visible in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three action pages carried their status inside the nav label, and the sidebar track was too narrow to hold it. The suffix was the part that ellipsed away: Delete Head Branch (De… GitHub Actions (Deprec… Post Check (Deprecated… "GitHub Actions (Deprec…" sits in the same tree as the live GitHub Actions integration page, so the one word separating them was the word being cut. Status moves out of the title into a `badge` field on NavItem, rendered as its own pill. Because it is a separate element it is never what gets truncated: it sits after the label where there is room and wraps beneath it where there is not, deciding per row rather than per label, so it keeps holding if a label is renamed or the sidebar is resized. No label is shortened to protect it. `--theme-left-sidebar-width` goes from 17rem to 18rem. The deepest rows are indented twice and left 152px beside the icon — enough for a label, not for a label plus a pill, so "GitHub Actions" pushed its pill onto a second line. The extra 12px keeps every badged label but "Delete Head Branch" inline, and costs the content column 12px. The right rail is unchanged. "GitHub Rulesets Compatibility" becomes "GitHub Rulesets", which now fits without wrapping. The pill reads 9.4:1 in light and 10:1 in dark. Co-Authored-By: Claude Opus 5 Change-Id: Ie8b27ca54974d6cb745292dacd61146b2776521a Claude-Session-Id: 18413631-aa75-4182-9e79-4c1ce31bb683 --- src/components/LeftSidebar/NavLink.astro | 41 +++++++++++++++++++++++- src/content/navItems.tsx | 21 +++++++++--- src/styles/theme.css | 8 ++++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/components/LeftSidebar/NavLink.astro b/src/components/LeftSidebar/NavLink.astro index d3697d3758..3abc48431c 100644 --- a/src/components/LeftSidebar/NavLink.astro +++ b/src/components/LeftSidebar/NavLink.astro @@ -19,10 +19,15 @@ const productIconName = parseProductIcon(navItem.icon); --- diff --git a/src/content/navItems.tsx b/src/content/navItems.tsx index 9f6d2aa3db..51b7d78522 100644 --- a/src/content/navItems.tsx +++ b/src/content/navItems.tsx @@ -6,6 +6,16 @@ export type NavItem = { children?: NavItem[]; id?: string; icon?: string; + /** + * Short status word rendered as a pill after the label, e.g. `Deprecated`. + * + * Status belongs here rather than inside `title`: the sidebar row is narrow, + * so a title carrying its own "(Deprecated)" suffix was the part that ellipsed + * away — leaving "GitHub Actions (Deprec…" next to the live GitHub Actions + * page. As its own element the pill is never the thing that gets cut: it sits + * after the label where there is room and wraps beneath it where there is not. + */ + badge?: string; }; const navItems: NavItem[] = [ @@ -69,7 +79,7 @@ const navItems: NavItem[] = [ { title: 'Two-Step CI', path: '/merge-queue/two-step', icon: 'lucide:arrow-right-left' }, { title: 'Deployment', path: '/merge-queue/deploy', icon: 'lucide:rocket' }, { - title: 'GitHub Rulesets Compatibility', + title: 'GitHub Rulesets', path: '/merge-queue/github-rulesets', icon: 'simple-icons:github', }, @@ -252,7 +262,8 @@ const navItems: NavItem[] = [ { title: 'Copy', path: '/workflow/actions/copy', icon: 'lucide:share-2' }, { title: 'Comment', path: '/workflow/actions/comment', icon: 'lucide:message-square' }, { - title: 'Delete Head Branch (Deprecated)', + title: 'Delete Head Branch', + badge: 'Deprecated', path: '/workflow/actions/delete_head_branch', icon: 'lucide:scissors', }, @@ -267,14 +278,16 @@ const navItems: NavItem[] = [ icon: 'octicon:git-pull-request-draft-16', }, { - title: 'GitHub Actions (Deprecated)', + title: 'GitHub Actions', + badge: 'Deprecated', path: '/workflow/actions/github_actions', icon: 'simple-icons:githubactions', }, { title: 'Label', path: '/workflow/actions/label', icon: 'lucide:badge-check' }, { title: 'Merge', path: '/workflow/actions/merge', icon: 'octicon:git-merge-16' }, { - title: 'Post Check (Deprecated)', + title: 'Post Check', + badge: 'Deprecated', path: '/workflow/actions/post_check', icon: 'lucide:circle-check', }, diff --git a/src/styles/theme.css b/src/styles/theme.css index 30c941a5df..ce06d23a0d 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -4,7 +4,13 @@ :root { --theme-navbar-height: 5rem; --theme-mobile-toc-height: 4rem; - --theme-left-sidebar-width: 17rem; + /* 18rem, not 17rem: the deepest nav rows (Workflow Automation → Actions → + an action page) are indented twice, and at 17rem they left 152px beside the + icon — enough for a label, not for a label plus a status pill, so + "GitHub Actions" pushed its "Deprecated" pill onto a second line. The extra + 12px lets every badged label but "Delete Head Branch" keep its pill inline. + Costs the content column 12px; the right rail is unchanged at 18rem. */ + --theme-left-sidebar-width: 18rem; --theme-right-sidebar-width: 18rem; /* Minimum visual horizontal spacing from the edges of the viewport, From c2e73056928b1f17d9af3c0217b81cc92892e30c Mon Sep 17 00:00:00 2001 From: Frank Lagendijk Date: Fri, 4 Sep 2026 21:10:57 +0200 Subject: [PATCH 4/4] fix(layout): drop the empty table of contents from pages with no headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The homepage's "On this page" rail held a single entry, "Overview", pointing at `#overview` — an id that does not exist there. It was the only dead link on the site. `generateToc` always prepends that entry, and the id it targets lives on the `

` `PageContent` renders. A page with `suppressTitle` emits no `

`, so the entry has nothing to scroll to. Those pages now get no rail, and the column it reserved goes back to the content: the homepage gains 336px of width it was holding for an 18rem aside with one broken link in it. Two things this turned up. `margin-inline-start` has to be restored to the sidebar width when the rail is gone. The left sidebar is `position: fixed`, so nothing in flow clears it; above 82em the column's own margin drops to a 3rem gutter because `#right-sidebar` — a flex item that precedes `#main-content` in the DOM and is then shifted right by `left: 100%` — reserves the width instead. Removing the rail without restoring that margin slid the content under the sidebar above 1312px only. `.main-column-footer` already compensates the same way for living outside `.layout`. The changelog index and every changelog entry also set `suppressTitle`. They never had a table of contents, but they did carry PageFeedback in that rail, and the article-footer copy is hidden above 82em precisely because the rail was assumed to have one — which left roughly 200 pages with no feedback widget at all on desktop. That rule is now scoped to pages that actually render a rail. Presence is passed explicitly rather than read from `Astro.slots.has()`, which reports a slot as present even when the expression filling it evaluated to nothing. Co-Authored-By: Claude Opus 5 Change-Id: I3a14a632ed544b4d7cefe21c7237703a8d919cf7 Claude-Session-Id: 18413631-aa75-4182-9e79-4c1ce31bb683 --- src/layouts/BaseLayout.astro | 37 +++++++++++++++++++++++++++++++----- src/layouts/MainLayout.astro | 22 ++++++++++++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 64a59b13ff..852c763c9f 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -13,9 +13,17 @@ import { getActivePageGroupIds } from '../util/activePageGroupIds'; export interface Props { content: CollectionEntry<'docs'>['data']; + /** + * Whether this page has anything to put in the right rail. When false the + * aside is not rendered and the column it reserves is given back, instead of + * an empty 18rem rail holding the main column short. Passed explicitly rather + * than read from `Astro.slots.has()`, which reports a slot as present even + * when the expression filling it evaluated to nothing. + */ + hasRightSidebar?: boolean; } -const { content } = Astro.props; +const { content, hasRightSidebar = true } = Astro.props; const url = Astro.url; const currentPage = url.pathname; const activePageGroupIds = getActivePageGroupIds(currentPage); @@ -140,6 +148,23 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro display: flex; flex-direction: column; } + + /* No secondary sidebar on this page: give the column back rather + than leaving a reserved, empty rail. + + `margin-inline-start` has to come back to the sidebar width + here. The left sidebar is `position: fixed`, so nothing in + flow clears it — above 50em the column's own margin does the + job, but at this breakpoint that margin drops to a 3rem + gutter because #right-sidebar, a flex item that precedes + #main-content in the DOM, reserves the width instead and is + then shifted right by `left: 100%`. Remove that rail and the + column has to reserve the width itself, exactly as + .main-column-footer already does for living outside .layout. */ + .layout.no-right-sidebar .main-column { + margin-inline-start: var(--theme-left-sidebar-width); + margin-inline-end: 0; + } } @@ -154,15 +179,17 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro }}>
-
+
- + {hasRightSidebar && ( + + )}
diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro index aabe75d0ef..5a1d2959df 100644 --- a/src/layouts/MainLayout.astro +++ b/src/layouts/MainLayout.astro @@ -20,10 +20,18 @@ export interface Props { } const { content, headings, breadcrumbTitle, showMarkdownActions = true } = Astro.props; + +// `generateToc` always prepends an "Overview" entry pointing at `#overview`, +// and that id lives on the

PageContent renders. A page with +// `suppressTitle` emits no

, so the entry has nothing to scroll to — which +// is how the homepage shipped a one-item table of contents whose only link was +// dead. Such a page gets no right rail at all; BaseLayout then reclaims the +// column rather than reserving width for an empty aside. +const hasToc = Boolean(headings) && !content.suppressTitle; --- - - + + {hasToc && } { Astro.url.pathname !== '/' && ( @@ -64,8 +72,12 @@ const { content, headings, breadcrumbTitle, showMarkdownActions = true } = Astro Astro.url.pathname !== '/' && ( {/* PageFeedback also lives in the right sidebar (visible ≥82em). - Hide this footer copy on wide viewports so it doesn't double up. */} -
+ Hide this footer copy on wide viewports so it doesn't double up — + but only where that rail is actually rendered. Changelog pages set + `suppressTitle` and pass no headings, so they have no rail to carry + it, and hiding the footer copy there left them with no feedback + widget at all above 82em. */} +
{showMarkdownActions && } @@ -79,7 +91,7 @@ const { content, headings, breadcrumbTitle, showMarkdownActions = true } = Astro /* The right sidebar (≥82em) hosts a PageFeedback widget. To avoid showing the same widget twice on wide viewports, hide the article-footer copy. */ @media (min-width: 82em) { - .article-page-feedback { + .article-page-feedback:not(.is-only-copy) { display: none; } }