From 3f12520426e6b94b8251683df2be65355c0aeffe Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Mon, 1 Jun 2026 01:19:36 -0300 Subject: [PATCH] fix(docs): activate toc scrollspy --- scripts/docs-site/assets.mjs | 8 ++++- scripts/docs-site/smoke.mjs | 9 +++++ scripts/docs-site/visual-smoke.mjs | 55 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/scripts/docs-site/assets.mjs b/scripts/docs-site/assets.mjs index 7312de249..b1341c7c6 100644 --- a/scripts/docs-site/assets.mjs +++ b/scripts/docs-site/assets.mjs @@ -58,7 +58,12 @@ function pageMarkdownForCopy(control){const source=control.closest(".article")?. function copyPageMarkdown(control){return copyText(pageMarkdownForCopy(control),control)} function handleDocsControlClick(e){const copyCode=e.target.closest("[data-code-copy]");if(copyCode){copyText(codeTextForCopy(copyCode.closest(".oc-code")),copyCode);return true}const copyPrompt=e.target.closest("[data-prompt-copy]");if(copyPrompt){const prompt=copyPrompt.closest(".oc-prompt")?.textContent?.replace(/Copy\\s*$/,"").trim()||"";copyText(prompt,copyPrompt);return true}const copyPage=e.target.closest("[data-copy-page]");if(copyPage){copyPageMarkdown(copyPage);return true}const headingAnchor=e.target.closest("[data-heading-anchor]");if(headingAnchor){const url=new URL(location.href);url.hash=headingAnchor.dataset.headingAnchor||"";copyText(url.href,headingAnchor);return true}const feedback=e.target.closest("[data-feedback-value]");if(feedback){const result=feedback.closest(".page-feedback")?.querySelector("[data-feedback-result]");if(result)result.value=feedback.dataset.feedbackValue==="yes"?"Thanks.":"Noted.";return true}return false} function scrollTarget(hash){if(hash){document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView()}else{scrollTo(0,0)}} -async function navigateTo(url,replace=false){if(navigating)return false;navigating=true;closeLanguage();try{const res=await fetch(url.href,{credentials:"same-origin"});if(!res.ok||!res.headers.get("content-type")?.includes("text/html"))return false;const nextDoc=new DOMParser().parseFromString(await res.text(),"text/html");if(!nextDoc.querySelector(".main"))return false;syncSidebar(nextDoc);swap(".header-left",nextDoc);swapTabs(nextDoc);swap(".main",nextDoc);syncStickyHeaderOffset();syncTocDisclosure();initCodeGroups();initMermaid();document.title=nextDoc.title;history[replace?"replaceState":"pushState"]({docs:true},"",url.href);setNavOpen(false);scrollTarget(url.hash);return true}catch{return false}finally{navigating=false}} +let tocObserver=null;let tocScrollHandler=null; +function tocLinkId(link){try{return decodeURIComponent(link.hash.slice(1))}catch{return link.hash.slice(1)}} +function setActiveTocLink(id){const toc=document.querySelector(".toc");if(!toc)return;let active=null;toc.querySelectorAll('a[href^="#"]').forEach(link=>{const on=Boolean(id&&tocLinkId(link)===id);link.classList.toggle("active",on);if(on)active=link});active?.scrollIntoView({block:"nearest"})} +function currentTocHeadingId(headings){const top=parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop)||120;const scroller=document.scrollingElement||document.documentElement;if(scroller.scrollTop+innerHeight>=scroller.scrollHeight-2)return headings.at(-1)?.id||"";let current=headings[0];for(const heading of headings){if(heading.getBoundingClientRect().top<=top+1)current=heading;else break}return current?.id||""} +function initTocScrollspy(){tocObserver?.disconnect();tocObserver=null;if(tocScrollHandler){removeEventListener("scroll",tocScrollHandler);tocScrollHandler=null;}const toc=document.querySelector(".toc");if(!toc)return;const links=[...toc.querySelectorAll('a[href^="#"]')];const headings=[...document.querySelectorAll(".doc h2[id],.doc h3[id]")].filter(heading=>links.some(link=>tocLinkId(link)===heading.id));links.forEach(link=>link.classList.remove("active"));if(!headings.length)return;const sync=()=>setActiveTocLink(currentTocHeadingId(headings));let tocScrollRaf=0;tocScrollHandler=()=>{cancelAnimationFrame(tocScrollRaf);tocScrollRaf=requestAnimationFrame(sync)};addEventListener("scroll",tocScrollHandler,{passive:true});requestAnimationFrame(sync);if(!("IntersectionObserver" in window)){sync();return}tocObserver=new IntersectionObserver(sync,{rootMargin:"-120px 0px -70% 0px",threshold:[0,1]});headings.forEach(heading=>tocObserver.observe(heading))} +async function navigateTo(url,replace=false){if(navigating)return false;navigating=true;closeLanguage();try{const res=await fetch(url.href,{credentials:"same-origin"});if(!res.ok||!res.headers.get("content-type")?.includes("text/html"))return false;const nextDoc=new DOMParser().parseFromString(await res.text(),"text/html");if(!nextDoc.querySelector(".main"))return false;syncSidebar(nextDoc);swap(".header-left",nextDoc);swapTabs(nextDoc);swap(".main",nextDoc);syncStickyHeaderOffset();syncTocDisclosure();initCodeGroups();initMermaid();document.title=nextDoc.title;history[replace?"replaceState":"pushState"]({docs:true},"",url.href);setNavOpen(false);scrollTarget(url.hash);initTocScrollspy();return true}catch{return false}finally{navigating=false}} function openSearch(){modal?.classList.add("open");setTimeout(()=>input?.focus(),0);pagefindReady ||= import(withBase("/pagefind/pagefind.js")).then(m=>m.init?.().then?.(()=>m)??m)} function escapeHtml(text){return String(text).replace(/[&<>"']/g,ch=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[ch]))} function trimTrailingPunctuation(value){return value.replace(/[.,;!?]+$/,"")} @@ -114,6 +119,7 @@ initChat(); initCodeGroups(); initMermaid(); scrollActiveNavLink(); +initTocScrollspy(); document.addEventListener("click",async e=>{const pageActions=e.target.closest(".page-actions");document.querySelectorAll(".page-actions-more[open]").forEach(menu=>{if(!pageActions?.contains(menu))menu.removeAttribute("open")});const toc=e.target.closest(".toc");document.querySelectorAll(".toc[open]").forEach(menu=>{if(compactTocQuery.matches&&!toc?.contains(menu))menu.open=false});if(e.target.closest(".toc a")&&compactTocQuery.matches)e.target.closest(".toc").open=false;if(handleDocsControlClick(e))return;const theme=e.target.closest("[data-theme-toggle]");if(theme){const next=root.dataset.theme==="dark"?"light":"dark";root.dataset.theme=next;localStorage.setItem("theme",next);initMermaid(true);return}const trigger=e.target.closest("[data-language-trigger]");if(trigger){e.stopPropagation();toggleLanguage();return}const picker=document.querySelector("[data-language-picker]");if(picker&&!picker.contains(e.target))closeLanguage();const navToggle=e.target.closest("[data-nav-toggle]");if(navToggle){setNavOpen(!document.body.classList.contains("nav-open"));return}if(e.target.closest("[data-nav-close]")){setNavOpen(false);return}if(document.body.classList.contains("nav-open")&&!e.target.closest(".sidebar")){setNavOpen(false);return}if(e.target.closest("[data-search-open]")){openSearch();return}if(e.target.closest("[data-search-close]")){modal?.classList.remove("open");return}const link=e.target.closest("a[href]");if(!link)return;if(link.closest("[data-language-picker]"))return;if(link.target||link.download||!isPlainLeftClick(e))return;const url=new URL(link.href,location.href);if(url.pathname===location.pathname&&url.search===location.search&&url.hash)return;if(!isDocsPage(url))return;e.preventDefault();modal?.classList.remove("open");const ok=await navigateTo(url);if(!ok)location.href=url.href}); modal?.addEventListener("click",e=>{if(e.target===modal)modal.classList.remove("open")});addEventListener("keydown",e=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="k"){e.preventDefault();openSearch()}if(e.key==="Escape"){modal?.classList.remove("open");document.querySelectorAll(".page-actions-more[open]").forEach(menu=>menu.removeAttribute("open"));document.querySelectorAll(".toc[open]").forEach(menu=>{if(compactTocQuery.matches)menu.open=false});closeLanguage();setNavOpen(false)}}); addEventListener("popstate",()=>navigateTo(new URL(location.href),true)); diff --git a/scripts/docs-site/smoke.mjs b/scripts/docs-site/smoke.mjs index 97d153b38..22098eac3 100644 --- a/scripts/docs-site/smoke.mjs +++ b/scripts/docs-site/smoke.mjs @@ -300,6 +300,15 @@ if (!/\.toc\{position:fixed;left:calc\(24px \+ 220px \+ 34px\);top:calc\(var\(-- || !/\.toc\[open\] nav\{display:grid;gap:2px\}/.test(siteCss)) { throw new Error("assets: compact table of contents dropdown is missing for mid-width pages"); } +if (!/let tocObserver=null/.test(siteJs) + || !/function initTocScrollspy/.test(siteJs) + || !/new IntersectionObserver/.test(siteJs) + || !/rootMargin:"-120px 0px -70% 0px"/.test(siteJs) + || !/scroller\.scrollTop\+innerHeight>=scroller\.scrollHeight-2/.test(siteJs) + || !/scrollTarget\(url\.hash\);initTocScrollspy\(\)/.test(siteJs) + || !/scrollActiveNavLink\(\);\s*initTocScrollspy\(\);\s*document\.addEventListener\("click"/.test(siteJs)) { + throw new Error("assets: table-of-contents scrollspy is missing"); +} if (!/function setNavOpen/.test(siteJs) || !/body\.nav-open:before/.test(siteCss) || !/data-nav-close/.test(index)) { throw new Error("assets: mobile navigation drawer state is missing"); } diff --git a/scripts/docs-site/visual-smoke.mjs b/scripts/docs-site/visual-smoke.mjs index 673661ef5..237af6fe3 100644 --- a/scripts/docs-site/visual-smoke.mjs +++ b/scripts/docs-site/visual-smoke.mjs @@ -35,6 +35,7 @@ const browser = await chromium.launch({ headless: true }); try { await checkDesktop(); + await checkTocScrollspy(); await checkAmbientCodePage(); await checkMobile(); await checkLightMode(); @@ -376,6 +377,60 @@ async function checkDesktop() { await page.close(); } +async function checkTocScrollspy() { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.goto(`${base}/channels/discord`, { waitUntil: "networkidle" }); + await page.locator(".toc a").first().waitFor({ state: "visible" }); + const initial = await page.evaluate(() => ({ + active: [...document.querySelectorAll(".toc a.active")].map((link) => link.hash), + tocCount: document.querySelectorAll(".toc a").length, + })); + if (initial.tocCount < 4 || initial.active.length !== 1) { + throw new Error(`toc initial active state failed: ${JSON.stringify(initial)}`); + } + + const scrolled = await scrollToTocItem(page, 4); + if (!scrolled || scrolled.activeHash !== scrolled.expectedHash || scrolled.scrollY < 700) { + throw new Error(`toc scrollspy failed on long page: ${JSON.stringify(scrolled)}`); + } + + await page.click('a.nav-link[href$="/channels/telegram"]'); + await page.waitForURL("**/channels/telegram"); + await page.locator(".toc a").first().waitFor({ state: "visible" }); + const afterPjax = await scrollToTocItem(page, 2); + if (!afterPjax + || afterPjax.pathname !== "/channels/telegram" + || afterPjax.activeHash !== afterPjax.expectedHash + || afterPjax.activeCount !== 1) { + throw new Error(`toc scrollspy failed after PJAX navigation: ${JSON.stringify(afterPjax)}`); + } + await page.close(); +} + +async function scrollToTocItem(page, index) { + const expected = await page.evaluate((targetIndex) => { + const links = [...document.querySelectorAll(".toc a")]; + const items = links + .map((link) => ({ hash: link.hash, id: decodeURIComponent(link.hash.slice(1)) })) + .filter((item) => item.id && document.getElementById(item.id)); + const item = items[Math.min(targetIndex, items.length - 1)]; + document.getElementById(item?.id)?.scrollIntoView(); + return item?.hash ?? null; + }, index); + if (!expected) return null; + await page.waitForFunction((hash) => [...document.querySelectorAll(".toc a.active")].some((link) => link.hash === hash), expected); + return page.evaluate((expectedHash) => { + const active = [...document.querySelectorAll(".toc a.active")]; + return { + expectedHash, + activeHash: active[0]?.hash ?? null, + activeCount: active.length, + pathname: location.pathname, + scrollY, + }; + }, expected); +} + async function checkMobile() { const page = await browser.newPage({ viewport: { width: 390, height: 980 }, isMobile: true }); await page.goto(`${base}/__elements`, { waitUntil: "networkidle" });