mirror of
https://github.com/litruv/Docs-Viewer.git
synced 2026-09-10 18:19:45 +10:00
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.
This commit is contained in:
425
src/app.js
Normal file
425
src/app.js
Normal file
@@ -0,0 +1,425 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user