@thebes/cadmus 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -307,6 +307,83 @@ function defineCmsConfig(config) {
307
307
  return resolved;
308
308
  }
309
309
  //#endregion
310
+ //#region src/cms/patch.ts
311
+ function deepEqual(a, b) {
312
+ if (a === b) return true;
313
+ if (a === null || b === null) return false;
314
+ if (typeof a !== typeof b) return false;
315
+ if (Array.isArray(a) || Array.isArray(b)) {
316
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
317
+ if (a.length !== b.length) return false;
318
+ return a.every((item, i) => deepEqual(item, b[i]));
319
+ }
320
+ if (typeof a === "object" && typeof b === "object") {
321
+ const ak = Object.keys(a);
322
+ const bk = Object.keys(b);
323
+ if (ak.length !== bk.length) return false;
324
+ return ak.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
325
+ }
326
+ return false;
327
+ }
328
+ /**
329
+ * Field-level diff between two document snapshots — the per-field
330
+ * added/removed/changed list a version-history UI renders. Values are
331
+ * compared structurally (deep-equal), so a field only shows as `changed`
332
+ * when its content actually differs.
333
+ */
334
+ function diffDocuments(before, after, options = {}) {
335
+ const ignore = new Set(options.ignore ?? []);
336
+ const keys = options.fields ? options.fields : [...new Set([...Object.keys(before), ...Object.keys(after)])];
337
+ const changes = [];
338
+ for (const path of keys) {
339
+ if (ignore.has(path)) continue;
340
+ const inBefore = Object.hasOwn(before, path);
341
+ const inAfter = Object.hasOwn(after, path);
342
+ if (inBefore && !inAfter) changes.push({
343
+ path,
344
+ kind: "removed",
345
+ before: before[path]
346
+ });
347
+ else if (!inBefore && inAfter) changes.push({
348
+ path,
349
+ kind: "added",
350
+ after: after[path]
351
+ });
352
+ else if (inBefore && inAfter && !deepEqual(before[path], after[path])) changes.push({
353
+ path,
354
+ kind: "changed",
355
+ before: before[path],
356
+ after: after[path]
357
+ });
358
+ }
359
+ return changes;
360
+ }
361
+ /**
362
+ * The {@link Patch} that transforms `before` into `after`: `set` for each
363
+ * added/changed field, `unset` for each removed field. `applyPatch(before,
364
+ * computePatch(before, after))` deep-equals `after`.
365
+ */
366
+ function computePatch(before, after) {
367
+ return diffDocuments(before, after).map((change) => change.kind === "removed" ? {
368
+ op: "unset",
369
+ path: change.path
370
+ } : {
371
+ op: "set",
372
+ path: change.path,
373
+ value: change.after
374
+ });
375
+ }
376
+ /**
377
+ * Apply a {@link Patch} to a document, returning a new document (the input is
378
+ * never mutated). Unknown ops are ignored defensively.
379
+ */
380
+ function applyPatch(doc, patch) {
381
+ const next = { ...doc };
382
+ for (const op of patch) if (op.op === "set") next[op.path] = op.value;
383
+ else if (op.op === "unset") delete next[op.path];
384
+ return next;
385
+ }
386
+ //#endregion
310
387
  //#region src/cms/validation.ts
311
388
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
312
389
  const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -895,6 +972,21 @@ function createVersionedLocalApi(db, table, versionsTable, config, registry) {
895
972
  const [row] = await db.update(table).set({ publishedVersionId: null }).where((0, drizzle_orm.eq)(idColumn, id)).returning();
896
973
  if (!row) notFound(config, id);
897
974
  return row;
975
+ },
976
+ async diffVersions(context, fromVersionId, toVersionId) {
977
+ await checkAccess(config, "read", context);
978
+ const rows = await db.select().from(versionsTable).where((0, drizzle_orm.inArray)(versionsIdColumn, [fromVersionId, toVersionId]));
979
+ const byId = new Map(rows.map((r) => [r.id, r.versionData]));
980
+ const before = byId.get(fromVersionId);
981
+ const after = byId.get(toVersionId);
982
+ if (!before) notFoundVersion(config, fromVersionId);
983
+ if (!after) notFoundVersion(config, toVersionId);
984
+ return diffDocuments(before, after, { ignore: [
985
+ "id",
986
+ "createdAt",
987
+ "status",
988
+ "publishedVersionId"
989
+ ] });
898
990
  }
899
991
  };
900
992
  }
