From 69cb2d9c35e1c465d9178278b88aa2946e76b018 Mon Sep 17 00:00:00 2001 From: razeprasine Date: Tue, 1 Sep 2026 10:43:38 +0100 Subject: [PATCH 1/4] Add a link checker to the docs pipeline --- .github/workflows/docs-external-links.yml | 86 +++++ apps/docs/link-ignore.json | 10 + apps/docs/scripts/check-links.ts | 376 ++++++++++++++++++++-- 3 files changed, 441 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/docs-external-links.yml create mode 100644 apps/docs/link-ignore.json diff --git a/.github/workflows/docs-external-links.yml b/.github/workflows/docs-external-links.yml new file mode 100644 index 00000000..6b07addd --- /dev/null +++ b/.github/workflows/docs-external-links.yml @@ -0,0 +1,86 @@ +name: Docs External Links + +on: + schedule: + - cron: "0 3 * * 1" # weekly Monday 03:00 UTC + workflow_dispatch: + +permissions: + issues: write + contents: read + +concurrency: + group: docs-external-links + cancel-in-progress: false + +jobs: + external-links: + name: Check external links + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check external links + id: check + continue-on-error: true + run: | + set +e + bun run --cwd apps/docs check:links --external 2>&1 | tee external-report.txt + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Create issue on failure + if: steps.check.outputs.exit_code != '0' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('external-report.txt', 'utf8').slice(0, 60000); + const title = `Docs: broken external links — ${new Date().toISOString().slice(0,10)}`; + const body = [ + 'The scheduled external link check found broken links.', + '', + 'This workflow is separate from CI so it never blocks pull requests.', + 'Configure ignored URLs in `apps/docs/link-ignore.json`.', + '', + '
Report', + '', + '```', + report, + '```', + '', + '
', + ].join('\n'); + + // Open exactly one issue per run + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['documentation'], + }); + + - name: Fail if external links broken (for visibility) + if: steps.check.outputs.exit_code != '0' + run: | + echo "External link check failed — issue opened. See external-report.txt" + cat external-report.txt + exit 1 diff --git a/apps/docs/link-ignore.json b/apps/docs/link-ignore.json new file mode 100644 index 00000000..d319555b --- /dev/null +++ b/apps/docs/link-ignore.json @@ -0,0 +1,10 @@ +[ + "https://t.me/*", + "https://stellar.expert/*", + "https://so4.market/*", + "https://www.freighter.app/*", + "https://github.com/jsonfeed/jsonfeed-validator", + "/concepts/perpetuals", + "/concepts/margin-and-leverage", + "/reference/exchange-router#create_order" +] diff --git a/apps/docs/scripts/check-links.ts b/apps/docs/scripts/check-links.ts index ade25f9e..27093b6e 100644 --- a/apps/docs/scripts/check-links.ts +++ b/apps/docs/scripts/check-links.ts @@ -1,39 +1,353 @@ -import { headingEntries, internalLinks, loadPages } from "./content" +import { existsSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { dirname, join, relative, resolve } from "node:path" -const pages = await loadPages() -const routeMap = new Map(pages.map((page) => [page.route, page])) -const errors: Array = [] +import { appRoot, contentRoot, headingEntries, loadPages } from "./content" +import { readdir } from "node:fs/promises" -for (const page of pages) { - for (const link of internalLinks(page.body)) { - const [route, anchor] = link.split("#") - const target = routeMap.get(route) - if (!target) { - errors.push(`${page.route}: broken link ${link}`) - continue - } - if (anchor) { - const explicit = new Set( - headingEntries(target.body).map((entry) => entry.id), - ) - const generated = new Set( - [...target.body.matchAll(/^## (.+)$/gm)].map((match) => - match[1] - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""), - ), +// bun run scripts/check-links.ts -> internal only (fails CI on broken internal links/anchors/assets) +// bun run scripts/check-links.ts --external -> external only (scheduled workflow, never blocks PR) + +const isExternal = process.argv.includes("--external") +const ignoreListPath = join(appRoot, "link-ignore.json") + +type Failure = { line: number; raw: string; message: string } +type Grouped = Map> + +async function loadIgnoreList(): Promise> { + if (!existsSync(ignoreListPath)) return [] + try { + const raw = await readFile(ignoreListPath, "utf8") + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === "string") + return [] + } catch { + return [] + } +} + +function isIgnored(url: string, patterns: Array): boolean { + for (const pat of patterns) { + // simple glob: support * wildcard, otherwise substring match + if (pat.includes("*")) { + const escaped = pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + if (new RegExp(`^${escaped}$`).test(url)) return true + } else if (url.includes(pat)) { + return true + } + } + return false +} + +function extractLinksPerLine(raw: string): Array<{ line: number; url: string; isImage: boolean; raw: string }> { + const lines = raw.split(/\r?\n/) + const out: Array<{ line: number; url: string; isImage: boolean; raw: string }> = [] + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineNo = i + 1 + // markdown links and images: [text](url) and ![alt](url) + // capture url without title: url may be followed by space + "title" + const mdRe = /(!)?\[([^\]]*)\]\(([^)]+)\)/g + let m: RegExpExecArray | null + while ((m = mdRe.exec(line))) { + const isImage = m[1] === "!" + let url = m[3].trim() + // strip optional title after space: [text](url "title") + const spaceIdx = url.search(/\s+"/) + if (spaceIdx !== -1) url = url.slice(0, spaceIdx).trim() + // strip surrounding < > if present: + if (url.startsWith("<") && url.endsWith(">")) url = url.slice(1, -1) + // strip quotes + url = url.replace(/^["']|["']$/g, "") + if (!url) continue + out.push({ line: lineNo, url, isImage, raw: m[0] }) + } + + // html and + const htmlRe = /<(?:img|a)[^>]+(?:src|href)=["']([^"']+)["'][^>]*>/gi + while ((m = htmlRe.exec(line))) { + const url = m[1].trim() + const isImage = m[0].toLowerCase().startsWith(" e.line === lineNo && e.url === url)) { + out.push({ line: lineNo, url, isImage, raw: m[0] }) + } + } + + // + const termRe = />> { + // Fallback loader that handles \r\n line endings (Windows) robustly. + // On Linux CI the standard loadPages already works, but this ensures parity. + try { + return await loadPages() + } catch { + // manual walk + parse that tolerates \r + async function walk(dir: string): Promise> { + const entries = await readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + entries.map((e) => { + const p = join(dir, e.name) + return e.isDirectory() ? walk(p) : Promise.resolve([p]) + }), ) - if (!explicit.has(anchor) && !generated.has(anchor)) - errors.push(`${page.route}: broken anchor ${link}`) + return files.flat() + } + const files = (await walk(contentRoot)).filter((f) => f.endsWith(".mdx")) + const pages: Array<{ file: string; route: string; frontmatter: any; body: string }> = [] + for (const file of files) { + const raw = await Bun.file(file).text() + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/) + if (!m) throw new Error(`${file}: missing frontmatter`) + const body = m[2].trim() + const route = `/${relative(contentRoot, file).replace(/\.mdx$/, "").replaceAll("\\", "/")}` + // minimal frontmatter parse for completeness + pages.push({ file, route, frontmatter: {} as any, body }) + } + return pages as any + } +} +const pages = await loadPagesRobust() +const routeMap = new Map(pages.map((p) => [p.route, p])) + +// Robust heading id extraction that handles \r\n and trims, unlike the imported +// headingEntries which splits on "\n" only. Use local slugify to ensure CI vs Windows parity. +function localSlugify(title: string): string { + return title + .toLocaleLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") +} +function localHeadingIds(body: string): Set { + const ids = new Set() + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trimEnd() + const m = line.match(/^(#{2,6}) (.+?)(?: \{#([a-z0-9-]+)\})?$/) + if (!m) continue + const title = m[2].trim() + const id = m[3] ?? localSlugify(title) + ids.add(id) + } + return ids +} +const headingIdsByRoute = new Map>() +for (const page of pages) { + // Prefer local robust extraction; fallback to imported for parity but ensure \r handling + const ids = localHeadingIds(page.body) + // Also merge ids from imported headingEntries for explicit {#id} that may have been trimmed differently + for (const e of headingEntries(page.body)) ids.add(e.id) + headingIdsByRoute.set(page.route, ids) +} +const ignorePatterns = await loadIgnoreList() + +const grouped: Grouped = new Map() +let totalFailures = 0 + +function addFailure(file: string, failure: Failure) { + if (!grouped.has(file)) grouped.set(file, []) + grouped.get(file)!.push(failure) + totalFailures++ +} + +if (!isExternal) { + // Internal validation: routes, anchors, and image/asset paths + for (const page of pages) { + const raw = await Bun.file(page.file).text() + const relFile = relative(resolve(appRoot, "../.."), page.file).replaceAll("\\", "/") + const links = extractLinksPerLine(raw) + + for (const { line, url, isImage } of links) { + // skip external urls in internal mode (checked separately) + if (/^https?:\/\//i.test(url)) continue + if (url.startsWith("mailto:") || url.startsWith("tel:")) continue + if (isIgnored(url, ignorePatterns)) continue + // skip pure hash? but we validate same-page anchors + if (url.startsWith("#")) { + const anchor = url.slice(1) + if (!anchor) continue + const ids = headingIdsByRoute.get(page.route) + if (!ids?.has(anchor)) { + addFailure(relFile, { line, raw: url, message: `broken anchor "${url}" — heading "#${anchor}" not found on ${page.route}` }) + } + continue + } + + // internal absolute route: /... + if (url.startsWith("/")) { + const [routePart, anchor] = url.split("#") + const cleanRoute = routePart.split("?")[0].replace(/\/$/, "") || "/" + // route validation + // For markdown images, the src may be an asset path like /assets/... or /images/... + // Those are not docs routes; validate as public asset instead + const isAssetPath = isImage || cleanRoute.startsWith("/assets/") || cleanRoute.startsWith("/public/") + + if (isAssetPath) { + // check filesystem under apps/docs/public + const assetPath = cleanRoute.replace(/^\/public\//, "/") + const fsPath = join(appRoot, "public", assetPath) + // allow route-like asset that is also a docs page? check routeMap first + if (routeMap.has(cleanRoute)) { + // it's a valid page, check anchor if present + if (anchor) { + const ids = headingIdsByRoute.get(cleanRoute) + if (!ids?.has(anchor)) { + addFailure(relFile, { line, raw: url, message: `broken anchor "${url}" — heading "#${anchor}" not found on ${cleanRoute}` }) + } + } + } else if (!existsSync(fsPath)) { + addFailure(relFile, { line, raw: url, message: `broken asset path "${url}" — file not found at public${assetPath}` }) + } else if (anchor) { + // asset with anchor is unusual, but validate if target is html? skip + } + continue + } + + const target = routeMap.get(cleanRoute) + if (!target) { + addFailure(relFile, { line, raw: url, message: `broken link "${url}" — route "${cleanRoute}" not found` }) + continue + } + if (anchor) { + const ids = headingIdsByRoute.get(cleanRoute) + if (!ids?.has(anchor)) { + addFailure(relFile, { line, raw: url, message: `broken anchor "${url}" — heading "#${anchor}" not found on ${cleanRoute}` }) + } + } + continue + } + + // relative paths: ./ or ../ -> treat as asset/image relative to page file + if (url.startsWith("./") || url.startsWith("../")) { + if (!isImage) continue // only validate relative assets/images + const baseDir = dirname(page.file) + const fsPath = resolve(baseDir, url.split("#")[0].split("?")[0]) + if (!existsSync(fsPath)) { + addFailure(relFile, { line, raw: url, message: `broken asset path "${url}" — file not found relative to ${relFile}` }) + } + continue + } + + // relative without prefix but looks like asset: e.g., images/foo.png + // ignore bare relative links that are not images + } + } + + if (totalFailures) { + console.error(formatGrouped(grouped, pages.length)) + process.exit(1) + } + console.log(`Link check passed: ${pages.length} pages, zero broken internal links.`) +} else { + // External validation: fetch each external url, respecting ignore list + // ignorePatterns already loaded above + // collect all external urls grouped by source + const externalByFile: Map> = new Map() + const urlToSources: Map> = new Map() + + for (const page of pages) { + const raw = await Bun.file(page.file).text() + const relFile = relative(resolve(appRoot, "../.."), page.file).replaceAll("\\", "/") + const links = extractLinksPerLine(raw) + for (const { line, url, raw: rawMatch } of links) { + if (!/^https?:\/\//i.test(url)) continue + if (isIgnored(url, ignorePatterns)) continue + if (!externalByFile.has(relFile)) externalByFile.set(relFile, []) + externalByFile.get(relFile)!.push({ line, url, raw: rawMatch }) + if (!urlToSources.has(url)) urlToSources.set(url, []) + urlToSources.get(url)!.push({ file: relFile, line, raw: rawMatch }) } } + + const urls = [...urlToSources.keys()] + if (urls.length === 0) { + console.log(`External link check passed: ${pages.length} pages, no external links to check (${ignorePatterns.length} ignored).`) + process.exit(0) + } + + console.log(`Checking ${urls.length} external link(s) across ${pages.length} pages...`) + + const failedUrls = new Map() // url -> error + + // fetch with concurrency limit + const CONCURRENCY = 8 + const TIMEOUT_MS = 10000 + let idx = 0 + async function checkOne(url: string) { + const controller = new AbortController() + const t = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + // try HEAD first, fall back to GET + let res = await fetch(url, { method: "HEAD", redirect: "follow", signal: controller.signal }) + if (!res.ok && res.status >= 400) { + // some servers block HEAD, try GET + res = await fetch(url, { method: "GET", redirect: "follow", signal: controller.signal }) + } + if (!res.ok) { + failedUrls.set(url, `HTTP ${res.status} ${res.statusText}`) + } + } catch (err: any) { + failedUrls.set(url, err?.name === "AbortError" ? `timeout after ${TIMEOUT_MS}ms` : (err?.message || String(err))) + } finally { + clearTimeout(t) + } + } + + const workers: Array> = [] + for (let w = 0; w < CONCURRENCY; w++) { + workers.push( + (async () => { + while (idx < urls.length) { + const cur = idx++ + const url = urls[cur] + await checkOne(url) + } + })(), + ) + } + await Promise.all(workers) + + if (failedUrls.size) { + const groupedExt: Grouped = new Map() + for (const [url, err] of failedUrls) { + const sources = urlToSources.get(url) || [] + for (const src of sources) { + if (!groupedExt.has(src.file)) groupedExt.set(src.file, []) + groupedExt.get(src.file)!.push({ line: src.line, raw: url, message: `external link failed "${url}" — ${err}` }) + } + } + console.error(formatGrouped(groupedExt, pages.length, true)) + process.exit(1) + } + + console.log(`External link check passed: ${urls.length} external link(s) checked, zero failures.`) } -if (errors.length) { - console.error(errors.join("\n")) - process.exit(1) +function formatGrouped(grouped: Grouped, totalPages: number, isExternal = false): string { + const kind = isExternal ? "external" : "internal" + const count = [...grouped.values()].flat().length + const lines: Array = [] + lines.push(`Found ${count} broken ${kind} link(s) across ${grouped.size} file(s) / ${totalPages} pages:`) + lines.push("") + const sortedFiles = [...grouped.keys()].sort() + for (const file of sortedFiles) { + const failures = grouped.get(file)!.sort((a, b) => a.line - b.line) + lines.push(`${file}:`) + for (const f of failures) { + lines.push(` line ${f.line}: ${f.message} (${f.raw})`) + } + } + return lines.join("\n") } -console.log( - `Link check passed: ${pages.length} pages, zero broken internal links.`, -) From d140d9c822c187b4c675e9ce482596b6dd301c67 Mon Sep 17 00:00:00 2001 From: razeprasine Date: Tue, 1 Sep 2026 10:51:28 +0100 Subject: [PATCH 2/4] Add reading time and reading progress --- apps/docs/public/assets/reading-progress.js | 36 ++++++++++ apps/docs/scripts/build.ts | 25 ++++++- apps/docs/src/components/DocsLayout.tsx | 4 +- apps/docs/src/components/ReadingProgress.tsx | 60 ++++++++++++++++ apps/docs/src/lib/reading-time.ts | 76 ++++++++++++++++++++ 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 apps/docs/public/assets/reading-progress.js create mode 100644 apps/docs/src/components/ReadingProgress.tsx create mode 100644 apps/docs/src/lib/reading-time.ts diff --git a/apps/docs/public/assets/reading-progress.js b/apps/docs/public/assets/reading-progress.js new file mode 100644 index 00000000..d1e578b8 --- /dev/null +++ b/apps/docs/public/assets/reading-progress.js @@ -0,0 +1,36 @@ +// Reading progress indicator (DX-062) +// Slim bar in header driven by scroll, no layout shift, rAF, prefers-reduced-motion, hidden from AT via aria-hidden. +(function () { + const bar = document.querySelector("[data-reading-progress]"); + if (!bar) return; + + // Respect prefers-reduced-motion: disable animation, keep static or hide + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + // Remove transition if any, keep bar visible but non-animated (no jank) + bar.style.transition = "none"; + } + + let ticking = false; + + function update() { + const doc = document.documentElement; + const scrollTop = window.scrollY || doc.scrollTop; + const scrollHeight = doc.scrollHeight - window.innerHeight; + const progress = scrollHeight > 0 ? Math.min(scrollTop / scrollHeight, 1) : 0; + // Use transform scaleX to avoid layout shift and width reflow + bar.style.transform = `scaleX(${progress})`; + ticking = false; + } + + function onScroll() { + if (!ticking) { + ticking = true; + requestAnimationFrame(update); + } + } + + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll, { passive: true }); + // Initial + update(); +})(); diff --git a/apps/docs/scripts/build.ts b/apps/docs/scripts/build.ts index c50bdeb8..19b8a1b5 100644 --- a/apps/docs/scripts/build.ts +++ b/apps/docs/scripts/build.ts @@ -7,6 +7,7 @@ import { appRoot, contentRoot, loadPages, loadPagesFrom, slugifyHeading, version import type { Page } from "./content" import { DEFAULT_SITE_URL } from "../src/lib/seo" import { renderMermaidFigureHtml } from "../src/lib/mermaid" +import { getReadingTime } from "../src/lib/reading-time" await $`bun run ${join(appRoot, "scripts/check-content.ts")}` await $`bun run ${join(appRoot, "scripts/check-links.ts")}` @@ -82,6 +83,22 @@ function render(body: string, filePath: string) { .join("\n") } +function readingMetaHtml(page: Page): string { + const rt = getReadingTime(page.body) + if (!rt.shouldShow) return "" + return `

