big cmap refactoring
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
/* Versioned JSON interchange for the Racket Wiki CMap model. */
|
||||
/**
|
||||
* Versioned JSON interchange for the Racket Wiki CMap model.
|
||||
*
|
||||
* The bundle separates shared concept content from map-local placement data.
|
||||
* `buildBundle` follows map and page references, embeds the referenced page
|
||||
* attachments, and validates the resulting object. Import code calls
|
||||
* `validateBundle` first and then uses `preparedMapDocument` to join shared
|
||||
* concepts back into a map document. The helpers in this module deliberately
|
||||
* return detached JSON values so callers cannot mutate an input map or bundle.
|
||||
*/
|
||||
|
||||
const FORMAT = "racket-wiki-cmap-bundle";
|
||||
const FORMAT_VERSION = 1;
|
||||
@@ -12,10 +21,15 @@
|
||||
];
|
||||
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
|
||||
|
||||
/** Return a detached JSON-compatible copy while preserving undefined. */
|
||||
function clone(value) {
|
||||
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a wiki page reference in either `slug` or `namespace:slug` form.
|
||||
* The check is shared by concept links, map metadata and exported pages.
|
||||
*/
|
||||
function validPageReference(value) {
|
||||
if (typeof value !== "string") return false;
|
||||
const separator = value.indexOf(":");
|
||||
@@ -25,6 +39,7 @@
|
||||
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
|
||||
}
|
||||
|
||||
/** Accept only complete HTTP(S) URLs for external concept links. */
|
||||
function validExternalUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
@@ -34,6 +49,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the one or two historical JSON string wrappers used by storage.
|
||||
* Invalid or non-object values become an empty document; bundle validation
|
||||
* remains responsible for rejecting malformed imported data.
|
||||
*/
|
||||
function decodedDocument(value) {
|
||||
let documentValue = value;
|
||||
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
||||
@@ -43,6 +63,7 @@
|
||||
documentValue : {};
|
||||
}
|
||||
|
||||
/** Copy only shared concept fields into the bundle-level concept table. */
|
||||
function conceptContent(value) {
|
||||
const result = {};
|
||||
for (const key of CONCEPT_KEYS) {
|
||||
@@ -51,18 +72,21 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Index valid document concepts by their stable identity. */
|
||||
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]));
|
||||
}
|
||||
|
||||
/** Return unique concept ids used by non-phrase placements. */
|
||||
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))];
|
||||
}
|
||||
|
||||
/** Find linked CMap slugs in the concepts actually placed on a document. */
|
||||
function linkedMapSlugs(documentValue) {
|
||||
const concepts = conceptsById(documentValue);
|
||||
return [...new Set(itemConceptIds(documentValue)
|
||||
@@ -70,6 +94,11 @@
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect page references from map metadata and placed concepts.
|
||||
* Unplaced concept records are intentionally ignored because they are not
|
||||
* part of the visible map dependency graph.
|
||||
*/
|
||||
function linkedPageReferences(documentValue) {
|
||||
const references = new Set();
|
||||
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
|
||||
@@ -87,6 +116,11 @@
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract local upload URLs from Markdown and HTML-like markup.
|
||||
* A Set removes duplicates; the final prefix check removes a shorter URL
|
||||
* accidentally captured from a URL containing a space.
|
||||
*/
|
||||
function attachmentUrls(markdown) {
|
||||
const source = String(markdown || "");
|
||||
const urls = new Set();
|
||||
@@ -102,6 +136,7 @@
|
||||
other !== url && other.startsWith(`${url} `)));
|
||||
}
|
||||
|
||||
/** Derive a readable fallback filename from an upload URL. */
|
||||
function attachmentName(url) {
|
||||
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
|
||||
try {
|
||||
@@ -111,6 +146,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace upload URLs after imported attachments receive new server URLs. */
|
||||
function replaceAttachmentUrls(markdown, replacements) {
|
||||
let result = String(markdown || "");
|
||||
const entries = replacements instanceof Map ? [...replacements.entries()] :
|
||||
@@ -123,6 +159,11 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip shared concept fields from placements while preserving layout data.
|
||||
* It also keeps only connectors whose two local item endpoints still exist,
|
||||
* because export must not emit dangling layout relations.
|
||||
*/
|
||||
function placementDocument(documentValue) {
|
||||
const documentCopy = clone(decodedDocument(documentValue));
|
||||
const ids = itemConceptIds(documentCopy);
|
||||
@@ -149,6 +190,10 @@
|
||||
return documentCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one wiki page and all uploads referenced by its Markdown into a
|
||||
* bundle page record. The attachment loader is called once per unique URL.
|
||||
*/
|
||||
async function pageRecord(page, requestedReference, loadAttachment) {
|
||||
const markdown = String(page.markdown || "");
|
||||
const attachments = [];
|
||||
@@ -176,6 +221,24 @@
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and validate a complete export bundle.
|
||||
*
|
||||
* @param {object} options Export options and asynchronous repository loaders.
|
||||
* @param {object} options.rootMap Root map with `slug`, `title` and `document`.
|
||||
* @param {Function} options.loadConceptMap Loads a linked map by slug.
|
||||
* @param {Function} options.loadWikiPage Loads a linked wiki page by reference.
|
||||
* @param {Function} [options.loadAttachment] Loads base64 upload content.
|
||||
* @param {number} [options.maxDepth=0] Maximum depth for linked CMaps.
|
||||
* @returns {Promise<object>} A validated, detached interchange bundle.
|
||||
* @throws {Error} When required loaders are absent or a loaded record is invalid.
|
||||
* @sideeffects Calls the supplied map, page and attachment loaders.
|
||||
*
|
||||
* `collectMap` traverses map dependencies and uses `placementDocument` for
|
||||
* layout-only map records. It collects shared concepts and page references
|
||||
* separately; page records are then built and the final aggregate is checked
|
||||
* by `validateBundle` before it is returned.
|
||||
*/
|
||||
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.");
|
||||
@@ -248,6 +311,18 @@
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the complete bundle contract and return the original bundle.
|
||||
*
|
||||
* @param {object} bundle Candidate bundle to validate.
|
||||
* @returns {object} The same bundle object after successful validation.
|
||||
* @throws {Error} With `validationErrors` when one or more contract checks fail.
|
||||
*
|
||||
* Validation also builds the concept, map, item and page-reference indexes
|
||||
* needed to detect duplicate identities, dangling references and missing
|
||||
* linked pages. Up to twenty errors are included in the message while the
|
||||
* complete list remains available on `error.validationErrors`.
|
||||
*/
|
||||
function validateBundle(bundle) {
|
||||
const errors = [];
|
||||
const issue = (path, message) => errors.push(`${path}: ${message}`);
|
||||
@@ -260,6 +335,7 @@
|
||||
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
|
||||
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
|
||||
|
||||
// Validate the shared concept table and collect its page dependencies.
|
||||
const conceptIds = new Set();
|
||||
const conceptLabels = new Set();
|
||||
const usedConceptIds = new Set();
|
||||
@@ -297,6 +373,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Validate map identities, placements, connectors and map-level page links.
|
||||
const mapSlugs = new Set();
|
||||
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
|
||||
const path = `cmaps[${mapIndex}]`;
|
||||
@@ -362,6 +439,7 @@
|
||||
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
|
||||
}
|
||||
|
||||
// Validate page records and their embedded attachment payloads.
|
||||
const pageReferences = new Set();
|
||||
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
|
||||
const path = `pages[${index}]`;
|
||||
@@ -405,6 +483,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconcile all collected page dependencies with present or declared-missing pages.
|
||||
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
|
||||
for (const reference of linkedPageReferences) {
|
||||
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
|
||||
@@ -412,6 +491,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Report all discovered issues together so import callers can repair a bundle in one pass.
|
||||
if (errors.length) {
|
||||
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
|
||||
error.validationErrors = errors;
|
||||
@@ -420,6 +500,14 @@
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoin bundle-level concept content with one placement-only map document.
|
||||
*
|
||||
* @param {object} bundle A bundle that satisfies `validateBundle`.
|
||||
* @param {object} cmap One entry from `bundle.cmaps`.
|
||||
* @returns {object} A detached document suitable for repository import.
|
||||
* @throws {Error} When the bundle is invalid or a referenced concept is absent.
|
||||
*/
|
||||
function preparedMapDocument(bundle, cmap) {
|
||||
validateBundle(bundle);
|
||||
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
|
||||
|
||||
Reference in New Issue
Block a user