diff options
Diffstat (limited to 'src/zenserver/frontend/html/pages/docs.js')
| -rw-r--r-- | src/zenserver/frontend/html/pages/docs.js | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/docs.js b/src/zenserver/frontend/html/pages/docs.js new file mode 100644 index 000000000..8caf36d0c --- /dev/null +++ b/src/zenserver/frontend/html/pages/docs.js @@ -0,0 +1,415 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { marked, Renderer } from "../thirdparty/marked.esm.js" +import { Fetcher } from "../util/fetcher.js" + +function slugify(text) +{ + return text.toLowerCase().replace(/[^\w]+/g, "-").replace(/^-|-$/g, ""); +} + +const renderer = new Renderer(); +renderer.heading = function({ text, depth }) +{ + const id = slugify(text); + if (depth === 1) + { + return `<h1 id="${id}">${text}<a class="docs-source-link" title="View source"></a><a class="docs-github-link" title="View on GitHub" target="_blank"></a></h1>`; + } + if (depth === 2) + { + // Close previous <details> section (if any) and open a new one + return `</details><details class="docs-section" open><summary class="docs-section-title" id="${id}">${text}</summary>`; + } + return `<h${depth} id="${id}">${text}</h${depth}>`; +}; + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + generate_crumbs() {} + + async main() + { + this.set_title("docs"); + this._parent.inner().classList.add("docs-page"); + + const index = await new Fetcher().resource("/dashboard/data/_index.json").json(); + if (!index || index.length === 0) + { + this._parent.tag().text("No documentation available."); + return; + } + + // Filter input + const filter_box = document.createElement("input"); + filter_box.type = "text"; + filter_box.className = "docs-filter"; + filter_box.placeholder = "Filter\u2026"; + filter_box.addEventListener("input", () => this._apply_filter(filter_box.value)); + this._parent.inner().appendChild(filter_box); + + // Layout: sidebar + content + const layout = document.createElement("div"); + layout.className = "docs-layout"; + this._parent.inner().appendChild(layout); + + // Sidebar + const sidebar = document.createElement("nav"); + sidebar.className = "docs-sidebar"; + layout.appendChild(sidebar); + + this._docs_index = index; + this._sidebar = sidebar; + this._selected_link = null; + this._filter = ""; + this._docs_cache = {}; + + for (const entry of index) + { + const link = document.createElement("a"); + link.className = "docs-sidebar-link"; + link.textContent = entry.title; + link.href = "#"; + link.addEventListener("click", (e) => { + e.preventDefault(); + this._select_doc(entry, link); + }); + sidebar.appendChild(link); + } + + // Prefetch all docs in the background for filtering + Promise.all(index.map(entry => + new Fetcher().resource("/dashboard/data/" + entry.path).text() + .then(md => { this._docs_cache[entry.path] = md.toLowerCase(); }) + .catch(() => {}) + )); + + // Content area + const content = document.createElement("article"); + content.className = "docs-content"; + layout.appendChild(content); + this._content = content; + + // Intercept clicks on links within rendered doc content + content.addEventListener("click", (e) => { + const anchor = e.target.closest("a"); + if (!anchor || anchor.classList.contains("docs-source-link") || anchor.classList.contains("docs-github-link")) + { + return; + } + + const href = anchor.getAttribute("href") || ""; + + // Fragment-only link (e.g. #workers) — scroll within current doc + if (href.startsWith("#")) + { + e.preventDefault(); + this._scroll_to_fragment(href.slice(1)); + return; + } + + // Relative link to another doc (e.g. API.md or specs/CompactBinary.md) + if (!href.startsWith("http") && href.endsWith(".md")) + { + const parts = href.split("#"); + const target_path = parts[0]; + const target_fragment = parts[1] || null; + const target_entry = this._docs_index.find(d => d.path.toLowerCase() === target_path.toLowerCase()); + if (target_entry) + { + e.preventDefault(); + const target_link = this._sidebar.children[this._docs_index.indexOf(target_entry)]; + this._select_doc(target_entry, target_link, target_fragment); + } + } + }); + + // Select initial doc from URL param or first entry + const requested = this.get_param("doc", ""); + const initial = index.find(e => e.path === requested) || index[0]; + const initial_link = sidebar.children[index.indexOf(initial)]; + this._select_doc(initial, initial_link); + } + + async _select_doc(entry, link, fragment) + { + if (this._selected_link) + { + this._selected_link.classList.remove("active"); + } + this._selected_link = link; + link.classList.add("active"); + + this.set_param("doc", entry.path); + + this._content.innerHTML = "<p class=\"docs-loading\">Loading\u2026</p>"; + + try + { + const md = await new Fetcher().resource("/dashboard/data/" + entry.path).text(); + this._content.innerHTML = marked.parse(md, { renderer }); + + const source_link = this._content.querySelector(".docs-source-link"); + if (source_link) + { + source_link.href = "/dashboard/data/" + entry.path; + } + + const github_link = this._content.querySelector(".docs-github-link"); + if (github_link) + { + github_link.href = "https://github.com/EpicGames/zen/blob/main/docs/" + entry.path; + } + + // Render mermaid diagrams + await this._render_mermaid(); + + // Re-apply filter to the newly rendered content + if (this._filter) + { + this._apply_filter_to_content(); + } + + // Scroll to fragment if specified + const target_fragment = fragment || window.location.hash.slice(1); + if (target_fragment) + { + this._scroll_to_fragment(target_fragment); + } + else + { + window.scrollTo(0, 0); + } + } + catch (e) + { + this._content.innerHTML = "<p class=\"docs-error\">Failed to load document.</p>"; + } + } + + _scroll_to_fragment(id) + { + if (!id) + { + return; + } + const target = this._content.querySelector("#" + CSS.escape(id)); + if (target) + { + target.scrollIntoView({ behavior: "smooth" }); + } + } + + _apply_filter(query) + { + this._filter = query.toLowerCase().trim(); + + // Filter sidebar entries based on cached doc content + const sidebar_links = this._sidebar.children; + for (let i = 0; i < this._docs_index.length; i++) + { + const entry = this._docs_index[i]; + const link = sidebar_links[i]; + if (!this._filter) + { + link.style.display = ""; + continue; + } + + const cached = this._docs_cache[entry.path]; + const matches = !cached || cached.includes(this._filter) || entry.title.toLowerCase().includes(this._filter); + link.style.display = matches ? "" : "none"; + } + + this._apply_filter_to_content(); + } + + _apply_filter_to_content() + { + // Remove existing highlights + for (const mark of this._content.querySelectorAll("mark.docs-highlight")) + { + const parent = mark.parentNode; + parent.replaceChild(document.createTextNode(mark.textContent), mark); + parent.normalize(); + } + + const sections = this._content.querySelectorAll("details.docs-section"); + + for (const section of sections) + { + if (!this._filter) + { + section.style.display = ""; + section.open = true; + continue; + } + + const matches = section.textContent.toLowerCase().includes(this._filter); + section.style.display = matches ? "" : "none"; + + if (matches) + { + section.open = true; + this._highlight_element(section); + } + } + } + + _get_mermaid_theme() + { + const attr = document.documentElement.getAttribute("data-theme"); + const is_dark = attr + ? attr === "dark" + : window.matchMedia("(prefers-color-scheme: dark)").matches; + return is_dark ? "dark" : "default"; + } + + async _load_mermaid() + { + if (window.mermaid) + { + return window.mermaid; + } + + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "/dashboard/thirdparty/mermaid.min.js"; + script.onload = () => { + window.mermaid.initialize({ + startOnLoad: false, + theme: this._get_mermaid_theme(), + themeVariables: { + background: "transparent", + }, + }); + resolve(window.mermaid); + }; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + async _render_mermaid() + { + // marked renders ```mermaid blocks as <code class="language-mermaid"> inside <pre> + const code_blocks = this._content.querySelectorAll("pre > code.language-mermaid"); + if (code_blocks.length === 0) + { + return; + } + + try + { + const mermaid = await this._load_mermaid(); + + for (let i = 0; i < code_blocks.length; i++) + { + const code = code_blocks[i]; + const pre = code.parentElement; + const definition = code.textContent; + + const { svg } = await mermaid.render(`mermaid-${Date.now()}-${i}`, definition); + + const container = document.createElement("div"); + container.className = "docs-mermaid"; + container.dataset.definition = definition; + container.innerHTML = svg; + pre.replaceWith(container); + } + + this._watch_theme(); + } + catch (e) + { + // Mermaid failed to load or render — leave code blocks as-is + } + } + + _watch_theme() + { + if (this._theme_observer) + { + return; + } + + this._theme_observer = new MutationObserver(() => this._rerender_mermaid()); + this._theme_observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + } + + async _rerender_mermaid() + { + const containers = this._content.querySelectorAll(".docs-mermaid[data-definition]"); + if (containers.length === 0) + { + return; + } + + try + { + const mermaid = await this._load_mermaid(); + mermaid.initialize({ + startOnLoad: false, + theme: this._get_mermaid_theme(), + themeVariables: { + background: "transparent", + }, + }); + + for (let i = 0; i < containers.length; i++) + { + const container = containers[i]; + const definition = container.dataset.definition; + const { svg } = await mermaid.render(`mermaid-${Date.now()}-${i}`, definition); + container.innerHTML = svg; + } + } + catch (e) + { + // Mermaid failed to re-render — leave existing SVGs as-is + } + } + + _highlight_element(root) + { + const filter = this._filter; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const matches = []; + + let node; + while ((node = walker.nextNode())) + { + if (node.parentElement && node.parentElement.closest("pre")) + { + continue; + } + + const text = node.textContent.toLowerCase(); + let pos = 0; + while ((pos = text.indexOf(filter, pos)) !== -1) + { + matches.push({ node, pos, len: filter.length }); + pos += filter.length; + } + } + + for (let i = matches.length - 1; i >= 0; i--) + { + const { node: text_node, pos, len } = matches[i]; + const after = text_node.splitText(pos); + after.splitText(len); + + const mark = document.createElement("mark"); + mark.className = "docs-highlight"; + mark.textContent = after.textContent; + after.parentNode.replaceChild(mark, after); + } + } +} |