@@ -908,6 +1000,50 @@ function getCollectionsMeta(config) {
908
1000
  }));
909
1001
  }
910
1002
  //#endregion
1003
+ //#region src/cms/migrate.ts
1004
+ /** Identity helper — gives a migration definition its type + a greppable call site. */
1005
+ function defineMigration(migration) {
1006
+ return migration;
1007
+ }
1008
+ function patchToUpdate(patch) {
1009
+ const values = {};
1010
+ for (const op of patch) values[op.path] = op.op === "set" ? op.value : null;
1011
+ return values;
1012
+ }
1013
+ /**
1014
+ * Run a migration over every document in a collection. Reads all documents
1015
+ * through `api.find`, applies `migration.document`, and (unless `dryRun`)
1016
+ * writes the resulting patch through `api.update`. Returns a report of what
1017
+ * changed — run it `dryRun` first, then apply.
1018
+ */
1019
+ async function runMigration(migration, options) {
1020
+ const { api, context, dryRun = false } = options;
1021
+ const rows = await api.find(context);
1022
+ const changes = [];
1023
+ const errors = [];
1024
+ let changed = 0;
1025
+ for (const before of rows) try {
1026
+ const patch = computePatch(before, await migration.document(before) ?? before);
1027
+ if (patch.length === 0) continue;
1028
+ changes.push({
1029
+ id: before.id,
1030
+ patch
1031
+ });
1032
+ changed++;
1033
+ if (!dryRun) await api.update(context, before.id, patchToUpdate(patch));
1034
+ } catch (err) {
1035
+ errors.push(`document ${before.id}: ${String(err)}`);
1036
+ }
1037
+ return {
1038
+ migration: migration.name,
1039
+ dryRun,
1040
+ scanned: rows.length,
1041
+ changed,
1042
+ changes,
1043
+ errors
1044
+ };
1045
+ }
1046
+ //#endregion
911
1047
  //#region src/cms/schema-gen.ts
912
1048
  function toSnakeCase(value) {
913
1049
  return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
@@ -1106,6 +1242,90 @@ function buildStudioStructure(config, options = {}) {
1106
1242
  }));
1107
1243
  }
1108
1244
  //#endregion
1245
+ //#region src/cms/visual-editing.ts
1246
+ /** The data attribute editable regions are tagged with. */
1247
+ const EDIT_ATTR = "data-cadmus-edit";
1248
+ /** `postMessage` payload type for a click-to-edit selection. */
1249
+ const VISUAL_EDIT_MESSAGE = "cadmus:visual-edit";
1250
+ function encodeEditRef(ref) {
1251
+ return `${ref.collection}:${ref.id}:${ref.field}`;
1252
+ }
1253
+ /** Parse an {@link EditRef} string, or null if malformed. */
1254
+ function decodeEditRef(value) {
1255
+ const parts = value.split(":");
1256
+ if (parts.length !== 3) return null;
1257
+ const [collection, idRaw, field] = parts;
1258
+ const id = Number.parseInt(idRaw, 10);
1259
+ if (!collection || !field || !Number.isFinite(id)) return null;
1260
+ return {
1261
+ collection,
1262
+ id,
1263
+ field
1264
+ };
1265
+ }
1266
+ /**
1267
+ * Attribute object to spread onto a rendered element so the overlay can map
1268
+ * it back to its source field, e.g. `<h1 {...editAttr({collection:'pages',
1269
+ * id, field:'title'})}>`.
1270
+ */
1271
+ function editAttr(ref) {
1272
+ return { [EDIT_ATTR]: encodeEditRef(ref) };
1273
+ }
1274
+ /**
1275
+ * Mount the click-to-edit overlay. Browser-only — call from a preview page's
1276
+ * client script. Highlights `[data-cadmus-edit]` elements on hover and, on
1277
+ * click, calls `onSelect` and posts a {@link VisualEditingMessage} to the
1278
+ * parent window. Returns a cleanup function that removes the listeners.
1279
+ */
1280
+ function mountVisualEditing(options = {}) {
1281
+ const { onSelect, targetOrigin = "*", highlightColor = "#56c6be" } = options;
1282
+ const closest = (target) => {
1283
+ if (!(target instanceof Element)) return null;
1284
+ const el = target.closest(`[${EDIT_ATTR}]`);
1285
+ return el instanceof HTMLElement ? el : null;
1286
+ };
1287
+ let previous = null;
1288
+ const clearHighlight = () => {
1289
+ if (previous) {
1290
+ previous.el.style.outline = previous.outline;
1291
+ previous = null;
1292
+ }
1293
+ };
1294
+ const onOver = (event) => {
1295
+ const el = closest(event.target);
1296
+ if (!el || el === previous?.el) return;
1297
+ clearHighlight();
1298
+ previous = {
1299
+ el,
1300
+ outline: el.style.outline
1301
+ };
1302
+ el.style.outline = `2px solid ${highlightColor}`;
1303
+ el.style.outlineOffset = "2px";
1304
+ el.style.cursor = "pointer";
1305
+ };
1306
+ const onClick = (event) => {
1307
+ const el = closest(event.target);
1308
+ if (!el) return;
1309
+ const ref = decodeEditRef(el.getAttribute("data-cadmus-edit") ?? "");
1310
+ if (!ref) return;
1311
+ event.preventDefault();
1312
+ event.stopPropagation();
1313
+ onSelect?.(ref, el);
1314
+ const message = {
1315
+ type: VISUAL_EDIT_MESSAGE,
1316
+ ref
1317
+ };
1318
+ window.parent?.postMessage(message, targetOrigin);
1319
+ };
1320
+ document.addEventListener("mouseover", onOver, true);
1321
+ document.addEventListener("click", onClick, true);
1322
+ return () => {
1323
+ clearHighlight();
1324
+ document.removeEventListener("mouseover", onOver, true);
1325
+ document.removeEventListener("click", onClick, true);
1326
+ };
1327
+ }
1328
+ //#endregion
1109
1329
  //#region src/cms/webhooks.ts
