refactoring of cmaps, widgets, etc.
This commit is contained in:
@@ -1,333 +0,0 @@
|
||||
/* Build a self-contained Markdown report from one stored CMap and its links. */
|
||||
((root, factory) => {
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.RacketWikiCmapExport = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis, () => {
|
||||
"use strict";
|
||||
|
||||
const labels = {
|
||||
en: {
|
||||
exportTitle: "CMap export", generated: "Generated", root: "Root CMap",
|
||||
depth: "Linked CMap depth", pagesIncluded: "Linked wiki pages included",
|
||||
yes: "yes", no: "no", cmap: "CMap", level: "level", address: "Address",
|
||||
tags: "Tags", none: "none", summary: "Summary", noSummary: "No summary supplied.",
|
||||
explanation: "CMap explanation", concepts: "Concepts", relations: "Relations",
|
||||
linkedMaps: "Linked CMaps", noConcepts: "No concepts.", noRelations: "No relations.",
|
||||
synopsis: "Summary", aspects: "Aspects", wikiPage: "Wiki page",
|
||||
conceptExplanation: "Concept explanation", linkedCmap: "Linked CMap", webPage: "Web page",
|
||||
people: "People (responsibility/action)",
|
||||
placements: "Placements", sourceView: "Derived view of", sourceMissing: "Source CMap unavailable",
|
||||
missingPage: "Page unavailable", linkedPages: "Linked wiki pages", page: "Page"
|
||||
},
|
||||
nl: {
|
||||
exportTitle: "CMap-export", generated: "Gegenereerd", root: "Start-CMap",
|
||||
depth: "Diepte gekoppelde CMaps", pagesIncluded: "Gekoppelde wikipagina's opgenomen",
|
||||
yes: "ja", no: "nee", cmap: "CMap", level: "niveau", address: "Adres",
|
||||
tags: "Tags", none: "geen", summary: "Samenvatting", noSummary: "Geen samenvatting opgegeven.",
|
||||
explanation: "CMap-uitleg", concepts: "Concepten", relations: "Relaties",
|
||||
linkedMaps: "Gekoppelde CMaps", noConcepts: "Geen concepten.", noRelations: "Geen relaties.",
|
||||
synopsis: "Samenvatting", aspects: "Aspecten", wikiPage: "Wikipagina",
|
||||
conceptExplanation: "Conceptuitleg", linkedCmap: "Gekoppelde CMap", webPage: "Webpagina",
|
||||
people: "Personen (verantwoordelijkheid/actie)",
|
||||
placements: "Plaatsingen", sourceView: "Afgeleide weergave van", sourceMissing: "Bron-CMap niet beschikbaar",
|
||||
missingPage: "Pagina niet beschikbaar", linkedPages: "Gekoppelde wikipagina's", page: "Pagina"
|
||||
}
|
||||
};
|
||||
|
||||
function decodedDocument(value) {
|
||||
let documentValue = value;
|
||||
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
||||
documentValue = JSON.parse(documentValue);
|
||||
}
|
||||
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
|
||||
documentValue : {};
|
||||
}
|
||||
|
||||
function cleanMetadata(documentValue) {
|
||||
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
|
||||
documentValue.metadata : {};
|
||||
return {
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
function itemRecords(documentValue) {
|
||||
const concepts = new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
|
||||
.filter((concept) => concept && concept.id)
|
||||
.map((concept) => [String(concept.id), concept]));
|
||||
return (Array.isArray(documentValue.items) ? documentValue.items : [])
|
||||
.filter((item) => item && item.id !== undefined)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
...(concepts.get(String(item.conceptId)) || {}),
|
||||
id: item.id,
|
||||
conceptId: item.conceptId
|
||||
}));
|
||||
}
|
||||
|
||||
function derivedDocument(sourceDocument, derivedDocumentValue) {
|
||||
const pointer = derivedDocumentValue.derivedView || {};
|
||||
const rootId = Number(pointer.rootItemId);
|
||||
const allItems = Array.isArray(sourceDocument.items) ? sourceDocument.items : [];
|
||||
const byId = new Map(allItems.map((item) => [Number(item.id), item]));
|
||||
const belongsToRoot = (item) => {
|
||||
if (Number(item.id) === rootId) return true;
|
||||
let parentId = Number(item.parentSubmapId);
|
||||
const seen = new Set();
|
||||
while (Number.isInteger(parentId) && parentId > 0 && !seen.has(parentId)) {
|
||||
if (parentId === rootId) return true;
|
||||
seen.add(parentId);
|
||||
parentId = Number(byId.get(parentId)?.parentSubmapId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const items = allItems.filter(belongsToRoot);
|
||||
const itemIds = new Set(items.map((item) => Number(item.id)));
|
||||
const conceptIds = new Set(items.map((item) => item.conceptId).filter(Boolean).map(String));
|
||||
const rootItem = itemRecords(sourceDocument).find((item) => Number(item.id) === rootId) ||
|
||||
byId.get(rootId) || {};
|
||||
const ownMetadata = cleanMetadata(derivedDocumentValue);
|
||||
const metadata = {
|
||||
tags: ownMetadata.tags.length ? ownMetadata.tags :
|
||||
(Array.isArray(rootItem.aspects) ? rootItem.aspects.map(String) : []),
|
||||
summary: ownMetadata.summary || String(rootItem.synopsis || "").trim(),
|
||||
explanationPageSlug: ownMetadata.explanationPageSlug || String(rootItem.descriptionPageSlug || "").trim()
|
||||
};
|
||||
return {
|
||||
...sourceDocument,
|
||||
metadata,
|
||||
items,
|
||||
concepts: (Array.isArray(sourceDocument.concepts) ? sourceDocument.concepts : [])
|
||||
.filter((concept) => conceptIds.has(String(concept.id))),
|
||||
connectors: (Array.isArray(sourceDocument.connectors) ? sourceDocument.connectors : [])
|
||||
.filter((connector) => itemIds.has(Number(connector.sourceId)) && itemIds.has(Number(connector.targetId)))
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMapDocument(map, loadConceptMap) {
|
||||
const ownDocument = decodedDocument(map.document);
|
||||
const pointer = ownDocument.derivedView;
|
||||
if (!pointer || !pointer.sourceCmapSlug || !Number.isInteger(Number(pointer.rootItemId))) {
|
||||
return { document: ownDocument, sourceSlug: "" };
|
||||
}
|
||||
try {
|
||||
const sourceMap = await loadConceptMap(pointer.sourceCmapSlug);
|
||||
return {
|
||||
document: derivedDocument(decodedDocument(sourceMap.document), ownDocument),
|
||||
sourceSlug: pointer.sourceCmapSlug
|
||||
};
|
||||
} catch (_error) {
|
||||
return { document: ownDocument, sourceSlug: pointer.sourceCmapSlug, sourceMissing: true };
|
||||
}
|
||||
}
|
||||
|
||||
function linkedMapSlugs(documentValue) {
|
||||
return [...new Set(itemRecords(documentValue)
|
||||
.map((item) => String(item.cmapSlug || "").trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function headingText(value) {
|
||||
return String(value || "").replace(/[\r\n]+/g, " ").replace(/#+/g, "").trim();
|
||||
}
|
||||
|
||||
function inlineText(value) {
|
||||
return String(value || "").replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]])/g, "\\$1").trim();
|
||||
}
|
||||
|
||||
function shiftHeadings(markdown, amount) {
|
||||
let fenced = false;
|
||||
return String(markdown || "").split("\n").map((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
fenced = !fenced;
|
||||
return line;
|
||||
}
|
||||
if (fenced) return line;
|
||||
return line.replace(/^(#{1,6})\s+/, (match, hashes) =>
|
||||
`${"#".repeat(Math.min(6, hashes.length + amount))} `);
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function relationLines(documentValue) {
|
||||
const items = itemRecords(documentValue);
|
||||
const byId = new Map(items.map((item) => [Number(item.id), item]));
|
||||
const connectors = (Array.isArray(documentValue.connectors) ? documentValue.connectors : [])
|
||||
.filter((connector) => connector && connector.sourceId !== undefined && connector.targetId !== undefined);
|
||||
const used = new Set();
|
||||
const result = [];
|
||||
const itemLabel = (id) => headingText(byId.get(Number(id))?.label || `[${id}]`);
|
||||
|
||||
for (const phrase of items.filter((item) => item.kind === "phrase")) {
|
||||
const incoming = connectors.filter((connector) => Number(connector.targetId) === Number(phrase.id));
|
||||
const outgoing = connectors.filter((connector) => Number(connector.sourceId) === Number(phrase.id));
|
||||
for (const before of incoming) {
|
||||
for (const after of outgoing) {
|
||||
used.add(before);
|
||||
used.add(after);
|
||||
result.push(`${itemLabel(before.sourceId)} — **${inlineText(phrase.label || "")}** → ${itemLabel(after.targetId)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const connector of connectors) {
|
||||
if (used.has(connector)) continue;
|
||||
result.push(`${itemLabel(connector.sourceId)} ${connector.hasArrow === false ? "—" : "→"} ${itemLabel(connector.targetId)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function conceptEntries(documentValue) {
|
||||
const entries = new Map();
|
||||
for (const item of itemRecords(documentValue)) {
|
||||
if (item.kind === "phrase") continue;
|
||||
const key = item.conceptId ? `concept:${item.conceptId}` : `item:${item.id}`;
|
||||
if (!entries.has(key)) entries.set(key, { ...item, placementCount: 0 });
|
||||
entries.get(key).placementCount += 1;
|
||||
}
|
||||
return [...entries.values()];
|
||||
}
|
||||
|
||||
async function generateMarkdown(options) {
|
||||
const rootMap = options.rootMap;
|
||||
if (!rootMap || !rootMap.slug) throw new Error("A root CMap is required.");
|
||||
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
|
||||
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
|
||||
const includeWikiPages = Boolean(options.includeWikiPages);
|
||||
const locale = String(options.language || "nl").toLowerCase().startsWith("nl") ? "nl" : "en";
|
||||
const t = labels[locale];
|
||||
const maps = [];
|
||||
const visited = new Set();
|
||||
|
||||
async function collectMap(map, depth) {
|
||||
if (!map?.slug || visited.has(map.slug)) return;
|
||||
visited.add(map.slug);
|
||||
const resolved = await resolveMapDocument(map, options.loadConceptMap);
|
||||
maps.push({ map, depth, ...resolved });
|
||||
if (depth >= maximumDepth) return;
|
||||
for (const slug of linkedMapSlugs(resolved.document)) {
|
||||
if (visited.has(slug)) continue;
|
||||
try {
|
||||
await collectMap(await options.loadConceptMap(slug), depth + 1);
|
||||
} catch (error) {
|
||||
maps.push({ map: { slug, title: slug }, depth: depth + 1, document: {}, loadError: error });
|
||||
visited.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
await collectMap(rootMap, 0);
|
||||
|
||||
const pageCache = new Map();
|
||||
const explanationReferences = new Set();
|
||||
async function loadPage(reference) {
|
||||
if (!reference || typeof options.loadWikiPage !== "function") return null;
|
||||
if (!pageCache.has(reference)) {
|
||||
pageCache.set(reference, Promise.resolve().then(() => options.loadWikiPage(reference))
|
||||
.catch((error) => ({ slug: reference, title: reference, loadError: error })));
|
||||
}
|
||||
return pageCache.get(reference);
|
||||
}
|
||||
|
||||
for (const entry of maps) {
|
||||
const metadata = cleanMetadata(entry.document);
|
||||
if (metadata.explanationPageSlug) {
|
||||
explanationReferences.add(metadata.explanationPageSlug);
|
||||
await loadPage(metadata.explanationPageSlug);
|
||||
}
|
||||
if (includeWikiPages) {
|
||||
for (const concept of conceptEntries(entry.document)) {
|
||||
await loadPage(concept.pageSlug);
|
||||
await loadPage(concept.descriptionPageSlug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`# ${t.exportTitle}: ${headingText(rootMap.title || rootMap.slug)}`,
|
||||
"",
|
||||
`- **${t.generated}:** ${new Date().toISOString()}`,
|
||||
`- **${t.root}:** \`cmap:${rootMap.slug}\``,
|
||||
`- **${t.depth}:** ${maximumDepth}`,
|
||||
`- **${t.pagesIncluded}:** ${includeWikiPages ? t.yes : t.no}`,
|
||||
""
|
||||
];
|
||||
|
||||
for (const entry of maps) {
|
||||
const metadata = cleanMetadata(entry.document);
|
||||
lines.push(`## ${t.cmap}: ${headingText(entry.map.title || entry.map.slug)} (${t.level} ${entry.depth})`, "");
|
||||
lines.push(`- **${t.address}:** \`cmap:${entry.map.slug}\``);
|
||||
lines.push(`- **${t.tags}:** ${metadata.tags.length ? metadata.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ") : t.none}`);
|
||||
if (entry.sourceSlug) lines.push(`- **${t.sourceView}:** \`cmap:${entry.sourceSlug}\``);
|
||||
if (entry.sourceMissing) lines.push(`- **${t.sourceMissing}:** \`cmap:${entry.sourceSlug}\``);
|
||||
if (entry.loadError) lines.push(`- **Fout:** ${inlineText(entry.loadError.message || entry.loadError)}`);
|
||||
lines.push("", `### ${t.summary}`, "", metadata.summary || t.noSummary, "");
|
||||
|
||||
if (metadata.explanationPageSlug) {
|
||||
const page = await loadPage(metadata.explanationPageSlug);
|
||||
lines.push(`### ${t.explanation}`, "", `**${t.page}:** \`${metadata.explanationPageSlug}\``, "");
|
||||
if (page?.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
|
||||
else if (page) {
|
||||
if (Array.isArray(page.tags) && page.tags.length) {
|
||||
lines.push(`**${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`, "");
|
||||
}
|
||||
lines.push(shiftHeadings(page.markdown || "", 3).trim() || t.noSummary, "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`### ${t.concepts}`, "");
|
||||
const concepts = conceptEntries(entry.document);
|
||||
if (!concepts.length) lines.push(`_${t.noConcepts}_`, "");
|
||||
for (const concept of concepts) {
|
||||
lines.push(`#### ${headingText(concept.label || concept.id || "Concept")}`, "");
|
||||
if (concept.synopsis) lines.push(`- **${t.synopsis}:** ${inlineText(concept.synopsis)}`);
|
||||
if (Array.isArray(concept.aspects) && concept.aspects.length) {
|
||||
lines.push(`- **${t.aspects}:** ${concept.aspects.map(inlineText).join(", ")}`);
|
||||
}
|
||||
const people = (Array.isArray(concept.tags) ? concept.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
|
||||
.map((tag) => tag.value);
|
||||
if (people.length) {
|
||||
lines.push(`- **${t.people}:** ${people.map(inlineText).join(", ")}`);
|
||||
}
|
||||
if (concept.pageSlug) lines.push(`- **${t.wikiPage}:** \`${inlineText(concept.pageSlug)}\``);
|
||||
if (concept.descriptionPageSlug) lines.push(`- **${t.conceptExplanation}:** \`${inlineText(concept.descriptionPageSlug)}\``);
|
||||
if (concept.cmapSlug) lines.push(`- **${t.linkedCmap}:** \`cmap:${inlineText(concept.cmapSlug)}\``);
|
||||
if (concept.externalUrl) lines.push(`- **${t.webPage}:** ${inlineText(concept.externalUrl)}`);
|
||||
if (concept.placementCount > 1) lines.push(`- **${t.placements}:** ${concept.placementCount}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`### ${t.relations}`, "");
|
||||
const relations = relationLines(entry.document);
|
||||
if (!relations.length) lines.push(`_${t.noRelations}_`, "");
|
||||
else lines.push(...relations.map((relation) => `- ${relation}`), "");
|
||||
const linked = linkedMapSlugs(entry.document);
|
||||
if (linked.length) {
|
||||
lines.push(`### ${t.linkedMaps}`, "", ...linked.map((slug) => `- \`cmap:${inlineText(slug)}\``), "");
|
||||
}
|
||||
}
|
||||
|
||||
if (includeWikiPages) {
|
||||
const pages = [];
|
||||
for (const [reference, promise] of pageCache) {
|
||||
if (explanationReferences.has(reference)) continue;
|
||||
pages.push(await promise);
|
||||
}
|
||||
if (pages.length) lines.push(`## ${t.linkedPages}`, "");
|
||||
for (const page of pages) {
|
||||
lines.push(`### ${headingText(page.title || page.slug)}`, "", `- **${t.address}:** \`${inlineText(page.slug)}\``);
|
||||
if (Array.isArray(page.tags) && page.tags.length) {
|
||||
lines.push(`- **${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
if (page.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
|
||||
else lines.push(shiftHeadings(page.markdown || "", 2).trim() || t.noSummary, "");
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
||||
}
|
||||
|
||||
return { generateMarkdown, decodedDocument, derivedDocument, relationLines };
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
/* Accessible, dependency-free combobox used by racket-wiki. */
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
class RacketWikiComboBox {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.input = root.querySelector("input");
|
||||
this.button = root.querySelector("button");
|
||||
this.list = root.querySelector('[role="listbox"]');
|
||||
this.options = [];
|
||||
this.filteredOptions = [];
|
||||
this.selectedValue = "";
|
||||
this.activeIndex = -1;
|
||||
this.selectionHandlers = [];
|
||||
|
||||
this.input.setAttribute("aria-controls", this.list.id);
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.setAttribute("aria-autocomplete", "list");
|
||||
this.input.setAttribute("autocomplete", "off");
|
||||
|
||||
this.input.addEventListener("input", () => {
|
||||
this.selectedValue = "";
|
||||
this.input.setCustomValidity("");
|
||||
this.open();
|
||||
this.renderOptions(false);
|
||||
});
|
||||
this.input.addEventListener("focus", () => {
|
||||
this.open();
|
||||
this.renderOptions(true);
|
||||
});
|
||||
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
this.input.addEventListener("blur", () => {
|
||||
window.setTimeout(() => this.close(), 100);
|
||||
});
|
||||
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
|
||||
this.button.addEventListener("click", () => {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
this.renderOptions(true);
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options, selectedValue = "") {
|
||||
this.options = options.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: String(option.label),
|
||||
description: String(option.description || "")
|
||||
}));
|
||||
const selected = this.options.find((option) => option.value === selectedValue) || null;
|
||||
this.selectedValue = selected ? selected.value : "";
|
||||
this.input.value = selected ? selected.label : "";
|
||||
this.activeIndex = -1;
|
||||
this.renderOptions();
|
||||
}
|
||||
|
||||
value() {
|
||||
const text = this.input.value.trim().toLocaleLowerCase();
|
||||
if (!text) {
|
||||
this.selectedValue = "";
|
||||
return "";
|
||||
}
|
||||
if (this.selectedValue) {
|
||||
const selected = this.options.find((option) => option.value === this.selectedValue);
|
||||
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
|
||||
}
|
||||
const selected = this.options.find((option) =>
|
||||
option.label.toLocaleLowerCase() === text ||
|
||||
option.value.toLocaleLowerCase() === text) || null;
|
||||
this.selectedValue = selected ? selected.value : "";
|
||||
return selected ? selected.value : null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selectedValue = "";
|
||||
this.input.value = "";
|
||||
this.input.setCustomValidity("");
|
||||
this.activeIndex = -1;
|
||||
this.renderOptions();
|
||||
}
|
||||
|
||||
onSelect(handler) {
|
||||
this.selectionHandlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !this.list.classList.contains("hidden");
|
||||
}
|
||||
|
||||
open() {
|
||||
this.list.classList.remove("hidden");
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.button.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.list.classList.add("hidden");
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.button.setAttribute("aria-expanded", "false");
|
||||
this.activeIndex = -1;
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
renderOptions(showAll = false) {
|
||||
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
|
||||
this.filteredOptions = this.options.filter((option) =>
|
||||
!query || option.label.toLocaleLowerCase().includes(query) ||
|
||||
option.description.toLocaleLowerCase().includes(query) ||
|
||||
option.value.toLocaleLowerCase().includes(query));
|
||||
this.list.replaceChildren();
|
||||
|
||||
for (const [index, option] of this.filteredOptions.entries()) {
|
||||
const item = document.createElement("div");
|
||||
item.id = `${this.list.id}-option-${index}`;
|
||||
item.className = "wiki-combobox-option";
|
||||
item.setAttribute("role", "option");
|
||||
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
|
||||
item.dataset.index = String(index);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wiki-combobox-option-label";
|
||||
label.textContent = option.label;
|
||||
item.append(label);
|
||||
if (option.description && option.description !== option.label) {
|
||||
const description = document.createElement("span");
|
||||
description.className = "wiki-combobox-option-description";
|
||||
description.textContent = option.description;
|
||||
item.append(description);
|
||||
}
|
||||
item.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
this.selectOption(option);
|
||||
});
|
||||
this.list.append(item);
|
||||
}
|
||||
this.updateActiveOption();
|
||||
}
|
||||
|
||||
selectOption(option) {
|
||||
this.selectedValue = option.value;
|
||||
this.input.value = option.label;
|
||||
this.input.setCustomValidity("");
|
||||
this.close();
|
||||
for (const handler of this.selectionHandlers) handler(option.value, option);
|
||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (!this.isOpen()) {
|
||||
this.open();
|
||||
this.renderOptions();
|
||||
}
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
const maximum = this.filteredOptions.length - 1;
|
||||
if (maximum < 0) return;
|
||||
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
|
||||
this.updateActiveOption();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && this.isOpen()) {
|
||||
const exactText = this.input.value.trim().toLocaleLowerCase();
|
||||
const exact = this.filteredOptions.find((option) =>
|
||||
option.label.toLocaleLowerCase() === exactText ||
|
||||
option.value.toLocaleLowerCase() === exactText) || null;
|
||||
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
|
||||
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
|
||||
if (selected) {
|
||||
event.preventDefault();
|
||||
this.selectOption(selected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveOption() {
|
||||
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
|
||||
for (const [index, element] of elements.entries()) {
|
||||
element.classList.toggle("active", index === this.activeIndex);
|
||||
}
|
||||
const active = elements[this.activeIndex];
|
||||
if (active) {
|
||||
this.input.setAttribute("aria-activedescendant", active.id);
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.RacketWikiComboBox = RacketWikiComboBox;
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
# Wiki widgets
|
||||
|
||||
This directory contains the small reusable interaction widgets used by the
|
||||
wiki. A widget manages an existing DOM structure and its browser interaction;
|
||||
it does not own wiki or CMap domain rules.
|
||||
|
||||
- `ComboBox` filters labelled options and returns their stable values.
|
||||
- `PopupMenu` positions and dismisses an action menu and provides keyboard
|
||||
navigation.
|
||||
- `TabSet` coordinates tabs, panels and keyboard focus.
|
||||
- `Tooltip` provides reusable tooltip visibility and placement.
|
||||
- `DescriptionPreview` composes `Tooltip` with asynchronous wiki-page loading.
|
||||
- `StatusField` presents persistent or temporary status messages.
|
||||
|
||||
Native buttons, inputs, selects and dialogs remain native elements. They do
|
||||
not get wrapper classes until the application has reusable behaviour for such
|
||||
a class to own. Functional dialog controllers can therefore be extracted
|
||||
separately without putting CMap or wiki rules in a generic widget.
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Manage an accessible editable combobox backed by an in-page option list.
|
||||
*
|
||||
* The existing DOM supplies the input, toggle button and listbox. This widget
|
||||
* owns filtering, keyboard navigation and conversion from displayed labels to
|
||||
* stable option values.
|
||||
*/
|
||||
export class ComboBox {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.input = root.querySelector('input[role="combobox"]');
|
||||
this.button = root.querySelector("button");
|
||||
this.list = root.querySelector('[role="listbox"]');
|
||||
this.options = [];
|
||||
this.filteredOptions = [];
|
||||
this.selectedValue = "";
|
||||
this.activeIndex = -1;
|
||||
this.selectionHandlers = [];
|
||||
|
||||
if (!this.input || !this.button || !this.list) {
|
||||
throw new Error("A ComboBox requires an input, toggle button and listbox.");
|
||||
}
|
||||
|
||||
this.input.setAttribute("aria-controls", this.list.id);
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.setAttribute("aria-autocomplete", "list");
|
||||
this.input.setAttribute("autocomplete", "off");
|
||||
this.button.setAttribute("aria-expanded", "false");
|
||||
|
||||
this.input.addEventListener("input", () => {
|
||||
this.selectedValue = "";
|
||||
this.input.setCustomValidity("");
|
||||
this.open();
|
||||
this.renderOptions(false);
|
||||
});
|
||||
this.input.addEventListener("focus", () => {
|
||||
this.open();
|
||||
this.renderOptions(true);
|
||||
});
|
||||
this.input.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
this.input.addEventListener("blur", () => {
|
||||
window.setTimeout(() => this.close(), 100);
|
||||
});
|
||||
this.button.addEventListener("pointerdown", (event) => event.preventDefault());
|
||||
this.button.addEventListener("click", () => {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
this.renderOptions(true);
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the option collection and optionally select one stable value. */
|
||||
setOptions(options, selectedValue = "") {
|
||||
this.options = options.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: String(option.label),
|
||||
description: String(option.description || "")
|
||||
}));
|
||||
const selected = this.options.find((option) => option.value === selectedValue) || null;
|
||||
this.selectedValue = selected ? selected.value : "";
|
||||
this.input.value = selected ? selected.label : "";
|
||||
this.activeIndex = -1;
|
||||
this.renderOptions();
|
||||
}
|
||||
|
||||
/** Return the selected stable value, an empty value, or null for invalid free text. */
|
||||
value() {
|
||||
const text = this.input.value.trim().toLocaleLowerCase();
|
||||
if (!text) {
|
||||
this.selectedValue = "";
|
||||
return "";
|
||||
}
|
||||
if (this.selectedValue) {
|
||||
const selected = this.options.find((option) => option.value === this.selectedValue);
|
||||
if (selected && selected.label.toLocaleLowerCase() === text) return selected.value;
|
||||
}
|
||||
const selected = this.options.find((option) =>
|
||||
option.label.toLocaleLowerCase() === text ||
|
||||
option.value.toLocaleLowerCase() === text) || null;
|
||||
this.selectedValue = selected ? selected.value : "";
|
||||
return selected ? selected.value : null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selectedValue = "";
|
||||
this.input.value = "";
|
||||
this.input.setCustomValidity("");
|
||||
this.activeIndex = -1;
|
||||
this.renderOptions();
|
||||
}
|
||||
|
||||
onSelect(handler) {
|
||||
this.selectionHandlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !this.list.classList.contains("hidden");
|
||||
}
|
||||
|
||||
open() {
|
||||
this.list.classList.remove("hidden");
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.button.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.list.classList.add("hidden");
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.button.setAttribute("aria-expanded", "false");
|
||||
this.activeIndex = -1;
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
renderOptions(showAll = false) {
|
||||
const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase();
|
||||
this.filteredOptions = this.options.filter((option) =>
|
||||
!query || option.label.toLocaleLowerCase().includes(query) ||
|
||||
option.description.toLocaleLowerCase().includes(query) ||
|
||||
option.value.toLocaleLowerCase().includes(query));
|
||||
this.list.replaceChildren();
|
||||
|
||||
for (const [index, option] of this.filteredOptions.entries()) {
|
||||
const item = document.createElement("div");
|
||||
item.id = `${this.list.id}-option-${index}`;
|
||||
item.className = "wiki-combobox-option";
|
||||
item.setAttribute("role", "option");
|
||||
item.setAttribute("aria-selected", String(option.value === this.selectedValue));
|
||||
item.dataset.index = String(index);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wiki-combobox-option-label";
|
||||
label.textContent = option.label;
|
||||
item.append(label);
|
||||
if (option.description && option.description !== option.label) {
|
||||
const description = document.createElement("span");
|
||||
description.className = "wiki-combobox-option-description";
|
||||
description.textContent = option.description;
|
||||
item.append(description);
|
||||
}
|
||||
item.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
this.selectOption(option);
|
||||
});
|
||||
this.list.append(item);
|
||||
}
|
||||
this.updateActiveOption();
|
||||
}
|
||||
|
||||
selectOption(option) {
|
||||
this.selectedValue = option.value;
|
||||
this.input.value = option.label;
|
||||
this.input.setCustomValidity("");
|
||||
this.close();
|
||||
for (const handler of this.selectionHandlers) handler(option.value, option);
|
||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (!this.isOpen()) {
|
||||
this.open();
|
||||
this.renderOptions();
|
||||
}
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
const maximum = this.filteredOptions.length - 1;
|
||||
if (maximum < 0) return;
|
||||
this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction));
|
||||
this.updateActiveOption();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && this.isOpen()) {
|
||||
const exactText = this.input.value.trim().toLocaleLowerCase();
|
||||
const exact = this.filteredOptions.find((option) =>
|
||||
option.label.toLocaleLowerCase() === exactText ||
|
||||
option.value.toLocaleLowerCase() === exactText) || null;
|
||||
const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] :
|
||||
(exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null));
|
||||
if (selected) {
|
||||
event.preventDefault();
|
||||
this.selectOption(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveOption() {
|
||||
const elements = Array.from(this.list.querySelectorAll('[role="option"]'));
|
||||
for (const [index, element] of elements.entries()) {
|
||||
element.classList.toggle("active", index === this.activeIndex);
|
||||
}
|
||||
const active = elements[this.activeIndex];
|
||||
if (active) {
|
||||
this.input.setAttribute("aria-activedescendant", active.id);
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Tooltip } from "./tooltip.js";
|
||||
|
||||
/**
|
||||
* Load and display rendered wiki-page content as a concept description.
|
||||
*
|
||||
* DescriptionPreview caches rendered pages for the lifetime of the workspace.
|
||||
* A sequence token prevents a slow request from replacing a newer preview.
|
||||
*/
|
||||
export class DescriptionPreview {
|
||||
constructor(loadPage, renderMarkdown) {
|
||||
const element = document.createElement("div");
|
||||
element.id = "cmap-description-tooltip";
|
||||
element.className = "cmap-description-tooltip hidden";
|
||||
element.setAttribute("role", "tooltip");
|
||||
document.body.append(element);
|
||||
|
||||
this.tooltip = new Tooltip(element);
|
||||
this.loadPage = loadPage;
|
||||
this.renderMarkdown = renderMarkdown;
|
||||
this.cache = new Map();
|
||||
this.sequence = 0;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.sequence += 1;
|
||||
this.tooltip.hide();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/** Load a referenced wiki page and show it beside the supplied anchor. */
|
||||
async show(anchor, reference, loadingText = "Loading…") {
|
||||
if (!anchor.classList.contains("is-filled") || !reference) return;
|
||||
const sequence = ++this.sequence;
|
||||
this.tooltip.show(anchor);
|
||||
this.tooltip.setText(loadingText);
|
||||
|
||||
try {
|
||||
let preview = this.cache.get(reference);
|
||||
if (preview === undefined) {
|
||||
const page = await this.loadPage(reference);
|
||||
preview = String(page.markdown || "").trim() ?
|
||||
this.renderMarkdown(page.markdown, page.slug) : null;
|
||||
this.cache.set(reference, preview);
|
||||
}
|
||||
if (sequence !== this.sequence) return;
|
||||
if (!preview) {
|
||||
anchor.classList.remove("is-filled");
|
||||
anchor.classList.add("is-empty");
|
||||
this.tooltip.hide();
|
||||
return;
|
||||
}
|
||||
this.tooltip.setHtml(`<article class="markdown-body">${preview}</article>`);
|
||||
} catch (error) {
|
||||
if (sequence !== this.sequence) return;
|
||||
anchor.classList.remove("is-filled");
|
||||
anchor.classList.add("is-empty");
|
||||
this.tooltip.hide();
|
||||
if (error.status !== 404) console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Present an existing menu at a viewport position.
|
||||
*
|
||||
* PopupMenu owns placement, dismissal and keyboard movement. The application
|
||||
* remains responsible for menu actions and for enabling individual items.
|
||||
*/
|
||||
export class PopupMenu {
|
||||
constructor(element, trigger = null) {
|
||||
this.element = element;
|
||||
this.trigger = trigger;
|
||||
|
||||
if (this.trigger) {
|
||||
this.trigger.setAttribute("aria-haspopup", "menu");
|
||||
this.trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
this.element.addEventListener("click", (event) => {
|
||||
if (event.target.closest('[role^="menuitem"]')) this.close();
|
||||
});
|
||||
this.element.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!this.isOpen()) return;
|
||||
if (this.element.contains(event.target)) return;
|
||||
if (this.trigger && this.trigger.contains(event.target)) return;
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !this.element.classList.contains("hidden");
|
||||
}
|
||||
|
||||
/** Open the menu at client coordinates and keep it inside the viewport. */
|
||||
openAt(clientX, clientY) {
|
||||
if (this.element.parentElement !== document.body) document.body.append(this.element);
|
||||
this.element.classList.remove("hidden");
|
||||
const left = Math.max(8, Math.min(clientX, window.innerWidth - this.element.offsetWidth - 8));
|
||||
const top = Math.max(8, Math.min(clientY, window.innerHeight - this.element.offsetHeight - 8));
|
||||
this.element.style.left = `${left}px`;
|
||||
this.element.style.top = `${top}px`;
|
||||
if (this.trigger) this.trigger.setAttribute("aria-expanded", "true");
|
||||
const first = this.items()[0];
|
||||
if (first) first.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
close() {
|
||||
const returnFocus = this.element.contains(document.activeElement);
|
||||
this.element.classList.add("hidden");
|
||||
if (this.trigger) this.trigger.setAttribute("aria-expanded", "false");
|
||||
if (returnFocus && this.trigger) this.trigger.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
items() {
|
||||
return Array.from(this.element.querySelectorAll('[role^="menuitem"]'))
|
||||
.filter((item) => !item.disabled && !item.classList.contains("hidden"));
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
const items = this.items();
|
||||
const current = items.indexOf(document.activeElement);
|
||||
let next = null;
|
||||
if (event.key === "ArrowDown") next = current < items.length - 1 ? current + 1 : 0;
|
||||
if (event.key === "ArrowUp") next = current > 0 ? current - 1 : items.length - 1;
|
||||
if (event.key === "Home") next = 0;
|
||||
if (event.key === "End") next = items.length - 1;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (next === null || !items[next]) return;
|
||||
event.preventDefault();
|
||||
items[next].focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Manage a status element and optional automatic clearing of its message. */
|
||||
export class StatusField {
|
||||
constructor(element, visibleClass = "") {
|
||||
this.element = element;
|
||||
this.visibleClass = visibleClass;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
set(message) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.element.textContent = message || "";
|
||||
if (this.visibleClass) {
|
||||
this.element.classList.toggle(this.visibleClass, Boolean(message));
|
||||
}
|
||||
}
|
||||
|
||||
showTemporarily(message, duration = 1800) {
|
||||
this.set(message);
|
||||
if (!message) return;
|
||||
this.timer = window.setTimeout(() => this.clear(), duration);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Coordinate tabs and their aria-controls panels inside one tab list.
|
||||
*
|
||||
* Tabs identify their logical name with data-tab. Selecting a tab updates the
|
||||
* ARIA state, panel visibility and roving keyboard focus.
|
||||
*/
|
||||
export class TabSet {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.tabs = Array.from(element.querySelectorAll('[role="tab"]'));
|
||||
this.selectionHandlers = [];
|
||||
|
||||
this.element.addEventListener("click", (event) => {
|
||||
const tab = event.target.closest('[role="tab"]');
|
||||
if (tab && this.element.contains(tab)) this.select(tab.dataset.tab);
|
||||
});
|
||||
this.element.addEventListener("keydown", (event) => this.handleKeydown(event));
|
||||
}
|
||||
|
||||
/** Select a named tab and show the panel named by its aria-controls value. */
|
||||
select(name) {
|
||||
for (const tab of this.tabs) {
|
||||
const selected = tab.dataset.tab === name;
|
||||
tab.setAttribute("aria-selected", String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
const panel = document.getElementById(tab.getAttribute("aria-controls"));
|
||||
if (panel) panel.classList.toggle("hidden", !selected);
|
||||
}
|
||||
for (const handler of this.selectionHandlers) handler(name);
|
||||
}
|
||||
|
||||
onSelect(handler) {
|
||||
this.selectionHandlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
const current = this.tabs.indexOf(event.target.closest('[role="tab"]'));
|
||||
if (current < 0) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? this.tabs.length - 1 :
|
||||
(current + (event.key === "ArrowRight" ? 1 : -1) + this.tabs.length) % this.tabs.length;
|
||||
this.select(this.tabs[next].dataset.tab);
|
||||
this.tabs[next].focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Manage the content, visibility and viewport placement of one tooltip. */
|
||||
export class Tooltip {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.anchor = null;
|
||||
}
|
||||
|
||||
setText(text) {
|
||||
this.element.textContent = text;
|
||||
this.place();
|
||||
}
|
||||
|
||||
setHtml(html) {
|
||||
this.element.innerHTML = html;
|
||||
this.place();
|
||||
}
|
||||
|
||||
show(anchor) {
|
||||
this.anchor = anchor;
|
||||
anchor.setAttribute("aria-describedby", this.element.id);
|
||||
this.element.classList.remove("hidden");
|
||||
this.place();
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.anchor) this.anchor.removeAttribute("aria-describedby");
|
||||
this.anchor = null;
|
||||
this.element.classList.add("hidden");
|
||||
}
|
||||
|
||||
place() {
|
||||
if (!this.anchor || this.element.classList.contains("hidden")) return;
|
||||
const rect = this.anchor.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - this.element.offsetWidth - 8));
|
||||
let top = rect.bottom + 8;
|
||||
if (top + this.element.offsetHeight > window.innerHeight - 8) {
|
||||
top = Math.max(8, rect.top - this.element.offsetHeight - 8);
|
||||
}
|
||||
this.element.style.left = `${left}px`;
|
||||
this.element.style.top = `${top}px`;
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -25,6 +25,7 @@ import { loadAdminOverview } from "./wiki/admin/overview.js";
|
||||
import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js";
|
||||
import { UserAdmin } from "./wiki/admin/user-admin.js";
|
||||
import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
import { ComboBox } from "./widgets/combobox.js";
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
@@ -53,7 +54,6 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
siteTitle: "Racket Wiki",
|
||||
bookmarks: [],
|
||||
conceptMaps: [],
|
||||
people: [],
|
||||
cmapConceptUsage: new Map(),
|
||||
cmapConceptIdsByName: new Map(),
|
||||
cmapPageConcepts: new Map(),
|
||||
@@ -63,7 +63,7 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
cmapSavedSnapshot: null,
|
||||
cmapGuardHash: "",
|
||||
cmapPrototype: null,
|
||||
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
|
||||
rawMarkdown: false
|
||||
};
|
||||
|
||||
let easyMDE = null;
|
||||
@@ -75,7 +75,7 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox"));
|
||||
const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox"));
|
||||
const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage);
|
||||
const cmapWorkspace = new CmapWorkspace(
|
||||
state,
|
||||
@@ -430,7 +430,6 @@ import { CmapWorkspace } from "./wiki/cmap/workspace.js";
|
||||
|
||||
function toggleRawMarkdown() {
|
||||
state.rawMarkdown = !state.rawMarkdown;
|
||||
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
|
||||
applyRawMarkdownMode();
|
||||
easyMDE.codemirror.refresh();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { pageReference, newPageReference, splitPageReference } from "../../reference.js";
|
||||
import { pageRoute } from "../../routes.js";
|
||||
import { ComboBox } from "../../../widgets/combobox.js";
|
||||
import { TabSet } from "../../../widgets/tab-set.js";
|
||||
|
||||
/**
|
||||
* Edit the content and placement-specific appearance of a CMap concept.
|
||||
*
|
||||
* The dialog owns its fields, tabs, image reader and input validation. It
|
||||
* returns normalized values to the workspace, which performs the actual CMap
|
||||
* editor transaction and persistence.
|
||||
*/
|
||||
export class CmapConceptDialog {
|
||||
constructor(dialog, tr, appearanceEditor, peopleDialog, normalizeExternalUrl) {
|
||||
this.dialog = dialog;
|
||||
this.tr = tr;
|
||||
this.appearanceEditor = appearanceEditor;
|
||||
this.peopleDialog = peopleDialog;
|
||||
this.normalizeExternalUrl = normalizeExternalUrl;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.pageCombobox = new ComboBox(dialog.querySelector("#cmap-concept-page-combobox"));
|
||||
this.cmapCombobox = new ComboBox(dialog.querySelector("#cmap-concept-cmap-combobox"));
|
||||
this.tabs = new TabSet(dialog.querySelector(".cmap-concept-tabs"))
|
||||
.onSelect(() => {
|
||||
dialog.querySelector(".cmap-concept-dialog-body").scrollTop = 0;
|
||||
});
|
||||
this.record = null;
|
||||
this.createContext = null;
|
||||
this.imageSource = "";
|
||||
this.imageRead = Promise.resolve();
|
||||
this.saveHandler = null;
|
||||
|
||||
this.installEvents();
|
||||
}
|
||||
|
||||
onSave(handler) {
|
||||
this.saveHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this.dialog.open;
|
||||
}
|
||||
|
||||
/** Open the dialog for an existing concept or sub-CMap placement. */
|
||||
open(record, resources) {
|
||||
if (!record || record.kind === "phrase") return;
|
||||
this.record = record;
|
||||
this.createContext = null;
|
||||
this.imageSource = record.imageSource || "";
|
||||
this.imageRead = Promise.resolve();
|
||||
this.dialog.querySelector("#cmap-concept-dialog-title").textContent =
|
||||
this.tr("edit-concept", "Edit concept");
|
||||
this.dialog.querySelector("#cmap-concept-label").value = record.label || "";
|
||||
this.dialog.querySelector("#cmap-concept-synopsis").value = record.synopsis || "";
|
||||
this.dialog.querySelector("#cmap-concept-aspects").value = (record.aspects || []).join(", ");
|
||||
const selectedPeople = (Array.isArray(record.tags) ? record.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person")
|
||||
.map((tag) => tag.value);
|
||||
this.peopleDialog.showSelection(selectedPeople);
|
||||
this.setLinkFields(record, resources);
|
||||
this.appearanceEditor.showRecord(record);
|
||||
this.dialog.querySelector("#cmap-concept-image").value = "";
|
||||
this.updateImagePreview();
|
||||
this.tabs.select("content");
|
||||
this.dialog.showModal();
|
||||
const label = this.dialog.querySelector("#cmap-concept-label");
|
||||
label.focus();
|
||||
label.select();
|
||||
}
|
||||
|
||||
/** Open the dialog for a new concept at the supplied editor context. */
|
||||
openNew(createContext, resources) {
|
||||
this.record = null;
|
||||
this.createContext = createContext;
|
||||
this.imageSource = "";
|
||||
this.imageRead = Promise.resolve();
|
||||
this.dialog.querySelector("#cmap-concept-dialog-title").textContent =
|
||||
this.tr("add-concept", "Add concept");
|
||||
this.dialog.querySelector("#cmap-concept-label").value = "New concept";
|
||||
this.dialog.querySelector("#cmap-concept-synopsis").value = "";
|
||||
this.dialog.querySelector("#cmap-concept-aspects").value = "";
|
||||
this.peopleDialog.showSelection([]);
|
||||
this.setLinkFields({ kind: "concept", pageSlug: null, cmapSlug: null }, resources);
|
||||
this.appearanceEditor.showNewConcept();
|
||||
this.dialog.querySelector("#cmap-concept-image").value = "";
|
||||
this.updateImagePreview();
|
||||
this.tabs.select("content");
|
||||
this.dialog.showModal();
|
||||
const label = this.dialog.querySelector("#cmap-concept-label");
|
||||
label.focus();
|
||||
label.select();
|
||||
}
|
||||
|
||||
setLinkFields(record, resources) {
|
||||
const description = this.dialog.querySelector("#cmap-concept-description-page");
|
||||
const descriptionLink = this.dialog.querySelector("#cmap-concept-description-link");
|
||||
description.value = record.descriptionPageSlug || "";
|
||||
descriptionLink.href = record.descriptionPageSlug ? pageRoute(record.descriptionPageSlug) : "#";
|
||||
descriptionLink.classList.toggle("hidden", !record.descriptionPageSlug);
|
||||
const externalUrl = this.dialog.querySelector("#cmap-concept-external-url");
|
||||
externalUrl.value = record.externalUrl || "";
|
||||
externalUrl.setCustomValidity("");
|
||||
this.populatePageOptions(record, resources.pages);
|
||||
this.populateCmapOptions(record, resources.conceptMaps, resources.parentMapAvailable);
|
||||
}
|
||||
|
||||
populatePageOptions(record, pages) {
|
||||
const sorted = [...pages].sort((a, b) => a.title.localeCompare(b.title));
|
||||
const entries = sorted.map((page) => this.comboboxEntry(page));
|
||||
if (record.pageSlug && !sorted.some((page) => page.slug === record.pageSlug)) {
|
||||
entries.push({ value: record.pageSlug, label: record.pageSlug });
|
||||
}
|
||||
this.pageCombobox.setOptions(entries, record.pageSlug || "");
|
||||
this.dialog.querySelector("#cmap-concept-page-row")
|
||||
.classList.toggle("hidden", record.kind === "submap");
|
||||
}
|
||||
|
||||
populateCmapOptions(record, conceptMaps, parentMapAvailable) {
|
||||
const entries = [];
|
||||
if (parentMapAvailable) {
|
||||
entries.push({
|
||||
value: "__parent__",
|
||||
label: `↩ ${this.tr("parent-concept-map", "Parent concept map")}`
|
||||
});
|
||||
}
|
||||
for (const conceptMap of [...conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) {
|
||||
entries.push(this.comboboxEntry(conceptMap));
|
||||
}
|
||||
if (record.cmapSlug && !conceptMaps.some((item) => item.slug === record.cmapSlug)) {
|
||||
entries.push({ value: record.cmapSlug, label: record.cmapSlug });
|
||||
}
|
||||
const selected = record.parentCmapLink ? "__parent__" : (record.cmapSlug || "");
|
||||
this.cmapCombobox.setOptions(entries, selected);
|
||||
this.dialog.querySelector("#cmap-concept-cmap-row")
|
||||
.classList.toggle("hidden", record.kind === "submap");
|
||||
}
|
||||
|
||||
comboboxEntry(record) {
|
||||
return {
|
||||
value: record.slug,
|
||||
label: record.title || record.slug,
|
||||
description: record.slug
|
||||
};
|
||||
}
|
||||
|
||||
updateImagePreview() {
|
||||
const row = this.dialog.querySelector("#cmap-concept-image-preview-row");
|
||||
const preview = this.dialog.querySelector("#cmap-concept-image-preview");
|
||||
if (!this.imageSource) {
|
||||
row.classList.add("hidden");
|
||||
preview.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
preview.src = this.imageSource;
|
||||
row.classList.remove("hidden");
|
||||
}
|
||||
|
||||
readImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true });
|
||||
reader.addEventListener("error", () => reject(
|
||||
reader.error || new Error("Image could not be read.")), { once: true });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate the form and return normalized concept values, or null on error. */
|
||||
values() {
|
||||
const labelInput = this.dialog.querySelector("#cmap-concept-label");
|
||||
const label = labelInput.value.trim();
|
||||
if (!label) {
|
||||
this.tabs.select("content");
|
||||
labelInput.focus();
|
||||
return null;
|
||||
}
|
||||
const record = this.record;
|
||||
const pageInput = this.dialog.querySelector("#cmap-concept-page");
|
||||
const cmapInput = this.dialog.querySelector("#cmap-concept-cmap");
|
||||
const selectedPage = record && record.kind === "submap" ? "" : this.pageCombobox.value();
|
||||
const selectedCmap = record && record.kind === "submap" ? "" : this.cmapCombobox.value();
|
||||
const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage;
|
||||
if (selectedPage === null && !linkedPage) {
|
||||
this.tabs.select("content");
|
||||
pageInput.setCustomValidity(
|
||||
this.tr("invalid-new-page", "Enter a page title or valid wiki address."));
|
||||
pageInput.reportValidity();
|
||||
return null;
|
||||
}
|
||||
if (selectedCmap === null) {
|
||||
this.tabs.select("content");
|
||||
cmapInput.setCustomValidity(this.tr(
|
||||
"select-listed-concept-map", "Select a CMap from the list or clear the field."));
|
||||
cmapInput.reportValidity();
|
||||
return null;
|
||||
}
|
||||
const externalInput = this.dialog.querySelector("#cmap-concept-external-url");
|
||||
const externalUrl = this.normalizeExternalUrl(externalInput.value);
|
||||
if (externalUrl === null) {
|
||||
this.tabs.select("content");
|
||||
externalInput.setCustomValidity(this.tr(
|
||||
"invalid-external-web-page", "Enter a complete http or https web address."));
|
||||
externalInput.reportValidity();
|
||||
externalInput.focus();
|
||||
return null;
|
||||
}
|
||||
const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page");
|
||||
const descriptionText = descriptionInput.value.trim();
|
||||
const descriptionPage = descriptionText ? newPageReference(descriptionText) :
|
||||
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
|
||||
if (!descriptionPage) {
|
||||
this.tabs.select("content");
|
||||
descriptionInput.setCustomValidity(
|
||||
this.tr("invalid-description-page", "Enter a valid description page address."));
|
||||
descriptionInput.reportValidity();
|
||||
return null;
|
||||
}
|
||||
const linkedCmapValue = selectedCmap || "";
|
||||
return {
|
||||
label,
|
||||
synopsis: this.dialog.querySelector("#cmap-concept-synopsis").value,
|
||||
aspects: this.dialog.querySelector("#cmap-concept-aspects").value.split(",")
|
||||
.map((aspect) => aspect.trim()).filter(Boolean),
|
||||
personNames: this.peopleDialog.selectedNames(),
|
||||
descriptionPageSlug: descriptionPage,
|
||||
pageSlug: linkedPage || null,
|
||||
cmapSlug: linkedCmapValue && linkedCmapValue !== "__parent__" ? linkedCmapValue : null,
|
||||
externalUrl: externalUrl || null,
|
||||
parentCmapLink: linkedCmapValue === "__parent__",
|
||||
imageSource: this.imageSource,
|
||||
appearance: this.appearanceEditor.placementChanges(
|
||||
Boolean(record && record.kind === "submap"))
|
||||
};
|
||||
}
|
||||
|
||||
installEvents() {
|
||||
this.dialog.querySelector("#cmap-concept-page").addEventListener("change", () => {
|
||||
if (this.pageCombobox.value()) this.cmapCombobox.clear();
|
||||
});
|
||||
this.dialog.querySelector("#cmap-concept-cmap").addEventListener("change", () => {
|
||||
if (this.cmapCombobox.value()) this.pageCombobox.clear();
|
||||
});
|
||||
for (const id of ["#cmap-concept-page", "#cmap-concept-cmap"]) {
|
||||
this.dialog.querySelector(id).addEventListener("input", (event) =>
|
||||
event.target.setCustomValidity(""));
|
||||
}
|
||||
this.dialog.querySelector("#cmap-concept-description-page")
|
||||
.addEventListener("input", (event) => event.target.setCustomValidity(""));
|
||||
this.dialog.querySelector("#cmap-concept-external-url")
|
||||
.addEventListener("input", (event) => event.target.setCustomValidity(""));
|
||||
this.dialog.querySelector("#cmap-concept-description-link")
|
||||
.addEventListener("click", () => this.dialog.close());
|
||||
this.dialog.querySelector("#cmap-concept-cancel")
|
||||
.addEventListener("click", () => this.dialog.close());
|
||||
this.dialog.querySelector("#cmap-concept-image").addEventListener("change", (event) => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (!file) return;
|
||||
const record = this.record;
|
||||
this.imageRead = this.readImage(file)
|
||||
.then((imageSource) => {
|
||||
if (this.record !== record) return;
|
||||
this.imageSource = imageSource;
|
||||
this.updateImagePreview();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
window.alert(error.message);
|
||||
});
|
||||
});
|
||||
this.dialog.querySelector("#cmap-concept-image-remove").addEventListener("click", () => {
|
||||
this.imageSource = "";
|
||||
this.imageRead = Promise.resolve();
|
||||
this.dialog.querySelector("#cmap-concept-image").value = "";
|
||||
this.updateImagePreview();
|
||||
});
|
||||
this.form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await this.imageRead;
|
||||
const values = this.values();
|
||||
if (!values || !this.saveHandler) return;
|
||||
const saved = await this.saveHandler(this.record, this.createContext, values);
|
||||
if (saved) this.dialog.close();
|
||||
});
|
||||
this.dialog.addEventListener("close", () => {
|
||||
this.record = null;
|
||||
this.createContext = null;
|
||||
this.imageSource = "";
|
||||
this.imageRead = Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { StatusField } from "../../../widgets/status-field.js";
|
||||
|
||||
/**
|
||||
* Run the user-facing Markdown and JSON export actions for one CMap.
|
||||
*
|
||||
* Export generation is supplied by the workspace. This controller owns export
|
||||
* options, progress messages, clipboard handling and browser downloads.
|
||||
*/
|
||||
export class CmapExportDialog {
|
||||
constructor(dialog, tr, buildMarkdown, buildJson) {
|
||||
this.dialog = dialog;
|
||||
this.tr = tr;
|
||||
this.buildMarkdown = buildMarkdown;
|
||||
this.buildJson = buildJson;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.depth = dialog.querySelector("#cmap-export-depth");
|
||||
this.pages = dialog.querySelector("#cmap-export-pages");
|
||||
this.status = new StatusField(dialog.querySelector("#cmap-export-status"));
|
||||
this.conceptMapSlug = "concept-map";
|
||||
|
||||
dialog.querySelector("#cmap-export-cancel").addEventListener("click", () => dialog.close());
|
||||
dialog.querySelector("#cmap-export-copy").addEventListener("click", () => {
|
||||
this.exportMarkdown(false);
|
||||
});
|
||||
dialog.querySelector("#cmap-export-json").addEventListener("click", () => {
|
||||
this.exportJson();
|
||||
});
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.exportMarkdown(true);
|
||||
});
|
||||
}
|
||||
|
||||
open(conceptMapSlug) {
|
||||
this.conceptMapSlug = conceptMapSlug;
|
||||
this.status.clear();
|
||||
this.dialog.showModal();
|
||||
this.depth.focus();
|
||||
}
|
||||
|
||||
options() {
|
||||
return {
|
||||
depth: Math.max(0, Math.min(10, Number(this.depth.value) || 0)),
|
||||
includeWikiPages: this.pages.checked
|
||||
};
|
||||
}
|
||||
|
||||
async exportJson() {
|
||||
this.status.set(this.tr("preparing-cmap-json", "Preparing complete CMap JSON…"));
|
||||
try {
|
||||
const bundle = await this.buildJson(this.options().depth);
|
||||
this.download(`${JSON.stringify(bundle, null, 2)}\n`,
|
||||
`${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8");
|
||||
this.status.set(this.tr("cmap-json-downloaded", "CMap JSON downloaded."));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.status.set(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exportMarkdown(download) {
|
||||
this.status.set(this.tr("preparing-markdown-export", "Preparing Markdown export…"));
|
||||
try {
|
||||
const markdown = await this.buildMarkdown(this.options());
|
||||
if (download) {
|
||||
this.download(markdown, `${this.conceptMapSlug}-report.md`,
|
||||
"text/markdown;charset=utf-8");
|
||||
this.status.set(this.tr("markdown-export-downloaded", "Markdown export downloaded."));
|
||||
} else {
|
||||
await this.copy(markdown);
|
||||
this.status.set(this.tr("markdown-export-copied", "Markdown export copied."));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.status.set(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
download(text, filename, contentType) {
|
||||
const blob = new Blob([text], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
async copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (_error) {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = text;
|
||||
input.style.position = "fixed";
|
||||
input.style.left = "-10000px";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Present and maintain the stored history of one CMap.
|
||||
*
|
||||
* History records come from CmapRepository. Loading a version is delegated to
|
||||
* the workspace because it controls unsaved-change transitions and the editor.
|
||||
*/
|
||||
export class CmapHistoryDialog {
|
||||
constructor(dialog, repository, tr, pageDisplayDate, can, loadVersion, showStatus) {
|
||||
this.dialog = dialog;
|
||||
this.repository = repository;
|
||||
this.tr = tr;
|
||||
this.pageDisplayDate = pageDisplayDate;
|
||||
this.can = can;
|
||||
this.loadVersion = loadVersion;
|
||||
this.showStatus = showStatus;
|
||||
this.list = dialog.querySelector("#cmap-history-list");
|
||||
|
||||
dialog.querySelector("#cmap-history-close").addEventListener("click", () => dialog.close());
|
||||
}
|
||||
|
||||
async open(conceptMap) {
|
||||
const versions = await this.repository.history(conceptMap);
|
||||
this.list.replaceChildren();
|
||||
if (!versions.length) this.showEmptyMessage();
|
||||
for (const version of versions) this.list.append(this.versionRow(conceptMap, version));
|
||||
this.dialog.showModal();
|
||||
}
|
||||
|
||||
showEmptyMessage() {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "muted cmap-history-empty";
|
||||
empty.textContent = this.tr(
|
||||
"no-concept-map-history", "No snapshots or manual saves yet.");
|
||||
this.list.append(empty);
|
||||
}
|
||||
|
||||
versionRow(conceptMap, version) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmap-history-row";
|
||||
const label = document.createElement("div");
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = `${this.tr("version", "Version")} ${version.version} — ${version.title}`;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "muted";
|
||||
meta.textContent = `${this.pageDisplayDate(version.createdAt)} · ${version.author} · ${this.versionSummary(version)}`;
|
||||
label.append(heading, document.createElement("br"), meta);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "cmap-history-actions";
|
||||
actions.append(this.loadButton(conceptMap, version));
|
||||
if (this.can("editor")) actions.append(this.deleteButton(conceptMap, version, row));
|
||||
row.append(label, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
versionSummary(version) {
|
||||
const knownSummaries = {
|
||||
create: this.tr("concept-map-created-version", "CMap created"),
|
||||
rename: this.tr("concept-map-renamed-version", "CMap renamed")
|
||||
};
|
||||
let summary = knownSummaries[version.action] || version.summary;
|
||||
if (version.action === "snapshot") {
|
||||
const snapshotLabel = this.tr("snapshot", "Snapshot");
|
||||
if (version.summary === "Current state when CMap history was enabled") {
|
||||
summary = this.tr("concept-map-initial-version", "Initial available version");
|
||||
} else {
|
||||
summary = version.summary === snapshotLabel ?
|
||||
snapshotLabel : `${snapshotLabel} — ${version.summary}`;
|
||||
}
|
||||
}
|
||||
if (version.summary === "Automatic save") {
|
||||
summary = this.tr("automatic-save", "Automatic save");
|
||||
}
|
||||
if (version.summary === "Manual save") summary = this.tr("manual-save", "Manual save");
|
||||
return summary;
|
||||
}
|
||||
|
||||
loadButton(conceptMap, version) {
|
||||
const load = document.createElement("button");
|
||||
load.type = "button";
|
||||
load.textContent = version.version === conceptMap.currentVersion ?
|
||||
this.tr("current-version", "Current") : this.tr("load-version", "Load version");
|
||||
load.disabled = version.version === conceptMap.currentVersion;
|
||||
load.addEventListener("click", () => {
|
||||
this.dialog.close();
|
||||
this.loadVersion(version.version);
|
||||
});
|
||||
return load;
|
||||
}
|
||||
|
||||
deleteButton(conceptMap, version, row) {
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = this.tr("delete-history-item", "Delete");
|
||||
remove.addEventListener("click", async () => {
|
||||
const question = this.tr(
|
||||
"delete-concept-map-history-confirm",
|
||||
"Delete CMap history version {version}? The current CMap will not be changed.")
|
||||
.replace("{version}", String(version.version));
|
||||
if (!window.confirm(question)) return;
|
||||
remove.disabled = true;
|
||||
try {
|
||||
await this.repository.deleteVersion(conceptMap, version.version);
|
||||
row.remove();
|
||||
if (!this.list.querySelector(".cmap-history-row")) this.showEmptyMessage();
|
||||
this.showStatus(
|
||||
this.tr("concept-map-history-deleted", "History item deleted"), true);
|
||||
} catch (error) {
|
||||
remove.disabled = false;
|
||||
this.showStatus(error.message);
|
||||
}
|
||||
});
|
||||
return remove;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Edit CMap metadata without knowing how the active CMap is persisted.
|
||||
*
|
||||
* The controller owns field population and validation. Its save handler
|
||||
* receives normalized metadata and reports success with a boolean result.
|
||||
*/
|
||||
export class CmapMetadataDialog {
|
||||
constructor(dialog, tr, normalizePageReference) {
|
||||
this.dialog = dialog;
|
||||
this.tr = tr;
|
||||
this.normalizePageReference = normalizePageReference;
|
||||
this.form = dialog.querySelector("form");
|
||||
this.summary = dialog.querySelector("#cmap-metadata-summary");
|
||||
this.tags = dialog.querySelector("#cmap-metadata-tags");
|
||||
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
|
||||
this.saveHandler = null;
|
||||
|
||||
dialog.querySelector("#cmap-metadata-cancel")
|
||||
.addEventListener("click", () => dialog.close());
|
||||
this.explanationPage.addEventListener("input", () =>
|
||||
this.explanationPage.setCustomValidity(""));
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.save().catch((error) => window.alert(error.message));
|
||||
});
|
||||
}
|
||||
|
||||
onSave(handler) {
|
||||
this.saveHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
open(metadata) {
|
||||
this.summary.value = metadata.summary || "";
|
||||
this.tags.value = (metadata.tags || []).join(", ");
|
||||
this.explanationPage.value = metadata.explanationPageSlug || "";
|
||||
this.explanationPage.setCustomValidity("");
|
||||
this.dialog.showModal();
|
||||
this.summary.focus();
|
||||
}
|
||||
|
||||
metadata() {
|
||||
const explanationInput = this.explanationPage.value.trim();
|
||||
const explanationPageSlug = explanationInput ?
|
||||
this.normalizePageReference(explanationInput) : "";
|
||||
if (explanationInput && !explanationPageSlug) {
|
||||
this.explanationPage.setCustomValidity(
|
||||
this.tr("invalid-description-page", "Enter a valid description page address."));
|
||||
this.explanationPage.reportValidity();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
summary: this.summary.value.trim(),
|
||||
explanationPageSlug
|
||||
};
|
||||
}
|
||||
|
||||
async save() {
|
||||
const metadata = this.metadata();
|
||||
if (!metadata || !this.saveHandler) return false;
|
||||
const saved = await this.saveHandler(metadata);
|
||||
if (saved) this.dialog.close();
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Manage the shared people list used by the concept picker and people dialog.
|
||||
*
|
||||
* The controller loads and updates people through the wiki API, renders both
|
||||
* views from the same collection and exposes only the selected person names to
|
||||
* the concept editor.
|
||||
*/
|
||||
export class CmapPeopleDialog {
|
||||
constructor(dialog, picker, repository, tr) {
|
||||
this.dialog = dialog;
|
||||
this.picker = picker;
|
||||
this.repository = repository;
|
||||
this.tr = tr;
|
||||
this.people = [];
|
||||
this.options = picker.querySelector("#cmap-person-tag-options");
|
||||
this.managementList = dialog.querySelector("#cmap-people-list");
|
||||
this.conceptInput = picker.querySelector("#cmap-person-new-name");
|
||||
this.managementInput = dialog.querySelector("#cmap-people-new-name");
|
||||
|
||||
picker.querySelector("#cmap-person-add").addEventListener("click", () => {
|
||||
this.createPerson(this.conceptInput, true)
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
this.conceptInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
this.createPerson(this.conceptInput, true)
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
dialog.querySelector("#cmap-people-add").addEventListener("click", () => {
|
||||
this.createPerson(this.managementInput, false)
|
||||
.then(() => this.renderManagement())
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
this.managementInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
this.createPerson(this.managementInput, false)
|
||||
.then(() => this.renderManagement())
|
||||
.catch((error) => window.alert(error.message));
|
||||
});
|
||||
this.options.addEventListener("change", () => this.renderOptions(this.selectedNames()));
|
||||
dialog.querySelector("#cmap-people-close").addEventListener("click", () => dialog.close());
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.people = await this.repository.all();
|
||||
return this.people;
|
||||
}
|
||||
|
||||
selectedNames() {
|
||||
return Array.from(this.options.querySelectorAll("input[type='checkbox']:checked"))
|
||||
.map((input) => input.dataset.personName)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
showSelection(selectedNames = []) {
|
||||
this.renderOptions(selectedNames);
|
||||
this.picker.open = false;
|
||||
}
|
||||
|
||||
/** Render the picker while retaining inactive or missing selected people. */
|
||||
renderOptions(selectedNames = []) {
|
||||
const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase()));
|
||||
this.options.replaceChildren();
|
||||
const visiblePeople = this.people.filter((person) =>
|
||||
person.active || selected.has(String(person.name).toLocaleLowerCase()));
|
||||
for (const person of visiblePeople) {
|
||||
this.options.append(this.personOption(person.name, person.active, selected));
|
||||
}
|
||||
|
||||
const knownNames = new Set(
|
||||
this.people.map((person) => String(person.name).toLocaleLowerCase()));
|
||||
for (const selectedName of selectedNames) {
|
||||
if (!knownNames.has(String(selectedName).toLocaleLowerCase())) {
|
||||
this.options.append(this.personOption(selectedName, false, selected));
|
||||
}
|
||||
}
|
||||
if (!this.options.childElementCount) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "muted";
|
||||
empty.textContent = this.tr("no-active-people", "No active people yet.");
|
||||
this.options.append(empty);
|
||||
}
|
||||
|
||||
const summary = this.picker.querySelector("summary");
|
||||
summary.textContent = selected.size ?
|
||||
this.tr("people-selected", "{count} people selected")
|
||||
.replace("{count}", String(selected.size)) :
|
||||
this.tr("select-people", "Select people");
|
||||
}
|
||||
|
||||
personOption(name, active, selected) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "cmap-person-tag-option";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.dataset.personName = name;
|
||||
checkbox.checked = selected.has(String(name).toLocaleLowerCase());
|
||||
const text = document.createElement("span");
|
||||
text.textContent = active ? name : `${name} (${this.tr("inactive", "inactive")})`;
|
||||
label.append(checkbox, text);
|
||||
return label;
|
||||
}
|
||||
|
||||
async createPerson(input, selectInConceptDialog) {
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
input.focus();
|
||||
return null;
|
||||
}
|
||||
const selected = selectInConceptDialog ? this.selectedNames() : [];
|
||||
const person = await this.repository.create(name);
|
||||
input.value = "";
|
||||
await this.load();
|
||||
if (selectInConceptDialog) this.renderOptions([...selected, person.name]);
|
||||
return person;
|
||||
}
|
||||
|
||||
renderManagement() {
|
||||
this.managementList.replaceChildren();
|
||||
for (const person of this.people) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmap-person-admin-row";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = person.name;
|
||||
const activeLabel = document.createElement("label");
|
||||
const active = document.createElement("input");
|
||||
active.type = "checkbox";
|
||||
active.checked = Boolean(person.active);
|
||||
const activeText = document.createElement("span");
|
||||
activeText.textContent = this.tr("active", "Active");
|
||||
activeLabel.append(active, activeText);
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = this.tr("save", "Save");
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
await this.repository.update(person, active.checked);
|
||||
await this.load();
|
||||
this.renderManagement();
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(name, activeLabel, save);
|
||||
this.managementList.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.load();
|
||||
this.renderManagement();
|
||||
this.dialog.showModal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Ask how pending CMap changes should be handled before navigation.
|
||||
*
|
||||
* Each call to choose resolves once with "save", "discard" or "cancel".
|
||||
* Saving and discarding remain workspace responsibilities because they change
|
||||
* the active editor and persistence state.
|
||||
*/
|
||||
export class CmapUnsavedDialog {
|
||||
constructor(dialog) {
|
||||
this.dialog = dialog;
|
||||
this.resolveChoice = null;
|
||||
|
||||
dialog.querySelector("#cmap-unsaved-save")
|
||||
.addEventListener("click", () => this.finish("save"));
|
||||
dialog.querySelector("#cmap-unsaved-discard")
|
||||
.addEventListener("click", () => this.finish("discard"));
|
||||
dialog.querySelector("#cmap-unsaved-cancel")
|
||||
.addEventListener("click", () => this.finish("cancel"));
|
||||
dialog.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
this.finish("cancel");
|
||||
});
|
||||
}
|
||||
|
||||
choose() {
|
||||
if (this.resolveChoice) return Promise.resolve("cancel");
|
||||
this.dialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
this.resolveChoice = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
chooseSave() {
|
||||
this.finish("save");
|
||||
}
|
||||
|
||||
finish(choice) {
|
||||
const resolve = this.resolveChoice;
|
||||
if (!resolve) return;
|
||||
this.resolveChoice = null;
|
||||
this.dialog.close();
|
||||
resolve(choice);
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
/* Versioned JSON interchange for Racket Wiki concept maps. */
|
||||
|
||||
const FORMAT = "racket-wiki-cmap-bundle";
|
||||
const FORMAT_VERSION = 1;
|
||||
const SCHEMA = "/schemas/racket-wiki-cmap-bundle-v1.schema.json";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const TEMPORARY_ID = /^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
|
||||
const SLUG = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}$/u;
|
||||
const CONCEPT_KEYS = [
|
||||
"id", "label", "synopsis", "aspects", "tags", "descriptionPageSlug",
|
||||
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
|
||||
];
|
||||
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
|
||||
|
||||
function clone(value) {
|
||||
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function validPageReference(value) {
|
||||
if (typeof value !== "string") return false;
|
||||
const separator = value.indexOf(":");
|
||||
if (separator < 0) return SLUG.test(value);
|
||||
const namespace = value.slice(0, separator);
|
||||
const slug = value.slice(separator + 1);
|
||||
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
|
||||
}
|
||||
|
||||
function validExternalUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodedDocument(value) {
|
||||
let documentValue = value;
|
||||
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
||||
documentValue = JSON.parse(documentValue);
|
||||
}
|
||||
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
|
||||
documentValue : {};
|
||||
}
|
||||
|
||||
function conceptContent(value) {
|
||||
const result = {};
|
||||
for (const key of CONCEPT_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function conceptsById(documentValue) {
|
||||
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
|
||||
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
|
||||
.map((concept) => [concept.id, concept]));
|
||||
}
|
||||
|
||||
function itemConceptIds(documentValue) {
|
||||
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
|
||||
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
|
||||
.map((item) => item.conceptId))];
|
||||
}
|
||||
|
||||
function linkedMapSlugs(documentValue) {
|
||||
const concepts = conceptsById(documentValue);
|
||||
return [...new Set(itemConceptIds(documentValue)
|
||||
.map((id) => String(concepts.get(id)?.cmapSlug || "").trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function linkedPageReferences(documentValue) {
|
||||
const references = new Set();
|
||||
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
|
||||
documentValue.metadata : {};
|
||||
if (typeof metadata.explanationPageSlug === "string" && metadata.explanationPageSlug.trim()) {
|
||||
references.add(metadata.explanationPageSlug.trim());
|
||||
}
|
||||
const concepts = conceptsById(documentValue);
|
||||
for (const id of itemConceptIds(documentValue)) {
|
||||
const concept = concepts.get(id) || {};
|
||||
for (const key of ["pageSlug", "descriptionPageSlug"]) {
|
||||
if (typeof concept[key] === "string" && concept[key].trim()) references.add(concept[key].trim());
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function attachmentUrls(markdown) {
|
||||
const source = String(markdown || "");
|
||||
const urls = new Set();
|
||||
const add = (value) => {
|
||||
const url = String(value || "").trim();
|
||||
if (url.startsWith("/uploads/") && url.split("/").length >= 4) urls.add(url);
|
||||
};
|
||||
for (const match of source.matchAll(/!?\[[^\]]*\]\((\/uploads\/[^)]*)\)/g)) add(match[1]);
|
||||
for (const match of source.matchAll(/(?:src|href)\s*=\s*["'](\/uploads\/[^"']+)["']/gi)) add(match[1]);
|
||||
for (const match of source.matchAll(/\/uploads\/[^\s"'<>\\)]+/g)) add(match[0]);
|
||||
const collected = [...urls];
|
||||
return collected.filter((url) => !collected.some((other) =>
|
||||
other !== url && other.startsWith(`${url} `)));
|
||||
}
|
||||
|
||||
function attachmentName(url) {
|
||||
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
|
||||
try {
|
||||
return decodeURIComponent(encoded) || "attachment.bin";
|
||||
} catch (_error) {
|
||||
return encoded || "attachment.bin";
|
||||
}
|
||||
}
|
||||
|
||||
function replaceAttachmentUrls(markdown, replacements) {
|
||||
let result = String(markdown || "");
|
||||
const entries = replacements instanceof Map ? [...replacements.entries()] :
|
||||
Object.entries(replacements || {});
|
||||
entries.sort(([left], [right]) => right.length - left.length);
|
||||
for (const [source, target] of entries) {
|
||||
if (!source || source === target) continue;
|
||||
result = result.split(source).join(String(target));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function placementDocument(documentValue) {
|
||||
const documentCopy = clone(decodedDocument(documentValue));
|
||||
const ids = itemConceptIds(documentCopy);
|
||||
for (const concept of (Array.isArray(documentCopy.concepts) ? documentCopy.concepts : [])) {
|
||||
if (concept && typeof concept.id === "string" && !ids.includes(concept.id)) ids.push(concept.id);
|
||||
}
|
||||
documentCopy.items = (Array.isArray(documentCopy.items) ? documentCopy.items : []).map((item) => {
|
||||
if (!item || item.kind === "phrase") return item;
|
||||
return Object.fromEntries(Object.entries(item)
|
||||
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
|
||||
});
|
||||
documentCopy.concepts = ids.map((id) => ({ id }));
|
||||
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;
|
||||
}
|
||||
|
||||
async function pageRecord(page, requestedReference, loadAttachment) {
|
||||
const markdown = String(page.markdown || "");
|
||||
const attachments = [];
|
||||
for (const url of attachmentUrls(markdown)) {
|
||||
if (typeof loadAttachment !== "function") {
|
||||
throw new Error(`Attachment loader is required for ${url}.`);
|
||||
}
|
||||
const loaded = await loadAttachment(url, requestedReference);
|
||||
if (!loaded || typeof loaded.contentBase64 !== "string") {
|
||||
throw new Error(`Attachment ${url} did not provide base64 content.`);
|
||||
}
|
||||
attachments.push({
|
||||
url,
|
||||
name: String(loaded.name || attachmentName(url)),
|
||||
mimeType: String(loaded.mimeType || "application/octet-stream"),
|
||||
contentBase64: loaded.contentBase64
|
||||
});
|
||||
}
|
||||
return {
|
||||
reference: String(requestedReference),
|
||||
title: String(page.title || page.slug || requestedReference),
|
||||
markdown,
|
||||
tags: Array.isArray(page.tags) ? page.tags.map(String) : [],
|
||||
attachments
|
||||
};
|
||||
}
|
||||
|
||||
async function buildBundle(options) {
|
||||
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
|
||||
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
|
||||
if (typeof options.loadWikiPage !== "function") throw new Error("loadWikiPage is required.");
|
||||
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
|
||||
const maps = [];
|
||||
const concepts = new Map();
|
||||
const pageReferences = new Set();
|
||||
const visited = new Set();
|
||||
const missingMaps = new Set();
|
||||
|
||||
async function collectMap(map, depth) {
|
||||
if (!map?.slug || visited.has(map.slug)) return;
|
||||
visited.add(map.slug);
|
||||
const documentValue = decodedDocument(map.document);
|
||||
maps.push({
|
||||
slug: String(map.slug),
|
||||
title: String(map.title || map.slug),
|
||||
document: placementDocument(documentValue)
|
||||
});
|
||||
for (const concept of conceptsById(documentValue).values()) {
|
||||
if (!concepts.has(concept.id)) concepts.set(concept.id, conceptContent(concept));
|
||||
}
|
||||
for (const reference of linkedPageReferences(documentValue)) pageReferences.add(reference);
|
||||
|
||||
const sourceSlug = String(documentValue.derivedView?.sourceCmapSlug || "").trim();
|
||||
if (sourceSlug && !visited.has(sourceSlug)) {
|
||||
try {
|
||||
await collectMap(await options.loadConceptMap(sourceSlug), depth);
|
||||
} catch (_error) {
|
||||
missingMaps.add(sourceSlug);
|
||||
}
|
||||
}
|
||||
if (depth >= maximumDepth) return;
|
||||
for (const slug of linkedMapSlugs(documentValue)) {
|
||||
if (visited.has(slug)) continue;
|
||||
try {
|
||||
await collectMap(await options.loadConceptMap(slug), depth + 1);
|
||||
} catch (_error) {
|
||||
missingMaps.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await collectMap(options.rootMap, 0);
|
||||
const pages = [];
|
||||
const missingPages = [];
|
||||
for (const reference of [...pageReferences].sort()) {
|
||||
try {
|
||||
pages.push(await pageRecord(
|
||||
await options.loadWikiPage(reference), reference, options.loadAttachment));
|
||||
} catch (_error) {
|
||||
missingPages.push(reference);
|
||||
}
|
||||
}
|
||||
|
||||
const bundle = {
|
||||
$schema: SCHEMA,
|
||||
format: FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
exportedAt: options.exportedAt || new Date().toISOString(),
|
||||
generator: options.generator || "Racket Wiki",
|
||||
rootCmapSlug: String(options.rootMap.slug),
|
||||
cmaps: maps,
|
||||
concepts: [...concepts.values()],
|
||||
pages,
|
||||
missing: { cmaps: [...missingMaps].sort(), pages: missingPages }
|
||||
};
|
||||
validateBundle(bundle);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function validateBundle(bundle) {
|
||||
const errors = [];
|
||||
const issue = (path, message) => errors.push(`${path}: ${message}`);
|
||||
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
|
||||
throw new Error("The import must be a JSON object.");
|
||||
}
|
||||
if (bundle.format !== FORMAT) issue("format", `must be ${FORMAT}`);
|
||||
if (bundle.formatVersion !== FORMAT_VERSION) issue("formatVersion", `must be ${FORMAT_VERSION}`);
|
||||
if (!Array.isArray(bundle.cmaps) || !bundle.cmaps.length) issue("cmaps", "must contain at least one CMap");
|
||||
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
|
||||
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
|
||||
|
||||
const conceptIds = new Set();
|
||||
const conceptLabels = new Set();
|
||||
const usedConceptIds = new Set();
|
||||
const linkedPageReferences = new Set();
|
||||
for (const [index, concept] of (Array.isArray(bundle.concepts) ? bundle.concepts : []).entries()) {
|
||||
const path = `concepts[${index}]`;
|
||||
if (!concept || typeof concept !== "object" || Array.isArray(concept)) {
|
||||
issue(path, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof concept.id !== "string" || !(UUID.test(concept.id) || TEMPORARY_ID.test(concept.id))) {
|
||||
issue(`${path}.id`, "must be a UUID or a new:<name> temporary id");
|
||||
} else if (conceptIds.has(concept.id)) {
|
||||
issue(`${path}.id`, "is duplicated");
|
||||
} else conceptIds.add(concept.id);
|
||||
if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required");
|
||||
else {
|
||||
const name = concept.label.trim().toLocaleLowerCase();
|
||||
if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name");
|
||||
else conceptLabels.add(name);
|
||||
}
|
||||
for (const key of ["pageSlug", "descriptionPageSlug"]) {
|
||||
if (typeof concept[key] === "string" && concept[key].trim()) {
|
||||
linkedPageReferences.add(concept[key].trim());
|
||||
if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference");
|
||||
}
|
||||
}
|
||||
if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) {
|
||||
issue(`${path}.cmapSlug`, "must be a valid CMap slug");
|
||||
}
|
||||
if (concept.externalUrl !== undefined && concept.externalUrl !== null &&
|
||||
(typeof concept.externalUrl !== "string" ||
|
||||
!concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) {
|
||||
issue(`${path}.externalUrl`, "must be a complete http or https URL");
|
||||
}
|
||||
}
|
||||
|
||||
const mapSlugs = new Set();
|
||||
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
|
||||
const path = `cmaps[${mapIndex}]`;
|
||||
if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) {
|
||||
issue(path, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug");
|
||||
else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated");
|
||||
else mapSlugs.add(cmap.slug);
|
||||
if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required");
|
||||
const documentValue = cmap.document;
|
||||
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
|
||||
issue(`${path}.document`, "must be an object");
|
||||
continue;
|
||||
}
|
||||
const items = Array.isArray(documentValue.items) ? documentValue.items : [];
|
||||
const metadataReference = documentValue.metadata?.explanationPageSlug;
|
||||
if (typeof metadataReference === "string" && metadataReference.trim()) {
|
||||
linkedPageReferences.add(metadataReference.trim());
|
||||
if (!validPageReference(metadataReference.trim())) {
|
||||
issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference");
|
||||
}
|
||||
}
|
||||
const documentConceptIds = new Set();
|
||||
for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ?
|
||||
documentValue.concepts : []).entries()) {
|
||||
const referencePath = `${path}.document.concepts[${referenceIndex}].id`;
|
||||
if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) {
|
||||
issue(referencePath, "must reference a concept in concepts[]");
|
||||
} else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap");
|
||||
else documentConceptIds.add(reference.id);
|
||||
}
|
||||
const itemIds = new Set();
|
||||
for (const [itemIndex, item] of items.entries()) {
|
||||
const itemPath = `${path}.document.items[${itemIndex}]`;
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
issue(itemPath, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer");
|
||||
else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap");
|
||||
else itemIds.add(Number(item.id));
|
||||
if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number");
|
||||
if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number");
|
||||
if (item.kind !== "phrase") {
|
||||
if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) {
|
||||
issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]");
|
||||
} else usedConceptIds.add(item.conceptId);
|
||||
}
|
||||
}
|
||||
for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ?
|
||||
documentValue.connectors : []).entries()) {
|
||||
const connectorPath = `${path}.document.connectors[${connectorIndex}]`;
|
||||
if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item");
|
||||
if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item");
|
||||
}
|
||||
}
|
||||
if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) {
|
||||
issue("rootCmapSlug", "must reference a CMap in cmaps[]");
|
||||
}
|
||||
for (const id of conceptIds) {
|
||||
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
|
||||
}
|
||||
|
||||
const pageReferences = new Set();
|
||||
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
|
||||
const path = `pages[${index}]`;
|
||||
if (!page || typeof page !== "object" || Array.isArray(page)) {
|
||||
issue(path, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference");
|
||||
else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated");
|
||||
else pageReferences.add(page.reference);
|
||||
if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required");
|
||||
if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string");
|
||||
if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) {
|
||||
issue(`${path}.tags`, "must be an array of strings");
|
||||
}
|
||||
if (page.attachments !== undefined && !Array.isArray(page.attachments)) {
|
||||
issue(`${path}.attachments`, "must be an array");
|
||||
}
|
||||
const attachmentReferences = new Set();
|
||||
for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ?
|
||||
page.attachments : []).entries()) {
|
||||
const attachmentPath = `${path}.attachments[${attachmentIndex}]`;
|
||||
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
|
||||
issue(attachmentPath, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) {
|
||||
issue(`${attachmentPath}.url`, "must be a local /uploads/ URL");
|
||||
} else if (attachmentReferences.has(attachment.url)) {
|
||||
issue(`${attachmentPath}.url`, "is duplicated within the page");
|
||||
} else attachmentReferences.add(attachment.url);
|
||||
if (typeof attachment.name !== "string" || !attachment.name.trim()) {
|
||||
issue(`${attachmentPath}.name`, "is required");
|
||||
}
|
||||
if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) {
|
||||
issue(`${attachmentPath}.mimeType`, "is required");
|
||||
}
|
||||
if (typeof attachment.contentBase64 !== "string" ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) {
|
||||
issue(`${attachmentPath}.contentBase64`, "must be valid base64");
|
||||
}
|
||||
}
|
||||
}
|
||||
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
|
||||
for (const reference of linkedPageReferences) {
|
||||
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
|
||||
issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
|
||||
error.validationErrors = errors;
|
||||
throw error;
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function preparedMapDocument(bundle, cmap) {
|
||||
validateBundle(bundle);
|
||||
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
|
||||
const documentValue = clone(cmap.document);
|
||||
const ids = itemConceptIds(documentValue);
|
||||
for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
|
||||
if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id);
|
||||
}
|
||||
documentValue.concepts = ids.map((id) => clone(byId.get(id)));
|
||||
return documentValue;
|
||||
}
|
||||
|
||||
export {
|
||||
FORMAT,
|
||||
FORMAT_VERSION,
|
||||
SCHEMA,
|
||||
attachmentUrls,
|
||||
buildBundle,
|
||||
decodedDocument,
|
||||
placementDocument,
|
||||
preparedMapDocument,
|
||||
replaceAttachmentUrls,
|
||||
validateBundle
|
||||
};
|
||||
+299
-1625
File diff suppressed because it is too large
Load Diff
@@ -182,6 +182,14 @@ function positionIsProtected(start, end, ranges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
|
||||
function literalWikiWord(namespace, wikiWord) {
|
||||
const characters = Array.from(wikiWord);
|
||||
const initial = `&#${characters[0].codePointAt(0)};`;
|
||||
const prefix = namespace ? `${namespace}:` : "";
|
||||
return `${prefix}${initial}${characters.slice(1).join("")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Rewrite namespaced Markdown targets to internal hash routes.
|
||||
* pre : markdown is source text; fenced and indented code must remain untouched.
|
||||
@@ -219,15 +227,18 @@ function expandNamespacedMarkdownLinks(markdown) {
|
||||
/**
|
||||
* 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.
|
||||
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged;
|
||||
* an exclamation mark directly before a WikiWord suppresses its link.
|
||||
* result : Render-only Markdown with WikiWord links. Literal WikiWords contain
|
||||
* an equivalent HTML character reference and remain literal when the
|
||||
* transformation is applied again.
|
||||
*/
|
||||
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;
|
||||
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(!?)(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-]|\.[\p{L}\p{N}_-])/gu;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||
@@ -250,12 +261,20 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (positionIsProtected(start, end, protectedRanges)) continue;
|
||||
const namespace = match[1] || "";
|
||||
if (match[1] === "!") {
|
||||
replacements.push({
|
||||
start,
|
||||
end,
|
||||
text: literalWikiWord(match[2] || "", match[3])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const namespace = match[2] || "";
|
||||
if (namespace.toLocaleLowerCase() === "cmap") {
|
||||
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
|
||||
const conceptMap = cmapMentionTarget(match[3], conceptMaps);
|
||||
if (conceptMap) replacements.push({ start, end, conceptMap });
|
||||
} else {
|
||||
const page = wikiWordTarget(match[2], aliases, namespace);
|
||||
const page = wikiWordTarget(match[3], aliases, namespace);
|
||||
if (page) replacements.push({ start, end, page });
|
||||
}
|
||||
}
|
||||
@@ -263,10 +282,10 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
|
||||
let expanded = line;
|
||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
||||
const replacement = replacements[index];
|
||||
const link = replacement.conceptMap ?
|
||||
const rendered = replacement.text || (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);
|
||||
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`);
|
||||
expanded = expanded.slice(0, replacement.start) + rendered + expanded.slice(replacement.end);
|
||||
}
|
||||
result.push(expanded);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user