canopycms 0.12.0 → 0.12.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.
- package/CHANGELOG.md +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +429 -52
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +90 -0
- package/dist/editor/conflict-cards.js +279 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +5 -0
- package/dist/git-manager.js +30 -0
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
package/dist/git-manager.js
CHANGED
|
@@ -1763,6 +1763,36 @@ export class GitManager {
|
|
|
1763
1763
|
throw err;
|
|
1764
1764
|
}
|
|
1765
1765
|
}
|
|
1766
|
+
/**
|
|
1767
|
+
* The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
|
|
1768
|
+
* clone has no such blob. Any other failure throws.
|
|
1769
|
+
*/
|
|
1770
|
+
async readBlob(sha, maxBytes) {
|
|
1771
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha))
|
|
1772
|
+
return undefined;
|
|
1773
|
+
let sizeText;
|
|
1774
|
+
try {
|
|
1775
|
+
sizeText = (await this.git.raw(['cat-file', '-s', '--end-of-options', sha])).trim();
|
|
1776
|
+
}
|
|
1777
|
+
catch (err) {
|
|
1778
|
+
// LC_ALL=C (gitChildEnv), so git's own wording is stable to match.
|
|
1779
|
+
if (/could not get object info|Not a valid object name/.test(getErrorMessage(err))) {
|
|
1780
|
+
return undefined;
|
|
1781
|
+
}
|
|
1782
|
+
throw err;
|
|
1783
|
+
}
|
|
1784
|
+
// simple-git resolves "" when git exits non-zero without writing to stderr, so anything but
|
|
1785
|
+
// digits is a failed read rather than an empty blob.
|
|
1786
|
+
if (!/^\d+$/.test(sizeText))
|
|
1787
|
+
throw new Error(`git cat-file -s ${sha} printed no size`);
|
|
1788
|
+
const size = Number(sizeText);
|
|
1789
|
+
if (size > maxBytes)
|
|
1790
|
+
return 'too-large';
|
|
1791
|
+
const text = await this.git.raw(['cat-file', 'blob', '--end-of-options', sha]);
|
|
1792
|
+
if (size > 0 && text === '')
|
|
1793
|
+
throw new Error(`git cat-file blob ${sha} printed nothing`);
|
|
1794
|
+
return text;
|
|
1795
|
+
}
|
|
1766
1796
|
async ensureAuthor(author) {
|
|
1767
1797
|
const config = (await this.git.listConfig());
|
|
1768
1798
|
// Verify this is a CanopyCMS-managed workspace before setting author
|
package/dist/item-keys.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export declare const ITEM_KEY = "_key";
|
|
|
12
12
|
export declare const isItemKey: (value: unknown) => value is string;
|
|
13
13
|
/** A new random item key: 12 Base58 characters. */
|
|
14
14
|
export declare function mintItemKey(): string;
|
|
15
|
+
/**
|
|
16
|
+
* True for a key with the shape {@link contentKey} gives, `h` and a 53-bit hash in at most 10
|
|
17
|
+
* Base58 digits (`-2` and on for a repeat): one a read wrote down. A key {@link mintItemKey}
|
|
18
|
+
* minted is 12 digits long, so it never has this shape.
|
|
19
|
+
*/
|
|
20
|
+
export declare const hasContentKeyShape: (key: string) => boolean;
|
|
21
|
+
/** True for a key a read or {@link mintItemKey} wrote, never one a person typed, such as `faq`. */
|
|
22
|
+
export declare const hasMachineKeyShape: (key: string) => boolean;
|
|
15
23
|
/**
|
|
16
24
|
* How `withItemKeys` keys an item that lacks a usable key, or repeats one an earlier item of its
|
|
17
25
|
* list holds:
|
|
@@ -31,6 +39,11 @@ type ListField = ObjectFieldConfig | BlockFieldConfig;
|
|
|
31
39
|
export declare function withItemKeys(fields: readonly FieldConfig[], data: Record<string, unknown>, fill: ItemKeyFill): Record<string, unknown>;
|
|
32
40
|
/** `item`, a copy about to join `field`'s list, with fresh keys for it and every item inside it. */
|
|
33
41
|
export declare function withFreshItemKeys<T>(field: ListField, item: T): T;
|
|
42
|
+
/**
|
|
43
|
+
* The key a read gives each item of a list, as `withItemKeys` with `content` does: its own usable
|
|
44
|
+
* key, else a hash of its content. Undefined for an item that isn't a record.
|
|
45
|
+
*/
|
|
46
|
+
export declare const itemKeysAsRead: (items: readonly unknown[]) => Array<string | undefined>;
|
|
34
47
|
/**
|
|
35
48
|
* The keys of the list items on `canopyPath` in `value`, outermost first: what a new field thread
|
|
36
49
|
* stores to name them. Undefined when an item on the path has no usable key or the path isn't
|
package/dist/item-keys.js
CHANGED
|
@@ -40,6 +40,14 @@ function contentKey(item) {
|
|
|
40
40
|
} while (n > 0);
|
|
41
41
|
return `h${out}`;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* True for a key with the shape {@link contentKey} gives, `h` and a 53-bit hash in at most 10
|
|
45
|
+
* Base58 digits (`-2` and on for a repeat): one a read wrote down. A key {@link mintItemKey}
|
|
46
|
+
* minted is 12 digits long, so it never has this shape.
|
|
47
|
+
*/
|
|
48
|
+
export const hasContentKeyShape = (key) => /^h[1-9A-HJ-NP-Za-km-z]{1,10}(-\d+)?$/.test(key);
|
|
49
|
+
/** True for a key a read or {@link mintItemKey} wrote, never one a person typed, such as `faq`. */
|
|
50
|
+
export const hasMachineKeyShape = (key) => hasContentKeyShape(key) || /^[1-9A-HJ-NP-Za-km-z]{12}$/.test(key);
|
|
43
51
|
const isListField = (field) => field.type === 'block' || (field.type === 'object' && !!field.list);
|
|
44
52
|
/**
|
|
45
53
|
* `data` with every object-list item and block keyed, each unique in its list, nested lists
|
|
@@ -72,6 +80,22 @@ export function withFreshItemKeys(field, item) {
|
|
|
72
80
|
return keyList(field, [item], 'fresh')[0];
|
|
73
81
|
}
|
|
74
82
|
function keyList(field, items, fill) {
|
|
83
|
+
const keys = listKeys(items, fill);
|
|
84
|
+
let changed = false;
|
|
85
|
+
const out = items.map((item, i) => {
|
|
86
|
+
if (!isPlainRecord(item))
|
|
87
|
+
return item;
|
|
88
|
+
let next = keyInside(field, item, fill);
|
|
89
|
+
if (keys[i] !== item[ITEM_KEY])
|
|
90
|
+
next = { ...next, [ITEM_KEY]: keys[i] };
|
|
91
|
+
if (next !== item)
|
|
92
|
+
changed = true;
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
return changed ? out : items;
|
|
96
|
+
}
|
|
97
|
+
/** The key each record of `items` ends with under `fill`; undefined for an item that isn't one. */
|
|
98
|
+
function listKeys(items, fill) {
|
|
75
99
|
const held = new Set();
|
|
76
100
|
if (fill !== 'fresh') {
|
|
77
101
|
for (const item of items) {
|
|
@@ -80,27 +104,25 @@ function keyList(field, items, fill) {
|
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
const kept = new Set();
|
|
83
|
-
|
|
84
|
-
const out = items.map((item) => {
|
|
107
|
+
return items.map((item) => {
|
|
85
108
|
if (!isPlainRecord(item))
|
|
86
|
-
return
|
|
87
|
-
let next = keyInside(field, item, fill);
|
|
109
|
+
return undefined;
|
|
88
110
|
const own = item[ITEM_KEY];
|
|
89
111
|
if (fill !== 'fresh' && isItemKey(own) && !kept.has(own)) {
|
|
90
112
|
kept.add(own);
|
|
113
|
+
return own;
|
|
91
114
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
next = { ...next, [ITEM_KEY]: key };
|
|
97
|
-
}
|
|
98
|
-
if (next !== item)
|
|
99
|
-
changed = true;
|
|
100
|
-
return next;
|
|
115
|
+
const key = fill === 'content' ? unheld(contentKey(item), held) : mintItemKey();
|
|
116
|
+
held.add(key);
|
|
117
|
+
kept.add(key);
|
|
118
|
+
return key;
|
|
101
119
|
});
|
|
102
|
-
return changed ? out : items;
|
|
103
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The key a read gives each item of a list, as `withItemKeys` with `content` does: its own usable
|
|
123
|
+
* key, else a hash of its content. Undefined for an item that isn't a record.
|
|
124
|
+
*/
|
|
125
|
+
export const itemKeysAsRead = (items) => listKeys(items, 'content');
|
|
104
126
|
/** `key`, or with a suffix when another item of the list already holds it (identical items). */
|
|
105
127
|
function unheld(key, held) {
|
|
106
128
|
let out = key;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names a place in a merged entry the way the "Show changes" drawer (`editor/entry-diff.ts`) names
|
|
3
|
+
* its changes, so a conflict and the drawer's change at the same place share `key` and
|
|
4
|
+
* `dataPath`: a field the schema knows joins the key with `.`, one it doesn't with `?`, a list item
|
|
5
|
+
* is named by its key as a read gives it, and a block's data is a `blockData` step.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldConfig } from '../config/index.js';
|
|
8
|
+
import type { DataPathSegment } from '../editor/entry-diff.js';
|
|
9
|
+
export declare const LOCATION_CONFLICT_KEY = "@location";
|
|
10
|
+
/**
|
|
11
|
+
* A step the merge took: a record's member, a merged list's item by its place, or, for an item no
|
|
12
|
+
* longer in the merged list, the key naming it.
|
|
13
|
+
*/
|
|
14
|
+
export type RawSegment = {
|
|
15
|
+
field: string;
|
|
16
|
+
} | {
|
|
17
|
+
index: number;
|
|
18
|
+
} | {
|
|
19
|
+
item: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* `at`, a path through `read` (the merged data as a read gives it, so items carry the keys the
|
|
23
|
+
* drawer names them by), as the drawer's key and data path. The drawer goes into a record the
|
|
24
|
+
* schema describes, an object list's items and a block whose template resolves; anywhere else it
|
|
25
|
+
* shows the whole value, so a deeper path is named there: `depth` is how many steps of `at` the
|
|
26
|
+
* name covers. `field` is the schema's field at the end of the whole path, when the schema reaches
|
|
27
|
+
* that far.
|
|
28
|
+
*/
|
|
29
|
+
export declare function describePath(fields: readonly FieldConfig[] | undefined, read: unknown, at: readonly RawSegment[]): {
|
|
30
|
+
key: string;
|
|
31
|
+
dataPath: DataPathSegment[];
|
|
32
|
+
depth: number;
|
|
33
|
+
field?: FieldConfig;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The drawer's key for the place `dataPath` names in `read`, under `fields`: how a conflict the
|
|
37
|
+
* worker recorded without the entry's schema, which names an unknown field with `?`, is named with
|
|
38
|
+
* it. A path deeper than the drawer goes is named where the drawer stops.
|
|
39
|
+
*/
|
|
40
|
+
export declare function keyOfDataPath(fields: readonly FieldConfig[], read: unknown, dataPath: readonly DataPathSegment[]): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names a place in a merged entry the way the "Show changes" drawer (`editor/entry-diff.ts`) names
|
|
3
|
+
* its changes, so a conflict and the drawer's change at the same place share `key` and
|
|
4
|
+
* `dataPath`: a field the schema knows joins the key with `.`, one it doesn't with `?`, a list item
|
|
5
|
+
* is named by its key as a read gives it, and a block's data is a `blockData` step.
|
|
6
|
+
*/
|
|
7
|
+
import { itemKeysAsRead } from '../item-keys.js';
|
|
8
|
+
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
9
|
+
import { isBlockStructuralKey } from '../validation/block-structural-keys.js';
|
|
10
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
11
|
+
export const LOCATION_CONFLICT_KEY = '@location';
|
|
12
|
+
const fieldNamed = (scope, name) => scope && flattenGroupFields(scope).find((f) => f.name === name);
|
|
13
|
+
const member = (node, name) => isPlainRecord(node) && Object.prototype.hasOwnProperty.call(node, name) ? node[name] : undefined;
|
|
14
|
+
const readKeys = new WeakMap();
|
|
15
|
+
/** The key a read gives each item of `items`, worked out once per list. */
|
|
16
|
+
const keysOf = (items) => {
|
|
17
|
+
let keys = readKeys.get(items);
|
|
18
|
+
if (!keys)
|
|
19
|
+
readKeys.set(items, (keys = itemKeysAsRead(items)));
|
|
20
|
+
return keys;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* `at`, a path through `read` (the merged data as a read gives it, so items carry the keys the
|
|
24
|
+
* drawer names them by), as the drawer's key and data path. The drawer goes into a record the
|
|
25
|
+
* schema describes, an object list's items and a block whose template resolves; anywhere else it
|
|
26
|
+
* shows the whole value, so a deeper path is named there: `depth` is how many steps of `at` the
|
|
27
|
+
* name covers. `field` is the schema's field at the end of the whole path, when the schema reaches
|
|
28
|
+
* that far.
|
|
29
|
+
*/
|
|
30
|
+
export function describePath(fields, read, at) {
|
|
31
|
+
let key = '';
|
|
32
|
+
const dataPath = [];
|
|
33
|
+
let scope = fields ?? [];
|
|
34
|
+
let list;
|
|
35
|
+
let mode = 'record';
|
|
36
|
+
let field;
|
|
37
|
+
let node = read;
|
|
38
|
+
let n = 0;
|
|
39
|
+
walk: for (; n < at.length; n++) {
|
|
40
|
+
const segment = at[n];
|
|
41
|
+
if (mode === 'list') {
|
|
42
|
+
if ('field' in segment)
|
|
43
|
+
break;
|
|
44
|
+
const items = Array.isArray(node) ? node : [];
|
|
45
|
+
const itemKey = 'item' in segment ? segment.item : (keysOf(items)[segment.index] ?? String(segment.index));
|
|
46
|
+
key = `${key}.${itemKey}`;
|
|
47
|
+
dataPath.push({ item: itemKey });
|
|
48
|
+
node = 'index' in segment ? items[segment.index] : undefined;
|
|
49
|
+
field = undefined;
|
|
50
|
+
if (list?.type === 'object') {
|
|
51
|
+
scope = list.fields ?? [];
|
|
52
|
+
mode = 'record';
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
mode = 'block';
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!('field' in segment))
|
|
60
|
+
break;
|
|
61
|
+
if (mode === 'block') {
|
|
62
|
+
// A block item: canonical `{ template, value }`, or inline `{ _type, ...data }`.
|
|
63
|
+
const template = member(node, 'template');
|
|
64
|
+
const inline = member(node, '_type');
|
|
65
|
+
const name = typeof template === 'string' ? template : inline;
|
|
66
|
+
const templateFields = list.templates.find((t) => t.name === name)?.fields;
|
|
67
|
+
if (!templateFields || isBlockStructuralKey(segment.field))
|
|
68
|
+
break;
|
|
69
|
+
dataPath.push({ blockData: true });
|
|
70
|
+
scope = templateFields;
|
|
71
|
+
mode = 'record';
|
|
72
|
+
if (typeof template === 'string') {
|
|
73
|
+
if (segment.field !== 'value')
|
|
74
|
+
break;
|
|
75
|
+
node = member(node, 'value');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const name = segment.field;
|
|
80
|
+
field = fieldNamed(scope, name);
|
|
81
|
+
key = field ? (key ? `${key}.${name}` : name) : `${key}?${name}`;
|
|
82
|
+
dataPath.push({ field: name });
|
|
83
|
+
node = member(node, name);
|
|
84
|
+
if (!field) {
|
|
85
|
+
n++;
|
|
86
|
+
break walk;
|
|
87
|
+
}
|
|
88
|
+
if (field.type === 'object' && !field.list) {
|
|
89
|
+
scope = field.fields ?? [];
|
|
90
|
+
}
|
|
91
|
+
else if (field.type === 'object' || field.type === 'block') {
|
|
92
|
+
list = field;
|
|
93
|
+
mode = 'list';
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
n++;
|
|
97
|
+
break walk;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { key, dataPath, depth: n, ...(n === at.length && field && { field }) };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The drawer's key for the place `dataPath` names in `read`, under `fields`: how a conflict the
|
|
104
|
+
* worker recorded without the entry's schema, which names an unknown field with `?`, is named with
|
|
105
|
+
* it. A path deeper than the drawer goes is named where the drawer stops.
|
|
106
|
+
*/
|
|
107
|
+
export function keyOfDataPath(fields, read, dataPath) {
|
|
108
|
+
const at = [];
|
|
109
|
+
let node = read;
|
|
110
|
+
for (const segment of dataPath) {
|
|
111
|
+
if ('field' in segment) {
|
|
112
|
+
at.push({ field: segment.field });
|
|
113
|
+
node = member(node, segment.field);
|
|
114
|
+
}
|
|
115
|
+
else if ('item' in segment) {
|
|
116
|
+
const items = Array.isArray(node) ? node : [];
|
|
117
|
+
const index = keysOf(items).indexOf(segment.item);
|
|
118
|
+
at.push(index >= 0 ? { index } : { item: segment.item });
|
|
119
|
+
node = index >= 0 ? items[index] : undefined;
|
|
120
|
+
}
|
|
121
|
+
else if (typeof member(node, 'template') === 'string') {
|
|
122
|
+
// A canonical block's data is its `value`; an inline block's is the item itself.
|
|
123
|
+
at.push({ field: 'value' });
|
|
124
|
+
node = member(node, 'value');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return describePath(fields, read, at).key;
|
|
128
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff3 over sequences of strings: where the base and the branch each changed the ancestor, as
|
|
3
|
+
* regions that are either one version's run of elements or a run both sides changed. Built on
|
|
4
|
+
* jsdiff's Myers diff, which stays fast on long input with many equal elements (blank lines, a
|
|
5
|
+
* repeated tag), where an LCS by candidate lists grows with the cube of the length on such input.
|
|
6
|
+
*/
|
|
7
|
+
/** A run `[start, end)` of one version. */
|
|
8
|
+
type Span = [number, number];
|
|
9
|
+
export type Diff3Region = {
|
|
10
|
+
stable: true;
|
|
11
|
+
side: 'ancestor' | 'base' | 'branch';
|
|
12
|
+
span: Span;
|
|
13
|
+
} | {
|
|
14
|
+
stable: false;
|
|
15
|
+
ancestor: Span;
|
|
16
|
+
base: Span;
|
|
17
|
+
branch: Span;
|
|
18
|
+
};
|
|
19
|
+
/** The pairs of equal elements a Myers diff keeps, in order; undefined when it ran out of time. */
|
|
20
|
+
export declare function commonPairs(x: readonly string[], y: readonly string[]): Array<[number, number]> | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The regions of a three-way merge of `base` and `branch` from `ancestor`, in order: hunks of the
|
|
23
|
+
* two sides that overlap or touch in the ancestor make one unstable region, as in GNU diff3 and
|
|
24
|
+
* git. Undefined when a diff ran out of time.
|
|
25
|
+
*/
|
|
26
|
+
export declare function diff3Regions(ancestor: readonly string[], base: readonly string[], branch: readonly string[]): Diff3Region[] | undefined;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff3 over sequences of strings: where the base and the branch each changed the ancestor, as
|
|
3
|
+
* regions that are either one version's run of elements or a run both sides changed. Built on
|
|
4
|
+
* jsdiff's Myers diff, which stays fast on long input with many equal elements (blank lines, a
|
|
5
|
+
* repeated tag), where an LCS by candidate lists grows with the cube of the length on such input.
|
|
6
|
+
*/
|
|
7
|
+
import { diffArrays } from 'diff';
|
|
8
|
+
/** How long one two-way diff may take before the caller treats the value as one whole. */
|
|
9
|
+
const DIFF_TIMEOUT_MS = 500;
|
|
10
|
+
/** Where `changed` differs from `ancestor`, as hunks; undefined when the diff ran out of time. */
|
|
11
|
+
function hunks(ancestor, changed, side) {
|
|
12
|
+
const changes = diffArrays([...ancestor], [...changed], { timeout: DIFF_TIMEOUT_MS });
|
|
13
|
+
if (!changes)
|
|
14
|
+
return undefined;
|
|
15
|
+
const out = [];
|
|
16
|
+
let [o, x] = [0, 0];
|
|
17
|
+
let open;
|
|
18
|
+
for (const change of changes) {
|
|
19
|
+
const count = change.count ?? change.value.length;
|
|
20
|
+
if (!change.added && !change.removed) {
|
|
21
|
+
open = undefined;
|
|
22
|
+
o += count;
|
|
23
|
+
x += count;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!open) {
|
|
27
|
+
open = { side, ancestor: [o, o], changed: [x, x] };
|
|
28
|
+
out.push(open);
|
|
29
|
+
}
|
|
30
|
+
if (change.removed)
|
|
31
|
+
open.ancestor[1] = o += count;
|
|
32
|
+
else
|
|
33
|
+
open.changed[1] = x += count;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** The pairs of equal elements a Myers diff keeps, in order; undefined when it ran out of time. */
|
|
38
|
+
export function commonPairs(x, y) {
|
|
39
|
+
const changes = diffArrays([...x], [...y], { timeout: DIFF_TIMEOUT_MS });
|
|
40
|
+
if (!changes)
|
|
41
|
+
return undefined;
|
|
42
|
+
const pairs = [];
|
|
43
|
+
let [i, j] = [0, 0];
|
|
44
|
+
for (const change of changes) {
|
|
45
|
+
const count = change.count ?? change.value.length;
|
|
46
|
+
if (change.added)
|
|
47
|
+
j += count;
|
|
48
|
+
else if (change.removed)
|
|
49
|
+
i += count;
|
|
50
|
+
else
|
|
51
|
+
for (let n = 0; n < count; n++)
|
|
52
|
+
pairs.push([i++, j++]);
|
|
53
|
+
}
|
|
54
|
+
return pairs;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The regions of a three-way merge of `base` and `branch` from `ancestor`, in order: hunks of the
|
|
58
|
+
* two sides that overlap or touch in the ancestor make one unstable region, as in GNU diff3 and
|
|
59
|
+
* git. Undefined when a diff ran out of time.
|
|
60
|
+
*/
|
|
61
|
+
export function diff3Regions(ancestor, base, branch) {
|
|
62
|
+
const baseHunks = hunks(ancestor, base, 'base');
|
|
63
|
+
const branchHunks = hunks(ancestor, branch, 'branch');
|
|
64
|
+
if (!baseHunks || !branchHunks)
|
|
65
|
+
return undefined;
|
|
66
|
+
const all = [...baseHunks, ...branchHunks].sort((p, q) => p.ancestor[0] - q.ancestor[0]);
|
|
67
|
+
const regions = [];
|
|
68
|
+
let at = 0;
|
|
69
|
+
const advanceTo = (end) => {
|
|
70
|
+
if (end > at)
|
|
71
|
+
regions.push({ stable: true, side: 'ancestor', span: [at, end] });
|
|
72
|
+
at = Math.max(at, end);
|
|
73
|
+
};
|
|
74
|
+
for (let n = 0; n < all.length;) {
|
|
75
|
+
const first = all[n];
|
|
76
|
+
const [start] = first.ancestor;
|
|
77
|
+
let end = first.ancestor[1];
|
|
78
|
+
const group = [first];
|
|
79
|
+
for (n++; n < all.length && all[n].ancestor[0] <= end; n++) {
|
|
80
|
+
end = Math.max(end, all[n].ancestor[1]);
|
|
81
|
+
group.push(all[n]);
|
|
82
|
+
}
|
|
83
|
+
advanceTo(start);
|
|
84
|
+
if (group.length === 1) {
|
|
85
|
+
const { side, changed } = first;
|
|
86
|
+
if (changed[1] > changed[0])
|
|
87
|
+
regions.push({ stable: true, side, span: changed });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Each side's run: its own hunks' extent, widened by the ancestor lines it left alone at
|
|
91
|
+
// the edges of the region.
|
|
92
|
+
const sideSpan = (side) => {
|
|
93
|
+
const own = group.filter((h) => h.side === side);
|
|
94
|
+
if (own.length === 0)
|
|
95
|
+
return [start, end];
|
|
96
|
+
const lo = Math.min(...own.map((h) => h.changed[0]));
|
|
97
|
+
const hi = Math.max(...own.map((h) => h.changed[1]));
|
|
98
|
+
const oLo = Math.min(...own.map((h) => h.ancestor[0]));
|
|
99
|
+
const oHi = Math.max(...own.map((h) => h.ancestor[1]));
|
|
100
|
+
return [lo - (oLo - start), hi + (end - oHi)];
|
|
101
|
+
};
|
|
102
|
+
regions.push({
|
|
103
|
+
stable: false,
|
|
104
|
+
ancestor: [start, end],
|
|
105
|
+
base: sideSpan('base'),
|
|
106
|
+
branch: sideSpan('branch'),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
at = Math.max(at, end);
|
|
110
|
+
}
|
|
111
|
+
advanceTo(ancestor.length);
|
|
112
|
+
return regions;
|
|
113
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way merge of one entry, field by field: the version the branch started from (ancestor),
|
|
3
|
+
* the base branch's version now (base), and the branch's own (branch). A value changed on one side
|
|
4
|
+
* takes that change; a value changed alike on both takes it once; a value changed differently on
|
|
5
|
+
* both is a conflict, and the merged entry holds the branch's value there until a person chooses.
|
|
6
|
+
*
|
|
7
|
+
* Pure and client-safe. Schema-light: the input is the RAW parsed data (`parseEntryParts`), never
|
|
8
|
+
* filtered by a schema, so a key the schema doesn't know merges like any other and a base's schema
|
|
9
|
+
* migration survives. The schema, when given, only names conflicts as the "Show changes" drawer
|
|
10
|
+
* names its changes.
|
|
11
|
+
*/
|
|
12
|
+
import type { ContentFormat, FieldConfig } from '../config/index.js';
|
|
13
|
+
import type { DataPathSegment } from '../editor/entry-diff.js';
|
|
14
|
+
import { type TextConflictRegion } from './text-merge.js';
|
|
15
|
+
/** One version of an entry: its raw data, its md/mdx body, and its entry type (from its name). */
|
|
16
|
+
export interface MergeSide {
|
|
17
|
+
data: Record<string, unknown>;
|
|
18
|
+
body?: string;
|
|
19
|
+
entryType?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface EntryMergeInput {
|
|
22
|
+
format: ContentFormat;
|
|
23
|
+
ancestor: MergeSide;
|
|
24
|
+
base: MergeSide;
|
|
25
|
+
branch: MergeSide;
|
|
26
|
+
/** The branch's entry type's fields, when the caller has them. */
|
|
27
|
+
fields?: readonly FieldConfig[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* - `changed`: changed differently on both sides, or added on both with different values;
|
|
31
|
+
* - `removed-on-base`: the base removed it and the branch changed it;
|
|
32
|
+
* - `removed-by-you`: the branch removed it and the base changed it.
|
|
33
|
+
*/
|
|
34
|
+
export type FieldConflictKind = 'changed' | 'removed-on-base' | 'removed-by-you';
|
|
35
|
+
export interface FieldConflict {
|
|
36
|
+
/** The drawer's `EntryChange.key` for this place: list items named by key. */
|
|
37
|
+
key: string;
|
|
38
|
+
dataPath: DataPathSegment[];
|
|
39
|
+
/**
|
|
40
|
+
* Where each version holds this place, as a read of that version names it: a list item the merge
|
|
41
|
+
* paired with another side's can read under another key there, as one in a list a side left
|
|
42
|
+
* keyless does. Absent for a version without the place.
|
|
43
|
+
*/
|
|
44
|
+
sidePaths: SidePaths;
|
|
45
|
+
kind: FieldConflictKind;
|
|
46
|
+
/** A whole list item or block, rather than a field's value. */
|
|
47
|
+
item?: true;
|
|
48
|
+
/** Each version's value; absent where that version doesn't have it. */
|
|
49
|
+
ancestor?: unknown;
|
|
50
|
+
base?: unknown;
|
|
51
|
+
branch?: unknown;
|
|
52
|
+
/** Long text: the regions both sides changed; the rest of the text merged. */
|
|
53
|
+
regions?: TextConflictRegion[];
|
|
54
|
+
/** The line merge of an mdx body or field did not parse, so the whole value is the conflict. */
|
|
55
|
+
unparseable?: true;
|
|
56
|
+
}
|
|
57
|
+
/** Each version's own data path to a place. */
|
|
58
|
+
export interface SidePaths {
|
|
59
|
+
ancestor?: DataPathSegment[];
|
|
60
|
+
base?: DataPathSegment[];
|
|
61
|
+
branch?: DataPathSegment[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A list item whose key the merge changed from the branch's to the base's: the branch's comment
|
|
65
|
+
* threads that name `from` inside the items `within` (the branch's keys, outermost first) now
|
|
66
|
+
* name `to`.
|
|
67
|
+
*/
|
|
68
|
+
export interface KeyRemap {
|
|
69
|
+
within: string[];
|
|
70
|
+
from: string;
|
|
71
|
+
to: string;
|
|
72
|
+
}
|
|
73
|
+
export type EntryMergeResult = {
|
|
74
|
+
kind: 'merged';
|
|
75
|
+
data: Record<string, unknown>;
|
|
76
|
+
body?: string;
|
|
77
|
+
conflicts: FieldConflict[];
|
|
78
|
+
keyRemaps: KeyRemap[];
|
|
79
|
+
}
|
|
80
|
+
/** The entry type changed on one side and the content on the other: no field merge applies. */
|
|
81
|
+
| {
|
|
82
|
+
kind: 'entry-conflict';
|
|
83
|
+
reason: 'entry-type';
|
|
84
|
+
};
|
|
85
|
+
export declare function mergeEntry({ format, ancestor, base, branch, fields, }: EntryMergeInput): EntryMergeResult;
|
|
86
|
+
/**
|
|
87
|
+
* Three-way merge of a collection's `.collection.json`: `order` as an ordered set of content IDs,
|
|
88
|
+
* so two branches that each add an entry both keep theirs; `entries` matched by `name`; every
|
|
89
|
+
* other member by the entry rules.
|
|
90
|
+
*/
|
|
91
|
+
export declare function mergeCollectionMeta(ancestor: Record<string, unknown>, base: Record<string, unknown>, branch: Record<string, unknown>): {
|
|
92
|
+
data: Record<string, unknown>;
|
|
93
|
+
conflicts: FieldConflict[];
|
|
94
|
+
};
|