1110
1330
  /**
1111
1331
  * Builds an `afterChange` hook that enqueues a `WebhookMessage` for every
@@ -1186,7 +1406,10 @@ async function deliverWebhookMessage(message) {
1186
1406
  }
1187
1407
  //#endregion
1188
1408
  exports.DEFAULT_STUDIO_GROUP = DEFAULT_STUDIO_GROUP;
1409
+ exports.EDIT_ATTR = EDIT_ATTR;
1189
1410
  exports.Rule = Rule;
1411
+ exports.VISUAL_EDIT_MESSAGE = VISUAL_EDIT_MESSAGE;
1412
+ exports.applyPatch = applyPatch;
1190
1413
  exports.assertValid = assertValid;
1191
1414
  exports.buildStudioStructure = buildStudioStructure;
1192
1415
  exports.can = can;
@@ -1195,24 +1418,32 @@ exports.collectionSearchTableName = collectionSearchTableName;
1195
1418
  exports.collectionSearchTableSQL = collectionSearchTableSQL;
1196
1419
  exports.collectionToTable = collectionToTable;
1197
1420
  exports.collectionVersionsTable = collectionVersionsTable;
1421
+ exports.computePatch = computePatch;
1198
1422
  exports.createBlockRegistry = createBlockRegistry;
1199
1423
  exports.createLocalApi = createLocalApi;
1200
1424
  exports.createVersionedLocalApi = createVersionedLocalApi;
1201
1425
  exports.createWebhookHook = createWebhookHook;
1426
+ exports.decodeEditRef = decodeEditRef;
1202
1427
  exports.defineCmsConfig = defineCmsConfig;
1203
1428
  exports.defineCollection = defineCollection;
1204
1429
  exports.defineField = defineField;
1430
+ exports.defineMigration = defineMigration;
1205
1431
  exports.deliverWebhookMessage = deliverWebhookMessage;
1432
+ exports.diffDocuments = diffDocuments;
1433
+ exports.editAttr = editAttr;
1434
+ exports.encodeEditRef = encodeEditRef;
1206
1435
  exports.extractSearchText = extractSearchText;
1207
1436
  exports.flattenDoc = flattenDoc;
1208
1437
  exports.flattenFields = flattenFields;
1209
1438
  exports.generateSchemaSource = generateSchemaSource;
1210
1439
  exports.getCollectionsMeta = getCollectionsMeta;
1211
1440
  exports.getRegisteredApi = getRegisteredApi;
1441
+ exports.mountVisualEditing = mountVisualEditing;
1212
1442
  exports.nestDoc = nestDoc;
1213
1443
  exports.relationshipJoinTables = relationshipJoinTables;
1214
1444
  exports.renderBlocksToString = renderBlocksToString;
1215
1445
  exports.rule = rule;
1446
+ exports.runMigration = runMigration;
1216
1447
  exports.validateDocument = validateDocument;
1217
1448
 
1218
1449
  //# sourceMappingURL=index.cjs.map