From e928f0fe561b9c6d6af439001cbbbf2c00ec20d6 Mon Sep 17 00:00:00 2001 From: veejask-41 Date: Fri, 21 Aug 2026 17:21:38 +0530 Subject: [PATCH] Optimize sidebar to cut payload, fix layout flash, and enable soft navigation --- en/docs/assets/js/theme.js | 699 +++++++++++++----- en/hooks.py | 241 ++++++ en/mkdocs.yml | 1 + .../assets/css/partials/_version-select.css | 5 +- .../material/partials/javascripts/base.html | 4 - en/theme/material/partials/nav-item.html | 209 ++++-- en/theme/material/partials/nav.html | 10 +- 7 files changed, 912 insertions(+), 257 deletions(-) diff --git a/en/docs/assets/js/theme.js b/en/docs/assets/js/theme.js index 9ee33d40bf..7dfe5e829d 100644 --- a/en/docs/assets/js/theme.js +++ b/en/docs/assets/js/theme.js @@ -16,6 +16,137 @@ * under the License. */ +/* + * Run `fn` on first load and after every instant-loading navigation. + * + * Material exposes `document$`, an RxJS subject that emits the new document + * after each instant-loading swap. `DOMContentLoaded` fires only once, so any + * handler bound to it silently stops running after the first soft navigation. + * Falls back to DOMContentLoaded when instant loading is off or the bundle has + * not defined document$ yet. + */ +function onDocument(fn) { + if (typeof window.document$ !== 'undefined' && window.document$ && + typeof window.document$.subscribe === 'function') { + window.document$.subscribe(function () { fn(); }); + } else if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn); + } else { + fn(); + } +} + +/* + * Instant loading replaces [data-md-component=container], and the primary + * sidebar lives inside it (material/base.html), so the sidebar DOM is rebuilt + * on every soft navigation and loses its scroll offset and expanded sections. + * Capture both just before the swap and reapply them just after, so the + * sidebar appears untouched. + */ +(function () { + if (typeof window.document$ === 'undefined' || !window.document$) return; + + var saved = null; + // One-shot gate: only apply `saved` to the single document$ emission caused + // by the sidebar click that produced it. Without this, a later navigation + // that document$ also reports -- browser back/forward, or a click on an + // in-content link -- would silently reapply the same stale scroll/expansion + // state, which is worse than not restoring at all. + var pendingRestore = false; + + // Index of the top-level nav section the reader is currently inside. Used + // to tell whether the reader stayed within the same product section or + // jumped to a different one. + // + // "Not pruned" is NOT a usable test here: childless top-level pages (e.g. + // Overview, Get Started) are never pruned either, since pruning only + // applies to sections that have children (nav-item.html:150). So the first + // non-pruned item is almost always one of those childless pages, at a + // fixed low index (0 or 1) on every page regardless of which product + // section is active -- verified live: AI Gateway and Cloud pages both + // produced index 0 under that test. The one server-side marker that + // actually singles out the active section is `md-nav__item--active`, + // applied only to the top-level item the current page lives under + // (nav-item.html:77-79); a nav subtree (nested `ul.md-nav__list`) is kept + // as a fallback since only that same active section is ever rendered with + // one. + function activeSectionIndex() { + var items = document.querySelectorAll('.md-nav--primary > .md-nav__list > .md-nav__item'); + for (var i = 0; i < items.length; i++) { + if (items[i].className.indexOf('md-nav__item--active') !== -1) return i; + } + for (var j = 0; j < items.length; j++) { + if (items[j].querySelector('.md-nav__list')) return j; + } + return -1; + } + + function capture() { + var wrap = document.querySelector('.md-sidebar--primary .md-sidebar__scrollwrap'); + if (!wrap) return; + var open = []; + document.querySelectorAll('.md-nav--primary input.md-nav__toggle').forEach(function (t) { + if (t.checked && t.id) open.push(t.id); + }); + saved = { scrollTop: wrap.scrollTop, open: open, sectionIndex: activeSectionIndex() }; + pendingRestore = true; + } + + function restore() { + // Consume the gate immediately: whether or not there is anything usable + // to restore, this document$ emission must not leave a stale `saved` + // available for a later, unrelated emission (e.g. a subsequent + // back-button navigation) to pick up. + var doRestore = pendingRestore; + pendingRestore = false; + if (!doRestore || !saved) return; + var wrap = document.querySelector('.md-sidebar--primary .md-sidebar__scrollwrap'); + if (!wrap) return; + // Re-open what the reader had open, on top of the server's active chain. + // Ids belonging to a now-pruned section simply don't exist in the new + // document, so getElementById returns null and this is a no-op for them. + saved.open.forEach(function (id) { + var t = document.getElementById(id); + if (t && t.classList.contains('md-nav__toggle')) t.checked = true; + }); + // Only carry the scroll offset over when the reader is still inside the + // same top-level product section. Across a product change the sidebar's + // whole shape is different (a new section expands, the old one prunes to + // a single link), so an old pixel offset has no meaningful destination -- + // restoring it would scroll the reader somewhere arbitrary rather than + // somewhere they recognize. + if (saved.sectionIndex === -1 || saved.sectionIndex !== activeSectionIndex()) return; + // Toggling checkboxes changes the sidebar's scroll height, so the offset + // can only be applied once layout has settled. Setting it in the same + // frame lands the reader at the wrong place, or at 0 on a short sidebar. + // + // requestAnimationFrame is the correct way to wait for that -- it runs + // right before the next paint, once layout has settled -- but a hidden + // or occluded tab (including some automated test environments) can defer + // rAF indefinitely, silently dropping the restore. A setTimeout(fn, 0) + // macrotask settles layout just as well (proven against this exact + // capture/restore pair: a scrollTop of 320 landed exactly via a + // macrotask) and always fires regardless of tab visibility, so it runs + // as a fallback. A one-shot guard makes sure only whichever of the two + // fires first actually applies the offset; the other becomes a no-op. + var applied = false; + function applyScroll() { + if (applied) return; + applied = true; + wrap.scrollTop = saved.scrollTop; + } + requestAnimationFrame(applyScroll); + setTimeout(applyScroll, 0); + } + + // Capture on click, before the swap; restore after each new document. + document.addEventListener('click', function (e) { + if (e.target && e.target.closest && e.target.closest('.md-nav--primary a')) capture(); + }, true); + + window.document$.subscribe(function () { restore(); }); +})(); + // Initialize version dropdown function initVersionDropdown() { const dropdown = document.querySelector('.md-header__version-select-dropdown'); @@ -45,17 +176,17 @@ function initVersionDropdown() { } } -// Run after DOM is ready - single initialization only +// Run on every document ready - single initialization only if (typeof window.versionDropdownInitialized === 'undefined') window.versionDropdownInitialized = false; -document.addEventListener('DOMContentLoaded', function() { +onDocument(function () { if (!window.versionDropdownInitialized) { initVersionDropdown(); window.versionDropdownInitialized = true; } }); -// Wrap tabbed content and nav items in DOMContentLoaded -document.addEventListener('DOMContentLoaded', function() { +// Wrap tabbed content and nav items +onDocument(function () { // Add a class to content tabs that has multiple child elements rather than a code block document.querySelectorAll('.tabbed-content').forEach(tabbedContent => { const tabbedBlocks = Array.from(tabbedContent.querySelectorAll('.tabbed-block')); @@ -83,22 +214,6 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Expanded sections start with checked=true due to the vertical-line design. - // Fix: uncheck every non-active section toggle at any nesting depth (e.g. - // "Guides > Developer Portal") so only the section chain containing the - // current page stays expanded on load. - const nestedItems = document.querySelectorAll('.md-nav--primary .md-nav__item--nested'); - nestedItems.forEach(function(item) { - if (!item.classList.contains('md-nav__item--active')) { - const toggle = Array.from(item.children).find(function(el) { - return el.tagName === 'INPUT' && el.type === 'checkbox' && el.classList.contains('md-nav__toggle'); - }); - if (toggle) { - toggle.checked = false; - } - } - }); - // Menu items expand/collapse independently: expanding one item no longer // collapses its siblings. Each item stays open until the user collapses it. }); @@ -107,16 +222,16 @@ document.addEventListener('DOMContentLoaded', function() { * Handle opening external links in a new tab * and initialize JSON tree formatter */ -document.addEventListener('DOMContentLoaded', function() { +onDocument(function () { // Open external links in new tab var links = document.links; for (var i = 0, linksLength = links.length; i < linksLength; i++) { if (links[i].hostname != window.location.hostname) { links[i].target = "_blank"; links[i].setAttribute("rel", "noopener noreferrer"); - links[i].className += " externalLink"; + links[i].classList.add("externalLink"); } else { - links[i].className += " localLink"; + links[i].classList.add("localLink"); } } @@ -141,7 +256,7 @@ document.addEventListener('DOMContentLoaded', function() { }); // Set last visited valid page in session storage -window.addEventListener("DOMContentLoaded", function () { +onDocument(function () { // Check if the server indicated this page is valid const isPageValid = document.documentElement.getAttribute("data-page-valid") === "true"; @@ -150,14 +265,140 @@ window.addEventListener("DOMContentLoaded", function () { } }); -/* +/* * Reading versions + * ------------------------------------------------------------------------- + * NOTE: this block's target DOM -- #version-select-dropdown, + * #current-version-stable, .md-header__version-select-dropdown -- exists + * nowhere in en/theme/ or the built site. initVersionDropdown() (above), its + * latch, and applyVersionsData() below are therefore all unreachable in this + * theme. Left in place rather than deleted -- removing it is a larger scope + * decision than this fix covers. + * + * Writes both header content (#version-select-dropdown, survives an instant + * swap) and versions-page content (#current-version-stable and friends, + * replaced on every swap). It must therefore re-run on every navigation so + * the versions page populates when reached by a soft navigation. The + * versions.json XMLHttpRequest itself must not repeat on every navigation, + * though: a successful response is memoised in the module-level versionsData + * and re-applied from cache on a later page instead of being re-fetched, and + * a failed attempt (network error, or the 404 this endpoint actually returns + * here) is likewise latched in versionsLoadFailed -- a failure has no data to + * reapply, so retrying it on every soft navigation would only repeat the same + * console error forever, which is what this block used to do before this + * flag was added. versionsRequestPending guards the moment between issuing + * the request and it resolving, since this page loads theme.js twice (once + * in , before document$ exists, and once again in the footer, after it + * does) which can otherwise call this handler twice in quick succession on a + * cold load, before the first request has resolved. */ -if (typeof window.versionsLoaded === 'undefined') window.versionsLoaded = false; -window.addEventListener('DOMContentLoaded', function() { - if (window.versionsLoaded) return; - window.versionsLoaded = true; - +var versionsData = null; +var versionsRequestPending = false; +var versionsLoadFailed = false; + +// Apply a parsed versions.json payload to whichever elements are present on +// the current page: the header's version dropdown, the versions-page tables, +// or both. Called both right after a fresh fetch and, on a later navigation, +// with the memoised versionsData. +function applyVersionsData(data, docSetUrl) { + var dropdown = document.getElementById('version-select-dropdown'); + var checkVersionsPage = document.getElementById('current-version-stable'); + + /* + * Appending versions to the version selector dropdown + */ + if (dropdown) { + data.list.sort().forEach(function(key, index){ + var versionData = data.all[key]; + + if(versionData) { + var liElem = document.createElement('li'); + var docLinkType = data.all[key].doc.split(':')[0]; + var target = '_self'; + var url = data.all[key].doc; + + if ((docLinkType == 'https') || (docLinkType == 'http')) { + target = '_blank' + } + else { + url = docSetUrl + url; + } + var anchor = document.createElement('a'); + + anchor.setAttribute('href', url); + anchor.setAttribute('target', target); + anchor.textContent = key; + + liElem.appendChild(anchor); + + dropdown.insertBefore(liElem, dropdown.firstChild); + } + }); + + document.getElementById('show-all-versions-link') + .setAttribute('href', docSetUrl + 'versions'); + } + + /* + * Appending versions to the version tables in versions page + */ + if (checkVersionsPage) { + var previousVersions = []; + + Object.keys(data.all).forEach(function(key, index){ + if ((key !== data.current) && (key !== data['pre-release'])) { + var docLinkType = data.all[key].doc.split(':')[0]; + var target = '_self'; + + if ((docLinkType == 'https') || (docLinkType == 'http')) { + target = '_blank' + } + + previousVersions.push('' + + '' + key + '' + + '' + + 'Documentation' + + '' + + '' + + 'Release Notes' + + '' + + ''); + } + }); + + // --- Past releases update --- + var prevEl = document.getElementById('previous-versions'); + if (prevEl) { + prevEl.innerHTML = previousVersions.join(' '); + } + + // --- Current released version update --- + var currentNum = document.getElementById('current-version-number'); + if (currentNum) { + currentNum.textContent = data.current; // safer than innerHTML + } + + var docLink = document.getElementById('current-version-documentation-link'); + if (docLink) { + docLink.setAttribute('href', docSetUrl + data.all[data.current].doc); + } + + var notesLink = document.getElementById('current-version-release-notes-link'); + if (notesLink) { + notesLink.setAttribute('href', docSetUrl + data.all[data.current].notes); + } + + // --- Pre-release version update --- + var preRelLink = document.getElementById('pre-release-version-documentation-link'); + if (preRelLink) { + preRelLink.setAttribute('href', docSetUrl + 'next/'); + } + } +} + +onDocument(function () { var pageHeader = document.getElementById('page-header'); if (!pageHeader) return; var docSetLang = pageHeader.getAttribute('data-lang') == null ? 'en' : pageHeader.getAttribute('data-lang'); @@ -169,136 +410,91 @@ window.addEventListener('DOMContentLoaded', function() { } var docSetUrl = window.location.origin + '/' + docSetLang; - + + // Already have the data from an earlier navigation: re-apply it to this + // page's DOM without re-fetching versions.json. + if (versionsData) { + applyVersionsData(versionsData, docSetUrl); + return; + } + + // A previous attempt already failed this session (see the header comment + // above -- versions.json 404s in this theme): a single failed attempt is + // enough, don't repeat the request on every soft navigation. + if (versionsLoadFailed) return; + + // A request is already in flight (see the header comment above about + // theme.js loading twice on a cold load): don't issue a second one. + if (versionsRequestPending) return; + versionsRequestPending = true; + // Try to load from local first, fallback to remote var versionsUrl = docSetUrl + 'versions/assets/versions.json'; - + var request = new XMLHttpRequest(); request.open('GET', versionsUrl, true); - + // Add error handler request.onerror = function() { + versionsRequestPending = false; + versionsLoadFailed = true; console.error("Failed to load versions.json. CORS or network error."); // For development, you can add mock data here console.log("You can create a local versions.json file at: /en/versions/assets/versions.json"); }; request.onload = function() { + versionsRequestPending = false; if (request.status >= 200 && request.status < 400) { - var data; try { data = JSON.parse(request.responseText); } catch (e) { + versionsLoadFailed = true; console.error("Failed to parse versions.json:", e); return; } - var dropdown = document.getElementById('version-select-dropdown'); - var checkVersionsPage = document.getElementById('current-version-stable'); - - /* - * Appending versions to the version selector dropdown - */ - if (dropdown){ - data.list.sort().forEach(function(key, index){ - var versionData = data.all[key]; - - if(versionData) { - var liElem = document.createElement('li'); - var docLinkType = data.all[key].doc.split(':')[0]; - var target = '_self'; - var url = data.all[key].doc; - - if ((docLinkType == 'https') || (docLinkType == 'http')) { - target = '_blank' - } - else { - url = docSetUrl + url; - } - const anchor = document.createElement('a'); - - anchor.setAttribute('href', url); - anchor.setAttribute('target', target); - anchor.textContent = key; - - liElem.appendChild(anchor); - - dropdown.insertBefore(liElem, dropdown.firstChild); - } - }); - - document.getElementById('show-all-versions-link') - .setAttribute('href', docSetUrl + 'versions'); - } - - /* - * Appending versions to the version tables in versions page - */ - if (checkVersionsPage){ - var previousVersions = []; - - Object.keys(data.all).forEach(function(key, index){ - if ((key !== data.current) && (key !== data['pre-release'])) { - var docLinkType = data.all[key].doc.split(':')[0]; - var target = '_self'; - - if ((docLinkType == 'https') || (docLinkType == 'http')) { - target = '_blank' - } - - previousVersions.push('' + - '' + key + '' + - '' + - 'Documentation' + - '' + - '' + - 'Release Notes' + - '' + - ''); - } - }); - - /// --- Past releases update --- -const prevEl = document.getElementById('previous-versions'); -if (prevEl) { - prevEl.innerHTML = previousVersions.join(' '); -} - -// --- Current released version update --- -const currentNum = document.getElementById('current-version-number'); -if (currentNum) { - currentNum.textContent = data.current; // safer than innerHTML -} - -const docLink = document.getElementById('current-version-documentation-link'); -if (docLink) { - docLink.setAttribute('href', docSetUrl + data.all[data.current].doc); -} - -const notesLink = document.getElementById('current-version-release-notes-link'); -if (notesLink) { - notesLink.setAttribute('href', docSetUrl + data.all[data.current].notes); -} - -// --- Pre-release version update --- -const preRelLink = document.getElementById('pre-release-version-documentation-link'); -if (preRelLink) { - preRelLink.setAttribute('href', docSetUrl + 'next/'); -} - - } - - } else { + versionsData = data; + applyVersionsData(data, docSetUrl); + } else { + versionsLoadFailed = true; console.error("We reached our target server, but it returned an error"); - } + } }; request.send(); }); +// Lazily fetch the build-time version index (assets/version-index.json, see +// hooks.py). Shared across the per-section versioned nav below and the +// search breadcrumbs' version-scoping fallback further down, so the file +// only ever issues one fetch for it and both call sites see the same data. +// Memoised for the lifetime of the page load; never needed on the critical +// path of a normal page view. +// +// versionIndexData caches the parsed *payload* -- a network result, safe to +// reuse for every page for the rest of the session. It deliberately does NOT +// cache anything resolved from it (e.g. which version is "active"): that +// depends on the current URL/localStorage/DOM and can change every +// navigation. See getActiveVersions() below for why conflating the two was a +// bug (residual of Important 2). +var versionIndexPromise = null; +var versionIndexData = null; +function loadVersionIndex() { + if (!versionIndexPromise) { + var scope = window.__md_scope || + (document.querySelector('base') + ? new URL(document.querySelector('base').href) + : new URL('/', location)); + versionIndexPromise = fetch(new URL('assets/version-index.json', scope).href) + .then(function (r) { return r.ok ? r.json() : {}; }) + .then(function (data) { versionIndexData = data; return data; }) + .catch(function () { versionIndexData = {}; return versionIndexData; }); + } + return versionIndexPromise; +} + /* * Per-section versioned navigation * ------------------------------------------------------------------------- @@ -309,12 +505,12 @@ if (preRelLink) { * 2. Show only the active version's group. * 3. On change, keep the user on the equivalent page under the new version, * falling back to that version's overview when it does not exist. + * + * The section markup (.md-nav__item--versioned and its groups) lives inside + * the sidebar, which an instant-loading swap replaces on every navigation, so + * this must re-run on every document$ emission rather than latch to once. */ -if (typeof window.versionedNavInitialized === 'undefined') window.versionedNavInitialized = false; -document.addEventListener('DOMContentLoaded', function () { - if (window.versionedNavInitialized) return; - window.versionedNavInitialized = true; - +onDocument(function () { // Version-scoped root nav sections (extra.version_scoped_navs): rendered // hidden (see nav-item.html / _version-select.css), revealed while the // current URL contains the matching version as a path segment — e.g. @@ -337,10 +533,13 @@ document.addEventListener('DOMContentLoaded', function () { var storageKey = 'docVersion:' + slug; - // Available version values, in the order rendered. - var versions = groups.map(function (g) { - return g.getAttribute('data-md-version'); - }); + // Available version values, in configured order. Read from the section + // attribute rather than the rendered groups: only the active group is + // rendered, so the group list is not the version list. + var versionsAttr = section.getAttribute('data-md-versions') || ''; + var versions = versionsAttr + ? versionsAttr.split(',') + : groups.map(function (g) { return g.getAttribute('data-md-version'); }); // Resolve the active version: URL path segment wins, then stored // preference, then the configured default. @@ -362,15 +561,33 @@ document.addEventListener('DOMContentLoaded', function () { stored = null; } + // The server renders exactly one group per section and names it here. + // That rendered version is the single source of truth: it is what the + // reader is actually looking at, so the dropdown and search scoping must + // agree with it rather than with a URL/localStorage/default guess that + // can now disagree with the DOM (e.g. a stored preference on a + // non-versioned page, where the server has no URL hint and renders the + // configured default). Fall back to the old resolution order only if the + // attribute is missing. + var renderedVersion = section.getAttribute('data-md-active-version'); var active = + (renderedVersion && versions.indexOf(renderedVersion) !== -1 ? renderedVersion : null) || versionFromPath() || (versions.indexOf(stored) !== -1 ? stored : null) || (versions.indexOf(defaultVersion) !== -1 ? defaultVersion : versions[0]); function showVersion(version) { - groups.forEach(function (g) { - g.classList.toggle('is-active', g.getAttribute('data-md-version') === version); - }); + // Only the active version's group is rendered server-side. Never + // deactivate a group that has no sibling, or the section's subtopics + // vanish (this happens on non-versioned pages where localStorage holds + // a different version than the one the server rendered). + if (groups.length > 1) { + groups.forEach(function (g) { + g.classList.toggle('is-active', g.getAttribute('data-md-version') === version); + }); + } else if (groups.length === 1) { + groups[0].classList.add('is-active'); + } // A version can be active without being offered in the dropdown (e.g. // "next" exists in the nav but is reachable only by URL). Append it // under its own group so the select reflects the active version @@ -389,11 +606,13 @@ document.addEventListener('DOMContentLoaded', function () { unreleasedGroup.appendChild(option); } if (select.value !== version) select.value = version; - try { - window.localStorage.setItem(storageKey, version); - } catch (e) { - /* storage unavailable: selection simply won't persist */ - } + // Do not persist here: showVersion() runs on every page load (to sync + // the dropdown/groups to the rendered version), not only on an + // explicit reader choice. Persisting unconditionally would silently + // overwrite a stored preference with the server-rendered default the + // moment the reader views a non-versioned page. The dropdown's + // 'change' handler below is the only place that writes localStorage, + // since that is the reader's actual choice. } showVersion(active); @@ -416,34 +635,9 @@ document.addEventListener('DOMContentLoaded', function () { select.addEventListener('change', function () { var target = select.value; - var group = groupForVersion(target); - if (!group) return; - - var links = Array.prototype.slice.call(group.querySelectorAll('a[href]')); - if (links.length === 0) { - showVersion(target); - return; - } - - // Build the equivalent URL: same tail path, new version segment. Compare - // against each link's resolved pathname (hrefs in the nav are relative). var current = versionFromPath(); var tail = current ? pathTailAfterVersion(window.location.pathname, current) : null; - - var destination = null; - if (tail !== null) { - var wanted = ('/' + slug + '/' + target + '/' + tail).replace(/\/+$/, ''); - for (var i = 0; i < links.length; i++) { - var linkPath = new URL(links[i].href).pathname.replace(/\/+$/, ''); - if (linkPath.slice(-wanted.length) === wanted) { - destination = links[i].href; - break; - } - } - } - - // Fall back to the new version's overview (its first rendered link). - if (!destination) destination = links[0].href; + if (tail === null) tail = ''; // Persist before navigating so the new page restores the same version. try { @@ -451,7 +645,30 @@ document.addEventListener('DOMContentLoaded', function () { } catch (e) { /* ignore */ } - window.location.href = destination; + + var scope = window.__md_scope || + (document.querySelector('base') + ? new URL(document.querySelector('base').href) + : new URL('/', location)); + + loadVersionIndex().then(function (index) { + var entry = index[slug]; + var pages = (entry && entry.pages && entry.pages[target]) || []; + // No page data for this version (index fetch failed or the slug/ + // version is missing from it): don't guess a "//" URL, + // since unlike a real page tail that root is not guaranteed to + // exist. Just reflect the selection, mirroring the previous + // behaviour when a version's rendered group had no links. + if (pages.length === 0) { + showVersion(target); + return; + } + // Same page under the new version when it exists, else that version's + // first page (its overview) — mirrors the previous DOM-based fallback. + var chosen = pages.indexOf(tail) !== -1 ? tail : (pages[0] || ''); + window.location.href = + new URL(slug + '/' + target + '/' + chosen, scope).href; + }); }); }); }); @@ -470,9 +687,59 @@ document.addEventListener('DOMContentLoaded', function () { * who selected 1.0.0 only sees 1.0.0 hits (plus non-versioned results such * as Cloud / Guides). Active version is resolved exactly like the nav * selector: URL segment > localStorage > configured default. + * + * getActiveVersions() reads `.md-nav__item--versioned` DOM (populated by + * nav-item.html only for the section the reader is currently inside; Task 5 + * prunes every other level-1 section to a plain link with no data-md-* + * attributes at all). A page outside any versioned product -- or inside one, + * viewing a different product's results -- therefore has none of that DOM + * for some or all slugs, and falls back to the build-time version index + * instead (see getActiveVersions()). + * + * Nothing about *which version is active* is cached across calls -- only the + * fetched version-index.json payload is (see loadVersionIndex() above). An + * earlier version of this fix memoised the resolved active-version map + * itself; that let a fallback resolution computed while no versioned `
  • ` + * was in the DOM (URL/localStorage-derived) outlive the DOM actually + * appearing on a later decorate() pass, hiding the reader's own rendered + * version and showing a stale one instead. The rendered DOM must always win + * over a stored preference when both exist (ruling R16 on this branch), so + * every getActiveVersions() call re-checks the DOM first, from scratch. */ + +// Resolve the active version for `slug` the same way the nav selector does, +// minus the rendered-DOM step (there's no DOM to read here): URL path +// segment, then localStorage, then the configured default. +function resolveActiveVersionFromIndex(slug, versions, def) { + var parts = window.location.pathname.split('/').filter(Boolean); + var idx = parts.lastIndexOf(slug); + if (idx !== -1 && idx + 1 < parts.length && versions.indexOf(parts[idx + 1]) !== -1) { + return parts[idx + 1]; + } + try { + var stored = window.localStorage.getItem('docVersion:' + slug); + if (versions.indexOf(stored) !== -1) return stored; + } catch (e) { /* ignore */ } + return versions.indexOf(def) !== -1 ? def : versions[0]; +} + +// Build the { slug: { active, versions } } map straight from a fetched +// version-index.json payload, for pages with no versioned nav DOM to read. +function resolveActiveVersionsFromIndex(index) { + var resolved = {}; + Object.keys(index).forEach(function (slug) { + var entry = index[slug] || {}; + var versions = entry.versions || []; + resolved[slug] = { + active: resolveActiveVersionFromIndex(slug, versions, entry.default), + versions: versions + }; + }); + return resolved; +} + if (typeof window.searchBreadcrumbsInitialized === 'undefined') window.searchBreadcrumbsInitialized = false; -document.addEventListener('DOMContentLoaded', function () { +onDocument(function () { if (window.searchBreadcrumbsInitialized) return; window.searchBreadcrumbsInitialized = true; @@ -492,25 +759,60 @@ document.addEventListener('DOMContentLoaded', function () { var pending = false; // Resolve, per versioned product (slug), which version is "active" for this - // page. Reads the global versioned-nav DOM, mirroring the nav selector logic. - var activeVersions = null; + // page. Reads the global versioned-nav DOM, mirroring the nav selector + // logic, when that DOM exists on this page; falls back to the build-time + // version index otherwise (see the block comment above). function getActiveVersions() { - if (activeVersions) return activeVersions; - activeVersions = {}; - document.querySelectorAll('.md-nav__item--versioned').forEach(function (section) { + // Re-checked from scratch on every call -- see the block comment above + // for why the resolved map itself must never be cached across calls. + var sections = document.querySelectorAll('.md-nav__item--versioned'); + if (sections.length === 0) { + // Nothing rendered on this page for any versioned product (see the + // block comment above). Fall back to assets/version-index.json + // (hooks.py), which knows every slug's versions and default + // regardless of which page is currently on screen. + if (versionIndexData) { + // Already fetched (this session or an earlier call on this page): + // resolve synchronously so this decorate() pass is accurate now. + return resolveActiveVersionsFromIndex(versionIndexData); + } + // Not fetched yet: kick off (or reuse) the request and redecorate once + // it resolves. This call returns {} -- nothing hidden this round -- + // rather than block; by the time the fetch settles, a versioned `
  • ` + // may since have appeared on screen too, in which case the redecorate + // triggered here takes the DOM branch instead and this fetch's result + // is simply unused for that pass. + loadVersionIndex().then(function () { decorate(); }); + return {}; + } + var activeVersions = {}; + sections.forEach(function (section) { var slug = section.getAttribute('data-md-versioned-section'); var def = section.getAttribute('data-md-default-version'); if (!slug) return; - var versions = []; - section.querySelectorAll('.md-nav__version-group').forEach(function (g) { - var v = g.getAttribute('data-md-version'); - if (v) versions.push(v); - }); + // Read the full version list from the section attribute; only the active + // version's group is rendered, so walking groups would see just one. + var attr = section.getAttribute('data-md-versions') || ''; + var versions = attr ? attr.split(',') : []; + if (!versions.length) { + section.querySelectorAll('.md-nav__version-group').forEach(function (g) { + var v = g.getAttribute('data-md-version'); + if (v) versions.push(v); + }); + } + // The rendered version is authoritative (see the nav selector's + // identical resolution above): it is the version whose links are + // actually in the sidebar, so search scoping must filter for that, + // not for a URL/localStorage/default guess that can disagree with it. var active = null; - var parts = window.location.pathname.split('/').filter(Boolean); - var idx = parts.lastIndexOf(slug); - if (idx !== -1 && idx + 1 < parts.length && versions.indexOf(parts[idx + 1]) !== -1) { - active = parts[idx + 1]; + var rendered = section.getAttribute('data-md-active-version'); + if (rendered && versions.indexOf(rendered) !== -1) active = rendered; + if (!active) { + var parts = window.location.pathname.split('/').filter(Boolean); + var idx = parts.lastIndexOf(slug); + if (idx !== -1 && idx + 1 < parts.length && versions.indexOf(parts[idx + 1]) !== -1) { + active = parts[idx + 1]; + } } if (!active) { try { @@ -554,7 +856,11 @@ document.addEventListener('DOMContentLoaded', function () { } // True if the page key belongs to a non-active version (should be hidden). - function isHiddenVersion(key) { + // `activeVersionsSnapshot` is resolved once per decorate() pass (see + // below) rather than looked up per item -- getActiveVersions() itself is + // no longer memoised across calls, so calling it once per pass instead of + // once per result avoids re-scanning the DOM for every search hit. + function isHiddenVersion(key, activeVersionsSnapshot) { if (!key) return false; var parts = key.split('/'); if (parts.length < 2) return false; @@ -563,7 +869,7 @@ document.addEventListener('DOMContentLoaded', function () { if (getScopedVersions()[parts[0]]) { return window.location.pathname.split('/').indexOf(parts[0]) === -1; } - var cfg = getActiveVersions()[parts[0]]; + var cfg = activeVersionsSnapshot[parts[0]]; if (!cfg) return false; if (cfg.versions.indexOf(parts[1]) === -1) return false; // not a version segment return parts[1] !== cfg.active; @@ -573,13 +879,16 @@ document.addEventListener('DOMContentLoaded', function () { if (!breadcrumbs) return; var items = output.querySelectorAll('.md-search-result__item'); var visible = 0; + // Resolved fresh for this pass -- see getActiveVersions() and the block + // comment above it for why this must never be cached across passes. + var activeVersionsSnapshot = getActiveVersions(); items.forEach(function (item) { var link = item.querySelector('.md-search-result__link'); if (!link) return; var key = keyForHref(link.getAttribute('href') || link.href); // 1. Version scoping: hide results from non-active versions. - var hide = isHiddenVersion(key); + var hide = isHiddenVersion(key, activeVersionsSnapshot); item.style.display = hide ? 'none' : ''; if (!hide) visible++; diff --git a/en/hooks.py b/en/hooks.py index b3eb11c2b8..aa2ff7dedc 100644 --- a/en/hooks.py +++ b/en/hooks.py @@ -14,6 +14,12 @@ # results UI can show which doc set / version a result belongs to. _breadcrumbs: dict[str, list[str]] = {} +# Per-slug index of which page tails exist in which version. Populated in +# on_nav; written to a JSON asset in on_post_build so the sidebar's version +# dropdown can resolve an equivalent destination without needing every +# version's nav rendered into the DOM. +_version_index = {} + def _file_hash(path: str) -> str: """Return the first 8 hex characters of the MD5 hash of a file's content.""" @@ -53,6 +59,27 @@ def on_pre_build(config, **kwargs): _theme_css_version = hashlib.md5(combined).hexdigest()[:8] +def _has_reachable_page(item): + """Return True if `item` (a mkdocs nav Section/Page/Link) resolves to a + URL, or anything in its subtree does. + + Mirrors first_page_url() in theme/material/partials/nav-item.html, which + walks a level-1 section's subtree depth-first looking for the first page + to link a pruned section to (see the "Level-1 sections the reader is not + currently inside render as a single link" branch there). If nothing in + the subtree resolves, that macro returns "", `| trim` leaves it falsy, + and the `{% if target %}` guard silently drops the entire
  • -- i.e. + the whole product/section vanishes from the sidebar, the only product + switcher on this site (navigation.tabs is off; there is no header nav). + """ + if getattr(item, "url", None): + return True + for child in getattr(item, "children", None) or []: + if _has_reachable_page(child): + return True + return False + + def on_nav(nav, config, files): """Build a URL -> breadcrumb map from the navigation tree. @@ -60,6 +87,108 @@ def on_nav(nav, config, files): ["API Gateway", "1.1.0", "Policies"]). This is used to disambiguate search results that share the same title across versions / doc sets. """ + versioned_sections = config["extra"].get("versioned_sections") or {} + + # A level-1 section with children but no reachable page anywhere in its + # subtree would silently disappear from the sidebar the moment the reader + # isn't inside it (see _has_reachable_page's docstring). Checking once + # here, over the whole nav tree, catches it for every page in a single + # pass instead of only when a reader happens to land elsewhere and the + # per-page pruning branch in nav-item.html silently no-ops. A loud build + # failure that names the section beats a product quietly missing from + # navigation. + for item in nav.items: + if getattr(item, "children", None) and not _has_reachable_page(item): + raise ValueError( + f"Navigation section {item.title!r} has children but no " + "reachable page anywhere in its subtree. " + "theme/material/partials/nav-item.html's first_page_url() " + "would return an empty target for it, and its pruning " + "branch would then silently drop this section from the " + "sidebar -- the only product switcher on this site. Add a " + "page under this section, or remove the section from the " + "navigation." + ) + + # The whole-subtree check above is not enough for a versioned + # section: nav-item.html's pruning branch does not link to just any + # reachable page in the subtree, it specifically calls + # first_page_url(default_version_group) -- the child titled + # versioned_cfg.default. A different version elsewhere in the same + # section (e.g. "next") can be fully reachable, which satisfies the + # check above, while the default version's own subtree is empty, + # which still yields an empty target and a silently dropped
  • . + # Check that specific child directly. (A default title with no + # matching child at all is a different, config-vs-nav mismatch -- + # see the versioned_sections cross-check below -- so this is a + # no-op here rather than a duplicate error.) + versioned_cfg = versioned_sections.get(getattr(item, "title", None)) + if versioned_cfg: + default_title = versioned_cfg.get("default") + default_child = next( + ( + child + for child in getattr(item, "children", None) or [] + if getattr(child, "title", None) == default_title + ), + None, + ) + if default_child is not None and not _has_reachable_page(default_child): + raise ValueError( + f"Navigation section {item.title!r}'s default version " + f"group {default_title!r} (extra.versioned_sections." + f"{item.title!r}.default) has no reachable page " + "anywhere in its subtree. nav-item.html's pruning " + "branch links a pruned copy of this section to " + "first_page_url(default_version_group), so an " + "unreachable default version leaves that link empty " + "and the section's
  • is silently dropped for every " + "reader not currently inside it. Add a page under this " + "version, or change extra.versioned_sections's " + "'default'." + ) + + # Cross-check extra.versioned_sections against the nav tree itself. + # Version-group titles are literal YAML nav keys, so a mismatch (a typo, + # a trailing space, "1.3" vs "1.3.0") is silent everywhere else: a + # missing default falls back to the section's first child ("next" for + # every product here), and a missing/renamed version simply never + # matches in the URL-matching loop, leaving zero version groups + # rendered. Both are green builds. Catch them here instead. + nav_items_by_title = { + getattr(item, "title", None): item for item in nav.items + } + for section, cfg in versioned_sections.items(): + nav_item = nav_items_by_title.get(section) + if nav_item is None: + continue + child_titles = [ + getattr(child, "title", None) + for child in getattr(nav_item, "children", None) or [] + ] + configured_versions = cfg.get("versions") or [] + missing_versions = [v for v in configured_versions if v not in child_titles] + if missing_versions: + raise ValueError( + f"extra.versioned_sections.{section!r} configures version(s) " + f"{missing_versions!r} that do not exist as a child nav " + f"title under the {section!r} section (found " + f"{child_titles!r}). Version-group titles are literal nav " + "keys, so this must be a typo in mkdocs.yml or the nav " + "tree -- fix whichever one is wrong." + ) + default = cfg.get("default") + if default not in child_titles: + raise ValueError( + f"extra.versioned_sections.{section!r}.default is " + f"{default!r}, which is not among the {section!r} " + f"section's child nav titles (found {child_titles!r}). " + "nav-item.html falls back to the section's first child " + "when this doesn't match, silently pointing every pruned " + "link at that child (typically the unreleased 'next' " + "version) instead of the configured default." + ) + _breadcrumbs.clear() for page in nav.pages: crumbs = [] @@ -70,9 +199,93 @@ def on_nav(nav, config, files): item = item.parent if page.url and crumbs: _breadcrumbs[page.url] = crumbs + + # Build a per-slug index of which page tails exist in which version, so the + # sidebar's version dropdown can resolve an equivalent destination without + # needing every version's nav rendered into the DOM. See theme.js. + _version_index.clear() + for section, cfg in versioned_sections.items(): + slug = cfg.get("slug") + if not slug: + raise ValueError( + f"extra.versioned_sections.{section!r} in mkdocs.yml has no " + f"'slug' (got {slug!r}); the version index cannot key this " + "section without one." + ) + versions = cfg.get("versions") or [] + if not versions: + raise ValueError( + f"extra.versioned_sections.{section!r} (slug {slug!r}) in " + f"mkdocs.yml has an empty or missing 'versions' list (got " + f"{cfg.get('versions')!r}); its version dropdown would have " + "nowhere to navigate." + ) + default = cfg.get("default") + if default not in versions: + raise ValueError( + f"extra.versioned_sections.{section!r} (slug {slug!r}) in " + f"mkdocs.yml has 'default' {default!r} which is not a member " + f"of 'versions' {versions!r}." + ) + slug_to_cfg = { + cfg["slug"]: cfg for cfg in versioned_sections.values() if cfg.get("slug") + } + for slug, cfg in slug_to_cfg.items(): + released = list(cfg.get("versions") or []) + # "next" is rendered as its own version group in the nav (see + # nav-item.html) even though it is deliberately absent from the + # config's `versions` list, so it never appears as a dropdown + #