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
+5
View File
@@ -4,6 +4,11 @@ Version 0.2.31 adds page namespaces as database metadata and extends wiki refere
Current development version: **0.2.100**.
The current development version removes the automatic navigation graph and the
page-local context graph. Both derived large diagrams obscured the ordinary
page and CMap navigation without adding enough practical value. Stored CMaps
and their editor remain unchanged.
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
Version 0.2.9 uses PostgreSQL as the wiki's complete content store. Users, sessions, pages, immutable page versions, attachment metadata and attachment bytes live in PostgreSQL. PostgreSQL full-text search is built into the page table and exposed by the wiki search UI.
+1 -1
View File
@@ -64,7 +64,7 @@ Voeg observability incrementeel toe:
1. eerst een request-id en gestandaardiseerde foutregel;
2. daarna tijdmetingen rond trage endpoints;
3. vervolgens database- en graphmetingen wanneer echte belasting dat vereist;
3. vervolgens database- en browsermetingen wanneer echte belasting dat vereist;
4. pas dan een externe metrics- of tracingstack wanneer lokaal loggen onvoldoende blijkt.
## Analyseerbare code
+1 -1
View File
@@ -41,7 +41,7 @@ De huidige vorm rust op een klein aantal bewuste keuzes:
## Kwaliteitsbeeld
De architectuur past goed bij een persoonlijke of teamwiki: weinig processen, een duidelijke databasebron en volledige historie. De sterkste punten zijn de transactionele inhoudsopslag, eenvoudige deployment en lokale frontend-assets. De voornaamste ontwikkelpunten zijn de omvang van `server.rkt` en `static/js/wiki.js`, het ontbreken van een volwaardige geautomatiseerde testsuite, een nieuwe databaseverbinding per opslagbewerking en de N+1-aanpak waarmee de volledige navigatiegraaf wordt opgebouwd.
De architectuur past goed bij een persoonlijke of teamwiki: weinig processen, een duidelijke databasebron en volledige historie. De sterkste punten zijn de transactionele inhoudsopslag, eenvoudige deployment en lokale frontend-assets. De voornaamste ontwikkelpunten zijn de omvang van `server.rkt` en `static/js/wiki.js`, het ontbreken van een volwaardige geautomatiseerde testsuite en een nieuwe databaseverbinding per opslagbewerking.
Deze punten zijn geen reden voor een voorafgaande grote herbouw. De ontwikkelregel is: meet eerst, isoleer het concrete probleem en splits een module wanneer een wijziging daar aantoonbaar eenvoudiger of beter testbaar door wordt.
+1 -1
View File
@@ -42,7 +42,7 @@ De uitzonderingsoptie is uitsluitend bedoeld voor een bewust vertrouwde lokale m
## Beschikbaarheid en misbruik
Er gelden al document- en veldvalidaties, maar een algemeen uploadmaximum en request-rate limiting zijn toekomstige versterkingen. De CMap-opslag begrenst het JSON-document tot 10 MiB. Grote Markdown, bijlagen, graph-opbouw en dure zoekvragen kunnen anders geheugen of verwerkingstijd gebruiken.
Er gelden al document- en veldvalidaties, maar een algemeen uploadmaximum en request-rate limiting zijn toekomstige versterkingen. De CMap-opslag begrenst het JSON-document tot 10 MiB. Grote Markdown, bijlagen en dure zoekvragen kunnen anders geheugen of verwerkingstijd gebruiken.
Voor een publiek bereikbare installatie horen reverse-proxylimits, databaseback-ups, logrotatie en monitoring bij het beveiligingsmodel. Beschikbaarheid is ook een beveiligingseigenschap.
-2
View File
@@ -53,8 +53,6 @@ De prijs is lineaire databasegroei met het aantal versies maal de documentgroott
`pages.search_document`, `todo_items` en `attachment_references.current_reference` zijn afgeleide gegevens. Zij moeten in dezelfde transactie als hun bron worden aangepast. Een los herstelcommando mag ze opnieuw kunnen opbouwen, maar gewone reads mogen niet afhankelijk zijn van een toevallig later achtergrondproces. Concepten met het aspect `TODO` worden daarentegen rechtstreeks uit `concept_definitions` geselecteerd en met de pagina-todo's samengevoegd; daarvoor bestaat bewust geen tweede index.
De gecombineerde navigatiegraaf is momenteel volledig afgeleid in de browser. Zij wordt niet als databasegraaf bewaard.
## CMap-uitwisselingsformaat
Het versieerbare JSON-formaat `racket-wiki-cmap-bundle` volgt dezelfde scheiding als de database. `cmaps[]` bevat per stabiele slug het volledige structuur- en opmaakdocument, `concepts[]` bevat de gedeelde inhoud per UUID en `pages[]` bevat de actuele titel, Markdown, tags en daarin gebruikte attachments van gekoppelde wiki- en uitlegpagina's. De binaire inhoud staat base64-gecodeerd bij de pagina. Daardoor kan één concept op meerdere kaarten dezelfde inhoud houden terwijl positie, formaat, kleuren en typografie per plaatsing behouden blijven. Verbindingszinnen en connectoren verwijzen naar lokale item-id's en blijven dus bij hun diagramcontext.
+1 -3
View File
@@ -72,12 +72,10 @@ conceptkaarten. De hit-test gebruikt dezelfde laagprioriteit, zodat een achterli
klik kan afvangen op de kaart die haar bedekt. Tijdelijke eindpunthendels blijven wel bovenop liggen
zolang een relatie wordt versleept.
## Zoeken, Recent en navigatiegraaf
## Zoeken en Recent
Paginazoeken gebruikt een opgeslagen gewogen `tsvector` en GIN-index. CMap-zoeken verzamelt titel, slug, labels en synopses uit JSONB. Recent voegt de nieuwste pagina- en CMapwijzigingen samen.
De gecombineerde navigatiegraaf leest verwijzingen uit alle huidige pagina's en CMaps. Hij wordt in de browser gecachet totdat een catalogus opnieuw wordt geladen. Dit levert rijke navigatie op, maar is het belangrijkste schaalrisico; zie [Verwachte performance](racket-wiki:performance).
## Wachtwoordherstel en mail
De publieke herstelactie geeft altijd dezelfde reactie, ook als een account niet bestaat. Een echte aanvraag maakt een gehashte, eenmalige code die één uur geldig is en past rate limiting per gebruiker toe. Na succesvol herstel worden bestaande sessies ingetrokken.
+2 -2
View File
@@ -6,7 +6,7 @@ De backend is functioneel opgesplitst. Configuratie, databaseverbinding, migrati
De belangrijkste grens is die tussen `server.rkt` en de opslagmodules. `server.rkt` hoort HTTP te begrijpen; `storage.rkt`, `cmap-storage.rkt` en `auth.rkt` horen domeinbewerkingen en database-invarianten te begrijpen. Geen opslagprocedure mag een webrequest nodig hebben.
De frontend heeft een vergelijkbare grens. `cmap-racket-wiki.js` implementeert een editorcomponent met callbacks voor openen, selectie en wijzigingen. `wiki.js` koppelt die callbacks aan routes, API's en dialoogvensters.
De frontend heeft een vergelijkbare grens. `cmap-racket-wiki.js` implementeert een editorcomponent met callbacks voor openen, selectie en wijzigingen. `wiki.js` koppelt die callbacks aan routes, API's en dialoogvensters. Zuivere referentie-, route-, Markdown- en outlinebewerkingen staan in eigen modules; `BreadcrumbTrail` beheert uitsluitend de per-tab paginahistorie en kent de DOM niet. `routes.js` herkent hashes, terwijl `wiki.js` eigenaar blijft van permissiecontrole en het openen van views. De concrete controllers onder `static/js/wiki/admin/` beheren hun eigen formulier- of lijstinteractie. Zij ontvangen alleen de bestaande API- en vertaalfuncties en, wanneer de gewone catalogus werkelijk verandert, één gerichte herlaadfunctie.
## Sterke punten
@@ -42,7 +42,7 @@ Deze concentraties zijn technische schuld, geen automatische opdracht tot een gr
Wanneer `server.rkt` verder groeit, ligt opsplitsing per adaptergebied voor de hand: sessie/profiel, pagina's, CMaps en beheer. De centrale router kan dan dun blijven. Handlers ontvangen `config` expliciet en roepen dezelfde bestaande diensten aan.
Wanneer `wiki.js` verder groeit, zijn route/state, pagina-editor, CMap-host, graph-view en admin-views natuurlijke grenzen. Splits alleen met native ES-modules wanneer de setup- en cacheversies van alle scripts tegelijk beheerst worden.
Wanneer `wiki.js` verder groeit, zijn route/state, pagina-editor, CMap-host en admin-views natuurlijke grenzen. Splits alleen met native ES-modules wanneer de setup- en cacheversies van alle scripts tegelijk beheerst worden.
Wanneer paginaopslag wordt aangepast, kunnen bookmarks, aliases en uploads later eigen modules krijgen. De pagina-schrijftransactie en afgeleide indices moeten daarbij als één consistente operatie behouden blijven; opsplitsing van bestanden mag geen opsplitsing van de transactie veroorzaken.
+10 -18
View File
@@ -9,8 +9,8 @@ De onderstaande schaalinschatting is een architectuuranalyse, geen benchmark. We
| Omvang | Verwachting met huidige architectuur |
| --- | --- |
| Honderden pagina's, tientallen CMaps | Normale lees-, schrijf- en zoekacties horen ruim voldoende te zijn op een gewone lokale server. |
| Enkele duizenden pagina's, honderden CMaps | Paginaweergave en geïndexeerd paginazoeken blijven waarschijnlijk goed; volledige graph-opbouw, CMap-zoeken, catalogusgrootte en verbindingsopbouw worden zichtbaar. |
| Tienduizenden pagina's of veel grote CMaps | Paginering, server-side graphindex, connectionpooling, geïndexeerd CMap-zoeken en versie-/bijlagebeleid worden waarschijnlijk noodzakelijk. |
| Enkele duizenden pagina's, honderden CMaps | Paginaweergave en geïndexeerd paginazoeken blijven waarschijnlijk goed; CMap-zoeken, catalogusgrootte en verbindingsopbouw worden zichtbaar. |
| Tienduizenden pagina's of veel grote CMaps | Paginering, connectionpooling, geïndexeerd CMap-zoeken en versie-/bijlagebeleid worden waarschijnlijk noodzakelijk. |
Het ontwerp is dus passend voor een persoonlijke of teamwiki. Het is niet zonder aanvullende maatregelen ontworpen als internetbrede kennisbank met zeer veel gelijktijdige gebruikers.
@@ -18,16 +18,10 @@ Het ontwerp is dus passend voor een persoonlijke of teamwiki. Het is niet zonder
De actuele pagina staat direct in `pages`; voor normaal lezen hoeft geen versiegeschiedenis te worden opgebouwd. Titel en Markdown leveren een opgeslagen gewogen zoekvector met GIN-index. PostgreSQL verwerkt transacties en locking dicht bij de data. De browser ontvangt bij de paginacatalogus alleen metadata en haalt Markdown pas op bij gebruik.
Vendor-assets worden lokaal geserveerd en veranderen niet tijdens normaal gebruik. De browser cachet de opgebouwde navigatiegraaf totdat pagina- of CMapcatalogus opnieuw wordt geladen.
Vendor-assets worden lokaal geserveerd en veranderen niet tijdens normaal gebruik.
## Belangrijkste toekomstige knelpunten
### Volledige navigatiegraaf
`loadGraphData()` haalt momenteel iedere pagina en iedere CMap sequentieel via een eigen API-request op en extraheert daarna links in de browser. Voor `P` pagina's en `C` CMaps zijn dat `P + C` inhoudsrequests naast de catalogi. Latency groeit daardoor lineair en netwerkvertraging telt herhaaldelijk op.
De duurzame oplossing is een server-side relationele linkindex die in dezelfde schrijftransactie als pagina of CMap wordt bijgewerkt. Een graph-endpoint kan dan alle nodes en edges in één compacte response leveren.
### CMap-zoeken
Paginazoeken gebruikt een opgeslagen index. CMap-zoeken bouwt per query tekst uit JSONB-items en maakt daar op dat moment een `tsvector` van. Bij veel of grote kaarten wordt dit een volledige scan. Voeg dan een opgeslagen zoektekst/`tsvector` en GIN-index aan `concept_maps` toe, bijgewerkt tijdens CMap-opslag.
@@ -50,7 +44,7 @@ Bijlagebytes staan in PostgreSQL en worden volledig in geheugen gelezen voor de
### Browserrendering
Grote CMaps tekenen veel DOM/SVG-objecten en connectors. Volledige maps en graphs hebben uiteindelijk layoutkosten die niet met een snellere database verdwijnen. Meet nodeaantal, rendertijd en interactieframes. Sub-CMaps, viewportculling of vereenvoudigde read-only rendering zijn dan gerichte opties.
Grote CMaps tekenen veel DOM/SVG-objecten en connectors. Volledige kaarten hebben uiteindelijk layoutkosten die niet met een snellere database verdwijnen. Meet nodeaantal, rendertijd en interactieframes. Sub-CMaps, viewportculling of vereenvoudigde read-only rendering zijn dan gerichte opties.
## Meetplan
@@ -58,9 +52,8 @@ Voeg vóór optimalisatie minimaal deze metingen toe:
- requestduur per endpoint en responsebytes;
- connecttijd versus SQL-tijd;
- aantallen pagina's, CMaps, graph-edges en versies;
- aantallen pagina's, CMaps en versies;
- gemiddelde en p95 Markdown-, JSONB- en bijlagegrootte;
- tijd voor volledige graph-opbouw;
- tijd voor CMap-zoekquery's;
- browserrendertijd bij representatieve CMaps.
@@ -70,11 +63,10 @@ Gebruik datasets met echte linkdichtheid en documentgroottes. Duizend lege pagin
De verwachte volgorde bij groei is:
1. graph-edges tijdens opslag indexeren en in één request leveren;
2. CMap-zoekvector opslaan en indexeren;
3. databaseconnectionpool toevoegen;
4. catalogi pagineren of per namespace laden;
5. expliciet versie- en uploadbeleid ontwerpen;
6. pas daarna horizontale of servicegerichte architectuur overwegen.
1. CMap-zoekvector opslaan en indexeren;
2. databaseconnectionpool toevoegen;
3. catalogi pagineren of per namespace laden;
4. expliciet versie- en uploadbeleid ontwerpen;
5. pas daarna horizontale of servicegerichte architectuur overwegen.
Deze volgorde behoudt de eenvoud van [Structuur en samenhang](racket-wiki:structuur-en-samenhang) zolang die nog waardevol is.
+2 -2
View File
@@ -38,7 +38,7 @@ De map `private/` bevat de backendonderdelen:
`translate.rkt` staat bewust aan de publieke rand: zowel backend als frontend gebruiken dezelfde effectieve vertaaltabel.
De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routing, API-aanroepen, editor- en paginatoestand en speciale views. `static/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak.
De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routedispatch, API-aanroepen, editor- en paginatoestand en speciale views. De modules onder `static/js/wiki/` bevatten afzonderlijk de referentiesyntaxis, routeherkenning, Markdowntransformaties, paginakoppen en breadcrumbhistorie. De controllers onder `static/js/wiki/admin/` beheren ieder één administratief formulier of overzicht; routes, permissiecontrole, breadcrumbs en algemene adminnavigatie blijven in `wiki.js`. `static/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak.
## Afhankelijkheidsrichting
@@ -56,7 +56,7 @@ Deze richting houdt de belangrijkste domeinregels buiten de UI. Een rolcontrole
Een pagina heeft een database-id, namespace, slug, actuele Markdown en een versieteller. `page_versions` verwijst naar dezelfde pagina-id. Todo's, bookmarks, aliases en bijlageverwijzingen sluiten via die id aan.
Een CMap heeft een stabiele slug, titel, JSONB-document en versieteller. `concept_map_versions` bewaart volledige JSONB-snapshots. CMap-items kunnen via `pageSlug` naar een wikipagina verwijzen en via `cmapSlug` naar een andere CMap. De browser gebruikt deze verwijzingen voor navigatie en de gecombineerde sitegraph.
Een CMap heeft een stabiele slug, titel, JSONB-document en versieteller. `concept_map_versions` bewaart volledige JSONB-snapshots. CMap-items kunnen via `pageSlug` naar een wikipagina verwijzen en via `cmapSlug` naar een andere CMap. De browser gebruikt deze verwijzingen om het gekoppelde doel rechtstreeks te openen.
Zie [Data, transacties en versiebeheer](racket-wiki:data-en-versies) voor de invarianten en [Modulariteit en afhankelijkheden](racket-wiki:modulariteit) voor de gewenste grenzen bij uitbreiding.
+613 -438
View File
File diff suppressed because it is too large Load Diff
+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 };
+4 -4
View File
@@ -74,7 +74,7 @@ function fakeLink(attributes) {
};
}
function loadEditorFactory() {
async function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
@@ -88,12 +88,12 @@ function loadEditorFactory() {
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
require("../static/cmap/cmap-racket-wiki.js");
await import("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("a dragged connector endpoint survives collapsing and expanding its submap", () => {
const factory = loadEditorFactory();
test("a dragged connector endpoint survives collapsing and expanding its submap", async () => {
const factory = await loadEditorFactory();
const links = [];
const map = {
onSelection() {},
+4 -4
View File
@@ -21,7 +21,7 @@ function fakeElement({ width = 320, height = 120 } = {}) {
};
}
function loadEditorFactory() {
async function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
@@ -30,12 +30,12 @@ function loadEditorFactory() {
createElement: () => fakeElement()
};
global.window = { Cmap: null };
require("../static/cmap/cmap-racket-wiki.js");
await import("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("automatic render sizing advances only an unchanged saved baseline", () => {
const factory = loadEditorFactory();
test("automatic render sizing advances only an unchanged saved baseline", async () => {
const factory = await loadEditorFactory();
const canvas = {
addEventListener() {},
querySelector: () => null,
+33 -3
View File
@@ -2,9 +2,21 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildBundle, validateBundle, preparedMapDocument, attachmentUrls, replaceAttachmentUrls
} = require("../static/js/cmap-interchange.js");
let buildBundle;
let validateBundle;
let preparedMapDocument;
let attachmentUrls;
let replaceAttachmentUrls;
test.before(async () => {
({
buildBundle,
validateBundle,
preparedMapDocument,
attachmentUrls,
replaceAttachmentUrls
} = await import("../static/js/wiki/cmap/interchange.js"));
});
const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555";
const childId = "53ef1cf3-c936-48f7-889a-ad644fab9321";
@@ -150,6 +162,24 @@ test("invalid connector endpoints and duplicate slugs are rejected", () => {
assert.throws(() => validateBundle(bundle), /unknown item[\s\S]*duplicated/);
});
test("bundle export omits connectors whose endpoint no longer exists", async () => {
const mapWithDanglingConnectors = structuredClone(root);
mapWithDanglingConnectors.document.connectors.push(
{ id: 2, sourceId: 10, targetId: 99 },
{ id: 3, sourceId: 98, targetId: 11 }
);
const bundle = await buildBundle({
rootMap: mapWithDanglingConnectors,
maxDepth: 0,
loadConceptMap: async () => { throw new Error("not loaded"); },
loadWikiPage: async (reference) => pages.get(reference),
loadAttachment
});
assert.deepEqual(bundle.cmaps[0].document.connectors, root.document.connectors);
});
test("external concept links only accept complete http or https URLs", () => {
const bundle = {
format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map",
+2 -1
View File
@@ -88,7 +88,8 @@ test("relations render and hit-test behind concept nodes", async () => {
requestAnimationFrame: (callback) => setImmediate(callback)
};
const Cmap = require("../static/cmap/cmap.js");
await import("../static/cmap/cmap.js");
const Cmap = global.window.Cmap;
const map = Cmap(root);
const concept = map.node({ x: 40, y: 0, width: 120, height: 50 });
const otherConcept = map.node({ x: 220, y: 0, width: 120, height: 50 });
+274
View File
@@ -0,0 +1,274 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
Concept,
ConceptOwnership,
ConceptRelation,
ConceptRepository
} from "../static/cmap/model/concept-repository.js";
import {
CmapModel,
ConceptMap,
ConceptMapConcept,
ConceptMapConnector,
ConceptMapPhrase
} from "../static/cmap/model/concept-map.js";
import { buildBundle } from "../static/js/wiki/cmap/interchange.js";
const firstId = "11111111-1111-4111-8111-111111111111";
const secondId = "22222222-2222-4222-8222-222222222222";
const thirdId = "33333333-3333-4333-8333-333333333333";
function sampleDocument() {
return {
schemaVersion: 2,
metadata: {
tags: ["architecture"],
summary: "System overview",
explanationPageSlug: "cmap:architecture"
},
concepts: [{
id: firstId,
label: "Application",
synopsis: "Runs the application",
aspects: ["runtime"],
tags: [{ type: "person", value: "Hans" }],
descriptionPageSlug: "cmap:application",
pageSlug: "application",
cmapSlug: null,
externalUrl: "https://example.com/application",
imageSource: ""
}],
items: [{
id: 1,
conceptId: firstId,
kind: "concept",
parentCmapLink: false,
groupId: null,
childMap: null,
parentSubmapId: null,
submapDepth: 0,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
hiddenContexts: [],
layouts: {},
backgroundColor: "#ffffff",
borderColor: "#333333",
submapBackgroundColor: null,
submapBorderColor: null,
textColor: "#111111",
fontFamily: "Arial",
fontSize: "11pt",
fontWeight: "700",
fontStyle: "normal",
synopsisTextColor: "#444444",
synopsisFontFamily: "Arial",
synopsisFontSize: "9pt",
synopsisFontWeight: "400",
synopsisFontStyle: "normal",
width: 220,
height: 70,
autoWidth: false,
autoHeight: false,
x: 80,
y: 90
}, {
id: 2,
conceptId: null,
kind: "phrase",
label: "uses",
synopsis: "",
parentCmapLink: false,
groupId: null,
childMap: null,
parentSubmapId: null,
submapDepth: 0,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null,
hiddenContexts: [],
layouts: {},
backgroundColor: "#ffffff",
borderColor: "transparent",
submapBackgroundColor: null,
submapBorderColor: null,
textColor: "#111111",
fontFamily: "Arial",
fontSize: "11pt",
fontWeight: "700",
fontStyle: "normal",
synopsisTextColor: "#444444",
synopsisFontFamily: "Arial",
synopsisFontSize: "9pt",
synopsisFontWeight: "400",
synopsisFontStyle: "normal",
width: 145,
height: 36,
autoWidth: true,
autoHeight: true,
x: 330,
y: 100
}],
connectors: [{
id: 1,
sourceId: 1,
targetId: 2,
hasArrow: false,
lineColor: "#333",
lineWidth: 2
}],
conceptMaps: []
};
}
test("CmapModel round-trips repository content, placements, phrases and connectors", () => {
const document = sampleDocument();
const model = CmapModel.fromDocument(document);
assert.ok(model.repository.concept(firstId) instanceof Concept);
assert.ok(model.conceptMap.item(1) instanceof ConceptMapConcept);
assert.ok(model.conceptMap.item(2) instanceof ConceptMapPhrase);
assert.ok(model.conceptMap.connector(1) instanceof ConceptMapConnector);
assert.deepEqual(model.toDocument(), document);
});
test("two placements share one repository concept without sharing appearance", () => {
const model = CmapModel.fromDocument(sampleDocument());
model.conceptMap.addItem(new ConceptMapConcept(3, firstId, {
x: 600, y: 300, width: 180, height: 60, backgroundColor: "#ffeeaa"
}));
model.repository.concept(firstId).update({ label: "Web application" });
assert.equal(model.repository.concept(firstId).label, "Web application");
assert.equal(model.conceptMap.item(1).backgroundColor, "#ffffff");
assert.equal(model.conceptMap.item(3).backgroundColor, "#ffeeaa");
assert.equal(model.toDocument().concepts.length, 1);
});
test("semantic relations and ownership remain independent of map connectors", () => {
const repository = new ConceptRepository([
{ id: firstId, label: "Parent" },
{ id: secondId, label: "Child" }
]);
repository.addRelation(new ConceptRelation("contains", firstId, secondId, {
label: "contains"
}));
repository.addOwnership(new ConceptOwnership(firstId, secondId));
const conceptMap = new ConceptMap();
conceptMap.addItem(new ConceptMapConcept(1, firstId, { x: 0, y: 0 }));
conceptMap.addItem(new ConceptMapConcept(2, secondId, {
parentSubmapId: 1, x: 50, y: 80
}));
conceptMap.addConnector(new ConceptMapConnector(1, 1, 2, {
relationId: "contains"
}));
const document = new CmapModel(repository, conceptMap).toDocument();
assert.equal(document.conceptRelations[0].id, "contains");
assert.equal(document.conceptOwnerships[0].parentConceptId, firstId);
assert.equal(document.items[1].parentSubmapId, 1);
assert.equal(document.connectors[0].relationId, "contains");
});
test("concept ownership rejects cycles", () => {
const repository = new ConceptRepository([
{ id: firstId, label: "First" },
{ id: secondId, label: "Second" }
]);
repository.addOwnership({ parentConceptId: firstId, childConceptId: secondId });
assert.throws(() => repository.addOwnership({
parentConceptId: secondId,
childConceptId: firstId
}), /cycle/);
});
test("an embedded submap becomes an independent map linked from its owner", () => {
const document = {
schemaVersion: 2,
metadata: { tags: [], summary: "Parent", explanationPageSlug: "" },
concepts: [
{ id: firstId, label: "Owner", synopsis: "Nested subject", aspects: ["design"] },
{ id: secondId, label: "Nested concept" },
{ id: thirdId, label: "Outside concept" }
],
conceptOwnerships: [
{ parentConceptId: firstId, childConceptId: secondId },
{ parentConceptId: firstId, childConceptId: thirdId }
],
items: [
{ id: 1, conceptId: firstId, kind: "submap", x: 100, y: 100, submapDepth: 0 },
{ id: 2, conceptId: secondId, kind: "concept", x: 240, y: 260,
parentSubmapId: 1, submapDepth: 1 },
{ id: 3, kind: "phrase", label: "contains", x: 450, y: 270,
parentSubmapId: 1, submapDepth: 1 },
{ id: 4, conceptId: thirdId, kind: "concept", x: 20, y: 100, submapDepth: 0 }
],
connectors: [
{ id: 1, sourceId: 2, targetId: 3 },
{ id: 2, sourceId: 4, targetId: 2 },
{ id: 3, sourceId: 1, targetId: 2 }
],
conceptMaps: []
};
const extraction = CmapModel.fromDocument(document)
.extractSubmap(1, "nested-subject");
assert.deepEqual(extraction.parentDocument.items.map((item) => item.id), [1, 4]);
assert.equal(extraction.parentDocument.items[0].kind, "concept");
assert.equal(extraction.parentDocument.concepts
.find((concept) => concept.id === firstId).cmapSlug, "nested-subject");
assert.deepEqual(extraction.parentDocument.connectors.map((connector) => ({
sourceId: connector.sourceId,
targetId: connector.targetId
})), [{ sourceId: 4, targetId: 1 }]);
assert.deepEqual(extraction.parentDocument.conceptOwnerships, [
{ parentConceptId: firstId, childConceptId: thirdId }
]);
assert.deepEqual(extraction.childDocument.items.map((item) => item.id), [2, 3]);
assert.ok(extraction.childDocument.items.every((item) => item.parentSubmapId === null));
assert.deepEqual(extraction.childDocument.concepts.map((concept) => concept.id), [secondId]);
assert.deepEqual(extraction.childDocument.connectors.map((connector) => connector.id), [1]);
assert.equal(extraction.childDocument.conceptOwnerships, undefined);
assert.equal(extraction.childDocument.derivedView, undefined);
assert.equal(extraction.childDocument.metadata.summary, "Nested subject");
const convertedDerivedMap = CmapModel.fromDocument(document).extractSubmap(
1,
"nested-subject",
{ tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: "" }
);
assert.deepEqual(convertedDerivedMap.childDocument.metadata, {
tags: ["retained"], summary: "Edited child metadata", explanationPageSlug: ""
});
});
test("a model document is accepted by the complete JSON bundle export", async () => {
const model = CmapModel.fromDocument(sampleDocument());
const bundle = await buildBundle({
rootMap: {
slug: "architecture",
title: "Architecture",
document: model.toDocument()
},
maxDepth: 0,
exportedAt: "2026-09-01T12:00:00.000Z",
loadConceptMap: async () => { throw new Error("not requested"); },
loadWikiPage: async (reference) => ({
slug: reference,
title: reference,
markdown: "Description",
tags: []
}),
loadAttachment: async () => { throw new Error("not requested"); }
});
assert.equal(bundle.rootCmapSlug, "architecture");
assert.deepEqual(bundle.cmaps[0].document.items, model.toDocument().items);
assert.equal(bundle.concepts[0].label, "Application");
});
+10 -4
View File
@@ -16,7 +16,7 @@ function fakeNode(attributes) {
};
}
function loadEditorFactory() {
async function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
@@ -30,12 +30,12 @@ function loadEditorFactory() {
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
require("../static/cmap/cmap-racket-wiki.js");
await import("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("the last selected concept determines target size and alignment", async () => {
const factory = loadEditorFactory();
const factory = await loadEditorFactory();
const map = {
onSelection() {},
onActivation() {},
@@ -53,6 +53,12 @@ test("the last selected concept determines target size and alignment", async ()
});
const second = editor.addItem({ id: 2, label: "Second", x: 80, y: 100, width: 120, height: 45 });
const third = editor.addItem({ id: 3, label: "Third", x: 250, y: 250, width: 200, height: 60 });
assert.equal(editor.conceptRepository().concept(first.conceptId).label, "First");
assert.equal(editor.conceptMapModel().item(first.id).conceptId, first.conceptId);
assert.equal("node" in editor.conceptRepository().concept(first.conceptId), false,
"repository concepts never contain drawing nodes");
assert.equal("node" in editor.conceptMapModel().item(first.id), false,
"map placements never contain drawing nodes");
const savedDocument = editor.toDocument();
assert.equal(
savedDocument.concepts.find((concept) => concept.id === first.conceptId).externalUrl,
@@ -141,7 +147,7 @@ test("the last selected concept determines target size and alignment", async ()
[Number(item.node.attr("width")), Number(item.node.attr("height"))]),
[[120, 45], [120, 45], [120, 45]]);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(editor.canUndo(), true, "layout commands participate in undo and autosave history");
assert.deepEqual(editor.toDocument().items.map((item) => [item.width, item.height]),
[[120, 45], [120, 45], [120, 45]]);
+4 -4
View File
@@ -16,7 +16,7 @@ function fakeNode(attributes) {
};
}
function loadEditorFactory() {
async function loadEditorFactory() {
global.Element = class Element {};
global.document = {
currentScript: null,
@@ -30,12 +30,12 @@ function loadEditorFactory() {
clearTimeout,
requestAnimationFrame: (callback) => callback()
};
require("../static/cmap/cmap-racket-wiki.js");
await import("../static/cmap/cmap-racket-wiki.js");
return global.window.RacketWikiCmap;
}
test("back from a second nested submap returns exactly one map level", () => {
const factory = loadEditorFactory();
test("back from a second nested submap returns exactly one map level", async () => {
const factory = await loadEditorFactory();
const map = {
onSelection() {},
onActivation() {},
+79
View File
@@ -0,0 +1,79 @@
import test from "node:test";
import assert from "node:assert/strict";
import { BreadcrumbTrail } from "../static/js/wiki/breadcrumb-trail.js";
class MemoryStorage {
constructor() {
this.values = new Map();
}
getItem(key) {
return this.values.has(key) ? this.values.get(key) : null;
}
setItem(key, value) {
this.values.set(key, String(value));
}
}
test("stored breadcrumbs are limited to current readable pages", () => {
const storage = new MemoryStorage();
storage.setItem("racket-wiki-breadcrumb-trail", JSON.stringify([
"first", "removed", 42, "second"
]));
const trail = new BreadcrumbTrail(storage);
assert.deepEqual(trail.load(["first", "second"]), ["first", "second"]);
assert.equal(
storage.getItem("racket-wiki-breadcrumb-trail"),
JSON.stringify(["first", "second"]));
});
test("invalid stored JSON starts with an empty trail", () => {
const storage = new MemoryStorage();
storage.setItem("racket-wiki-breadcrumb-trail", "not JSON");
const trail = new BreadcrumbTrail(storage);
assert.deepEqual(trail.load(["first"]), []);
});
test("page visits append, truncate revisits and clear at home", () => {
const storage = new MemoryStorage();
const trail = new BreadcrumbTrail(storage);
trail.load(["home", "first", "second", "third"]);
trail.record("first", "home");
trail.record("second", "home");
trail.record("third", "home");
assert.deepEqual(trail.slugs, ["first", "second", "third"]);
assert.deepEqual(trail.record("second", "home"), ["first", "second"]);
const returnedSlugs = trail.slugs;
returnedSlugs.push("outside-change");
assert.deepEqual(trail.slugs, ["first", "second"]);
assert.deepEqual(trail.record("home", "home"), []);
});
test("the trail retains at most eight non-home pages", () => {
const storage = new MemoryStorage();
const trail = new BreadcrumbTrail(storage);
const pages = Array.from({ length: 10 }, (_value, index) => `page-${index}`);
trail.load(["home", ...pages]);
for (const page of pages) trail.record(page, "home");
assert.deepEqual(trail.slugs, pages.slice(-8));
});
test("selecting a breadcrumb discards only its successors", () => {
const storage = new MemoryStorage();
const trail = new BreadcrumbTrail(storage);
trail.load(["home", "first", "second", "third"]);
trail.record("first", "home");
trail.record("second", "home");
trail.record("third", "home");
assert.deepEqual(trail.truncate("first", "home"), ["first"]);
assert.deepEqual(trail.truncate("missing", "home"), ["first"]);
});
+69
View File
@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyImageWidthMarkup,
cmapMentionTarget,
escapeHtml,
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
extractCmapEmbeds,
restoreCmapEmbeds
} from "../static/js/wiki/markdown.js";
test("Todo markup is escaped, numbered and left alone in fenced code", () => {
const markdown = "todo(<script>)\n```\ntodo(no change)\n```\ntodo(second)";
const expanded = expandTodoMarkup(markdown, "start", "Todo & note");
assert.match(expanded, /#todo\/start\/1/);
assert.match(expanded, /#todo\/start\/2/);
assert.match(expanded, /Todo &amp; note/);
assert.match(expanded, /&lt;script&gt;/);
assert.match(expanded, /```\ntodo\(no change\)\n```/);
});
test("image options add width and alignment to rendered image tags", () => {
assert.equal(
applyImageWidthMarkup('<img src="x.png"> { width=40% center }'),
'<img src="x.png" style="width: 40%" class="wiki-image-center">');
assert.equal(
applyImageWidthMarkup('<img class="existing" src="x.png"> { width=240 left float }'),
'<img class="existing wiki-image-float-left" src="x.png" style="width: 240px">');
});
test("explicit namespaced links become internal routes outside code", () => {
const markdown = "[Page](notes:roadmap)\n[CMap](cmap:architecture)\n```\n[Code](notes:keep)\n```";
assert.equal(
expandNamespacedMarkdownLinks(markdown),
"[Page](#/notes%3Aroadmap)\n[CMap](#cmap/architecture)\n```\n[Code](notes:keep)\n```");
});
test("WikiWords use page and CMap catalogues without expanding protected text", () => {
const pages = [
{ slug: "homeopathie", namespace: "", pageSlug: "homeopathie", title: "Homeopathie Wiki" }
];
const conceptMaps = [{ slug: "architectuur", title: "Architectuur Kaart" }];
const markdown = "HomeopathieWiki en cmap:ArchitectuurKaart, maar `NieuwePagina` niet.";
assert.equal(
expandWikiMentions(markdown, pages, [], conceptMaps),
"[Homeopathie Wiki](#/homeopathie) en [Architectuur Kaart](#cmap/architectuur), maar `NieuwePagina` niet.");
});
test("ambiguous CMap mentions are not resolved", () => {
const conceptMaps = [
{ slug: "first", title: "Zelfde Kaart" },
{ slug: "second", title: "ZelfdeKaart" }
];
assert.equal(cmapMentionTarget("ZelfdeKaart", conceptMaps), null);
});
test("CMap embeds survive Markdown rendering through escaped placeholders", () => {
const extracted = extractCmapEmbeds("before\n{{cmap:architecture}}\n```\n{{cmap:code}}\n```");
assert.equal(extracted.embeds.length, 1);
assert.match(extracted.markdown, /RACKETWIKICMAPEMBED0TOKEN/);
assert.match(extracted.markdown, /\{\{cmap:code\}\}/);
assert.equal(
restoreCmapEmbeds("<p>RACKETWIKICMAPEMBED0TOKEN</p>", extracted.embeds, "Loading…"),
'<section class="rw-cmap-embed" data-cmap-reference="architecture"><div class="rw-cmap-embed-loading">Loading…</div></section>');
assert.equal(escapeHtml('<a href="x">&'), "&lt;a href=&quot;x&quot;&gt;&amp;");
});
+30
View File
@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import { headingId, markdownHeadings } from "../static/js/wiki/page-outline.js";
test("heading identifiers are readable and unique", () => {
const usedIds = new Set();
assert.equal(headingId("Résumé & planning", usedIds), "resume-planning");
assert.equal(headingId("Résumé & planning", usedIds), "resume-planning-2");
assert.equal(headingId("***", usedIds), "section");
});
test("Markdown headings retain their source line and level", () => {
const markdown = "intro\n# First\ntext\n ### Third ###\n";
assert.deepEqual(markdownHeadings(markdown), [
{ level: 1, text: "First", line: 1 },
{ level: 3, text: "Third", line: 3 }
]);
});
test("headings inside fenced code are ignored", () => {
const markdown = "# Visible\n```racket\n## Code\n```\n## Also visible";
assert.deepEqual(markdownHeadings(markdown), [
{ level: 1, text: "Visible", line: 0 },
{ level: 2, text: "Also visible", line: 4 }
]);
});
+43
View File
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
newPageReference,
pageReference,
slugTitle,
splitPageReference,
wikiWordTarget
} from "../static/js/wiki/reference.js";
test("wiki references preserve root and namespace syntax", () => {
assert.deepEqual(splitPageReference("roadmap"), { namespace: "", slug: "roadmap" });
assert.deepEqual(splitPageReference("racket:roadmap"), {
namespace: "racket",
slug: "roadmap"
});
assert.equal(pageReference("", "roadmap"), "roadmap");
assert.equal(pageReference(" racket ", "roadmap"), "racket:roadmap");
});
test("freely entered page names become bounded wiki references", () => {
assert.equal(newPageReference("Racket:Résumé pagina"), "racket:resume-pagina");
assert.equal(newPageReference(" Nieuwe pagina "), "nieuwe-pagina");
assert.equal(newPageReference(""), null);
assert.equal(newPageReference(":"), null);
});
test("WikiWords resolve to existing aliases or predictable new targets", () => {
const aliases = new Map([
[":homeopathiewiki", { slug: "homeopathie", title: "Homeopathie Wiki" }]
]);
assert.deepEqual(wikiWordTarget("HomeopathieWiki", aliases), {
slug: "homeopathie",
title: "Homeopathie Wiki"
});
assert.deepEqual(wikiWordTarget("NieuwePagina", aliases, "notes"), {
slug: "notes:nieuwe-pagina",
title: "Nieuwe Pagina"
});
assert.equal(wikiWordTarget("geen-wikiword", aliases), null);
assert.equal(slugTitle("notes:nieuwe-pagina"), "Nieuwe Pagina");
});
+60
View File
@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
cmapRoute,
pageRoute,
parseWikiRoute
} from "../static/js/wiki/routes.js";
test("route builders encode references without changing their identity", () => {
assert.equal(pageRoute("notes:een pagina"), "#/notes%3Aeen%20pagina");
assert.equal(cmapRoute("een kaart"), "#cmap/een%20kaart");
});
test("page, CMap and Todo routes decode their parameters", () => {
assert.deepEqual(parseWikiRoute("#/notes%3Aeen%20pagina"), {
name: "page",
slug: "notes:een pagina"
});
assert.deepEqual(parseWikiRoute("#cmap/een%20kaart"), {
name: "cmap",
slug: "een kaart"
});
assert.deepEqual(parseWikiRoute("#todo/notes%3Aplanning/12"), {
name: "todo",
slug: "notes:planning",
number: 12
});
});
test("search and fixed special routes have explicit names", () => {
assert.deepEqual(parseWikiRoute("#search/een%20term"), {
name: "search",
query: "een term"
});
assert.deepEqual(parseWikiRoute("#profile"), { name: "profile" });
assert.deepEqual(parseWikiRoute("#cmaps"), { name: "cmaps" });
assert.deepEqual(parseWikiRoute("#recent"), { name: "recent" });
assert.deepEqual(parseWikiRoute("#bookmarks"), { name: "bookmarks" });
assert.deepEqual(parseWikiRoute("#todos"), { name: "todos" });
});
test("administration routes remain distinct for permission dispatch", () => {
assert.deepEqual(parseWikiRoute("#admin"), { name: "admin" });
assert.deepEqual(parseWikiRoute("#admin/users"), { name: "admin-users" });
assert.deepEqual(parseWikiRoute("#admin/mail"), { name: "admin-mail" });
assert.deepEqual(parseWikiRoute("#admin/aliases"), { name: "admin-aliases" });
assert.deepEqual(parseWikiRoute("#admin/orphaned-uploads"), {
name: "admin-orphaned-uploads"
});
assert.deepEqual(parseWikiRoute("#admin/archived-cmaps"), {
name: "admin-archived-cmaps"
});
});
test("empty, incomplete and unknown hashes fall back to home", () => {
assert.deepEqual(parseWikiRoute(""), { name: "home" });
assert.deepEqual(parseWikiRoute("#search/"), { name: "home" });
assert.deepEqual(parseWikiRoute("#unknown"), { name: "home" });
});
+10 -14
View File
@@ -24,14 +24,14 @@
(define english
(hash
'users "Users" 'page-aliases "Page aliases" 'no-page-aliases "No page aliases." 'old-address "Old address" 'current-address "Current address" 'referenced-by "Referenced by" 'current-references "Current references" 'historical-references "Historical references" 'cleanup-alias "Clean up references" 'cleanup-alias-confirm "Replace all current references to this old address with the current address? Historical versions will not be changed." 'cleanup-complete "Cleanup complete" 'delete-alias "Delete alias" 'delete-alias-confirm "Delete this alias?" 'delete-alias-history-confirm "Delete this alias? Historical page versions still contain this old address and those links may stop resolving." 'software-version "Software version" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'archived-concept-maps "Archived CMaps" 'no-archived-concept-maps "No archived CMaps." 'restore-concept-map "Restore CMap" 'restore-concept-map-confirm "Restore concept map \"{title}\"?" 'concept-map-restored "CMap restored." 'archived "Archived" 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'no-recent-changes "No recent changes." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Concept maps" 'concept-map-storage-note "Concept maps are stored in the wiki database." 'new-concept-map "New CMap" 'concept-map-name "Concept map name" 'concept-map-saved "CMap saved" 'no-concept-maps "No saved CMaps" 'concept-map-prototype "Concept map prototype" 'concept-map-prototype-note "In-memory prototype; changes are not saved yet." 'concept-map-help "Select a concept to show its relation and resize handles. Drag the relation handle to another concept; edit the new relation name immediately. Double-click a page concept to open the wiki page." 'add-concept "Add concept" 'add-page-node "Add current page" 'add-submap "Add sub-CMap" 'connect-selected "Connect selected" 'edit-selected-concept "Edit selected" 'reset "Reset" 'relation "Relation" 'create-relation "Create relation" 'resize-concept "Resize concept" 'select-one-concept "Select one concept first." 'label "Label" 'synopsis "Synopsis" 'background-color "Background color" 'font-family "Font family" 'font-size "Font size" 'sub-concept-map "Sub concept map" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'context-graph-direction-summary "{incoming} incoming, {outgoing} outgoing" 'popup "Popup" 'close "Close" 'dock-right "Dock right" 'dock-above "Dock above" 'open-full-graph "Open full graph" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'users "Users" 'page-aliases "Page aliases" 'no-page-aliases "No page aliases." 'old-address "Old address" 'current-address "Current address" 'referenced-by "Referenced by" 'current-references "Current references" 'historical-references "Historical references" 'cleanup-alias "Clean up references" 'cleanup-alias-confirm "Replace all current references to this old address with the current address? Historical versions will not be changed." 'cleanup-complete "Cleanup complete" 'delete-alias "Delete alias" 'delete-alias-confirm "Delete this alias?" 'delete-alias-history-confirm "Delete this alias? Historical page versions still contain this old address and those links may stop resolving." 'software-version "Software version" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'archived-concept-maps "Archived CMaps" 'no-archived-concept-maps "No archived CMaps." 'restore-concept-map "Restore CMap" 'restore-concept-map-confirm "Restore concept map \"{title}\"?" 'concept-map-restored "CMap restored." 'archived "Archived" 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'no-recent-changes "No recent changes." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'cmaps "CMaps" 'concept-maps "Concept maps" 'concept-map-storage-note "Concept maps are stored in the wiki database." 'new-concept-map "New CMap" 'concept-map-name "Concept map name" 'concept-map-saved "CMap saved" 'no-concept-maps "No saved CMaps" 'concept-map-prototype "Concept map prototype" 'concept-map-prototype-note "In-memory prototype; changes are not saved yet." 'concept-map-help "Select a concept to show its relation and resize handles. Drag the relation handle to another concept; edit the new relation name immediately. Double-click a page concept to open the wiki page." 'add-concept "Add concept" 'add-page-node "Add current page" 'add-submap "Add sub-CMap" 'connect-selected "Connect selected" 'edit-selected-concept "Edit selected" 'reset "Reset" 'relation "Relation" 'create-relation "Create relation" 'resize-concept "Resize concept" 'select-one-concept "Select one concept first." 'label "Label" 'synopsis "Synopsis" 'background-color "Background color" 'font-family "Font family" 'font-size "Font size" 'sub-concept-map "Sub concept map" 'context "Context" 'close "Close" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'database-schema-version "Database schema version"
'concept-todos "Concepts" 'concept-marked-todo "Concept marked with the TODO aspect."
'select-all "Select all" 'group-selected "Group selection as sub-CMap"
'ungroup-selected "Detach from sub-CMap" 'delete-selected "Delete selection"
'group-submap-name "Name of the main concept for the new sub-CMap"
'grouped-submap-synopsis "Grouped sub-concept map."
'navigation-graph "Navigation graph" 'click "click"
'click "click"
'cmap-help-add-selection "Add to or remove from the selection"
'copy-paste-linked-concepts "Cut/copy/paste linked concepts"
'cut-linked-concepts "Cut concept reference" 'copy-linked-concepts "Copy concept reference" 'paste-linked-concepts "Paste linked concept"
@@ -65,7 +65,7 @@
'submap-derived "Standalone view created for sub-CMap"
'hidden-concepts "Hidden concepts" 'hide-in-this-view "Hide in this view"
'show-hidden-concept "Show this concept again"
'before-submap-extraction "Before creating shared sub-CMap view" 'submap-extracted "Sub-CMap moved to a separate CMap" 'empty-submap "This sub-CMap has no concepts to move." 'submap-created-parent-unsaved "The new CMap was created, but the parent CMap still needs to be saved."
'before-submap-extraction "Before extracting sub-CMap" 'submap-extracted "Sub-CMap moved to a separate CMap" 'empty-submap "This sub-CMap has no concepts to move." 'submap-created-parent-unsaved "The new CMap was created, but the parent CMap still needs to be saved." 'submap-already-independent "This linked CMap is already independent."
'rename-concept-map "Rename CMap" 'archive-entire-concept-map "Archive entire CMap…" 'redo-action "Redo" 'save-now "Save now"
'set-start-concept-map "Use as start CMap" 'start-concept-map "Start CMap" 'start-concept-map-set "Start CMap set"
'autosave-pending "Changes waiting to be saved" 'autosaving "Saving automatically…" 'concept-map-autosaved "CMap saved automatically"
@@ -108,7 +108,7 @@
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
'back "Back" 'user-administration "User administration" 'username "Username"
'display-name "Display name" 'initial-password "Initial password" 'create-user "Create user"
'display-name "Display name" 'initial-password "Initial password" 'create-user "Create user" 'delete-user-confirm "Delete user {username}?"
'sign-in "Sign in" 'sign-out "Sign out" 'password "Password" 'profile "Profile"
'email-address "Email address" 'change-password "Change password" 'change-password-help "Leave these fields empty if you do not want to change your password."
'current-password "Current password" 'save-profile "Save profile" 'profile-saved "Profile saved." 'passwords-do-not-match "Passwords do not match."
@@ -145,20 +145,18 @@
'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen" 'raw-markdown "Raw Markdown" 'automatic-wiki-link "Automatic wiki link"
'language "Language" 'language-en "English" 'language-nl "Dutch"
'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete"
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."
'combined-wiki-graph "Wiki and CMap graph"
'combined-graph-summary "{pages} pages, {cmaps} concept maps, {links} links"))
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."))
(define dutch
(hash
'users "Gebruikers" 'page-aliases "Pagina-aliassen" 'no-page-aliases "Geen pagina-aliassen." 'old-address "Oud adres" 'current-address "Huidig adres" 'referenced-by "Gerefereerd door" 'current-references "Actuele verwijzingen" 'historical-references "Historische verwijzingen" 'cleanup-alias "Verwijzingen opschonen" 'cleanup-alias-confirm "Alle actuele verwijzingen naar dit oude adres vervangen door het huidige adres? Historische versies worden niet gewijzigd." 'cleanup-complete "Opschonen voltooid" 'delete-alias "Alias verwijderen" 'delete-alias-confirm "Deze alias verwijderen?" 'delete-alias-history-confirm "Deze alias verwijderen? Historische paginaversies bevatten dit oude adres nog en die links werken daarna mogelijk niet meer." 'software-version "Softwareversie" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'archived-concept-maps "Gearchiveerde CMaps" 'no-archived-concept-maps "Geen gearchiveerde CMaps." 'restore-concept-map "CMap herstellen" 'restore-concept-map-confirm "Conceptmap \"{title}\" herstellen?" 'concept-map-restored "CMap hersteld." 'archived "Gearchiveerd" 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'no-recent-changes "Nog geen recente wijzigingen." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Conceptmaps" 'concept-map-storage-note "Conceptmaps worden in de wikidatabase opgeslagen." 'new-concept-map "Nieuwe CMap" 'concept-map-name "Naam van de conceptmap" 'concept-map-saved "CMap opgeslagen" 'no-concept-maps "Geen opgeslagen CMaps" 'concept-map-prototype "Conceptmap-prototype" 'concept-map-prototype-note "Prototype in geheugen; wijzigingen worden nog niet opgeslagen." 'concept-map-help "Selecteer een concept om de relatie- en formaatgrepen te tonen. Sleep de relatiegreep naar een ander concept en bewerk daarna direct de relatienaam. Dubbelklik op een paginaconcept om de wikipagina te openen." 'add-concept "Concept toevoegen" 'add-page-node "Huidige pagina toevoegen" 'add-submap "Sub-CMap toevoegen" 'connect-selected "Selectie verbinden" 'edit-selected-concept "Selectie bewerken" 'reset "Opnieuw" 'relation "Relatie" 'create-relation "Relatie maken" 'resize-concept "Concept vergroten/verkleinen" 'select-one-concept "Selecteer eerst één concept." 'label "Naam" 'synopsis "Samenvatting" 'background-color "Achtergrondkleur" 'font-family "Lettertype" 'font-size "Lettergrootte" 'sub-concept-map "Sub-conceptmap" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'context-graph-direction-summary "{incoming} inkomend, {outgoing} uitgaand" 'popup "Popup" 'close "Sluiten" 'dock-right "Rechts vastzetten" 'dock-above "Boven vastzetten" 'open-full-graph "Volledig openen" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'users "Gebruikers" 'page-aliases "Pagina-aliassen" 'no-page-aliases "Geen pagina-aliassen." 'old-address "Oud adres" 'current-address "Huidig adres" 'referenced-by "Gerefereerd door" 'current-references "Actuele verwijzingen" 'historical-references "Historische verwijzingen" 'cleanup-alias "Verwijzingen opschonen" 'cleanup-alias-confirm "Alle actuele verwijzingen naar dit oude adres vervangen door het huidige adres? Historische versies worden niet gewijzigd." 'cleanup-complete "Opschonen voltooid" 'delete-alias "Alias verwijderen" 'delete-alias-confirm "Deze alias verwijderen?" 'delete-alias-history-confirm "Deze alias verwijderen? Historische paginaversies bevatten dit oude adres nog en die links werken daarna mogelijk niet meer." 'software-version "Softwareversie" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'archived-concept-maps "Gearchiveerde CMaps" 'no-archived-concept-maps "Geen gearchiveerde CMaps." 'restore-concept-map "CMap herstellen" 'restore-concept-map-confirm "Conceptmap \"{title}\" herstellen?" 'concept-map-restored "CMap hersteld." 'archived "Gearchiveerd" 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'no-recent-changes "Nog geen recente wijzigingen." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'cmaps "CMaps" 'concept-maps "Conceptmaps" 'concept-map-storage-note "Conceptmaps worden in de wikidatabase opgeslagen." 'new-concept-map "Nieuwe CMap" 'concept-map-name "Naam van de conceptmap" 'concept-map-saved "CMap opgeslagen" 'no-concept-maps "Geen opgeslagen CMaps" 'concept-map-prototype "Conceptmap-prototype" 'concept-map-prototype-note "Prototype in geheugen; wijzigingen worden nog niet opgeslagen." 'concept-map-help "Selecteer een concept om de relatie- en formaatgrepen te tonen. Sleep de relatiegreep naar een ander concept en bewerk daarna direct de relatienaam. Dubbelklik op een paginaconcept om de wikipagina te openen." 'add-concept "Concept toevoegen" 'add-page-node "Huidige pagina toevoegen" 'add-submap "Sub-CMap toevoegen" 'connect-selected "Selectie verbinden" 'edit-selected-concept "Selectie bewerken" 'reset "Opnieuw" 'relation "Relatie" 'create-relation "Relatie maken" 'resize-concept "Concept vergroten/verkleinen" 'select-one-concept "Selecteer eerst één concept." 'label "Naam" 'synopsis "Samenvatting" 'background-color "Achtergrondkleur" 'font-family "Lettertype" 'font-size "Lettergrootte" 'sub-concept-map "Sub-conceptmap" 'context "Context" 'close "Sluiten" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'database-schema-version "Databaseschemaversie"
'concept-todos "Concepten" 'concept-marked-todo "Concept met het aspect TODO."
'select-all "Alles selecteren" 'group-selected "Selectie groeperen als sub-CMap"
'ungroup-selected "Losmaken van sub-CMap" 'delete-selected "Selectie verwijderen"
'group-submap-name "Naam van het hoofdconcept van de nieuwe sub-CMap"
'grouped-submap-synopsis "Gegroepeerde sub-conceptmap."
'navigation-graph "Navigatiegraaf" 'click "klik"
'click "klik"
'cmap-help-add-selection "Aan de selectie toevoegen of eruit verwijderen"
'copy-paste-linked-concepts "Gekoppelde concepten knippen/kopiëren/plakken"
'cut-linked-concepts "Conceptverwijzing knippen" 'copy-linked-concepts "Conceptverwijzing kopiëren" 'paste-linked-concepts "Gekoppeld concept plakken"
@@ -192,7 +190,7 @@
'submap-derived "Zelfstandige weergave voor sub-CMap aangemaakt"
'hidden-concepts "Verborgen concepten" 'hide-in-this-view "In deze weergave verbergen"
'show-hidden-concept "Dit concept weer tonen"
'before-submap-extraction "Voor het maken van de gedeelde sub-CMap-weergave" 'submap-extracted "Sub-CMap verplaatst naar een aparte CMap" 'empty-submap "Deze sub-CMap bevat geen concepten om te verplaatsen." 'submap-created-parent-unsaved "De nieuwe CMap is aangemaakt, maar de bovenliggende CMap moet nog worden opgeslagen."
'before-submap-extraction "Voor het losmaken van de sub-CMap" 'submap-extracted "Sub-CMap verplaatst naar een aparte CMap" 'empty-submap "Deze sub-CMap bevat geen concepten om te verplaatsen." 'submap-created-parent-unsaved "De nieuwe CMap is aangemaakt, maar de bovenliggende CMap moet nog worden opgeslagen." 'submap-already-independent "Deze gekoppelde CMap is al zelfstandig."
'rename-concept-map "CMap hernoemen" 'archive-entire-concept-map "Volledige CMap archiveren…" 'redo-action "Opnieuw uitvoeren" 'save-now "Nu opslaan"
'set-start-concept-map "Als start-CMap gebruiken" 'start-concept-map "Start-CMap" 'start-concept-map-set "Start-CMap ingesteld"
'autosave-pending "Wijzigingen wachten op opslaan" 'autosaving "Automatisch opslaan…" 'concept-map-autosaved "CMap automatisch opgeslagen"
@@ -235,7 +233,7 @@
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam"
'display-name "Weergavenaam" 'initial-password "Initieel wachtwoord" 'create-user "Gebruiker aanmaken"
'display-name "Weergavenaam" 'initial-password "Initieel wachtwoord" 'create-user "Gebruiker aanmaken" 'delete-user-confirm "Gebruiker {username} verwijderen?"
'sign-in "Inloggen" 'sign-out "Afmelden" 'password "Wachtwoord" 'profile "Profiel"
'email-address "E-mailadres" 'change-password "Wachtwoord wijzigen" 'change-password-help "Laat deze velden leeg als u uw wachtwoord niet wilt wijzigen."
'current-password "Huidig wachtwoord" 'save-profile "Profiel opslaan" 'profile-saved "Profiel opgeslagen." 'passwords-do-not-match "De wachtwoorden komen niet overeen."
@@ -272,9 +270,7 @@
'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm" 'raw-markdown "Ruwe Markdown" 'automatic-wiki-link "Automatische wikilink"
'language "Taal" 'language-en "Engels" 'language-nl "Nederlands"
'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid"
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."
'combined-wiki-graph "Wiki- en CMap-graaf"
'combined-graph-summary "{pages} pagina's, {cmaps} conceptmaps, {links} links"))
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."))
(define (base-translations language)
(if (string-ci=? language "nl") dutch english))