canopycms 0.11.0 → 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.
@@ -22,6 +22,12 @@ export const EDITOR_ACTIONS = {
22
22
  changeUrl: 'Change URL',
23
23
  changeSlug: 'Change slug',
24
24
  showChanges: 'Show changes',
25
+ undoChange: 'Undo change',
26
+ redoChange: 'Redo change',
27
+ undoAllChanges: 'Undo all changes',
28
+ redoAllChanges: 'Redo all changes',
29
+ restoreEntry: 'Restore entry',
30
+ deleteEntry: 'Delete entry',
25
31
  };
26
32
  /**
27
33
  * Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
@@ -37,6 +43,8 @@ export const rewrittenSubmitReason = (isAdmin) => isAdmin
37
43
  * says how to finish the submit, where it can be retried at all.
38
44
  */
39
45
  export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
46
+ /** Why an entry's form, and every control that would change the entry, can't act for this user. */
47
+ export const NO_EDIT_ACCESS_REASON = "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it.";
40
48
  /** Why only an admin may delete a branch that may hold commits nothing else has. */
41
49
  export const DELETE_NEEDS_ADMIN_REASON = 'This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.';
42
50
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
@@ -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
- this.fields(a.fields, b.data, a.data, where);
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
- const started = Date.now();
591
- const walker = new Walker(referenceTitle, started + DIFF_TIMEOUT_MS, started + SHORT_DIFF_TIMEOUT_MS);
592
- const root = { path: [], key: '' };
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.11.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.11.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.11.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": {