canopycms 0.11.1 → 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.
Files changed (158) hide show
  1. package/CHANGELOG.md +174 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-changes.d.ts +1 -0
  4. package/dist/api/branch-changes.js +1 -1
  5. package/dist/api/branch-entry-changes.d.ts +92 -7
  6. package/dist/api/branch-entry-changes.js +272 -38
  7. package/dist/api/branch-review.js +45 -13
  8. package/dist/api/branch-status.d.ts +12 -0
  9. package/dist/api/branch-status.js +13 -0
  10. package/dist/api/branch-update-from-base.d.ts +23 -0
  11. package/dist/api/branch-update-from-base.js +176 -0
  12. package/dist/api/branch.d.ts +5 -0
  13. package/dist/api/branch.js +1 -0
  14. package/dist/api/client.d.ts +17 -2
  15. package/dist/api/client.js +26 -0
  16. package/dist/api/comments.d.ts +2 -2
  17. package/dist/api/content.d.ts +38 -1
  18. package/dist/api/content.js +41 -5
  19. package/dist/api/merge-conflicts.d.ts +226 -0
  20. package/dist/api/merge-conflicts.js +674 -0
  21. package/dist/api/routes.js +2 -0
  22. package/dist/api/types.d.ts +7 -3
  23. package/dist/base-update.d.ts +26 -0
  24. package/dist/base-update.js +31 -0
  25. package/dist/branch-metadata-file.d.ts +51 -0
  26. package/dist/branch-metadata-file.js +11 -0
  27. package/dist/branch-schema-cache.d.ts +12 -0
  28. package/dist/branch-schema-cache.js +27 -2
  29. package/dist/cli/backfill-item-keys.d.ts +77 -0
  30. package/dist/cli/backfill-item-keys.js +320 -0
  31. package/dist/cli/cli.d.ts +1 -1
  32. package/dist/cli/cli.js +9713 -1493
  33. package/dist/cli/generate-ai-content.js +415 -244
  34. package/dist/cli/init.js +6 -0
  35. package/dist/cli/site-config.d.ts +14 -2
  36. package/dist/cli/site-config.js +20 -4
  37. package/dist/comment-store.d.ts +8 -0
  38. package/dist/comment-store.js +28 -0
  39. package/dist/content-index-generation.d.ts +1 -4
  40. package/dist/content-index-generation.js +1 -4
  41. package/dist/content-store.d.ts +65 -3
  42. package/dist/content-store.js +121 -15
  43. package/dist/editor/BranchManager.d.ts +4 -1
  44. package/dist/editor/BranchManager.js +9 -5
  45. package/dist/editor/Editor.js +549 -53
  46. package/dist/editor/EntryNavigator.d.ts +10 -2
  47. package/dist/editor/EntryNavigator.js +12 -4
  48. package/dist/editor/FormRenderer.d.ts +8 -2
  49. package/dist/editor/FormRenderer.js +5 -6
  50. package/dist/editor/base-update-wait.d.ts +74 -0
  51. package/dist/editor/base-update-wait.js +91 -0
  52. package/dist/editor/branch-status.js +1 -1
  53. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  54. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  55. package/dist/editor/components/BranchPill.d.ts +4 -1
  56. package/dist/editor/components/BranchPill.js +5 -2
  57. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  58. package/dist/editor/components/ConflictsGroup.js +364 -0
  59. package/dist/editor/components/EditorHeader.d.ts +16 -1
  60. package/dist/editor/components/EditorHeader.js +92 -30
  61. package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
  62. package/dist/editor/components/EntryChangesDrawer.js +164 -162
  63. package/dist/editor/components/EntryHeader.d.ts +3 -1
  64. package/dist/editor/components/EntryHeader.js +4 -6
  65. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  66. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  67. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  68. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  69. package/dist/editor/components/RetryToastMessage.js +1 -1
  70. package/dist/editor/components/change-parts.d.ts +51 -0
  71. package/dist/editor/components/change-parts.js +171 -0
  72. package/dist/editor/conflict-cards.d.ts +90 -0
  73. package/dist/editor/conflict-cards.js +279 -0
  74. package/dist/editor/conflict-choices.d.ts +60 -0
  75. package/dist/editor/conflict-choices.js +108 -0
  76. package/dist/editor/conflict-notice.d.ts +25 -0
  77. package/dist/editor/conflict-notice.js +39 -0
  78. package/dist/editor/copy.d.ts +20 -0
  79. package/dist/editor/copy.js +20 -0
  80. package/dist/editor/entry-diff.d.ts +18 -2
  81. package/dist/editor/entry-diff.js +26 -32
  82. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  83. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  84. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  85. package/dist/editor/hooks/useBranchManager.js +28 -7
  86. package/dist/editor/hooks/useBranchesData.js +3 -0
  87. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  88. package/dist/editor/hooks/useConflictChoices.js +75 -0
  89. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  90. package/dist/editor/hooks/useConflictsData.js +89 -0
  91. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  92. package/dist/editor/hooks/useDraftManager.js +24 -8
  93. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  94. package/dist/editor/hooks/useEntryManager.js +5 -0
  95. package/dist/editor/release-hold.d.ts +12 -0
  96. package/dist/editor/release-hold.js +39 -0
  97. package/dist/editor/server-clock.d.ts +4 -0
  98. package/dist/editor/server-clock.js +15 -0
  99. package/dist/editor/undo-change.d.ts +52 -0
  100. package/dist/editor/undo-change.js +241 -0
  101. package/dist/git-manager.d.ts +5 -0
  102. package/dist/git-manager.js +30 -0
  103. package/dist/item-keys.d.ts +13 -0
  104. package/dist/item-keys.js +36 -14
  105. package/dist/merge/conflict-path.d.ts +40 -0
  106. package/dist/merge/conflict-path.js +128 -0
  107. package/dist/merge/diff3.d.ts +27 -0
  108. package/dist/merge/diff3.js +113 -0
  109. package/dist/merge/entry-merge.d.ts +94 -0
  110. package/dist/merge/entry-merge.js +875 -0
  111. package/dist/merge/list-alignment.d.ts +52 -0
  112. package/dist/merge/list-alignment.js +441 -0
  113. package/dist/merge/text-merge.d.ts +29 -0
  114. package/dist/merge/text-merge.js +59 -0
  115. package/dist/merge/value-equality.d.ts +18 -0
  116. package/dist/merge/value-equality.js +50 -0
  117. package/dist/merge-conflicts.d.ts +232 -0
  118. package/dist/merge-conflicts.js +451 -0
  119. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  120. package/dist/operating-mode/client-safe-strategy.js +6 -0
  121. package/dist/operating-mode/types.d.ts +5 -0
  122. package/dist/services.d.ts +16 -1
  123. package/dist/services.js +82 -22
  124. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  125. package/dist/types.d.ts +28 -2
  126. package/dist/utils/content-serialize.d.ts +18 -1
  127. package/dist/utils/content-serialize.js +12 -0
  128. package/dist/utils/content-write-lock.d.ts +3 -3
  129. package/dist/utils/content-write-lock.js +3 -3
  130. package/dist/utils/markdown-body-splice.d.ts +1 -1
  131. package/dist/utils/markdown-body-splice.js +1 -19
  132. package/dist/utils/markdown-parse.d.ts +9 -0
  133. package/dist/utils/markdown-parse.js +27 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/worker/branch-merge.d.ts +53 -37
  137. package/dist/worker/branch-merge.js +139 -54
  138. package/dist/worker/cms-worker.d.ts +16 -3
  139. package/dist/worker/cms-worker.js +59 -12
  140. package/dist/worker/field-merge.d.ts +65 -0
  141. package/dist/worker/field-merge.js +636 -0
  142. package/dist/worker/git-sync.d.ts +1 -1
  143. package/dist/worker/git-sync.js +7 -6
  144. package/dist/worker/github-commits.d.ts +42 -10
  145. package/dist/worker/github-commits.js +287 -47
  146. package/dist/worker/github-gateway-http.js +17 -9
  147. package/dist/worker/github-gateway-wire.d.ts +21 -12
  148. package/dist/worker/github-gateway-wire.js +5 -1
  149. package/dist/worker/github-gateway.d.ts +10 -1
  150. package/dist/worker/github-gateway.js +18 -6
  151. package/dist/worker/github-transfer.d.ts +5 -3
  152. package/dist/worker/github-transfer.js +8 -5
  153. package/dist/worker/rebase.d.ts +5 -4
  154. package/dist/worker/rebase.js +293 -156
  155. package/dist/worker/task-runner.d.ts +1 -1
  156. package/dist/worker/task-runner.js +14 -2
  157. package/dist/worker/worker-context.d.ts +18 -2
  158. package/package.json +1 -1
