Z ZPI 文档 v0.3.8
介绍页

源码

scripts/docs/app.js

javascript455 行查看原文
const root = document.documentElement;
const body = document.body;
const THEME_KEY = "zpi-docs-theme";
const docsBase = document.querySelector('meta[name="zpi-docs-base"]')?.getAttribute("content") ?? "";
const docsUrl = (path) => `${docsBase}${path}`;
const isMac = /Mac|iPhone|iPad/.test(navigator.platform) || /Mac OS X/.test(navigator.userAgent);

if (!isMac) {
  for (const key of document.querySelectorAll("kbd")) {
    if (key.textContent.trim() === "⌘K") key.textContent = "Ctrl K";
  }
}

function currentTheme() {
  return root.dataset.theme === "dark" ? "dark" : "light";
}

function applyTheme(theme) {
  root.dataset.theme = theme;
  try {
    localStorage.setItem(THEME_KEY, theme);
  } catch {
    // Storage can be unavailable in private modes; the theme still applies.
  }
}

document.getElementById("theme-toggle")?.addEventListener("click", () => {
  applyTheme(currentTheme() === "dark" ? "light" : "dark");
  renderMermaid();
});

document.getElementById("sidebar-toggle")?.addEventListener("click", () => {
  body.classList.toggle("sidebar-open");
});
document.querySelector("[data-sidebar-close]")?.addEventListener("click", () => {
  body.classList.remove("sidebar-open");
});
for (const link of document.querySelectorAll(".nav-item")) {
  link.addEventListener("click", () => body.classList.remove("sidebar-open"));
}

