canopycms 0.11.1 → 0.12.0
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.
- package/CHANGELOG.md +58 -48
- package/dist/api/branch-changes.d.ts +1 -0
- package/dist/api/branch-changes.js +1 -1
- package/dist/api/branch-entry-changes.d.ts +92 -7
- package/dist/api/branch-entry-changes.js +272 -38
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/cli/cli.js +62 -1
- package/dist/cli/generate-ai-content.js +61 -0
- package/dist/content-store.d.ts +26 -0
- package/dist/content-store.js +73 -0
- package/dist/editor/Editor.js +134 -15
- package/dist/editor/components/EntryChangesDrawer.d.ts +36 -0
- package/dist/editor/components/EntryChangesDrawer.js +141 -5
- package/dist/editor/components/EntryHeader.js +2 -4
- package/dist/editor/copy.d.ts +8 -0
- package/dist/editor/copy.js +8 -0
- package/dist/editor/entry-diff.d.ts +16 -1
- package/dist/editor/entry-diff.js +21 -11
- package/dist/editor/undo-change.d.ts +45 -0
- package/dist/editor/undo-change.js +226 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -99,9 +99,22 @@ export type DisplayValue = {
|
|
|
99
99
|
kind: 'raw';
|
|
100
100
|
text: string;
|
|
101
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* One step into a form value: a record's member, a list item by its item key, or a block item's
|
|
104
|
+
* data, which is its `value` record or, in the inline `_type` shape, the item itself.
|
|
105
|
+
*/
|
|
106
|
+
export type DataPathSegment = {
|
|
107
|
+
field: string;
|
|
108
|
+
} | {
|
|
109
|
+
item: string;
|
|
110
|
+
} | {
|
|
111
|
+
blockData: true;
|
|
112
|
+
};
|
|
102
113
|
export interface EntryChange {
|
|
103
114
|
/** Stable among one diff's changes: its data path, list items named by key. */
|
|
104
115
|
key: string;
|
|
116
|
+
/** Where the change is in the form value; absent for the entry type, which no field holds. */
|
|
117
|
+
dataPath?: DataPathSegment[];
|
|
105
118
|
/** Human labels from the entry down to the change: "Page sections › Hero › Heading". */
|
|
106
119
|
path: string[];
|
|
107
120
|
kind: 'added' | 'removed' | 'changed' | 'moved';
|
|
@@ -127,6 +140,8 @@ export interface EntryDiffInput {
|
|
|
127
140
|
after: EntryChangeSide | null;
|
|
128
141
|
/** A referenced entry's title, from the entries the editor has listed. */
|
|
129
142
|
referenceTitle?: (id: string) => string | undefined;
|
|
143
|
+
/** False when only which changes there are matters: no text is diffed for its marks. */
|
|
144
|
+
marks?: boolean;
|
|
130
145
|
}
|
|
131
146
|
/**
|
|
132
147
|
* The difference between two texts. Short single-line text is diffed by word; anything else by
|
|
@@ -140,4 +155,4 @@ export interface EntryDiffInput {
|
|
|
140
155
|
*/
|
|
141
156
|
export declare function textDiff(before: string, after: string, long?: boolean, deadline?: number, wordDeadline?: number): TextDiff | undefined;
|
|
142
157
|
/** The entry's changes between `before` and `after`, in schema order. */
|
|
143
|
-
export declare function computeEntryDiff({ fields, before, after, referenceTitle, }: EntryDiffInput): EntryDiff;
|
|
158
|
+
export declare function computeEntryDiff({ fields, before, after, referenceTitle, marks, }: EntryDiffInput): EntryDiff;
|
|
@@ -59,14 +59,18 @@ const normalized = (value) => {
|
|
|
59
59
|
return out;
|
|
60
60
|
};
|
|
61
61
|
const sameValue = (a, b) => (isEmpty(a) && isEmpty(b)) || equal(normalized(a), normalized(b));
|
|
62
|
-
const at = (parent, label, key) => ({
|
|
62
|
+
const at = (parent, label, key, segment) => ({
|
|
63
63
|
path: [...parent.path, label],
|
|
64
64
|
key: parent.key ? `${parent.key}.${key}` : key,
|
|
65
|
+
...(parent.segments && segment && { segments: [...parent.segments, segment] }),
|
|
65
66
|
});
|
|
67
|
+
/** `where`'s data path as a change carries it. */
|
|
68
|
+
const dataPathOf = (where) => where.segments ? { dataPath: where.segments } : {};
|
|
66
69
|
/** A member the schema doesn't name, under an "Other fields" crumb. */
|
|
67
70
|
const otherAt = (parent, name) => ({
|
|
68
71
|
path: [...parent.path, OTHER_FIELDS_LABEL, humanizeName(name)],
|
|
69
72
|
key: `${parent.key}?${name}`,
|
|
73
|
+
...(parent.segments && { segments: [...parent.segments, { field: name }] }),
|
|
70
74
|
});
|
|
71
75
|
/** The file name of an image `src`, its query and fragment dropped. */
|
|
72
76
|
const fileName = (src) => {
|
|
@@ -245,7 +249,7 @@ class Walker {
|
|
|
245
249
|
const flat = flattenGroupFields(fields);
|
|
246
250
|
const known = new Set(flat.map((f) => f.name));
|
|
247
251
|
for (const field of flat) {
|
|
248
|
-
this.field(field, before?.[field.name], after?.[field.name], at(where, schemaLabel(field), field.name));
|
|
252
|
+
this.field(field, before?.[field.name], after?.[field.name], at(where, schemaLabel(field), field.name, { field: field.name }));
|
|
249
253
|
}
|
|
250
254
|
const others = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
|
|
251
255
|
for (const name of others) {
|
|
@@ -287,7 +291,7 @@ class Walker {
|
|
|
287
291
|
if (sameValue(before, after) || sameInstant(field, before, after))
|
|
288
292
|
return;
|
|
289
293
|
const kind = isEmpty(before) ? 'added' : isEmpty(after) ? 'removed' : 'changed';
|
|
290
|
-
const change = { key: where.key, path: where.path, kind };
|
|
294
|
+
const change = { key: where.key, ...dataPathOf(where), path: where.path, kind };
|
|
291
295
|
if (kind !== 'added')
|
|
292
296
|
change.before = this.display(field, before);
|
|
293
297
|
if (kind !== 'removed')
|
|
@@ -337,13 +341,15 @@ class Walker {
|
|
|
337
341
|
const a = isPlainRecord(after) ? resolveBlockItem(field, after) : undefined;
|
|
338
342
|
const sameTemplate = isPlainRecord(before) && isPlainRecord(after) && before.template === after.template;
|
|
339
343
|
if (b && a && sameTemplate) {
|
|
340
|
-
|
|
344
|
+
const data = where.segments && { segments: [...where.segments, { blockData: true }] };
|
|
345
|
+
this.fields(a.fields, b.data, a.data, { ...where, ...data });
|
|
341
346
|
return;
|
|
342
347
|
}
|
|
343
348
|
if (sameValue(before, after))
|
|
344
349
|
return;
|
|
345
350
|
this.changes.push({
|
|
346
351
|
key: where.key,
|
|
352
|
+
...dataPathOf(where),
|
|
347
353
|
path: where.path,
|
|
348
354
|
kind: 'changed',
|
|
349
355
|
before: this.blockRecord(field, before),
|
|
@@ -373,10 +379,11 @@ class Walker {
|
|
|
373
379
|
const from = bIndex.get(key);
|
|
374
380
|
// A matched item is named as it was at the base, so a change to its title doesn't rename
|
|
375
381
|
// the path to it.
|
|
376
|
-
const itemAt = at(where, from === undefined ? item.heading(value, index) : item.heading(bItems[from], from), key);
|
|
382
|
+
const itemAt = at(where, from === undefined ? item.heading(value, index) : item.heading(bItems[from], from), key, { item: key });
|
|
377
383
|
if (from === undefined) {
|
|
378
384
|
this.changes.push({
|
|
379
385
|
key: itemAt.key,
|
|
386
|
+
...dataPathOf(itemAt),
|
|
380
387
|
path: itemAt.path,
|
|
381
388
|
kind: 'added',
|
|
382
389
|
item: true,
|
|
@@ -387,6 +394,7 @@ class Walker {
|
|
|
387
394
|
if (!stayed.has(key)) {
|
|
388
395
|
this.changes.push({
|
|
389
396
|
key: `${itemAt.key}#moved`,
|
|
397
|
+
...dataPathOf(itemAt),
|
|
390
398
|
path: itemAt.path,
|
|
391
399
|
kind: 'moved',
|
|
392
400
|
item: true,
|
|
@@ -400,9 +408,10 @@ class Walker {
|
|
|
400
408
|
const key = bKeys[index];
|
|
401
409
|
if (aSet.has(key))
|
|
402
410
|
return;
|
|
403
|
-
const itemAt = at(where, item.heading(value, index), key);
|
|
411
|
+
const itemAt = at(where, item.heading(value, index), key, { item: key });
|
|
404
412
|
this.changes.push({
|
|
405
413
|
key: itemAt.key,
|
|
414
|
+
...dataPathOf(itemAt),
|
|
406
415
|
path: itemAt.path,
|
|
407
416
|
kind: 'removed',
|
|
408
417
|
item: true,
|
|
@@ -585,11 +594,12 @@ const withContext = (lines) => {
|
|
|
585
594
|
return out;
|
|
586
595
|
};
|
|
587
596
|
/** The entry's changes between `before` and `after`, in schema order. */
|
|
588
|
-
export function computeEntryDiff({ fields, before, after, referenceTitle, }) {
|
|
589
|
-
// One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS.
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
const
|
|
597
|
+
export function computeEntryDiff({ fields, before, after, referenceTitle, marks = true, }) {
|
|
598
|
+
// One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS. Without
|
|
599
|
+
// marks, both deadlines have passed before the walk starts.
|
|
600
|
+
const started = marks ? Date.now() : 0;
|
|
601
|
+
const walker = new Walker(referenceTitle, started + (marks ? DIFF_TIMEOUT_MS : 0), started + (marks ? SHORT_DIFF_TIMEOUT_MS : 0));
|
|
602
|
+
const root = { path: [], key: '', segments: [] };
|
|
593
603
|
if (before && after && before.entryType !== after.entryType) {
|
|
594
604
|
walker.value(undefined, humanizeName(before.entryType), humanizeName(after.entryType), at(root, 'Entry type', '$entryType'));
|
|
595
605
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Undo change" in the "Show changes" drawer: one saved change of an entry set back, in its form
|
|
3
|
+
* value, to how `source` has it: the base's version to undo it, the branch's saved one to redo it.
|
|
4
|
+
* Pure; the editor puts the result in the entry's draft, so an undo is an unsaved edit until saved.
|
|
5
|
+
* List and block items are found by item key, so an undo lands on the item the change named
|
|
6
|
+
* wherever it now sits, and an item put back keeps its key, which its comment threads are anchored
|
|
7
|
+
* by (`resolveThreadPath` in item-keys.ts).
|
|
8
|
+
*/
|
|
9
|
+
import type { EntryChangeSide } from '../api/branch-entry-changes.js';
|
|
10
|
+
import type { CanopyPathSegment } from './canopy-path.js';
|
|
11
|
+
import type { EntryChange } from './entry-diff.js';
|
|
12
|
+
type FormValue = Record<string, unknown>;
|
|
13
|
+
/** A side as the editor's form holds it: an md/mdx body under `body` (`normalizeContentPayload`). */
|
|
14
|
+
export declare const formValueOfSide: (side: EntryChangeSide) => FormValue;
|
|
15
|
+
/** A form value as a side shaped like `like`, for comparing it with the base. */
|
|
16
|
+
export declare const sideOfFormValue: (value: FormValue, like: EntryChangeSide) => EntryChangeSide;
|
|
17
|
+
/**
|
|
18
|
+
* Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
|
|
19
|
+
* nothing: a moved item placed as `side` places it, anything else the same value at the change's
|
|
20
|
+
* path, an added or removed item's whole content included.
|
|
21
|
+
*/
|
|
22
|
+
export declare function holdsAs(value: FormValue, side: FormValue, change: EntryChange, bodyField?: string): boolean;
|
|
23
|
+
/** Whether a change has a place in the form value that an undo can set back. */
|
|
24
|
+
export declare const canSetBack: (change: EntryChange) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* `current` with `change` set to how `source` has it. A whole item added or removed is put in or
|
|
27
|
+
* taken out; a moved item goes back beside its neighbours in `source`; anything else takes
|
|
28
|
+
* `source`'s value at the change's path. Undefined when the change can't be placed in `current`,
|
|
29
|
+
* such as a field inside an item `current` no longer has. `bodyField` is the schema's body field
|
|
30
|
+
* on an md/mdx entry.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setChangeBack(current: FormValue, source: FormValue, change: EntryChange, bodyField?: string): FormValue | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The canopy path (`blocks[2].title`) of a change in `value`, which comment threads anchor to;
|
|
35
|
+
* undefined when an item on it isn't in `value`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function canopyPathOf(value: FormValue, change: EntryChange, bodyField?: string): CanopyPathSegment[] | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* `current` with every one of `changes` set to how `source` has it, leaving anything else, such
|
|
40
|
+
* as an unsaved edit to an unchanged field, as it is. Items are placed in `source`'s order, so
|
|
41
|
+
* each lands beside a neighbour already back in place and the list ends in `source`'s order. A
|
|
42
|
+
* change that can't be placed is skipped.
|
|
43
|
+
*/
|
|
44
|
+
export declare function setAllBack(current: FormValue, source: FormValue, changes: readonly EntryChange[], bodyField?: string): FormValue;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Undo change" in the "Show changes" drawer: one saved change of an entry set back, in its form
|
|
3
|
+
* value, to how `source` has it: the base's version to undo it, the branch's saved one to redo it.
|
|
4
|
+
* Pure; the editor puts the result in the entry's draft, so an undo is an unsaved edit until saved.
|
|
5
|
+
* List and block items are found by item key, so an undo lands on the item the change named
|
|
6
|
+
* wherever it now sits, and an item put back keeps its key, which its comment threads are anchored
|
|
7
|
+
* by (`resolveThreadPath` in item-keys.ts).
|
|
8
|
+
*/
|
|
9
|
+
import equal from 'fast-deep-equal';
|
|
10
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
11
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
12
|
+
/** The form key of an md/mdx body, which the diff names by the schema's body field. */
|
|
13
|
+
const FORM_BODY_KEY = 'body';
|
|
14
|
+
/** A side as the editor's form holds it: an md/mdx body under `body` (`normalizeContentPayload`). */
|
|
15
|
+
export const formValueOfSide = (side) => side.body === undefined ? { ...side.data } : { ...side.data, [FORM_BODY_KEY]: side.body };
|
|
16
|
+
/** A form value as a side shaped like `like`, for comparing it with the base. */
|
|
17
|
+
export const sideOfFormValue = (value, like) => {
|
|
18
|
+
if (like.body === undefined)
|
|
19
|
+
return { ...like, data: value };
|
|
20
|
+
const { [FORM_BODY_KEY]: body, ...data } = value;
|
|
21
|
+
return { ...like, data, body: typeof body === 'string' ? body : '' };
|
|
22
|
+
};
|
|
23
|
+
const indexOfKey = (list, key) => list.findIndex((item) => isPlainRecord(item) && item[ITEM_KEY] === key);
|
|
24
|
+
/** A change's data path in form keys: the body field, when it heads the path, is `body`. */
|
|
25
|
+
const formPath = (change, bodyField) => {
|
|
26
|
+
const path = change.dataPath ?? [];
|
|
27
|
+
const head = path[0];
|
|
28
|
+
return bodyField && head && 'field' in head && head.field === bodyField
|
|
29
|
+
? [{ field: FORM_BODY_KEY }, ...path.slice(1)]
|
|
30
|
+
: path;
|
|
31
|
+
};
|
|
32
|
+
const step = (value, segment) => {
|
|
33
|
+
if ('field' in segment)
|
|
34
|
+
return isPlainRecord(value) ? value[segment.field] : undefined;
|
|
35
|
+
if ('item' in segment) {
|
|
36
|
+
if (!Array.isArray(value))
|
|
37
|
+
return undefined;
|
|
38
|
+
const index = indexOfKey(value, segment.item);
|
|
39
|
+
return index < 0 ? undefined : value[index];
|
|
40
|
+
}
|
|
41
|
+
return isPlainRecord(value) ? (isPlainRecord(value.value) ? value.value : value) : undefined;
|
|
42
|
+
};
|
|
43
|
+
const valueAt = (value, path) => path.reduce(step, value);
|
|
44
|
+
/** What a `replace` returns when the change has nothing to act on, so it can't be placed. */
|
|
45
|
+
const NOT_PLACED = Symbol('not-placed');
|
|
46
|
+
/**
|
|
47
|
+
* `value` with what is at `path` replaced by `replace`'s result, copied on the way down; a
|
|
48
|
+
* record missing on the way is made, so a field comes back inside an object the branch cleared.
|
|
49
|
+
* Undefined when the path leads through an item or block the value no longer has. A record
|
|
50
|
+
* member replaced by undefined is dropped.
|
|
51
|
+
*/
|
|
52
|
+
const updateAt = (value, path, replace) => {
|
|
53
|
+
if (path.length === 0) {
|
|
54
|
+
const next = replace(value);
|
|
55
|
+
return next === NOT_PLACED ? undefined : { value: next };
|
|
56
|
+
}
|
|
57
|
+
const [segment, ...rest] = path;
|
|
58
|
+
if ('field' in segment) {
|
|
59
|
+
const record = isPlainRecord(value) ? value : value == null ? {} : undefined;
|
|
60
|
+
if (!record)
|
|
61
|
+
return undefined;
|
|
62
|
+
const next = updateAt(record[segment.field], rest, replace);
|
|
63
|
+
if (!next)
|
|
64
|
+
return undefined;
|
|
65
|
+
const out = { ...record };
|
|
66
|
+
if (next.value === undefined)
|
|
67
|
+
delete out[segment.field];
|
|
68
|
+
else
|
|
69
|
+
out[segment.field] = next.value;
|
|
70
|
+
return { value: out };
|
|
71
|
+
}
|
|
72
|
+
if ('item' in segment) {
|
|
73
|
+
if (!Array.isArray(value))
|
|
74
|
+
return undefined;
|
|
75
|
+
const index = indexOfKey(value, segment.item);
|
|
76
|
+
if (index < 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
const next = updateAt(value[index], rest, replace);
|
|
79
|
+
if (!next)
|
|
80
|
+
return undefined;
|
|
81
|
+
const out = [...value];
|
|
82
|
+
out[index] = next.value;
|
|
83
|
+
return { value: out };
|
|
84
|
+
}
|
|
85
|
+
if (!isPlainRecord(value))
|
|
86
|
+
return undefined;
|
|
87
|
+
if (!isPlainRecord(value.value))
|
|
88
|
+
return updateAt(value, rest, replace);
|
|
89
|
+
const next = updateAt(value.value, rest, replace);
|
|
90
|
+
return next && { value: { ...value, value: next.value } };
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* `item` placed in `list` (which lacks it) where `order` has it: after the nearest item before it
|
|
94
|
+
* in `order` that `list` holds, else first.
|
|
95
|
+
*/
|
|
96
|
+
const placeLike = (list, item, key, order) => {
|
|
97
|
+
const at = indexOfKey(order, key);
|
|
98
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
99
|
+
const before = order[i];
|
|
100
|
+
const index = isPlainRecord(before) ? indexOfKey(list, before[ITEM_KEY]) : -1;
|
|
101
|
+
if (index >= 0)
|
|
102
|
+
return [...list.slice(0, index + 1), item, ...list.slice(index + 1)];
|
|
103
|
+
}
|
|
104
|
+
return [item, ...list];
|
|
105
|
+
};
|
|
106
|
+
const isNothing = (value) => value === undefined || isEmptyContainer(value);
|
|
107
|
+
/**
|
|
108
|
+
* Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
|
|
109
|
+
* nothing: a moved item placed as `side` places it, anything else the same value at the change's
|
|
110
|
+
* path, an added or removed item's whole content included.
|
|
111
|
+
*/
|
|
112
|
+
export function holdsAs(value, side, change, bodyField) {
|
|
113
|
+
if (change.item && change.kind === 'moved') {
|
|
114
|
+
const placed = setChangeBack(value, side, change, bodyField);
|
|
115
|
+
return !!placed && equal(placed, value);
|
|
116
|
+
}
|
|
117
|
+
const path = formPath(change, bodyField);
|
|
118
|
+
const [mine, theirs] = [valueAt(value, path), valueAt(side, path)];
|
|
119
|
+
return (isNothing(mine) && isNothing(theirs)) || equal(mine, theirs);
|
|
120
|
+
}
|
|
121
|
+
/** Whether a change has a place in the form value that an undo can set back. */
|
|
122
|
+
export const canSetBack = (change) => change.dataPath !== undefined;
|
|
123
|
+
/**
|
|
124
|
+
* `current` with `change` set to how `source` has it. A whole item added or removed is put in or
|
|
125
|
+
* taken out; a moved item goes back beside its neighbours in `source`; anything else takes
|
|
126
|
+
* `source`'s value at the change's path. Undefined when the change can't be placed in `current`,
|
|
127
|
+
* such as a field inside an item `current` no longer has. `bodyField` is the schema's body field
|
|
128
|
+
* on an md/mdx entry.
|
|
129
|
+
*/
|
|
130
|
+
export function setChangeBack(current, source, change, bodyField) {
|
|
131
|
+
if (!canSetBack(change))
|
|
132
|
+
return undefined;
|
|
133
|
+
const path = formPath(change, bodyField);
|
|
134
|
+
const last = path[path.length - 1];
|
|
135
|
+
let next;
|
|
136
|
+
if (change.item && last && 'item' in last) {
|
|
137
|
+
const listPath = path.slice(0, -1);
|
|
138
|
+
const order = valueAt(source, listPath);
|
|
139
|
+
const sourceList = Array.isArray(order) ? order : [];
|
|
140
|
+
const wanted = valueAt(source, path);
|
|
141
|
+
next = updateAt(current, listPath, (old) => {
|
|
142
|
+
const list = Array.isArray(old) ? old : [];
|
|
143
|
+
const index = indexOfKey(list, last.item);
|
|
144
|
+
if (wanted === undefined)
|
|
145
|
+
return index < 0 ? old : list.filter((_, i) => i !== index);
|
|
146
|
+
if (change.kind === 'moved') {
|
|
147
|
+
if (index < 0)
|
|
148
|
+
return NOT_PLACED;
|
|
149
|
+
const without = list.filter((_, i) => i !== index);
|
|
150
|
+
return placeLike(without, list[index], last.item, sourceList);
|
|
151
|
+
}
|
|
152
|
+
return index >= 0 ? old : placeLike(list, structuredClone(wanted), last.item, sourceList);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const wanted = valueAt(source, path);
|
|
157
|
+
next = updateAt(current, path, () => wanted === undefined ? undefined : structuredClone(wanted));
|
|
158
|
+
}
|
|
159
|
+
const placed = next && isPlainRecord(next.value) ? next.value : undefined;
|
|
160
|
+
return placed && withoutEmptyContainers(placed, source, path);
|
|
161
|
+
}
|
|
162
|
+
const isEmptyContainer = (value) => Array.isArray(value)
|
|
163
|
+
? value.length === 0
|
|
164
|
+
: isPlainRecord(value) && Object.keys(value).length === 0;
|
|
165
|
+
/**
|
|
166
|
+
* `value` without the records and lists on `path` that setting a change back left empty where
|
|
167
|
+
* `source` has none, so a value set back matches `source` rather than holding `seo: {}`.
|
|
168
|
+
*/
|
|
169
|
+
const withoutEmptyContainers = (value, source, path) => {
|
|
170
|
+
let out = value;
|
|
171
|
+
for (let end = path.length; end > 0; end--) {
|
|
172
|
+
const at = path.slice(0, end);
|
|
173
|
+
if (!('field' in at[at.length - 1]))
|
|
174
|
+
continue;
|
|
175
|
+
if (valueAt(source, at) !== undefined || !isEmptyContainer(valueAt(out, at)))
|
|
176
|
+
continue;
|
|
177
|
+
const next = updateAt(out, at, () => undefined);
|
|
178
|
+
if (next && isPlainRecord(next.value))
|
|
179
|
+
out = next.value;
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* The canopy path (`blocks[2].title`) of a change in `value`, which comment threads anchor to;
|
|
185
|
+
* undefined when an item on it isn't in `value`.
|
|
186
|
+
*/
|
|
187
|
+
export function canopyPathOf(value, change, bodyField) {
|
|
188
|
+
const out = [];
|
|
189
|
+
let at = value;
|
|
190
|
+
for (const segment of formPath(change, bodyField)) {
|
|
191
|
+
if ('item' in segment) {
|
|
192
|
+
const index = Array.isArray(at) ? indexOfKey(at, segment.item) : -1;
|
|
193
|
+
if (index < 0)
|
|
194
|
+
return undefined;
|
|
195
|
+
out.push(index);
|
|
196
|
+
}
|
|
197
|
+
else if ('field' in segment) {
|
|
198
|
+
out.push(segment.field);
|
|
199
|
+
}
|
|
200
|
+
at = step(at, segment);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** An item change's place in `source`'s list: -1 for an item `source` lacks; undefined otherwise. */
|
|
205
|
+
const sourcePosition = (source, change, bodyField) => {
|
|
206
|
+
if (!change.item)
|
|
207
|
+
return undefined;
|
|
208
|
+
const path = formPath(change, bodyField);
|
|
209
|
+
const last = path[path.length - 1];
|
|
210
|
+
if (!last || !('item' in last))
|
|
211
|
+
return undefined;
|
|
212
|
+
const list = valueAt(source, path.slice(0, -1));
|
|
213
|
+
return Array.isArray(list) ? indexOfKey(list, last.item) : -1;
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* `current` with every one of `changes` set to how `source` has it, leaving anything else, such
|
|
217
|
+
* as an unsaved edit to an unchanged field, as it is. Items are placed in `source`'s order, so
|
|
218
|
+
* each lands beside a neighbour already back in place and the list ends in `source`'s order. A
|
|
219
|
+
* change that can't be placed is skipped.
|
|
220
|
+
*/
|
|
221
|
+
export function setAllBack(current, source, changes, bodyField) {
|
|
222
|
+
const ordered = changes
|
|
223
|
+
.map((change) => ({ change, position: sourcePosition(source, change, bodyField) }))
|
|
224
|
+
.sort((a, b) => (a.position ?? -2) - (b.position ?? -2));
|
|
225
|
+
return ordered.reduce((value, { change }) => setChangeBack(value, source, change, bodyField) ?? value, current);
|
|
226
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.12.0";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.12.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|