refactoring

This commit is contained in:
2026-09-02 08:38:03 +02:00
parent 649ff0d7c5
commit 38f255c1a4
48 changed files with 6998 additions and 5577 deletions
+198 -4488
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
import { pageRoute } from "../routes.js";
/* Page-alias administration view. */
class AliasAdmin {
/**
* goal : Create a controller for retained page aliases.
* pre : api and translate are application services; reloadPages refreshes
* the public page and alias catalogue after an administrative edit.
* post : The controller is ready to load and mutate aliases.
* result : A directly usable alias administration controller.
*/
constructor(api, translate, reloadPages) {
this.api = api;
this.translate = translate;
this.reloadPages = reloadPages;
this.list = document.getElementById("alias-list");
this.status = document.getElementById("alias-admin-status");
}
/**
* goal : Load retained aliases and render their references and actions.
* pre : The current session has administrator permission.
* post : The alias list represents the current server state.
* result : A promise that resolves when all alias rows have been rendered.
*/
async load() {
this.status.textContent = "";
const result = await this.api("/api/admin/aliases");
const aliases = result.aliases || [];
this.list.replaceChildren();
if (aliases.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = this.translate("no-page-aliases", "No page aliases.");
this.list.append(empty);
return;
}
for (const alias of aliases) this.list.append(this.#renderAlias(alias));
}
/** Build one alias row, including cleanup and deletion actions. */
#renderAlias(alias) {
const row = document.createElement("article");
row.className = "alias-row";
const address = document.createElement("div");
address.className = "alias-address";
const target = document.createElement("a");
target.href = pageRoute(alias.targetSlug);
target.textContent = alias.targetSlug;
address.append(
document.createTextNode(
`${this.translate("old-address", "Old address")}: ${alias.slug} · ` +
`${this.translate("current-address", "Current address")}: `),
target
);
row.append(address);
const references = alias.references || [];
const currentSummary = document.createElement("div");
currentSummary.className = "alias-summary";
currentSummary.textContent =
`${this.translate("current-references", "Current references")}: ${references.length}`;
row.append(currentSummary);
if (references.length > 0) {
const referenceList = document.createElement("ul");
referenceList.className = "alias-references";
for (const reference of references) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = pageRoute(reference.slug);
link.textContent = reference.title;
item.append(link);
referenceList.append(item);
}
row.append(referenceList);
}
const historicalCount = Number(alias.historicalReferenceCount || 0);
const historicalSummary = document.createElement("div");
historicalSummary.className = "alias-summary muted";
historicalSummary.textContent =
`${this.translate("historical-references", "Historical references")}: ${historicalCount}`;
row.append(historicalSummary);
const historicalReferences = alias.historicalReferences || [];
if (historicalReferences.length > 0) {
const historyList = document.createElement("ul");
historyList.className = "alias-references alias-history";
for (const reference of historicalReferences) {
const item = document.createElement("li");
item.textContent = `${reference.title} · ` +
`${this.translate("version", "Version")} ${reference.version}`;
historyList.append(item);
}
row.append(historyList);
}
const actions = document.createElement("div");
actions.className = "alias-actions";
actions.append(
this.#cleanupButton(alias, references),
this.#deleteButton(alias, references, historicalCount)
);
row.append(actions);
return row;
}
/** Build the action that replaces current references to an old address. */
#cleanupButton(alias, references) {
const button = document.createElement("button");
button.type = "button";
button.textContent = this.translate("cleanup-alias", "Clean up references");
button.disabled = references.length === 0;
button.addEventListener("click", () => {
this.#cleanup(alias).catch((error) => console.error(error));
});
return button;
}
/** Build the action that removes an unused alias. */
#deleteButton(alias, references, historicalCount) {
const button = document.createElement("button");
button.type = "button";
button.textContent = this.translate("delete-alias", "Delete alias");
button.disabled = references.length > 0;
button.addEventListener("click", () => {
this.#delete(alias, historicalCount).catch((error) => console.error(error));
});
return button;
}
/** Confirm, perform and report reference cleanup for one alias. */
async #cleanup(alias) {
const question = this.translate(
"cleanup-alias-confirm",
"Replace all current references to this old address with the current address? Historical versions will not be changed.");
if (!window.confirm(question)) return;
const result = await this.api(`/api/admin/aliases/${alias.id}/cleanup`, {
method: "POST"
});
await this.reloadPages();
await this.load();
this.status.textContent =
`${this.translate("cleanup-complete", "Cleanup complete")}: ${result.changedPages || 0}`;
}
/** Confirm and remove one alias that has no current references. */
async #delete(alias, historicalCount) {
const warning = historicalCount > 0
? this.translate(
"delete-alias-history-confirm",
"Delete this alias? Historical page versions still contain this old address and those links may stop resolving.")
: this.translate("delete-alias-confirm", "Delete this alias?");
if (!window.confirm(warning)) return;
await this.api(`/api/admin/aliases/${alias.id}`, { method: "DELETE" });
await this.reloadPages();
await this.load();
}
}
export { AliasAdmin };
@@ -0,0 +1,103 @@
/* Archived CMap administration view. */
class ArchivedCmapsAdmin {
/**
* goal : Create the archived-CMap administration controller.
* pre : formatDate formats database timestamps; reloadConceptMaps refreshes
* ordinary CMap navigation after a restore.
* post : The controller is ready to load and restore archived maps.
* result : A directly usable archived-CMap controller.
*/
constructor(api, translate, formatDate, reloadConceptMaps) {
this.api = api;
this.translate = translate;
this.formatDate = formatDate;
this.reloadConceptMaps = reloadConceptMaps;
this.list = document.getElementById("archived-cmaps-list");
this.status = document.getElementById("archived-cmaps-status");
}
/**
* goal : Load and render all archived concept maps.
* pre : The current session has administrator permission.
* post : The archived-CMap list represents the current server state.
* result : A promise that resolves when all map rows have been rendered.
*/
async load() {
this.status.textContent = "";
const result = await this.api("/api/admin/cmaps/archived");
const conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : [];
this.list.replaceChildren();
if (conceptMaps.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = this.translate(
"no-archived-concept-maps", "No archived CMaps.");
this.list.append(empty);
return;
}
for (const conceptMap of conceptMaps) {
this.list.append(this.#renderConceptMap(conceptMap));
}
}
/** Build one archived map row and its restore action. */
#renderConceptMap(conceptMap) {
const row = document.createElement("article");
row.className = "archived-cmap-row";
const title = document.createElement("strong");
title.textContent = conceptMap.title;
const archivedAt = conceptMap.archivedAt
? this.formatDate(conceptMap.archivedAt)
: "";
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = [
`cmap:${conceptMap.slug}`,
archivedAt
? `${this.translate("archived", "Archived")} ${archivedAt}`
: this.translate("archived", "Archived"),
conceptMap.archivedBy
? `${this.translate("by", "by")} ${conceptMap.archivedBy}`
: ""
].filter(Boolean).join(" · ");
const restore = document.createElement("button");
restore.type = "button";
restore.textContent = this.translate("restore-concept-map", "Restore CMap");
restore.addEventListener("click", () => {
this.#restore(conceptMap, restore).catch((error) => console.error(error));
});
row.append(title, meta, restore);
return row;
}
/** Confirm and restore one CMap, then refresh both CMap catalogues. */
async #restore(conceptMap, button) {
const question = this.translate(
"restore-concept-map-confirm", "Restore concept map \"{title}\"?")
.replace("{title}", conceptMap.title);
if (!window.confirm(question)) return;
button.disabled = true;
try {
await this.api(
`/api/admin/cmaps/archived/${encodeURIComponent(conceptMap.slug)}/restore`,
{ method: "POST" });
await this.reloadConceptMaps();
await this.load();
this.status.textContent = this.translate(
"concept-map-restored", "CMap restored.");
} catch (error) {
button.disabled = false;
this.status.textContent = error.message;
}
}
}
export { ArchivedCmapsAdmin };
+130
View File
@@ -0,0 +1,130 @@
/* Mail and password-reset administration view. */
class MailAdmin {
/**
* goal : Connect mail settings, test delivery and TLS controls to the API.
* pre : The mail administration form exists in the document.
* post : Save, test and TLS-change events are handled by this controller.
* result : A directly usable mail administration controller.
*/
constructor(api, translate) {
this.api = api;
this.translate = translate;
this.form = document.getElementById("mail-settings-form");
this.status = document.getElementById("mail-settings-status");
this.testButton = document.getElementById("mail-test-button");
this.tls = document.getElementById("mail-smtp-tls");
this.acceptUntrusted = document.getElementById(
"mail-smtp-accept-untrusted-certificates");
this.form.addEventListener("submit", (event) => this.#save(event));
this.testButton.addEventListener("click", () => this.#test());
this.tls.addEventListener("change", () => this.#syncTlsControls());
}
/**
* goal : Load stored mail settings and initialize the test recipient.
* pre : The current session has administrator permission.
* post : All form controls represent the stored server configuration.
* result : A promise that resolves after the form has been initialized.
*/
async load(currentUserEmail = "") {
const settings = await this.api("/api/admin/mail-settings");
document.getElementById("mail-public-url").value =
settings.publicUrl || window.location.origin;
document.getElementById("mail-smtp-host").value = settings.smtpHost || "";
document.getElementById("mail-smtp-port").value = settings.smtpPort || "587";
document.getElementById("mail-smtp-from").value = settings.smtpFrom || "";
document.getElementById("mail-smtp-user").value = settings.smtpUser || "";
document.getElementById("mail-smtp-password").value = "";
this.tls.checked = settings.smtpTls !== false;
this.acceptUntrusted.checked = settings.smtpAcceptUntrustedCertificates === true;
document.getElementById("mail-reset-limit").value = settings.resetLimit || "2";
document.getElementById("mail-test-recipient").value = currentUserEmail;
document.getElementById("mail-password-help").textContent = settings.hasPassword
? this.translate(
"smtp-password-kept", "A password is stored; leave empty to keep it.")
: "";
this.status.textContent = "";
this.acceptUntrusted.disabled = !this.tls.checked;
}
/** Read the mail form using the public server API field names. */
#formData() {
return {
publicUrl: document.getElementById("mail-public-url").value,
smtpHost: document.getElementById("mail-smtp-host").value,
smtpPort: document.getElementById("mail-smtp-port").value,
smtpFrom: document.getElementById("mail-smtp-from").value,
smtpUser: document.getElementById("mail-smtp-user").value,
smtpPassword: document.getElementById("mail-smtp-password").value,
smtpTls: this.tls.checked,
smtpAcceptUntrustedCertificates: this.acceptUntrusted.checked,
resetLimit: document.getElementById("mail-reset-limit").value
};
}
/** Store the current mail settings and retain an omitted password. */
async #save(event) {
event.preventDefault();
try {
await this.api("/api/admin/mail-settings", {
method: "PUT",
body: JSON.stringify(this.#formData())
});
document.getElementById("mail-smtp-password").value = "";
document.getElementById("mail-password-help").textContent = this.translate(
"smtp-password-kept", "A password is stored; leave empty to keep it.");
this.status.textContent = this.translate("saved", "Saved");
} catch (error) {
this.status.textContent = error.message;
}
}
/** Send one test message using the unsaved values currently in the form. */
async #test() {
const recipient = document.getElementById("mail-test-recipient").value.trim();
this.testButton.disabled = true;
this.status.textContent = this.translate(
"sending-test-mail", "Sending test email…");
try {
await this.api("/api/admin/mail-settings/test", {
method: "POST",
body: JSON.stringify({ ...this.#formData(), recipient })
});
this.status.textContent = this.translate(
"test-mail-sent", "Test email accepted by SMTP server");
} catch (error) {
console.error("SMTP test failed", error);
this.status.textContent = this.#smtpErrorMessage(error);
} finally {
this.testButton.disabled = false;
}
}
/** Translate known TLS failures and retain other server error messages. */
#smtpErrorMessage(error) {
const message = error && error.message ? error.message : String(error);
const certificateFailure = message.includes("certificate verify failed") ||
message.includes("TLS certificate verification failed");
if (certificateFailure) {
return this.translate(
"smtp-certificate-verification-failed",
"The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server.");
}
if (message.includes("no protocols available")) {
return this.translate(
"smtp-no-modern-tls",
"The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically.");
}
return message;
}
/** Disable the certificate exception whenever STARTTLS is disabled. */
#syncTlsControls() {
this.acceptUntrusted.disabled = !this.tls.checked;
if (this.acceptUntrusted.disabled) this.acceptUntrusted.checked = false;
}
}
export { MailAdmin };
@@ -0,0 +1,124 @@
import { pageRoute } from "../routes.js";
/* Orphaned-upload administration view. */
/** Format a byte count for the administrative upload list. */
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
class OrphanedUploadsAdmin {
/**
* goal : Create the controller for uploads without current references.
* pre : api performs authenticated requests and translate resolves UI keys.
* post : The controller is connected to the orphaned-upload list.
* result : A directly usable orphaned-upload administration controller.
*/
constructor(api, translate) {
this.api = api;
this.translate = translate;
this.list = document.getElementById("orphaned-uploads-list");
}
/**
* goal : Load orphaned uploads and their last historical uses.
* pre : The current session has administrator permission.
* post : The upload list represents the current server state.
* result : A promise that resolves when all upload rows have been rendered.
*/
async load() {
const result = await this.api("/api/admin/uploads/orphaned");
const uploads = result.uploads || [];
this.list.replaceChildren();
if (uploads.length === 0) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = this.translate("no-orphaned-uploads", "No orphaned uploads.");
this.list.append(empty);
return;
}
for (const upload of uploads) this.list.append(this.#renderUpload(upload));
}
/** Build one upload row with history, view link and deletion action. */
#renderUpload(upload) {
const row = document.createElement("article");
row.className = "orphaned-upload-row";
const title = document.createElement("strong");
title.textContent = upload.originalName;
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ` +
`${this.translate("uploaded-by", "uploaded by")} ${upload.uploadedBy}`;
const history = this.#uploadHistory(upload);
const actions = document.createElement("div");
actions.className = "orphaned-upload-actions";
const view = document.createElement("a");
view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/` +
encodeURIComponent(upload.storedName);
view.target = "_blank";
view.rel = "noopener";
view.textContent = this.translate("view", "View");
const remove = document.createElement("button");
remove.className = "danger";
remove.textContent = this.translate("delete", "Delete");
remove.addEventListener("click", () => {
this.#deleteUpload(upload).catch((error) => console.error(error));
});
actions.append(view, remove);
row.append(title, meta, history, actions);
return row;
}
/** Build the historical-use portion of one upload row. */
#uploadHistory(upload) {
const history = document.createElement("div");
const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : [];
if (lastUses.length === 0) {
history.textContent = this.translate(
"never-referenced", "Never referenced by a saved page.");
return history;
}
const label = document.createElement("div");
label.textContent = `${this.translate("last-used", "Last used")}:`;
const uses = document.createElement("ul");
uses.className = "orphaned-upload-uses";
for (const use of lastUses) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = pageRoute(use.slug);
link.textContent = use.title || use.slug;
item.append(link);
if (use.version) {
item.append(document.createTextNode(
` · ${this.translate("version", "Version")} ${use.version}`));
}
uses.append(item);
}
history.append(label, uses);
return history;
}
/** Confirm and remove one still-orphaned upload, then refresh the list. */
async #deleteUpload(upload) {
const question = this.translate(
"delete-orphaned-upload-confirm", "Delete this orphaned upload?");
if (!window.confirm(question)) return;
await this.api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" });
await this.load();
}
}
export { OrphanedUploadsAdmin };
+25
View File
@@ -0,0 +1,25 @@
/* Administration overview. */
/**
* goal : Refresh the software and database versions on the admin overview.
* pre : api performs authenticated requests and translate resolves UI keys.
* post : The overview shows current and required version information.
* result : A promise that resolves after the overview has been updated.
*/
async function loadAdminOverview(api, translate) {
const info = await api("/api/admin/info");
document.getElementById("admin-software-version").textContent =
info.softwareVersion || "?";
const schemaVersion = info.databaseSchemaVersion ?? "?";
const requiredSchemaVersion = info.requiredDatabaseSchemaVersion ?? "?";
const databaseVersion = document.getElementById("admin-database-schema-version");
if (schemaVersion === requiredSchemaVersion) {
databaseVersion.textContent = String(schemaVersion);
} else {
databaseVersion.textContent =
`${schemaVersion} (${translate("required", "required")}: ${requiredSchemaVersion})`;
}
}
export { loadAdminOverview };
+148
View File
@@ -0,0 +1,148 @@
/*
* User administration view.
*
* UserAdmin owns the user list, its editable controls and the new-user form.
* The application remains responsible for permissions, routing, breadcrumbs
* and making the surrounding view visible.
*/
class UserAdmin {
/**
* goal : Connect the user administration controls to the wiki API.
* pre : api performs authenticated requests; translate resolves UI keys;
* the user administration form and list exist in the document.
* post : Submitting the new-user form creates a user and reloads the list.
* result : A directly usable controller for the user administration view.
*/
constructor(api, translate) {
this.api = api;
this.translate = translate;
this.form = document.getElementById("new-user-form");
this.list = document.getElementById("user-list");
this.form.addEventListener("submit", (event) => {
this.#createUser(event).catch((error) => console.error(error));
});
}
/**
* goal : Refresh the editable user list from the server.
* pre : The current session has administrator permission.
* post : Every current user has editable identity, role and password fields.
* result : A promise that resolves when all rows have been rendered.
* internals: The API supplies public user records. #renderUser creates each
* row and connects its save and delete operations to that record.
*/
async load() {
const result = await this.api("/api/admin/users");
this.list.replaceChildren();
for (const user of result.users) {
this.list.append(this.#renderUser(user));
}
}
/** Create a user from the dedicated form and reload the canonical list. */
async #createUser(event) {
event.preventDefault();
const username = document.getElementById("new-username").value;
await this.api("/api/admin/users", {
method: "POST",
body: JSON.stringify({
username,
displayName: document.getElementById("new-display-name").value || username,
email: document.getElementById("new-email").value,
password: document.getElementById("new-password").value,
role: document.getElementById("new-role").value,
enabled: true
})
});
this.form.reset();
await this.load();
}
/** Build one editable user row and connect its actions. */
#renderUser(user) {
const row = document.createElement("div");
row.className = "user-row";
const username = document.createElement("strong");
username.textContent = user.username;
const displayName = document.createElement("input");
displayName.value = user.displayName;
const email = document.createElement("input");
email.type = "email";
email.value = user.email || "";
email.placeholder = this.translate("email-address", "Email address");
const role = this.#roleSelect(user.role);
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
const password = document.createElement("input");
password.type = "password";
password.placeholder = this.translate("new-password", "New password");
password.autocomplete = "new-password";
const save = document.createElement("button");
save.textContent = this.translate("save", "Save");
save.addEventListener("click", () => {
this.#updateUser(user, displayName, email, role, enabled, password)
.catch((error) => console.error(error));
});
const remove = document.createElement("button");
remove.textContent = this.translate("delete", "Delete");
remove.className = "danger";
remove.addEventListener("click", () => {
this.#deleteUser(user).catch((error) => console.error(error));
});
const actions = document.createElement("div");
actions.append(save, remove);
row.append(username, displayName, email, role, enabled, password, actions);
return row;
}
/** Build the translated role selector for one user row. */
#roleSelect(selectedRole) {
const select = document.createElement("select");
for (const roleName of ["reader", "editor", "admin"]) {
const option = document.createElement("option");
option.value = roleName;
option.textContent = this.translate(`role-${roleName}`, roleName);
option.selected = roleName === selectedRole;
select.append(option);
}
return select;
}
/** Store the current controls of one user row. */
async #updateUser(user, displayName, email, role, enabled, password) {
await this.api(`/api/admin/users/${user.id}`, {
method: "PUT",
body: JSON.stringify({
displayName: displayName.value,
email: email.value,
role: role.value,
enabled: enabled.checked,
password: password.value || undefined
})
});
password.value = "";
}
/** Confirm and remove one user, then reload the canonical list. */
async #deleteUser(user) {
const question = this.translate("delete-user-confirm", "Delete user {username}?")
.replace("{username}", user.username);
if (!window.confirm(question)) return;
await this.api(`/api/admin/users/${user.id}`, { method: "DELETE" });
await this.load();
}
}
export { UserAdmin };
+116
View File
@@ -0,0 +1,116 @@
/*
* Per-tab wiki page history.
*
* BreadcrumbTrail owns the ordered page slugs and their session-storage
* representation. It deliberately knows nothing about page titles, routes or
* DOM elements; the application supplies the current set of valid page slugs.
*/
const storageKey = "racket-wiki-breadcrumb-trail";
const trailLimit = 8;
class BreadcrumbTrail {
/**
* goal : Create an empty breadcrumb history backed by session storage.
* pre : storage implements the Web Storage getItem/setItem interface.
* post : No stored value is read until load is called with valid pages.
* result : A directly usable breadcrumb history.
*/
constructor(storage) {
this.storage = storage;
this.pageSlugs = [];
}
/** Return a copy of the ordered non-home page slugs. */
get slugs() {
return this.pageSlugs.slice();
}
/**
* goal : Restore the trail and discard references to missing pages.
* pre : validSlugs contains the current readable page slugs.
* post : The normalized trail is stored again for this browser tab.
* result : A copy of the restored trail.
* internals: JSON is decoded first, then filtered through validSlugs so stale
* browser state cannot become application navigation.
*/
load(validSlugs) {
let stored = [];
try {
stored = JSON.parse(this.storage.getItem(storageKey) || "[]");
} catch (_error) {
stored = [];
}
const valid = new Set(validSlugs);
if (Array.isArray(stored)) {
this.pageSlugs = stored.filter((slug) =>
typeof slug === "string" && valid.has(slug));
} else {
this.pageSlugs = [];
}
this.#save();
return this.slugs;
}
/** Empty and persist the breadcrumb history. */
clear() {
this.pageSlugs = [];
this.#save();
}
/**
* goal : Record navigation to one wiki page.
* pre : pageSlug identifies the visited page; homeSlug may be null.
* post : Home clears the trail, revisits truncate it, and new visits append.
* result : A copy of the updated trail.
* internals: The trail contains only non-home pages and retains at most the
* eight most recent entries, matching the visible breadcrumb.
*/
record(pageSlug, homeSlug = null) {
if (!pageSlug) return this.slugs;
if (homeSlug && pageSlug === homeSlug) {
this.clear();
return this.slugs;
}
const existingIndex = this.pageSlugs.indexOf(pageSlug);
if (existingIndex >= 0) {
this.pageSlugs = this.pageSlugs.slice(0, existingIndex + 1);
} else {
this.pageSlugs.push(pageSlug);
if (this.pageSlugs.length > trailLimit) {
this.pageSlugs = this.pageSlugs.slice(-trailLimit);
}
}
this.#save();
return this.slugs;
}
/**
* goal : Discard breadcrumb entries that follow a selected trail page.
* pre : pageSlug is a breadcrumb destination; homeSlug may be null.
* post : Home clears the trail; an existing page becomes its final entry.
* result : A copy of the updated trail.
*/
truncate(pageSlug, homeSlug = null) {
if (homeSlug && pageSlug === homeSlug) {
this.clear();
return this.slugs;
}
const index = this.pageSlugs.indexOf(pageSlug);
if (index >= 0) {
this.pageSlugs = this.pageSlugs.slice(0, index + 1);
this.#save();
}
return this.slugs;
}
/** Persist the current representation for this browser tab. */
#save() {
this.storage.setItem(storageKey, JSON.stringify(this.pageSlugs));
}
}
export { BreadcrumbTrail };
@@ -1,10 +1,4 @@
/* Versioned JSON interchange for Racket Wiki concept maps. */
((root, factory) => {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.RacketWikiCmapInterchange = api;
})(typeof window !== "undefined" ? window : globalThis, () => {
"use strict";
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
@@ -141,7 +135,17 @@
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
});
documentCopy.concepts = ids.map((id) => ({ id }));
if (!Array.isArray(documentCopy.connectors)) documentCopy.connectors = [];
const itemIds = new Set();
for (const item of documentCopy.items) {
const itemId = Number(item?.id);
if (Number.isInteger(itemId)) itemIds.add(itemId);
}
documentCopy.connectors = (Array.isArray(documentCopy.connectors) ?
documentCopy.connectors : []).filter((connector) => {
const sourceExists = itemIds.has(Number(connector?.sourceId));
const targetExists = itemIds.has(Number(connector?.targetId));
return sourceExists && targetExists;
});
return documentCopy;
}
@@ -428,9 +432,15 @@
return documentValue;
}
return {
FORMAT, FORMAT_VERSION, SCHEMA, buildBundle, validateBundle,
preparedMapDocument, placementDocument, decodedDocument,
attachmentUrls, replaceAttachmentUrls
};
});
export {
FORMAT,
FORMAT_VERSION,
SCHEMA,
attachmentUrls,
buildBundle,
decodedDocument,
placementDocument,
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
};
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
import {
normalizeMentionText,
pageReference,
slugTitle,
splitPageReference,
wikiWordTarget
} from "./reference.js";
import { cmapRoute, pageRoute } from "./routes.js";
/*
* Render-only transformations for racket-wiki Markdown.
*
* Stored Markdown remains authoritative. These functions prepare wiki syntax
* for Marked; the application remains responsible for DOMPurify sanitizing and
* for hydrating the generated CMap placeholders.
*/
/** Escape plain text before placing it in generated HTML. */
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** Expand Todo markers outside fenced code for rendering. */
function expandTodoMarkup(markdown, pageSlug, todoLabel) {
let inFence = false;
let todoNumber = 0;
const label = escapeHtml(todoLabel);
return (markdown || "").split("\n").map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => {
todoNumber += 1;
const todoText = escapeHtml(text.trim());
if (pageSlug) {
const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`;
return `<a class="wiki-todo" href="${href}"><strong>${label}:</strong> ${todoText}</a>`;
}
return `<span class="wiki-todo"><strong>${label}:</strong> ${todoText}</span>`;
});
}).join("\n");
}
/** Apply racket-wiki image width and alignment suffixes to rendered image tags. */
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;
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
const width = `${amount}${unit || "px"}`;
const closing = imageTag.endsWith("/>") ? "/>" : ">";
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
const classes = [];
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
if (effectiveFloatSide) {
classes.push(`wiki-image-float-${effectiveFloatSide}`);
} else if (align) {
classes.push(`wiki-image-${align}`);
}
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
` style=${quote}${value}; width: ${width}${quote}`);
} else {
tagBody += ` style="width: ${width}"`;
}
if (classes.length > 0) {
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
` class=${quote}${value} ${classes.join(" ")}${quote}`);
} else {
tagBody += ` class="${classes.join(" ")}"`;
}
}
return `${tagBody}${closing}`;
});
}
/** Collect the human-readable aliases through which a page can be mentioned. */
function mentionAliases(page) {
const aliases = new Set();
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);
for (const wikiWord of value.match(/(?:\p{Lu}\p{Ll}+){2,}/gu) || []) {
aliases.add(wikiWord);
}
}
return Array.from(aliases)
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
.filter((alias) => alias.key.length >= 5);
}
/** Build the namespace-aware and ambiguity-aware lookup for page mentions. */
function pageMentionMap(pages, pageAliases, currentSlug = null) {
const map = new Map();
for (const page of pages) {
if (page.slug === currentSlug) continue;
const namespace = String(page.namespace || "").toLocaleLowerCase();
for (const alias of mentionAliases(page)) {
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);
}
}
}
for (const alias of pageAliases) {
const targetPage = pages.find((page) => page.slug === alias.targetSlug);
if (!targetPage || targetPage.slug === currentSlug) continue;
const namespace = String(alias.namespace || "").toLocaleLowerCase();
const aliasPage = { title: alias.title, pageSlug: alias.pageSlug, slug: alias.slug };
for (const candidate of mentionAliases(aliasPage)) {
const namespacedKey = `${namespace}:${candidate.key}`;
if (!map.has(namespacedKey)) map.set(namespacedKey, targetPage);
const rootKey = `:${candidate.key}`;
if (!map.has(rootKey)) map.set(rootKey, targetPage);
}
}
return map;
}
/** Resolve a CMap WikiWord only when it identifies exactly one concept map. */
function cmapMentionTarget(text, conceptMaps) {
const key = normalizeMentionText(text);
const matches = conceptMaps.filter((conceptMap) => {
const aliases = [conceptMap.title, conceptMap.slug, slugTitle(conceptMap.slug)];
return aliases.some((alias) => normalizeMentionText(alias) === key);
});
return matches.length === 1 ? matches[0] : null;
}
/** Locate ranges in one Markdown line in which WikiWords must not be expanded. */
function markdownProtectedRanges(line) {
const ranges = [];
const patterns = [
/todo\([^()\n]*\)/gi,
/!?\[[^\]\n]*\]\([^\)\n]*\)/g,
/`+[^`\n]*`+/g,
/<https?:\/\/[^>\n]+>/gi,
/https?:\/\/[^\s<>()]+/gi
];
for (const pattern of patterns) {
for (const match of line.matchAll(pattern)) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
}
return ranges;
}
/** Test whether a candidate WikiWord overlaps protected Markdown syntax. */
function positionIsProtected(start, end, ranges) {
for (const range of ranges) {
if (start < range.end && end > range.start) return true;
}
return false;
}
/**
* goal : Rewrite namespaced Markdown targets to internal hash routes.
* pre : markdown is source text; fenced and indented code must remain untouched.
* post : Stored Markdown is not changed.
* result : Render-only Markdown with explicit wiki routes.
*/
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) => {
const target = namespace.toLocaleLowerCase() === "cmap" ?
cmapRoute(slug) : pageRoute(pageReference(namespace, slug));
return `${before}${target}${after}`;
}));
}
return result.join("\n");
}
/**
* goal : Expand classic WikiWords to temporary Markdown links.
* pre : pages, pageAliases and conceptMaps are current catalogues.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
* result : Render-only Markdown with WikiWord links.
*/
function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSlug = null) {
const aliases = pageMentionMap(pages, pageAliases, 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*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
result.push(line);
continue;
}
if (fence !== null || /^\s{4}/.test(line)) {
result.push(line);
continue;
}
const protectedRanges = markdownProtectedRanges(line);
const replacements = [];
for (const match of line.matchAll(wikiWordPattern)) {
const start = match.index;
const end = start + match[0].length;
if (positionIsProtected(start, end, protectedRanges)) continue;
const namespace = match[1] || "";
if (namespace.toLocaleLowerCase() === "cmap") {
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
if (conceptMap) replacements.push({ start, end, conceptMap });
} else {
const page = wikiWordTarget(match[2], aliases, namespace);
if (page) replacements.push({ start, end, page });
}
}
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = replacement.conceptMap ?
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
}
result.push(expanded);
}
return result.join("\n");
}
/** Replace standalone CMap embeds outside fenced code with stable tokens. */
function extractCmapEmbeds(markdown) {
const embeds = [];
let fence = null;
const lines = String(markdown || "").split("\n").map((line) => {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
return line;
}
if (fence !== null) return line;
const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i);
if (!match) return line;
const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`;
embeds.push({ token, reference: match[1].trim() });
return token;
});
return { markdown: lines.join("\n"), embeds };
}
/** Restore extracted CMap tokens as inert placeholders for later hydration. */
function restoreCmapEmbeds(html, embeds, loadingLabel) {
let result = html;
const loading = escapeHtml(loadingLabel);
for (const embed of embeds) {
const reference = escapeHtml(embed.reference);
const placeholder = `<section class="rw-cmap-embed" data-cmap-reference="${reference}"><div class="rw-cmap-embed-loading">${loading}</div></section>`;
result = result.replace(`<p>${embed.token}</p>`, placeholder).replace(embed.token, placeholder);
}
return result;
}
export {
applyImageWidthMarkup,
cmapMentionTarget,
escapeHtml,
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
extractCmapEmbeds,
restoreCmapEmbeds
};
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+64
View File
@@ -0,0 +1,64 @@
/*
* Markdown page-outline calculations.
*
* This module recognizes source headings and assigns stable HTML identifiers.
* It has no application state and does not inspect or modify the DOM.
*/
/**
* goal : Produce a unique HTML identifier for a rendered heading.
* pre : usedIds contains identifiers already assigned within the document.
* post : The returned identifier is added to usedIds.
* result : A readable normalized identifier, with a numeric suffix if needed.
*/
function headingId(text, usedIds) {
const base = String(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;
}
/**
* goal : Extract ATX headings and their source locations from Markdown.
* pre : markdown is source text and may contain fenced code blocks.
* post : The source is not changed; headings inside fences are ignored.
* result : Ordered level/text/line records for the document outline.
*/
function markdownHeadings(markdown) {
const headings = [];
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
const fencePattern = /^\s*(```|~~~)/;
let inFence = false;
for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
const line = lines[lineNumber];
if (fencePattern.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!match) continue;
headings.push({
level: match[1].length,
text: match[2].trim(),
line: lineNumber
});
}
return headings;
}
export { headingId, markdownHeadings };
+102
View File
@@ -0,0 +1,102 @@
/*
* Wiki page references.
*
* This module owns the syntax used to identify root and namespaced pages.
* It has no application or DOM state.
*/
/** Split a wiki reference into its namespace and page slug. */
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) };
}
/** Build the compact external reference for a root or namespaced page. */
function pageReference(namespace, slug) {
const cleanNamespace = String(namespace || "").trim();
return cleanNamespace ? `${cleanNamespace}:${slug}` : slug;
}
/** Convert one freely entered reference part to a bounded wiki identifier. */
function slugifyReferencePart(value, maximum) {
return String(value || "")
.normalize("NFKD")
.toLocaleLowerCase()
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, maximum)
.replace(/-+$/g, "");
}
/**
* goal : Convert a freely entered page title/address to a wiki reference.
* pre : input may contain a namespace separated by a colon.
* post : No page is created and no application state is changed.
* result : A normalized reference, or null when the input is empty or invalid.
*/
function newPageReference(input) {
const parts = splitPageReference(String(input || "").trim());
const namespace = parts.namespace ? slugifyReferencePart(parts.namespace, 80) : "";
const slug = slugifyReferencePart(parts.slug, 120);
if (!slug || (parts.namespace && !namespace)) return null;
return pageReference(namespace, slug);
}
/** Normalize human-readable text for ambiguity-aware WikiWord matching. */
function normalizeMentionText(text) {
return String(text || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
}
/** Return the separate capitalized words when text is a classic WikiWord. */
function wikiWordParts(text) {
const value = String(text || "");
if (!/^(?:\p{Lu}\p{Ll}+){2,}$/u.test(value)) return [];
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
}
/**
* goal : Resolve a classic WikiWord to an existing page or new page target.
* pre : aliases is an ambiguity-aware map built by wiki-markdown.js.
* post : No page is created and aliases is not changed.
* result : A slug/title pair, or null when text is not a WikiWord.
*/
function wikiWordTarget(text, aliases, namespace = "") {
const parts = wikiWordParts(text);
if (parts.length === 0) return null;
const aliasKey = `${String(namespace || "").toLocaleLowerCase()}:${normalizeMentionText(text)}`;
const existing = aliases.get(aliasKey);
if (existing) return { slug: existing.slug, title: existing.title };
const slug = parts.map((part) => part.toLocaleLowerCase()).join("-");
return {
slug: pageReference(namespace, slug),
title: parts.join(" ")
};
}
/** Derive a readable title from the page part of a wiki reference. */
function slugTitle(reference) {
const slug = splitPageReference(reference || "").slug;
return slug
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export {
newPageReference,
normalizeMentionText,
pageReference,
slugTitle,
splitPageReference,
wikiWordTarget
};
+71
View File
@@ -0,0 +1,71 @@
/*
* Client-side wiki routes.
*
* This module owns page/CMap hash construction and all hash recognition. It
* returns plain route records and has no knowledge of sessions, permissions,
* application state or DOM views.
*/
/** Build the client-side route for a wiki page reference. */
function pageRoute(reference) {
return `#/${encodeURIComponent(reference)}`;
}
/** Build the client-side route for a concept map. */
function cmapRoute(slug) {
return `#cmap/${encodeURIComponent(slug)}`;
}
/**
* goal : Recognize one browser hash as a wiki application route.
* pre : hash is the current location hash or another string to inspect.
* post : No browser or application state is changed.
* result : A named route record; unknown and empty hashes produce home.
* internals: Exact special views are recognized first. Parameterized Todo,
* CMap, search and page routes are then decoded into their values.
*/
function parseWikiRoute(hash) {
const value = String(hash || "");
switch (value) {
case "#profile": return { name: "profile" };
case "#cmaps": return { name: "cmaps" };
case "#recent": return { name: "recent" };
case "#bookmarks": return { name: "bookmarks" };
case "#todos": return { name: "todos" };
case "#admin": return { name: "admin" };
case "#admin/users": return { name: "admin-users" };
case "#admin/mail": return { name: "admin-mail" };
case "#admin/aliases": return { name: "admin-aliases" };
case "#admin/orphaned-uploads": return { name: "admin-orphaned-uploads" };
case "#admin/archived-cmaps": return { name: "admin-archived-cmaps" };
}
const todoMatch = value.match(/^#todo\/([^/]+)\/(\d+)$/);
if (todoMatch) {
return {
name: "todo",
slug: decodeURIComponent(todoMatch[1]),
number: Number(todoMatch[2])
};
}
const cmapMatch = value.match(/^#cmap\/([^/]+)$/);
if (cmapMatch) {
return { name: "cmap", slug: decodeURIComponent(cmapMatch[1]) };
}
const searchMatch = value.match(/^#search\/(.+)$/);
if (searchMatch) {
return { name: "search", query: decodeURIComponent(searchMatch[1]) };
}
const pageMatch = value.match(/^#\/([^/]+)$/);
if (pageMatch) {
return { name: "page", slug: decodeURIComponent(pageMatch[1]) };
}
return { name: "home" };
}
export { cmapRoute, pageRoute, parseWikiRoute };