- 后端 goldmark 渲染管线:/docs/:slug 页面 + /docs/search-data.json 搜索索引, manifest.json 驱动分组导航,正文剥 H1 避免与页面头重复 - 三栏布局(分组侧栏/内容/本页目录)+ 顶栏(品牌/版本徽章/搜索面板/官网入口), docs.css 品牌绿版 + docs.js(代码复制/轻量高亮/提示块/TOC 滚动高亮/移动端抽屉) - 替换 docsify;无上一页时空占位无边框;visual-judge 两轮评审(初版 fail → 修复后 pass)
197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
/* LokiStack 文档站交互:搜索 / 代码复制 / 目录高亮 / 移动端导航(参考 opsxapi 体系) */
|
|
(function () {
|
|
"use strict";
|
|
|
|
/* ---------- 代码块:语言标签 + 复制按钮 + 轻量高亮 ---------- */
|
|
document.querySelectorAll("article.md pre").forEach(function (pre) {
|
|
var code = pre.querySelector("code");
|
|
if (!code) return;
|
|
var lang = (code.className.match(/language-([\w-]+)/) || [, ""])[1];
|
|
if (lang) {
|
|
var tag = document.createElement("span");
|
|
tag.className = "lang-tag";
|
|
tag.textContent = lang;
|
|
pre.appendChild(tag);
|
|
}
|
|
// 复制按钮
|
|
var btn = document.createElement("button");
|
|
btn.className = "copy-btn";
|
|
btn.type = "button";
|
|
btn.textContent = "复制";
|
|
btn.addEventListener("click", function () {
|
|
navigator.clipboard.writeText(code.textContent).then(
|
|
function () {
|
|
btn.textContent = "已复制 ✓";
|
|
setTimeout(function () { btn.textContent = "复制"; }, 1400);
|
|
},
|
|
function () { btn.textContent = "复制失败"; }
|
|
);
|
|
});
|
|
pre.appendChild(btn);
|
|
// JSON / bash 轻量高亮
|
|
if (lang === "json") highlightJSON(code);
|
|
else if (lang === "bash" || lang === "sh" || lang === "shell") highlightBash(code);
|
|
});
|
|
|
|
function esc(s) {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
function highlightJSON(code) {
|
|
var src = code.textContent;
|
|
code.innerHTML = esc(src).replace(
|
|
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
|
function (m, str, colon, kw, num) {
|
|
if (str && colon) return '<span class="tok-key">' + str + "</span>" + colon;
|
|
if (str) return '<span class="tok-str">' + str + "</span>";
|
|
if (kw) return '<span class="tok-num">' + kw + "</span>";
|
|
if (num) return '<span class="tok-num">' + num + "</span>";
|
|
return m;
|
|
}
|
|
);
|
|
}
|
|
function highlightBash(code) {
|
|
var lines = code.textContent.split("\n");
|
|
code.innerHTML = lines
|
|
.map(function (line) {
|
|
if (/^\s*#/.test(line)) return '<span class="tok-com">' + esc(line) + "</span>";
|
|
return esc(line).replace(/(^|\s)(-{1,2}[\w-]+)/g, '$1<span class="tok-key">$2</span>');
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
/* ---------- blockquote 提示样式约定:> **提示** / > **注意** ---------- */
|
|
document.querySelectorAll("article.md blockquote").forEach(function (bq) {
|
|
var t = (bq.textContent || "").trim();
|
|
if (/^(注意|警告|⚠)/.test(t)) bq.classList.add("warn");
|
|
else if (/^(提示|说明|💡|Tip)/i.test(t)) bq.classList.add("tip");
|
|
});
|
|
|
|
/* ---------- 搜索 ---------- */
|
|
var input = document.getElementById("search-input");
|
|
var panel = document.getElementById("search-panel");
|
|
var entries = null;
|
|
var selIndex = -1;
|
|
|
|
function ensureIndex(cb) {
|
|
if (entries) return cb();
|
|
fetch("/docs/search-data.json")
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) { entries = data; cb(); })
|
|
.catch(function () { entries = []; cb(); });
|
|
}
|
|
|
|
function snippet(text, q) {
|
|
var i = text.toLowerCase().indexOf(q);
|
|
if (i < 0) return text.slice(0, 60) + "…";
|
|
var start = Math.max(0, i - 24);
|
|
return (start > 0 ? "…" : "") + text.slice(start, i + q.length + 40) + "…";
|
|
}
|
|
|
|
function doSearch() {
|
|
var q = input.value.trim().toLowerCase();
|
|
selIndex = -1;
|
|
if (!q) { panel.hidden = true; return; }
|
|
ensureIndex(function () {
|
|
var hits = [];
|
|
entries.forEach(function (e) {
|
|
var titleHit = e.title.toLowerCase().indexOf(q) >= 0;
|
|
var idx = e.text.toLowerCase().indexOf(q);
|
|
if (titleHit || idx >= 0) {
|
|
hits.push({
|
|
slug: e.slug,
|
|
title: e.title,
|
|
group: e.group,
|
|
score: (titleHit ? 10 : 0) + (idx === 0 ? 3 : 0),
|
|
snippet: titleHit ? snippet(e.text, q) : snippet(e.text, q),
|
|
});
|
|
}
|
|
});
|
|
hits.sort(function (a, b) { return b.score - a.score; });
|
|
hits = hits.slice(0, 8);
|
|
if (!hits.length) {
|
|
panel.innerHTML = '<div class="search-empty">没有找到相关内容,换个关键词试试</div>';
|
|
} else {
|
|
panel.innerHTML = hits
|
|
.map(function (h) {
|
|
return (
|
|
'<a class="search-item" href="/docs/' + h.slug + '">' +
|
|
'<span class="si-title">' + h.title + '</span><span class="si-group">' + h.group + "</span>" +
|
|
'<span class="si-snippet">' + h.snippet + "</span></a>"
|
|
);
|
|
})
|
|
.join("");
|
|
}
|
|
panel.hidden = false;
|
|
});
|
|
}
|
|
|
|
var debounce;
|
|
if (input) {
|
|
input.addEventListener("input", function () {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(doSearch, 160);
|
|
});
|
|
input.addEventListener("focus", function () {
|
|
if (input.value.trim()) doSearch();
|
|
});
|
|
input.addEventListener("keydown", function (e) {
|
|
var items = panel.querySelectorAll(".search-item");
|
|
if (e.key === "ArrowDown" && items.length) {
|
|
e.preventDefault();
|
|
selIndex = Math.min(selIndex + 1, items.length - 1);
|
|
} else if (e.key === "ArrowUp" && items.length) {
|
|
e.preventDefault();
|
|
selIndex = Math.max(selIndex - 1, 0);
|
|
} else if (e.key === "Enter") {
|
|
if (selIndex >= 0 && items[selIndex]) { location.href = items[selIndex].href; return; }
|
|
var first = items[0];
|
|
if (first) location.href = first.href;
|
|
return;
|
|
} else if (e.key === "Escape") {
|
|
panel.hidden = true;
|
|
return;
|
|
} else {
|
|
return;
|
|
}
|
|
items.forEach(function (el, i) { el.classList.toggle("sel", i === selIndex); });
|
|
});
|
|
document.addEventListener("click", function (e) {
|
|
if (!e.target.closest(".search")) panel.hidden = true;
|
|
});
|
|
}
|
|
|
|
/* ---------- 目录滚动高亮 ---------- */
|
|
var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc-nav a"));
|
|
if (tocLinks.length && "IntersectionObserver" in window) {
|
|
var byId = {};
|
|
tocLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
|
|
var io = new IntersectionObserver(
|
|
function (entries) {
|
|
entries.forEach(function (en) {
|
|
if (en.isIntersecting) {
|
|
tocLinks.forEach(function (a) { a.classList.remove("active"); });
|
|
var a = byId[en.target.id];
|
|
if (a) a.classList.add("active");
|
|
}
|
|
});
|
|
},
|
|
{ rootMargin: "-72px 0px -70% 0px", threshold: 0 }
|
|
);
|
|
document.querySelectorAll("article.md h2[id], article.md h3[id]").forEach(function (h) { io.observe(h); });
|
|
}
|
|
|
|
|
|
|
|
/* ---------- 移动端侧栏 ---------- */
|
|
var menuBtn = document.getElementById("menu-btn");
|
|
var sidebar = document.getElementById("sidebar");
|
|
if (menuBtn && sidebar) {
|
|
menuBtn.addEventListener("click", function () { sidebar.classList.toggle("open"); });
|
|
document.addEventListener("click", function (e) {
|
|
if (sidebar.classList.contains("open") && !e.target.closest(".sidebar") && !e.target.closest(".menu-btn")) {
|
|
sidebar.classList.remove("open");
|
|
}
|
|
});
|
|
}
|
|
})();
|