mirror of
https://github.com/litruv/Docs-Viewer.git
synced 2026-09-10 01:59:44 +10:00
Replace the classic DOM shell with slate widgets, markdown→slate rendering, and a CI index build that uses build-docs.cjs.
426 lines
14 KiB
JavaScript
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";
|
|
}
|