import { EventBus } from "./services/event-bus.js"; import { IndexService } from "./services/index-service.js"; import { SearchService } from "./services/search-service.js"; import { DocumentService } from "./services/document-service.js"; import { NavigationService } from "./services/navigation-service.js"; import { addContent } from "slatehtml/umc"; /** Replace children via widget `set` when available, else `addContent`. */ function fill(host, specs) { if (!host) return; if (typeof host.set === "function") { host.set(...(specs || [])); return; } host.replaceChildren(); if (specs?.length) addContent(host, specs); } /** * Orchestrates index load, navigation, search, and markdown rendering * against a stamped host. */ export class DocsApp { /** * @param {HTMLElement} host slate-docs-viewer element * @param {{ indexUrl?: string }} [options] */ constructor(host, options = {}) { this.host = host; this.indexUrl = options.indexUrl || host.getAttribute("index") || "./index.json"; this.eventBus = new EventBus(); this.indexService = new IndexService(); this.searchService = new SearchService(this.eventBus, this.indexService); this.documentService = new DocumentService( this.eventBus, this.indexService, () => this.indexData ); this.navigationService = new NavigationService(this.eventBus, () => this.indexData?.defaultPage || "home" ); this.indexData = null; this._outlineObserver = null; this._searchTimer = null; this.$ = { nav: () => host.querySelector("[data-docs-nav]"), outline: () => host.querySelector("[data-docs-outline]"), markdown: () => host.querySelector("[data-docs-markdown]"), error: () => host.querySelector("[data-docs-error]"), progress: () => host.querySelector("[data-docs-progress]"), articleScroll: () => host.querySelector("[data-docs-article-scroll]"), siteName: () => host.querySelector("[data-docs-site-name]"), authorRole: () => host.querySelector("[data-docs-author-role]"), socials: () => host.querySelector("[data-docs-socials]"), search: () => host.querySelector("[data-docs-search]"), searchPanel: () => host.querySelector("[data-docs-search-panel]"), }; } async start() { this.bindChrome(); this.eventBus.on("navigation:requested", ({ slug, hash }) => { this.loadDocumentBySlug(slug, hash); }); try { const res = await fetch(this.indexUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); this.indexData = await res.json(); window._indexData = this.indexData; this.applyIndexChrome(this.indexData); this.searchService.buildSearchIndex(this.indexData.documents || []); const nav = this.$.nav(); nav?.setDocuments?.(this.indexData.documents || []); const { slug, hash } = this.navigationService.readLocation(); await this.loadDocumentBySlug(slug, hash); } catch (err) { this.showError(`Failed to load documentation index: ${err.message}`); } } bindChrome() { const nav = this.$.nav(); nav?.addEventListener("navigate", (e) => { const slug = e.detail?.slug; if (!slug) return; this.navigationService.navigate(slug); }); const outline = this.$.outline(); outline?.addEventListener("navigate", (e) => { const id = e.detail?.id; if (!id) return; this.scrollToId(id, true); }); const md = this.$.markdown(); md?.addEventListener("rendered", (e) => { const headings = e.detail?.headings || []; outline?.setHeadings?.(headings); this.setupOutlineObserver(headings); }); md?.addEventListener("headingclicked", (e) => { const id = e.detail?.id; if (!id) return; this.scrollToId(id, true); }); const search = this.$.search(); const onSearch = () => { clearTimeout(this._searchTimer); this._searchTimer = setTimeout(() => { const value = search?.getAttribute?.("text") || search?.textContent || ""; this.renderSearch(value); }, 60); }; search?.addEventListener("textchanged", onSearch); search?.addEventListener("input", onSearch); search?.addEventListener("keyup", onSearch); document.addEventListener("keydown", (e) => { const typing = ["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable || document.activeElement?.tagName === "EDITABLETEXT" || document.activeElement?.localName === "editabletext" || document.activeElement?.localName === "umc-editabletext"; if ((e.key === "s" || e.key === "S") && (e.altKey || (!e.ctrlKey && !e.metaKey && !typing))) { if (!e.altKey && typing) return; e.preventDefault(); this.focusSearch(); } if (e.key === "Escape") this.hideSearchPanel(); }); document.addEventListener("click", (e) => { const panel = this.$.searchPanel(); const searchField = this.$.search(); const block = this.host.querySelector(".docs-viewer-search-block"); if (!panel || panel.hasAttribute("hidden")) return; if (block?.contains(e.target)) return; if (panel.contains(e.target) || searchField?.contains(e.target)) return; this.hideSearchPanel(); }); } applyIndexChrome(data) { const site = data.metadata?.site_name || data.metadata?.title || "Docs"; const siteEl = this.$.siteName(); if (siteEl) siteEl.setAttribute("text", site); document.title = site; const logo = this.host.querySelector("[data-docs-logo]"); if (logo) { logo.setAttribute("alt", site); const logoUrl = (data.metadata?.logo || this.host.getAttribute("logo") || "./img/logo.png").trim(); if (logoUrl) { logo.setAttribute("src", logoUrl); logo.removeAttribute("hidden"); siteEl?.setAttribute("hidden", ""); } } const role = this.$.authorRole(); if (role) { role.setAttribute("text", data.author?.role || ""); role.toggleAttribute("hidden", !data.author?.role); } const socials = this.$.socials(); if (socials) { const entries = (data.author?.socials || []).map((s) => { const icon = faClassToIconName(s.icon); const label = shortSocialLabel(s.title || s.url || "link"); // value carries the URL so selection can open it return `${encodeURIComponent(s.url)}|${label}|${icon}`; }); socials.setAttribute("options", entries.join(",")); socials.setAttribute("labels", "hide"); if (!socials._docsSocialBound) { socials._docsSocialBound = true; socials.addEventListener("selectionchanged", (e) => { const raw = e.detail?.value || ""; let url = raw; try { url = decodeURIComponent(raw); } catch { /* keep raw */ } if (/^https?:\/\//i.test(url)) { window.open(url, "_blank", "noopener,noreferrer"); } // don't leave a "selected" social highlighted socials.removeAttribute("selected"); }); } } if (data.customCSS) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = data.customCSS; document.head.appendChild(link); } } async loadDocumentBySlug(slug, hash = "") { const base = String(slug || "").split("#")[0]; const doc = this.indexService.findDocumentBySlug(this.indexData?.documents || [], base); if (!doc) { this.showError(`Document not found: ${base}`); return; } if (doc.type === "folder") { if (doc.path) await this.loadDocument(doc.path, hash, doc.slug); else if (doc.items?.[0]?.path) { await this.loadDocument(doc.items[0].path, hash, doc.items[0].slug); } else this.showError("This folder is empty."); return; } await this.loadDocument(doc.path, hash, doc.slug); } async loadDocument(path, hash = "", slug = "") { this.setProgress(true); this.clearError(); try { const { content, title } = await this.documentService.loadDocument(path); const md = this.$.markdown(); if (md) { md.__mdContent = null; md.setAttribute("content", content); } document.title = `${title} · ${this.indexData?.metadata?.site_name || "Docs"}`; const fromPath = findSlugByPath(this.indexData?.documents || [], path); this.$.nav()?.setAttribute("selected", fromPath || slug || ""); const scroll = this.$.articleScroll(); if (hash) { const id = hash.replace(/^#/, ""); requestAnimationFrame(() => setTimeout(() => this.scrollToId(id, false), 40)); } else if (scroll) { scroll.scrollTop = 0; } } catch (err) { this.showError(err.message || "Error loading document."); } finally { this.setProgress(false); } } setupOutlineObserver(headings) { this._outlineObserver?.disconnect(); const root = this.$.articleScroll(); if (!root || !headings?.length) return; const map = new Map(headings.map((h) => [h.el, h.id])); this._outlineObserver = new IntersectionObserver( (entries) => { const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => a.target.offsetTop - b.target.offsetTop); if (!visible.length) return; const id = map.get(visible[0].target); if (id) this.$.outline()?.setAttribute("active", id); }, { root, rootMargin: "-48px 0px -55% 0px", threshold: [0, 0.25, 1] } ); for (const h of headings) { if (h.el) this._outlineObserver.observe(h.el); } } scrollToId(id, pushHash) { const el = this.host.querySelector(`#${CSS.escape(id)}`); const scroll = this.$.articleScroll(); if (!el || !scroll) return; // Open any collapsed ancestors so the heading is visible (docs fold). let node = el.parentElement; while (node && node !== this.host) { if (node.localName === "slate-collapse" && !node.hasAttribute("open")) { if (typeof node.toggle === "function") node.toggle(true); else node.setAttribute("open", ""); } node = node.parentElement; } const top = el.offsetTop - 12; scroll.scrollTo({ top, behavior: "smooth" }); if (pushHash) { const { slug } = this.navigationService.readLocation(); history.pushState(null, "", `?${slug}#${id}`); } el.classList.remove("md-heading-flash"); void el.offsetWidth; el.classList.add("md-heading-flash"); } renderSearch(query) { const panel = this.$.searchPanel(); if (!panel) return; const q = String(query || "").trim(); if (!q) { this.hideSearchPanel(); return; } const results = this.searchService.search(q); panel.removeAttribute("hidden"); const specs = results.length ? results.map((r) => ({ tag: "border", class: "docs-search-hit", role: "option", tabindex: "0", "data-search-slug": r.slug, children: [ { tag: "textblock", class: "docs-search-hit-title", text: r.title }, { tag: "textblock", class: "docs-search-hit-meta", text: r.location || r.type, }, ], })) : [ { tag: "textblock", class: "docs-search-hit-meta", text: "No results", }, ]; fill(panel, specs); panel.onclick = (e) => { const hit = e.target.closest?.("[data-search-slug]"); if (!hit) return; const slug = hit.getAttribute("data-search-slug"); this.hideSearchPanel(); const search = this.$.search(); if (search) search.setAttribute("text", ""); this.navigationService.navigate(slug); }; } hideSearchPanel() { this.$.searchPanel()?.setAttribute("hidden", ""); } focusSearch() { const field = this.$.search(); field?.focus?.(); const sidebar = this.host.querySelector("#docs-viewer-sidebar"); if (sidebar?.getAttribute("layout") === "drawer" && !sidebar.hasAttribute("open")) { sidebar.setAttribute("open", ""); } } setProgress(on) { this.$.progress()?.toggleAttribute("hidden", !on); } showError(message) { const el = this.$.error(); if (!el) return; el.removeAttribute("hidden"); el.setAttribute("text", message); const md = this.$.markdown(); if (md) { md.__mdContent = null; md.setAttribute("content", ""); } } clearError() { const el = this.$.error(); if (!el) return; el.setAttribute("hidden", ""); el.removeAttribute("text"); } } function findSlugByPath(documents, path) { for (const doc of documents || []) { if (doc.path === path) return doc.slug; if (doc.type === "folder" && doc.items) { const found = findSlugByPath(doc.items, path); if (found) return found; } } return ""; } /** Map Font Awesome class strings to slate-icon names (FA provider). */ export function faClassToIconName(iconClass) { const parts = String(iconClass || "") .split(/\s+/) .filter(Boolean); const namePart = parts.find( (p) => p.startsWith("fa-") && !["fa-solid", "fa-brands", "fa-regular", "fa-sharp"].includes(p) && p !== "fa" ); const raw = (namePart || "link").replace(/^fa-/, ""); if (parts.some((p) => p.includes("brands") || p === "fab")) return `fab:${raw}`; if (parts.some((p) => p.includes("regular") || p === "far")) return `far:${raw}`; if (parts.some((p) => p.includes("solid") || p === "fas")) return `fas:${raw}`; return raw; } function shortSocialLabel(title) { const t = String(title || "").trim(); if (/github/i.test(t)) return "GitHub"; if (/youtube/i.test(t)) return "YouTube"; if (/steam/i.test(t)) return "Steam"; if (/discord/i.test(t)) return "Discord"; if (/bluesky|bsky/i.test(t)) return "Bluesky"; const head = t.split(/\s[-–—]\s/)[0]?.trim() || t; return head.slice(0, 12) || "Link"; }