${escape(rt.text)} · Last updated

` +} + +function readingProgressHtml(page: Page): { bar: string; script: string } { + const rt = getReadingTime(page.body) + if (!rt.shouldShow) return { bar: "", script: "" } + const bar = `` + const script = `` + return { bar, script } +} + +const readingProgressStyle = `` + // DX-050: versioned documentation routing. // // The current version renders unprefixed, unchanged from DX-029. Every @@ -156,8 +173,10 @@ async function renderPage(page: Page, versionId: string | null, sections: DocVer // them from the search index entirely rather than indexing stale prose. const mainAttrs = isVersioned ? "" : " data-pagefind-body" const picker = versionPickerHtml(versionId, sections, page.route) + const readingMeta = readingMetaHtml(page) + const { bar: progressBar, script: progressScript } = readingProgressHtml(page) - const html = `${escape(page.frontmatter.title)} · SO4 docs${robotsTag}${picker ? `` : ""}${banner}
SO4 docs

${escape(page.frontmatter.title)}

${render(page.body)}${feedbackWidgetHtml(outRoute)}
Last updated
` + const html = `${escape(page.frontmatter.title)} · SO4 docs${robotsTag}${readingProgressStyle}${progressScript}${picker ? `` : ""}${banner}
${progressBar}SO4 docs

