Files
sunvpy-docs/docs/js/app.js
2026-04-10 13:47:53 +08:00

470 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* app.js — Main logic for sunvpy static documentation site.
* Handles routing, markdown rendering, navigation, TOC, and theme.
*/
(function () {
"use strict";
// --- State ---
let navData = null;
let flatPages = []; // ordered array of all slugs
let currentPage = null;
let isDark = false;
let mermaidInitialized = false;
// --- DOM refs ---
const sidebarNav = document.getElementById("sidebarNav");
const article = document.getElementById("article");
const pageNav = document.getElementById("pageNav");
const tocNav = document.getElementById("tocNav");
const contentEl = document.getElementById("content");
// --- Init ---
async function init() {
// Theme
const saved = localStorage.getItem("sunvpy-theme");
if (saved === "dark" || (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
setTheme(true, false);
}
// Load nav
try {
const resp = await fetch("data/nav.json");
navData = await resp.json();
} catch (e) {
article.innerHTML = '<div class="loading">无法加载导航数据。请通过 HTTP 服务器访问(如 python -m http.server。</div>';
return;
}
// Build flat page list
flatPages = [];
navData.sections.forEach(function (section) {
if (section.pages) {
section.pages.forEach(function (p) { flatPages.push(p.slug); });
}
if (section.groups) {
section.groups.forEach(function (group) {
group.pages.forEach(function (p) { flatPages.push(p.slug); });
});
}
});
// Build sidebar
renderSidebar();
// Route
handleRoute();
window.addEventListener("hashchange", handleRoute);
// Theme toggle buttons
document.getElementById("themeToggle").addEventListener("click", function () { setTheme(!isDark, true); });
document.getElementById("themeToggleMobile").addEventListener("click", function () { setTheme(!isDark, true); });
// Mobile sidebar
document.getElementById("hamburger").addEventListener("click", toggleMobileSidebar);
document.getElementById("sidebarBackdrop").addEventListener("click", closeMobileSidebar);
}
// --- Theme ---
function setTheme(dark, save) {
isDark = dark;
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
const lightSheet = document.getElementById("hljs-light-theme");
const darkSheet = document.getElementById("hljs-dark-theme");
lightSheet.disabled = dark;
darkSheet.disabled = !dark;
if (save) localStorage.setItem("sunvpy-theme", dark ? "dark" : "light");
// Re-render mermaid if already initialized
if (mermaidInitialized && currentPage) {
rerenderMermaid();
}
}
// --- Sidebar ---
function renderSidebar() {
var html = "";
navData.sections.forEach(function (section) {
var isActive = currentPage && isSectionActive(section);
html += '<div class="nav-section' + (isActive ? "" : " collapsed") + '" data-section="' + section.id + '">';
html += '<div class="nav-section-header" onclick="toggleSection(this)">';
html += '<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>';
html += escHtml(section.title);
html += "</div>";
html += '<div class="nav-section-body">';
if (section.pages) {
section.pages.forEach(function (page) {
html += renderPageLink(page);
});
}
if (section.groups) {
section.groups.forEach(function (group) {
html += '<div class="nav-group">';
html += '<div class="nav-group-label">' + escHtml(group.title) + "</div>";
group.pages.forEach(function (page) {
html += renderPageLink(page);
});
html += "</div>";
});
}
html += "</div></div>";
});
sidebarNav.innerHTML = html;
}
function renderPageLink(page) {
var active = currentPage === page.slug ? " active" : "";
var levelClass = page.level ? page.level.toLowerCase() : "";
var badge = page.level ? ' <span class="level-badge ' + levelClass + '">' + escHtml(page.level.charAt(0)) + "</span>" : "";
return '<a class="nav-page' + active + '" href="#' + page.slug + '">' + escHtml(page.title) + badge + "</a>";
}
function isSectionActive(section) {
if (!currentPage) return false;
if (section.pages) {
for (var i = 0; i < section.pages.length; i++) {
if (section.pages[i].slug === currentPage) return true;
}
}
if (section.groups) {
for (var g = 0; g < section.groups.length; g++) {
for (var j = 0; j < section.groups[g].pages.length; j++) {
if (section.groups[g].pages[j].slug === currentPage) return true;
}
}
}
return false;
}
// Expose toggleSection globally for onclick
window.toggleSection = function (header) {
header.parentElement.classList.toggle("collapsed");
};
// --- Routing ---
function handleRoute() {
var hash = window.location.hash.slice(1) || flatPages[0];
if (!hash || hash === currentPage) return;
loadPage(hash);
}
// --- Load & Render Page ---
async function loadPage(slug) {
currentPage = slug;
// Update sidebar active state
renderSidebar();
// Scroll content to top
contentEl.scrollTop = 0;
// Show loading
article.innerHTML = '<div class="loading">加载中...</div>';
tocNav.innerHTML = "";
pageNav.innerHTML = "";
// Fetch markdown
var md;
try {
var resp = await fetch("content/" + slug + ".md");
if (!resp.ok) throw new Error(resp.status);
md = await resp.text();
} catch (e) {
article.innerHTML = '<div class="loading">无法加载页面: ' + escHtml(slug) + "</div>";
return;
}
// Pre-process: transform cross-doc links
md = md.replace(/\]\((\d+-[\w-]+)\)/g, function (match, linkSlug) {
// Only transform if it matches a known page slug
if (flatPages.indexOf(linkSlug) !== -1) {
return "](#" + linkSlug + ")";
}
return match;
});
// Configure marked
var renderer = new marked.Renderer();
// Custom paragraph handler for "Sources:" lines
renderer.paragraph = function (obj) {
var text = typeof obj === "object" ? obj.text : obj;
if (text && (text.indexOf("Sources:") === 0 || text.indexOf("Sources: [") === 0)) {
return '<div class="source-ref">' + text + "</div>";
}
return "<p>" + text + "</p>";
};
// Add IDs to headings
var headingIndex = {};
renderer.heading = function (text, depth) {
// marked v15 passes heading data as object or text+depth
var headingText = typeof text === "object" ? text.text : text;
var headingDepth = typeof text === "object" ? text.depth : depth;
var raw = headingText.replace(/<[^>]+>/g, "");
var baseId = raw.toLowerCase()
.replace(/[^\w\u4e00-\u9fff]+/g, "-")
.replace(/^-|-$/g, "");
if (!baseId) baseId = "heading";
if (headingIndex[baseId] !== undefined) {
headingIndex[baseId]++;
baseId = baseId + "-" + headingIndex[baseId];
} else {
headingIndex[baseId] = 0;
}
return '<h' + headingDepth + ' id="' + escAttr(baseId) + '">' + headingText + "</h" + headingDepth + ">";
};
// Code block handler
renderer.code = function (obj) {
var code, lang;
if (typeof obj === "object") {
code = obj.text;
lang = obj.lang || "";
} else {
code = obj;
lang = "";
}
if (lang === "mermaid") {
return '<div class="mermaid-wrapper"><div class="mermaid" data-mermaid-source="' + escAttr(code) + '">' + escHtml(code) + "</div></div>";
}
var highlighted = "";
try {
if (lang && hljs.getLanguage(lang)) {
highlighted = hljs.highlight(code, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(code).value;
}
} catch (e) {
highlighted = escHtml(code);
}
var langLabel = lang ? '<span class="code-lang">' + escHtml(lang) + "</span>" : "";
return "<pre>" + langLabel + '<code class="hljs language-' + escAttr(lang) + '">' + highlighted + "</code><button class=\"copy-btn\" onclick=\"copyCode(this)\">复制</button></pre>";
};
marked.use({
renderer: renderer,
gfm: true,
breaks: false,
});
// Render
var html;
try {
html = marked.parse(md);
} catch (e) {
console.error("Markdown render error:", e);
article.innerHTML = '<div class="loading">渲染出错: ' + escHtml(String(e)) + '</div>';
return;
}
article.innerHTML = html;
// Add page title from nav data
var pageInfo = findPageInfo(slug);
if (pageInfo) {
var titleDiv = document.createElement("div");
titleDiv.className = "page-title";
titleDiv.innerHTML = "<h1>" + escHtml(pageInfo.title) + "</h1>";
if (pageInfo.section) {
titleDiv.innerHTML += '<div class="page-meta">' + escHtml(pageInfo.section);
if (pageInfo.level) titleDiv.innerHTML += ' · <span class="level-badge ' + pageInfo.level.toLowerCase() + '">' + escHtml(pageInfo.level) + "</span>";
titleDiv.innerHTML += "</div>";
}
article.insertBefore(titleDiv, article.firstChild);
}
// Render mermaid diagrams
await renderMermaid();
// Build TOC
buildTOC();
// Prev/Next navigation
renderPageNav();
// Update document title
document.title = (pageInfo ? pageInfo.title + " — " : "") + "sunvpy 文档";
}
// --- Mermaid ---
async function renderMermaid() {
var mermaidDivs = article.querySelectorAll(".mermaid");
if (mermaidDivs.length === 0) return;
if (!mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: isDark ? "dark" : "default",
securityLevel: "loose",
maxTextSize: 90000,
});
mermaidInitialized = true;
}
try {
await mermaid.run({ nodes: mermaidDivs, suppressErrors: true });
} catch (e) {
console.warn("Mermaid render error:", e);
}
}
async function rerenderMermaid() {
var wrappers = article.querySelectorAll(".mermaid-wrapper");
wrappers.forEach(function (wrapper) {
var div = wrapper.querySelector(".mermaid");
if (!div) return;
var source = div.getAttribute("data-mermaid-source");
if (source) {
div.removeAttribute("data-processed");
div.innerHTML = escHtml(source);
}
});
mermaid.initialize({
startOnLoad: false,
theme: isDark ? "dark" : "default",
securityLevel: "loose",
maxTextSize: 90000,
});
try {
await mermaid.run({ nodes: article.querySelectorAll(".mermaid"), suppressErrors: true });
} catch (e) {
console.warn("Mermaid re-render error:", e);
}
}
// --- TOC ---
function buildTOC() {
var headings = article.querySelectorAll("h2, h3");
if (headings.length === 0) {
tocNav.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem">无目录</div>';
return;
}
var html = "<ul>";
headings.forEach(function (h) {
var cls = h.tagName === "H3" ? "toc-h3" : "";
html += '<li><a class="' + cls + '" href="#' + escAttr(h.id) + '">' + escHtml(h.textContent) + "</a></li>";
});
html += "</ul>";
tocNav.innerHTML = html;
// Scroll spy
setupScrollSpy(headings);
}
function setupScrollSpy(headings) {
var links = tocNav.querySelectorAll("a");
contentEl.removeEventListener("scroll", scrollSpyHandler);
contentEl.addEventListener("scroll", scrollSpyHandler);
function scrollSpyHandler() {
var scrollTop = contentEl.scrollTop;
var activeIdx = 0;
for (var i = headings.length - 1; i >= 0; i--) {
if (headings[i].offsetTop - 100 <= scrollTop) {
activeIdx = i;
break;
}
}
links.forEach(function (link, idx) {
link.classList.toggle("active", idx === activeIdx);
});
}
}
// --- Prev/Next ---
function renderPageNav() {
var idx = flatPages.indexOf(currentPage);
if (idx === -1) { pageNav.innerHTML = ""; return; }
var html = "";
if (idx > 0) {
var prevInfo = findPageInfo(flatPages[idx - 1]);
html += '<a class="page-nav-btn prev" href="#' + flatPages[idx - 1] + '">';
html += '<span class="label">上一节</span>';
html += '<span class="title">' + escHtml(prevInfo ? prevInfo.title : flatPages[idx - 1]) + "</span>";
html += "</a>";
} else {
html += "<div></div>";
}
if (idx < flatPages.length - 1) {
var nextInfo = findPageInfo(flatPages[idx + 1]);
html += '<a class="page-nav-btn next" href="#' + flatPages[idx + 1] + '">';
html += '<span class="label">下一节</span>';
html += '<span class="title">' + escHtml(nextInfo ? nextInfo.title : flatPages[idx + 1]) + "</span>";
html += "</a>";
}
pageNav.innerHTML = html;
}
// --- Helpers ---
function findPageInfo(slug) {
for (var s = 0; s < navData.sections.length; s++) {
var section = navData.sections[s];
if (section.pages) {
for (var i = 0; i < section.pages.length; i++) {
if (section.pages[i].slug === slug) {
return Object.assign({}, section.pages[i], { section: section.title });
}
}
}
if (section.groups) {
for (var g = 0; g < section.groups.length; g++) {
for (var j = 0; j < section.groups[g].pages.length; j++) {
if (section.groups[g].pages[j].slug === slug) {
return Object.assign({}, section.groups[g].pages[j], { section: section.title });
}
}
}
}
}
return null;
}
function escHtml(s) {
if (!s) return "";
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escAttr(s) {
return escHtml(s);
}
// Copy code to clipboard
window.copyCode = function (btn) {
var code = btn.parentElement.querySelector("code");
var text = code.textContent;
navigator.clipboard.writeText(text).then(function () {
btn.textContent = "已复制";
setTimeout(function () { btn.textContent = "复制"; }, 1500);
});
};
// --- Mobile sidebar ---
function toggleMobileSidebar() {
document.getElementById("sidebar").classList.toggle("open");
document.getElementById("sidebarBackdrop").classList.toggle("open");
}
function closeMobileSidebar() {
document.getElementById("sidebar").classList.remove("open");
document.getElementById("sidebarBackdrop").classList.remove("open");
}
// Close mobile sidebar on nav
document.addEventListener("click", function (e) {
if (e.target.classList.contains("nav-page")) {
closeMobileSidebar();
}
});
// --- Start ---
init();
})();