namespaces added and documentation

This commit is contained in:
2026-08-15 15:01:50 +02:00
parent b9e081b13d
commit 8997f7f94a
20 changed files with 696 additions and 158 deletions
+384 -77
View File
@@ -1,6 +1,16 @@
(() => {
"use strict";
/*
* racket-wiki browser application.
*
* The source is deliberately kept as one direct script instead of a client
* framework. Larger functions are documented with goal/pre/post/result-style
* comments, following the same readability rules as the Racket sources.
* Markdown source remains authoritative; Todo markup, WikiWords, namespace
* references and image options are expanded only for rendering.
*/
const state = {
session: null,
pages: [],
@@ -19,6 +29,10 @@
let easyMDE = null;
////////////////////////////////////////////////////////////////////////////////
// General UI and HTTP support
////////////////////////////////////////////////////////////////////////////////
const $ = (id) => document.getElementById(id);
function show(viewId) {
@@ -53,6 +67,38 @@
.replaceAll('"', """);
}
/**
* goal : Split a wiki page reference into namespace and page slug.
* pre : reference is a string such as "roadmap" or "racket:roadmap".
* post : No application state is changed.
* result : An object with namespace and slug fields.
*/
function splitPageReference(reference) {
const value = String(reference || "");
const separator = value.indexOf(":");
if (separator < 0) return { namespace: "", slug: value };
return { namespace: value.slice(0, separator), slug: value.slice(separator + 1) };
}
/**
* goal : Build the compact external reference for a wiki page.
* pre : namespace and slug are already suitable wiki identifiers.
* post : No application state is changed.
* result : slug for root pages, otherwise namespace:slug.
*/
function pageReference(namespace, slug) {
const cleanNamespace = String(namespace || "").trim();
return cleanNamespace ? `${cleanNamespace}:${slug}` : slug;
}
function pageRoute(reference) {
return `#/${encodeURIComponent(reference)}`;
}
function namespaceLabel(namespace) {
return namespace || tr("root-namespace", "Root");
}
function expandTodoMarkup(markdown, pageSlug = null) {
let inFence = false;
let todoNumber = 0;
@@ -92,6 +138,12 @@
});
}
/**
* 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)) {
@@ -114,6 +166,10 @@
return body;
}
////////////////////////////////////////////////////////////////////////////////
// Markdown rendering and wiki-link syntax
////////////////////////////////////////////////////////////////////////////////
function applyImageWidthMarkup(html) {
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
@@ -164,24 +220,33 @@
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
}
function camelCaseTarget(text, aliases) {
/**
* goal : Resolve a classic WikiWord to an existing page or a new page target.
* pre : text contains only the candidate WikiWord and aliases is built from state.pages.
* post : No page is created; this only creates a render target.
* result : {slug,title} or null when text is not a valid WikiWord.
*/
function camelCaseTarget(text, aliases, namespace = "") {
const parts = wikiWordParts(text);
if (parts.length === 0) return null;
const existing = aliases.get(normalizeMentionText(text));
const aliasKey = `${String(namespace || "").toLocaleLowerCase()}:${normalizeMentionText(text)}`;
const existing = aliases.get(aliasKey);
if (existing) {
return { slug: existing.slug, title: existing.title };
}
const pageSlug = parts.map((part) => part.toLocaleLowerCase()).join("-");
return {
slug: parts.map((part) => part.toLocaleLowerCase()).join("-"),
slug: pageReference(namespace, pageSlug),
title: parts.join(" ")
};
}
function mentionAliases(page) {
const aliases = new Set();
const values = [page.title, slugTitle(page.slug), page.slug.replaceAll("-", " ")];
const rawSlug = page.pageSlug || splitPageReference(page.slug).slug;
const values = [page.title, slugTitle(rawSlug), rawSlug.replaceAll("-", " ")];
for (const value of values) {
if (!value) continue;
aliases.add(value);
@@ -194,15 +259,30 @@
.filter((alias) => alias.key.length >= 5);
}
/**
* goal : Build an ambiguity-aware lookup table for WikiWords.
* pre : state.pages contains the current page metadata.
* post : No state is changed.
* result : Map keys include the page namespace, so equal names may exist in different namespaces.
*/
function pageMentionMap(currentSlug = null) {
const map = new Map();
for (const page of state.pages) {
if (page.slug === currentSlug) continue;
const namespace = String(page.namespace || "").toLocaleLowerCase();
for (const alias of mentionAliases(page)) {
if (!map.has(alias.key)) {
map.set(alias.key, page);
} else if (map.get(alias.key)?.slug !== page.slug) {
map.set(alias.key, null);
const namespacedKey = `${namespace}:${alias.key}`;
if (!map.has(namespacedKey)) {
map.set(namespacedKey, page);
} else if (map.get(namespacedKey)?.slug !== page.slug) {
map.set(namespacedKey, null);
}
const rootKey = `:${alias.key}`;
if (!map.has(rootKey)) {
map.set(rootKey, page);
} else if (map.get(rootKey)?.slug !== page.slug) {
map.set(rootKey, null);
}
}
}
@@ -235,11 +315,48 @@
}
/**
* goal : Rewrite explicit Markdown targets such as (racket:roadmap) to the internal hash route.
* pre : markdown is source text; fenced code must remain untouched.
* post : Only explicit wiki-looking link destinations are rewritten for rendering.
* result : Render-only Markdown; stored Markdown is never changed.
*/
function expandNamespacedMarkdownLinks(markdown) {
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
fence = fence === null ? marker : (fence === marker ? null : fence);
result.push(line);
continue;
}
if (fence !== null || /^\s{4}/.test(line)) {
result.push(line);
continue;
}
result.push(line.replace(/(!?\[[^\]\n]*\]\()([\p{L}\p{N}._-]+):([\p{L}\p{N}._-]+)(\))/gu,
(_match, before, namespace, slug, after) => `${before}${pageRoute(pageReference(namespace, slug))}${after}`));
}
return result.join("\n");
}
/**
* goal : Expand classic WikiWords to temporary Markdown links before Marked renders them.
* pre : markdown may contain paragraphs, lists, tables, quotes and headings.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
* result : Render-only Markdown with WikiWord links.
*/
function expandWikiMentions(markdown, currentSlug = null) {
const aliases = pageMentionMap(currentSlug);
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
@@ -260,29 +377,20 @@
}
const protectedRanges = markdownProtectedRanges(line);
const words = Array.from(line.matchAll(/\p{L}+/gu));
const replacements = [];
for (const word of words) {
const start = word.index;
const end = start + word[0].length;
for (const match of line.matchAll(wikiWordPattern)) {
const start = match.index;
const end = start + match[0].length;
if (positionIsProtected(start, end, protectedRanges)) continue;
const target = camelCaseTarget(word[0], aliases);
if (target) {
replacements.push({ start, end, page: target });
}
}
if (replacements.length === 0) {
result.push(line);
continue;
const namespace = match[1] || "";
const target = camelCaseTarget(match[2], aliases, namespace);
if (target) replacements.push({ start, end, page: target });
}
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = `[${replacement.page.title}](#/${encodeURIComponent(replacement.page.slug)})`;
const link = `[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
}
result.push(expanded);
@@ -291,8 +399,15 @@
return result.join("\n");
}
/**
* goal : Render wiki Markdown using the same EasyMDE/Marked pipeline everywhere.
* pre : markdown is source text; pageSlug is optional context for Todo links.
* post : The returned HTML is sanitized with DOMPurify.
* result : Safe HTML for preview, reader view or history view.
*/
function renderMarkdown(markdown, pageSlug = null) {
const withWikiLinks = expandWikiMentions(markdown || "", pageSlug);
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(markdown || "");
const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug);
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
const html = easyMDE.markdown(withTodos);
const withImages = applyImageWidthMarkup(html);
@@ -300,13 +415,18 @@
}
function slugTitle(slug) {
return (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");
@@ -336,11 +456,15 @@
const details = $("page-details");
details.replaceChildren();
const fields = [
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");
@@ -377,7 +501,7 @@
candidate = href.slice(1);
} else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) {
candidate = href.slice(2);
} else if (!href.includes("/") && !href.includes(":") && !href.includes("#") && !href.includes("?")) {
} else if (!href.includes("/") && !href.includes("#") && !href.includes("?")) {
candidate = href;
}
@@ -416,6 +540,10 @@
};
}
////////////////////////////////////////////////////////////////////////////////
// EasyMDE editor setup and editing support
////////////////////////////////////////////////////////////////////////////////
function initializeHighlighting() {
if (!window.hljs) return;
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
@@ -435,6 +563,11 @@
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.");
@@ -535,6 +668,11 @@
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();
@@ -635,6 +773,11 @@
}
}
/**
* 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();
@@ -766,6 +909,11 @@
}
}
/**
* 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();
@@ -806,6 +954,11 @@
});
}
/**
* 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 = [];
@@ -862,6 +1015,11 @@
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();
@@ -878,23 +1036,38 @@
}
}
/**
* 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 = `#/${encodeURIComponent(page.slug)}`;
link.href = pageRoute(page.slug);
link.className = "page-link";
link.textContent = page.title;
link.classList.toggle("active", state.currentPage?.slug === page.slug);
link.addEventListener("click", (event) => {
event.preventDefault();
location.hash = `#/${encodeURIComponent(page.slug)}`;
});
list.append(link);
}
}
/**
* 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;
@@ -905,7 +1078,7 @@
function templatePages() {
return state.pages.filter((page) => page.slug.toLocaleLowerCase().startsWith("template-"));
return state.pages.filter((page) => (page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase().startsWith("template-"));
}
function updateTemplateSelect() {
@@ -928,6 +1101,11 @@
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)}`);
@@ -969,6 +1147,11 @@
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;
@@ -988,23 +1171,33 @@
}
function updateEditorSlugInfo() {
const namespace = $("editor-namespace")?.value.trim() || "";
if (!state.editingNew && state.currentPage) {
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.currentPage.slug}`;
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) {
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${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 || "");
@@ -1019,6 +1212,11 @@
$("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) {
@@ -1029,6 +1227,8 @@
state.editingNew = false;
state.newPageSlug = null;
$("editor-title").value = state.currentPage.title;
$("editor-namespace").value = state.currentPage.namespace || "";
$("editor-namespace").disabled = state.currentPage.slug === state.translationPage;
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
easyMDE.value(state.currentPage.markdown);
$("edit-summary").value = "";
@@ -1038,8 +1238,14 @@
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);
@@ -1050,12 +1256,14 @@
if (state.editingNew) {
const body = {
title,
namespace,
markdown,
tags,
summary: summary || tr("created-page", "Created page")
};
if (state.newPageSlug) {
body.slug = state.newPageSlug;
const requested = splitPageReference(state.newPageSlug);
body.slug = pageReference(namespace || requested.namespace, requested.slug);
}
page = await api("/api/pages", {
method: "POST",
@@ -1067,6 +1275,7 @@
method: "PUT",
body: JSON.stringify({
title,
namespace,
markdown,
tags,
baseVersion: state.currentPage.currentVersion,
@@ -1130,6 +1339,11 @@
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;
@@ -1319,6 +1533,11 @@
}
}
/**
* 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";
@@ -1382,6 +1601,11 @@
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* goal : Show uploads no current page references and their last historical uses.
* pre : Current user has the admin role.
* post : Admin view offers safe deletion only for orphaned uploads.
*/
async function loadOrphanedUploadsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
@@ -1451,6 +1675,11 @@
}
}
/**
* goal : Render user administration from the current server-side user list.
* pre : Current user has the admin role.
* post : User rows allow supported create/update/delete operations.
*/
async function loadUsersAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
@@ -1544,6 +1773,11 @@
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.
*/
async function route() {
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
if (todoMatch) {
@@ -1592,6 +1826,10 @@
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([
@@ -1642,6 +1880,11 @@
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([
@@ -1662,53 +1905,71 @@
return;
}
const groups = new Map();
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 (!groups.has(section)) groups.set(section, []);
groups.get(section).push(bookmark);
if (!sections.has(section)) sections.set(section, []);
sections.get(section).push(bookmark);
}
for (const [section, bookmarks] of groups.entries()) {
const sectionElement = document.createElement("section");
sectionElement.className = "bookmark-section";
const heading = document.createElement("h2");
heading.textContent = section || tr("bookmarks", "Bookmarks");
sectionElement.append(heading);
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 bookmark of bookmarks) {
const row = document.createElement("div");
row.className = "bookmark-row";
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(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);
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(sectionElement);
target.append(namespaceSection);
}
}
/**
* goal : Show Todo items grouped by the namespace of their source page.
* 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([
@@ -1727,12 +1988,21 @@
list.append(empty);
return;
}
let todoNamespace = null;
for (const item of result.items) {
const namespace = item.namespace || "";
if (namespace !== todoNamespace) {
const heading = document.createElement("h2");
heading.className = "namespace-heading";
heading.textContent = namespaceLabel(namespace);
list.append(heading);
todoNamespace = namespace;
}
const row = document.createElement("article");
row.className = "todo-item";
row.id = `todo-${item.slug}-${item.number}`;
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(item.slug)}`;
link.href = pageRoute(item.slug);
link.textContent = item.title;
const text = document.createElement("div");
text.className = "todo-item-text";
@@ -1753,6 +2023,10 @@
}
}
////////////////////////////////////////////////////////////////////////////////
// Wiki graph support
////////////////////////////////////////////////////////////////////////////////
function pageLinks(markdown) {
const container = document.createElement("div");
container.innerHTML = renderMarkdown(markdown || "");
@@ -1766,6 +2040,12 @@
return links;
}
/**
* goal : Build the wiki link graph from current rendered page links.
* pre : state.pages contains current pages.
* post : No page state is changed.
* result : Graph nodes and directed edges.
*/
async function loadGraphData() {
const pageSlugs = new Set(state.pages.map((page) => page.slug));
const edges = [];
@@ -1785,6 +2065,11 @@
return { nodes: state.pages, edges };
}
/**
* goal : Render a clickable SVG graph for all or contextual wiki pages.
* pre : data contains graph nodes/edges using compact page references.
* post : #graph-view contains the new graph; node clicks navigate to pages.
*/
function renderWikiGraph(data, focusSlug = null) {
const svg = $("wiki-graph");
svg.replaceChildren();
@@ -1854,6 +2139,11 @@
svg.append(nodeLayer);
}
/**
* goal : Open the complete wiki graph special view.
* pre : User can read pages.
* post : Full graph data is loaded and rendered.
*/
async function showGraph() {
state.previousView = state.currentPage ? "page-view" : "graph-view";
renderBreadcrumbs([
@@ -1871,6 +2161,11 @@
renderWikiGraph(data);
}
/**
* goal : Open the graph containing the current page and its direct neighbours.
* pre : state.currentPage identifies an existing page.
* post : Context graph is rendered with the current page highlighted.
*/
async function showContextGraph() {
if (!state.currentPage) return;
@@ -1928,11 +2223,21 @@
}
}
/**
* 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) {
@@ -2037,6 +2342,8 @@
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;