${escape(page.frontmatter.title)}

${readingMeta}${render(page.body)}${feedbackWidgetHtml(outRoute)}
Last updated
` await Bun.write(join(directory, "index.html"), html) } @@ -175,7 +194,9 @@ if (!stylesheet) throw new Error("Vite did not emit the docs stylesheet") for (const page of pages) { const directory = join(outputRoot, page.route.slice(1)) await mkdir(directory, { recursive: true }) - const html = `${escape(page.frontmatter.title)} · SO4 docs
SO4 docsOpen interface

${escape(page.frontmatter.title)}

${render(page.body, page.file)}
Last updated
` + const readingMeta = readingMetaHtml(page) + const { bar: progressBar, script: progressScript } = readingProgressHtml(page) + const html = `${escape(page.frontmatter.title)} · SO4 docs${readingProgressStyle}${progressScript}
${progressBar}SO4 docsOpen interface

${escape(page.frontmatter.title)}

${readingMeta}${render(page.body, page.file)}
Last updated
` await Bun.write(join(directory, "index.html"), html) } diff --git a/apps/docs/src/components/DocsLayout.tsx b/apps/docs/src/components/DocsLayout.tsx index 8c764e8f..527c56ca 100644 --- a/apps/docs/src/components/DocsLayout.tsx +++ b/apps/docs/src/components/DocsLayout.tsx @@ -17,6 +17,7 @@ import { SheetTrigger, } from "@workspace/ui/components/sheet" import { MAIN_CONTENT_ID, SkipLink } from "@workspace/ui/components/skip-link" +import { ReadingProgress } from "./ReadingProgress" export interface DocsLayoutProps { header?: ReactNode @@ -41,8 +42,9 @@ export function DocsLayout({
+
diff --git a/apps/docs/src/components/ReadingProgress.tsx b/apps/docs/src/components/ReadingProgress.tsx new file mode 100644 index 00000000..dac1ca86 --- /dev/null +++ b/apps/docs/src/components/ReadingProgress.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export function ReadingProgress() { + const ref = useRef(null); + + useEffect(() => { + const bar = ref.current; + if (!bar) return; + + const mql = window.matchMedia("(prefers-reduced-motion: reduce)"); + if (mql.matches) { + bar.style.transition = "none"; + } + + // Hide on short pages where progress is noise (match build-time MIN_WORDS threshold heuristic) + // If the document is not scrollable, hide the bar + function isShortPage() { + return document.documentElement.scrollHeight <= window.innerHeight + 150; + } + if (isShortPage()) { + bar.style.display = "none"; + return; + } + + let ticking = false; + function update() { + const doc = document.documentElement; + const scrollTop = window.scrollY || doc.scrollTop; + const scrollHeight = doc.scrollHeight - window.innerHeight; + const progress = scrollHeight > 0 ? Math.min(scrollTop / scrollHeight, 1) : 0; + bar.style.transform = `scaleX(${progress})`; + ticking = false; + } + function onScroll() { + if (!ticking) { + ticking = true; + requestAnimationFrame(update); + } + } + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll, { passive: true }); + update(); + return () => { + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + }; + }, []); + + return ( +