diff --git a/public/knowledge.js b/public/knowledge.js index dcae405..376f867 100644 --- a/public/knowledge.js +++ b/public/knowledge.js @@ -3,22 +3,170 @@ const count = document.getElementById("knowledge-count"); const entries = Array.from(document.querySelectorAll("[data-knowledge-entry]")); - if (!(input instanceof HTMLInputElement) || !count) return; + if (input instanceof HTMLInputElement && count) { + function update() { + const query = input.value.trim().toLowerCase(); + let visible = 0; - function update() { - const query = input.value.trim().toLowerCase(); - let visible = 0; + for (const entry of entries) { + const matches = !query || (entry.getAttribute("data-search") || "").includes(query); + entry.hidden = !matches; + if (matches) visible += 1; + } - for (const entry of entries) { - const matches = !query || (entry.getAttribute("data-search") || "").includes(query); - entry.hidden = !matches; - if (matches) visible += 1; + count.textContent = query + ? `${visible} of ${entries.length} pages` + : `${entries.length} pages`; } - count.textContent = query - ? `${visible} of ${entries.length} pages` - : `${entries.length} pages`; + input.addEventListener("input", update); } - input.addEventListener("input", update); + const preview = document.getElementById("knowledge-preview"); + const previewTitle = document.getElementById("knowledge-preview-title"); + const previewMeta = document.getElementById("knowledge-preview-meta"); + const previewSummary = document.getElementById("knowledge-preview-summary"); + const previewExcerpt = document.getElementById("knowledge-preview-excerpt"); + const previewSources = Array.from(document.querySelectorAll("[data-knowledge-preview-source]")); + + if ( + preview && + previewTitle && + previewMeta && + previewSummary && + previewExcerpt && + previewSources.length > 0 + ) { + const normalizePath = (path) => path.length > 1 ? path.replace(/\/$/, "") : path; + const sourceByPath = new Map(previewSources.map((source) => [ + normalizePath(source.getAttribute("data-preview-href") || ""), + source, + ])); + let activeLink; + + function sourceForTarget(target) { + if (!(target instanceof Element)) return; + const link = target.closest("a[href]"); + if (!(link instanceof HTMLAnchorElement)) return; + + const url = new URL(link.href, window.location.href); + if (url.origin !== window.location.origin) return; + const source = sourceByPath.get(normalizePath(url.pathname)); + return source ? { link, source } : undefined; + } + + function showPreview(link, source) { + if (activeLink === link) return; + activeLink = link; + previewTitle.textContent = source.getAttribute("data-preview-title") || ""; + previewMeta.textContent = source.getAttribute("data-preview-meta") || ""; + previewSummary.textContent = source.getAttribute("data-preview-summary") || ""; + previewExcerpt.textContent = source.getAttribute("data-preview-excerpt") || ""; + preview.setAttribute("data-active", ""); + preview.setAttribute("aria-hidden", "false"); + } + + function hidePreview(link) { + if (link && activeLink !== link) return; + activeLink = undefined; + preview.removeAttribute("data-active"); + preview.setAttribute("aria-hidden", "true"); + } + + document.addEventListener("pointerover", (event) => { + const target = sourceForTarget(event.target); + if (target) showPreview(target.link, target.source); + }); + + document.addEventListener("pointerout", (event) => { + const target = sourceForTarget(event.target); + if (!target) return; + if (event.relatedTarget instanceof Node && target.link.contains(event.relatedTarget)) return; + hidePreview(target.link); + }); + + document.addEventListener("focusin", (event) => { + const target = sourceForTarget(event.target); + if (target) showPreview(target.link, target.source); + }); + + document.addEventListener("focusout", (event) => { + const target = sourceForTarget(event.target); + if (target) hidePreview(target.link); + }); + } + + const contents = document.getElementById("knowledge-contents"); + const contentsList = document.getElementById("knowledge-contents-list"); + const knowledgeMain = document.querySelector(".knowledge-overview, .knowledge-entry"); + + if (contents && contentsList && knowledgeMain) { + const headings = Array.from(knowledgeMain.querySelectorAll("h1, h2, h3")) + .filter((heading) => heading.textContent && !heading.closest(".knowledge-chart")); + const usedIds = new Set(Array.from(document.querySelectorAll("[id]")) + .map((element) => element.id) + .filter(Boolean)); + + function headingId(heading, index) { + if (heading.id) return heading.id; + const base = (heading.textContent || "section") + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || `section-${index + 1}`; + let id = base; + let suffix = 2; + while (usedIds.has(id)) id = `${base}-${suffix++}`; + heading.id = id; + usedIds.add(id); + return id; + } + + const links = headings.map((heading, index) => { + const item = document.createElement("li"); + const link = document.createElement("a"); + const id = headingId(heading, index); + item.dataset.level = heading.tagName.slice(1); + link.href = `#${id}`; + link.textContent = heading.textContent.trim(); + item.append(link); + contentsList.append(item); + return { heading, link }; + }); + + if (links.length > 1) { + contents.setAttribute("data-active", ""); + contents.setAttribute("aria-hidden", "false"); + + if (window.location.hash.length > 1) { + const initialTarget = document.getElementById(decodeURIComponent(window.location.hash.slice(1))); + if (initialTarget) window.requestAnimationFrame(() => initialTarget.scrollIntoView()); + } + + let frame; + const updateCurrent = () => { + frame = undefined; + const threshold = window.innerHeight * 0.24; + let current = links[0]; + for (const candidate of links) { + if (candidate.heading.getBoundingClientRect().top <= threshold) current = candidate; + else break; + } + for (const candidate of links) { + const active = candidate === current; + candidate.link.toggleAttribute("data-current", active); + if (active) candidate.link.setAttribute("aria-current", "location"); + else candidate.link.removeAttribute("aria-current"); + } + }; + const requestUpdate = () => { + if (frame) return; + frame = window.requestAnimationFrame(updateCurrent); + }; + window.addEventListener("scroll", requestUpdate, { passive: true }); + window.addEventListener("resize", requestUpdate); + updateCurrent(); + } + } })(); diff --git a/public/site.css b/public/site.css index 5e0580b..45af9f6 100644 --- a/public/site.css +++ b/public/site.css @@ -1638,6 +1638,148 @@ h1 { white-space: nowrap; } +.knowledge-preview { + display: none; +} + +.knowledge-contents { + display: none; +} + +@media (min-width: 1240px) { + .knowledge-contents { + position: fixed; + inset-block: clamp(6.5rem, 12vh, 9rem) 3rem; + inset-inline-end: calc(50% + 23rem); + display: block; + box-sizing: border-box; + width: min(18rem, calc(50vw - 26rem)); + overflow-y: auto; + padding-inline-end: 1.2rem; + border-inline-end: 1px solid var(--site-border); + opacity: 0; + visibility: hidden; + scrollbar-width: thin; + } + + .knowledge-contents[data-active] { + opacity: 1; + visibility: visible; + } + + .knowledge-contents > p { + margin: 0 0 0.8rem; + color: var(--site-text-muted); + font-size: 0.64rem; + font-weight: 650; + } + + .knowledge-contents ol { + display: grid; + gap: 0.05rem; + margin: 0; + padding: 0; + list-style: none; + } + + .knowledge-contents li[data-level="3"] { + padding-inline-start: 0.75rem; + } + + .knowledge-contents a { + display: block; + padding: 0.28rem 0; + color: var(--site-text-muted); + font-size: 0.68rem; + line-height: 1.35; + text-decoration: none; + transition: color 120ms ease; + } + + .knowledge-contents li[data-level="1"] a { + color: var(--site-text-secondary); + font-weight: 650; + } + + .knowledge-contents a:hover, + .knowledge-contents a:focus-visible, + .knowledge-contents a[data-current] { + color: var(--site-link); + } + + .knowledge-overview h1, + .knowledge-overview h2, + .knowledge-overview h3, + .knowledge-entry h1, + .knowledge-entry h2, + .knowledge-entry h3 { + scroll-margin-top: 2rem; + } + + .knowledge-preview { + position: fixed; + inset-block: clamp(6.5rem, 12vh, 9rem) 3rem; + inset-inline-start: calc(50% + 23rem); + display: block; + box-sizing: border-box; + width: min(22rem, calc(50vw - 26rem)); + overflow: hidden; + padding-inline-start: 1.2rem; + border-inline-start: 1px solid var(--site-border); + opacity: 0; + visibility: hidden; + pointer-events: none; + -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 1.75rem), transparent 100%); + mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 1.75rem), transparent 100%); + transform: translateY(0.35rem); + transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease; + } + + .knowledge-preview[data-active] { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + + .knowledge-preview h2 { + margin: 0; + font-family: var(--site-font-body); + font-size: clamp(1.05rem, 1.35vw, 1.35rem); + font-weight: 650; + line-height: 1.25; + letter-spacing: -0.02em; + overflow-wrap: anywhere; + } + + .knowledge-preview-meta { + margin: 0.55rem 0 0; + color: var(--site-text-muted); + font-size: 0.62rem; + } + + .knowledge-preview-summary { + margin: 1.2rem 0 0; + color: var(--site-text-secondary); + font-size: 0.78rem; + font-weight: 600; + line-height: 1.55; + } + + .knowledge-preview-excerpt { + margin: 1rem 0 0; + color: var(--site-text-secondary); + font-size: 0.76rem; + line-height: 1.65; + } +} + +@media (prefers-reduced-motion: reduce) { + .knowledge-preview, + .knowledge-contents a { + transition: none; + } +} + .knowledge-empty, .knowledge-error { color: var(--site-text-muted); diff --git a/src/components/knowledge-contents.tsx b/src/components/knowledge-contents.tsx new file mode 100644 index 0000000..c5e76a6 --- /dev/null +++ b/src/components/knowledge-contents.tsx @@ -0,0 +1,13 @@ +export function KnowledgeContents() { + return ( + + ); +} diff --git a/src/components/knowledge-entry.tsx b/src/components/knowledge-entry.tsx index 9eab8e2..d38f60f 100644 --- a/src/components/knowledge-entry.tsx +++ b/src/components/knowledge-entry.tsx @@ -5,6 +5,7 @@ import { type KnowledgeEntry as KnowledgeEntryData, } from "../knowledge.ts"; import { KnowledgeBody } from "../knowledge-charts/body.tsx"; +import { KnowledgeContents } from "./knowledge-contents.tsx"; import { PageNav } from "./page-nav.tsx"; import { ProtocolLinks } from "./protocol-links.tsx"; @@ -89,6 +90,8 @@ export async function KnowledgeEntry({ /> + + ); } diff --git a/src/components/knowledge-index.tsx b/src/components/knowledge-index.tsx index 19eeb49..1271f0e 100644 --- a/src/components/knowledge-index.tsx +++ b/src/components/knowledge-index.tsx @@ -8,8 +8,32 @@ import { type KnowledgeGraph, } from "../knowledge.ts"; import { KnowledgeBody } from "../knowledge-charts/body.tsx"; +import { KnowledgeContents } from "./knowledge-contents.tsx"; import { PageNav } from "./page-nav.tsx"; +const KNOWLEDGE_PREVIEW_LENGTH = 820; + +export function knowledgePreviewExcerpt(body: string, limit = KNOWLEDGE_PREVIEW_LENGTH): string { + const plain = body + .replace(/```[\s\S]*?```/g, " ") + .replace(/~~~[\s\S]*?~~~/g, " ") + .replace(/\{\{chart:[^}]+\}\}/g, " ") + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]*>/g, " ") + .replace(/^\s{0,3}#{1,6}\s+/gm, "") + .replace(/^\s*(?:[-*+] |\d+\. )/gm, "") + .replace(/[*_~`>|]/g, "") + .replace(/\s+/g, " ") + .replace(/\s+([.,;:!?])/g, "$1") + .trim(); + + if (plain.length <= limit) return plain; + const candidate = plain.slice(0, limit + 1); + const wordBoundary = candidate.lastIndexOf(" "); + return `${candidate.slice(0, wordBoundary > limit * 0.75 ? wordBoundary : limit).trim()}…`; +} + function PageList({ entries, searchable = false }: { entries: KnowledgeEntry[]; searchable?: boolean }) { return (