(() => { "use strict"; const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent); const config = window.__DOCSLIB_CONFIG__ || JSON.parse(document.getElementById("docslib-config").textContent); const mobileLayout = matchMedia("(max-width: 820px)"); const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); const elements = { desktopTitle: document.getElementById("desktop-site-title"), mobileTitle: document.getElementById("mobile-site-title"), desktopHeaderButtons: document.getElementById("desktop-header-buttons"), mobileHeaderButtons: document.getElementById("mobile-header-buttons"), desktopTree: document.getElementById("desktop-page-tree"), mobileTree: document.getElementById("mobile-page-tree"), toolbar: document.getElementById("desktop-toolbar"), desktopSearchShell: document.getElementById("desktop-search-shell"), desktopSearchInput: document.getElementById("desktop-search-input"), desktopSearchButton: document.getElementById("desktop-search-button"), desktopSearchResults: document.getElementById("desktop-search-results"), mobileSearchInput: document.getElementById("mobile-search-input"), mobileSearchButton: document.getElementById("mobile-search-button"), mobileSearchResults: document.getElementById("mobile-search-results"), content: document.getElementById("content"), pageSurface: document.getElementById("page-surface"), previous: document.getElementById("previous-page"), next: document.getElementById("next-page"), themeIcon: document.getElementById("theme-icon"), refresh: document.getElementById("refresh-button"), identity: document.getElementById("manifest-identity"), mobileMenuButton: document.getElementById("mobile-menu-button"), }; let manifest = window.__DOCSLIB_MANIFEST__ || embedded; let currentNumber = null; let syncing = false; let mobileMode = "content"; let modeBeforeSearch = "content"; let placeholderTimer = 0; let placeholderText = ""; let placeholderTarget = ""; let placeholderIndex = 0; let placeholderDots = 0; let placeholderPhase = "typing"; let lastPlaceholderPage = -1; let customScriptLoaded = false; let lastSyncedRevision = String(manifest.revision || ""); const dbPromise = new Promise((resolve, reject) => { if (!window.indexedDB) return reject(new Error("IndexedDB")); const request = indexedDB.open("docslib-cache", 1); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains("manifests")) { request.result.createObjectStore("manifests"); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); async function cachePut(value) { const db = await dbPromise; return new Promise((resolve, reject) => { const transaction = db.transaction("manifests", "readwrite"); transaction.objectStore("manifests").put(value, config.cacheKey); transaction.oncomplete = resolve; transaction.onerror = () => reject(transaction.error); }); } function manifestIdentity(value = manifest) { const updated = String(value.updated_at || "") .replace("T", " ") .replace(/\.\d+(?=Z|[+-])/, "") .replace("+00:00", "Z"); const revision = String(value.revision || "").slice(0, 8); return [updated, revision].filter(Boolean).join(" · ") || "—"; } function isManifest(value) { return Boolean( value && value.schema_version >= 2 && Array.isArray(value.pages) && value.client && value.client.css && typeof value.client.css.system === "string" && value.client.js && typeof value.client.js.system === "string" ); } function updateIdentity() { elements.identity.textContent = manifestIdentity(); elements.refresh.title = String(manifest.revision || ""); } function setSyncing(value) { syncing = value; elements.refresh.classList.toggle("loading", value); } function siblingResourceUrl(url, name) { const target = new URL(url, document.baseURI); target.pathname = target.pathname.replace(/[^/]*$/, name); target.search = ""; target.hash = ""; return target.href; } function noCacheUrl(url) { const target = new URL(url, document.baseURI); target.searchParams.set( "no_cache", `${Date.now()}_${Math.random().toString(36).slice(2)}`, ); return target.href; } function fetchNoCache(url) { return fetch(noCacheUrl(url), { cache: "no-store" }); } function routeNumber() { const match = location.hash.match(/^#\/page\/(.+)$/); return match ? decodeURIComponent(match[1]) : null; } function isPlainClick(event) { return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; } function setMobileMode(mode) { mobileMode = mode; document.body.classList.toggle("mobile-menu-open", mode === "menu"); document.body.classList.toggle("mobile-search-open", mode === "search"); } function closeSearch({ clear = false } = {}) { elements.desktopSearchShell.classList.remove("open"); if (clear) { elements.desktopSearchInput.value = ""; elements.mobileSearchInput.value = ""; } if (mobileMode === "search") setMobileMode(modeBeforeSearch); } function navigate(number, replace = false) { const hash = `#/page/${encodeURIComponent(number)}`; if (replace) history.replaceState(null, "", hash); else if (location.hash !== hash) location.hash = hash; closeSearch({ clear: true }); setMobileMode("content"); renderPage(number); } function buildTree(container) { container.replaceChildren(); const root = { children: new Map(), page: null }; manifest.pages.forEach((page) => { let node = root; page.number.split(".").forEach((part) => { if (!node.children.has(part)) node.children.set(part, { children: new Map(), page: null }); node = node.children.get(part); }); node.page = page; }); const appendNodes = (parent, node) => { node.children.forEach((child) => { const group = document.createElement("div"); group.className = "tree-group"; if (child.page) { const button = document.createElement("button"); button.className = "tree-link"; button.dataset.page = child.page.number; const number = document.createElement("span"); number.className = "tree-number"; number.textContent = `${child.page.number}.`; const title = document.createElement("span"); title.className = "tree-title"; title.textContent = child.page.title; button.append(number, title); button.addEventListener("click", () => navigate(child.page.number)); group.append(button); } if (child.children.size) { const nested = document.createElement("div"); nested.className = "tree-children"; appendNodes(nested, child); group.append(nested); } parent.append(group); }); }; appendNodes(container, root); } function buildHeaderButtons(container) { container.replaceChildren(); const buttons = (manifest.ui && manifest.ui.header_buttons) || []; buttons.forEach((button) => { if (!button.label || (!button.href && !button.page)) return; const link = document.createElement("a"); link.className = "header-link"; link.textContent = button.label; if (button.page) { link.href = `#/page/${encodeURIComponent(button.page)}`; link.dataset.page = button.page; link.addEventListener("click", (event) => { if (!isPlainClick(event)) return; event.preventDefault(); navigate(button.page); }); } else { link.href = button.href; if (button.target === "_blank") { link.target = "_blank"; link.rel = "noopener noreferrer"; } } container.append(link); }); } function assetDataUri(name) { const asset = manifest.assets && manifest.assets[name]; return asset ? `data:${asset.mime};base64,${asset.data}` : ""; } function hydrateAssets() { elements.content.querySelectorAll("[data-docslib-asset]").forEach((element) => { const name = element.dataset.docslibAsset; const uri = assetDataUri(name); if (uri && element.tagName === "IMG") element.src = uri; else if (!uri) element.setAttribute("title", name); }); } function updateActiveNavigation(number) { document.querySelectorAll(".tree-link[data-page]").forEach((button) => { button.classList.toggle("active", button.dataset.page === number); }); document.querySelectorAll(".header-link[data-page]").forEach((button) => { const target = button.dataset.page; button.classList.toggle("active", number === target || number.startsWith(`${target}.`)); }); } function setupNavButton(button, page, direction) { button.disabled = !page; button.replaceChildren(); if (!page) return; const arrow = document.createElement("span"); arrow.className = `nav-arrow nav-arrow-${direction}`; arrow.setAttribute("aria-hidden", "true"); const title = document.createElement("span"); title.textContent = page.title; if (direction === "previous") button.append(arrow, title); else button.append(title, arrow); button.onclick = () => navigate(page.number); } function renderPage(number) { if (!manifest.pages.length) { elements.content.replaceChildren(); elements.previous.disabled = elements.next.disabled = true; return; } let index = manifest.pages.findIndex((page) => page.number === number); if (index < 0) index = 0; const page = manifest.pages[index]; currentNumber = page.number; if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`); document.title = `${page.title} · ${manifest.title}`; elements.content.innerHTML = page.content || ""; hydrateAssets(); elements.content.querySelectorAll("a[href^='#/page/']").forEach((link) => { link.addEventListener("click", (event) => { if (!isPlainClick(event)) return; event.preventDefault(); navigate(decodeURIComponent(link.hash.slice(7))); }); }); updateActiveNavigation(page.number); setupNavButton(elements.previous, manifest.pages[index - 1], "previous"); setupNavButton(elements.next, manifest.pages[index + 1], "next"); elements.pageSurface.scrollTo({ top: 0, behavior: "instant" }); } function plainText(html) { const node = document.createElement("div"); node.innerHTML = html; return (node.textContent || "").replace(/\s+/g, " ").trim(); } function rankedPages(query) { const needle = query.trim().toLocaleLowerCase(); if (!needle) return []; const tokens = needle.split(/\s+/).filter(Boolean); return manifest.pages .map((page, order) => { const title = page.title.toLocaleLowerCase(); const number = page.number.toLocaleLowerCase(); const text = plainText(page.content); const haystack = `${number} ${title} ${text.toLocaleLowerCase()}`; let score = 0; if (title === needle) score += 1000; if (title.startsWith(needle)) score += 500; if (title.includes(needle)) score += 300; if (number === needle) score += 600; if (haystack.includes(needle)) score += 120; tokens.forEach((token) => { if (title.startsWith(token)) score += 80; else if (title.includes(token)) score += 45; else if (haystack.includes(token)) score += 12; }); return { page, text, score, order }; }) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score || a.order - b.order); } function renderSearchResults(container, query, limit) { container.replaceChildren(); rankedPages(query).slice(0, limit).forEach(({ page, text }) => { const button = document.createElement("button"); button.className = "search-result"; const title = document.createElement("strong"); title.textContent = `${page.number}. ${page.title}`; const excerpt = document.createElement("span"); excerpt.textContent = text.slice(0, 110) || "—"; button.append(title, excerpt); button.addEventListener("click", () => navigate(page.number)); container.append(button); }); } function handleDesktopSearch() { const query = elements.desktopSearchInput.value; renderSearchResults(elements.desktopSearchResults, query, 8); elements.desktopSearchShell.classList.toggle("open", Boolean(query.trim())); } function handleMobileSearch() { const query = elements.mobileSearchInput.value; if (query.trim()) { if (mobileMode !== "search") modeBeforeSearch = mobileMode; setMobileMode("search"); renderSearchResults(elements.mobileSearchResults, query, 8); } else if (mobileMode === "search") { setMobileMode(modeBeforeSearch); elements.mobileSearchResults.replaceChildren(); } } function choosePlaceholder() { if (!manifest.pages.length) return ""; let index = Math.floor(Math.random() * manifest.pages.length); if (manifest.pages.length > 1 && index === lastPlaceholderPage) index = (index + 1) % manifest.pages.length; lastPlaceholderPage = index; const words = manifest.pages[index].title.trim().split(/\s+/).filter(Boolean); const count = Math.min(words.length, words.length > 2 && Math.random() > .5 ? 3 : 2); return words.slice(0, Math.max(1, count)).join(" "); } function applyPlaceholder(value, dots = 0) { const display = value ? `${value}${".".repeat(dots)}` : ""; elements.desktopSearchInput.placeholder = display; elements.mobileSearchInput.placeholder = display; } function placeholderTick() { clearTimeout(placeholderTimer); if (elements.desktopSearchInput.value || elements.mobileSearchInput.value) { placeholderTimer = setTimeout(placeholderTick, 400); return; } if (!placeholderTarget) placeholderTarget = choosePlaceholder(); if (!placeholderTarget) return; if (placeholderPhase === "typing") { placeholderIndex += 1; placeholderText = placeholderTarget.slice(0, placeholderIndex); applyPlaceholder(placeholderText); if (placeholderIndex >= placeholderTarget.length) { placeholderPhase = "dots"; placeholderTimer = setTimeout(placeholderTick, 180); } else placeholderTimer = setTimeout(placeholderTick, 95 + Math.random() * 90); } else if (placeholderPhase === "dots") { placeholderDots += 1; applyPlaceholder(placeholderText, placeholderDots); if (placeholderDots >= 3) { placeholderPhase = "waiting"; placeholderTimer = setTimeout(placeholderTick, 5000); } else placeholderTimer = setTimeout(placeholderTick, 220); } else if (placeholderPhase === "waiting") { placeholderPhase = "erasing"; placeholderDots = 0; applyPlaceholder(placeholderText); placeholderTimer = setTimeout(placeholderTick, 180); } else { placeholderIndex -= 1; placeholderText = placeholderTarget.slice(0, Math.max(0, placeholderIndex)); applyPlaceholder(placeholderText); if (placeholderIndex <= 0) { placeholderTarget = choosePlaceholder(); placeholderPhase = "typing"; placeholderTimer = setTimeout(placeholderTick, 420); } else placeholderTimer = setTimeout(placeholderTick, 55); } } function startPlaceholder() { clearTimeout(placeholderTimer); placeholderText = ""; placeholderTarget = choosePlaceholder(); placeholderIndex = 0; placeholderDots = 0; placeholderPhase = "typing"; if (reducedMotion.matches) { applyPlaceholder(placeholderTarget, 3); return; } placeholderTimer = setTimeout(placeholderTick, 500); } function applyTheme(theme) { document.documentElement.dataset.theme = theme; elements.themeIcon.textContent = theme === "dark" ? "☀" : "◐"; localStorage.setItem("docslib-theme", theme); } function renderAll() { window.__DOCSLIB_MANIFEST__ = manifest; document.documentElement.lang = "und"; elements.desktopTitle.textContent = manifest.title; elements.mobileTitle.textContent = manifest.title; buildHeaderButtons(elements.desktopHeaderButtons); buildHeaderButtons(elements.mobileHeaderButtons); buildTree(elements.desktopTree); buildTree(elements.mobileTree); renderPage(routeNumber() || currentNumber || (manifest.pages[0] && manifest.pages[0].number)); updateIdentity(); startPlaceholder(); } function loadCustomScript() { if (customScriptLoaded) return; customScriptLoaded = true; const source = manifest.client && manifest.client.js && manifest.client.js.custom; if (!source || !source.trim()) return; const script = document.createElement("script"); script.id = "docslib-custom-script"; script.textContent = `${source}\n//# sourceURL=docslib-custom.js`; document.body.append(script); } function announceRendered() { try { loadCustomScript(); } catch (error) { console.error("SharedDocsLib custom_js", error); } finally { document.dispatchEvent(new CustomEvent("docslib:rendered", { detail: { manifest } })); } } async function announceManifestUpdate(nextManifest, source) { const previousRevision = lastSyncedRevision; let cached = false; try { await cachePut(nextManifest); cached = true; } catch (_) { /* the update event still reports an unavailable cache */ } lastSyncedRevision = String(nextManifest.revision || lastSyncedRevision); document.dispatchEvent(new CustomEvent("on_manifest_update", { bubbles: true, detail: { manifest: nextManifest, currentManifest: manifest, revision: nextManifest.revision, previousRevision, source, cached, applied: false, }, })); } async function syncManifest() { if (syncing) return false; setSyncing(true); let changed = false; try { if ( isManifest(embedded) && embedded.schema_version === manifest.schema_version && String(embedded.updated_at || "") > String(manifest.updated_at || "") && embedded.revision !== lastSyncedRevision ) { await announceManifestUpdate(embedded, "embedded"); changed = true; } const versionUrl = config.versionUrl || siblingResourceUrl(config.manifestUrl, "version"); const versionResponse = await fetchNoCache(versionUrl); if (!versionResponse.ok) throw new Error(`version:${versionResponse.status}`); const remoteRevision = (await versionResponse.text()).trim(); if (!remoteRevision) throw new Error("version:empty"); if (remoteRevision === lastSyncedRevision) { elements.refresh.classList.remove("error"); updateIdentity(); return changed; } const response = await fetchNoCache(config.manifestUrl); if (!response.ok) throw new Error(String(response.status)); const remote = await response.json(); if (!isManifest(remote) || remote.schema_version !== embedded.schema_version) throw new Error("manifest"); if (String(remote.revision || "") !== remoteRevision) throw new Error("version:mismatch"); const remoteChanged = remote.revision !== lastSyncedRevision; if (remoteChanged) { await announceManifestUpdate(remote, "remote"); changed = true; } elements.refresh.classList.remove("error"); updateIdentity(); return changed; } catch (error) { elements.refresh.classList.add("error"); elements.refresh.title = String(error); return false; } finally { setSyncing(false); } } async function pollLive() { try { const response = await fetch(config.liveUrl, { cache: "no-store" }); if (!response.ok) return; const live = await response.json(); if (live.revision && live.revision !== lastSyncedRevision) { const changed = await syncManifest(); if (changed) location.reload(); } } catch (_) { /* local preview may stop */ } } async function start() { applyTheme(localStorage.getItem("docslib-theme") || "dark"); renderAll(); announceRendered(); syncManifest(); if (config.livePreview) setInterval(pollLive, 1200); } elements.desktopSearchInput.addEventListener("input", handleDesktopSearch); elements.desktopSearchButton.addEventListener("click", () => elements.desktopSearchInput.focus()); elements.mobileSearchInput.addEventListener("input", handleMobileSearch); elements.mobileSearchButton.addEventListener("click", () => elements.mobileSearchInput.focus()); elements.mobileMenuButton.addEventListener("click", () => { elements.mobileSearchInput.value = ""; elements.mobileSearchResults.replaceChildren(); setMobileMode(mobileMode === "menu" ? "content" : "menu"); }); document.getElementById("theme-button").addEventListener("click", () => { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); }); elements.refresh.addEventListener("pointerenter", () => elements.toolbar.classList.add("refresh-expanded")); elements.refresh.addEventListener("pointerleave", () => elements.toolbar.classList.remove("refresh-expanded")); elements.refresh.addEventListener("focus", () => elements.toolbar.classList.add("refresh-expanded")); elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded")); elements.refresh.addEventListener("click", async () => { if (syncing) return; await syncManifest(); }); document.addEventListener("pointerdown", (event) => { if (!elements.desktopSearchShell.contains(event.target)) elements.desktopSearchShell.classList.remove("open"); }); window.addEventListener("hashchange", () => renderPage(routeNumber())); window.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { event.preventDefault(); (mobileLayout.matches ? elements.mobileSearchInput : elements.desktopSearchInput).focus(); } if (event.key === "Escape") closeSearch({ clear: true }); }); mobileLayout.addEventListener?.("change", () => { closeSearch({ clear: true }); setMobileMode("content"); }); start(); })();