v0.25.0
This commit is contained in:
1 parent
2e4aa4df4d
commit
87e571c170
106 files changed
+17952
-6185
No files matched your search
+121
-13
@@ -1,9 +1,11 @@
|
||||
"use strict";
|
||||
// Fixed same-origin schema + browser session; never query-configurable/CDN-based.
|
||||
(() => {
|
||||
const element = id => document.getElementById(id); let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
|
||||
const element = id => document.getElementById(id);
|
||||
let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
|
||||
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
|
||||
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
|
||||
const t = key => translations[key] ?? fallback[key] ?? key;
|
||||
|
||||
function docsConfirm(message) {
|
||||
return new Promise(resolve => {
|
||||
const dialog = document.createElement("dialog"); dialog.className = "docs-confirm";
|
||||
@@ -18,11 +20,16 @@
|
||||
dialog.showModal(); cancel.focus();
|
||||
});
|
||||
}
|
||||
function clear() { epoch++; session = null; spec = null; ui = null; element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-session").textContent = ""; }
|
||||
|
||||
function clear() {
|
||||
epoch++; session = null; spec = null; ui = null; documents = []; docsByPath = new Map(); currentDocument = null;
|
||||
element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-reader-list").replaceChildren(); element("docs-reader-content").replaceChildren(); element("docs-session").textContent = "";
|
||||
}
|
||||
function expired() { clear(); element("docs-error").replaceChildren(); const link = document.createElement("a"); link.href = "/?next=docs"; link.textContent = t("docsExpired"); element("docs-error").append(link); }
|
||||
channel?.addEventListener("message", event => { if (event.data === "locked") expired(); });
|
||||
async function get(path) { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", redirect: "error" }); if (!response.ok) { if (response.status === 401) expired(); throw new Error(t("docsError")); } return response.json(); }
|
||||
async function checkSession() { const mine = epoch; const info = await get("/api/auth/session"); if (mine !== epoch || !session || info.csrf_token !== session.csrf_token || info.username !== session.username) { expired(); throw new Error(t("docsExpired")); } return info; }
|
||||
|
||||
async function requestInterceptor(request) {
|
||||
if (!session || !spec) throw new Error(t("docsExpired"));
|
||||
const url = new URL(request.url, location.origin), method = (request.method || "GET").toLowerCase();
|
||||
@@ -36,12 +43,9 @@
|
||||
}
|
||||
return request;
|
||||
}
|
||||
function render() {
|
||||
if (!session || !spec) return;
|
||||
// Disable the online validator and URL/config overrides. No persisted auth or
|
||||
// externally supplied schema can send cookies or CSRF tokens elsewhere.
|
||||
// These mandatory wire headers are supplied by the browser/interceptor, not
|
||||
// typed into Swagger fields. Keep the downloadable specification accurate.
|
||||
|
||||
function renderApi() {
|
||||
if (!session || !spec || typeof SwaggerUIBundle !== "function") return;
|
||||
const displaySpec = structuredClone(spec);
|
||||
for (const path of Object.values(displaySpec.paths)) for (const [method, operation] of Object.entries(path)) {
|
||||
if (["get", "post", "put", "delete", "patch", "head", "options"].includes(method) && operation.parameters)
|
||||
@@ -55,17 +59,121 @@
|
||||
requestInterceptor, responseInterceptor: response => { if (response.status === 401) expired(); return response; }
|
||||
});
|
||||
}
|
||||
element("docs-writes").addEventListener("change", render);
|
||||
|
||||
function normalizeDocPath(base, target) {
|
||||
const clean = target.split("#", 1)[0].split("?", 1)[0];
|
||||
if (!clean) return base;
|
||||
const stack = clean.startsWith("/") ? [] : base.split("/").slice(0, -1);
|
||||
for (const part of clean.replace(/^\//, "").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") { if (stack.length) stack.pop(); continue; }
|
||||
stack.push(part);
|
||||
}
|
||||
return stack.join("/");
|
||||
}
|
||||
|
||||
function appendInline(parent, text, basePath) {
|
||||
const pattern = /(`[^`]+`|\[[^\]]+\]\([^)]+\))/g; let offset = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match.index > offset) parent.append(document.createTextNode(text.slice(offset, match.index)));
|
||||
const token = match[0];
|
||||
if (token.startsWith("`")) {
|
||||
const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code);
|
||||
} else {
|
||||
const parsed = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); const anchor = document.createElement("a");
|
||||
anchor.textContent = parsed[1]; const target = parsed[2].trim();
|
||||
if (/^https?:\/\//i.test(target)) { anchor.href = target; anchor.target = "_blank"; anchor.rel = "noreferrer"; }
|
||||
else if (target.startsWith("#")) { anchor.href = target; }
|
||||
else {
|
||||
const resolved = normalizeDocPath(basePath, target);
|
||||
if (docsByPath.has(resolved)) {
|
||||
anchor.href = `#doc=${encodeURIComponent(resolved)}`;
|
||||
anchor.addEventListener("click", event => { event.preventDefault(); selectDocument(resolved, true); });
|
||||
} else { anchor.href = target; }
|
||||
}
|
||||
parent.append(anchor);
|
||||
}
|
||||
offset = match.index + token.length;
|
||||
}
|
||||
if (offset < text.length) parent.append(document.createTextNode(text.slice(offset)));
|
||||
}
|
||||
|
||||
function renderMarkdown(documentInfo) {
|
||||
const target = element("docs-reader-content"); target.replaceChildren();
|
||||
const lines = documentInfo.content.replaceAll("\r\n", "\n").split("\n");
|
||||
let code = null, list = null, paragraph = [];
|
||||
const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInline(p, paragraph.join(" ").trim(), documentInfo.path); target.append(p); paragraph = []; };
|
||||
const closeList = () => { list = null; };
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("```")) {
|
||||
flushParagraph(); closeList();
|
||||
if (code) { target.append(code.pre); code = null; }
|
||||
else { const pre = document.createElement("pre"), node = document.createElement("code"); pre.append(node); code = { pre, node, lines: [] }; }
|
||||
continue;
|
||||
}
|
||||
if (code) { code.lines.push(line); code.node.textContent = code.lines.join("\n"); continue; }
|
||||
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
|
||||
if (heading) { flushParagraph(); closeList(); const h = document.createElement(`h${heading[1].length}`); appendInline(h, heading[2], documentInfo.path); target.append(h); continue; }
|
||||
const bullet = /^\s*[-*]\s+(.+)$/.exec(line), ordered = /^\s*\d+\.\s+(.+)$/.exec(line);
|
||||
if (bullet || ordered) {
|
||||
flushParagraph(); const tag = ordered ? "ol" : "ul";
|
||||
if (!list || list.tagName.toLowerCase() !== tag) { list = document.createElement(tag); target.append(list); }
|
||||
const item = document.createElement("li"); appendInline(item, (bullet || ordered)[1], documentInfo.path); list.append(item); continue;
|
||||
}
|
||||
if (/^\s*>\s?/.test(line)) { flushParagraph(); closeList(); const q = document.createElement("blockquote"); appendInline(q, line.replace(/^\s*>\s?/, ""), documentInfo.path); target.append(q); continue; }
|
||||
if (/^\s*---+\s*$/.test(line)) { flushParagraph(); closeList(); target.append(document.createElement("hr")); continue; }
|
||||
if (!line.trim()) { flushParagraph(); closeList(); continue; }
|
||||
closeList(); paragraph.push(line.trim());
|
||||
}
|
||||
flushParagraph(); if (code) target.append(code.pre);
|
||||
}
|
||||
|
||||
const categoryLabel = category => t({ project: "docsCategoryProject", guides: "docsCategoryGuides", technical: "docsCategoryTechnical", audits: "docsCategoryAudits", historical: "docsCategoryHistorical" }[category] || category);
|
||||
function renderReaderList() {
|
||||
const query = (element("docs-reader-search").value || "").trim().toLowerCase();
|
||||
const root = element("docs-reader-list"); root.replaceChildren();
|
||||
const matches = documents.filter(item => !query || `${item.title} ${item.path}`.toLowerCase().includes(query));
|
||||
for (const category of ["project", "guides", "technical", "audits", "historical"]) {
|
||||
const group = matches.filter(item => item.category === category); if (!group.length) continue;
|
||||
const heading = document.createElement("div"); heading.className = "reader-group"; heading.textContent = categoryLabel(category); root.append(heading);
|
||||
for (const item of group) {
|
||||
const button = document.createElement("button"); button.type = "button"; button.classList.toggle("active", item.path === currentDocument?.path);
|
||||
const title = document.createElement("span"); title.textContent = item.title; const path = document.createElement("small"); path.textContent = item.path;
|
||||
button.append(title, path); button.addEventListener("click", () => selectDocument(item.path, true)); root.append(button);
|
||||
}
|
||||
}
|
||||
if (!root.children.length) { const empty = document.createElement("div"); empty.className = "reader-empty"; empty.textContent = t("docsReaderEmpty"); root.append(empty); }
|
||||
}
|
||||
function selectDocument(path, updateHash = false) {
|
||||
const item = docsByPath.get(path) || docsByPath.get("docs/README.md") || documents[0]; if (!item) return;
|
||||
currentDocument = item; renderReaderList(); renderMarkdown(item);
|
||||
if (updateHash) history.replaceState(null, "", `#doc=${encodeURIComponent(item.path)}`);
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
activeTab = tab === "api" ? "api" : "reader";
|
||||
element("docs-reader-pane").hidden = activeTab !== "reader"; element("docs-api-pane").hidden = activeTab !== "api";
|
||||
element("docs-tab-reader").classList.toggle("active", activeTab === "reader"); element("docs-tab-api").classList.toggle("active", activeTab === "api");
|
||||
if (activeTab === "api") renderApi();
|
||||
}
|
||||
|
||||
element("docs-writes").addEventListener("change", renderApi);
|
||||
element("docs-reader-search").addEventListener("input", renderReaderList);
|
||||
element("docs-tab-reader").addEventListener("click", () => selectTab("reader"));
|
||||
element("docs-tab-api").addEventListener("click", () => selectTab("api"));
|
||||
element("docs-logout").addEventListener("click", async () => { if (!session) return; try { await checkSession(); const response = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "X-RustProxy-Request": "1", "X-CSRF-Token": session.csrf_token } }); if (!response.ok && response.status !== 401) throw new Error(t("docsError")); channel?.postMessage("locked"); expired(); } catch (error) { element("docs-error").textContent = error.message; } });
|
||||
|
||||
async function boot() {
|
||||
const mine = ++epoch; try {
|
||||
const info = await get("/api/auth/session"); if (mine !== epoch) return;
|
||||
const [document, bundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json")]); if (mine !== epoch) return;
|
||||
const [documentSpec, bundle, docBundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json"), get("/docs/content.json")]); if (mine !== epoch) return;
|
||||
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {}; let selected = null; try { selected = localStorage.getItem("rp-language"); } catch { }
|
||||
const pack = packs.find(p => p.id === selected) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
|
||||
window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr";
|
||||
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n));
|
||||
session = info; spec = document; element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = ""; render();
|
||||
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); window.document.querySelectorAll("[data-i18n-placeholder]").forEach(n => n.placeholder = t(n.dataset.i18nPlaceholder));
|
||||
session = info; spec = documentSpec; documents = Array.isArray(docBundle.documents) ? docBundle.documents : []; docsByPath = new Map(documents.map(item => [item.path, item]));
|
||||
element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = "";
|
||||
const hash = new URLSearchParams(location.hash.replace(/^#/, "")).get("doc"); selectDocument(hash && docsByPath.has(hash) ? hash : "docs/README.md"); selectTab("reader");
|
||||
} catch (error) { if (mine === epoch) { clear(); element("docs-error").textContent = error.message; } }
|
||||
}
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && session) void checkSession().catch(() => expired()); });
|
||||
|
||||
Reference in new issue
Block a user