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('
');
- }
- });
-
- /// --- 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
+ #