Files
Docs-Viewer/src/app.js
litruv 10e9f7ac2f Port Docs-Viewer to slatehtml as the docs-viewer package.
Replace the classic DOM shell with slate widgets, markdown→slate rendering, and a CI index build that uses build-docs.cjs.
2026-08-02 20:48:31 +10:00

426 lines
14 KiB
JavaScript

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 <slate-docs-viewer> 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";
}