c49555b3b9
- app/docs/screenshots.md и каталог app/docs/images/*.png - раздача /static/docs-images/* из FastAPI; documentation.js переписывает src картинок - стили .markdown-body img; строка в api_routes.md; превью в README
405 lines
14 KiB
JavaScript
405 lines
14 KiB
JavaScript
/**
|
||
* Страница /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();
|
||
}
|
||
})();
|