PageSourceSearch

https://www.envoyproxy.io/docs/envoy/latest/_static/js/envoy/nav.js

js envoyproxy.io collected 2026-09-24 07:36:03 UTC 8,625 bytes, 252 lines download raw bytes

1/**
2 * Sidebar navigation: an accordion over the toctree captions, plus the mobile
3 * drawer.
4 *
5 * The Envoy tree has nine top-level areas and thousands of pages behind them.
6 * sphinx_rtd_theme renders every caption and its subtree at the same visual
7 * weight, which gives no sense of place. Here each caption becomes a
8 * <details> and only the section containing the current page starts open.
9 */
10
11const DESKTOP_QUERY = '(max-width: 960px)';
12
13/**
14 * A line icon for each top-level area.
15 *
16 * Keyed on the link target rather than the label: the real tree has no toctree
17 * captions, just nine `toctree-l1` links, and their titles are prose that gets
18 * reworded far more often than their paths move. Anything unmatched simply has
19 * no icon, which is why the fallback is nothing rather than a generic glyph.
20 */
21const AREA_ICONS = {
22  about_docs: '<path d="M8 4.6v8"/><path d="M2.5 3.4h4A1.5 1.5 0 0 1 8 4.6v8a1.5 1.5 0 0 0-1.5-1.1h-4z"/><path d="M13.5 3.4h-4A1.5 1.5 0 0 0 8 4.6v8a1.5 1.5 0 0 1 1.5-1.1h4z"/>',
23  intro: '<circle cx="8" cy="8" r="6"/><path d="M8 7.3v4.1M8 4.8v.2"/>',
24  start: '<circle cx="8" cy="8" r="6"/><path d="M6.6 5.5l4.1 2.5-4.1 2.5z"/>',
25  configuration: '<path d="M2 5.2h12M2 10.8h12"/><circle cx="6" cy="5.2" r="1.6"/><circle cx="10.4" cy="10.8" r="1.6"/>',
26  operations: '<path d="M2.4 12a5.9 5.9 0 1 1 11.2 0"/><path d="M8 12l2.9-3.4"/>',
27  extending: '<path d="M2.6 2.6h4.6v4.6H2.6z"/><path d="M8.8 2.6h4.6v4.6H8.8z"/><path d="M8.8 8.8h4.6v4.6H8.8z"/>',
28  api: '<path d="M6.1 2.6C4.6 2.6 4.6 8 3.1 8c1.5 0 1.5 5.4 3 5.4"/><path d="M9.9 2.6c1.5 0 1.5 5.4 3 5.4-1.5 0-1.5 5.4-3 5.4"/>',
29  faq: '<circle cx="8" cy="8" r="6"/><path d="M6.3 6.4a1.75 1.75 0 1 1 2.3 1.7c-.4.2-.6.5-.6.9v.3"/><path d="M8 11.6v.1"/>',
30  version_history: '<circle cx="8" cy="8" r="6"/><path d="M8 4.7V8l2.4 1.5"/>',
31};
32
33/**
34 * The first meaningful path segment of a top-level link.
35 *
36 * Sidebar hrefs are relative to the current page, so from deep in the API tree
37 * they arrive as `../../start/start.html`; the leading hops have to come off
38 * before the segment means anything.
39 */
40function areaOf(href) {
41  const path = href.split(/[?#]/)[0].replace(/^(?:\.\.?\/)+/, '');
42  const [first, ...rest] = path.split('/');
43  return rest.length ? first : first.replace(/\.html$/, '');
44}
45
46/** Prefixes each top-level entry with its area icon. */
47function addIcons(menu) {
48  menu.querySelectorAll(':scope > ul > li.toctree-l1 > a[href]').forEach((link) => {
49    const paths = AREA_ICONS[areaOf(link.getAttribute('href'))];
50    if (!paths || link.querySelector('.envoy-nav-icon')) {
51      return;
52    }
53
54    const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
55    icon.setAttribute('class', 'envoy-nav-icon');
56    icon.setAttribute('viewBox', '0 0 16 16');
57    icon.setAttribute('width', '14');
58    icon.setAttribute('height', '14');
59    icon.setAttribute('fill', 'none');
60    icon.setAttribute('stroke', 'currentColor');
61    icon.setAttribute('stroke-width', '1.4');
62    icon.setAttribute('stroke-linecap', 'round');
63    icon.setAttribute('aria-hidden', 'true');
64    icon.innerHTML = paths;
65
66    link.prepend(icon);
67    link.classList.add('envoy-nav-area');
68  });
69}
70
71/**
72 * Every entry in the API tree ends in "(proto)", which distinguishes none of
73 * them and costs a wrapped line on the long ones. The full title stays in the
74 * tooltip; the page's own heading is untouched.
75 */
76function trimTitles(menu) {
77  menu.querySelectorAll('a[href]').forEach((link) => {
78    const text = link.textContent.trim();
79    if (!text.endsWith('(proto)')) {
80      return;
81    }
82
83    link.title = text;
84    const trimmed = text.replace(/\s*\(proto\)$/, '');
85    const node = Array.from(link.childNodes)
86      .reverse()
87      .find((child) => child.nodeType === Node.TEXT_NODE && child.textContent.includes('(proto)'));
88
89    if (node) {
90      node.textContent = node.textContent.replace(/\s*\(proto\)\s*$/, '');
91    } else {
92      link.textContent = trimmed;
93    }
94  });
95}
96
97/** Groups each `p.caption` and the `ul` that follows it into a disclosure. */
98function buildSections(menu) {
99  const captions = Array.from(menu.querySelectorAll(':scope > p.caption'));
100  if (!captions.length) {
101    return;
102  }
103
104  captions.forEach((caption) => {
105    const list = caption.nextElementSibling;
106    if (!list || list.tagName !== 'UL') {
107      return;
108    }
109
110    const label = caption.textContent.trim();
111    const details = document.createElement('details');
112    details.className = 'envoy-nav-section';
113
114    const summary = document.createElement('summary');
115    summary.textContent = label;
116    details.append(summary);
117
118    caption.replaceWith(details);
119    details.append(list);
120
121    // Open the section holding the current page; if the reader is on a page
122    // that is in no toctree, leave the first section open as a 
122starting point.
123    if (list.querySelector('.current')) {
124      details.open = true;
125    }
126  });
127
128  const sections = Array.from(menu.querySelectorAll('.envoy-nav-section'));
129  if (sections.length && !sections.some((section) => section.open)) {
130    sections[0].open = true;
131  }
132}
133
134function focusable(container) {
135  return Array.from(container.querySelectorAll(
136    'a[href], button:not([disabled]), input:not([disabled]), summary, ' +
137    '[tabindex]:not([tabindex="-1"])'
138  )).filter((element) => !element.hidden && element.offsetParent !== null);
139}
140
141export function init() {
142  const menu = document.querySelector('.wy-menu-vertical');
143  if (menu) {
144    // The published tree has no toctree captions — it is a flat list of nine
145    // `toctree-l1` links — so this is a no-op there and only groups builds that
146    // do use captions.
147    buildSections(menu);
148    addIcons(menu);
149    trimTitles(menu);
150
151    // Keep the current page in view when the sidebar is taller than the pane.
152    const current = menu.querySelector('a.current');
153    if (current) {
154      window.requestAnimationFrame(() => {
155        current.scrollIntoView({block: 'nearest'});
156      });
157    }
158  }
159
160  const toggle = document.querySelector('[data-envoy-nav-toggle]');
161  const sidebar = document.querySelector('.wy-nav-side');
162  const backdrop = document.querySelector('.envoy-nav-backdrop');
163  const content = document.querySelector('.wy-nav-content-wrap');
164  const topbar = document.querySelector('.envoy-doc-topbar');
165  const mobile = window.matchMedia(DESKTOP_QUERY);
166
167  if (!toggle || !sidebar || !backdrop) {
168    return;
169  }
170
171  sidebar.id = sidebar.id || 'envoy-doc-sidebar';
172
173  let open = false;
174  let returnFocus = null;
175
176  function setOpen(next, restoreFocus) {
177    open = next && mobile.matches;
178    document.body.classList.toggle('envoy-nav-open', open);
179    toggle.setAttribute('aria-expanded', String(open));
180    toggle.setAttribute(
181      'aria-label', open ? 'Close documentation navigation' :
182        'Open documentation navigation');
183    backdrop.hidden = !open;
184
185    const hidden = mobile.matches && !open;
186    sidebar.inert = hidden;
187    sidebar.toggleAttribute('aria-hidden', hidden);
188
189    [content, topbar].forEach((element) => {
190      if (!element) {
191        return;
192      }
193      element.inert = open;
194      element.toggleAttribute('aria-hidden', open);
195    });
196
197    if (open) {
198      returnFocus = document.activeElement;
199      const targets = focusable(sidebar);
200      if (targets.length) {
201        window.requestAnimationFrame(() => targets[0].focus());
202      }
203    } else if (restoreFocus && returnFocus instanceof HTMLElement) {
204      returnFocus.focus();
205    }
206  }
207
208  setOpen(false, false);
209
210  toggle.addEventListener('click', () => setOpen(!open, true));
211
212  document.querySelectorAll('[data-envoy-nav-close]').forEach((button) => {
213    button.addEventListener('click', () => setOpen(false, true));
214  });
215
216  sidebar.addEventListener('click', (event) => {
217    if (event.target.closest('a[href]') && mobile.matches) {
218      setOpen(false, false);
219    }
220  });
221
222  document.addEventListener('keydown', (event) => {
223    if (!open) {
224      return;
225    }
226
227    if (event.key === 'Escape') {
228      event.preventDefault();
229      setOpen(false, true);
230      return;
231    }
232
233    if (event.key === 'Tab') {
234      const targets = focusable(sidebar);
235      if (!targets.length) {
236        return;
237      }
238
239      const first = targets[0];
240      const last = targets[targets.length - 1];
241      if (event.shiftKey && document.activeElement === first) {
242        event.preventDefault();
243        last.focus();
244      } else if (!event.shiftKey && document.activeElement === last) {
245        event.preventDefault();
246        first.focus();
247      }
248    }
249  });
250
251  mobile.addEventListener('change', () => setOpen(false, false));
252}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.