@@ -0,0 +1,171 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * How the "Show changes" drawer shows a value and a text change, shared by its change cards and its
5
+ * conflict cards: marked text, values as the form would show them, and the drawer's reasoned button.
6
+ */
7
+ import React, { useEffect, useRef, useState } from 'react';
8
+ import { Button, Group, List, Stack, Text, Tooltip, VisuallyHidden } from '@mantine/core';
9
+ import { IconPhotoOff } from '@tabler/icons-react';
10
+ import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
11
+ import { useAssetContext } from '../context/index.js';
12
+ import { editorImageSrc } from '../media/editor-image-src.js';
13
+ import { formatRelativeTime } from '../relative-time.js';
14
+ /**
15
+ * Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
16
+ * `red.2` in light, `red.6` at 30% over the dark card.
17
+ */
18
+ const REMOVED_STYLE = {
19
+ background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
20
+ color: 'inherit',
21
+ textDecoration: 'line-through',
22
+ textDecorationThickness: 2,
23
+ };
24
+ /** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
25
+ const ADDED_STYLE = {
26
+ background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
27
+ color: 'inherit',
28
+ textDecoration: 'underline',
29
+ textDecorationThickness: 2,
30
+ };
31
+ export const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
32
+ /** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
33
+ const COLLAPSED_LINES = 50;
34
+ const LONG_TEXT_LINES = 200;
35
+ /** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
36
+ const WHOLE_LINE_SHARE = 0.6;
37
+ export const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
38
+ export const ordinal = (n) => {
39
+ const tens = n % 100;
40
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
41
+ return `${n}${suffix}`;
42
+ };
43
+ /**
44
+ * `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
45
+ * so a screen reader hears one removal, not one per word.
46
+ */
47
+ const sideRuns = (parts, side) => {
48
+ const runs = [];
49
+ for (const part of parts) {
50
+ if (side === 'before' ? part.added : part.removed)
51
+ continue;
52
+ const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
53
+ const last = runs[runs.length - 1];
54
+ const gap = runs[runs.length - 2];
55
+ if (mark && last && last.mark === mark) {
56
+ last.value += part.value;
57
+ }
58
+ else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
59
+ runs.pop();
60
+ gap.value += last.value + part.value;
61
+ }
62
+ else {
63
+ runs.push({ value: part.value, ...(mark && { mark }) });
64
+ }
65
+ }
66
+ return runs;
67
+ };
68
+ const WHITESPACE_MARKS = {
69
+ ' ': { shown: '·', said: 'space' },
70
+ '\t': { shown: '→', said: 'tab' },
71
+ '\n': { shown: '⏎', said: 'line break' },
72
+ };
73
+ /** A run's text; whitespace alone shows a mark for each character, said in words. */
74
+ const RunText = ({ value }) => {
75
+ if (value === '')
76
+ return _jsx("em", { children: "(blank line)" });
77
+ if (!/^\s+$/.test(value))
78
+ return _jsx(_Fragment, { children: value });
79
+ const chars = [...value];
80
+ return (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: chars.map((c) => WHITESPACE_MARKS[c]?.shown ?? c).join('') }), _jsx(VisuallyHidden, { component: "span", children: chars.map((c) => WHITESPACE_MARKS[c]?.said ?? 'space').join(', ') })] }));
81
+ };
82
+ const MARK_WORDS = {
83
+ removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
84
+ added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
85
+ };
86
+ /**
87
+ * One side's text with its runs marked by a strike or an underline and by words, never colour
88
+ * alone. A line mostly changed says so once, up front, instead of run by run.
89
+ */
90
+ export const MarkedText = ({ parts, side, unit }) => {
91
+ const runs = sideRuns(parts, side);
92
+ const total = runs.reduce((n, run) => n + run.value.length, 0);
93
+ const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
94
+ const mark = side === 'before' ? 'removed' : 'added';
95
+ const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
96
+ return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
97
+ if (!run.mark)
98
+ return _jsx(React.Fragment, { children: run.value }, i);
99
+ const Tag = run.mark === 'removed' ? 'del' : 'ins';
100
+ const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
101
+ // Whitespace alone, or a blank line's label, has nothing for a line to cross.
102
+ const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
103
+ return (_jsxs(Tag, { style: shown, children: [!whole && (_jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].start })), _jsx(RunText, { value: run.value }), !whole && _jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].end })] }, i));
104
+ })] }));
105
+ };
106
+ const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
107
+ /** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
108
+ const Thumbnail = ({ src }) => {
109
+ const { baseUrl } = useAssetContext();
110
+ const [failed, setFailed] = useState(false);
111
+ if (failed) {
112
+ return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
113
+ }
114
+ return (_jsx("img", { src: editorImageSrc(src, baseUrl, { width: EDITOR_THUMBNAIL_WIDTHS.listRow }), alt: "", width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, onError: () => setFailed(true), style: { objectFit: 'cover', borderRadius: 4, flexShrink: 0 } }));
115
+ };
116
+ /** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
117
+ const LongText = ({ text }) => {
118
+ const [expanded, setExpanded] = useState(false);
119
+ const firstNew = useRef(null);
120
+ useEffect(() => {
121
+ if (expanded)
122
+ firstNew.current?.focus();
123
+ }, [expanded]);
124
+ const lines = text.split('\n');
125
+ const long = lines.length > LONG_TEXT_LINES;
126
+ const collapsed = !expanded && long;
127
+ return (_jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: !long ? (text) : (_jsxs(_Fragment, { children: [lines.slice(0, COLLAPSED_LINES).join('\n'), expanded && (_jsxs(_Fragment, { children: ['\n', _jsx("span", { ref: firstNew, tabIndex: -1, children: lines.slice(COLLAPSED_LINES).join('\n') })] }))] })) }), collapsed && (_jsxs(Button, { size: "compact-sm", variant: "subtle", onClick: () => setExpanded(true), children: ["Show all ", lines.length, " lines"] }))] }));
128
+ };
129
+ /** A value as the form would show it: titles for references, file names for images. */
130
+ export const Value = ({ value, alt, }) => {
131
+ switch (value.kind) {
132
+ case 'empty':
133
+ return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
134
+ case 'text':
135
+ return _jsx(LongText, { text: value.text });
136
+ case 'number':
137
+ return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
138
+ case 'boolean':
139
+ return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
140
+ case 'date':
141
+ return _jsx(DateValue, { value: value });
142
+ case 'select':
143
+ return _jsx(Text, { size: "sm", children: value.label });
144
+ case 'reference':
145
+ return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
146
+ case 'image':
147
+ return (_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(Thumbnail, { src: value.src }, value.src), _jsxs(Stack, { gap: 0, miw: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: value.name }), value.alt && (_jsxs(Text, { size: "xs", style: TEXT_STYLE, children: [_jsxs(Text, { span: true, size: "xs", c: "dimmed", children: ["Alt text:", ' '] }), alt?.text.mode === 'words' ? (_jsx(MarkedText, { parts: alt.text.parts, side: alt.side, unit: "text" })) : (value.alt)] }))] })] }));
148
+ case 'position':
149
+ return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
150
+ case 'list':
151
+ return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
152
+ case 'fields':
153
+ return (_jsx(Stack, { gap: 6, children: value.fields.map((field, i) => (_jsxs("div", { children: [_jsx(Text, { size: "xs", fw: 500, children: field.label }), _jsx(Value, { value: field.value })] }, i))) }));
154
+ case 'raw':
155
+ return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
156
+ }
157
+ };
158
+ /**
159
+ * A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
160
+ * place the editor shows a date absolute first and relative after. A day has no relative form: it
161
+ * may be a future event, and a past one is not hours ago.
162
+ */
163
+ export const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), !value.dateOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.iso) }))] }));
164
+ /**
165
+ * A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
166
+ * why (`data-disabled` keeps the Tooltip working on focus and touch).
167
+ */
168
+ export const ReasonedButton = ({ reason, onClick, danger, ...props }) => (_jsx(Tooltip, { label: reason, disabled: !reason, multiline: true, maw: 280, position: "bottom-start", children: _jsx(Button, { size: "compact-sm", variant: "subtle", ...(danger && { color: 'red', c: 'var(--canopy-danger-text)' }), ...props, onClick: () => {
169
+ if (!reason)
170
+ onClick();
171
+ }, "data-disabled": reason ? true : undefined, "aria-disabled": reason ? true : undefined }) }));
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The cards "Show changes" shows for an entry's open conflicts, built from what
3
+ * `GET /:branch/conflicts/...entryPath` answers (api/merge-conflicts.ts): each place's three
4
+ * versions as the drawer shows values, and long text split into the regions both sides changed.
5
+ * Pure. Where a choice lands in the form value is `conflict-choices.ts`.
6
+ */
7
+ import type { EntryConflictData } from '../api/merge-conflicts.js';
8
+ import type { FieldConfig } from '../config/index.js';
9
+ import type { FieldConflictKind } from '../merge/entry-merge.js';
10
+ import { type TextConflictRegion } from '../merge/text-merge.js';
11
+ import { type DataPathSegment, type DisplayValue, type TextDiff } from './entry-diff.js';
12
+ type FormValue = Record<string, unknown>;
13
+ interface CardBase {
14
+ /** Unique among the entry's cards: the conflict's key, and a region's number within it. */
15
+ id: string;
16
+ /** The record's key, which a save's `resolvedConflicts` sends back. */
17
+ key: string;
18
+ mergeSha: string;
19
+ /** Human labels from the entry down to the place: "Page sections › Hero › Heading". */
20
+ path: string[];
21
+ }
22
+ /** A value both sides changed: a field, a whole list item, or a value the drawer shows whole. */
23
+ interface ValueConflictCard extends CardBase {
24
+ type: 'value';
25
+ conflict: FieldConflictKind;
26
+ /** A whole list item or block, which the choice keeps or removes. */
27
+ item: boolean;
28
+ before: DisplayValue;
29
+ latest: DisplayValue;
30
+ yours: DisplayValue;
31
+ /** Word or line marks from Before to the latest version, and from Before to yours. */
32
+ latestText?: TextDiff;
33
+ yoursText?: TextDiff;
34
+ /** The latest version's value here can't be found ({@link FieldConflict.latestPlaceable}). */
35
+ latestUnavailable?: true;
36
+ }
37
+ /** Lines of long text both sides changed, one card per region of a field. */
38
+ interface RegionConflictCard extends CardBase {
39
+ type: 'region';
40
+ /** 0-based, among the field's regions. */
41
+ index: number;
42
+ count: number;
43
+ before: string;
44
+ latest: string;
45
+ yours: string;
46
+ latestText?: TextDiff;
47
+ yoursText?: TextDiff;
48
+ }
49
+ /** The entry was moved to different places on each side; the branch's place stands. */
50
+ interface LocationConflictCard extends CardBase {
51
+ type: 'location';
52
+ }
53
+ export type ConflictCard = ValueConflictCard | RegionConflictCard | LocationConflictCard;
54
+ /** One recorded conflict and what its choices act on. */
55
+ export interface FieldConflict {
56
+ key: string;
57
+ mergeSha: string;
58
+ dataPath: DataPathSegment[];
59
+ item: boolean;
60
+ cards: ConflictCard[];
61
+ /** The latest version as a form value, which "Use latest version" takes from. */
62
+ latest?: FormValue;
63
+ /**
64
+ * The latest version was read and holds every list item on the place's path, other than the item
65
+ * a removal on its side took out, so its value there can be found and placed. It is keyed as the
66
+ * merge named the place along the base's own path ({@link alignedTo}), so this fails only where
67
+ * the record gives no such path, one that runs another way, or one whose merged key another of
68
+ * its items holds.
69
+ */
70
+ latestPlaceable: boolean;
71
+ /**
72
+ * Long text found again by region from its three versions: the text the merge left, and its
73
+ * regions, each holding your lines there. Absent when the text is one whole value.
74
+ */
75
+ text?: {
76
+ merged: string;
77
+ regions: TextConflictRegion[];
78
+ };
79
+ }
80
+ export interface EntryConflicts {
81
+ fields: FieldConflict[];
82
+ /** Versions the API couldn't show, so a field's cards may show less. */
83
+ incomplete: boolean;
84
+ }
85
+ /**
86
+ * The cards for one entry's open field conflicts. `bodyField` is the schema's body field on an
87
+ * md/mdx entry. Each version the cards compare is diffed once per pair, however many fields share it.
88
+ */
89
+ export declare function buildEntryConflicts(data: EntryConflictData, fields: readonly FieldConfig[], bodyField: string | undefined, referenceTitle?: (id: string) => string | undefined, saved?: FormValue): EntryConflicts;
90
+ export {};
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The cards "Show changes" shows for an entry's open conflicts, built from what
3
+ * `GET /:branch/conflicts/...entryPath` answers (api/merge-conflicts.ts): each place's three
4
+ * versions as the drawer shows values, and long text split into the regions both sides changed.
5
+ * Pure. Where a choice lands in the form value is `conflict-choices.ts`.
6
+ */
7
+ import equal from 'fast-deep-equal';
8
+ import { LOCATION_CONFLICT_KEY } from '../merge/conflict-path.js';
9
+ import { mergeText } from '../merge/text-merge.js';
10
+ import { computeEntryDiff, displayWhole, textDiff, } from './entry-diff.js';
11
+ import { flattenGroupFields } from '../utils/flatten-group-fields.js';
12
+ import { humanizeName, schemaLabel } from './schema-label.js';
13
+ import { formValueOfSide, valueAtChange } from './undo-change.js';
14
+ import { ITEM_KEY } from '../item-keys.js';
15
+ import { isPlainRecord } from '../validation/field-traversal.js';
16
+ const EMPTY = { kind: 'empty' };
17
+ /** The version a blob id names; undefined when it can't be shown. */
18
+ const versionOf = (versions, sha) => {
19
+ if (sha === undefined)
20
+ return null;
21
+ const side = versions[sha];
22
+ return side === undefined || 'unavailable' in side ? undefined : side;
23
+ };
24
+ /**
25
+ * `version` with its list items along `from`, its own path to a conflict's place, keyed as `to`,
26
+ * the merged path, names them, so `to` finds the value in it: an item of a list a side left keyless
27
+ * reads there with a key of its own content (api/merge-conflicts.ts `EntryConflictField.sidePaths`).
28
+ * Unchanged where there is no `from` or it is `to`. Undefined, as a version that can't be shown,
29
+ * where the two paths don't run alike or a key `to` names is another item's in that list: the
30
+ * merged path would find that item there.
31
+ */
32
+ const alignedTo = (version, from, to) => {
33
+ if (!version || !from || equal(from, to))
34
+ return version;
35
+ // A path one step short of an item names the list of a version without that item.
36
+ const last = to.at(-1);
37
+ const missing = from.length === to.length - 1 && last !== undefined && 'item' in last ? last.item : undefined;
38
+ if (missing === undefined && from.length !== to.length)
39
+ return undefined;
40
+ const data = structuredClone(version.data);
41
+ let node = data;
42
+ for (const [i, segment] of from.entries()) {
43
+ const target = to[i];
44
+ if ('field' in segment) {
45
+ if (!('field' in target) || target.field !== segment.field)
46
+ return undefined;
47
+ node = isPlainRecord(node) ? node[segment.field] : undefined;
48
+ }
49
+ else if ('blockData' in segment) {
50
+ if (!('blockData' in target))
51
+ return undefined;
52
+ node = isPlainRecord(node) ? (isPlainRecord(node.value) ? node.value : node) : undefined;
53
+ }
54
+ else {
55
+ if (!('item' in target) || !Array.isArray(node))
56
+ return undefined;
57
+ const keyed = (key) => node.find((x) => isPlainRecord(x) && x[ITEM_KEY] === key);
58
+ const item = keyed(segment.item);
59
+ if (!isPlainRecord(item))
60
+ return undefined;
61
+ if (target.item !== segment.item) {
62
+ if (keyed(target.item) !== undefined)
63
+ return undefined;
64
+ item[ITEM_KEY] = target.item;
65
+ }
66
+ node = item;
67
+ }
68
+ }
69
+ if (missing !== undefined) {
70
+ // Another item there holding the key `to` names would be found as it: it takes another key.
71
+ if (!Array.isArray(node))
72
+ return undefined;
73
+ const other = node.find((x) => isPlainRecord(x) && x[ITEM_KEY] === missing);
74
+ if (isPlainRecord(other))
75
+ other[ITEM_KEY] = `${missing}~other`;
76
+ }
77
+ return { ...version, data };
78
+ };
79
+ /** A diff's changes by key, a whole entry missing on a side giving none. */
80
+ const changesByKey = (fields, before, after, referenceTitle) => {
81
+ if (!before || !after)
82
+ return new Map();
83
+ const { changes } = computeEntryDiff({ fields, before, after, referenceTitle });
84
+ return new Map(changes.map((change) => [change.key, change]));
85
+ };
86
+ /**
87
+ * The schema field a data path ends at, and the labels of the fields on the way, for a conflict no
88
+ * change of the drawer names: one the merge compared whole, such as a list it had no schema for.
89
+ * A block's data has no one field set, so the walk stops there.
90
+ */
91
+ const fieldAlong = (fields, dataPath) => {
92
+ let scope = fields;
93
+ let field;
94
+ const path = [];
95
+ for (const segment of dataPath) {
96
+ if ('field' in segment) {
97
+ field = scope && flattenGroupFields(scope).find((f) => f.name === segment.field);
98
+ path.push(field ? schemaLabel(field) : humanizeName(segment.field));
99
+ scope = field?.type === 'object' ? field.fields : undefined;
100
+ }
101
+ else if ('item' in segment) {
102
+ field = field && 'list' in field && field.list ? { ...field, list: false } : undefined;
103
+ }
104
+ else {
105
+ return { path };
106
+ }
107
+ }
108
+ return { field, path };
109
+ };
110
+ /** A string at `dataPath` in a version, as the form holds it. */
111
+ const textAt = (version, dataPath, bodyField) => {
112
+ if (!version)
113
+ return version === null ? '' : undefined;
114
+ const value = valueAtChange(formValueOfSide(version), { dataPath }, bodyField);
115
+ return typeof value === 'string' ? value : value === undefined ? '' : undefined;
116
+ };
117
+ /**
118
+ * The cards for one entry's open field conflicts. `bodyField` is the schema's body field on an
119
+ * md/mdx entry. Each version the cards compare is diffed once per pair, however many fields share it.
120
+ */
121
+ export function buildEntryConflicts(data, fields, bodyField, referenceTitle, saved) {
122
+ const diffs = new Map();
123
+ const diff = (from, to) => {
124
+ const id = `${from ?? '-'}>${to ?? '-'}`;
125
+ let found = diffs.get(id);
126
+ if (!found) {
127
+ found = changesByKey(fields, versionOf(data.versions, from), versionOf(data.versions, to), referenceTitle);
128
+ diffs.set(id, found);
129
+ }
130
+ return found;
131
+ };
132
+ let incomplete = false;
133
+ const out = data.fields.map((field) => {
134
+ const { ancestor, base, branch } = field.sides;
135
+ const read = {
136
+ before: versionOf(data.versions, ancestor),
137
+ latest: versionOf(data.versions, base),
138
+ yours: versionOf(data.versions, branch),
139
+ };
140
+ if (Object.values(read).some((v) => v === undefined))
141
+ incomplete = true;
142
+ // Each version keyed as the merge named this place, so the one data path finds it in all three.
143
+ const versions = {
144
+ before: alignedTo(read.before, field.sidePaths.ancestor, field.dataPath),
145
+ latest: alignedTo(read.latest, field.sidePaths.base, field.dataPath),
146
+ yours: alignedTo(read.yours, field.sidePaths.branch, field.dataPath),
147
+ };
148
+ const realigned = ['before', 'latest', 'yours'].some((v) => versions[v] !== read[v]);
149
+ if (Object.values(versions).some((v) => v === undefined))
150
+ incomplete = true;
151
+ const latest = versions.latest ? formValueOfSide(versions.latest) : undefined;
152
+ const latestPlaceable = !!latest &&
153
+ field.dataPath.every((segment, i) => !('item' in segment) ||
154
+ (field.item && field.kind === 'removed-on-base' && i === field.dataPath.length - 1) ||
155
+ valueAtChange(latest, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !==
156
+ undefined);
157
+ const common = {
158
+ key: field.key,
159
+ mergeSha: field.mergeSha,
160
+ dataPath: field.dataPath,
161
+ item: !!field.item,
162
+ latestPlaceable,
163
+ ...(latest && { latest }),
164
+ };
165
+ const card = { key: field.key, mergeSha: field.mergeSha };
166
+ if (field.key === LOCATION_CONFLICT_KEY) {
167
+ return { ...common, cards: [{ ...card, id: field.key, type: 'location', path: [] }] };
168
+ }
169
+ // A realigned version is this field's own, so its diffs are too.
170
+ const pair = (from, to, fromSha, toSha) => (realigned ? changesByKey(fields, from, to, referenceTitle) : diff(fromSha, toSha));
171
+ const toLatest = pair(versions.before, versions.latest, ancestor, base);
172
+ const toYours = pair(versions.before, versions.yours, ancestor, branch);
173
+ const between = pair(versions.latest, versions.yours, base, branch);
174
+ const named = toLatest.get(field.drawerKey) ?? toYours.get(field.drawerKey);
175
+ const along = fieldAlong(fields, field.dataPath);
176
+ const path = named?.path ?? between.get(field.drawerKey)?.path ?? along.path;
177
+ // Your saved value where a save since the merge changed it, which is what keeping yours keeps:
178
+ // the branch's version is only how it read at the merge.
179
+ const savedHere = saved && valueAtChange(saved, { dataPath: field.dataPath }, bodyField);
180
+ const branchHere = versions.yours &&
181
+ valueAtChange(formValueOfSide(versions.yours), { dataPath: field.dataPath }, bodyField);
182
+ const savedYours = saved && !equal(savedHere, branchHere)
183
+ ? displayWhole(along.field, savedHere, referenceTitle)
184
+ : undefined;
185
+ const savedMarks = (before) => savedYours && before.kind === 'text' && savedYours.kind === 'text'
186
+ ? textDiff(before.text, savedYours.text)
187
+ : undefined;
188
+ if (field.text) {
189
+ const texts = [versions.before, versions.latest, versions.yours].map((v) => textAt(v, field.dataPath, bodyField));
190
+ const [o, a, b] = texts;
191
+ const merged = latestPlaceable && o !== undefined && a !== undefined && b !== undefined
192
+ ? mergeText(o, a, b)
193
+ : undefined;
194
+ // Regions place their lines in the text the merge left, so they apply only while your saved
195
+ // text is that text: not once you saved since, nor where the merge kept your text whole.
196
+ const regionsApply = !!merged &&
197
+ merged.regions.length > 0 &&
198
+ (saved === undefined ||
199
+ valueAtChange(saved, { dataPath: field.dataPath }, bodyField) === merged.text);
200
+ if (merged && regionsApply) {
201
+ return {
202
+ ...common,
203
+ text: { merged: merged.text, regions: merged.regions },
204
+ cards: merged.regions.map((region, index) => ({
205
+ ...card,
206
+ id: `${field.key}#${index}`,
207
+ type: 'region',
208
+ path,
209
+ index,
210
+ count: merged.regions.length,
211
+ before: region.ancestor,
212
+ latest: region.base,
213
+ yours: region.branch,
214
+ latestText: textDiff(region.ancestor, region.base, true),
215
+ yoursText: textDiff(region.ancestor, region.branch, true),
216
+ })),
217
+ };
218
+ }
219
+ }
220
+ const fromLatest = toLatest.get(field.drawerKey);
221
+ const fromYours = toYours.get(field.drawerKey);
222
+ const across = between.get(field.drawerKey);
223
+ if (!fromLatest && !fromYours && !across) {
224
+ // No change of the drawer is at this place: the merge compared it whole, so it shows whole.
225
+ const whole = (version) => version
226
+ ? displayWhole(along.field, valueAtChange(formValueOfSide(version), { dataPath: field.dataPath }, bodyField), referenceTitle)
227
+ : EMPTY;
228
+ const [before, latestValue, yoursValue] = [
229
+ versions.before,
230
+ versions.latest,
231
+ versions.yours,
232
+ ].map(whole);
233
+ const marks = (to) => before.kind === 'text' && to.kind === 'text' ? textDiff(before.text, to.text) : undefined;
234
+ const latestText = marks(latestValue);
235
+ const yoursText = savedYours ? savedMarks(before) : marks(yoursValue);
236
+ return {
237
+ ...common,
238
+ cards: [
239
+ {
240
+ ...card,
241
+ id: field.key,
242
+ type: 'value',
243
+ conflict: field.kind,
244
+ item: !!field.item,
245
+ path,
246
+ before,
247
+ latest: latestValue,
248
+ ...(!latestPlaceable && { latestUnavailable: true }),
249
+ yours: savedYours ?? yoursValue,
250
+ ...(latestText && { latestText }),
251
+ ...(yoursText && { yoursText }),
252
+ },
253
+ ],
254
+ };
255
+ }
256
+ const before = fromLatest?.before ?? fromYours?.before ?? EMPTY;
257
+ const yoursText = savedYours ? savedMarks(before) : fromYours?.text;
258
+ return {
259
+ ...common,
260
+ cards: [
261
+ {
262
+ ...card,
263
+ id: field.key,
264
+ type: 'value',
265
+ conflict: field.kind,
266
+ item: !!field.item,
267
+ path,
268
+ before,
269
+ latest: fromLatest ? (fromLatest.after ?? EMPTY) : (across?.before ?? EMPTY),
270
+ ...(!latestPlaceable && { latestUnavailable: true }),
271
+ yours: savedYours ?? (fromYours ? (fromYours.after ?? EMPTY) : (across?.after ?? EMPTY)),
272
+ ...(fromLatest?.text && { latestText: fromLatest.text }),
273
+ ...(yoursText && { yoursText }),
274
+ },
275
+ ],
276
+ };
277
+ });
278
+ return { fields: out, incomplete };
279
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What a choice on a conflict card does to the open entry's form value (conflict-cards.ts builds
3
+ * the cards). Pure. A choice is an unsaved edit: the save that writes it sends the conflict's key in
4
+ * `resolvedConflicts` (api/content.ts), which is what resolves it.
5
+ */
6
+ import type { TextConflictRegion } from '../merge/text-merge.js';
7
+ import type { FieldConflict } from './conflict-cards.js';
8
+ import type { EntryChange } from './entry-diff.js';
9
+ type FormValue = Record<string, unknown>;
10
+ /** A region's choice: the latest version's lines, yours, or the latest's followed by yours. */
11
+ export type RegionChoice = 'latest' | 'yours' | 'both';
12
+ /**
13
+ * `latest`: the latest version's value, which for a removed one removes it. `yours`: your saved
14
+ * value as it stands. `edited`: what the form holds now, marked resolved after an edit. `regions`:
15
+ * long text chosen region by region, resolved once every region is chosen.
16
+ */
17
+ export type FieldChoice = {
18
+ how: 'latest';
19
+ } | {
20
+ how: 'yours';
21
+ } | {
22
+ how: 'edited';
23
+ } | {
24
+ how: 'regions';
25
+ regions: Readonly<Record<number, RegionChoice>>;
26
+ };
27
+ /** An entry's choices, by conflict key, each for the merge it was made on. */
28
+ export type EntryChoices = Readonly<Record<string, {
29
+ mergeSha: string;
30
+ choice: FieldChoice;
31
+ }>>;
32
+ /** The change-shaped handle `undo-change.ts` places a value by. */
33
+ export declare const placeOf: (field: Pick<FieldConflict, "key" | "dataPath" | "item">) => EntryChange;
34
+ /** Whether a choice says everything its conflict needs. */
35
+ export declare const isDecided: (field: FieldConflict, choice: FieldChoice | undefined) => boolean;
36
+ /**
37
+ * The text the merge left with each region as `regions` chooses it. A region's lines sit where the
38
+ * merge put yours (`TextConflictRegion.line`), so regions are placed from the last up.
39
+ * @internal Exported for tests.
40
+ */
41
+ export declare function textWithChoices(text: {
42
+ merged: string;
43
+ regions: readonly TextConflictRegion[];
44
+ }, regions: Readonly<Record<number, RegionChoice>>): string;
45
+ /**
46
+ * Whether the form still holds the conflict's place as the last choice (or, before any, your saved
47
+ * value) left it. False once it was edited by hand: a choice would then replace that edit.
48
+ */
49
+ export declare function holdsChoice(current: FormValue, saved: FormValue, field: FieldConflict, choice: FieldChoice | undefined, bodyField: string | undefined): boolean;
50
+ /**
51
+ * `current` with the latest version at the conflict's place; undefined when it can't be placed, as
52
+ * where the latest version couldn't be read or doesn't hold the items on the place's path
53
+ * ({@link FieldConflict.latestPlaceable}).
54
+ */
55
+ export declare const withLatest: (current: FormValue, field: FieldConflict, bodyField: string | undefined) => FormValue | undefined;
56
+ /** `current` with your saved value back at the conflict's place, as before any choice. */
57
+ export declare const withYours: (current: FormValue, saved: FormValue, field: FieldConflict, bodyField: string | undefined) => FormValue | undefined;
58
+ /** `current` with long text as `regions` chooses it. */
59
+ export declare const withRegions: (current: FormValue, field: FieldConflict, regions: Readonly<Record<number, RegionChoice>>, bodyField: string | undefined) => FormValue | undefined;
60
+ export {};