239 lines
14 KiB
JavaScript
239 lines
14 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
|
|
installGlobalDiagnostics();
|
|
|
|
import { api } from "@rustpad/api";
|
|
import { copyText } from "@rustpad/clipboard";
|
|
import { lineFromHash, lineLink } from "@rustpad/line-links";
|
|
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
|
|
import { toast } from "@rustpad/toast";
|
|
import { formatDateTime, t } from "@rustpad/i18n";
|
|
import { getTheme } from "@rustpad/theme";
|
|
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
|
|
|
|
const token = location.pathname.split("/").filter(Boolean)[1];
|
|
const content = document.querySelector("#public-content");
|
|
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
|
|
const lineLinksToggle = document.querySelector("#public-line-links-toggle");
|
|
const fullWidthToggle = document.querySelector("#public-full-width-toggle");
|
|
const fullWidthStorageKey = "rustpad:public-full-width";
|
|
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
|
|
const publicTitle = document.querySelector("#public-title");
|
|
publicTitle?.removeAttribute("data-i18n");
|
|
if (publicTitle) publicTitle.textContent = t("common.loading", {}, "Loading…");
|
|
let pagePassword = "";
|
|
let mermaidRenderVersion = 0;
|
|
let publicAnchorSettleCleanup = null;
|
|
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
|
|
async function renderMermaid() {
|
|
const renderVersion = ++mermaidRenderVersion;
|
|
const nodes = [...content.querySelectorAll(".mermaid")];
|
|
if (!nodes.length) return;
|
|
try {
|
|
const mermaid = await loadMermaid();
|
|
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
|
await mermaid.run({ nodes });
|
|
} catch {
|
|
if (renderVersion !== mermaidRenderVersion) return;
|
|
nodes.forEach(node => {
|
|
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
|
|
const message = document.createElement("p");
|
|
message.className = "error mermaid-error";
|
|
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
|
|
node.parentNode.insertBefore(message, node);
|
|
});
|
|
}
|
|
}
|
|
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await loadHighlight(); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
|
|
async function renderMediaPlayers() { const nodes = content.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(content); } catch { } }
|
|
function lockPublicContent(allowTaskUpdates) {
|
|
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
|
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
|
|
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
|
|
}
|
|
function publicAnchorTarget(hash) {
|
|
const rawId = String(hash || "").replace(/^#/, "");
|
|
if (!rawId) return null;
|
|
let id;
|
|
try { id = decodeURIComponent(rawId); } catch { id = rawId; }
|
|
const target = document.getElementById(id);
|
|
if (target && content.contains(target)) return target;
|
|
const line = lineFromHash(hash);
|
|
return line ? content.querySelector(`.preview-source-line[data-source-line="${line}"]`) : null;
|
|
}
|
|
function markPublicAnchor(target) {
|
|
content.querySelectorAll(".is-public-link-target").forEach(node => node.classList.remove("is-public-link-target"));
|
|
target?.classList.add("is-public-link-target");
|
|
}
|
|
function scrollToPublicAnchor(hash, behavior = "auto") {
|
|
const target = publicAnchorTarget(hash);
|
|
if (!target) return false;
|
|
for (let details = target.closest("details"); details; details = details.parentElement?.closest("details")) details.open = true;
|
|
markPublicAnchor(target);
|
|
target.scrollIntoView({ behavior, block: "start" });
|
|
return true;
|
|
}
|
|
function settleInitialPublicAnchor(hash) {
|
|
publicAnchorSettleCleanup?.();
|
|
publicAnchorSettleCleanup = null;
|
|
if (!hash) return;
|
|
|
|
let stopped = false;
|
|
let frame = 0;
|
|
let observer = null;
|
|
const cleanups = [];
|
|
const cleanup = () => {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
cancelAnimationFrame(frame);
|
|
observer?.disconnect();
|
|
cleanups.splice(0).forEach(remove => remove());
|
|
if (publicAnchorSettleCleanup === cleanup) publicAnchorSettleCleanup = null;
|
|
};
|
|
const correct = () => {
|
|
frame = 0;
|
|
if (stopped || location.hash !== hash) return;
|
|
scrollToPublicAnchor(hash);
|
|
};
|
|
const scheduleCorrection = () => {
|
|
if (stopped || frame) return;
|
|
frame = requestAnimationFrame(correct);
|
|
};
|
|
const stopOnUserInput = () => cleanup();
|
|
|
|
["wheel", "touchstart", "pointerdown", "keydown"].forEach(type => {
|
|
window.addEventListener(type, stopOnUserInput, { capture: true, passive: true });
|
|
cleanups.push(() => window.removeEventListener(type, stopOnUserInput, true));
|
|
});
|
|
|
|
content.querySelectorAll("img, video, iframe").forEach(asset => {
|
|
["load", "error", "loadedmetadata"].forEach(type => {
|
|
asset.addEventListener(type, scheduleCorrection, { once: true });
|
|
cleanups.push(() => asset.removeEventListener(type, scheduleCorrection));
|
|
});
|
|
});
|
|
|
|
if (document.readyState !== "complete") {
|
|
window.addEventListener("load", scheduleCorrection, { once: true });
|
|
cleanups.push(() => window.removeEventListener("load", scheduleCorrection));
|
|
}
|
|
document.fonts?.ready?.then(scheduleCorrection).catch(() => {});
|
|
|
|
if (typeof ResizeObserver === "function") {
|
|
observer = new ResizeObserver(scheduleCorrection);
|
|
observer.observe(content);
|
|
}
|
|
|
|
publicAnchorSettleCleanup = cleanup;
|
|
scheduleCorrection();
|
|
requestAnimationFrame(() => requestAnimationFrame(scheduleCorrection));
|
|
const timeout = window.setTimeout(cleanup, 6000);
|
|
cleanups.push(() => clearTimeout(timeout));
|
|
}
|
|
function isBlankPublicSourceLine(target) {
|
|
if (!target.matches("div.preview-source-line")) return false;
|
|
return ![...target.childNodes].some(node => {
|
|
if (node.nodeType === Node.TEXT_NODE) return Boolean(node.textContent?.trim());
|
|
if (!(node instanceof Element)) return false;
|
|
return !node.matches("br, .public-permalink");
|
|
});
|
|
}
|
|
function installPublicPermalinks() {
|
|
content.querySelectorAll(".preview-source-line[data-source-line]").forEach(target => {
|
|
if (target.matches("hr") || isBlankPublicSourceLine(target) || target.querySelector(":scope > .public-permalink")) return;
|
|
const line = Number(target.dataset.sourceLine);
|
|
if (!Number.isSafeInteger(line) || line < 1) return;
|
|
const heading = target.matches("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]");
|
|
const anchor = document.createElement("a");
|
|
anchor.className = "public-permalink";
|
|
anchor.href = heading ? `#${encodeURIComponent(target.id)}` : `#L${line}`;
|
|
anchor.dataset.line = String(line);
|
|
anchor.dataset.linkKind = heading ? "heading" : "line";
|
|
anchor.textContent = String(line);
|
|
anchor.setAttribute("aria-label", heading ? t("public.copyHeadingAria", { line }, `Copy link to heading on line ${line}`) : t("public.copyLineAria", { line }, `Copy link to line ${line}`));
|
|
anchor.title = heading ? t("public.copyHeadingTitle", { line }, `Copy link to this heading (line ${line})`) : t("public.copyLineAria", { line }, `Copy link to line ${line}`);
|
|
target.append(anchor);
|
|
});
|
|
}
|
|
function setFullWidth(enabled, { persist = true } = {}) {
|
|
const active = Boolean(enabled);
|
|
document.body.classList.toggle("public-full-width", active);
|
|
fullWidthToggle.checked = active;
|
|
if (persist) {
|
|
try { localStorage.setItem(fullWidthStorageKey, active ? "1" : "0"); } catch { }
|
|
}
|
|
requestAnimationFrame(() => alignPreviewLineNumbers(content));
|
|
}
|
|
function restoreFullWidth() {
|
|
let enabled = false;
|
|
try { enabled = localStorage.getItem(fullWidthStorageKey) === "1"; } catch { }
|
|
setFullWidth(enabled, { persist: false });
|
|
}
|
|
async function initialize() {
|
|
try {
|
|
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
|
|
if (passwordDialog.open) passwordDialog.close();
|
|
passwordError.textContent = "";
|
|
publicTitle.textContent = page.title;
|
|
document.querySelector("#public-meta").textContent = page.allow_task_updates
|
|
? t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`)
|
|
: t("public.updated", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)}`);
|
|
document.title = `${page.title} · RustPad`;
|
|
setMarkdownFiles(page.files || []);
|
|
content.innerHTML = renderMarkdown(page.content);
|
|
lockPublicContent(page.allow_task_updates);
|
|
await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]);
|
|
installPublicPermalinks();
|
|
alignPreviewLineNumbers(content);
|
|
settleInitialPublicAnchor(location.hash);
|
|
} catch (error) {
|
|
publicAnchorSettleCleanup?.();
|
|
if (error.status === 401 || error.status === 403) {
|
|
passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password.");
|
|
if (!passwordDialog.open) passwordDialog.showModal();
|
|
passwordInput.focus();
|
|
return;
|
|
}
|
|
content.replaceChildren();
|
|
const message = document.createElement("p");
|
|
message.className = "error";
|
|
message.textContent = String(error.message);
|
|
content.append(message);
|
|
toast.danger(error.message, { title: "Could not load published page" });
|
|
}
|
|
}
|
|
content.addEventListener("click", async event => {
|
|
const link = event.target.closest('a[href^="#"]');
|
|
if (!link || !content.contains(link)) return;
|
|
const hash = link.getAttribute("href");
|
|
event.preventDefault();
|
|
if (!hash || hash === "#" || !scrollToPublicAnchor(hash, "smooth")) return;
|
|
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
|
|
if (!link.classList.contains("public-permalink")) return;
|
|
try {
|
|
const href = link.dataset.linkKind === "heading" ? new URL(hash, location.href).href : lineLink(location.href, Number(link.dataset.line));
|
|
await copyText(href);
|
|
link.classList.add("is-copied");
|
|
setTimeout(() => link.classList.remove("is-copied"), 900);
|
|
toast.success(link.dataset.linkKind === "heading" ? "Heading link copied to the clipboard." : `Link to line ${link.dataset.line} copied to the clipboard.`, { title: "Link copied" });
|
|
} catch (error) { toast.danger(error.message, { title: "Could not copy link" }); }
|
|
});
|
|
window.addEventListener("hashchange", () => { publicAnchorSettleCleanup?.(); scrollToPublicAnchor(location.hash, "smooth"); });
|
|
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = t("public.updatedTasks", { date: formatDateTime(page.updated_at) }, `Updated: ${formatDateTime(page.updated_at)} · tasks can be updated`); toast.success(box.checked ? "Task marked as complete." : "Task marked as incomplete.", { title: "Task updated" }); } catch (error) { box.checked = previous; toast.danger(error.message, { title: "Could not update task" }); } finally { box.disabled = false; } });
|
|
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
|
|
lineLinksToggle.addEventListener("change", () => { document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked); });
|
|
fullWidthToggle.addEventListener("change", () => setFullWidth(fullWidthToggle.checked));
|
|
window.addEventListener("resize", () => requestAnimationFrame(() => alignPreviewLineNumbers(content)));
|
|
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); if (!passwordDialog.open) toast.success("The published page has been unlocked.", { title: "Page unlocked" }); else toast.danger(passwordError.textContent || "Enter the correct password.", { title: "Could not unlock page" }); });
|
|
passwordDialog.addEventListener("cancel", event => event.preventDefault());
|
|
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast.success("Published page link copied to the clipboard.", { title: "Link copied" }); } catch (error) { toast.danger(error.message, { title: "Could not copy page link" }); } });
|
|
initialize();
|