Files
KindClustersDashboard/app/static/js/documentation.js
T
Sergey Antropoff c49555b3b9 docs: скриншоты UI (светлая/тёмная тема) в app/docs и README
- app/docs/screenshots.md и каталог app/docs/images/*.png
- раздача /static/docs-images/* из FastAPI; documentation.js переписывает src картинок
- стили .markdown-body img; строка в api_routes.md; превью в README
2026-04-05 01:18:22 +03:00

405 lines
14 KiB
JavaScript
Raw 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.
/**
* Страница /documentation: README и файлы app/docs/*.md через API, рендер Markdown.
* Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
var body = document.body;
var API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
var DOC_BASE = "/documentation";
function showErr(el, msg) {
if (!el) return;
el.textContent = msg;
el.classList.remove("hidden");
}
function hideErr(el) {
if (!el) return;
el.textContent = "";
el.classList.add("hidden");
}
/** Полноэкранный спиннер как на панели (page-loading-overlay в documentation.html). */
function showDocumentationPageLoadingOverlay() {
var el = document.getElementById("documentation-page-loading-overlay");
if (!el) return;
el.classList.remove("hidden");
el.setAttribute("aria-busy", "true");
el.setAttribute("aria-hidden", "false");
document.body.classList.add("documentation-page-loading");
}
function hideDocumentationPageLoadingOverlay() {
var el = document.getElementById("documentation-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("documentation-page-loading");
}
function getPathFromLocation() {
var q = new URLSearchParams(window.location.search || "");
var p = (q.get("path") || "").trim();
return p || null;
}
function docUrlForPath(relPath) {
if (!relPath) return DOC_BASE;
return DOC_BASE + "?path=" + encodeURIComponent(relPath);
}
/**
* Хлебные крошки: Документация → файл (для app/docs/* кроме api_routes — ссылка «app/docs» на api_routes.md).
*/
function updateBreadcrumbs(docPath) {
var ol = document.getElementById("doc-breadcrumbs-list");
if (!ol) return;
ol.replaceChildren();
function addLiLink(href, text) {
var li = document.createElement("li");
li.className = "doc-breadcrumbs__item";
var a = document.createElement("a");
a.href = href;
a.className = "doc-breadcrumbs__link";
a.textContent = text;
li.appendChild(a);
ol.appendChild(li);
}
function addLiCurrent(text) {
var li = document.createElement("li");
li.className = "doc-breadcrumbs__item doc-breadcrumbs__item--current";
li.setAttribute("aria-current", "page");
li.textContent = text;
ol.appendChild(li);
}
addLiLink(DOC_BASE, "Документация");
if (!docPath) {
addLiCurrent("README.md (корень репозитория)");
return;
}
if (docPath.indexOf("app/docs/") === 0) {
if (docPath === "app/docs/api_routes.md") {
addLiCurrent("api_routes.md");
} else {
addLiLink(docUrlForPath("app/docs/api_routes.md"), "app/docs");
addLiCurrent(docPath.replace(/^.*\//, ""));
}
} else {
addLiCurrent(docPath);
}
}
/**
* Внутренняя ссылка на .md под app/docs/: полный путь или относительно текущего файла.
*/
function resolveInternalMdHref(href, currentDocPath) {
if (!href) return null;
var h = href.trim();
if (h.startsWith("#")) return null;
if (/^https?:\/\//i.test(h)) return null;
if (/^mailto:/i.test(h)) return null;
var abs = h.match(/^(\.\/)?(app\/docs\/[^?#]+\.md)([\?#].*)?$/i);
if (abs) {
return { path: abs[2].replace(/\\/g, "/"), tail: abs[3] || "" };
}
if (!currentDocPath || currentDocPath.indexOf("app/docs/") !== 0) return null;
if (h.indexOf("..") >= 0) return null;
var baseDir = currentDocPath.replace(/[^/]+\.md$/i, "");
if (!baseDir) baseDir = "app/docs/";
try {
var u = new URL(h, "http://doc.local/" + baseDir);
var pathname = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
if (pathname.indexOf("..") >= 0) return null;
if (!pathname.startsWith("app/docs/") || !pathname.endsWith(".md")) return null;
var tail = (u.hash || "") + (u.search || "");
return { path: pathname, tail: tail };
} catch (e) {
return null;
}
}
function rewriteMdLinks(container, currentDocPath) {
container.querySelectorAll("a[href]").forEach(function (a) {
var href = a.getAttribute("href");
var resolved = resolveInternalMdHref(href, currentDocPath);
if (!resolved) return;
a.setAttribute("href", docUrlForPath(resolved.path) + resolved.tail);
a.classList.add("doc-md-link");
});
}
/**
* Картинки из app/docs/*.md с src вида images/foo.png → /static/docs-images/foo.png
* (каталог app/docs/images смонтирован в FastAPI как StaticFiles).
*/
function rewriteMdImages(container, currentDocPath) {
if (!currentDocPath || currentDocPath.indexOf("app/docs/") !== 0) return;
var baseDir = currentDocPath.replace(/[^/]+\.md$/i, "");
if (!baseDir) return;
container.querySelectorAll("img[src]").forEach(function (img) {
var src = (img.getAttribute("src") || "").trim();
if (!src || /^https?:\/\//i.test(src) || src.startsWith("data:")) return;
if (src.indexOf("..") >= 0) return;
if (src.startsWith("/static/docs-images/")) return;
try {
var u = new URL(src, "http://doc.local/" + baseDir);
var pathname = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
if (pathname.indexOf("..") >= 0) return;
if (!pathname.startsWith("app/docs/images/")) return;
var rest = pathname.replace(/^app\/docs\/images\/?/, "");
if (!rest) return;
img.setAttribute("src", "/static/docs-images/" + rest);
} catch (e) {
/* ignore */
}
});
}
/** Текст первого H1 в разобранном HTML (для заголовка вкладки). */
function extractFirstH1Text(root) {
var h = root.querySelector("h1");
if (!h) return "";
return (h.textContent || "").replace(/\s+/g, " ").trim();
}
/**
* Заголовок вкладки: «Документация — <первый H1> — <app_title>» (как в api_routes: заголовок документа).
*/
function applyDocumentationTabTitle(h1Text) {
var appTitle = (body.dataset.appTitle || "").trim() || "kind";
if (h1Text) {
document.title = "Документация — " + h1Text + " — " + appTitle;
} else {
document.title = "Документация — " + appTitle;
}
}
/**
* До первого H2 — одна карточка; для каждого H2 — одна карточка: заголовок h2 и всё содержимое до следующего H2.
*/
function sectionizeFromNodes(sourceRoot) {
var page = document.createElement("div");
page.className = "readme-doc-page-inner";
var root = document.createElement("div");
root.className = "readme-doc-root";
page.appendChild(root);
var nodes = Array.prototype.slice.call(sourceRoot.childNodes);
var i = 0;
function appendCard(classExtra, chunk) {
if (!chunk.length) return;
var card = document.createElement("section");
card.className = "card readme-section-card markdown-body " + classExtra;
chunk.forEach(function (n) {
card.appendChild(n);
});
root.appendChild(card);
}
function flushIntro() {
var chunk = [];
while (i < nodes.length) {
var n = nodes[i];
if (n.nodeType === 1 && n.tagName === "H2") break;
chunk.push(n);
i++;
}
appendCard("readme-section-card--intro", chunk);
}
function flushH2Section() {
if (i >= nodes.length) return;
var h2 = nodes[i];
if (h2.nodeType !== 1 || h2.tagName !== "H2") return;
i++;
var chunk = [h2];
while (i < nodes.length) {
var n = nodes[i];
if (n.nodeType === 1 && n.tagName === "H2") break;
chunk.push(n);
i++;
}
appendCard("readme-section-card--block", chunk);
}
flushIntro();
while (i < nodes.length) flushH2Section();
return page;
}
/**
* Таблицы Markdown с более чем двумя колонками: класс и data-label для мини-карточек при ширине до 480px (CSS).
* @param {HTMLElement} rootEl
*/
function markupWideMarkdownTables(rootEl) {
if (!rootEl || !rootEl.querySelectorAll) return;
rootEl.querySelectorAll(".markdown-body table").forEach(function (table) {
var thList = table.querySelectorAll("thead tr th");
if (!thList.length) return;
var n = thList.length;
if (n <= 2) return;
table.classList.add("markdown-body-table--stack");
var labels = [];
for (var i = 0; i < thList.length; i++) {
labels.push((thList[i].textContent || "").trim());
}
var tbody = table.querySelector("tbody");
if (!tbody) return;
tbody.querySelectorAll("tr").forEach(function (tr) {
var tds = tr.querySelectorAll("td");
for (var j = 0; j < tds.length; j++) {
if (labels[j]) tds[j].setAttribute("data-label", labels[j]);
}
});
});
}
function renderMarkdownToRoot(text, docPath, rootEl, errEl) {
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
applyDocumentationTabTitle("");
return;
}
try {
try {
if (marked.defaults && typeof marked.defaults === "object") {
marked.defaults.breaks = true;
marked.defaults.gfm = true;
}
} catch (e1) {
/* ignore */
}
var rawHtml =
typeof marked.parse === "function" ? marked.parse(text, { async: false }) : marked(text);
var temp = document.createElement("div");
temp.innerHTML = DOMPurify.sanitize(rawHtml);
rewriteMdLinks(temp, docPath);
rewriteMdImages(temp, docPath);
var h1ForTitle = extractFirstH1Text(temp);
applyDocumentationTabTitle(h1ForTitle);
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
var pageInner = sectionizeFromNodes(temp);
rootEl.appendChild(pageInner);
markupWideMarkdownTables(rootEl);
rootEl.removeAttribute("hidden");
} catch (e2) {
showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2)));
applyDocumentationTabTitle("");
}
}
function fetchUrlForPath(docPath) {
if (!docPath) return API + "/docs/readme";
return API + "/docs/file?path=" + encodeURIComponent(docPath);
}
async function loadDocumentation(docPath, opts) {
opts = opts || {};
var push = opts.pushState === true;
var replace = opts.replaceState === true;
var rootEl = document.getElementById("readme-doc-root");
var errEl = document.getElementById("readme-error");
if (!rootEl) {
hideDocumentationPageLoadingOverlay();
return;
}
showDocumentationPageLoadingOverlay();
try {
hideErr(errEl);
rootEl.setAttribute("hidden", "hidden");
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
updateBreadcrumbs(docPath);
var url = fetchUrlForPath(docPath);
var r = await fetch(url, {
headers: { Accept: "text/markdown, text/plain, */*" },
});
var text = await r.text();
if (!r.ok) {
var detail = text;
try {
var j = JSON.parse(text);
if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
} catch (e0) {
/* raw */
}
showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText));
applyDocumentationTabTitle("");
return;
}
renderMarkdownToRoot(text, docPath, rootEl, errEl);
var newUrl = docUrlForPath(docPath);
if (push) {
window.history.pushState({ docPath: docPath }, "", newUrl);
} else if (replace) {
window.history.replaceState({ docPath: docPath }, "", newUrl);
}
} catch (e) {
showErr(errEl, "Ошибка: " + (e.message || String(e)));
applyDocumentationTabTitle("");
} finally {
hideDocumentationPageLoadingOverlay();
}
}
function onPopState() {
loadDocumentation(getPathFromLocation(), {});
}
function onRootClick(e) {
var a = e.target && e.target.closest ? e.target.closest("a.doc-md-link") : null;
if (!a || !a.getAttribute("href")) return;
var href = a.getAttribute("href");
if (href.indexOf(DOC_BASE) !== 0) return;
e.preventDefault();
try {
var u = new URL(href, window.location.origin);
var p = (u.searchParams.get("path") || "").trim() || null;
loadDocumentation(p, { pushState: true });
} catch (e1) {
loadDocumentation(getPathFromLocation(), { pushState: true });
}
}
function run() {
var rootEl = document.getElementById("readme-doc-root");
if (!rootEl) {
hideDocumentationPageLoadingOverlay();
return;
}
window.addEventListener("popstate", onPopState);
rootEl.addEventListener("click", onRootClick);
var initial = getPathFromLocation();
loadDocumentation(initial, { replaceState: true });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();