Files
racket-wiki/static/js/wiki.js
T

1410 lines
48 KiB
JavaScript

(() => {
"use strict";
const state = {
session: null,
pages: [],
currentPage: null,
editingNew: false,
newPageSlug: null,
previousView: "page-view",
translations: {},
translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki"
};
let easyMDE = null;
const $ = (id) => document.getElementById(id);
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
document.body.classList.toggle("editor-mode", viewId === "editor-view");
}
function tr(key, fallback = key) {
return state.translations[key] || fallback;
}
function applyTranslations() {
document.querySelectorAll("[data-tr]").forEach((node) => {
node.textContent = tr(node.dataset.tr, node.textContent);
});
document.querySelectorAll("[data-tr-placeholder]").forEach((node) => {
node.placeholder = tr(node.dataset.trPlaceholder, node.placeholder);
});
document.documentElement.lang = state.language || "en";
}
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function expandTodoMarkup(markdown) {
let inFence = false;
return (markdown || "").split("\n").map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
return line.replace(/todo\(([^()\r\n]+)\)/g, (_match, text) =>
`<span class="wiki-todo"><strong>TODO</strong> ${escapeHtml(text.trim())}</span>`);
}).join("\n");
}
function roleLevel(role) {
return { reader: 10, editor: 20, admin: 30 }[role] || 0;
}
function can(role) {
return state.session?.authenticated && roleLevel(state.session.user.role) >= roleLevel(role);
}
function updateRoleUi() {
document.querySelectorAll(".editor-only").forEach((node) => {
node.classList.toggle("hidden", !can("editor"));
});
document.querySelectorAll(".admin-only").forEach((node) => {
node.classList.toggle("hidden", !can("admin"));
});
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) {
headers.set("Content-Type", "application/json");
}
if (state.session?.csrfToken && !["GET", "HEAD"].includes((options.method || "GET").toUpperCase())) {
headers.set("X-CSRF-Token", state.session.csrfToken);
}
const response = await fetch(path, { ...options, headers });
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
if (response.status === 401) {
window.location.replace("/login");
}
const error = new Error(body?.error || body || `HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return body;
}
function applyImageWidthMarkup(html) {
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
const width = `${amount}${unit || "px"}`;
const closing = imageTag.endsWith("/>") ? "/>" : ">";
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
const classes = [];
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
if (effectiveFloatSide) {
classes.push(`wiki-image-float-${effectiveFloatSide}`);
} else if (align) {
classes.push(`wiki-image-${align}`);
}
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
` style=${quote}${value}; width: ${width}${quote}`);
} else {
tagBody += ` style="width: ${width}"`;
}
if (classes.length > 0) {
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
` class=${quote}${value} ${classes.join(" ")}${quote}`);
} else {
tagBody += ` class="${classes.join(" ")}"`;
}
}
return `${tagBody}${closing}`;
});
}
function renderMarkdown(markdown) {
const html = easyMDE.markdown(expandTodoMarkup(markdown || ""));
return DOMPurify.sanitize(applyImageWidthMarkup(html));
}
function slugTitle(slug) {
return (slug || "")
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function setPageActionVisibility(pageExists) {
if (!can("editor")) return;
$("edit-page").classList.remove("hidden");
$("delete-page").classList.toggle("hidden", !pageExists);
$("history-page").classList.toggle("hidden", !pageExists);
}
function pageDisplayDate(seconds) {
return new Date(seconds * 1000).toLocaleString();
}
function parseTags(text) {
const seen = new Set();
const tags = [];
for (const part of (text || "").split(",")) {
const tag = part.trim();
const key = tag.toLocaleLowerCase();
if (tag && !seen.has(key)) {
seen.add(key);
tags.push(tag);
}
}
return tags;
}
function renderPageDetails(page) {
const details = $("page-details");
details.replaceChildren();
const fields = [
`${tr("created", "Created")} ${pageDisplayDate(page.createdAt)} ${tr("by", "by")} ${page.createdBy}`,
`${tr("modified", "Modified")} ${pageDisplayDate(page.updatedAt)} ${tr("by", "by")} ${page.updatedBy}`,
`${tr("version", "Version")} ${page.currentVersion}`
];
for (const text of fields) {
const span = document.createElement("span");
span.textContent = text;
details.append(span);
}
const tags = Array.isArray(page.tags) ? page.tags : [];
const tagField = document.createElement("span");
tagField.className = "page-tags";
if (tags.length === 0) {
tagField.textContent = tr("tags-none", "Tags: none");
} else {
const label = document.createTextNode(tr("tags-label", "Tags: "));
tagField.append(label);
tags.forEach((tag, index) => {
if (index > 0) tagField.append(document.createTextNode(", "));
const value = document.createElement("span");
value.className = "page-tag";
value.textContent = tag;
tagField.append(value);
});
}
details.append(tagField);
}
function wikiSlugFromHref(href) {
if (!href) return null;
let candidate = null;
if (href.startsWith("#/") && !href.includes("?")) {
candidate = href.slice(2);
} else if (href.startsWith("/") && href.indexOf("/", 1) === -1 && !href.includes("?") && !href.includes("#")) {
candidate = href.slice(1);
} else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) {
candidate = href.slice(2);
} else if (!href.includes("/") && !href.includes(":") && !href.includes("#") && !href.includes("?")) {
candidate = href;
}
if (!candidate) return null;
const reserved = new Set(["api", "uploads", "setup", "login", "vendor", "css", "js", "index.html"]);
let decoded;
try {
decoded = decodeURIComponent(candidate);
} catch (_error) {
return null;
}
return reserved.has(decoded) ? null : decoded;
}
function installWikiLinkNavigation() {
$("markdown-preview").addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const slug = wikiSlugFromHref(link.getAttribute("href"));
if (!slug) return;
event.preventDefault();
location.hash = `#/${encodeURIComponent(slug)}`;
});
}
function toolbarButton(name, action, icon, title, options = {}) {
return {
name,
action,
className: `fa fa-${icon}`,
title,
...options
};
}
function initializeHighlighting() {
if (!window.hljs) return;
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
window.hljs.registerAliases(["racket"], { languageName: "scheme" });
}
}
function initializeEditor() {
if (!window.EasyMDE) {
throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup.");
}
if (!window.DOMPurify) {
throw new Error("DOMPurify is not installed. Open /setup to repair the frontend setup.");
}
initializeHighlighting();
easyMDE = new EasyMDE({
element: $("markdown-editor"),
autoDownloadFontAwesome: false,
autoRefresh: { delay: 200 },
forceSync: true,
lineNumbers: true,
lineWrapping: true,
indentWithTabs: false,
tabSize: 2,
minHeight: "520px",
nativeSpellcheck: true,
spellChecker: false,
previewImagesInEditor: true,
previewClass: ["editor-preview", "markdown-body", "preview-pane"],
sideBySideFullscreen: false,
syncSideBySidePreviewScroll: true,
status: ["lines", "words", "cursor"],
uploadImage: true,
imageMaxSize: 50 * 1024 * 1024,
imageAccept: "image/png,image/jpeg,image/gif,image/webp",
imageUploadFunction: (file, onSuccess, onError) => {
uploadImageForEasyMDE(file)
.then(onSuccess)
.catch((error) => onError(error.message));
},
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText) : "",
renderingConfig: {
codeSyntaxHighlighting: true,
hljs: window.hljs,
sanitizerFunction: (html) => DOMPurify.sanitize(html)
},
toolbar: [
toolbarButton("bold", EasyMDE.toggleBold, "bold", tr("bold", "Bold")),
toolbarButton("italic", EasyMDE.toggleItalic, "italic", tr("italic", "Italic")),
toolbarButton("strikethrough", EasyMDE.toggleStrikethrough, "strikethrough", tr("strikethrough", "Strikethrough")),
toolbarButton("heading", EasyMDE.toggleHeadingSmaller, "header", tr("heading", "Heading")),
"|",
toolbarButton("quote", EasyMDE.toggleBlockquote, "quote-left", tr("quote", "Quote")),
toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list-ul", tr("bulleted-list", "Bulleted list")),
toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ol", tr("numbered-list", "Numbered list")),
toolbarButton("check-list", EasyMDE.toggleCheckList, "check-square-o", tr("checklist", "Checklist")),
toolbarButton("code", EasyMDE.toggleCodeBlock, "code", tr("code-block", "Code block")),
toolbarButton("table", EasyMDE.drawTable, "table", tr("table", "Table")),
"|",
toolbarButton("link", EasyMDE.drawLink, "link", tr("link", "Link")),
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "picture-o", tr("upload-image", "Upload image")),
toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")),
toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")),
"|",
toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")),
toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")),
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "arrows-alt", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
]
});
easyMDE.codemirror.on("change", () => {
if (!$("editor-view").classList.contains("hidden")) {
renderEditorToc();
}
});
installGeneralFileDrop();
window.addEventListener("resize", () => {
if (!$("editor-view").classList.contains("hidden")) {
updateEditorChromeMetrics();
easyMDE.codemirror.refresh();
}
});
}
function editorContainer() {
return easyMDE.codemirror.getWrapperElement().closest(".EasyMDEContainer");
}
function updateEditorChromeMetrics() {
if (!easyMDE) return;
const container = editorContainer();
const toolbar = container.querySelector(".editor-toolbar");
const statusbar = container.querySelector(".editor-statusbar");
const toolbarHeight = toolbar ? toolbar.offsetHeight : 0;
const statusHeight = statusbar ? statusbar.offsetHeight : 0;
container.style.setProperty("--editor-toolbar-height", `${toolbarHeight}px`);
container.style.setProperty("--editor-status-height", `${statusHeight}px`);
}
function activateEditor() {
show("editor-view");
renderEditorToc();
requestAnimationFrame(() => {
easyMDE.codemirror.refresh();
const wideScreen = window.matchMedia("(min-width: 901px)").matches;
if (wideScreen && !easyMDE.isSideBySideActive()) {
easyMDE.toggleSideBySide();
}
updateEditorChromeMetrics();
easyMDE.codemirror.refresh();
});
}
function headingId(text, usedIds) {
const base = text
.trim()
.toLocaleLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "") || "section";
let id = base;
let number = 2;
while (usedIds.has(id)) {
id = `${base}-${number}`;
number += 1;
}
usedIds.add(id);
return id;
}
function markdownHeadings(markdown) {
const headings = [];
let inFence = false;
const fencePattern = /^\s*(```|~~~)/;
(markdown || "").replace(/\r\n/g, "\n").split("\n").forEach((line, lineNumber) => {
if (fencePattern.test(line)) {
inFence = !inFence;
return;
}
if (inFence) return;
const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!match) return;
headings.push({
level: match[1].length,
text: match[2].trim(),
line: lineNumber
});
});
return headings;
}
function renderToc(entries, onSelect) {
const toc = $("toc-list");
toc.replaceChildren();
if (entries.length === 0) {
const empty = document.createElement("div");
empty.className = "toc-empty";
empty.textContent = tr("no-headings", "No headings");
toc.append(empty);
return;
}
for (const entry of entries) {
const link = document.createElement("a");
link.href = entry.href || "#";
link.className = `toc-link toc-level-${Math.min(entry.level, 4)}`;
link.textContent = entry.text;
link.addEventListener("click", (event) => {
event.preventDefault();
onSelect(entry);
});
toc.append(link);
}
}
function renderPageToc() {
const article = $("markdown-preview");
const usedIds = new Set();
const entries = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading) => {
const text = heading.textContent.trim();
const id = heading.id || headingId(text, usedIds);
heading.id = id;
return {
level: Number(heading.tagName.slice(1)),
text,
href: `#${id}`,
element: heading
};
});
renderToc(entries, (entry) => {
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
});
}
function renderEditorToc() {
if (!easyMDE) return;
const entries = markdownHeadings(easyMDE.value());
renderToc(entries, (entry) => {
easyMDE.codemirror.setCursor({ line: entry.line, ch: 0 });
easyMDE.codemirror.scrollIntoView({ line: entry.line, ch: 0 }, 120);
easyMDE.codemirror.focus();
});
}
function renderBreadcrumbs(items) {
const breadcrumbs = $("breadcrumbs");
breadcrumbs.replaceChildren();
if (!items || items.length === 0) {
breadcrumbs.classList.add("hidden");
return;
}
breadcrumbs.classList.remove("hidden");
const label = document.createElement("span");
label.className = "breadcrumb-label";
label.textContent = `${tr("you-are-here", "You are here")}:`;
breadcrumbs.append(label);
items.forEach((item, index) => {
if (index > 0) {
const separator = document.createElement("span");
separator.className = "breadcrumb-separator";
separator.textContent = "»";
breadcrumbs.append(separator);
}
if (item.href) {
const link = document.createElement("a");
link.href = item.href;
if (item.href === "/") link.dataset.home = "true";
link.textContent = item.label;
breadcrumbs.append(link);
} else {
const current = document.createElement("span");
current.className = "breadcrumb-current";
current.textContent = item.label;
breadcrumbs.append(current);
}
});
}
function pageBreadcrumbs(page, suffix = null) {
const items = [
{ label: state.siteTitle, href: "/" },
{ label: page?.title || page?.slug || "New page" }
];
if (suffix) {
items[items.length - 1].href = `#/${encodeURIComponent(page.slug)}`;
items.push({ label: suffix });
}
renderBreadcrumbs(items);
}
function startPage() {
const namedStartPage = state.pages.find((page) => page.slug === "start") || null;
if (namedStartPage) return namedStartPage;
if (state.pages.length === 0) return null;
return state.pages.reduce((first, page) => {
if (!first) return page;
return Number(page.createdAt) < Number(first.createdAt) ? page : first;
}, null);
}
function updateWikiIdentity() {
const firstPage = startPage();
state.siteTitle = firstPage?.title || "Racket Wiki";
$("wiki-brand").textContent = state.siteTitle;
$("wiki-brand").href = firstPage ? `#/${encodeURIComponent(firstPage.slug)}` : "#";
document.title = state.siteTitle;
}
async function goHome() {
const firstPage = startPage();
if (!firstPage) {
await route();
return;
}
const targetHash = `#/${encodeURIComponent(firstPage.slug)}`;
if (location.hash === targetHash) {
await openPage(firstPage.slug);
} else {
location.hash = targetHash;
}
}
function renderPageList() {
const list = $("page-list");
list.replaceChildren();
for (const page of state.pages) {
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(page.slug)}`;
link.className = "page-link";
link.textContent = page.title;
link.classList.toggle("active", state.currentPage?.slug === page.slug);
link.addEventListener("click", (event) => {
event.preventDefault();
location.hash = `#/${encodeURIComponent(page.slug)}`;
});
list.append(link);
}
}
async function loadPages() {
const result = await api("/api/pages");
state.pages = result.pages;
updateWikiIdentity();
renderPageList();
}
async function openPage(slug) {
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
state.currentPage = page;
state.editingNew = false;
state.newPageSlug = null;
$("page-title").textContent = page.title;
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = renderMarkdown(page.markdown);
renderPageDetails(page);
pageBreadcrumbs(page);
show("page-view");
setPageActionVisibility(true);
renderPageToc();
renderPageList();
}
function updateEditorSlugInfo() {
if (!state.editingNew && state.currentPage) {
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.currentPage.slug}`;
return;
}
if (state.newPageSlug) {
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.newPageSlug}`;
return;
}
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
}
function beginNewPage(requestedSlug = null) {
state.editingNew = true;
state.currentPage = null;
state.newPageSlug = requestedSlug;
const translationPage = requestedSlug && requestedSlug === state.translationPage;
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
$("editor-tags").value = "";
if (translationPage) {
easyMDE.value(state.translationTemplate || "");
} else {
easyMDE.value("");
}
$("edit-summary").value = "";
$("save-status").textContent = "";
updateEditorSlugInfo();
activateEditor();
$("editor-title").focus();
}
function beginEditPage() {
if (!state.currentPage) {
if (state.newPageSlug) {
beginNewPage(state.newPageSlug);
}
return;
}
state.editingNew = false;
state.newPageSlug = null;
$("editor-title").value = state.currentPage.title;
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
easyMDE.value(state.currentPage.markdown);
$("edit-summary").value = "";
$("save-status").textContent = "";
updateEditorSlugInfo();
activateEditor();
}
async function savePage() {
const title = $("editor-title").value.trim();
const markdown = easyMDE.value();
const tags = parseTags($("editor-tags").value);
const summary = $("edit-summary").value.trim();
$("save-status").textContent = tr("saving", "Saving…");
try {
let page;
if (state.editingNew) {
const body = {
title,
markdown,
tags,
summary: summary || tr("created-page", "Created page")
};
if (state.newPageSlug) {
body.slug = state.newPageSlug;
}
page = await api("/api/pages", {
method: "POST",
body: JSON.stringify(body)
});
} else {
const slug = state.currentPage.slug;
page = await api(`/api/pages/${encodeURIComponent(slug)}`, {
method: "PUT",
body: JSON.stringify({
title,
markdown,
tags,
baseVersion: state.currentPage.currentVersion,
summary: summary || tr("edited-page", "Edited page")
})
});
}
state.currentPage = page;
state.editingNew = false;
state.newPageSlug = null;
await loadPages();
if (page.slug === state.translationPage) {
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationTemplate = translationData.template || state.translationTemplate;
state.translations = translationData.translations || {};
applyTranslations();
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
}
location.hash = `#/${encodeURIComponent(page.slug)}`;
await openPage(page.slug);
$("save-status").textContent = tr("saved", "Saved");
} catch (error) {
$("save-status").textContent = error.message;
}
}
function insertTextAtCursor(text) {
const doc = easyMDE.codemirror.getDoc();
doc.replaceSelection(text, "end");
easyMDE.codemirror.focus();
}
function isInlineImage(file) {
return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.type);
}
function requireUploadablePage() {
if (state.editingNew || !state.currentPage) {
throw new Error("Save a new page once before uploading files.");
}
return state.currentPage.slug;
}
async function uploadOneFile(file) {
const slug = requireUploadablePage();
$("save-status").textContent = `${tr("uploading", "Uploading")} ${file.name}`;
return api(`/api/pages/${encodeURIComponent(slug)}/upload`, {
method: "POST",
headers: { "X-File-Name": file.name },
body: file
});
}
async function uploadImageForEasyMDE(file) {
if (!isInlineImage(file)) {
throw new Error("Only PNG, JPEG, GIF and WebP can be inserted as images.");
}
const result = await uploadOneFile(file);
$("save-status").textContent = tr("image-upload-complete", "Image upload complete");
return result.url;
}
async function uploadFiles(files) {
const fileList = Array.from(files || []);
if (fileList.length === 0) return;
try {
requireUploadablePage();
for (const file of fileList) {
const result = await uploadOneFile(file);
const escapedName = file.name.replace(/[\[\]]/g, "\\$&");
const markdown = isInlineImage(file)
? `![${escapedName}](${result.url})`
: `[${escapedName}](${result.url})`;
insertTextAtCursor(`\n${markdown}\n`);
}
$("save-status").textContent = tr("upload-complete", "Upload complete");
} catch (error) {
$("save-status").textContent = error.message;
throw error;
}
}
function containsNonImageFile(dataTransfer) {
const items = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file");
return items.some((item) => !item.type.startsWith("image/"));
}
function installGeneralFileDrop() {
const container = editorContainer();
if (!container) return;
container.addEventListener("dragenter", (event) => {
if (Array.from(event.dataTransfer?.types || []).includes("Files")) {
container.classList.add("dragging");
}
}, true);
container.addEventListener("dragover", (event) => {
if (containsNonImageFile(event.dataTransfer)) {
event.preventDefault();
}
}, true);
container.addEventListener("dragleave", (event) => {
if (!container.contains(event.relatedTarget)) {
container.classList.remove("dragging");
}
}, true);
container.addEventListener("drop", (event) => {
container.classList.remove("dragging");
const files = Array.from(event.dataTransfer?.files || []);
if (files.some((file) => !isInlineImage(file))) {
event.preventDefault();
event.stopImmediatePropagation();
uploadFiles(files).catch(() => {});
}
}, true);
}
async function deleteCurrentPage() {
if (!state.currentPage) return;
if (!confirm(`Archive page “${state.currentPage.title}”?`)) return;
await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}`, { method: "DELETE" });
state.currentPage = null;
await loadPages();
if (state.pages.length > 0) {
const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else {
$("page-title").textContent = tr("no-pages", "No pages yet");
$("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {});
show("page-view");
}
}
function buildUnifiedDiff(oldText, newText, oldName, newName) {
const oldLines = oldText.replace(/\r\n/g, "\n").split("\n");
const newLines = newText.replace(/\r\n/g, "\n").split("\n");
const n = oldLines.length;
const m = newLines.length;
if (n * m > 4_000_000) {
return [
`--- ${oldName}`,
`+++ ${newName}`,
`@@ -1,${n} +1,${m} @@`,
...oldLines.map((line) => `-${line}`),
...newLines.map((line) => `+${line}`)
].join("\n");
}
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
table[i][j] = oldLines[i] === newLines[j]
? table[i + 1][j + 1] + 1
: Math.max(table[i + 1][j], table[i][j + 1]);
}
}
const body = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
body.push(` ${oldLines[i]}`);
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
body.push(`-${oldLines[i++]}`);
} else {
body.push(`+${newLines[j++]}`);
}
}
while (i < n) body.push(`-${oldLines[i++]}`);
while (j < m) body.push(`+${newLines[j++]}`);
return [
`--- ${oldName}`,
`+++ ${newName}`,
`@@ -1,${n} +1,${m} @@`,
...body
].join("\n");
}
function appendSearchSnippet(target, snippet) {
const parts = String(snippet || "").split(/(\[\[\[|\]\]\])/);
let highlighted = false;
let mark = null;
for (const part of parts) {
if (part === "[[[") {
highlighted = true;
mark = document.createElement("mark");
target.append(mark);
} else if (part === "]]]") {
highlighted = false;
mark = null;
} else if (part) {
const text = document.createTextNode(part.replace(/\s+/g, " "));
if (highlighted && mark) mark.append(text);
else target.append(text);
}
}
}
async function searchWiki(query) {
const text = (query || "").trim();
if (!text) {
await route();
return;
}
const result = await api(`/api/search?q=${encodeURIComponent(text)}`);
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("search", "Search") }
]);
renderToc([], () => {});
show("search-view");
const results = $("search-results");
results.replaceChildren();
const resultWord = result.results.length === 1 ? tr("result", "result") : tr("results", "results");
$("search-summary").textContent = `${result.results.length} ${resultWord} ${tr("for", "for")}${text}`;
for (const item of result.results) {
const article = document.createElement("article");
article.className = "search-result";
const heading = document.createElement("h2");
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(item.slug)}`;
link.textContent = item.title;
heading.append(link);
const snippet = document.createElement("p");
appendSearchSnippet(snippet, item.snippet);
article.append(heading, snippet);
results.append(article);
}
if (result.results.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-matching-pages", "No matching pages.");
results.append(empty);
}
}
async function showHistory() {
if (!state.currentPage) return;
state.previousView = "page-view";
pageBreadcrumbs(state.currentPage, "History");
show("history-view");
const result = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/history`);
const list = $("history-list");
list.replaceChildren();
$("diff-target").replaceChildren();
result.versions.forEach((version, index) => {
const row = document.createElement("div");
row.className = "history-row";
const label = document.createElement("div");
label.innerHTML = `<strong>${pageDisplayDate(version.createdAt)}</strong><br><span class="muted"></span>`;
label.querySelector("span").textContent = `${version.author}${version.summary}`;
const view = document.createElement("button");
view.textContent = tr("view", "View");
view.addEventListener("click", async () => {
const full = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`);
$("diff-target").innerHTML = `<article class="markdown-body">${renderMarkdown(full.markdown)}</article>`;
});
const compare = document.createElement("button");
compare.textContent = index + 1 < result.versions.length ? tr("compare-previous", "Compare previous") : "";
compare.disabled = index + 1 >= result.versions.length;
compare.addEventListener("click", async () => {
const older = result.versions[index + 1];
const [oldVersion, newVersion] = await Promise.all([
api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(older.version)}`),
api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`)
]);
const diff = buildUnifiedDiff(oldVersion.markdown, newVersion.markdown, older.version, version.version);
const target = $("diff-target");
target.replaceChildren();
const ui = new Diff2HtmlUI(target, diff, {
drawFileList: false,
matching: "lines",
outputFormat: "side-by-side",
highlight: false
});
ui.draw();
});
row.append(label, view, compare);
list.append(row);
});
}
function showAdmin() {
state.previousView = state.currentPage ? "page-view" : "admin-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") }
]);
show("admin-view");
}
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("users", "Users") }
]);
show("user-admin-view");
const result = await api("/api/admin/users");
const list = $("user-list");
list.replaceChildren();
for (const user of result.users) {
const row = document.createElement("div");
row.className = "user-row";
const username = document.createElement("strong");
username.textContent = user.username;
const display = document.createElement("input");
display.value = user.displayName;
const role = document.createElement("select");
for (const roleName of ["reader", "editor", "admin"]) {
const option = document.createElement("option");
option.value = roleName;
option.textContent = tr(`role-${roleName}`, roleName);
option.selected = roleName === user.role;
role.append(option);
}
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
const password = document.createElement("input");
password.type = "password";
password.placeholder = tr("new-password", "New password");
password.autocomplete = "new-password";
const actions = document.createElement("div");
const save = document.createElement("button");
save.textContent = tr("save", "Save");
save.addEventListener("click", async () => {
await api(`/api/admin/users/${user.id}`, {
method: "PUT",
body: JSON.stringify({
displayName: display.value,
role: role.value,
enabled: enabled.checked,
password: password.value || undefined
})
});
password.value = "";
});
const remove = document.createElement("button");
remove.textContent = tr("delete", "Delete");
remove.className = "danger";
remove.addEventListener("click", async () => {
if (!confirm(`Delete user ${user.username}?`)) return;
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
await loadUsersAdmin();
});
actions.append(save, remove);
row.append(username, display, role, enabled, password, actions);
list.append(row);
}
}
function showNotFound(slug) {
state.currentPage = null;
state.editingNew = false;
state.newPageSlug = null;
$("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`;
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: slug }
]);
show("not-found-view");
renderToc([], () => {});
renderPageList();
}
function showMissingEditablePage(slug) {
state.currentPage = null;
state.editingNew = false;
state.newPageSlug = slug;
$("page-title").textContent = slugTitle(slug) || slug;
$("page-meta").textContent = tr("this-page-missing", "This page does not exist yet.");
$("markdown-preview").innerHTML = "";
$("page-details").replaceChildren();
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: slugTitle(slug) || slug }
]);
show("page-view");
setPageActionVisibility(false);
renderToc([], () => {});
renderPageList();
}
async function route() {
const match = location.hash.match(/^#\/([^/]+)$/);
if (match) {
const slug = decodeURIComponent(match[1]);
try {
await openPage(slug);
} catch (error) {
if (error.status !== 404) {
throw error;
}
if (can("editor")) {
showMissingEditablePage(slug);
} else {
showNotFound(slug);
}
}
return;
}
if (state.pages.length > 0) {
const firstPage = startPage();
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
} else {
if (can("editor")) {
showMissingEditablePage("start");
} else {
state.currentPage = null;
$("page-title").textContent = tr("no-pages", "No pages yet");
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = "";
renderBreadcrumbs([{ label: state.siteTitle }]);
renderToc([], () => {});
show("page-view");
}
}
}
async function showTodos() {
state.previousView = state.currentPage ? "page-view" : "todo-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("todo", "Todo") }
]);
renderToc([], () => {});
show("todo-view");
const result = await api("/api/todos");
const list = $("todo-list");
list.replaceChildren();
if (result.items.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-todos", "No todo items.");
list.append(empty);
return;
}
for (const item of result.items) {
const row = document.createElement("article");
row.className = "todo-item";
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(item.slug)}`;
link.textContent = item.title;
const text = document.createElement("div");
text.className = "todo-item-text";
text.textContent = item.text;
const location = document.createElement("span");
location.className = "muted";
location.textContent = `${tr("line", "line")} ${item.line}`;
row.append(link, text, location);
list.append(row);
}
}
function pageLinks(markdown) {
const container = document.createElement("div");
container.innerHTML = renderMarkdown(markdown || "");
const links = new Set();
for (const link of container.querySelectorAll("a[href]")) {
const slug = wikiSlugFromHref(link.getAttribute("href"));
if (slug) links.add(slug);
}
return links;
}
async function loadGraphData() {
const pageSlugs = new Set(state.pages.map((page) => page.slug));
const edges = [];
const seen = new Set();
for (const page of state.pages) {
const full = await api(`/api/pages/${encodeURIComponent(page.slug)}`);
for (const target of pageLinks(full.markdown)) {
if (!pageSlugs.has(target) || target === page.slug) continue;
const key = `${page.slug}\u0000${target}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({ from: page.slug, to: target });
}
}
return { nodes: state.pages, edges };
}
function renderWikiGraph(data) {
const svg = $("wiki-graph");
svg.replaceChildren();
const width = 1000;
const height = 700;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.36;
const positions = new Map();
const nodes = data.nodes;
nodes.forEach((page, index) => {
const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index / nodes.length) - Math.PI / 2;
positions.set(page.slug, {
x: nodes.length <= 1 ? centerX : centerX + Math.cos(angle) * radius,
y: nodes.length <= 1 ? centerY : centerY + Math.sin(angle) * radius
});
});
const edgeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
edgeLayer.setAttribute("class", "wiki-graph-edges");
for (const edge of data.edges) {
const from = positions.get(edge.from);
const to = positions.get(edge.to);
if (!from || !to) continue;
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", from.x);
line.setAttribute("y1", from.y);
line.setAttribute("x2", to.x);
line.setAttribute("y2", to.y);
edgeLayer.append(line);
}
svg.append(edgeLayer);
const nodeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
nodeLayer.setAttribute("class", "wiki-graph-nodes");
for (const page of nodes) {
const position = positions.get(page.slug);
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("class", "wiki-graph-node");
group.setAttribute("transform", `translate(${position.x} ${position.y})`);
group.setAttribute("tabindex", "0");
group.setAttribute("role", "link");
group.setAttribute("aria-label", page.title);
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", "11");
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", "17");
text.setAttribute("y", "4");
text.textContent = page.title.length > 34 ? `${page.title.slice(0, 31)}` : page.title;
const open = () => { location.hash = `#/${encodeURIComponent(page.slug)}`; };
group.addEventListener("click", open);
group.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
group.append(circle, text);
nodeLayer.append(group);
}
svg.append(nodeLayer);
}
async function showGraph() {
state.previousView = state.currentPage ? "page-view" : "graph-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("graph", "Graph") }
]);
renderToc([], () => {});
show("graph-view");
const data = await loadGraphData();
$("graph-summary").textContent = tr("graph-summary", "{pages} pages, {links} links")
.replace("{pages}", String(data.nodes.length))
.replace("{links}", String(data.edges.length));
renderWikiGraph(data);
}
function setConnectionStatus(status) {
const node = $("connection-status");
node.classList.remove("hidden", "online", "offline");
node.classList.add(status);
node.textContent = status === "offline" ? tr("offline", "Offline") : tr("online", "Online");
if (status === "online") {
window.setTimeout(() => node.classList.add("hidden"), 1800);
}
}
async function pingServer() {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch("/api/ping", { cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if ($("connection-status").classList.contains("offline")) setConnectionStatus("online");
} catch (_error) {
setConnectionStatus("offline");
} finally {
window.clearTimeout(timeout);
}
}
function startKeepAlive() {
window.setInterval(pingServer, 15000);
pingServer();
}
async function initialize() {
state.session = await api("/api/session");
if (!state.session.authenticated) {
window.location.replace("/login");
return;
}
const translationData = await api("/api/translations");
state.language = translationData.language;
state.translationPage = translationData.page;
state.translationTemplate = translationData.template || "";
state.translations = translationData.translations || {};
applyTranslations();
initializeEditor();
$("account-name").textContent = state.session.user.displayName;
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi();
await loadPages();
await route();
startKeepAlive();
}
installWikiLinkNavigation();
$("search-form").addEventListener("submit", (event) => {
event.preventDefault();
searchWiki($("search-input").value).catch((error) => console.error(error));
});
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
$("wiki-brand").addEventListener("click", (event) => {
event.preventDefault();
goHome().catch((error) => console.error(error));
});
$("breadcrumbs").addEventListener("click", (event) => {
const home = event.target.closest("a[data-home='true']");
if (!home) return;
event.preventDefault();
goHome().catch((error) => console.error(error));
});
$("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); });
$("graph-link").addEventListener("click", (event) => { event.preventDefault(); showGraph().catch((error) => console.error(error)); });
$("logout-link").addEventListener("click", async (event) => {
event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
$("admin-translations-link").addEventListener("click", (event) => {
event.preventDefault();
const page = state.pages.find((item) => item.slug === state.translationPage);
if (page) {
location.hash = `#/${encodeURIComponent(state.translationPage)}`;
return;
}
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("translations", "Translations") }
]);
beginNewPage(state.translationPage);
});
$("close-history").addEventListener("click", () => show("page-view"));
$("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
$("cancel-edit").addEventListener("click", () => {
if (state.currentPage) {
openPage(state.currentPage.slug);
return;
}
if (state.newPageSlug) {
state.newPageSlug = null;
location.hash = "";
return;
}
route();
});
$("save-page").addEventListener("click", savePage);
$("file-input").addEventListener("change", (event) => {
uploadFiles(event.target.files).catch(() => {});
event.target.value = "";
});
$("new-user-form").addEventListener("submit", async (event) => {
event.preventDefault();
await api("/api/admin/users", {
method: "POST",
body: JSON.stringify({
username: $("new-username").value,
displayName: $("new-display-name").value || $("new-username").value,
password: $("new-password").value,
role: $("new-role").value,
enabled: true
})
});
event.target.reset();
await loadUsersAdmin();
});
window.addEventListener("hashchange", () => route().catch(console.error));
initialize().catch((error) => {
console.error(error);
const main = $("main");
main.replaceChildren();
const message = document.createElement("div");
message.className = "card error";
message.textContent = error.message;
main.append(message);
});
})();