function escapeHtml(value) {
  return String(value).replace(
    /[&<>"']/g,
    (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character],
  );
}

function escapeRegExp(value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function markText(text, query) {
  const escaped = escapeHtml(text);
  if (!query) return escaped;
  return escaped.replace(
    new RegExp(escapeRegExp(escapeHtml(query)), "gi"),
    (match) => `<mark>${match}</mark>`,
  );
}

function snippet(text, query) {
  if (!query) return text.slice(0, 140);
  const index = text.toLowerCase().indexOf(query.toLowerCase());
  if (index < 0) return text.slice(0, 140);
  const start = Math.max(0, index - 40);
  const end = Math.min(text.length, start + 170);
  return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
}

const modal = document.getElementById("search-modal");
const input = document.getElementById("search-input");
const results = document.getElementById("search-results");
const openButton = document.getElementById("search-open");
let searchIndex;
let searchEntries = [];
let activeIndex = -1;

function loadSearchIndex() {
  searchIndex ??= fetch(docsUrl("/__search.json"))
    .then((response) => {
      if (!response.ok) throw new Error(`搜索索引加载失败(${response.status})`);
      return response.json();
    })
    .then((data) => {
      searchEntries = Array.isArray(data.entries) ? data.entries : [];
      return searchEntries;
    })
    .catch((error) => {
      searchIndex = undefined;
      throw error;
    });
  return searchIndex;
}

function scoreEntry(entry, query) {
  const needle = query.toLowerCase();
  const heading = (entry.heading || "").toLowerCase();
  const title = entry.title.toLowerCase();
  const text = entry.text.toLowerCase();
  if (!heading.includes(needle) && !title.includes(needle) && !text.includes(needle)) return -1;
  let score = 10;
  if (heading === needle) score += 100;
  else if (heading.startsWith(needle)) score += 70;
  else if (heading.includes(needle)) score += 50;
  if (title.includes(needle)) score += 30;
  score += Math.min(text.split(needle).length - 1, 5) * 4;
  return score;
}

function resultHref(entry) {
  return `${entry.url}${entry.id ? `#${encodeURIComponent(entry.id)}` : ""}`;
}

function resultHtml(entry, query) {
  const heading = entry.heading || entry.title;
  const context = entry.heading ? entry.title : "文档";
  return `<a class="search-result" href="${resultHref(entry)}">
    <span class="search-result-head"><span class="search-result-title">${markText(heading, query)}</span><span class="search-result-context">${escapeHtml(context)}</span></span>
    <span class="search-result-text">${markText(snippet(entry.text, query), query)}</span>
  </a>`;
}

function updateActiveResult() {
  const items = [...results.querySelectorAll(".search-result")];
  items.forEach((item, index) => {
    item.classList.toggle("active", index === activeIndex);
  });
  items[activeIndex]?.scrollIntoView({ block: "nearest" });
}

function renderResults(query) {
  const needle = query.trim();
  if (!needle) {
    results.innerHTML = '<p class="search-empty">输入关键词开始搜索</p>';
    activeIndex = -1;
    return;
  }
  const matches = searchEntries
    .map((entry) => ({ entry, score: scoreEntry(entry, needle) }))
    .filter((match) => match.score >= 0)
    .sort((left, right) => right.score - left.score)
    .slice(0, 20);
  if (!matches.length) {
    results.innerHTML = `<p class="search-empty">没有找到“${escapeHtml(needle)}”</p>`;
    activeIndex = -1;
    return;
  }
  results.innerHTML = matches.map((match) => resultHtml(match.entry, needle)).join("");
  activeIndex = 0;
  updateActiveResult();
}

function openSearch() {
  modal.hidden = false;
  body.classList.add("search-open");
  input.value = "";
  renderResults("");
  input.focus();
  loadSearchIndex()
    .then(() => renderResults(input.value))
    .catch((error) => {
      results.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
    });
}

function closeSearch({ restoreFocus = true } = {}) {
  modal.hidden = true;
  body.classList.remove("search-open");
  if (restoreFocus) openButton?.focus();
}

openButton?.addEventListener("click", openSearch);
for (const closer of document.querySelectorAll("[data-search-close]")) {
  closer.addEventListener("click", closeSearch);
}

input?.addEventListener("input", () => renderResults(input.value));
input?.addEventListener("keydown", (event) => {
  const items = [...results.querySelectorAll(".search-result")];
  if (event.key === "ArrowDown") {
    event.preventDefault();
    activeIndex = items.length ? (activeIndex + 1) % items.length : -1;
    updateActiveResult();
  } else if (event.key === "ArrowUp") {
    event.preventDefault();
    activeIndex = items.length ? (activeIndex - 1 + items.length) % items.length : -1;
    updateActiveResult();
  } else if (event.key === "Enter") {
    event.preventDefault();
    (items[activeIndex] ?? items[0])?.click();
  } else if (event.key === "Escape") {
    event.preventDefault();
    closeSearch();
  }
});

document.addEventListener("keydown", (event) => {
  const command = isMac ? event.metaKey : event.ctrlKey;
  if (command && event.key.toLowerCase() === "k") {
    event.preventDefault();
    modal.hidden ? openSearch() : closeSearch();
  } else if (event.key === "Escape" && !modal.hidden) {
    closeSearch();
  } else if (
    event.key === "/" &&
    modal.hidden &&
    !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)
  ) {
    event.preventDefault();
    openSearch();
  }
});

function fallbackCopy(text) {
  const area = document.createElement("textarea");
  area.value = text;
  area.style.position = "fixed";
  area.style.opacity = "0";
  document.body.append(area);
  area.select();
  document.execCommand("copy");
  area.remove();
}

function setupCopyButtons(scope = document) {
  for (const pre of scope.querySelectorAll(".markdown pre")) {
    if (pre.parentElement?.classList.contains("code-shell")) continue;
    const shell = document.createElement("div");
    shell.className = "code-shell";
    pre.before(shell);
    shell.append(pre);
    const button = document.createElement("button");
    button.type = "button";
    button.className = "copy-button";
    button.textContent = "复制";
    button.addEventListener("click", async () => {
      const text = pre.textContent;
      try {
        await navigator.clipboard.writeText(text);
      } catch {
        fallbackCopy(text);
      }
      button.textContent = "已复制";
      button.classList.add("copied");
      setTimeout(() => {
        button.textContent = "复制";
        button.classList.remove("copied");
      }, 1600);
    });
    shell.append(button);
  }
}

let mermaidLoader;

function loadMermaid() {
  if (window.mermaid) return Promise.resolve(window.mermaid);
  mermaidLoader ??= new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = docsUrl("/__vendor/mermaid.min.js");
    script.onload = () => resolve(window.mermaid);
    script.onerror = () => reject(new Error("Mermaid 资源加载失败"));
    document.head.append(script);
  });
  return mermaidLoader;
}

async function renderMermaid() {
  const nodes = [...document.querySelectorAll(".mermaid")];
  if (!nodes.length) return;
  for (const node of nodes) {
    if (!node.dataset.source) node.dataset.source = node.textContent;
  }
  try {
    const mermaid = await loadMermaid();
    mermaid.initialize({
      startOnLoad: false,
      securityLevel: "loose",
      theme: currentTheme() === "dark" ? "dark" : "default",
      fontFamily: 'system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
    });
    for (const node of nodes) {
      node.removeAttribute("data-processed");
      node.classList.remove("mermaid-error");
      node.innerHTML = node.dataset.source;
      try {
        await mermaid.run({ nodes: [node] });
      } catch (error) {
        node.classList.add("mermaid-error");
        node.textContent = node.dataset.source;
        console.error(error);
      }
    }
  } catch (error) {
    console.error(error);
    for (const node of nodes) node.classList.add("mermaid-error");
  }
}

let scrollSpyObserver;

function setupScrollSpy() {
  scrollSpyObserver?.disconnect();
  scrollSpyObserver = undefined;
  const links = [...document.querySelectorAll(".toc-link")];
  if (!links.length) return;
  const byId = new Map(
    links.map((link) => {
      const id = decodeURIComponent(link.hash.slice(1));
      return [id, link];
    }),
  );
  const headings = [...document.querySelectorAll(".markdown h2[id], .markdown h3[id]")];
  scrollSpyObserver = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        for (const link of links) link.classList.remove("active");
        byId.get(entry.target.id)?.classList.add("active");
      }
    },
    { rootMargin: "-72px 0px -70% 0px", threshold: 0 },
  );
  for (const heading of headings) scrollSpyObserver.observe(heading);
}

