// 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 `

${text}

`; } if (depth === 2) { // Close previous
section (if any) and open a new one return `
${text}`; } return `${text}`; }; //////////////////////////////////////////////////////////////////////////////// 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 = "

Loading\u2026

"; 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 = "

Failed to load document.

"; } } _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 inside
		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);
		}
	}
}