(() => { "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) => `TODO ${escapeHtml(text.trim())}`); }).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})` : `[${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 = `${pageDisplayDate(version.createdAt)}
`; 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 = `
${renderMarkdown(full.markdown)}
`; }); 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); }); })();