function isDocsPagePath(pathname) {
  const intro = docsUrl("/intro/");
  if (pathname === intro || pathname === intro.slice(0, -1)) return false;
  if (!docsBase) return true;
  return pathname === docsBase || pathname === `${docsBase}/` || pathname.startsWith(`${docsBase}/`);
}

function updateSidebarActive(pathname) {
  for (const link of document.querySelectorAll(".nav-item")) {
    const active = new URL(link.href, location.href).pathname === pathname;
    link.classList.toggle("active", active);
    if (active) link.setAttribute("aria-current", "page");
    else link.removeAttribute("aria-current");
  }
}

let navigationController;

async function navigate(url, { push = true, force = false } = {}) {
  const target = new URL(url, location.href);
  if (!force && target.href === location.href && !target.hash) return;
  navigationController?.abort();
  const controller = new AbortController();
  navigationController = controller;
  const currentMain = document.querySelector("main.content");
  currentMain?.classList.add("is-loading");
  currentMain?.setAttribute("aria-busy", "true");
  try {
    const response = await fetch(target.href, {
      headers: { Accept: "text/html" },
      signal: controller.signal,
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    if (!(response.headers.get("content-type") ?? "").includes("text/html")) {
      throw new Error("不是 HTML 页面");
    }
    const html = await response.text();
    if (controller.signal.aborted) return;
    const parsed = new DOMParser().parseFromString(html, "text/html");
    const nextMain = parsed.querySelector("main.content");
    const nextLayout = parsed.querySelector(".layout");
    if (!nextMain || !nextLayout) throw new Error("不是文档页面");
    const finalUrl = new URL(response.url || target.href);
    finalUrl.hash = target.hash;
    const nextToc = nextLayout.querySelector(".toc");
    const currentLayout = document.querySelector(".layout");
    const currentToc = currentLayout?.querySelector(".toc");
    if (nextToc) {
      if (currentToc) currentToc.replaceWith(nextToc);
      else currentLayout?.append(nextToc);
      currentLayout?.classList.remove("no-toc");
    } else {
      currentToc?.remove();
      currentLayout?.classList.add("no-toc");
    }
    currentMain.replaceWith(nextMain);
    nextMain.classList.remove("is-loading");
    nextMain.removeAttribute("aria-busy");
    document.title = parsed.title || document.title;
    const nextDescription = parsed.querySelector('meta[name="description"]')?.getAttribute("content");
    if (nextDescription) {
      document.querySelector('meta[name="description"]')?.setAttribute("content", nextDescription);
    }
    updateSidebarActive(finalUrl.pathname);
    if (!modal.hidden) closeSearch({ restoreFocus: false });
    body.classList.remove("sidebar-open");
    if (push) history.pushState({}, "", finalUrl.href);
    setupCopyButtons(nextMain);
    setupScrollSpy();
    renderMermaid();
    const hash = decodeURIComponent(finalUrl.hash.slice(1));
    const anchor = hash ? document.getElementById(hash) : null;
    if (anchor) anchor.scrollIntoView({ block: "start" });
    else window.scrollTo({ top: 0, left: 0, behavior: "instant" });
  } catch (error) {
    if (controller.signal.aborted || error.name === "AbortError") return;
    currentMain?.classList.remove("is-loading");
    currentMain?.removeAttribute("aria-busy");
    location.href = target.href;
  } finally {
    if (navigationController === controller) navigationController = undefined;
  }
}

document.addEventListener("click", (event) => {
  if (
    event.defaultPrevented ||
    event.button !== 0 ||
    event.metaKey ||
    event.ctrlKey ||
    event.shiftKey ||
    event.altKey
  ) {
    return;
  }
  const anchor = event.target instanceof Element ? event.target.closest("a[href]") : null;
  if (!anchor || anchor.target || anchor.hasAttribute("download")) return;
  const url = new URL(anchor.href, location.href);
  if (url.origin !== location.origin) return;
  if (url.searchParams.has("raw")) return;
  if (url.pathname === location.pathname) {
    if (!modal.hidden) closeSearch({ restoreFocus: false });
    if (url.hash) return;
    if (url.search && url.search !== location.search) return;
    event.preventDefault();
    window.scrollTo({ top: 0, behavior: "smooth" });
    return;
  }
  if (!isDocsPagePath(url.pathname)) return;
  event.preventDefault();
  navigate(url.href);
});

window.addEventListener("popstate", () => {
  navigate(location.href, { push: false, force: true });
});

setupCopyButtons();
setupScrollSpy();
renderMermaid();

if (document.querySelector('meta[name="zpi-live"]')) {
  const events = new EventSource(docsUrl("/__events"));
  events.onmessage = (event) => {
    if (event.data === "reload") location.reload();
  };
}