import "/js/cmap/cmap-racket-wiki.js";
import {
newPageReference,
pageReference,
slugTitle,
splitPageReference
} from "./wiki/reference.js";
import { cmapRoute, pageRoute, parseWikiRoute } from "./wiki/routes.js";
import {
applyImageWidthMarkup,
escapeHtml,
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
normalizeMarkdownLinkDestinations,
protectCamelCaseWikiWords,
extractCmapEmbeds,
restoreCmapEmbeds
} from "./wiki/markdown.js";
import { BreadcrumbTrail } from "./wiki/breadcrumb-trail.js";
import { headingId, markdownHeadings } from "./wiki/page-outline.js";
import { AliasAdmin } from "./wiki/admin/alias-admin.js";
import { ArchivedCmapsAdmin } from "./wiki/admin/archived-cmaps-admin.js";
import { MailAdmin } from "./wiki/admin/mail-admin.js";
import { loadAdminOverview } from "./wiki/admin/overview.js";
import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
import { UserAdmin } from "./wiki/admin/user-admin.js";
import { CmapWorkspaceController } from "./wiki/cmap/cmap-workspace-controller.js";
import { ComboBox } from "./widgets/combobox.js";
(() => {
"use strict";
/*
* racket-wiki browser application.
*
* The browser application uses direct native modules without a client
* framework or build step. This entry module owns application orchestration;
* cohesive, independently testable operations live below static/js/wiki/.
* Larger functions are documented with goal/pre/post/result-style comments,
* following the same readability rules as the Racket sources.
*/
const state = {
session: null,
pages: [],
pageAliases: [],
currentPage: null,
editingNew: false,
newPageSlug: null,
newPageSuggestedTitle: "",
previousView: "page-view",
translations: {},
translationPage: "wiki-translations",
translationTemplate: "",
siteTitle: "Racket Wiki",
bookmarks: [],
conceptMaps: [],
cmapConceptUsage: new Map(),
cmapConceptIdsByName: new Map(),
cmapPageConcepts: new Map(),
currentConceptMap: null,
currentConceptMapSource: null,
cmapLoadSequence: 0,
cmapSavedSnapshot: null,
cmapGuardHash: "",
cmapPrototype: null,
rawMarkdown: false
};
let easyMDE = null;
let pendingWikiCmapLinkLabel = "";
const sidebarPreferenceKey = "racket-wiki-sidebar-collapsed";
const editorSideBySidePreferenceKey = "racket-wiki-editor-side-by-side";
////////////////////////////////////////////////////////////////////////////////
// General UI and HTTP support
////////////////////////////////////////////////////////////////////////////////
const $ = (id) => document.getElementById(id);
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
const cmapWorkspace = new CmapWorkspaceController(
state,
api,
tr,
can,
show,
renderBreadcrumbs,
renderToc,
pageDisplayDate,
loadPages,
openPage,
navigateToHash,
canonicalPageReference,
renderMarkdown,
renderPageCmapConnections,
escapeMarkdownLinkLabel
);
const aliasAdmin = new AliasAdmin(api, tr, loadPages);
const archivedCmapsAdmin = new ArchivedCmapsAdmin(
api, tr, pageDisplayDate, cmapWorkspace.loadConceptMaps);
const mailAdmin = new MailAdmin(api, tr);
const orphanedUploadsAdmin = new OrphanedUploadsAdmin(api, tr);
const userAdmin = new UserAdmin(api, tr);
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view", "archived-cmaps-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");
document.body.classList.toggle("cmap-mode", viewId === "cmap-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.querySelectorAll("[data-tr-title]").forEach((node) => {
node.title = tr(node.dataset.trTitle, node.title);
});
document.querySelectorAll("[data-tr-aria-label]").forEach((node) => {
node.setAttribute("aria-label", tr(node.dataset.trAriaLabel, node.getAttribute("aria-label")));
});
document.documentElement.lang = state.language || "en";
}
function setSidebarCollapsed(collapsed, remember = true) {
document.body.classList.toggle("sidebar-collapsed", collapsed);
const toggle = $("sidebar-toggle");
if (!toggle) return;
const translationKey = collapsed ? "expand-sidebar" : "collapse-sidebar";
toggle.dataset.trTitle = translationKey;
toggle.dataset.trAriaLabel = translationKey;
toggle.title = tr(translationKey, collapsed ? "Expand sidebar" : "Collapse sidebar");
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(!collapsed));
const icon = document.createElement("i");
icon.dataset.lucide = collapsed ? "panel-left-open" : "panel-left-close";
icon.setAttribute("aria-hidden", "true");
const oldIcon = toggle.querySelector("[data-lucide], svg");
if (oldIcon) {
oldIcon.replaceWith(icon);
} else {
toggle.append(icon);
}
if (window.lucide && typeof window.lucide.createIcons === "function") {
window.lucide.createIcons();
}
if (remember) {
try {
window.sessionStorage.setItem(sidebarPreferenceKey, collapsed ? "true" : "false");
} catch (_error) {
// A blocked session storage should not prevent the toggle from working.
}
}
}
function initializeSidebarToggle() {
const toggle = $("sidebar-toggle");
if (!toggle) return;
let collapsed = false;
try {
collapsed = window.sessionStorage.getItem(sidebarPreferenceKey) === "true";
} catch (_error) {
collapsed = false;
}
setSidebarCollapsed(collapsed, false);
toggle.addEventListener("click", () => setSidebarCollapsed(!document.body.classList.contains("sidebar-collapsed")));
}
function navigateToHash(targetHash) {
const navigate = () => {
if (location.hash === targetHash) return route();
location.hash = targetHash;
return undefined;
};
return cmapWorkspace.requestTransition(navigate);
}
function namespaceLabel(namespace) {
return namespace || tr("root-namespace", "Root");
}
function canonicalPageReference(reference) {
const value = String(reference || "");
const key = value.toLocaleLowerCase();
const alias = state.pageAliases.find((item) => String(item.slug || "").toLocaleLowerCase() === key);
return alias ? alias.targetSlug : value;
}
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"));
});
}
/**
* goal : Perform one authenticated API request and decode its response.
* pre : path identifies a racket-wiki API route.
* post : CSRF and JSON headers are applied when needed; 401 redirects to login.
* result : Decoded JSON/text response or a thrown Error.
*/
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;
}
////////////////////////////////////////////////////////////////////////////////
// Markdown rendering and wiki-link syntax
////////////////////////////////////////////////////////////////////////////////
function renderMarkdown(markdown, pageSlug = null) {
const extracted = extractCmapEmbeds(markdown || "");
const normalizedLinks = normalizeMarkdownLinkDestinations(extracted.markdown);
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(normalizedLinks);
const withWikiLinks = expandWikiMentions(
withExplicitWikiLinks,
state.pages,
state.pageAliases,
state.conceptMaps,
pageSlug);
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug, tr("todo", "Todo"));
const html = easyMDE.markdown(withTodos);
const withEmbeds = restoreCmapEmbeds(html, extracted.embeds, tr("loading", "Loading…"));
const withImages = applyImageWidthMarkup(withEmbeds);
const safeHtml = DOMPurify.sanitize(withImages);
if (extracted.embeds.length) cmapWorkspace.queueEmbedHydration();
if (/\b(?:language|lang)-mermaid\b/.test(safeHtml)) window.RacketWikiMermaid.queue(document);
return safeHtml;
}
////////////////////////////////////////////////////////////////////////////////
// Page display and navigation
////////////////////////////////////////////////////////////////////////////////
function setPageActionVisibility(pageExists) {
if (!can("editor")) return;
$("edit-page").classList.remove("hidden");
$("rename-page").classList.toggle("hidden", !pageExists || state.currentPage?.slug === state.translationPage);
$("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 = [];
if (page.namespace) {
fields.push(`${tr("namespace", "Namespace")}: ${page.namespace}`);
}
fields.push(
`${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 renderPageCmapConnections(page) {
const section = $("page-cmap-connections");
const list = $("page-cmap-connections-list");
const pageKey = canonicalPageReference(page.slug).toLocaleLowerCase();
const concepts = [...(state.cmapPageConcepts.get(pageKey)?.values() || [])]
.sort((first, second) => first.label.localeCompare(second.label));
list.replaceChildren();
section.classList.toggle("hidden", concepts.length === 0);
if (!concepts.length) return;
for (const concept of concepts) {
const row = document.createElement("div");
row.className = "page-cmap-concept-row";
const description = document.createElement("div");
description.className = "page-cmap-concept-description";
const label = document.createElement("strong");
label.textContent = concept.label;
const usage = document.createElement("span");
usage.className = "muted";
usage.textContent = tr("concept-usage-count", "{count} placements across all concept maps")
.replace("{count}", String(concept.count));
description.append(label, usage);
const maps = document.createElement("nav");
maps.className = "page-cmap-concept-maps";
maps.setAttribute("aria-label", tr("concept-map-locations", "Concept map locations"));
for (const placement of [...concept.maps.values()]
.sort((first, second) => first.title.localeCompare(second.title))) {
const link = document.createElement("a");
link.href = cmapRoute(placement.slug);
link.textContent = placement.count > 1 ?
`${placement.title} (${placement.count})` : placement.title;
maps.append(link);
}
row.append(description, maps);
list.append(row);
}
}
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("?")) {
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 cmapSlugFromHref(href) {
if (!href) return null;
const candidate = href.startsWith("#cmap/") ? href.slice(6) :
(href.toLocaleLowerCase().startsWith("cmap:") ? href.slice(5) : null);
if (!candidate || candidate.includes("/") || candidate.includes("?") || candidate.includes("#")) return null;
try {
return decodeURIComponent(candidate);
} catch (_error) {
return null;
}
}
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 cmapSlug = cmapSlugFromHref(link.getAttribute("href"));
if (cmapSlug) {
event.preventDefault();
location.hash = cmapRoute(cmapSlug);
return;
}
const slug = wikiSlugFromHref(link.getAttribute("href"));
if (!slug) return;
event.preventDefault();
location.hash = `#/${encodeURIComponent(slug)}`;
});
}
const editorToolbarIcons = new Map();
function toolbarButton(name, action, icon, title, options = {}) {
editorToolbarIcons.set(name, icon);
return {
name,
action,
className: `rw-mde-button rw-mde-${name}`,
title,
...options
};
}
function installLucideIcons() {
if (!window.lucide || typeof window.lucide.createIcons !== "function") {
throw new Error("Lucide is not installed. Open /setup to repair the frontend setup.");
}
for (const [name, iconName] of editorToolbarIcons) {
const button = document.querySelector(`.EasyMDEContainer .editor-toolbar .rw-mde-${name}`);
if (!button) continue;
const icon = document.createElement("i");
icon.dataset.lucide = iconName;
icon.setAttribute("aria-hidden", "true");
button.replaceChildren(icon);
}
window.lucide.createIcons();
}
////////////////////////////////////////////////////////////////////////////////
// EasyMDE editor setup and editing support
////////////////////////////////////////////////////////////////////////////////
function initializeHighlighting() {
if (!window.hljs) return;
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
window.hljs.registerAliases(["racket"], { languageName: "scheme" });
}
}
function applyRawMarkdownMode() {
if (!easyMDE) return;
editorContainer().classList.toggle("raw-markdown", state.rawMarkdown);
}
function toggleRawMarkdown() {
state.rawMarkdown = !state.rawMarkdown;
applyRawMarkdownMode();
easyMDE.codemirror.refresh();
}
function preferredEditorSideBySide() {
try {
const stored = window.sessionStorage.getItem(editorSideBySidePreferenceKey);
return stored === null ? true : stored === "true";
} catch (_error) {
return true;
}
}
function rememberEditorSideBySide() {
if (!easyMDE) return;
try {
window.sessionStorage.setItem(
editorSideBySidePreferenceKey,
String(easyMDE.isSideBySideActive())
);
} catch (_error) {
// A blocked session storage should not prevent the editor from working.
}
}
function toggleEditorSideBySide() {
easyMDE.toggleSideBySide();
rememberEditorSideBySide();
}
function protectCamelCaseInPage() {
if (!easyMDE) return;
const source = easyMDE.value();
const protectedMarkdown = protectCamelCaseWikiWords(source);
if (protectedMarkdown === source) return;
easyMDE.value(protectedMarkdown);
renderEditorToc();
$("save-status").textContent = tr("camelcase-protected", "CamelCase links protected; save the page to keep the changes.");
}
/**
* goal : Configure the single EasyMDE instance used for page editing.
* pre : Vendor scripts and the editor textarea are loaded.
* post : easyMDE is ready with toolbar, uploads, Raw mode and live TOC updates.
*/
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.");
}
if (!window.RacketWikiMermaid) {
throw new Error("The Mermaid wiki integration is not installed.");
}
initializeHighlighting();
window.RacketWikiMermaid.initialize();
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, state.currentPage?.slug || state.newPageSlug) : "",
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, "heading", tr("heading", "Heading")),
"|",
toolbarButton("quote", EasyMDE.toggleBlockquote, "quote", tr("quote", "Quote")),
toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list", tr("bulleted-list", "Bulleted list")),
toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ordered", tr("numbered-list", "Numbered list")),
toolbarButton("check-list", EasyMDE.toggleCheckList, "list-checks", 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("cmap-link", () => {
openWikiCmapLinkDialog().catch((error) => {
$("save-status").textContent = error.message;
console.error(error);
});
}, "share-2", tr("link-concept-map", "Link to CMap")),
toolbarButton("protect-camelcase", protectCamelCaseInPage, "ban", tr("protect-camelcase", "Protect CamelCase")),
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", 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-2", tr("undo", "Undo")),
toolbarButton("redo", EasyMDE.redo, "redo-2", tr("redo", "Redo")),
toolbarButton("save", savePageFromFullscreen, "save", tr("save", "Save"), { noDisable: true }),
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
toolbarButton("side-by-side", toggleEditorSideBySide, "columns-2", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "maximize", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
]
});
installLucideIcons();
applyRawMarkdownMode();
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`);
}
/**
* goal : Show the editor and synchronize its sticky chrome and TOC.
* pre : easyMDE has been initialized.
* post : Editor view is visible and sized for the current viewport.
*/
function activateEditor() {
show("editor-view");
renderEditorToc();
requestAnimationFrame(() => {
easyMDE.codemirror.refresh();
const wideScreen = window.matchMedia("(min-width: 901px)").matches;
if (wideScreen && easyMDE.isSideBySideActive() !== preferredEditorSideBySide()) {
easyMDE.toggleSideBySide();
}
updateEditorChromeMetrics();
easyMDE.codemirror.refresh();
});
}
function renderToc(entries, onSelect, onEdit = null) {
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 row = document.createElement("div");
row.className = "toc-row";
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);
});
row.append(link);
if (onEdit) {
const edit = document.createElement("a");
edit.href = "#";
edit.className = "toc-edit-link";
edit.textContent = "✎";
edit.title = tr("edit-section", "Edit section");
edit.setAttribute("aria-label", `${tr("edit-section", "Edit section")}: ${entry.text}`);
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onEdit(entry);
});
row.append(edit);
}
toc.append(row);
}
}
/**
* goal : Build the reader TOC from rendered headings and Markdown source lines.
* pre : state.currentPage and #markdown-preview represent the same page.
* post : TOC navigation scrolls below sticky chrome; editors get section-edit links.
*/
function renderPageToc() {
const article = $("markdown-preview");
const usedIds = new Set();
const sourceHeadings = markdownHeadings(state.currentPage?.markdown || "");
const entries = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading, index) => {
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,
line: sourceHeadings[index]?.line ?? null
};
});
renderToc(
entries,
(entry) => {
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
},
can("editor")
? (entry) => beginEditPageAtLine(entry.line)
: null
);
}
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();
});
}
let highlightedEditorLine = null;
let highlightedEditorTimer = null;
function clearEditorLineHighlight() {
if (!easyMDE || highlightedEditorLine === null) return;
easyMDE.codemirror.removeLineClass(highlightedEditorLine, "background", "section-edit-highlight");
highlightedEditorLine = null;
if (highlightedEditorTimer) {
window.clearTimeout(highlightedEditorTimer);
highlightedEditorTimer = null;
}
}
function focusEditorLine(line) {
if (!easyMDE || line === null || line === undefined) return;
clearEditorLineHighlight();
easyMDE.codemirror.setCursor({ line, ch: 0 });
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
highlightedEditorLine = easyMDE.codemirror.addLineClass(line, "background", "section-edit-highlight");
highlightedEditorTimer = window.setTimeout(clearEditorLineHighlight, 4500);
easyMDE.codemirror.focus();
}
function beginEditPageAtLine(line) {
beginEditPage();
requestAnimationFrame(() => {
requestAnimationFrame(() => focusEditorLine(line));
});
}
/**
* goal : Render the sticky history breadcrumb as real navigation links.
* pre : items are ordered from wiki home to the current context.
* post : #breadcrumbs contains clickable previous page locations.
*/
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.home || item.href === "/") link.dataset.home = "true";
if (item.slug) link.dataset.breadcrumbSlug = item.slug;
link.textContent = item.label;
breadcrumbs.append(link);
} else {
const current = document.createElement("span");
current.className = "breadcrumb-current";
current.textContent = item.label;
breadcrumbs.append(current);
}
});
}
/**
* goal : Build the page breadcrumb from the per-tab visit history.
* pre : state.pages and breadcrumbTrail are current.
* post : The sticky breadcrumb shows Home, previous pages and optional suffix.
*/
function pageBreadcrumbs(page, suffix = null) {
const firstPage = startPage();
const items = [];
if (firstPage) {
items.push({
label: state.siteTitle,
href: `#/${encodeURIComponent(firstPage.slug)}`,
slug: firstPage.slug,
home: true
});
} else {
items.push({ label: state.siteTitle });
}
for (const slug of breadcrumbTrail.slugs) {
const trailPage = state.pages.find((item) => item.slug === slug);
if (!trailPage) continue;
const isCurrent = page && trailPage.slug === page.slug && !suffix;
items.push({
label: trailPage.title,
href: isCurrent ? null : `#/${encodeURIComponent(trailPage.slug)}`,
slug: trailPage.slug
});
}
if (page && firstPage && page.slug === firstPage.slug && suffix) {
items[0].href = `#/${encodeURIComponent(firstPage.slug)}`;
}
if (suffix) {
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;
}
/**
* goal : Return to the first/start page from every normal or special view.
* pre : Page metadata has been loaded.
* post : Breadcrumb history is cleared and the start page is opened.
*/
async function goHome() {
const firstPage = startPage();
breadcrumbTrail.clear();
if (!firstPage) {
await route();
return;
}
const targetHash = `#/${encodeURIComponent(firstPage.slug)}`;
if (location.hash === targetHash) {
await openPage(firstPage.slug);
} else {
location.hash = targetHash;
}
}
/**
* goal : Render the secondary page list grouped by namespace.
* pre : state.pages contains current page metadata.
* post : #page-list reflects the current page set.
*/
function renderPageList() {
const list = $("page-list");
list.replaceChildren();
let previousNamespace = null;
for (const page of state.pages) {
const namespace = page.namespace || "";
if (namespace !== previousNamespace) {
const heading = document.createElement("div");
heading.className = "namespace-heading";
heading.textContent = namespaceLabel(namespace);
list.append(heading);
previousNamespace = namespace;
}
const link = document.createElement("a");
link.href = pageRoute(page.slug);
link.className = "page-link";
link.textContent = page.title;
link.classList.toggle("active", state.currentPage?.slug === page.slug);
list.append(link);
}
}
/**
* goal : Refresh current page metadata from PostgreSQL through the API.
* pre : The user is authenticated.
* post : state.pages, site identity, page list and template list are refreshed.
*/
async function loadPages() {
const result = await api("/api/pages");
state.pages = result.pages;
state.pageAliases = result.aliases || [];
cmapWorkspace.clearDescriptionPreviews();
updateWikiIdentity();
renderPageList();
updateTemplateSelect();
}
function templatePages() {
return state.pages.filter((page) => (page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase().startsWith("template-"));
}
function updateTemplateSelect() {
const select = $("template-select");
const selected = select.value;
select.replaceChildren();
const none = document.createElement("option");
none.value = "";
none.textContent = tr("no-template", "No template");
select.append(none);
for (const page of templatePages()) {
const option = document.createElement("option");
option.value = page.slug;
option.textContent = page.title;
select.append(option);
}
select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : "";
}
/**
* goal : Replace editor Markdown with a selected template-* wiki page.
* pre : slug identifies a readable template page.
* post : Editor contents are replaced after confirmation when necessary.
*/
async function applyTemplate(slug) {
if (!slug) return;
const template = await api(`/api/pages/${encodeURIComponent(slug)}`);
const current = easyMDE.value();
if (current.trim() !== "") {
const message = tr("replace-with-template", "Replace the current page content with template {template}?")
.replace("{template}", template.title);
if (!window.confirm(message)) {
$("template-select").value = "";
return;
}
}
easyMDE.value(template.markdown || "");
$("template-select").value = "";
renderEditorToc();
easyMDE.codemirror.focus();
}
function bookmarkForSlug(slug) {
return state.bookmarks.find((bookmark) => bookmark.slug === slug) || null;
}
function updateBookmarkAction() {
const link = $("bookmark-page");
if (!state.currentPage) {
link.classList.add("hidden");
return;
}
link.classList.remove("hidden");
const bookmark = bookmarkForSlug(state.currentPage.slug);
link.textContent = bookmark ? tr("bookmarked", "Bookmarked") : tr("bookmark", "Bookmark");
}
async function loadBookmarks() {
const result = await api("/api/bookmarks");
state.bookmarks = result.bookmarks || [];
updateBookmarkAction();
}
/**
* goal : Open one page in reader mode.
* pre : slug is a root or namespace-qualified page reference.
* post : Current page state, breadcrumb, TOC and bookmark action are updated.
*/
async function openPage(slug) {
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
state.currentPage = page;
if (slug !== page.slug) {
history.replaceState(null, "", pageRoute(page.slug));
}
breadcrumbTrail.record(page.slug, startPage()?.slug || null);
state.editingNew = false;
state.newPageSlug = null;
$("page-title").textContent = page.title;
$("page-meta").textContent = "";
$("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug);
renderPageDetails(page);
renderPageCmapConnections(page);
pageBreadcrumbs(page);
show("page-view");
setPageActionVisibility(true);
renderPageToc();
renderPageList();
updateBookmarkAction();
}
function updateEditorSlugInfo() {
const namespace = $("editor-namespace")?.value.trim() || "";
if (!state.editingNew && state.currentPage) {
const rawSlug = state.currentPage.pageSlug || splitPageReference(state.currentPage.slug).slug;
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace, rawSlug)}`;
return;
}
if (state.newPageSlug) {
const requested = splitPageReference(state.newPageSlug);
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace || requested.namespace, requested.slug)}`;
return;
}
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
}
/**
* goal : Open an empty editor for a not-yet-existing page reference.
* pre : requestedSlug is null or a compact root/namespaced page reference.
* post : Namespace, title, template and Markdown controls are initialized for creation.
*/
function beginNewPage(requestedSlug = null, suggestedTitle = "") {
state.editingNew = true;
state.currentPage = null;
state.newPageSlug = requestedSlug;
const translationPage = requestedSlug && requestedSlug === state.translationPage;
$("editor-title").value = translationPage ? tr("translations", "Translations") :
(suggestedTitle || "");
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
$("editor-namespace").disabled = Boolean(translationPage);
$("editor-tags").value = "";
if (translationPage) {
easyMDE.value(state.translationTemplate || "");
} else {
easyMDE.value("");
}
$("edit-summary").value = "";
$("save-status").textContent = "";
$("template-select").value = "";
updateEditorSlugInfo();
activateEditor();
$("editor-title").focus();
}
/**
* goal : Open the current page in EasyMDE.
* pre : state.currentPage is a current page, or newPageSlug identifies a missing page.
* post : Editor fields contain the page title, namespace, tags and Markdown.
*/
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-namespace").value = state.currentPage.namespace || "";
// Existing page addresses are changed only through Rename so an alias can be retained.
$("editor-namespace").disabled = true;
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
easyMDE.value(state.currentPage.markdown);
$("edit-summary").value = "";
$("save-status").textContent = "";
$("template-select").value = "";
updateEditorSlugInfo();
activateEditor();
}
/**
* goal : Save the current editor contents as a new or updated wiki page.
* pre : EasyMDE is active and title/namespace fields contain editor input.
* post : A successful save refreshes page metadata and opens the stored page.
*/
async function savePage() {
return savePageWithOptions();
}
function savePageFromFullscreen() {
savePageWithOptions({ keepEditing: true }).catch((error) => {
$("save-status").textContent = error.message;
});
}
/** Save the current Markdown and optionally keep the editor active. */
async function savePageWithOptions({ keepEditing = false } = {}) {
const title = $("editor-title").value.trim();
const namespace = $("editor-namespace").value.trim();
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
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,
namespace,
markdown,
tags,
summary: summary || tr("created-page", "Created page")
};
if (state.newPageSlug) {
const requested = splitPageReference(state.newPageSlug);
body.slug = pageReference(namespace || requested.namespace, requested.slug);
}
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,
namespace,
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);
}
if (keepEditing) {
$("editor-namespace").disabled = true;
updateEditorSlugInfo();
$("edit-summary").value = "";
} else {
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 escapeMarkdownLinkLabel(text) {
return String(text || "")
.replaceAll("\\", "\\\\")
.replaceAll("[", "\\[")
.replaceAll("]", "\\]");
}
async function openWikiCmapLinkDialog() {
pendingWikiCmapLinkLabel = easyMDE.codemirror.getDoc().getSelection();
await cmapWorkspace.loadConceptMaps();
wikiCmapLinkCombobox.setOptions(
state.conceptMaps.map((conceptMap) => cmapWorkspace.conceptMapEntry(conceptMap)), "");
$("wiki-cmap-link-submit").disabled = state.conceptMaps.length === 0;
$("wiki-cmap-embed-submit").disabled = state.conceptMaps.length === 0;
$("wiki-cmap-link").placeholder = state.conceptMaps.length ?
tr("filter-concept-maps", "Filter concept maps") :
tr("no-concept-maps", "No saved CMaps");
$("wiki-cmap-link-dialog").showModal();
$("wiki-cmap-link").focus();
}
function insertSelectedWikiCmapLink() {
const slug = wikiCmapLinkCombobox.value();
if (slug === null || !slug) {
const input = $("wiki-cmap-link");
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
input.reportValidity();
return false;
}
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
if (!conceptMap) return false;
const label = escapeMarkdownLinkLabel(pendingWikiCmapLinkLabel.trim() || conceptMap.title);
insertTextAtCursor(`[${label}](cmap:${slug})`);
$("wiki-cmap-link-dialog").close();
return true;
}
function insertSelectedWikiCmapEmbed() {
const slug = wikiCmapLinkCombobox.value();
if (slug === null || !slug) {
const input = $("wiki-cmap-link");
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
input.reportValidity();
return false;
}
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
if (!conceptMap) return false;
insertTextAtCursor(`\n\n{{cmap:${conceptMap.slug}}}\n\n`);
$("wiki-cmap-link-dialog").close();
return true;
}
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 encodeURI(result.url);
}
/**
* goal : Upload dropped/selected files and insert Markdown references at the cursor.
* pre : The current page has already been saved once.
* post : Uploaded files are stored through the API and referenced from the editor.
*/
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 url = encodeURI(result.url);
const markdown = isInlineImage(file)
? ``
: `[${escapedName}](${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);
}
/**
* goal : Open the rename/move form for the current page.
* pre : A current page exists and the user can edit pages.
* post : Rename fields contain current title, namespace and slug.
*/
function beginRenamePage() {
if (!state.currentPage || !can("editor")) return;
const address = splitPageReference(state.currentPage.slug);
$("rename-title").value = state.currentPage.title;
$("rename-namespace").value = state.currentPage.namespace || address.namespace;
$("rename-slug").value = state.currentPage.pageSlug || address.slug;
$("rename-status").textContent = "";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: state.currentPage.title, href: pageRoute(state.currentPage.slug) },
{ label: tr("rename", "Rename") }
]);
show("rename-view");
}
/**
* goal : Rename/move the current page while retaining the old address as an alias.
* pre : Rename fields contain a title, namespace and slug accepted by the server.
* post : Page metadata and aliases are refreshed and the canonical page is opened.
*/
async function saveRenamePage() {
if (!state.currentPage) return;
const oldReference = state.currentPage.slug;
$("rename-status").textContent = tr("saving", "Saving…");
try {
const page = await api(`/api/pages/${encodeURIComponent(oldReference)}/rename`, {
method: "POST",
body: JSON.stringify({
title: $("rename-title").value.trim(),
namespace: $("rename-namespace").value.trim(),
slug: $("rename-slug").value.trim(),
summary: tr("rename-summary", "Renamed page")
})
});
await loadPages();
await loadBookmarks();
state.currentPage = page;
const targetHash = pageRoute(page.slug);
if (location.hash === targetHash) {
await openPage(page.slug);
} else {
location.hash = targetHash;
}
} catch (error) {
$("rename-status").textContent = error.message;
}
}
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";
article.dataset.resultType = item.type || "page";
const heading = document.createElement("h2");
const type = document.createElement("span");
type.className = "search-result-type";
type.textContent = item.type === "cmap" ?
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
const link = document.createElement("a");
link.href = item.type === "cmap" ?
cmapRoute(item.slug) : pageRoute(item.slug);
link.textContent = item.title;
heading.append(type, 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-search-results", "No matching pages or concept maps.");
results.append(empty);
}
}
/**
* goal : Show immutable versions of the current page and diff actions.
* pre : state.currentPage identifies an existing page.
* post : History view contains newest-first versions.
*/
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, state.currentPage?.slug)}`;
});
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);
});
}
/** Show the administration overview and refresh its version information. */
async function showAdmin() {
state.previousView = state.currentPage ? "page-view" : "admin-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") }
]);
show("admin-view");
await loadAdminOverview(api, tr);
}
/** Show retained aliases and their current and historical references. */
async function loadAliasesAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("page-aliases", "Page aliases") }
]);
show("alias-admin-view");
await aliasAdmin.load();
}
/** Show uploads that have no current page references. */
async function loadOrphanedUploadsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("orphaned-uploads", "Orphaned uploads") }
]);
show("orphaned-uploads-view");
await orphanedUploadsAdmin.load();
}
/** Show archived CMaps that an administrator can restore. */
async function loadArchivedCmapsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin"), href: "#admin" },
{ label: tr("archived-concept-maps", "Archived CMaps") }
]);
show("archived-cmaps-view");
await archivedCmapsAdmin.load();
}
/** Show the user administration controller in its application view. */
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin") },
{ label: tr("users", "Users") }
]);
show("user-admin-view");
await userAdmin.load();
}
function showProfile() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("profile", "Profile") }
]);
show("profile-view");
const user = state.session.user;
$("profile-username").value = user.username;
$("profile-display-name").value = user.displayName;
$("profile-email").value = user.email || "";
$("profile-current-password").value = "";
$("profile-new-password").value = "";
$("profile-repeat-password").value = "";
$("profile-status").textContent = "";
}
/** Show mail settings and initialize the test recipient for the current user. */
async function loadMailSettingsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin"), href: "#admin" },
{ label: tr("email-and-password-reset", "Email and password reset") }
]);
show("mail-admin-view");
await mailAdmin.load(state.session.user.email || "");
}
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) {
const suggestedTitle = state.newPageSuggestedTitle;
state.newPageSuggestedTitle = "";
if (suggestedTitle) {
beginNewPage(slug, suggestedTitle);
return;
}
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();
$("page-cmap-connections").classList.add("hidden");
$("page-cmap-connections-list").replaceChildren();
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: slugTitle(slug) || slug }
]);
show("page-view");
setPageActionVisibility(false);
renderToc([], () => {});
renderPageList();
}
/**
* goal : Resolve the current hash route to a normal or special wiki view.
* pre : Session and page metadata have been loaded.
* post : Exactly one application view is made active.
* internals: parseWikiRoute recognizes syntax and decodes parameters. This
* dispatcher applies permissions and invokes the matching view.
*/
async function route() {
const currentRoute = parseWikiRoute(location.hash);
switch (currentRoute.name) {
case "profile":
showProfile();
return;
case "todo":
await showTodos(currentRoute.slug, currentRoute.number);
return;
case "cmap":
await cmapWorkspace.show(currentRoute.slug);
return;
case "cmaps":
await cmapWorkspace.show();
return;
case "recent":
await showRecent();
return;
case "bookmarks":
await showBookmarks();
return;
case "todos":
await showTodos();
return;
case "search":
$("search-input").value = currentRoute.query;
await searchWiki(currentRoute.query);
return;
case "admin":
if (can("admin")) {
await showAdmin();
return;
}
break;
case "admin-users":
if (can("admin")) {
await loadUsersAdmin();
return;
}
break;
case "admin-mail":
if (can("admin")) {
await loadMailSettingsAdmin();
return;
}
break;
case "admin-aliases":
if (can("admin")) {
await loadAliasesAdmin();
return;
}
break;
case "admin-orphaned-uploads":
if (can("admin")) {
await loadOrphanedUploadsAdmin();
return;
}
break;
case "admin-archived-cmaps":
if (can("admin")) {
await loadArchivedCmapsAdmin();
return;
}
break;
case "page":
try {
await openPage(currentRoute.slug);
} catch (error) {
if (error.status !== 404) throw error;
if (can("editor")) {
showMissingEditablePage(currentRoute.slug);
} else {
showNotFound(currentRoute.slug);
}
}
return;
case "home":
break;
}
if (state.pages.length > 0) {
const firstPage = startPage();
location.hash = pageRoute(firstPage.slug);
return;
}
if (can("editor")) {
showMissingEditablePage("start");
return;
}
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");
}
function formatTimestamp(value) {
const date = new Date(Number(value) * 1000);
return date.toLocaleString(state.language || undefined);
}
////////////////////////////////////////////////////////////////////////////////
// Special wiki views: Recent, Bookmarks and Todo
////////////////////////////////////////////////////////////////////////////////
async function showRecent() {
state.previousView = state.currentPage ? "page-view" : "recent-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("recent", "Recent") }
]);
setPageActionVisibility(false);
show("recent-view");
const result = await api("/api/recent");
const list = $("recent-list");
list.replaceChildren();
const items = result.items || [];
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-recent-changes", "No recent changes.");
list.append(empty);
return;
}
for (const item of items) {
const row = document.createElement("div");
row.className = "special-page-row";
row.dataset.resultType = item.type;
const heading = document.createElement("span");
heading.className = "special-page-heading";
const type = document.createElement("span");
type.className = "search-result-type";
type.textContent = item.type === "cmap" ?
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
const link = document.createElement("a");
link.href = item.type === "cmap" ? cmapRoute(item.slug) : pageRoute(item.slug);
link.textContent = item.title;
const meta = document.createElement("span");
meta.className = "special-page-meta";
meta.textContent = `${formatTimestamp(item.updatedAt)} · ${tr("changed-by", "changed by")} ${item.updatedBy}`;
heading.append(type, link);
row.append(heading, meta);
list.append(row);
}
}
async function saveBookmark(slug, existingSection = "") {
const section = window.prompt(tr("bookmark-section", "Bookmark section"), existingSection);
if (section === null) return;
await api("/api/bookmarks", {
method: "POST",
body: JSON.stringify({ slug, section: section.trim() })
});
await loadBookmarks();
}
async function removeBookmark(slug) {
await api(`/api/bookmarks/${encodeURIComponent(slug)}`, { method: "DELETE" });
await loadBookmarks();
}
/**
* goal : Show bookmarks grouped first by page namespace and then user section.
* pre : The user is authenticated.
* post : The Bookmarks special view contains the latest bookmark state.
*/
async function showBookmarks() {
state.previousView = state.currentPage ? "page-view" : "bookmarks-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("bookmarks", "Bookmarks") }
]);
setPageActionVisibility(false);
show("bookmarks-view");
await loadBookmarks();
const target = $("bookmarks-list");
target.replaceChildren();
if (state.bookmarks.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = tr("no-bookmarks", "No bookmarks yet.");
target.append(empty);
return;
}
const namespaceGroups = new Map();
for (const bookmark of state.bookmarks) {
const namespace = bookmark.namespace || "";
if (!namespaceGroups.has(namespace)) namespaceGroups.set(namespace, new Map());
const sections = namespaceGroups.get(namespace);
const section = bookmark.section || "";
if (!sections.has(section)) sections.set(section, []);
sections.get(section).push(bookmark);
}
for (const [namespace, sections] of namespaceGroups.entries()) {
const namespaceSection = document.createElement("section");
const namespaceHeading = document.createElement("h2");
namespaceHeading.textContent = namespaceLabel(namespace);
namespaceSection.append(namespaceHeading);
for (const [section, bookmarks] of sections.entries()) {
const sectionElement = document.createElement("section");
sectionElement.className = "bookmark-section";
if (section) {
const heading = document.createElement("h3");
heading.textContent = section;
sectionElement.append(heading);
}
for (const bookmark of bookmarks) {
const row = document.createElement("div");
row.className = "bookmark-row";
const link = document.createElement("a");
link.href = pageRoute(bookmark.slug);
link.textContent = bookmark.title;
const actions = document.createElement("span");
actions.className = "bookmark-section-actions";
const move = document.createElement("a");
move.href = "#";
move.textContent = tr("move", "Move");
move.addEventListener("click", async (event) => {
event.preventDefault();
await saveBookmark(bookmark.slug, bookmark.section || "");
await showBookmarks();
});
const separator = document.createTextNode(" · ");
const remove = document.createElement("a");
remove.href = "#";
remove.textContent = tr("remove", "Remove");
remove.addEventListener("click", async (event) => {
event.preventDefault();
await removeBookmark(bookmark.slug);
await showBookmarks();
});
actions.append(move, separator, remove);
row.append(link, actions);
sectionElement.append(row);
}
namespaceSection.append(sectionElement);
}
target.append(namespaceSection);
}
}
/**
* goal : Show page markers and TODO-aspect concepts in one Todo view.
* pre : Todo index is available through /api/todos.
* post : Optional target Todo is scrolled into view and highlighted.
*/
async function showTodos(targetSlug = null, targetNumber = null) {
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;
}
let todoGroup = null;
for (const item of result.items) {
const conceptTodo = item.type === "concept";
const group = conceptTodo ? "__concepts__" : (item.namespace || "");
if (group !== todoGroup) {
const heading = document.createElement("h2");
heading.className = "namespace-heading";
heading.textContent = conceptTodo ?
tr("concept-todos", "Concepts") : namespaceLabel(item.namespace || "");
list.append(heading);
todoGroup = group;
}
const row = document.createElement("article");
row.className = `todo-item${conceptTodo ? " todo-item-concept" : ""}`;
row.id = conceptTodo ? `todo-concept-${item.conceptId}` :
`todo-${item.slug}-${item.number}`;
const link = document.createElement("a");
if (conceptTodo) {
const pageTarget = item.descriptionPageSlug || item.pageSlug;
const cmapTarget = item.cmapSlug || item.placementCmapSlug;
const externalTarget = cmapWorkspace.normalizeExternalUrl(item.externalUrl);
link.href = pageTarget ? pageRoute(pageTarget) :
(item.cmapSlug ? cmapRoute(item.cmapSlug) :
(externalTarget || (cmapTarget ? cmapRoute(cmapTarget) : "#cmaps")));
if (externalTarget && link.href === externalTarget) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
} else {
link.href = pageRoute(item.slug);
}
link.textContent = item.title;
const text = document.createElement("div");
text.className = "todo-item-text";
text.textContent = item.text || (conceptTodo ?
tr("concept-marked-todo", "Concept marked with the TODO aspect.") : "");
const location = document.createElement("span");
location.className = "muted";
location.textContent = conceptTodo ?
`${tr("concept-map", "Concept map")}: ${item.placementCmapTitle}` :
`${tr("line", "line")} ${item.line}`;
row.append(link, text, location);
list.append(row);
}
if (targetSlug && targetNumber) {
const target = document.getElementById(`todo-${targetSlug}-${targetNumber}`);
if (target) {
target.classList.add("todo-item-target");
target.scrollIntoView({ block: "center" });
}
}
}
////////////////////////////////////////////////////////////////////////////////
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);
}
}
/**
* goal : Start periodic connectivity checks for the wiki backend.
* pre : Browser application initialization has completed.
* post : Offline/online state is updated approximately every 15 seconds.
*/
function startKeepAlive() {
window.setInterval(pingServer, 15000);
pingServer();
}
/**
* goal : Bootstrap the authenticated browser application.
* pre : index.html and all pinned vendor scripts are loaded.
* post : Session, translations, pages, editor, navigation and keep-alive are ready.
*/
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();
initializeSidebarToggle();
await cmapWorkspace.initialize();
initializeEditor();
$("account-name").textContent = state.session.user.displayName;
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
updateRoleUi();
await loadPages();
breadcrumbTrail.load(state.pages.map((page) => page.slug));
await loadBookmarks();
await route();
startKeepAlive();
}
installWikiLinkNavigation();
$("search-form").addEventListener("submit", (event) => {
event.preventDefault();
const query = $("search-input").value.trim();
if (!query) {
const target = state.currentPage ? pageRoute(state.currentPage.slug) : pageRoute(startPage()?.slug || "start");
navigateToHash(target).catch((error) => console.error(error));
return;
}
navigateToHash(`#search/${encodeURIComponent(query)}`)
.catch((error) => console.error(error));
});
$("bookmark-page").addEventListener("click", (event) => {
event.preventDefault();
if (!state.currentPage) return;
const existing = bookmarkForSlug(state.currentPage.slug);
saveBookmark(state.currentPage.slug, existing?.section || "").catch((error) => console.error(error));
});
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("rename-page").addEventListener("click", (event) => { event.preventDefault(); beginRenamePage(); });
$("save-rename").addEventListener("click", () => { saveRenamePage().catch(console.error); });
$("cancel-rename").addEventListener("click", () => { if (state.currentPage) openPage(state.currentPage.slug).catch(console.error); });
$("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 link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
if (!link) return;
event.preventDefault();
const slug = link.dataset.breadcrumbSlug;
if (link.dataset.home === "true") {
goHome().catch((error) => console.error(error));
return;
}
breadcrumbTrail.truncate(slug, startPage()?.slug || null);
const targetHash = `#/${encodeURIComponent(slug)}`;
if (location.hash === targetHash) {
openPage(slug).catch((error) => console.error(error));
} else {
location.hash = targetHash;
}
});
$("recent-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#recent").catch((error) => console.error(error));
});
$("bookmarks-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#bookmarks").catch((error) => console.error(error));
});
$("todo-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#todos").catch((error) => console.error(error));
});
$("cmaps-link").addEventListener("click", (event) => {
event.preventDefault();
const preferred = cmapWorkspace.startSlug();
const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) ||
state.conceptMaps[0] || null;
navigateToHash(target ? cmapRoute(target.slug) : "#cmaps")
.catch((error) => console.error(error));
});
$("wiki-cmap-link-cancel").addEventListener("click", () => $("wiki-cmap-link-dialog").close());
$("wiki-cmap-link-form").addEventListener("submit", (event) => {
event.preventDefault();
insertSelectedWikiCmapLink();
});
$("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed);
$("wiki-cmap-link-dialog").addEventListener("close", () => {
pendingWikiCmapLinkLabel = "";
wikiCmapLinkCombobox.clear();
});
$("logout-link").addEventListener("click", async (event) => {
event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("profile-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#profile").catch(console.error);
});
$("profile-form").addEventListener("submit", async (event) => {
event.preventDefault();
const newPassword = $("profile-new-password").value;
if (newPassword !== $("profile-repeat-password").value) {
$("profile-status").textContent = tr("passwords-do-not-match", "Passwords do not match.");
return;
}
try {
const result = await api("/api/profile", {
method: "PUT",
body: JSON.stringify({
displayName: $("profile-display-name").value,
email: $("profile-email").value,
currentPassword: $("profile-current-password").value,
newPassword
})
});
state.session = result.session;
$("account-name").textContent = state.session.user.displayName;
$("profile-current-password").value = "";
$("profile-new-password").value = "";
$("profile-repeat-password").value = "";
$("profile-status").textContent = tr("profile-saved", "Profile saved.");
} catch (error) {
$("profile-status").textContent = error.message;
}
});
$("admin-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("admin-users-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/users").catch(console.error);
});
$("admin-mail-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/mail").catch(console.error);
});
$("admin-aliases-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/aliases").catch(console.error);
});
$("admin-orphaned-uploads-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/orphaned-uploads").catch(console.error);
});
$("admin-archived-cmaps-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/archived-cmaps").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-orphaned-uploads").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-archived-cmaps").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-user-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-mail-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-alias-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("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);
$("editor-namespace").addEventListener("input", updateEditorSlugInfo);
$("template-select").addEventListener("change", (event) => {
applyTemplate(event.target.value).catch((error) => {
$("save-status").textContent = error.message;
event.target.value = "";
});
});
$("file-input").addEventListener("change", (event) => {
uploadFiles(event.target.files).catch(() => {});
event.target.value = "";
});
document.addEventListener("click", (event) => {
if (!cmapWorkspace.hasUnsavedChanges()) return;
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!link || link.target === "_blank") return;
event.preventDefault();
event.stopImmediatePropagation();
cmapWorkspace.requestTransition(() => link.click()).catch((error) => console.error(error));
}, true);
window.addEventListener("beforeunload", (event) => {
if (!cmapWorkspace.hasUnsavedChanges()) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("hashchange", () => {
if (cmapWorkspace.hasUnsavedChanges()) {
const requestedHash = location.hash;
const previousHash = state.cmapGuardHash;
history.replaceState(history.state, "", `${location.pathname}${location.search}${previousHash}`);
cmapWorkspace.requestTransition(() => {
state.cmapGuardHash = requestedHash;
if (location.hash === requestedHash) {
return route();
}
location.hash = requestedHash;
return undefined;
}).catch(console.error);
return;
}
state.cmapGuardHash = location.hash;
route().catch(console.error);
});
initialize().catch((error) => {
console.error(error);
const main = $("main");
main.replaceChildren();
const message = document.createElement("div");
message.className = "card error";
const text = String(error && error.message || error);
const setupPath = "/setup";
const setupIndex = text.indexOf(setupPath);
if (setupIndex === -1) {
message.textContent = text;
} else {
message.append(document.createTextNode(text.slice(0, setupIndex)));
const setupLink = document.createElement("a");
setupLink.href = setupPath;
setupLink.textContent = setupPath;
message.append(setupLink, document.createTextNode(text.slice(setupIndex + setupPath.length)));
}
main.append(message);
});
})();