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
+18 -4
View File
@@ -9,9 +9,16 @@ racket-wiki. Its public additions include `onSelection` on a map and
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`dblclick` event that may be suppressed by dragging.
`cmap-racket-wiki.js` contains the wiki-specific editor model, selection state,
content-based initial sizing, resize and relation controls, page concepts and
submap concepts. Automatic sizing remains active while text is edited and is
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
placements, linking phrases, connectors, metadata and presentation values.
Neither model module contains DOM or drawing-engine objects.
`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance.
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
state, content-based initial sizing, resize and relation controls. Its view
records connect pure model ids to drawing nodes without making those nodes part
of the model. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
@@ -42,6 +49,13 @@ Multiple selected items can be moved or deleted together. Grouping is a
structural operation: the editor creates an expanded sub-CMap with a named main
concept and adopts the selection as its contents. Ungrouping dissolves a
selected inline sub-CMap or moves selected child items one level outward.
**Make separate CMap** extracts the descendants into a complete stored CMap.
The former sub-CMap head remains in the parent as an ordinary concept whose
shared `cmapSlug` links to the new map. Ownership from that concept to the
extracted concepts is removed; internal child connectors move with the child,
while relations crossing the boundary terminate on the retained owner concept.
The same action converts an older `derivedView` in place: its slug, title and
metadata remain intact, but its document no longer depends on the parent CMap.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
@@ -49,7 +63,7 @@ map references. The wiki host persists this JSON document through its CMap API.
## JSON interchange
`/js/cmap-interchange.js` implements the versioned `racket-wiki-cmap-bundle`
`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle`
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
+270 -269
View File
@@ -1,3 +1,13 @@
import {
CmapModel,
ConceptMapConcept,
ConceptMapConnector,
ConceptMapPhrase,
PLACEMENT_FIELDS
} from "./model/concept-map.js";
import { CONCEPT_FIELDS } from "./model/concept-repository.js";
import { CmapView } from "./cmap-view.js";
/*
* Racket Wiki editor layer for the bundled racket-wiki CMap component.
*
@@ -152,14 +162,18 @@
resizeConcept: options.resizeConceptLabel || "Resize concept",
relation: options.relationLabel || "Relation"
};
this.map = this.CmapFactory(canvas);
this.map.onSelection((component, event) => this.handleMapSelection(component, event));
this.map.onActivation((component, event) => this.handleMapActivation(component, event));
this.view = new CmapView(
canvas,
this.CmapFactory,
(component, event) => this.handleMapSelection(component, event),
(component, event) => this.handleMapActivation(component, event));
this.map = this.view.map;
this.items = [];
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
this.model = CmapModel.fromDocument({});
this.conceptMaps = this.model.conceptMap.conceptMapsById;
this.documentMetadata = this.model.conceptMap.metadata;
this.activeMapRoot = null;
this.mapHistory = [];
this.selectedItem = null;
@@ -190,6 +204,229 @@
});
}
/** Return the view record that renders one model item id. */
itemRecord(id) {
return this.items.find((record) => Number(record.id) === Number(id)) || null;
}
/** Return a copy of the records currently rendered by this editor. */
itemRecords() {
return [...this.items];
}
/** Return rendered records that are visible in the active map context. */
visibleItemRecords() {
return this.items.filter((record) => this.isEffectiveItemVisible(record));
}
containsItemRecord(record) {
return this.items.includes(record);
}
descendantItemRecords(record) {
return this.items.filter((candidate) => this.isDescendantOf(candidate, record));
}
setConceptMapReference(reference) {
if (!reference?.id) throw new TypeError("A concept-map reference id is required");
this.conceptMaps.set(reference.id, reference);
return reference;
}
itemCount() {
return this.items.length;
}
connectorCount() {
return this.connectors.length + this.unresolvedConnectors.length;
}
conceptRepository() {
return this.model.repository;
}
conceptMapModel() {
return this.model.conceptMap;
}
/**
* Register the domain data behind a view record.
* The model receives only serializable concept and placement fields.
*/
registerModelItem(record) {
const existing = this.model.conceptMap.item(record.id);
if (existing) return existing;
const values = { kind: record.kind };
for (const field of PLACEMENT_FIELDS) {
if (field === "parentSubmapId") {
values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null;
} else if (field === "hiddenContexts") {
values.hiddenContexts = Array.from(record.hiddenContexts || []);
} else if (field === "x" || field === "y" || field === "width" || field === "height") {
values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]);
} else {
values[field] = record[field];
}
}
let modelItem;
if (record.kind === "phrase") {
modelItem = new ConceptMapPhrase(record.id, {
...values,
label: record.label,
synopsis: record.synopsis
});
} else {
const conceptValues = {};
for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field];
this.model.repository.ensure(record.conceptId, conceptValues);
modelItem = new ConceptMapConcept(record.id, record.conceptId, values);
}
this.model.conceptMap.addItem(modelItem);
return modelItem;
}
/**
* Make an existing editor record a view onto its pure model item.
* DOM handles stay on the record; all serializable fields live in the model.
*/
bindRecordToModel(record, modelItem) {
if (record.modelItem === modelItem) return record;
Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true });
Object.defineProperty(record, "kind", {
configurable: true,
get: () => modelItem.kind,
set: (value) => { modelItem.kind = value; }
});
Object.defineProperty(record, "conceptId", {
configurable: true,
get: () => modelItem.conceptId || null,
set: (conceptId) => {
if (modelItem instanceof ConceptMapPhrase || !conceptId) return;
const current = this.model.repository.concept(modelItem.conceptId);
const target = this.model.repository.concept(conceptId) ||
this.model.repository.ensure(conceptId, current ? current.toDocument() : {});
modelItem.conceptId = target.id;
}
});
for (const field of ["label", "synopsis"]) {
Object.defineProperty(record, field, {
configurable: true,
get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] :
this.model.repository.requireConcept(modelItem.conceptId)[field],
set: (value) => {
if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || "");
else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
}
});
}
for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) {
Object.defineProperty(record, field, {
configurable: true,
get: () => modelItem instanceof ConceptMapPhrase ? null :
this.model.repository.requireConcept(modelItem.conceptId)[field],
set: (value) => {
if (!(modelItem instanceof ConceptMapPhrase)) {
this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
}
}
});
}
for (const field of PLACEMENT_FIELDS.filter((name) =>
name !== "parentSubmapId" && name !== "x" && name !== "y")) {
Object.defineProperty(record, field, {
configurable: true,
get: () => modelItem[field],
set: (value) => { modelItem[field] = value; }
});
}
Object.defineProperty(record, "parentSubmap", {
configurable: true,
get: () => modelItem.parentSubmapId === null ? null :
this.itemRecord(modelItem.parentSubmapId),
set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; }
});
return record;
}
/** Register and bind a record that was inserted through the editor view. */
attachRecordToModel(record) {
return this.bindRecordToModel(record, this.registerModelItem(record));
}
/** Bind a rendered connector to its map-local connector model. */
attachConnectorToModel(record) {
let modelConnector = this.model.conceptMap.connector(record.id);
if (!modelConnector) {
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
record.id, record.source.id, record.target.id, record));
}
Object.defineProperty(record, "modelConnector", {
value: modelConnector,
configurable: true
});
for (const field of ["hasArrow", "lineColor", "lineWidth"]) {
Object.defineProperty(record, field, {
configurable: true,
get: () => modelConnector[field],
set: (value) => { modelConnector[field] = value; }
});
}
for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) {
Object.defineProperty(record, field, {
configurable: true,
get: () => this.itemRecord(modelConnector[idField]),
set: (value) => { modelConnector[idField] = Number(value.id); }
});
}
return record;
}
/** Keep the pure model synchronized with geometry owned by the drawing engine. */
synchronizeModel() {
const itemIds = new Set(this.items.map((record) => Number(record.id)));
for (const modelItem of this.model.conceptMap.items()) {
if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id);
}
for (const record of this.items) {
const modelItem = this.registerModelItem(record);
this.bindRecordToModel(record, modelItem);
if (record.node) {
modelItem.x = Number(record.node.attr("x"));
modelItem.y = Number(record.node.attr("y"));
modelItem.width = Number(record.node.attr("width"));
modelItem.height = Number(record.node.attr("height"));
}
}
const connectorIds = new Set();
for (const connector of this.connectors) {
connectorIds.add(Number(connector.id));
let modelConnector = this.model.conceptMap.connector(connector.id);
if (!modelConnector) {
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
connector.id, connector.source.id, connector.target.id, connector));
}
modelConnector.sourceId = Number(connector.source.id);
modelConnector.targetId = Number(connector.target.id);
modelConnector.hasArrow = connector.hasArrow;
modelConnector.lineColor = connector.lineColor;
modelConnector.lineWidth = connector.lineWidth;
}
for (const connector of this.unresolvedConnectors) {
connectorIds.add(Number(connector.id));
if (!this.model.conceptMap.connector(connector.id)) {
this.model.conceptMap.addConnector(new ConceptMapConnector(
connector.id, connector.sourceId, connector.targetId, connector));
}
}
for (const connector of this.model.conceptMap.connectors()) {
if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id);
}
this.model.conceptMap.metadata = this.documentMetadata;
this.model.conceptMap.setConceptMapReferences([...this.conceptMaps.values()]);
this.conceptMaps = this.model.conceptMap.conceptMapsById;
return this.model;
}
historySnapshot() {
return JSON.stringify(this.toDocument());
}
@@ -266,8 +503,9 @@
this.items = [];
this.connectors = [];
this.unresolvedConnectors = [];
this.conceptMaps = new Map();
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
this.model = CmapModel.fromDocument({});
this.conceptMaps = this.model.conceptMap.conceptMapsById;
this.documentMetadata = this.model.conceptMap.metadata;
this.activeMapRoot = null;
this.mapHistory = [];
this.nextId = 1;
@@ -423,7 +661,7 @@
this.items.filter((item) => item.conceptId === record.conceptId &&
item.kind !== "phrase").length + 1 : null;
const node = this.map.node({
const node = this.view.createNode({
content: this.itemHtml(record),
contentType: "html",
x: numberOr(options.x, 80 + ((id * 37) % 420)),
@@ -437,6 +675,7 @@
});
record.node = node;
this.items.push(record);
this.attachRecordToModel(record);
this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []);
this.refreshConceptMapReferences();
node.onRendered((_renderedNode, element) => this.decorateItem(record, element));
@@ -885,177 +1124,10 @@
return record.mapReference;
}
prepareStoredSubmapExtraction(record, targetSlug) {
console.warn(`${debugPrefix} destructive submap extraction is disabled; use a shared derived view`, {
itemId: record ? record.id : null,
targetSlug
});
return null;
/* istanbul ignore next -- retained only to read historical documents */
if (!record || record.kind !== "submap" || !targetSlug) return null;
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
if (!record || record.kind !== "submap") return null;
this.ensureSubmapContents(record);
const sourceDocument = this.toDocument();
const descendants = this.items.filter((item) => this.isDescendantOf(item, record));
const descendantIds = new Set(descendants.map((item) => item.id));
const allItemIds = new Set(this.items.map((item) => item.id));
if (!descendantIds.size) return null;
const descendantDocuments = sourceDocument.items.filter((item) => descendantIds.has(item.id));
const headSource = sourceDocument.items.find((item) => Number(item.id) === Number(record.id));
if (!headSource) return null;
const scopedDocuments = [headSource, ...descendantDocuments];
const left = Math.min(...scopedDocuments.map((item) => Number(item.x) || 0));
const top = Math.min(...scopedDocuments.map((item) => Number(item.y) || 0));
const placeInChild = (item, parentSubmapId, submapDepth) => {
const x = (Number(item.x) || 0) - left + 80;
const y = (Number(item.y) || 0) - top + 80;
const rootLayout = item.layouts && item.layouts.root ? item.layouts.root : {};
return {
...item,
parentSubmapId,
submapDepth,
x,
y,
layouts: {
...(item.layouts || {}),
root: {
...rootLayout,
x,
y,
width: Number(item.width),
height: Number(item.height)
}
}
};
};
const childHead = {
...placeInChild(headSource, null, 0),
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
pageSlug: null,
// The placement in the parent links to this map. The head on the
// child itself must not be a link back to the same map.
cmapSlug: null,
parentCmapLink: false
};
const childItems = [childHead, ...descendantDocuments.map((item) =>
placeInChild(
item,
Number(item.parentSubmapId) === Number(record.id) ? null : item.parentSubmapId,
Math.max(0, Number(item.submapDepth || 0) - record.submapDepth - 1)
))];
const parentConnectors = [];
const childConnectors = [];
const parentConnectorKeys = new Set();
for (const connector of sourceDocument.connectors) {
const sourceInside = descendantIds.has(Number(connector.sourceId));
const targetInside = descendantIds.has(Number(connector.targetId));
const sourceIsHead = Number(connector.sourceId) === Number(record.id);
const targetIsHead = Number(connector.targetId) === Number(record.id);
if ((sourceInside && targetInside) ||
(sourceIsHead && targetInside) ||
(sourceInside && targetIsHead)) {
childConnectors.push({ ...connector });
continue;
}
if (sourceInside || targetInside) {
const outsideId = Number(sourceInside ? connector.targetId : connector.sourceId);
if (!allItemIds.has(outsideId)) {
childConnectors.push({ ...connector });
continue;
}
const rewired = {
...connector,
sourceId: sourceInside ? record.id : connector.sourceId,
targetId: targetInside ? record.id : connector.targetId
};
if (Number(rewired.sourceId) === Number(rewired.targetId)) continue;
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
if (parentConnectorKeys.has(key)) continue;
parentConnectorKeys.add(key);
parentConnectors.push(rewired);
continue;
}
const key = `${connector.sourceId}\u0000${connector.targetId}\u0000${connector.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push({ ...connector });
}
}
const childConceptIds = new Set(childItems.map((item) => item.conceptId).filter(Boolean));
const parentItems = sourceDocument.items
.filter((item) => !descendantIds.has(item.id))
.map((item) => item.id === record.id ? {
...item,
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
cmapSlug: targetSlug,
parentCmapLink: false,
borderColor: "#57834a"
} : item);
const parentConceptIds = new Set(parentItems.map((item) => item.conceptId).filter(Boolean));
const conceptById = new Map(sourceDocument.concepts.map((concept) => [concept.id, concept]));
const childConceptById = new Map(conceptById);
const parentConceptById = new Map(conceptById);
const headDocument = parentItems.find((item) => item.id === record.id);
if (headDocument && headDocument.conceptId) {
parentConceptById.set(headDocument.conceptId, {
id: headDocument.conceptId,
kind: "concept",
label: headDocument.label,
synopsis: headDocument.synopsis,
aspects: headDocument.aspects || [],
descriptionPageSlug: headDocument.descriptionPageSlug,
pageSlug: null,
cmapSlug: targetSlug,
externalUrl: headDocument.externalUrl || null,
parentCmapLink: false,
imageSource: headDocument.imageSource || ""
});
childConceptById.set(headDocument.conceptId, {
id: headDocument.conceptId,
kind: "concept",
label: childHead.label,
synopsis: childHead.synopsis,
aspects: childHead.aspects || [],
descriptionPageSlug: childHead.descriptionPageSlug,
pageSlug: null,
cmapSlug: null,
externalUrl: childHead.externalUrl || null,
parentCmapLink: false,
imageSource: childHead.imageSource || ""
});
}
return {
childDocument: {
schemaVersion: 2,
concepts: Array.from(childConceptById.values()).filter((concept) => childConceptIds.has(concept.id)),
items: childItems,
connectors: childConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
descendantIds.has(Number(reference.rootItemId)))
},
parentDocument: {
...sourceDocument,
concepts: Array.from(parentConceptById.values()).filter((concept) => parentConceptIds.has(concept.id)),
items: parentItems,
connectors: parentConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
Number(reference.rootItemId) !== record.id &&
!descendantIds.has(Number(reference.rootItemId)))
}
};
return this.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
}
replaceDocument(document) {
@@ -1084,7 +1156,7 @@
setZoom(percent) {
const next = Math.max(25, Math.min(300, Number(percent) || 100));
this.zoomFactor = next / 100;
this.map.zoom(this.zoomFactor);
this.view.setZoom(this.zoomFactor);
this.ensureCanvasExtent(0, 0);
debug("zoom changed", { percent: next, factor: this.zoomFactor });
return next;
@@ -1095,7 +1167,7 @@
}
surfaceElement() {
return this.canvas.querySelector(":scope > .rw-cmap-surface");
return this.view.surfaceElement();
}
ensureCanvasExtent(x, y, padding = 180) {
@@ -1228,7 +1300,7 @@
addConnector(source, target, hasArrow = true, options = {}) {
const sourceCenter = this.itemCenter(source);
const targetCenter = this.itemCenter(target);
const link = this.map.link({
const link = this.view.createConnector({
content: "",
width: 1,
height: 1,
@@ -1262,6 +1334,7 @@
lineWidth: numberOr(Number(options.lineWidth), 2)
};
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
this.attachConnectorToModel(record);
this.connectors.push(record);
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
link.onConnectionChange((_changedLink, type, node) =>
@@ -1782,103 +1855,31 @@
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
this.model.conceptMap.metadata = this.documentMetadata;
return this.getDocumentMetadata();
}
/** Serialize the pure repository and map model at the API boundary. */
toDocument() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
const concepts = Array.from(new Map(this.items
.filter((record) => record.conceptId && record.kind !== "phrase")
.map((record) => [record.conceptId, {
id: record.conceptId,
label: record.label,
synopsis: record.synopsis,
aspects: record.aspects,
tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"),
descriptionPageSlug: record.descriptionPageSlug,
pageSlug: record.pageSlug,
cmapSlug: record.cmapSlug,
externalUrl: record.externalUrl,
imageSource: record.imageSource
}])).values());
return {
schemaVersion: 2,
metadata: this.getDocumentMetadata(),
concepts,
items: this.items.map((record) => ({
id: record.id,
conceptId: record.conceptId,
kind: record.kind,
...(record.kind === "phrase" ? {
label: record.label,
synopsis: record.synopsis
} : {}),
parentCmapLink: record.parentCmapLink,
groupId: record.groupId,
childMap: record.childMap,
parentSubmapId: record.parentSubmap ? record.parentSubmap.id : null,
submapDepth: record.submapDepth,
expanded: record.expanded,
submapInitialized: record.submapInitialized,
separateMap: record.separateMap,
mapReference: record.mapReference,
hiddenContexts: Array.from(record.hiddenContexts),
layouts: record.layouts,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
submapBackgroundColor: record.submapBackgroundColor,
submapBorderColor: record.submapBorderColor,
textColor: record.textColor,
fontFamily: record.fontFamily,
fontSize: record.fontSize,
fontWeight: record.fontWeight,
fontStyle: record.fontStyle,
synopsisTextColor: record.synopsisTextColor,
synopsisFontFamily: record.synopsisFontFamily,
synopsisFontSize: record.synopsisFontSize,
synopsisFontWeight: record.synopsisFontWeight,
synopsisFontStyle: record.synopsisFontStyle,
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height")),
autoWidth: record.autoWidth,
autoHeight: record.autoHeight,
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y"))
})),
connectors: this.connectors.map((connector) => ({
id: connector.id,
sourceId: connector.source.id,
targetId: connector.target.id,
hasArrow: connector.hasArrow,
lineColor: connector.lineColor,
lineWidth: connector.lineWidth
})).concat(this.unresolvedConnectors.map((connector) => ({ ...connector }))),
conceptMaps: Array.from(this.conceptMaps.values())
};
return this.synchronizeModel().toDocument();
}
loadDocument(document = {}) {
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
throw new Error("A concept map document can only be loaded into an empty editor");
}
const itemDocuments = Array.isArray(document.items) ? document.items : [];
const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : [];
const concepts = new Map((Array.isArray(document.concepts) ? document.concepts : [])
.filter((concept) => concept && concept.id)
.map((concept) => [concept.id, concept]));
this.model = CmapModel.fromDocument(document);
this.conceptMaps = this.model.conceptMap.conceptMapsById;
this.documentMetadata = this.model.conceptMap.metadata;
const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument());
const connectorDocuments = this.model.conceptMap.connectors()
.map((connector) => connector.toDocument());
const records = new Map();
const metadata = document.metadata && typeof document.metadata === "object" ?
document.metadata : {};
this.documentMetadata = {
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
.map((tag) => tag.trim()).filter(Boolean) : [],
summary: String(metadata.summary || "").trim(),
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
};
for (const itemDocument of itemDocuments) {
const concept = concepts.get(itemDocument.conceptId) || {};
const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {};
const record = this.addItem({
...itemDocument,
...concept,
@@ -1924,10 +1925,6 @@
}
this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
}
this.conceptMaps = new Map(
(Array.isArray(document.conceptMaps) ? document.conceptMaps : [])
.filter((reference) => reference && reference.id)
.map((reference) => [reference.id, reference]));
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.applyCurrentContextLayout();
@@ -2812,7 +2809,7 @@
item.submapAnchorLineElement = null;
}
}
if (this.map && typeof this.map.destroy === "function") this.map.destroy();
this.view.destroy();
debug("editor destroyed");
}
@@ -2862,12 +2859,16 @@
if (!records.size && !connectors.size) return false;
this.clearSelection(false);
for (const connector of connectors) connector.link.remove();
for (const connector of connectors) {
connector.link.remove();
this.model.conceptMap.removeConnector(connector.id);
}
this.connectors = this.connectors.filter((connector) => !connectors.has(connector));
for (const record of records) {
if (record.submapFrameElement) record.submapFrameElement.remove();
if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id);
record.node.remove();
this.model.conceptMap.removeItem(record.id);
}
if (records.size && this.unresolvedConnectors.length) {
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
+37
View File
@@ -0,0 +1,37 @@
/**
* Present a CMap through the bundled drawing engine.
*
* CmapView owns the canvas and concrete cmap.js instance. It creates and
* destroys drawing objects but contains no concept, placement or persistence
* rules; CmapEditor remains responsible for interpreting user interaction.
*/
export class CmapView {
constructor(canvas, CmapFactory, onSelection, onActivation) {
if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required");
if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required");
this.canvas = canvas;
this.map = CmapFactory(canvas);
this.map.onSelection(onSelection);
this.map.onActivation(onActivation);
}
createNode(attributes) {
return this.map.node(attributes);
}
createConnector(attributes) {
return this.map.link(attributes);
}
setZoom(factor) {
this.map.zoom(factor);
}
surfaceElement() {
return this.canvas.querySelector(":scope > .rw-cmap-surface");
}
destroy() {
if (typeof this.map.destroy === "function") this.map.destroy();
}
}
+9
View File
@@ -52,6 +52,11 @@ body.cmap-mode #main {
min-width: 16px;
}
#cmap-extract-selected {
flex: 0 0 auto;
white-space: nowrap;
}
.cmap-hidden-items {
position: relative;
flex: 0 0 auto;
@@ -466,6 +471,10 @@ body.cmap-mode #main {
z-index: 100000;
display: grid;
min-width: 210px;
max-height: calc(100vh - 16px);
max-height: calc(100dvh - 16px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 5px;
border: 1px solid #8c95a3;
border-radius: 6px;
+422
View File
@@ -0,0 +1,422 @@
import { ConceptRepository } from "./concept-repository.js";
const PLACEMENT_FIELDS = [
"parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth",
"expanded", "submapInitialized", "separateMap", "mapReference",
"hiddenContexts", "layouts", "backgroundColor", "borderColor",
"submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily",
"fontSize", "fontWeight", "fontStyle", "synopsisTextColor",
"synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Join one repository concept to its placement and appearance on a CMap. */
export class ConceptMapConcept {
constructor(id, conceptId, values = {}) {
if (!Number.isInteger(Number(id)) || !conceptId) {
throw new TypeError("A concept placement requires an integer id and concept id");
}
this.id = Number(id);
this.conceptId = String(conceptId);
this.kind = values.kind || "concept";
this.update(values);
}
update(values = {}) {
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentCmapLink = Boolean(this.parentCmapLink);
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.submapDepth = Number(this.submapDepth) || 0;
this.expanded = Boolean(this.expanded);
this.submapInitialized = Boolean(this.submapInitialized);
this.separateMap = Boolean(this.separateMap);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
this.autoWidth = Boolean(this.autoWidth);
this.autoHeight = Boolean(this.autoHeight);
return this;
}
toDocument() {
const document = { id: this.id, conceptId: this.conceptId, kind: this.kind };
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Represent a map-local linking phrase without creating a repository concept. */
export class ConceptMapPhrase {
constructor(id, values = {}) {
if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id");
this.id = Number(id);
this.kind = "phrase";
this.label = String(values.label || "?????");
this.synopsis = String(values.synopsis || "");
this.update(values);
}
update(values = {}) {
if (values.label !== undefined) this.label = String(values.label);
if (values.synopsis !== undefined) this.synopsis = String(values.synopsis);
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
return this;
}
toDocument() {
const document = {
id: this.id,
conceptId: null,
kind: "phrase",
label: this.label,
synopsis: this.synopsis
};
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Store one rendered connection between two map-local item placements. */
export class ConceptMapConnector {
constructor(id, sourceId, targetId, values = {}) {
if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) ||
!Number.isInteger(Number(targetId))) {
throw new TypeError("A connector requires integer ids");
}
this.id = Number(id);
this.sourceId = Number(sourceId);
this.targetId = Number(targetId);
this.relationId = values.relationId || null;
this.hasArrow = values.hasArrow !== false;
this.lineColor = values.lineColor || "#333";
this.lineWidth = Number(values.lineWidth) || 2;
}
toDocument() {
return {
id: this.id,
sourceId: this.sourceId,
targetId: this.targetId,
...(this.relationId ? { relationId: this.relationId } : {}),
hasArrow: this.hasArrow,
lineColor: this.lineColor,
lineWidth: this.lineWidth
};
}
}
/** Own the placements, phrases, connectors and metadata of one CMap. */
export class ConceptMap {
constructor(values = {}) {
this.schemaVersion = Number(values.schemaVersion) || 2;
this.metadata = {
tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [],
summary: String(values.metadata?.summary || ""),
explanationPageSlug: String(values.metadata?.explanationPageSlug || "")
};
this.derivedView = values.derivedView ? copy(values.derivedView) : null;
this.itemsById = new Map();
this.connectorsById = new Map();
this.conceptMapsById = new Map();
}
addItem(value) {
const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value :
(value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) :
new ConceptMapConcept(value.id, value.conceptId, value));
if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`);
this.itemsById.set(item.id, item);
return item;
}
item(id) {
return this.itemsById.get(Number(id)) || null;
}
items() {
return [...this.itemsById.values()];
}
removeItem(id) {
const itemId = Number(id);
this.itemsById.delete(itemId);
for (const [connectorId, connector] of this.connectorsById) {
if (connector.sourceId === itemId || connector.targetId === itemId) {
this.connectorsById.delete(connectorId);
}
}
}
addConnector(value) {
const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector(
value.id, value.sourceId, value.targetId, value);
if (this.connectorsById.has(connector.id)) {
throw new Error(`Duplicate CMap connector id: ${connector.id}`);
}
this.connectorsById.set(connector.id, connector);
return connector;
}
connector(id) {
return this.connectorsById.get(Number(id)) || null;
}
connectors() {
return [...this.connectorsById.values()];
}
removeConnector(id) {
this.connectorsById.delete(Number(id));
}
setConceptMapReferences(references = []) {
this.conceptMapsById = new Map(references
.filter((reference) => reference && reference.id)
.map((reference) => [reference.id, copy(reference)]));
}
toDocument() {
const document = {
schemaVersion: this.schemaVersion,
metadata: copy(this.metadata),
items: this.items().map((item) => item.toDocument()),
connectors: this.connectors().map((connector) => connector.toDocument()),
conceptMaps: [...this.conceptMapsById.values()].map(copy)
};
if (this.derivedView) document.derivedView = copy(this.derivedView);
return document;
}
}
/** Combine a concept repository with one concrete concept-map document. */
export class CmapModel {
constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) {
this.repository = repository;
this.conceptMap = conceptMap;
}
static fromDocument(document = {}) {
const repository = new ConceptRepository(
Array.isArray(document.concepts) ? document.concepts : [],
Array.isArray(document.conceptRelations) ? document.conceptRelations : [],
Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []);
const conceptMap = new ConceptMap(document);
for (const item of (Array.isArray(document.items) ? document.items : [])) {
if (item.kind !== "phrase" && !repository.concept(item.conceptId)) {
repository.add({ id: item.conceptId, label: item.label || "Concept" });
}
conceptMap.addItem(item);
}
for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) {
conceptMap.addConnector(connector);
}
conceptMap.setConceptMapReferences(document.conceptMaps);
return new CmapModel(repository, conceptMap);
}
/**
* Split an embedded submap into a standalone map and its remaining parent.
* The owner stays in the parent as an ordinary concept and links to targetSlug.
* Descendant placements become the complete contents of the child map.
*/
extractSubmap(rootItemId, targetSlug = null, childMetadata = null) {
const sourceDocument = this.toDocument();
const rootId = Number(rootItemId);
const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId);
if (!rootItem || rootItem.kind !== "submap") {
throw new TypeError("A submap item is required for extraction");
}
const descendantIds = new Set();
let foundDescendant = true;
while (foundDescendant) {
foundDescendant = false;
for (const item of sourceDocument.items) {
const parentId = Number(item.parentSubmapId);
if (parentId !== rootId && !descendantIds.has(parentId)) continue;
if (descendantIds.has(Number(item.id))) continue;
descendantIds.add(Number(item.id));
foundDescendant = true;
}
}
const descendantItems = sourceDocument.items
.filter((item) => descendantIds.has(Number(item.id)));
if (!descendantItems.some((item) => item.kind !== "phrase")) {
throw new Error("The submap has no concepts to extract");
}
const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0));
const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0));
const childItems = descendantItems.map((item) => {
const x = (Number(item.x) || 0) - left + 80;
const y = (Number(item.y) || 0) - top + 80;
const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId;
const submapDepth = Math.max(
0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1);
return {
...item,
parentSubmapId,
submapDepth,
x,
y,
layouts: {
...(item.layouts || {}),
root: {
...(item.layouts?.root || {}),
x,
y,
width: Number(item.width),
height: Number(item.height)
}
}
};
});
const parentItems = sourceDocument.items
.filter((item) => !descendantIds.has(Number(item.id)))
.map((item) => Number(item.id) === rootId ? {
...item,
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null
} : item);
const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id)));
const parentConnectors = [];
const parentConnectorKeys = new Set();
const childConnectors = [];
for (const connector of sourceDocument.connectors) {
const sourceId = Number(connector.sourceId);
const targetId = Number(connector.targetId);
if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue;
const sourceInside = descendantIds.has(sourceId);
const targetInside = descendantIds.has(targetId);
if (sourceInside && targetInside) {
childConnectors.push({ ...connector });
continue;
}
if (sourceInside || targetInside) {
const rewired = {
...connector,
sourceId: sourceInside ? rootId : sourceId,
targetId: targetInside ? rootId : targetId
};
if (rewired.sourceId === rewired.targetId) continue;
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push(rewired);
}
continue;
}
const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push({ ...connector });
}
}
const conceptIdsFor = (items) => new Set(items
.filter((item) => item.kind !== "phrase" && item.conceptId)
.map((item) => String(item.conceptId)));
const childConceptIds = conceptIdsFor(childItems);
const parentConceptIds = conceptIdsFor(parentItems);
const ownerConceptId = String(rootItem.conceptId);
const conceptsFor = (ids) => sourceDocument.concepts
.filter((concept) => ids.has(String(concept.id)))
.map((concept) => ({ ...concept }));
const parentConcepts = conceptsFor(parentConceptIds);
if (targetSlug) {
const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId);
if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug);
}
const relationsFor = (ids) => (sourceDocument.conceptRelations || [])
.filter((relation) => ids.has(String(relation.sourceConceptId)) &&
ids.has(String(relation.targetConceptId)))
.map((relation) => ({ ...relation }));
const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || [])
.filter((ownership) => {
const parentId = String(ownership.parentConceptId);
const childId = String(ownership.childConceptId);
const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId);
return !extractedOwnership && ids.has(parentId) && ids.has(childId);
})
.map((ownership) => ({ ...ownership }));
const ownerConcept = sourceDocument.concepts
.find((concept) => String(concept.id) === ownerConceptId) || {};
const extractedMetadata = childMetadata ? copy(childMetadata) : {
tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [],
summary: String(ownerConcept.synopsis || ""),
explanationPageSlug: String(ownerConcept.descriptionPageSlug || "")
};
const childDocument = {
schemaVersion: sourceDocument.schemaVersion,
metadata: extractedMetadata,
concepts: conceptsFor(childConceptIds),
items: childItems,
connectors: childConnectors,
conceptMaps: sourceDocument.conceptMaps
.filter((reference) => descendantIds.has(Number(reference.rootItemId)))
};
const childRelations = relationsFor(childConceptIds);
const childOwnerships = ownershipsFor(childConceptIds);
if (childRelations.length) childDocument.conceptRelations = childRelations;
if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships;
const parentDocument = {
...sourceDocument,
concepts: parentConcepts,
items: parentItems,
connectors: parentConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
Number(reference.rootItemId) !== rootId &&
!descendantIds.has(Number(reference.rootItemId)))
};
const parentRelations = relationsFor(parentConceptIds);
const parentOwnerships = ownershipsFor(parentConceptIds);
if (parentRelations.length) parentDocument.conceptRelations = parentRelations;
else delete parentDocument.conceptRelations;
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
else delete parentDocument.conceptOwnerships;
return { parentDocument, childDocument };
}
toDocument() {
const document = this.conceptMap.toDocument();
const usedConceptIds = new Set(this.conceptMap.items()
.filter((item) => item instanceof ConceptMapConcept)
.map((item) => item.conceptId));
document.concepts = this.repository.toDocument()
.filter((concept) => usedConceptIds.has(concept.id));
const relations = this.repository.relationDocuments();
const ownerships = this.repository.ownershipDocuments();
if (relations.length) document.conceptRelations = relations;
if (ownerships.length) document.conceptOwnerships = ownerships;
return document;
}
}
export { PLACEMENT_FIELDS };
+217
View File
@@ -0,0 +1,217 @@
const CONCEPT_FIELDS = [
"label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
const type = String(tag.type || "label").trim() || "label";
const value = String(tag.value || tag.name || "").trim();
return value ? { type, value } : null;
}).filter(Boolean);
}
/**
* Store the shared content and external references of one concept.
* A Concept never contains coordinates, styling or browser objects.
*/
export class Concept {
constructor(id, values = {}) {
if (!id) throw new TypeError("A concept id is required");
this.id = String(id);
this.label = String(values.label || "Concept");
this.synopsis = String(values.synopsis || "");
this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : [];
this.tags = normalizeTags(values.tags);
this.descriptionPageSlug = values.descriptionPageSlug || null;
this.pageSlug = values.pageSlug || null;
this.cmapSlug = values.cmapSlug || null;
this.externalUrl = values.externalUrl || null;
this.imageSource = values.imageSource || "";
}
/** Change shared content without accepting placement or presentation fields. */
update(changes = {}) {
for (const field of CONCEPT_FIELDS) {
if (changes[field] === undefined) continue;
if (field === "aspects") {
this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : [];
} else if (field === "tags") {
this.tags = normalizeTags(changes.tags);
} else {
this[field] = changes[field];
}
}
return this;
}
/** Return the stable JSON representation used at the wiki API boundary. */
toDocument() {
return {
id: this.id,
label: this.label,
synopsis: this.synopsis,
aspects: [...this.aspects],
tags: copy(this.tags),
descriptionPageSlug: this.descriptionPageSlug,
pageSlug: this.pageSlug,
cmapSlug: this.cmapSlug,
externalUrl: this.externalUrl,
imageSource: this.imageSource
};
}
}
/** Describe a semantic relation that exists independently of a diagram. */
export class ConceptRelation {
constructor(id, sourceConceptId, targetConceptId, values = {}) {
if (!id || !sourceConceptId || !targetConceptId) {
throw new TypeError("A concept relation requires an id and two concepts");
}
this.id = String(id);
this.sourceConceptId = String(sourceConceptId);
this.targetConceptId = String(targetConceptId);
this.label = String(values.label || "");
this.tags = normalizeTags(values.tags);
}
toDocument() {
return {
id: this.id,
sourceConceptId: this.sourceConceptId,
targetConceptId: this.targetConceptId,
label: this.label,
tags: copy(this.tags)
};
}
}
/** Describe semantic parent/child ownership between repository concepts. */
export class ConceptOwnership {
constructor(parentConceptId, childConceptId) {
if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) {
throw new TypeError("Concept ownership requires two different concepts");
}
this.parentConceptId = String(parentConceptId);
this.childConceptId = String(childConceptId);
}
toDocument() {
return {
parentConceptId: this.parentConceptId,
childConceptId: this.childConceptId
};
}
}
/**
* Own all shared concepts and their semantic relationships.
* Placements refer to this repository by concept id.
*/
export class ConceptRepository {
constructor(concepts = [], relations = [], ownerships = []) {
this.conceptsById = new Map();
this.relationsById = new Map();
this.ownershipsByChildId = new Map();
for (const value of concepts) this.add(value);
for (const value of relations) this.addRelation(value);
for (const value of ownerships) this.addOwnership(value);
}
add(value) {
const concept = value instanceof Concept ? value : new Concept(value.id, value);
this.conceptsById.set(concept.id, concept);
return concept;
}
ensure(id, values = {}) {
const key = String(id);
const existing = this.conceptsById.get(key);
if (existing) {
existing.update(values);
return existing;
}
return this.add(new Concept(key, values));
}
concept(id) {
return this.conceptsById.get(String(id)) || null;
}
concepts() {
return [...this.conceptsById.values()];
}
remove(id) {
const key = String(id);
this.conceptsById.delete(key);
for (const [relationId, relation] of this.relationsById) {
if (relation.sourceConceptId === key || relation.targetConceptId === key) {
this.relationsById.delete(relationId);
}
}
this.ownershipsByChildId.delete(key);
for (const [childId, ownership] of this.ownershipsByChildId) {
if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId);
}
}
addRelation(value) {
const relation = value instanceof ConceptRelation ? value : new ConceptRelation(
value.id, value.sourceConceptId, value.targetConceptId, value);
this.requireConcept(relation.sourceConceptId);
this.requireConcept(relation.targetConceptId);
this.relationsById.set(relation.id, relation);
return relation;
}
addOwnership(value) {
const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership(
value.parentConceptId, value.childConceptId);
this.requireConcept(ownership.parentConceptId);
this.requireConcept(ownership.childConceptId);
if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) {
throw new Error("Concept ownership would create a cycle");
}
this.ownershipsByChildId.set(ownership.childConceptId, ownership);
return ownership;
}
isOwnedBy(conceptId, possibleAncestorId) {
let current = this.ownershipsByChildId.get(String(conceptId));
const seen = new Set();
while (current && !seen.has(current.childConceptId)) {
if (current.parentConceptId === String(possibleAncestorId)) return true;
seen.add(current.childConceptId);
current = this.ownershipsByChildId.get(current.parentConceptId);
}
return false;
}
requireConcept(id) {
const concept = this.concept(id);
if (!concept) throw new Error(`Unknown concept: ${id}`);
return concept;
}
toDocument() {
return this.concepts().map((concept) => concept.toDocument());
}
relationDocuments() {
return [...this.relationsById.values()].map((relation) => relation.toDocument());
}
ownershipDocuments() {
return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument());
}
}
export { CONCEPT_FIELDS, normalizeTags };
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+1 -251
View File
@@ -1188,78 +1188,8 @@ body.editor-mode .editor-metadata-row {
}
/* Wiki graph ------------------------------------------------------------- */
#graph-view {
max-width: 1100px;
}
.wiki-graph-shell {
margin-top: 18px;
border: 1px solid var(--wiki-border);
background: #fafafa;
overflow: auto;
}
.wiki-graph {
display: block;
width: 100%;
min-width: 720px;
min-height: 520px;
}
.wiki-graph-edges line {
stroke: #b8b8b8;
stroke-width: 1.4;
}
.wiki-graph-node {
cursor: pointer;
outline: none;
}
.wiki-graph-node circle {
fill: #46639b;
stroke: white;
stroke-width: 2;
}
.wiki-graph-node text {
fill: var(--wiki-text);
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
}
.wiki-graph-node:hover circle,
.wiki-graph-node:focus circle {
fill: #1e2f6d;
stroke: #1e2f6d;
}
.wiki-graph-node-cmap circle {
fill: #57834a;
}
.wiki-graph-node-cmap:hover circle,
.wiki-graph-node-cmap:focus circle {
fill: #315f2b;
stroke: #315f2b;
}
/* 0.2.15 context navigation and sticky-anchor correction ---------------- */
.context-link {
color: var(--wiki-link);
font-size: .76rem;
font-weight: 400;
letter-spacing: normal;
text-transform: none;
text-decoration: none;
}
.context-link:hover {
text-decoration: underline;
}
/* Sticky-anchor correction ---------------------------------------------- */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
@@ -1269,16 +1199,6 @@ body.editor-mode .editor-metadata-row {
scroll-margin-top: 82px;
}
.wiki-graph-node-focus circle {
r: 15px;
fill: #1e2f6d;
stroke: #1e2f6d;
}
.wiki-graph-node-focus text {
font-weight: 700;
}
/* Recent changes and bookmark overview pages. */
.special-page-list,
@@ -1444,173 +1364,3 @@ body.editor-mode .editor-metadata-row {
.alias-history { color: #666; }
.alias-actions { display: flex; gap: .5rem; margin-top: .6rem; }
.alias-actions button { font: inherit; }
/* 0.2.36 context graph overlay and docking ------------------------------ */
.page-reading-layout {
min-width: 0;
}
.page-reading-layout.context-dock-right {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 42%);
gap: 1rem;
align-items: start;
}
.page-reading-layout.context-dock-top {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-reading-layout.context-dock-top .context-dock {
order: -1;
}
.context-dock {
border: 1px solid var(--wiki-border);
background: #fafafa;
padding: .75rem;
position: sticky;
top: 84px;
max-height: calc(100vh - 100px);
overflow: auto;
}
.context-dock-header,
.context-overlay-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.context-graph-actions {
display: flex;
gap: .7rem;
flex-wrap: wrap;
font-size: .82rem;
}
.context-graph-actions a {
color: var(--wiki-link);
text-decoration: none;
}
.context-graph-actions a:hover {
text-decoration: underline;
}
.context-graph-small {
min-width: 0;
min-height: 320px;
max-height: 520px;
}
.context-overlay {
position: fixed;
inset: 0;
z-index: 500;
background: rgb(0 0 0 / 35%);
display: grid;
place-items: center;
padding: 2rem;
}
.context-overlay-panel {
width: min(1180px, 96vw);
max-height: 92vh;
overflow: auto;
background: white;
border: 1px solid var(--wiki-border);
box-shadow: 0 14px 44px rgb(0 0 0 / 24%);
padding: 1rem;
}
.context-overlay-header h2 {
margin: 0 0 .2rem;
}
.context-overlay-graph-shell {
margin-top: .8rem;
}
.wiki-graph-edges marker path {
fill: #9a9a9a;
}
.wiki-graph-edge-incoming {
stroke: #7a5d9e !important;
}
.wiki-graph-edge-outgoing {
stroke: #46639b !important;
}
@media (max-width: 1100px) {
.page-reading-layout.context-dock-right {
display: flex;
flex-direction: column;
}
.page-reading-layout.context-dock-right .context-dock {
order: -1;
width: 100%;
position: static;
max-height: none;
}
}
@media (max-width: 700px) {
.context-overlay {
padding: .5rem;
}
.context-overlay-panel {
width: 100%;
max-height: 96vh;
}
.context-overlay-header {
display: block;
}
.context-graph-actions {
margin-top: .5rem;
}
}
/* Keep the established document width and use otherwise empty desktop
space for the docked context view. */
#page-view:has(.context-dock-right) {
max-width: none;
}
.page-reading-layout.context-dock-right {
grid-template-columns: minmax(0, 900px) minmax(420px, 520px);
justify-content: start;
gap: 1.5rem;
}
.page-reading-layout.context-dock-right .markdown-body {
width: 100%;
}
.page-reading-layout.context-dock-right .context-dock {
width: 100%;
}
.context-dock .wiki-graph-node text {
font-size: 20px;
font-weight: 600;
}
.context-dock .wiki-graph-node-focus text {
font-size: 22px;
}
@media (max-width: 1450px) {
.page-reading-layout.context-dock-right {
grid-template-columns: minmax(0, 760px) minmax(360px, 440px);
}
}
+26 -50
View File
@@ -19,14 +19,13 @@
<a id="bookmarks-link" class="sidebar-icon-link" href="#bookmarks" title="Bookmarks" data-tr-title="bookmarks" aria-label="Bookmarks" data-tr-aria-label="bookmarks"><i data-lucide="star"></i><span class="visually-hidden" data-tr="bookmarks">Bookmarks</span></a>
<a id="todo-link" class="sidebar-icon-link" href="#todos" title="Todo list" data-tr-title="todo-list" aria-label="Todo list" data-tr-aria-label="todo-list"><i data-lucide="list-checks"></i><span class="visually-hidden" data-tr="todo-list">Todo list</span></a>
<a id="recent-link" class="sidebar-icon-link" href="#recent" title="Recent" data-tr-title="recent" aria-label="Recent" data-tr-aria-label="recent"><i data-lucide="square-stack"></i><span class="visually-hidden" data-tr="recent">Recent</span></a>
<a id="graph-link" class="sidebar-icon-link" href="#graph" title="Navigation graph" data-tr-title="navigation-graph" aria-label="Navigation graph" data-tr-aria-label="navigation-graph"><i data-lucide="ship-wheel"></i><span class="visually-hidden" data-tr="navigation-graph">Navigation graph</span></a>
<a id="cmaps-link" class="sidebar-icon-link" href="#" title="CMaps" data-tr-title="cmaps" aria-label="CMaps" data-tr-aria-label="cmaps"><i data-lucide="share-2"></i><span class="visually-hidden" data-tr="cmaps">CMaps</span></a>
</nav>
<form id="search-form" class="wiki-search" role="search">
<input id="search-input" type="search" placeholder="Search pages and concept maps" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search pages and concept maps" data-tr-aria-label="search-wiki">
</form>
<section class="sidebar-section toc-section">
<div class="sidebar-heading"><span data-tr="contents">Contents</span> <a id="context-link" class="context-link hidden" href="#">(<span data-tr="context">context</span>)</a></div>
<div class="sidebar-heading" data-tr="contents">Contents</div>
<nav id="toc-list" class="toc-list"></nav>
</section>
<details class="sidebar-section pages-section">
@@ -67,20 +66,7 @@
<div id="page-meta" class="muted"></div>
</div>
</header>
<div id="page-reading-layout" class="page-reading-layout">
<article id="markdown-preview" class="markdown-body"></article>
<aside id="context-dock" class="context-dock hidden" aria-label="Context graph">
<div class="context-dock-header">
<strong data-tr="context-graph">Context graph</strong>
<span class="context-graph-actions">
<a id="context-dock-popup" href="#" data-tr="popup">Popup</a>
<a id="context-dock-close" href="#" data-tr="close">Close</a>
</span>
</div>
<div id="context-dock-summary" class="muted"></div>
<svg id="context-dock-graph" class="wiki-graph context-graph-small" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
</aside>
</div>
<article id="markdown-preview" class="markdown-body"></article>
<section id="page-cmap-connections" class="page-cmap-connections hidden" aria-labelledby="page-cmap-connections-title">
<h2 id="page-cmap-connections-title" data-tr="connected-cmap-concepts">Connected CMap concepts</h2>
<div id="page-cmap-connections-list"></div>
@@ -158,16 +144,6 @@
<div id="todo-list" class="todo-items"></div>
</section>
<section id="graph-view" class="hidden">
<header class="page-header">
<h1 id="graph-title" data-tr="wiki-graph">Wiki graph</h1>
<div id="graph-summary" class="muted"></div>
</header>
<div class="wiki-graph-shell">
<svg id="wiki-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Wiki graph"></svg>
</div>
</section>
<section id="cmap-view" class="hidden">
<header class="cmap-page-header">
<div class="cmap-storage-controls">
@@ -182,6 +158,7 @@
<button id="cmap-wiki-reference" class="cmap-wiki-reference hidden" type="button" data-tr-title="copy-wiki-link" title="Copy wiki link"></button>
<span class="cmap-toolbar-separator" aria-hidden="true"></span>
<button id="cmap-tools-menu" class="editor-only" type="button" aria-label="Concept map tools" aria-haspopup="menu"></button>
<button id="cmap-extract-selected" class="editor-only hidden" type="button" data-tr="promote-submap">Make separate CMap</button>
<span id="cmap-save-status" class="muted" role="status"></span>
<details id="cmap-hidden-items" class="cmap-hidden-items hidden">
<summary data-tr="hidden-concepts">Hidden concepts</summary>
@@ -417,27 +394,6 @@
</main>
</div>
<div id="context-overlay" class="context-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="context-overlay-title">
<div class="context-overlay-panel">
<div class="context-overlay-header">
<div>
<h2 id="context-overlay-title" data-tr="context-graph">Context graph</h2>
<div id="context-overlay-summary" class="muted"></div>
</div>
<nav class="context-graph-actions">
<a id="context-dock-right" href="#" data-tr="dock-right">Dock right</a>
<a id="context-dock-top" href="#" data-tr="dock-above">Dock above</a>
<a id="context-open-full" href="#" data-tr="open-full-graph">Open full graph</a>
<a id="context-overlay-close" href="#" data-tr="close">Close</a>
</nav>
</div>
<div class="wiki-graph-shell context-overlay-graph-shell">
<svg id="context-overlay-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
</div>
</div>
</div>
<dialog id="cmap-concept-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-concept-dialog-title">
<form id="cmap-concept-form" novalidate>
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
@@ -675,11 +631,31 @@
<script src="/vendor/lucide.min.js"></script>
<script src="/vendor/diff2html-ui-base.min.js"></script>
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
<script src="/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/cmap-export.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/cmap-interchange.js?id=__CMAP_CACHE_ID__"></script>
<script src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
<script type="importmap">
{
"imports": {
"/cmap/cmap-racket-wiki.js": "/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
"/cmap/model/concept-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
"/js/wiki/cmap/interchange.js": "/js/wiki/cmap/interchange.js?id=__CMAP_CACHE_ID__",
"/js/wiki/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
"/js/wiki/routes.js": "/js/wiki/routes.js?id=__CMAP_CACHE_ID__",
"/js/wiki/markdown.js": "/js/wiki/markdown.js?id=__CMAP_CACHE_ID__",
"/js/wiki/breadcrumb-trail.js": "/js/wiki/breadcrumb-trail.js?id=__CMAP_CACHE_ID__",
"/js/wiki/page-outline.js": "/js/wiki/page-outline.js?id=__CMAP_CACHE_ID__",
"/js/wiki/admin/overview.js": "/js/wiki/admin/overview.js?id=__CMAP_CACHE_ID__",
"/js/wiki/admin/alias-admin.js": "/js/wiki/admin/alias-admin.js?id=__CMAP_CACHE_ID__",
"/js/wiki/admin/archived-cmaps-admin.js": "/js/wiki/admin/archived-cmaps-admin.js?id=__CMAP_CACHE_ID__",
"/js/wiki/admin/mail-admin.js": "/js/wiki/admin/mail-admin.js?id=__CMAP_CACHE_ID__",
"/js/wiki/admin/orphaned-uploads-admin.js": "/js/wiki/admin/orphaned-uploads-admin.js?id=__CMAP_CACHE_ID__",
"/js/wiki/admin/user-admin.js": "/js/wiki/admin/user-admin.js?id=__CMAP_CACHE_ID__"
}
}
</script>
<script type="module" src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
</body>
</html>
+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 };