${embed.token}
`, placeholder).replace(embed.token, placeholder); } return result; } function queueCmapEmbedHydration() { if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer); cmapEmbedHydrationTimer = window.setTimeout(() => { cmapEmbedHydrationTimer = null; hydrateCmapEmbeds(document).catch((error) => console.error(error)); }, 0); } function renderMarkdown(markdown, pageSlug = null) { const extracted = extractCmapEmbeds(markdown || ""); const withExplicitWikiLinks = expandNamespacedMarkdownLinks(extracted.markdown); const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug); const withTodos = expandTodoMarkup(withWikiLinks, pageSlug); const html = easyMDE.markdown(withTodos); const withEmbeds = restoreCmapEmbeds(html, extracted.embeds); const withImages = applyImageWidthMarkup(withEmbeds); const safeHtml = DOMPurify.sanitize(withImages); if (extracted.embeds.length) queueCmapEmbedHydration(); return safeHtml; } async function hydrateCmapEmbeds(root) { const embeds = Array.from(root.querySelectorAll(".rw-cmap-embed:not([data-cmap-hydrated])")); for (const embed of embeds) { embed.dataset.cmapHydrated = "loading"; const conceptMap = cmapMentionTarget(embed.dataset.cmapReference || ""); if (!conceptMap) { embed.dataset.cmapHydrated = "error"; embed.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = tr("concept-map-not-found", "CMap not found"); embed.append(message); continue; } try { const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`); const documentValue = decodeStoredConceptMapDocument(stored); embed.replaceChildren(); embed.dataset.cmapSlug = conceptMap.slug; embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap."); const header = document.createElement("header"); const title = document.createElement("strong"); title.textContent = conceptMap.title; const hint = document.createElement("span"); hint.textContent = tr("embedded-concept-map-help", "Double-click to open this CMap."); header.append(title, hint); const viewport = document.createElement("div"); viewport.className = "rw-cmap-embed-viewport"; const canvas = document.createElement("div"); canvas.className = "cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas"; viewport.append(canvas); embed.append(header, viewport); const editor = window.RacketWikiCmap.createEditor(canvas, { Cmap: window.Cmap, renderItem: (record) => cmapNodeHtml(record) }); editor.loadDocument(documentValue); const visible = editor.items.filter((item) => editor.isEffectiveItemVisible(item)); if (visible.length) { const left = Math.min(...visible.map((item) => Number(item.node.attr("x")))) - 24; const top = Math.min(...visible.map((item) => Number(item.node.attr("y")))) - 24; const right = Math.max(...visible.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24; const bottom = Math.max(...visible.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24; const availableWidth = Math.max(320, embed.clientWidth - 2); const scale = Math.min(1, availableWidth / Math.max(1, right - left), 520 / Math.max(1, bottom - top)); editor.zoomFactor = scale; editor.map.zoom(scale); viewport.style.height = `${Math.max(180, Math.ceil((bottom - top) * scale))}px`; window.requestAnimationFrame(() => { viewport.scrollLeft = Math.max(0, left * scale); viewport.scrollTop = Math.max(0, top * scale); }); } embed.dataset.cmapHydrated = "ready"; embed.addEventListener("dblclick", () => { navigateToHash(cmapRoute(conceptMap.slug)).catch((error) => console.error(error)); }); } catch (error) { embed.dataset.cmapHydrated = "error"; embed.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = error.message; embed.append(message); } } } function slugTitle(slug) { const pageSlug = splitPageReference(slug || "").slug; return pageSlug .split("-") .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } //////////////////////////////////////////////////////////////////////////////// // 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 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; window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false"); applyRawMarkdownMode(); easyMDE.codemirror.refresh(); } /** * 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."); } initializeHighlighting(); easyMDE = new EasyMDE({ element: $("markdown-editor"), autoDownloadFontAwesome: false, autoRefresh: { delay: 200 }, forceSync: true, lineNumbers: true, lineWrapping: true, indentWithTabs: false, tabSize: 2, minHeight: "520px", nativeSpellcheck: true, spellChecker: false, previewImagesInEditor: true, previewClass: ["editor-preview", "markdown-body", "preview-pane"], sideBySideFullscreen: false, syncSideBySidePreviewScroll: true, status: ["lines", "words", "cursor"], uploadImage: true, imageMaxSize: 50 * 1024 * 1024, imageAccept: "image/png,image/jpeg,image/gif,image/webp", imageUploadFunction: (file, onSuccess, onError) => { uploadImageForEasyMDE(file) .then(onSuccess) .catch((error) => onError(error.message)); }, previewRender: (plainText) => easyMDE ? renderMarkdown(plainText, 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("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("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", EasyMDE.toggleSideBySide, "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()) { 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, 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)); }); } const breadcrumbStorageKey = "racket-wiki-breadcrumb-trail"; const breadcrumbTrailLimit = 8; function saveBreadcrumbTrail() { window.sessionStorage.setItem(breadcrumbStorageKey, JSON.stringify(state.breadcrumbTrail)); } function loadBreadcrumbTrail() { let stored = []; try { stored = JSON.parse(window.sessionStorage.getItem(breadcrumbStorageKey) || "[]"); } catch (_error) { stored = []; } const pageSlugs = new Set(state.pages.map((page) => page.slug)); state.breadcrumbTrail = Array.isArray(stored) ? stored.filter((slug) => typeof slug === "string" && pageSlugs.has(slug)) : []; saveBreadcrumbTrail(); } function clearBreadcrumbTrail() { state.breadcrumbTrail = []; saveBreadcrumbTrail(); } function recordPageVisit(page) { if (!page) return; const firstPage = startPage(); if (firstPage && page.slug === firstPage.slug) { clearBreadcrumbTrail(); return; } const existingIndex = state.breadcrumbTrail.indexOf(page.slug); if (existingIndex >= 0) { state.breadcrumbTrail = state.breadcrumbTrail.slice(0, existingIndex + 1); } else { state.breadcrumbTrail.push(page.slug); if (state.breadcrumbTrail.length > breadcrumbTrailLimit) { state.breadcrumbTrail = state.breadcrumbTrail.slice(-breadcrumbTrailLimit); } } saveBreadcrumbTrail(); } function truncateBreadcrumbTrail(slug) { const firstPage = startPage(); if (firstPage && slug === firstPage.slug) { clearBreadcrumbTrail(); return; } const index = state.breadcrumbTrail.indexOf(slug); if (index >= 0) { state.breadcrumbTrail = state.breadcrumbTrail.slice(0, index + 1); saveBreadcrumbTrail(); } } /** * 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 state.breadcrumbTrail) { 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(); clearBreadcrumbTrail(); 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 || []; state.graphData = null; 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)); } recordPageVisit(page); state.editingNew = false; state.newPageSlug = null; $("page-title").textContent = page.title; $("page-meta").textContent = ""; $("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug); renderPageDetails(page); pageBreadcrumbs(page); show("page-view"); setPageActionVisibility(true); renderPageToc(); renderPageList(); updateBookmarkAction(); if (state.contextDockPosition) { refreshContextDock().catch((error) => console.error(error)); } } 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) { state.editingNew = true; state.currentPage = null; state.newPageSlug = requestedSlug; const translationPage = requestedSlug && requestedSlug === state.translationPage; $("editor-title").value = translationPage ? tr("translations", "Translations") : ""; $("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() { 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); } 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 loadConceptMaps(); wikiCmapLinkCombobox.setOptions( state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(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 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 markdown = isInlineImage(file) ? `` : `[${escapedName}](${result.url})`; insertTextAtCursor(`\n${markdown}\n`); } $("save-status").textContent = tr("upload-complete", "Upload complete"); } catch (error) { $("save-status").textContent = error.message; throw error; } } function containsNonImageFile(dataTransfer) { const items = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file"); return items.some((item) => !item.type.startsWith("image/")); } function installGeneralFileDrop() { const container = editorContainer(); if (!container) return; container.addEventListener("dragenter", (event) => { if (Array.from(event.dataTransfer?.types || []).includes("Files")) { container.classList.add("dragging"); } }, true); container.addEventListener("dragover", (event) => { if (containsNonImageFile(event.dataTransfer)) { event.preventDefault(); } }, true); container.addEventListener("dragleave", (event) => { if (!container.contains(event.relatedTarget)) { container.classList.remove("dragging"); } }, true); container.addEventListener("drop", (event) => { container.classList.remove("dragging"); const files = Array.from(event.dataTransfer?.files || []); if (files.some((file) => !isInlineImage(file))) { event.preventDefault(); event.stopImmediatePropagation(); uploadFiles(files).catch(() => {}); } }, true); } /** * 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)}