1185 lines
40 KiB
JavaScript
1185 lines
40 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const state = {
|
|
session: null,
|
|
pages: [],
|
|
currentPage: null,
|
|
editingNew: false,
|
|
newPageSlug: null,
|
|
previousView: "page-view",
|
|
translations: {},
|
|
translationPage: "wiki-translations-en"
|
|
};
|
|
|
|
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", "history-view", "admin-view"]) {
|
|
$(id).classList.toggle("hidden", id !== viewId);
|
|
}
|
|
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
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 renderMarkdown(markdown) {
|
|
return easyMDE.markdown(expandTodoMarkup(markdown || ""));
|
|
}
|
|
|
|
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 ? easyMDE.markdown(expandTodoMarkup(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;
|
|
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: "Racket Wiki", 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 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;
|
|
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) {
|
|
const lines = [
|
|
"# Translation overrides",
|
|
"",
|
|
"Edit only the values after =. Unknown keys are ignored by the UI until they are used.",
|
|
""
|
|
];
|
|
Object.keys(state.translations).sort().forEach((key) => lines.push(`${key} = ${state.translations[key]}`));
|
|
easyMDE.value(lines.join("\n"));
|
|
} 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();
|
|
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})`;
|
|
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) {
|
|
location.hash = `#/${encodeURIComponent(state.pages[0].slug)}`;
|
|
} else {
|
|
$("page-title").textContent = tr("no-pages", "No pages yet");
|
|
$("markdown-preview").innerHTML = "";
|
|
renderBreadcrumbs([{ label: "Racket Wiki" }]);
|
|
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: "Racket Wiki", 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);
|
|
});
|
|
}
|
|
|
|
async function loadAdmin() {
|
|
state.previousView = state.currentPage ? "page-view" : "admin-view";
|
|
renderBreadcrumbs([]);
|
|
show("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 = 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 loadAdmin();
|
|
});
|
|
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: "Racket Wiki", 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: "Racket Wiki", 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) {
|
|
location.hash = `#/${encodeURIComponent(state.pages[0].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: "Racket Wiki" }]);
|
|
renderToc([], () => {});
|
|
show("page-view");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function showTodos() {
|
|
state.previousView = state.currentPage ? "page-view" : "todo-view";
|
|
renderBreadcrumbs([
|
|
{ label: "Racket Wiki", 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 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.translations = translationData.translations || {};
|
|
applyTranslations();
|
|
initializeEditor();
|
|
const userBox = $("user-box");
|
|
userBox.replaceChildren();
|
|
const displayName = document.createTextNode(state.session.user.displayName);
|
|
const breakNode = document.createElement("br");
|
|
const role = document.createElement("span");
|
|
role.className = "muted";
|
|
role.textContent = state.session.user.role;
|
|
const logout = document.createElement("button");
|
|
logout.id = "logout";
|
|
logout.textContent = tr("sign-out", "Sign out");
|
|
userBox.append(displayName, breakNode, role, " ", logout);
|
|
logout.addEventListener("click", async () => {
|
|
await api("/api/logout", { method: "POST", body: "{}" });
|
|
window.location.replace("/login");
|
|
});
|
|
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", beginEditPage);
|
|
$("delete-page").addEventListener("click", deleteCurrentPage);
|
|
$("history-page").addEventListener("click", showHistory);
|
|
$("todo-button").addEventListener("click", () => showTodos().catch((error) => console.error(error)));
|
|
$("translations-button").addEventListener("click", () => { location.hash = `#/${encodeURIComponent(state.translationPage)}`; });
|
|
$("admin-button").addEventListener("click", loadAdmin);
|
|
$("close-history").addEventListener("click", () => show("page-view"));
|
|
$("close-admin").addEventListener("click", () => state.currentPage ? show("page-view") : route());
|
|
$("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 loadAdmin();
|
|
});
|
|
|
|
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);
|
|
});
|
|
})();
|